サムネイル画像の生成と表示

今,所用で「サムネイル画像を一覧表示」する機能を作っています.取り合えず表示だけさせようと思ってシングルスレッドで走らせると(やはり)体感速度(スクロールすると引っかかりが生じるなど)に問題がありました.体感速度の改善に関しては,当初「取り合えず画像生成の部分を BackgroundWorker に突っ込んで生成終了したら再描画すればいいんだろ?」みたいに適当な事を考えていたのですが,やはりそう言う適当な考えではうまくいきませんでした.

そんな訳で,サムネイル画像を生成するエンジンを少し一般化してメモしておきます.

using System;
using System.Drawing;
using System.Windows.Forms;
using System.ComponentModel;
using Container = System.Collections.Generic;

/* --------------------------------------------------------------------- */
/// ThumbEventArgs
/* --------------------------------------------------------------------- */
public class ThumbEventArgs : EventArgs {
    /* ----------------------------------------------------------------- */
    /// Constructor
    /* ----------------------------------------------------------------- */
    public ThumbEventArgs(int page) : base() {
        page_ = page;
    }
    
    /* ----------------------------------------------------------------- */
    /// PageNum
    /* ----------------------------------------------------------------- */
    public int PageNum {
        get { return page_; }
    }
    
    private int page_ = 0;
}

/* --------------------------------------------------------------------- */
///
/// ThumbEventHandler
/// 
/// <summary>
/// サムネイル画像の生成が終了すると発生するイベント.
/// 発生するタイミングは 1ページ毎で,どのページのサムネイル画像が
/// 生成されたのかは e.PageNum で取得できる.
/// </summary>
/// 
/* --------------------------------------------------------------------- */
public delegate void ThumbEventHandler(object sender, ThumbEventArgs e);

/* --------------------------------------------------------------------- */
///
/// ThumbEngine
/// 
/// <summary>
/// サムネイル画像を実際に生成するエンジン.生成したものは,
/// オブジェクト内でキャッシュしておく.
/// TODO: 現状では,生成した全てのサムネイル画像をキャッシュしているが
/// ページ数によっては莫大なメモリを消費してしまう.必要に応じて
/// キャッシュを開放する処理を追加する.
/// </summary>
/// 
/* --------------------------------------------------------------------- */
public class ThumbEngine : IDisposable {
    /* ----------------------------------------------------------------- */
    /// ThumbEngine
    /* ----------------------------------------------------------------- */
    public ThumbEngine(CoreLib core, int width) {
        core_ = core;
        width_ = width;
        worker_.DoWork -= new DoWorkEventHandler(DoWorkHandler);
        worker_.DoWork += new DoWorkEventHandler(DoWorkHandler);
        worker_.RunWorkerCompleted -= new RunWorkerCompletedEventHandler(RunCompletedHandler);
        worker_.RunWorkerCompleted += new RunWorkerCompletedEventHandler(RunCompletedHandler);
        worker_.RunWorkerAsync();
    }
    
    /* ----------------------------------------------------------------- */
    /// Core
    /* ----------------------------------------------------------------- */
    public CoreLib Core {
        get { return core_; }
        set { core_ = value; }
    }
    
    /* ----------------------------------------------------------------- */
    /// Contains
    /* ----------------------------------------------------------------- */
    public bool Contains(int pagenum) {
        lock (lock_) {
            return images_.ContainsKey(pagenum);
        }
    }
    
    /* ----------------------------------------------------------------- */
    /// Get
    /* ----------------------------------------------------------------- */
    public Image Get(int pagenum) {
        lock (lock_) {
            if (images_.ContainsKey(pagenum)) return images_[pagenum];
        }
        return null;
    }
    
    /* ----------------------------------------------------------------- */
    ///
    /// Enqueue
    /// 
    /// <summary>
    /// TODO: 不必要なキューの削除処理を改善する.
    /// </summary>
    /// 
    /* ----------------------------------------------------------------- */
    public void Enqueue(int pagenum) {
        lock (lock_) {
            if (!queue_.Contains(pagenum)) {
                queue_.Enqueue(pagenum);
                //if (queue_.Count > 15) queue_.Dequeue();
            }
            if (!worker_.IsBusy) worker_.RunWorkerAsync();
        }
    }
    
    /* ----------------------------------------------------------------- */
    /// QueueCount
    /* ----------------------------------------------------------------- */
    public int QueueCount() {
        lock (lock_) {
            return queue_.Count;
        }
    }
    
    /* ----------------------------------------------------------------- */
    /// ClearQueue
    /* ----------------------------------------------------------------- */
    public void ClearQueue() {
        lock (lock_) {
            queue_.Clear();
        }
    }
    
    /* ----------------------------------------------------------------- */
    /// Clear
    /* ----------------------------------------------------------------- */
    public void Clear() {
        lock (lock_) {
            foreach (Image item in images_.Values) item.Dispose();
            images_.Clear();
            queue_.Clear();
        }
    }
    
    /* ----------------------------------------------------------------- */
    /// Dispose
    /* ----------------------------------------------------------------- */
    public void Dispose() {
        Dispose(true);
        GC.SuppressFinalize(this);
    }
    
    /* ----------------------------------------------------------------- */
    /// Dispose
    /* ----------------------------------------------------------------- */
    protected virtual void Dispose(bool disposing) {
        if (!disposed_) {
            if (disposing) {
                while (worker_.IsBusy) System.Threading.Thread.Sleep(100);
                worker_.Dispose();
                this.Clear();
            }
        }
        disposed_ = true;
    }
    
    /* ----------------------------------------------------------------- */
    ///
    /// ImageGenerated
    ///
    /// <summary>
    /// サムネイル画像の生成が完了したタイミングで発生するイベント.
    /// 関連付けられているフォームは
    /// ((ThumbEngine)sender).Get(e.PageNum) などで生成された画像を
    /// 取得し,再描画する.
    /// </summary>
    ///
    /* ----------------------------------------------------------------- */
    public event ThumbEventHandler ImageGenerated;
    protected virtual void OnImageGenerated(ThumbEventArgs e) {
        if (ImageGenerated != null) ImageGenerated(this, e);
    }
    
    /* ----------------------------------------------------------------- */
    /// DoWorkHandler (private)
    /* ----------------------------------------------------------------- */
    private void DoWorkHandler(object sender, DoWorkEventArgs e) {
        int pagenum = 0;
        lock (lock_) {
            if (queue_.Count > 0) pagenum = queue_.Dequeue();
        }
        if (pagenum > 0) this.GenerateImage(pagenum);
        e.Result = pagenum;
    }
    
    /* ----------------------------------------------------------------- */
    ///
    /// RunCompletedHandler (private)
    ///
    /// <summary>
    /// 画像の生成が完了したら,ImageGenerated イベントを発生させて
    /// 関連付けられているフォームに再描画してもらう.
    /// </summary>
    ///
    /* ----------------------------------------------------------------- */
    private void RunCompletedHandler(object sender, RunWorkerCompletedEventArgs e) {
        lock (lock_) {
            if (queue_.Count > 0) worker_.RunWorkerAsync();
        }
        
        int pagenum = (int)e.Result;
        if (pagenum > 0) {
            var args = new ThumbEventArgs(pagenum);
            this.OnImageGenerated(args);
        }
    }
    
    /* ----------------------------------------------------------------- */
    ///
    /// GenerateImage (private)
    ///
    /// <summary>
    /// 実際にサムネイル画像を生成する.画像の生成はライブラリに依存.
    /// </summary>
    ///
    /* ----------------------------------------------------------------- */
    private void GenerateImage(int pagenum) {
        if (core_ == null || images_.ContainsKey(pagenum)) return;
        
        lock (proc_lock_) {
            // ここがボトルネック
            Image image = core.GenerateImage(width_);
            lock (lock_) {
                images_.Add(pagenum, image);
            }
        }
    }
    
    private CoreLib core_ = null;
    private int width_ = 0;
    private Container.Dictionary<int, Image> images_ = new Container.Dictionary<int,Image>();
    private Container.Queue<int> queue_ = new Container.Queue<int>();
    private object lock_ = new object();
    private object proc_lock_ = new object();
    private bool disposed_ = false;
    private BackgroundWorker worker_ = new BackgroundWorker();
}

方針としては,キュー + キャッシュで処理しています.当初は,単一のロックで行っていたため生成処理 (core.GenerateImage()) に引っ張られて GUI がカクカクすると言う問題が発生していました(生成されたサムネイル画像の Get() 時にロックされていると,生成が終了するまで待たなければならない).これまでマルチスレッドで何かを書いた事がほとんどなかったのですが,画像生成処理のためのロックとユーザとやり取りをするためのロックはうまく使い分けないとダメだと言う事を痛感させられました.こう言うのは,後になると「それはそうだろう・・・馬鹿か」と言う感想を抱くのですが,パターン化された解法を持ってないと四苦八苦してしまいます.

描画側は,以下のような感じ.

public class Thumbnail : ListView {
    /* ----------------------------------------------------------------- */
    ///
    /// GetInstance
    /// 
    /// <summary>
    /// 補助関数.Thumbnail オブジェクトは,何らかの Control
    /// オブジェクトに埋め込む (Controls に登録する)形で使用する為,
    /// 親となる Control オブジェクトから Thumbnail オブジェクトを
    /// 見つける際に使用する.
    /// </summary>
    /// 
    /* ----------------------------------------------------------------- */
    public static Thumbnail GetInstance(Control parent) {
        return parent.Controls["Thumbnail"] as Thumbnail;
    }
    
    /* ----------------------------------------------------------------- */
    /// Constructor
    /* ----------------------------------------------------------------- */
    public Thumbnail(Control parent, CoreLib src) : base() {
        this.Create(parent, src);
    }
    
    /* ----------------------------------------------------------------- */
    /// Engine
    /* ----------------------------------------------------------------- */
    public ThumbEngine Engine {
        get { return engine_; }
    }
    
    /* ----------------------------------------------------------------- */
    ///
    /// WndProc
    /// 
    /// <summary>
    /// サムネイル画像を生成するためのキューを特定のイベントが発生した
    /// 際にキャンセルする.
    /// 
    /// NOTE: LargeChange によるスクロールが発生した場合,必要な
    /// 画像まで生成がキャンセルされている模様.現在は,MouseDown
    /// イベントが発生した直後の Scroll イベント時にのみキャンセル
    /// している.キャンセルのタイミングについては,もう少し検討する
    /// 必要がある.
    /// </summary>
    /// 
    /* ----------------------------------------------------------------- */
    protected override void WndProc(ref Message m) {
        const int WM_SIZE = 0x0005;
        const int WM_VSCROLL = 0x0115;
        const int WM_LBUTTONDOWN = 0x0201;
        const int WM_LBUTTONUP = 0x0202;
        
        if (engine_ != null) {
            switch (m.Msg) {
            case WM_SIZE:
                engine_.ClearQueue();
                break;
            case WM_VSCROLL:
                if (valid_) engine_.ClearQueue();
                break;
            case WM_LBUTTONDOWN:
                valid_ = true;
                break;
            case WM_LBUTTONUP:
                valid_ = false;
                break;
            default:
                break;
            }
        }
        base.WndProc(ref m);
    }
    
    /* ----------------------------------------------------------------- */
    /// Dispose
    /* ----------------------------------------------------------------- */
    protected override void Dispose(bool disposing) {
        if (disposing) {
            var parent = this.Parent;
            if (this.Engine != null) {
                this.Engine.ImageGenerated -= new ThumbEventHandler(ImageGeneratedHandler);
                this.Engine.Dispose();
            }
            this.Items.Clear();
            this.DrawItem -= new DrawListViewItemEventHandler(DrawItemHandler);
            this.MouseEnter -= new EventHandler(MouseEnterHandler);
            parent.Controls.Remove(this);
        }
        base.Dispose(disposing);
    }
    
    /* ----------------------------------------------------------------- */
    /// Create (private)
    /* ----------------------------------------------------------------- */
    private void Create(Control parent, CoreLib src) {
        if (src == null) return;
        
        this.Name = "Thumbnail";
        this.BackColor = Color.Gray;
        this.Alignment = ListViewAlignment.Default;
        this.MultiSelect = false;
        this.Dock = DockStyle.Fill;
        this.OwnerDraw = true;
        this.DrawItem -= new DrawListViewItemEventHandler(DrawItemHandler);
        this.DrawItem += new DrawListViewItemEventHandler(DrawItemHandler);
        this.MouseEnter -= new EventHandler(MouseEnterHandler);
        this.MouseEnter += new EventHandler(MouseEnterHandler);
        
        // 水平スクロールバーが出ないサイズ.
        // 16 は垂直スクロールバーの幅(TODO: 垂直スクロールバーの幅の取得方法).
        double ratio = core.Pages[1].Height / (double)core.Pages[1].Width;
        int width = parent.ClientSize.Width;
        if (width * ratio * core.PageCount > parent.Size.Height) width -= 20;
        width -= 3; // NOTE: 余白を持たせる.手動で微調整したもの
        
        engine_ = new ThumbEngine(core, width - 10);
        engine_.ImageGenerated -= new ThumbEventHandler(ImageGeneratedHandler);
        engine_.ImageGenerated += new ThumbEventHandler(ImageGeneratedHandler);
        
        this.View = View.Tile;
        this.TileSize = new Size(width, (int)(width * ratio));
        this.Clear();
        for (int i = 0; i < core.PageCount; i++) this.Items.Add((i + 1).ToString());
        
        parent.Controls.Add(this);
    }
    
    /* ----------------------------------------------------------------- */
    ///
    /// DrawItemHandler (private)
    /// 
    /// <summary>
    /// サムネイルの DrawItem イベントハンドラ.1 ページ分のサムネイル
    /// 画像を自力で描画する.描画する際には,engine_ にキャッシュが
    /// あるかどうかをチェックし,あればその画像で,なければ真っ白な
    /// 画像を取り合えず表示する.
    /// </summary>
    /// 
    /* ----------------------------------------------------------------- */
    private void DrawItemHandler(object sender, DrawListViewItemEventArgs e) {
        var engine = this.Engine;
        if (engine == null) return;
        
        Rectangle rect = new Rectangle(e.Bounds.Location, e.Bounds.Size);
        rect.Inflate(-5, -5);
        var image = engine.Get(e.ItemIndex + 1);
        if (image != null) {
            e.Graphics.DrawImage(image, rect);
        }
        else {
            engine.Enqueue(e.ItemIndex + 1);
            this.Cursor = Cursors.AppStarting;
            
            // 生成されてないページは真っ白な画像を表示する.
            var brush = new SolidBrush(Color.White);
            e.Graphics.FillRectangle(brush, rect);
            brush.Dispose();
        }
        e.Graphics.DrawRectangle(Pens.LightGray, rect);
        
        // MEMO: キャプションを描画する方法.
        // var stringFormat = new StringFormat();
        // stringFormat.Alignment = StringAlignment.Center;
        // stringFormat.LineAlignment = StringAlignment.Center;
        // e.Graphics.DrawString(e.Item.Text, canvas.Font, Brushes.Black, new RectangleF(e.Bounds.X, e.Bounds.Y + e.Bounds.Height - 10, e.Bounds.Width, 10), stringFormat);
        
        if (e.ItemIndex == engine.Core.CurrentPage - 1) {
            var pen = new Pen(Color.FromArgb(255, 50, 0));
            pen.Width = 2;
            e.Graphics.DrawRectangle(pen, rect);
            pen.Dispose();
        }
    }
    
    /* ----------------------------------------------------------------- */
    /// MouseEnterHandler
    /* ----------------------------------------------------------------- */
    private void MouseEnterHandler(object sender, EventArgs e) {
        this.Focus();
    }
    
    /* ----------------------------------------------------------------- */
    ///
    /// ImageGeneratedHandler
    ///
    /// <summary>
    /// 画像の生成が完了したら発生するイベント (ImageGenerated) の
    /// イベントハンドラ.現状の実装だと,e.PageNum からどの部分を
    /// 再描画する必要があるのかが導出できるので,その部分だけを
    /// 再描画する.
    /// </summary>
    ///
    /* ----------------------------------------------------------------- */
    private void ImageGeneratedHandler(object sender, ThumbEventArgs e) {
        if (e.PageNum <= 0 && e.PageNum >= this.Items.Count) return;
        this.Invalidate(this.Items[e.PageNum - 1].Bounds);
        
        var engine = sender as ThumbEngine;
        if (engine == null || engine.QueueCount() <= 0) this.Cursor = Cursors.Default;
        else this.Cursor = Cursors.AppStarting;
    }
    
    private bool valid_ = false;
    private ThumbEngine engine_ = null;
}

描画部分は,ListView を OwnerDraw モードで利用しています.最初に描画する分の情報だけ Items.Add() しておき,後は DrawItem イベントが起こる度に更新していきます.

コメントでいくつか書いていますが,スクロールしたりすると最終的には描画する必要のない部分の画像までキューに入ってしまって,必要な画像がなかなか生成されないと言うことが起こるので,イベントが発生する度にうまくキューをリセットする必要があります(・・・が,この辺はまだうまく実装できていない).後は,現在の実装だといったん生成したサムネイル画像はずっとキャッシュし続けるのですが,この方法だとメモリ消費量的な問題も発生しておきます.この辺りは,最大で何ページ位のサムネイル画像を生成する事があるのかを考えながら必要に応じて調整する必要がありそうです.