読者です 読者をやめる 読者になる 読者になる

SourceChord

C#とXAML好きなプログラマの備忘録。最近はWPF系の話題が中心です。

WPFでBitmapFrame.Createなどの画像ロード処理を強制的にキャンセルさせる

WPFで画像を扱ってて遭遇した困りごと。。。

BitmapFrameやBitmapDecoder、BitmapImageクラスなど、WPFには画像の読み込みをする手段として色々なクラスが用意されています。
しかし、それらのどのクラスも、WinRTの画像系クラスのような○○Asyncっていう非同期版のメソッドがありません。
また、一度読み込み始めた画像のロード処理をキャンセルする手段もありません。。


ということで、画像の読み込みを無理やり止める方法を考えてみました。

画像の読み込みをキャンセルしたいような例

BitmapFrameやBitmapDecoderなど、画像ファイルを読み込むことができる各種クラスでは、
BitmapCacheOptionを指定することで、画像データをメモリ上にキャッシュしておくか、必要になったタイミングで読み込むか、ということを指定できます。

BitmapFrame.Createなどのメソッドを呼んだタイミングで、すぐにメモリ上に画像データを読み込んでおきたい場合は、BitmapCacheOption.OnLoadを指定します。


しかし、このオプションを指定して画像データの読み込みを行うと、同期的にファイル読み込みと画像のデコードが始まり、メソッドを呼んだっきり処理が帰ってこなくなります。
また、この読み込みをキャンセルする方法もありませんし、非同期読み込みを行うためのメソッドも用意されてません。

でも、例えば10000px位あるようなデカイ画像や、bmp形式のようなファイルサイズの大きいものを読み込み始めてしまうと大変なことになります。
大きな画像ではデコード処理に時間がかかるし、ファイルサイズが大きい場合には、ディスクアクセスで処理に長い時間がかかります。

そのため、何も考えずにロードしてしまうとUIスレッドを固めることに繋がってしまいます。
別スレッドで読み込みをしておけばUIの応答性は保てますが、読み込みが不要になった場合には、できれば途中で読み込み処理を止めたい。
でも、読み込みを中断する時にスレッドをAbortするっていうのはできれば避けたい。。。
スレッドをAbortするのは、本当に最終手段って感じですよね。

サンプルコード

一応サンプルコードです。
VMのpath変数で、何か特大の画像ファイルを指定すると、画像の読み込みでどれだけ時間がかかるか実感できると思います。
f:id:minami_SC:20141108213723p:plain:w300

MainWindow.xaml
<Window x:Class="WpfBaseTemplate1.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:WpfBaseTemplate1"
        Title="MainWindow"
        Width="450"
        Height="320">
    <Window.DataContext>
        <local:MainWindowViewModel />
    </Window.DataContext>
    <Grid>
        <StackPanel Margin="10">
            <Image Width="300"
                   Height="200"
                   Margin="5"
                   Source="{Binding Image}" />
            <TextBlock Margin="5" Text="{Binding Message}" />
            <StackPanel Orientation="Horizontal">
                <Button Width="75"
                        Margin="0,0,5,0"
                        Command="{Binding LoadCommand}"
                        Content="Load" />
            </StackPanel>
        </StackPanel>
    </Grid>
</Window>
MainWindowViewModel.cs
    class MainWindowViewModel : BindableBase
    {
        private BitmapSource image;
        public BitmapSource Image
        {
            get { return image; }
            set { this.SetProperty(ref this.image, value); }
        }

        private string message;
        public string Message
        {
            get { return message; }
            set { this.SetProperty(ref this.message, value); }
        }

        // Loadボタンから呼び出すコマンド
        private RelayCommand loadCommand;
        public RelayCommand LoadCommand
        {
            get { return loadCommand = loadCommand ?? new RelayCommand(Load); }
        }

        private void Load()
        {
            // 一度画像をクリアする
            this.Image = null;
            // 処理時間を計測する
            var sw = Stopwatch.StartNew();
            this.Message = "読み込み中...";

            var path = @"H:\Test\Large.png";
            this.Image = BitmapFrame.Create(new Uri(path, UriKind.Absolute),
                                            BitmapCreateOptions.None,
                                            BitmapCacheOption.OnLoad);

            this.Message = string.Format("経過時間: {0}ms", sw.ElapsedMilliseconds);
        }
    }

「そんな大きな画像なんて普通扱わない」とか思ってても、画像ビューアとかを作って、フォルダ内の画像一覧表示とかやってると、たまーにこんな画像とも出くわします。
で、読み込みを始めてしまうと、キャンセルできずにUIの応答性が悪くなったりしてしまいます。


画像の読み込みをキャンセルする

方法

色々考えたけど、読み込みを途中でやめるには、このくらいしか方法が見当たりません。。。
BitmapFrame.Createなどで画像を読む際に、Streamを用いて読み込みをしておき、
画像読み込みをキャンセルしたい時には、StreamをDisposeしてしまう!!
こうすると、読み込みをしていた所で即座に例外が飛ぶので、try~catchで囲っておいて例外を拾えば、読み込み処理を任意のタイミングで止められるようになります。

サンプルコード

まずは、画像の読み込みをDecodeImageAsyncという非同期メソッドを作り、このメソッド内でBitmapFrame.Createを使ったStreamからの画像読み込み処理をしています。
そして、Cancelボタンを押下したら、このStreamをDisposeしています。

以下のサンプルでは、15000×12000px位のpng画像を読み込み、途中でキャンセルをしています。
png画像の読み込みを途中でキャンセルしたら、部分的にデコードされた画像が取得できていました。
BitmapFrameの作成中に例外を発生させてるんで、途中でキャンセルした場合の画像データは使わないようにした方が良いかも。。
この辺の動きはまだ未確認。。。
f:id:minami_SC:20141108213734p:plain:w300

MainWindow.xaml
<Window x:Class="WpfBaseTemplate1.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:WpfBaseTemplate1"
        Title="MainWindow"
        Width="450"
        Height="320">
    <Window.DataContext>
        <local:MainWindowViewModel />
    </Window.DataContext>
    <Grid>
        <StackPanel Margin="10">
            <Image Width="300"
                   Height="200"
                   Margin="5"
                   Source="{Binding Image}" />
            <TextBlock Margin="5" Text="{Binding Message}" />
            <StackPanel Orientation="Horizontal">
                <Button Width="75"
                        Margin="0,0,5,0"
                        Command="{Binding LoadCommand}"
                        Content="Load" />
                <Button Width="75"
                        Command="{Binding CancelCommand}"
                        Content="Cancel" />
            </StackPanel>
        </StackPanel>
    </Grid>
</Window>
MainWindow.xaml.cs
    class MainWindowViewModel : BindableBase
    {
        private BitmapSource image;
        public BitmapSource Image
        {
            get { return image; }
            set { this.SetProperty(ref this.image, value); }
        }

        private string message;
        public string Message
        {
            get { return message; }
            set { this.SetProperty(ref this.message, value); }
        }

        // 画像読み込み時に使用するStream
        // 画像ロード中に、コイツをDisposeすることで、無理やり読み込みをキャンセルしてみる。
        private Stream _stream;

        // Loadボタンから呼び出すコマンド
        private RelayCommand loadCommand;
        public RelayCommand LoadCommand
        {
            get { return loadCommand = loadCommand ?? new RelayCommand(Load); }
        }
        // お行儀悪いかもしれないけど、async voidなメソッドをコマンドから実行する
        private async void Load()
        {
            // 一度画像をクリアする
            this.Image = null;
            // 処理時間を計測する
            var sw = Stopwatch.StartNew();
            this.Message = "読み込み中...";

            var path = @"H:\Test\Large.png";
            this.Image = await DecodeImageAsync(path);

            this.Message = string.Format("経過時間: {0}ms", sw.ElapsedMilliseconds);
        }

        // DecodeImageメソッドを非同期メソッドとしてラップ
        private Task<BitmapSource> DecodeImageAsync(string path)
        {
            return Task.Run(() =>
                {
                    return DecodeImage(path);
                });
        }

        private BitmapSource DecodeImage(string path)
        {
            BitmapSource img = null;
            _stream = File.Open(path, FileMode.Open);
            try
            {
                img = BitmapFrame.Create(_stream, BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
                // ワーカースレッドなどでロードした場合は、
                // UIスレッドで使う前にFreezeしておく必要がある。
                img.Freeze();
            }
            catch (Exception ex)
            {
                // StreamがDisposeされて読み込みキャンセルされた。
                Trace.WriteLine("Cancelled");
            }
            finally
            {
                _stream.Close();
                _stream = null;
            }

            return (BitmapSource)img;
        }

        // Cancelボタンから呼び出すコマンド
        private RelayCommand cancelCommand;
        public RelayCommand CancelCommand
        {
            get { return cancelCommand = cancelCommand ?? new RelayCommand(Cancel); }
        }
        private void Cancel()
        {
            if (_stream != null)
            {
                // Streamを閉じて、無理やり画像読み込みをキャンセルする。
                _stream.Dispose();
            }
        }
    }