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


某案件で、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型

# プロジェクト

https://github.com/belre/CSharpCodeSamples/tree/main/DataGridViewOptimization/DataGridViewOptimization

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/281173/fd3fd313-47b9-9cfa-efd2-b58c143facc5.png)


# やったこと


## BindingSourceを使用する

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

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

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

```C#:BindingSourceを使用しない
foreach(var line in Lines)
{
   dataGridView1.Rows.Add(new[]
   {
      line.Number.ToString(), line.Name, line.DateTime.ToString("yyyy/MM/dd HH:mm:SS")
   });
}
```

```C#: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
<?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等にしないと、
各セルの描画処理を逐次で変換するようになり、処理がくそ重たくなるので注意すべし。

```C#: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などを使用して、表示するべき列の幅を調べてしまうのが良いと思う。

https://docs.microsoft.com/ja-jp/dotnet/api/system.drawing.graphics.measurestring?view=dotnet-plat-ext-6.0

## 自動バインドの抑制

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

https://kazzz.hatenablog.com/entry/20070925/p1

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

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

```C#:自動バインド抑制

// 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|

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

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

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/281173/f2ecb1da-29f8-0a52-fcb2-5fcc52636ce6.png)

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

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

（以下の動画を参照。スライド２枚あります）


<iframe src="https://docs.google.com/presentation/d/e/2PACX-1vQMLdRMlZbwLQX9gXj7f39wPB9cWX-WVd4ZpDbMFXGWPOJDU4ybs38BYd2C8GiifVTKXtMbPFCBb8ex/embed?start=false&loop=false&delayms=60000" frameborder="0" width="960" height="569" allowfullscreen="true" mozallowfullscreen="true" webkitallowfullscreen="true"></iframe>


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

```C#: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というメソッドを追加

```C#:UserDataGridViewControl.cs
public void EnableResizeMode()
{
  repaintOptimizedDataGridView1.IsEnableBorderPaint = false;
}

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

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

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

```C#: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のデータを非同期登録するやり方が良いだろう。

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

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

https://bg1.hatenablog.com/entry/2017/05/07/210000

https://uchukamen.com/Programming/Asyncro/

などにあるように、

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

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

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

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

## Virtual Modeの使用

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

https://www.softech.co.jp/mm_200506_tr.htm