Windows Formsの限界を突破せよ:数千件の項目を持つListBox/CheckedListBoxを「カクつきゼロ」で描画する極限の最適化術
業務システムにおいて、数千件におるマスターデータやログファイルの一覧をWindows Formsの `ListBox` や `CheckedListBox` にバインドした瞬間、激しいスクロールのカクつきに直面したことはないだろうか。
「マウスホイールを回した途端にUIがフリーズする」
「選択状態の切り替えにワンテンポ遅れる」
もし君がこのようなクレームをユーザーから受けているなら、原因は明確だ。デフォルトの描画エンジン(Standard Draw)が、大量の文字列描画とレイアウト計算のオーバーヘッドに耐え切れていないからだ。
今回は、VB.NETのWindows Formsアプリケーション設計において、`DrawMode` を `OwnerDrawFixed` に切り替え、数千件のアイテムをマッハで描画しつつ、実務に耐えうる堅牢なカスタム描画を実装する極限の知見を伝授する。
—
1. なぜデフォルトのListBoxは数千件で失速するのか?
多くの開発者は、`ListBox.DataSource` に数千件のリストを突っ込み、満足する。しかし、内部の挙動を理解しているだろうか。
デフォルトの `DrawMode.Normal` では、OSが標準の描画ルーチンを用いて各アイテムを描画する。しかし、アイテム数が数千件を超え、さらにウィンドウのサイズ変更や高速スクロールが発生すると、Windowsは描画領域内の全アイテムに対してテキストのフォントメトリクス計算、背景の塗りつぶし、境界線の描画をその都度(同期的に)実行する。
ここに `CheckedListBox` が加わると、チェックボックスのアイコン描画と状態管理が加わり、パフォーマンスはさらに悪化する。
解決の鍵:`OwnerDrawFixed` と `DoubleBuffered`
このボトルネックを粉砕するアプローチが以下の2点だ。
1. `DrawMode = DrawMode.OwnerDrawFixed` への変更
OS任セの描画を捨て、開発者が `DrawItem` イベントで「必要な領域だけを最小限のコストで描画する」仕組みに切り替える。高さを固定(Fixed)にすることで、レイアウト計算のコストを完全に排除する。
2. ダブルバッファリングの強制適用
描画時のチラつき(フリッカー)を抑えるため、コントロールの内部スタイルで `OptimizedDoubleBuffer` を有効化する。
—
2. 実務で通用する堅牢なカスタム描画の実装
それでは、実際にプロジェクトへ投入できるプロダクションコードを解説する。
ここでは、単なる文字列だけでなく、アイコン風のステータス表示や、偶数・奇数行のインタリーブ表示(ストライプ背景)を高速に処理する `CheckedListBox` の拡張クラスを作成する。
以下のコードをそのままプロジェクトのユーティリティ、またはカスタムコントロールとして配置してほしい。
Imports System.Drawing
Imports System.Windows.Forms
”’
”’
Public Class HighPerformanceCheckedListBox
Inherits CheckedListBox
Public Sub New()
‘ ダブルバッファリングを有効化し、描画時のチラつきを完全に排除
Me.SetStyle(ControlStyles.OptimizedDoubleBuffer Or _
ControlStyles.AllPaintingInWmPaint Or _
ControlStyles.ResizeRedraw, True)
‘ オーナードロー固定モードに設定
Me.DrawMode = DrawMode.OwnerDrawFixed
‘ 1行あたりの高さをピクセル単位で厳密に固定(パフォーマンス向上)
Me.ItemHeight = 24
End Sub
”’
”’
Protected Overrides Sub OnDrawItem(e As DrawItemEventArgs)
‘ 項目が空、またはインデックスが無効な場合は何もしない
If e.Index < 0 OrElse e.Index >= Me.Items.Count Then Return
Dim g As Graphics = e.Graphics
Dim bounds As Rectangle = e.Bounds
‘ 1. 背景色の決定(選択状態、偶数・奇数行のストライプ描画)
Dim isSelected As Boolean = (e.State And DrawItemState.Selected) = DrawItemState.Selected
Dim backColor As Color
Dim foreColor As Color
If isSelected Then
backColor = SystemColors.Highlight
foreColor = SystemColors.HighlightText
Else
‘ 偶数行と奇数行で背景色を変える(視認性の向上)
If e.Index Mod 2 = 0 Then
backColor = Color.White
Else
backColor = Color.FromArgb(245, 247, 250)
End If
foreColor = SystemColors.WindowText
End If
‘ 背景の塗りつぶし(GDI+の描画コストを最小限に抑えるため SolidBrush を使用)
Using bgBrush As New SolidBrush(backColor)
g.FillRectangle(bgBrush, bounds)
End Using
‘ 2. CheckedListBox特有のチェックボックスを手動描画
‘ ※ OwnerDrawFixedにするとデフォルトのチェックボックスが消えるため自前で描画する
Dim isChecked As Boolean = Me.GetItemChecked(e.Index)
Dim checkBoxRect As New Rectangle(bounds.X + 4, bounds.Y + (bounds.Height – 14) \ 2, 14, 14)
‘ チェックボックスの枠と背景
g.FillRectangle(Brushes.White, checkBoxRect)
g.DrawRectangle(Pens.Gray, checkBoxRect)
If isChecked Then
‘ チェックマーク(レ)の描画
Using checkPen As New Pen(Color.DarkGreen, 2)
g.DrawLine(checkPen, checkBoxRect.X + 2, checkBoxRect.Y + 7, checkBoxRect.X + 6, checkBoxRect.Y + 11)
g.DrawLine(checkPen, checkBoxRect.X + 6, checkBoxRect.Y + 11, checkBoxRect.X + 12, checkBoxRect.Y + 3)
End Using
End If
‘ 3. テキストの描画
Dim itemText As String = Me.GetItemText(Me.Items(e.Index))
Dim textRect As New Rectangle(checkBoxRect.Right + 8, bounds.Y, bounds.Width – (checkBoxRect.Right + 8), bounds.Height)
Using textBrush As New SolidBrush(foreColor)
‘ テキストを描画領域の垂直中央に配置
Dim sf As New StringFormat() With {
.LineAlignment = StringAlignment.Center,
.Alignment = StringAlignment.Near,
.Trimming = StringTrimming.EllipsisCharacter
}
g.DrawString(itemText, Me.Font, textBrush, textRect, sf)
End Using
‘ 4. フォーカス枠の描画(キーボード操作時の視認性確保)
If (e.State And DrawItemState.Focus) = DrawItemState.Focus Then
ControlPaint.DrawFocusRectangle(g, bounds)
End If
End Sub
End Class
—
3. データベース連携と数千件データロードの鉄則
オーナードローで描画を最適化しても、データ投入時の設計を誤ればアプリは一瞬でフリーズする。特にデータベース(SQL Server / SQLite等)や巨大なCSVファイルからデータを取得する場合のアンチパターンと正しい実装を示す。
❌ やってはいけない実装(UIスレッドのブロック)
‘ 【悪手】数千件のレコードをループさせて直接Addする
For Each row As DataRow In dataTable.Rows
highPerformanceCheckedListBox1.Items.Add(row(“ItemName”).ToString())
Next
理由: `Add` メソッドが呼ばれるたびに内部レイアウトが無効化され、数千回の再描画イベントが発生して画面が固まる。
⭕ 正しい実装:BeginUpdate / EndUpdate の活用
一括追加を行う場合は、必ず描画更新をサスペンド(一時停止)させる。
”’
”’
Public Sub LoadDataAsync(dataTable As DataTable)
‘ 1. 描画更新を完全に停止
HighPerformanceCheckedListBox1.BeginUpdate()
Try
HighPerformanceCheckedListBox1.Items.Clear()
‘ 配列への一括変換またはストリーム処理によるメモリ効率化
Dim items As String() = dataTable.AsEnumerable().
Select(Function(r) r.Field(Of String)(“ItemName”)).
ToArray()
‘ 2. コレクションを一括追加(内部配列の再割り当てを最小化)
HighPerformanceCheckedListBox1.Items.AddRange(items)
Finally
‘ 3. 描画更新を再開し、一度だけ強制描画を行う
HighPerformanceCheckedListBox1.EndUpdate()
End Sub
End Class
`BeginUpdate` と `EndUpdate` で挟むことにより、数千件のアイテム追加に伴うUIの再描画処理が「1回」に集約され、ロード時間は劇的に短縮される。
—
4. チーフアーキテクトからの実践的なアドバイス
1. メモリリークの防止
今回のコードでは `SolidBrush` や `Pen` などのGDIリソースに対して `Using` ステートメントを厳格に適用している。オーナードロー内でのオブジェクトのインスタンス化(`New`)の乱立は、ガベージコレクタ(GC)に過大な負荷をかけ、将来的なメモリリークやプチフリーズの原因になる。極限を求めるならブラシをフィールドにキャッシュする設計も検討せよ。
2. フォントとDPIスケーリング
高解像度環境(4Kモニターなど)でのスケーリングに対応するため、固定の `ItemHeight = 24` は、`Me.Font.Height` を基準に動的に計算する構造(例: `CInt(Me.Font.Height 1.5)`)にしておくと、環境依存の表示崩れを防ぐことができる。
総括
Windows Formsは古いテクノロジーと揶揄されることもあるが、その内部挙動を深く理解し、適切なアーキテクチャ(オーナードロー+ダブルバッファリング+一括更新)を適用すれば、WPFやWebアプリに匹敵する軽快で堅牢な業務UIを実現できる。
「動けばいい」という妥協を捨て、細部まで磨き上げられたコードこそが、現場のストレスを消し去る真のエンジニアリングだ。次の開発では、ぜひこの知見を組み込んで圧倒的なパフォーマンスを体感してほしい。
