23
40

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【WinForms】DataGridViewの高速化のためにやったこと

Last updated at Posted at 2022-09-02

某案件で、WinFormsで数万行のデータを処理してくれと言われ、
高速化についてはかなりいろいろな経緯を踏まえた記憶があるので、まとめておく。

この内容については、結構な人が改善案を提示しているので、
何番煎じかは分からないが、整理はしておいた方がいいかなと思った。

環境

せっかくなので、出来立てほやほやの.NET Framework 4.8.1で実行してみよう。

項目 内容
PC DELL G3 3500
CPU Intel(R) Core(TM) i7-10750H CPU @ 2.60GHz 2.59 GHz
メモリ 16GB
OS Windows 11 21H2
開発環境 .NET Framework 4.8.1

データは以下のものを用意

  • Number : int型
  • Name : string型
  • DateTime : DateTime型

プロジェクト

image.png

やったこと

BindingSourceを使用する

DataGridViewでBindingSourceを使わずに実装するというやつは避ける。
WinForms初心者がやりがち。

実際、過去の開発委託でこれをやろうとしている人がいて、
「データの順序が常に崩れるんですけど、おかしいなあ」
みたいな寝ぼけたことを言う輩がいて、そのときはこのことを理解させるのに苦労させられた。

当然データの速度の問題もあるが、
データの順序などが崩れてデータ操作が異様にしづらい問題も生じる。

BindingSourceを使用しない
foreach(var line in Lines)
{
   dataGridView1.Rows.Add(new[]
   {
      line.Number.ToString(), line.Name, line.DateTime.ToString("yyyy/MM/dd HH:mm:SS")
   });
}
BindingSourceを使用する
// designer.cs側では次のように定義
this.dataGridView1.DataSource = this.TableBindingSource;
this.TableBindingSource.DataSource = typeof(DataGridViewOptimization.GridDataModel.SimpleDataLine);

// データ入力操作
TableBindingSource.DataSource = Lines;

BindingSourceを作ったとき、
例えばローカルオブジェクトを指定して生成すると次のようなXMLファイルが
DataGridViewOptimization.GridDataModel.SimpleDataLine.datasource
という名前で作られる。
ここの内容はウィザードで設定でき、データベース(SQL Serverなど)との連携なども可能

<?xml version="1.0" encoding="utf-8"?>
<!--
    This file is automatically generated by Visual Studio .Net. It is 
    used to store generic object data source configuration information.  
    Renaming the file extension or editing the content of this file may   
    cause the file to be unrecognizable by the program.
-->
<GenericObjectDataSource DisplayName="SimpleDataLine" Version="1.0" xmlns="urn:schemas-microsoft-com:xml-msdatasource">
   <TypeInfo>DataGridViewOptimization.GridDataModel.SimpleDataLine, DataGridViewOptimization, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null</TypeInfo>
</GenericObjectDataSource>

試行回数10回、
データ数10000データで、
画面表示処理を実行。

項目 実行時間
BindingSourceなし 2873ms
BindingSourceあり 9.4ms

全てにおいて圧勝である。

AutoSizeModeでAllCellsなどを避ける

これも割と有名な事例だが、
AutoSizeModeをNotSet等にしないと、
各セルの描画処理を逐次で変換するようになり、処理がくそ重たくなるので注意すべし。

AutoSizeMode
DataGridViewColumn target_item = dataGridView1.Columns[0];
target_item.AutoSizeMode = DataGridViewAutoSizeColumnMode.NotSet;

DateTimeをNotSetとAllCellsにしたときに、どの程度処理時間に差が出るかを評価してみても差は明らかだ。

項目 実行時間
AllCells 718.2ms
NotSet 9.4ms

AllCells以外に、AllCellsExceptHeaderなどもダメなので注意。
基本的には、NotSet、Fillあたりで選択する必要があるだろうか。

どうしてもAllCellsを使いたいとしても、あきらめて、

新規にBitmapクラスで画像用のオブジェクトを生成して、
MeasureStringなどを使用して、表示するべき列の幅を調べてしまうのが良いと思う。

自動バインドの抑制

詳しくはここの最初の方に載っている

あまりにも多量なデータがある場合、
BindingSourceにわざわざ登録するたびに、
バインドのイベントが入ってしまう。

次のような処理に書き換えると、
初期化時のバインドイベントを省略して、高速化することが出来る。

自動バインド抑制

// DataSource = listとすると、リストデータをDataGridViewに反映できるが、
// そのまま入力すると自動的にリスト生成が反映されてしまうので、
// それを停止させる
TableBindingSource.RaiseListChangedEvents = false;

// 勝手にDataSource=listとしたときにデータをバインドさせる処理を取りやめる
TableBindingSource.SuspendBinding();

// データを入力する
TableBindingSource.DataSource = list;

// データ一覧の変更をtrueとする
TableBindingSource.RaiseListChangedEvents = true;

// データバインドを継続させる
// このデータバインドを逐一行わせるのが処理を遅くさせている要因である
TableBindingSource.ResumeBinding();

// バインドされた結果の表示を更新する
// - ここでfalseに指定することで、データ構造に変化がないことを通知する
// (trueにするとデータ構造ごと反映する必要が出てくるので重くなる)
// - ListChangedEventsは、このタイミングで初めて行われるので無駄な処理がなくなる
TableBindingSource.ResetBindings(false);

10000データ程度では有意な結果が見えなかったので、
1000000データで表示した結果がこちら

項目 実行時間
自動バインド 387.9ms
自動バインド抑制 187.5ms

2倍ほど違いが出ている。
実際、今回のようなint, string, DateTimeのように単純なデータ列ではなく、
バインドするデータが複雑になると、
ここの処理時間は思いっきり長くなるので結構役に立つと思う。

頻繁にかかる再描画処理の一部を省略する(Borderなど)

データの初期化ではなく、データの描画時に遅さを減らすために行なった作業。

例として、画面サイズが比較的大きなサイズで、
リサイズを頻繁に行う、みたいなシチュエーションを考える。
行のデータもやや複雑目にしておこう。

image.png

これを、何もしないまま、フォームのリサイズをしてみると、体感的に「ちょっと遅いな」と感じる。

1枚目と2枚目を比較すると何となくわかるであろう。

(以下の動画を参照。スライド2枚あります)

2枚目で、どのような操作をしたかというと、
新たにDataGridViewを派生したクラス、RepaintOptimizedDataGridViewを作成し、
次のような形式でDataGridViewのBorderを省くようにした。
実際、リサイズ時は描画時に境界線が崩れていることが分かると思う。

RepaintOptimizedDataGridView.cs
public class RepaintOptimizedDataGridView : DataGridView
{
  public RepaintOptimizedDataGridView()
  {
    IsEnableBorderPaint = true;
  }
  public bool IsEnableBorderPaint
  {
    get;
    set;
  }
  protected override void OnRowPrePaint(DataGridViewRowPrePaintEventArgs e)
  {
    base.OnRowPrePaint(e);
    if (IsEnableBorderPaint)
    {
      /* リサイズしないときはここを通るようにする */
      e.PaintCells(e.ClipBounds, DataGridViewPaintParts.All);
      e.PaintHeader(DataGridViewPaintParts.Background
                    | DataGridViewPaintParts.Border
                    | DataGridViewPaintParts.Focus
                    | DataGridViewPaintParts.SelectionBackground
                    | DataGridViewPaintParts.ContentForeground);
      }
    else
    {
      /* 
        リサイズしているときはここを通るようにする
        DataGridViewPaintParts.AllはFlagsを格納している列挙型であることに注意 
      */
      e.PaintCells(e.ClipBounds, DataGridViewPaintParts.All & ~DataGridViewPaintParts.Border);
      e.PaintHeader(DataGridViewPaintParts.Background
                    | DataGridViewPaintParts.Border
                    | DataGridViewPaintParts.Focus
                    | DataGridViewPaintParts.SelectionBackground
                    | DataGridViewPaintParts.ContentForeground);
    }
    e.Handled = true;
  }
}

フォームは、リサイズ開始終了時に、IsEnableBorderPaintを制御することで実装する。

オーバーヘッドをつけて少しでも遅くして分かりやすくするため、
UserControlを作成し、EnableResizeMode、DisableResizeModeというメソッドを追加

UserDataGridViewControl.cs
public void EnableResizeMode()
{
  repaintOptimizedDataGridView1.IsEnableBorderPaint = false;
}

public void DisableResizeMode()
{
  repaintOptimizedDataGridView1.IsEnableBorderPaint = true;

  /* リサイズが終わった時、再描画しないと、崩れたままになってしまうので注意*/
  repaintOptimizedDataGridView1.Invalidate();
}

フォーム側には、ResizeBeginイベントとResizeEndイベントを付与し、
次のように実装することで解決している。

DataGridViewResize.cs
/* DataGridViewResize.Designer.cs */
this.ResizeBegin += new System.EventHandler(this.DataGridViewResize_ResizeBegin);
this.ResizeEnd += new System.EventHandler(this.DataGridViewResize_ResizeEnd);

/* DataGridViewResize.cs */
private void DataGridViewResize_ResizeBegin(object sender, EventArgs e)
{
  if (CheckBoxResizeOptimized.Checked)
  {
    userDataGridViewControl1.EnableResizeMode();
  }
}

private void DataGridViewResize_ResizeEnd(object sender, EventArgs e)
{
  if (CheckBoxResizeOptimized.Checked)
  {
    userDataGridViewControl1.DisableResizeMode();
  }
}

もちろん、リサイズで描画が崩れる形で動くので、最低限崩れた後戻すときは、System.Windows.Timerなどを使用して、タイミング処理で時々Invalidateを実行させるのが有効だと思う。

また同様の処理を応用することも割としやすく、スクロール移動やマウスホイールにも適用可能。

WinFormsのアプリケーションで「何か遅い」と言われる原因の一つは、
こういう再描画の問題を理解されていないこともあると思う。

そのあたりは実際の運用を踏まえながら実装するとよい。

データの読み込み処理を非同期にする。

私も今回使用した。
他の複雑な処理をOnLoad中に実装しながら、DataGridViewのデータを非同期登録するやり方が良いだろう。

BeginInvoke((MethodInvoker)delegate { 
  TableBindingSource.DataSource = list;
});

注意点として、非同期処理を起こすTask.Runが一見よさそうに見えるが、

などにあるように、

フォームはControlクラスを継承しており、専用の非同期実行のメソッドControl.BeginInvokeが定義されており、
Task.Runなどを起こすとWinFormsとは全く関係ない管理されていないスレッドが非同期処理を管理してしまう。

WinFormsで、各GUIを操作するときには、
間違ってTask.Runなどの非同期処理を使わないように気を付けよう。

思わぬ処理不良が起きる可能性がある。

なお、データベースでSQLのクエリ操作などをするときはTask.Runを素直に使ってよいと思う。
デリゲートで結果を受けるときに、Control.BeginInvokeを使うことだけ肝に銘じておくこと。

Virtual Modeの使用

コードは若干に煩雑にはなるが、
Virtual Modeを使って高速化するのも役に立つ。

23
40
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
40

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?