標準の 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 エクスポートで OOM | 1 行ごとに 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 つのチューニングは間違いなく価値があります。それでも壁にぶつかり続けるなら、問題はつまみの設定ではなく、モデルの方にあります。
さらに読む
- WinForms / WPF アプリで編集可能グリッドを選ぶときの考え方 —
DataGridを卒業するタイミング - ReoGrid 4.4 リリース — 一括ロードと条件付き書式のパフォーマンス計測値
- Office Interop を使わずに C# で DataTable を Excel(.xlsx)に出力する — .NET から大規模な表データを書き出す
- 遅延ロードモードによる大規模データの高速読み込み —
IDataSourceの詳細な実装手順 - パフォーマンス — 描画パイプラインを高速にしている仕組み
