標準の System.Windows.Controls.DataGrid は仮想化されています。画面に映っている行だけがビジュアルとして実体化されます。「仮想化」と聞くと、行数をいくら積んでもスケールしてくれるように感じますが、実際にはそうはなりません。グリッド自体は問題なくても、その周囲にある ItemsSource の materialization、ソート、フィルタ、スタイルトリガ、エクスポートが先に音を上げるラインがあります。

本記事は、まさにそのギャップについてです。WPF DataGrid が実際にどこでスケールしなくなるのか、チューニングで取り戻せる範囲はどこまでで、その先で 100 万行クラスを実用に耐える状態に保つための「遅延データソース」パターンを順に見ていきます。


WPF DataGrid にとっての「大量」とは

1 万行までは、仮想化がオンのままなら標準 DataGrid で十分です。スクロールはスムーズで、行はオンデマンドに実体化され、体感上の問題は出ません。

1 万〜10 万行になると、スクロール中の仮想化は依然として効きますが、スクロール以外が痛みはじめます。初回バインド、ソート、フィルタ、AutoGenerateColumns による列幅測定、各セルにかかる Style.Triggers などです。

10 万行を超えると、今度はモデル側そのものが問題になります。たとえ可視行はすべて仮想化されていても、ビューモデルはすでに 10 万件メモリに作られており、それらをソート用のコンパレータが舐め、CollectionView が抱え込みます。メモリと GC が支配的になります。

本記事は「これだけの行数を本当に表示する必要がある」前提で書いています。テレメトリビューア、約定ブロッター、ログビューア、計測データなどです。単に長いリストをページングして見せたいだけなら、別の問題で、DataGrid + ページングで十分対応できます。


実務で DataGrid が詰まるポイント

症状実際に起きていること
初回ロードで UI が数秒固まるItemsSource の全行が初回描画前にコンストラクトされ、メモリに保持される
スクロールがカクつくScrollUnit="Item" が全行を measure する、または行コンテナがスクロールのたびに再テンプレ
列ヘッダクリックでソートに 5 秒以上ICollectionView.SortDescriptions が可視行ではなくソース全行を走査する
フィルタドロップダウンが遅いドロップダウンを開くたびに全行を distinct で再列挙する
AutoGenerateColumns="True" の初回描画が遅い列幅をサンプル行で測定し、そのサンプル数が行数に依存する
Excel エクスポートで OOM1 行ごとに XLCell / ExcelCell インスタンスが生成され、50 万セル × オブジェクトオーバーヘッドはかなりの量になる

表の上半分の症状はチューニングで解決できます。下半分 — 特にエクスポートと、「この大量データに加えて数式・結合セル・複数シートも扱いたい」という要件 — はアーキテクチャ側の話で、それは後段で扱います。


打ち手 1: WPF DataGrid のチューニング

新しい依存を追加する前に、コントロールが既に提供しているレバーを引いてみます。良い知らせとして、最近の .NET ではデフォルトはほぼ正しい設定です。失敗の多くは、XAML のどこかで「正しいデフォルト」を意図せず無効にしてしまっているケースです。

仮想化が効いていることを確認する。 デフォルトは EnableRowVirtualization="True"EnableColumnVirtualization="True" です。ビジュアルツリーのどこかで ScrollViewer.CanContentScroll="False" を設定していたり、グリッドを別の ScrollViewer で包んでいると、仮想化は黙って切れます。インスペクタで確認しましょう。

重いテンプレートの場合は、ピクセル単位ではなく行単位でスクロールする。

<DataGrid VirtualizingPanel.ScrollUnit="Item"
          VirtualizingPanel.VirtualizationMode="Recycling"
          EnableRowVirtualization="True"
          EnableColumnVirtualization="True" />

Recycling を指定すると、スクロールのたびに新しいコンテナを作らず、既存のものを使い回します。行テンプレートにトリガ、コンバータ、アタッチビヘイビアがある場合に効果が大きいです。トレードオフとして、ピクセル単位の滑らかなスクロールが「1 行刻み」になります。ユーザーがざっと眺めるタイプのデータグリッドなら、こちらが正解です。

全列の自動サイズはやめる。 DataGridLength.Auto は変更のたびに全ロード済み行を再 measure します。分かっている幅は固定値で指定し、*(スター)はユーザーが実際にリサイズする 1 列だけにとどめます。

本番グリッドでは AutoGenerateColumns="True" を避ける。 列は明示的に定義します。自動生成は型推論のためにソースを走査し、初回描画では行ごとにリフレクションを走らせます。

非同期にロードする。 UI スレッド上で同期 DB 呼び出しから直接 ItemsSource を割り当てない:

// async イベントハンドラ内:
var rows = await Task.Run(() => _repo.LoadRows());
grid.ItemsSource = rows;          // 割り当て自体は速い。重かったのはロード

さらに大きなセットでは、バッチで ObservableCollection<T> に流し込むか、リフレッシュを遅延させる CollectionView を使ってインクリメンタルロードに切り替えます:

using (collectionView.DeferRefresh())
{
    foreach (var batch in batches)
        foreach (var row in batch)
            source.Add(row);
}

ここまでは安価な修正です。10 万行の画面を「6 秒固まる」から「すぐ開いてスムーズにスクロールする」に変えてくれます。一方で、アーキテクチャ上の問題は解決しません — ソートは依然として全行に触れますし、フィルタはソース全体を列挙しますし、ユーザーが数式・結合ヘッダ・.xlsx ラウンドトリップを求めはじめた瞬間に、DataGrid が想定する範囲を超えます。その判断については WinForms / WPF アプリで編集可能グリッドを選ぶときの考え方 を参照してください。


打ち手 2: 遅延データソース

数十万行を超えたら、そもそも「全部グリッドのメモリに載せる」のをやめるのが正解です。コントロールはソースに対して「これから描く行」だけを要求し、それ以外は触らない、という設計にします。

ReoGrid 4.4 で追加された組み込みの LazyLoadDataSource を使うと、これがほぼ 1 行で済みます。object[,] 配列を渡してレイジーモードでアタッチするだけで、ワークシートは実際に描画・参照された行だけを Cell インスタンスに materialize します。配列はコピーされず参照保持なので、グリッド側のメモリは「全行 × 全列」ではなく、おおよそ「可視範囲」になります。

using unvell.ReoGrid.Data;

// 100 万行 × 10 列。事前にメモリ上に object[,] として保持しているもの。
var data = new object[1_000_000, 10];
FillData(data);   // 自前のデータ充填処理

worksheet.SetRows(data.GetLength(0));

// 1 行で接続。範囲はソースの RecordCount / ColumnCount から自動推定される。
worksheet.AddDataSource(
    new LazyLoadDataSource(data),
    DataSourceLoadMode.LazyLoading);

100 万行接続しても、Cell インスタンスとして実体化されるのは現在画面に出ている約 30 行だけです。ユーザーがスクロールすれば、その分だけオンデマンドに実体化されます。まだ画面に出ていないセルを参照する数式があっても、同じ経路で引き出されるため、行をまたいだ参照も問題なく機能します。

シート全体ではなく一部範囲だけにバインドしたい場合は、従来通り明示的な範囲指定もできます:

worksheet.AddDataSource(
    new RangePosition(0, 0, rows, cols),
    new LazyLoadDataSource(data),
    DataSourceLoadMode.LazyLoading);

実務上のポイントが 3 つあります:

  • object[,] は参照保持。アタッチ後に呼び出し側で配列を書き換えると、次回そのセルがロードされたときにシートに反映されます。既存行の値をストリーミング更新したい場合は便利ですが、想定していないと驚きになります。
  • 行を追加するときはソースを差し替えるobject[,] を大きい配列にコピーし直し、RemoveAllDataSources() してから AddDataSource(new LazyLoadDataSource(newData), …)SetRows(newRows) を呼びます。リポジトリ内のデモ(DemoJP/Performance/LazyLoadDemo.cs)がまさにこのパターンです。
  • メモリプロファイル。生の object[,] 自体は依然としてメモリにあります。遅延ロードが避けるのはあくまでグリッド側の materialization(1 行ごとのレコード、スタイル、罫線、数式状態)です。生の配列ですら大きすぎる場合は、IDataSource<T> を自前実装して GetRecord がディスクや DB から読み出す形にすれば、同じ DataSourceLoadMode.LazyLoading でバッキングストア側もストリーミングにできます。

書式設定や罫線まで含めた完全な手順は 遅延ロードモードによる大規模データの高速読み込み を参照してください。


打ち手 3: 全データが手元にあるなら一括書き込み

データセット全体がすでにメモリにあって「とにかく一刻も早く画面に出したい」だけなら、適切な道具は遅延ロードではなく一括書き込みです。sheet["A1"] = x を 20 万回呼ぶコストは、値の代入そのものではなく、1 セルごとのイベント・セットアップオーバーヘッドにあります。

SetRangeData は 2 次元ブロック全体を受け取って一括で書き込みます:

var data = new object[rows, cols];
for (int r = 0; r < rows; r++)
    for (int c = 0; c < cols; c++)
        data[r, c] = source[r, c];

sheet.SetRangeData("A1", data);

ReoGrid 4.4 では、20 万セル規模で約 3 倍高速化しています。さらに条件付き書式と組み合わせたときの一括投入も 4.4 で大きく速くなりました(4.4 リリースノートを参照)。

目安:

  • ストリーミング、または時間とともに増えるソース → 遅延データソース。
  • データセット全体が手元にある → SetRangeData
  • 両方該当 → SetRangeData でロードしておき、グリッドは通常モードのままにする。

DataGrid では得られない、ついでに手に入るもの

行数の事情でスプレッドシートコントロールに移った時点で、WPF DataGrid には元から無かった機能も自然に揃います:

  • 行/列の固定 が水平スクロールにも追従する
  • セル結合 がヘッダーにも本体にもできる
  • 複数シート を 1 つのコントロールに収められる
  • 条件付き書式 がスクロールごとに再評価されない(4.4 で一括書き込み + 条件付き書式は約 11,700 倍高速化)
  • .xlsx への直接エクスポート で、見えている内容を数式・書式ごと書き出せる
  • セル間の数式。ユーザーが =SUM(...) 行を自分で足せる(その実装をあなたが書く必要はありません)

使う必要はありません。ただ、来期に要件として出てきた時に取り出せます。


データセットを .xlsx にエクスポートする

「50 万行を見せて」と言われたら、ほぼ確実に「で、Excel でダウンロードしたい」がセットでついてきます。DataGrid ベースのスタックがいちばん辛いのがここで、エクスポート経路はたいてい Items を再列挙して値ごとに新しいセルオブジェクトを生成します。これがメモリを食い尽くす本体です。

データがすでに Worksheet 上にあるなら、エクスポートは 1 行で済みます:

worksheet.Workbook.Save("export.xlsx");

2 度走らせる必要はありません。別ライブラリのオブジェクトモデルにコピーする必要もありません。グリッドに描画しているのと同じインメモリ表現が、そのままディスクに書き出されます。

グリッドが直接関係しない DataTable 系のソースについては、独立ライブラリの比較を Office Interop を使わずに C# で DataTable を Excel(.xlsx)に出力する にまとめています。


簡単な意思決定フロー

  • 1 万行未満、リスト構造、数式なし? → 標準 WPF DataGrid、デフォルトのままで十分。
  • 1 万〜10 万行、依然としてリスト構造? → WPF DataGrid を上記のチューニング(リサイクル、明示的な列定義、非同期ロード)で運用。
  • 10 万行を超える、またはデータが時間とともに到着する? → 遅延データソース(IDataSource + DataSourceLoadMode.LazyLoading)。
  • データセット全体がメモリにあり、瞬時に画面に出したい?SetRangeData による一括書き込み。
  • 数式・結合ヘッダ・複数シート・.xlsx ラウンドトリップのいずれか? → すでに DataGrid の射程外。編集可能グリッドの選び方を参照。

ここでの「高くつく間違い」は、編集可能グリッド全般と同じです。標準コントロールを引っ張りすぎて、症状を 1 つずつ場当たり的に潰し続けることです。最初の 3 つのチューニングは間違いなく価値があります。それでも壁にぶつかり続けるなら、問題はつまみの設定ではなく、モデルの方にあります。


さらに読む