WPFで「UriSouceプロパティに画像のURLを入れてBitmapImageを初期化する処理」を非同期で実行するとしぬ

この記事は、WPFでReactiveProperty入門 ~Bing画像検索ビューアを作る - pierre3のブログ の続きです。

関連記事

  1. WPFでReactiveProperty入門 ~Bing画像検索ビューアを作る - pierre3のブログ
  2. WPFでReactiveProperty入門 ~Bing画像検索ビューアを作る (1. 検索バーの実装) - pierre3のブログ
  3. WPFでReactiveProperty入門 ~ Rxを使って検索結果のサムネイル画像を一括ダウンロードする - pierre3のブログ

サンプルプロジェクト github.com

目次

はじめに

今回は、検索結果として取得した画像のURLから画像データを読み込んでBitmapImageを生成する部分について書くのですが、 先に言ってしまうと、試してみたけど結果うまく行きませんでした。というお話です。

BitmapImageの初期化にUriSourceを使うとUIがブロックされる

BitmapImageにWeb上の画像を読み込ませるには、UriSourceプロパティを使用するのが最も簡単です。
UriSourceプロパティに画像のURLを指定するだけで、画像データのダウンロードとBitmapへのデコードをBitmapImage自身がやってくれます。

public BitmapImage CreateBitmapImage(Uri sourceUri)
{
    var bitmap = new BitmapImage();
    bitmap.BeginInit();
    bitmap.UriSource = sourceUri;
    bitmap.EndInit();
    return bitmap;
}
// 以下の様にコンストラクタにURIを渡すのと同じ
// var bitmap = new BitmapImage(sourceUri);

非常に簡単で便利なのですが、この処理はUIをブロックするようなのです。
実際に、上記のコードを使って複数の画像を連続してダウンロードすると、すべての画像が生成されるまでUIの表示が更新されず、操作もできない状態になります。
(ダウンロード完了を待たずにEndInit()を抜けて、DownloadCompletedイベントで完了通知を受け取るようになっているので、ダウンロード自体はバックグラウンドで行われているように見えるのですが)

非同期にしてみる

そこで、Taskにツッコんで別スレッドで実行するようにしてみます。

public Task<BitmapImage> CreateBitmapImageAsync(Uri sourceUri)
{
    return Task.Run(()=> {
        var bitmap = new BitmapImage();
        bitmap.BeginInit();
        bitmap.UriSource = sourceUri;
        bitmap.EndInit();
        return bitmap;
    }
}

しかし、上記コードではImageコントロール等で表示しようとした時点で以下のエラーが発生します。

型 'System.Windows.Markup.XamlParseException' のハンドルされていない例外が PresentationFramework.dll で発生しました
DependencySource は、DependencyObject と同じ Thread 上で作成する必要があります。

UIスレッド以外で生成されたUIオブジェクトはFreeze()してからでないとUIスレッドで使用できないのでした。

UIで使用する前に Freeze しておく必要があるのですが、そのタイミングも問題になります。
例えば、以下の様にEndInit() の後に Freeze しようとしても、この時点では画像のダウンロードが完了していないので、Freezeできません。

public Task<BitmapImage> CreateBitmapImageAsync(Uri sourceUri)
{
    return Task.Run(()=> {
        var bitmap = new BitmapImage();
        bitmap.BeginInit();
        bitmap.UriSource = sourceUri;
        bitmap.EndInit();
        bitmap.Freeze();  //初期化が終わっていないのでFreezeできません!!
        return bitmap;
    }
}

ダウンロード完了を待ってからFreezeする

ダウンロードの完了を待ってからFreezeすればどうでしょうか? 試してみます。
ダウンロードの完了は DownloadCompleted イベントで受け取れます。
また、ダウンロード中に発生した例外は、DownloadFailed イベントで補足できます。

private ObservableCollection images = new ObservableCollection();
private Task CreateBitmapImage(Uri sourceUri)
{
    return Task.Run(()=> {
        var bitmap = new BitmapImage();
        bitmap.BeginInit();
        bitmap.UriSource = sourceUri;
        bitmap.CacheOption = BitmapCacheOption.OnLoad;
        bitmap.DownloadCompleted += (sender,args) => {
            var image =(BitmapImage)sender; 
            image.Freeze();        //Freezeしてから
            images.Add(image);     //Binding Sourceのコレクションに追加
        };
        bitmap.DownloadFailed += (sender,args) => {
          //失敗したらここに来る(はず)
          Trace.WriteLine($"DL Failed. [{args.ErrorException}]");  
        };
        bitmap.EndInit();
    }
}

UIスレッド以外で実行すると謎の沈黙

これでうまく行きそうな気がしますが、今度はまた別の問題が発生します。

  • DownloadCompleted が発火しない!
  • DownloadFailed にも飛んでこない!

しかも、例外で落ちることもなく沈黙する最悪のパターンです。

何か手掛かりは無いか、VisualStudio(2015)の診断ツールで「イベント」を確認すると、何やら謎の例外がスローされて直後にキャッチされているのが分かります。
f:id:pierre3:20151024090321p:plain

どうやら、System.Deployment.Application.InvalidDeploymentException なる例外が発生して、尚且つそれが握りつぶされているのが原因のようです。

このInvalidDeploymentExceptionってなんなんでしょう? 例外発生時の呼び出し履歴を見てみます。

System.Deployment.dll!System.Deployment.Application.ApplicationDeployment.CurrentDeployment.get()
System.Deployment.dll!System.Deployment.Application.ApplicationDeployment.IsNetworkDeployed.get()
PresentationCore.dll!MS.Internal.AppModel.SiteOfOriginContainer.SiteOfOriginForClickOnceApp.get()
PresentationCore.dll!MS.Internal.SecurityHelper.ExtractUriForClickOnceDeployedApp()
PresentationCore.dll!MS.Internal.SecurityHelper.BlockCrossDomainForHttpsApps(System.Uri uri = {不明})
PresentationCore.dll!System.Windows.Media.Imaging.BitmapDownload.BeginDownload(System.Windows.Media.Imaging.BitmapDecoder decoder = {不明}, System.Uri uri = {不明}, System.Net.Cache.RequestCachePolicy uriCachePolicy = {不明}, System.IO.Stream stream = {不明})
PresentationCore.dll!System.Windows.Media.Imaging.LateBoundBitmapDecoder..ctor(System.Uri baseUri = {不明}, System.Uri uri = {不明}, System.IO.Stream stream = {不明}, System.Windows.Media.Imaging.BitmapCreateOptions createOptions = {不明}, System.Windows.Media.Imaging.BitmapCacheOption cacheOption = {不明}, System.Net.Cache.RequestCachePolicy requestCachePolicy = {不明})
PresentationCore.dll!System.Windows.Media.Imaging.BitmapDecoder.CreateFromUriOrStream(System.Uri baseUri = {不明}, System.Uri uri = {不明}, System.IO.Stream stream = {不明}, System.Windows.Media.Imaging.BitmapCreateOptions createOptions = {不明}, System.Windows.Media.Imaging.BitmapCacheOption cacheOption = {不明}, System.Net.Cache.RequestCachePolicy uriCachePolicy = {不明}, bool insertInDecoderCache = {不明})
PresentationCore.dll!System.Windows.Media.Imaging.BitmapImage.FinalizeCreation()
PresentationCore.dll!System.Windows.Media.Imaging.BitmapImage.EndInit()

EndInig()メソッドの内部でLateBoundBitmapDecoder が生成されて、そのコンストラクタ内でBitmapDownload.BeginDownload() が実行され、 SecurityHelper.BlockCrossDomainForHttpsApps() メソッド内でClickOnce Deployed がなんちゃら・・・・
うーん、よくわかりません。
これ以上の深追いはやめておきます。

UriSourceを使ったBitmapImageの初期化はUIスレッドで

色々試してみましたが、今回のようなアプローチの仕方ではうまく行きそうにありません。

「UriSource を使った一連の処理は、UIスレッド以外で実行しないようにしましょう。」

別のアプローチ

では、非同期で処理できないの?というわけではなく、別の方法があります。

以下の様に、画像データを先にダウンロードしておき、MemoryStream でBitmapImageに流してやればOKです。

public async Task<BitmapImage> DownloadImageAsync(string url)
{
    using (var web = new HttpClient())
    {
        var bytes = await web.GetByteArrayAsync(url).ConfigureAwait(false);
        using (var stream = new MemoryStream(bytes))
        {
            var bitmap = new BitmapImage();
            bitmap.BeginInit();
            bitmap.StreamSource = stream;
            bitmap.CacheOption = BitmapCacheOption.OnLoad;
            bitmap.EndInit();
            bitmap.Freeze();
            return bitmap;
        }
    }
} 

Web検索等で調べると、このようなやり方が一般的のようですね。
(だったら最初からそうしろってことですが、結果がダメでもいろいろ試してみることに意義があるという事で)

しかし、これにもまだ問題があります。 上のDownloadImageAsync()を実行するとメモリリークを引き起こすのですが、長くなってきたので続きは次回!