これは Delphi Advent Calendar 補欠の記事です。
THttpClient とはなんぞや
これについては、落とし穴1をご覧下さい!
処理中アニメーション
THttpClient / TNetHttpClient を使う時 OnReceivedData で、プログレスバーを出したり、くるくる回るアニメーション出したいですよね!?
そこで、前回は、THttpClient を別スレッドで使い、メインスレッドでは、プログレスバーを更新しました。
メインスレッドが動いていないと、くるくる回したりできませんからね!
Android の場合はメインスレッドが5~10秒停止すると、ANR(Application Not Response)で落とされちゃいますしね!
TNetHttpClient の実装
僕が、かたくなに TNetHttpClient を使わないのは TNetHttpClient が残念実装だからです。
具体的なコードをちょっと見てみましょう。
function TNetHTTPClient.Get(const AURL: string; const AResponseContent: TStream;
  const AHeaders: TNetHeaders): IHTTPResponse;
begin
  try
    Result := FHttpClient.Get(AURL, AResponseContent, AHeaders);
    DoOnRequestCompleted(Self, Result);
  except
    on E: Exception do
      DoOnRequestError(Self, E.Message);
  end;
end;
_人人人人人人人人人人人人人_
> まさかのメインスレッド <
 ̄Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y ̄
THttpClient.Get はサーバの応答を待って、処理が返ってきます。
落とし穴1にも書きましたがブロッキング動作です。
この実装では、Get を呼んだ段階で、メインスレッドが停止します/(^o^)\
当然、ぐるぐるアニメーションも停止します!!
多分 TNetHttpClient の言い分としては、OnReceivedData 内で、Application.ProcessMessages を呼んだりしてくれよ、ということだと思いますが、そんなん知るか!!
ということで、TNetHttpClient は使い物になりません。
実際、HttpDownload というエンバカデロが提供しているサンプルコードでは、TNetHttpClient を使ってません!
僕と同じように TThread を使って処理を回しています。
まあ、それは置いておいて、今回は OnReceiveData の処理についてです。
OnReceiveData で Synchronize しない!
再び、TNetHttpClient の実装をちょっと見てみます。
procedure TNetHTTPClient.DoOnReceiveData(const Sender: TObject; AContentLength, AReadCount: Int64; var Abort: Boolean);
var
  LAbort: Boolean;
begin
  if Assigned(FOnReceiveData) then
  begin
    LAbort := Abort;
    TThread.Synchronize(nil, procedure
    begin
      FOnReceiveData(Sender, AContentLength, AReadCount, LAbort);
    end);
    Abort := LAbort;
  end;
end;
はい。見事に Synchronize で OnReceiveData を呼び出してますよね。
この実装は一見正しいのです。
というのは、UI の描画はメインスレッドで行う必要があり、THttpClinet.OnReceivedData は別スレッドのままやってくるからです。
なので、Synchronize でメインスレッドと同期して、メインスレッドで UI を描画する訳です。
ですが!
メインスレッドと同期して、UI を描画しているあいだ、ダウンロード処理はどうなってるでしょうか?
もちろん、停止しています!!
さて、ここが問題です。
UI 描画程度で接続が切断されることはないと思いますが、大きなデータをダウンロードしている時に、一々 Synchronize していると、待機時間が馬鹿にならないレベルになります。
どのくらいのオーバーヘッドが生じるのかテストしました。
Android で45[MB]のデータをダウンロードする秒数を3回計測し平均しました。
| 手法 | ダウンロード秒数 | 備考 | 
|---|---|---|
| Synchronize | 96.6 | |
| 50[msec] 毎にSynchronize | 6.3 | 結局停止してる | 
| CreateAnonymousThread | 10.4 | ※ | 
| TThread を事前に作って使い回す1 | 6.1 | |
| TThread を事前に作って使い回す2 | 5.0 | 50[msec]制限つき | 
※OnReceivedData 毎に CreateAnonymousThread でスレッドを作って、その中で Synchronize
procedure TDownloadThread.HttpReceiveData(
const Sender: TObject;
ContentLength, ReadCount: Int64;
var Abort: Boolean);
begin
TThread.CreateAnonymousThread(
procedure
begin
TThread.Synchronize(
TThread.Current,
procedure
begin
// UI 更新処理
end
)
end
).Start;
end;
その差、実に90秒以上!!!
Synchronize をすることで、90秒もロスするわけです。
では、ということで 50[msec]毎に Synchronize するようにしてみると、確かに劇的に速くはなりました!ですが、停止していることには変わりありません。
そこで、CreateAnonymousThread にしてみましたが、スレッドの生成にオーバーヘッドがあって、50[msec]毎に Synchronize するバージョンよりも遅い!
しかも、スレッドガンガン立って気分が悪い!
ということで、最適な方法は、事前に Synchronize 専用スレッドを作って、そこから呼ぶ、という方法です。
しかも、一回の描画間隔を 50[msec]以上後、とすると更に良い結果を得られました。
その Synchronize 専用スレッドのソースが↓こちらです。
```delphi:uDownloadThread.pas(抜粋)
TProgressThread = class(TThread)
private
  [Weak] FDownloadThread: TDownloadThread;
  FRunning: Boolean;
  FAbort: Boolean;
  FReadCount: Int64;
  FContentLength: Int64;
  FStart: TDateTime;
  FProgressTime: Integer;
  FSynchronizer: TMultiReadExclusiveWriteSynchronizer;
protected
  procedure Execute; override;
public
  constructor Create(
    const iDownloadThread: TDownloadThread;
    const iProgressTime: Integer); reintroduce;
  destructor Destroy; override;
  procedure SetCount(const iReadCount, iContentLength: Int64);
  property Abort: Boolean read FAbort;
end;
{ TDownloadThread.TProgressThread }
constructor TDownloadThread.TProgressThread.Create(
  const iDownloadThread: TDownloadThread;
  const iProgressTime: Integer);
begin
  inherited Create(True);
  FreeOnTerminate := True;
  FDownloadThread := iDownloadThread;
  FProgressTime := iProgressTime;
  FSynchronizer := TMultiReadExclusiveWriteSynchronizer.Create;
end;
destructor TDownloadThread.TProgressThread.Destroy;
begin
  FSynchronizer.DisposeOf;
  inherited;
end;
procedure TDownloadThread.TProgressThread.Execute;
var
  ReadCount, ContentLength: Int64;
begin
  while (not Terminated) do
  begin
    if (FRunning) then
      Continue;
    FSynchronizer.BeginRead;
    try
      ReadCount := FReadCount;
      ContentLength := FContentLength;
    finally
      FSynchronizer.EndRead;
    end;
    if
      (MilliSecondsBetween(Now, FStart) > FProgressTime) or
      (ReadCount >= ContentLength)
    then
    begin
      FRunning := True;
      try
        TThread.Synchronize(
          Self,
          procedure
          begin
            if (Assigned(FDownloadThread.FOnProgress)) then
              FDownloadThread.FOnProgress(
                FDownloadThread,
                FReadCount,
                FContentLength,
                FAbort);
          end
        );
      finally
        FRunning := False;
      end;
      FStart := Now;
    end;
  end;
end;
procedure TDownloadThread.TProgressThread.SetCount(
  const iReadCount, iContentLength: Int64);
begin
  FSynchronizer.BeginWrite;
  try
    FReadCount := iReadCount;
    FContentLength := iContentLength;
  finally
    FSynchronizer.EndWrite;
  end;
end;
使用する側は↓こんな感じ
procedure TDownloadThread.HttpReceiveData(
  const Sender: TObject;
  iContentLength, iReadCount: Int64;
  var ioAbort: Boolean);
begin
  if (Assigned(FOnProgress)) then
  begin
    FProgressThread.SetCount(iReadCount, iContentLength);
    ioAbort := FProgressThread.Abort;
  end;
end;
使用する側では FProgressThread に読んだバイト数と総バイト数をセット。
以前の実行による Abort 結果を OnReceivedData に返す、ということだけして、すぐ抜けます。
これによって、ダウンロードのオーバーヘッドを最小限に留めます。
TMultiReadExclusiveWriteSynchronizer によるブロックがあるので、若干のオーバーヘッドがあります。
50[msec]の空き時間を取ると速くなるのも、これと関係しています。
まとめ
ということで、TNetHttpClient がクソであり、OnReceivedData の中では Synchronize は好ましくない、と言うことが解りました!