ListBoxとCheckedListBoxの描画高速化:DrawModeをOwnerDrawFixedにして大量項目のカスタム描画でカクつきを防ぐ
レガシーな業務アプリケーションの保守、あるいは極限までリソースを切り詰めたオンプレミス環境において、Windows FormsのUIパフォーマンスチューニングは今なお避けて通れない実務の壁だ。
特に、数千から数万件に及ぶマスターデータを扱う`ListBox`や`CheckedListBox`において、デフォルトの描画機構(`DrawMode.Normal`)のままアイテムを詰め込んでいるシステムは、スクロールのたびに深刻なカクつき(スタッタリング)を引き起こす。GDI+のデフォルト描画は、オーバーヘッドが多く、大量の文字列やアイコンを描画する際のバッファリング戦略が最適化されていないからだ。
今回は、`DrawMode.OwnerDrawFixed`を強制適用し、GDIのネイティブ描画とダブルバッファリングを極限までチューニングすることで、「数万件のアイテムがあっても60fpsで滑らかに動くリスト」を構築する極限の知見を公開する。
—
1. なぜ標準のListBoxは大量データで失速するのか
標準の`ListBox`(および`CheckedListBox`)に大量のアイテムを追加すると、Win32 APIレベルでは`LB_ADDSTRING`などのメッセージが飛び交い、内部のHWNDが再描画要求(`WM_PAINT`)を頻繁に発行する。
さらに、ユーザーがスクロールバーを動かした際、フレームごとに可視領域の全アイテムに対してテキストレイアウトの計算と描画が走る。ここに`.ToString()`の暗黙的なボクシングや、不要なフォント・ブラシオブジェクトの都度生成が絡むと、一瞬でUIスレッドが枯渇する。
解決へのアプローチ:OwnerDrawFixed と ダブルバッファリング
1. `DrawMode.OwnerDrawFixed`への変更: フレームワークのデフォルト描画をバイパスし、開発者が描画責務を完全に掌握する。
2. 描画の完全な同期化(ダブルバッファリング): コントロールのスタイルをいじり、ちらつき(フリッカ)の元凶である画面上の直接描画を排除する。
3. GDIオブジェクトの枯渇防止: ブラシやフォントのインスタンスを静的に保持し、GC(ガベージコレクション)のプレッシャーをゼロにする。
—
2. 実装コード:限界まで最適化されたカスタム CheckedListBox
以下に、VB.NETによる極限最適化済みのカスタム`CheckedListBox`の実装を示す。数万件のデータバインドに耐え、カスタムアイコンや偶数/奇数行の背景色交互描画(ゼブラストライプ)をノーウェイトで実現するコードだ。
Imports System.Windows.Forms
Imports System.Drawing
Imports System.Runtime.InteropServices
”’
”’
Public Class UltraFastCheckedListBox
Inherits CheckedListBox
‘ GDIオブジェクトの都度生成を避けるためのキャッシュ(メモリ最適化)
Private ReadOnly _backBrushEven As New SolidBrush(Color.FromArgb(255, 255, 255))
Private ReadOnly _backBrushOdd As New SolidBrush(Color.FromArgb(245, 247, 250))
Private ReadOnly _backBrushSelected As New SolidBrush(Color.FromArgb(204, 232, 255))
Private ReadOnly _textBrush As New SolidBrush(Color.FromArgb(30, 30, 30))
Private ReadOnly _textBrushSelected As New SolidBrush(Color.FromArgb(0, 0, 0))
Public Sub New()
MyBase.New()
‘ 【最重要】コントロール自体のダブルバッファリングを有効化
‘ これによりWM_PAINT時のちらつきを根絶する
Me.SetStyle(ControlOptimizedStyles.OptimizedDoubleBuffer Or _
ControlOptimizedStyles.AllPaintingInWmPaint Or _
ControlOptimizedStyles.ResizeRedraw, True)
‘ オーナードロー固定モードに設定
Me.DrawMode = DrawMode.OwnerDrawFixed
‘ 項目あたりの高さを固定(計算コストを排除)
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
‘ 選択状態の判定
Dim isSelected As Boolean = (e.State And DrawItemState.Selected) = DrawItemState.Selected
Dim isChecked As Boolean = Me.GetItemChecked(e.Index)
‘ 1. 背景の描画(キャッシュされたブラシを使用し、GCの発生を抑制)
Dim bgBrush As SolidBrush
If isSelected Then
bgBrush = _backBrushSelected
Else
bgBrush = If((e.Index Mod 2 = 0), _backBrushEven, _backBrushOdd)
End If
g.FillRectangle(bgBrush, bounds)
‘ 2. チェックボックスの自前描画(CheckedListBoxの標準描画をバイパスして高速化)
Dim checkBoxRect As New Rectangle(bounds.X + 4, bounds.Y + (bounds.Height – 14) \ 2, 14, 14)
ControlPaint.DrawCheckBox(g, checkBoxRect, If(isChecked, ButtonState.Checked, ButtonState.Normal) Or ButtonState.Flat)
‘ 3. テキストの描画
Dim textBrush As SolidBrush = If(isSelected, _textBrushSelected, _textBrush)
Dim textRect As New Rectangle(bounds.X + 24, bounds.Y, bounds.Width – 28, bounds.Height)
Using sf As New StringFormat()
sf.Alignment = StringAlignment.Near
sf.LineAlignment = StringAlignment.Center
sf.Trimming = StringTrimming.EllipsisCharacter
Dim itemText As String = Me.GetItemText(Me.Items(e.Index))
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
”’
”’
Protected Overrides Sub Dispose(disposing As Boolean)
If disposing Then
_backBrushEven.Dispose()
_backBrushOdd.Dispose()
_backBrushSelected.Dispose()
_textBrush.Dispose()
_textBrushSelected.Dispose()
End If
MyBase.Dispose(disposing)
End Sub
End Class
—
3. チーフアーキテクトが解説する実装の急所
上記のコードには、レガシーシステムを生き返らせるためのシニアエンジニアの知見が凝縮されている。
1. `ControlStyles.OptimizedDoubleBuffer` の強制
Windows Formsのデフォルトでは、一部のコントロールにおいてダブルバッファが有効になっていない。特に`CheckedListBox`はスクロール時に背景の消去(`WM_ERASEBKGND`)と再描画が別々に行われるため、激しいフリッカ(画面のちらつき)が発生する。
コンストラクタ内で `SetStyle` を用い、描画プロセスをメモリ上のバックバッファに閉じ込めることで、描画スループットを劇的に向上させている。
2. GDIブラシの「キャッシュ」によるGCスパイクの排除
初心者によくあるアンチパターンとして、`OnDrawItem` メソッドの中で `New SolidBrush(…)` を毎回生成・破棄するコードが挙げられる。
数千件のアイテムをスクロールする際、1フレームあたり数十個のBrushオブジェクトが生成され、瞬時にガベージコレクタ(GC)の負担となる。これがカクつきの主原因だ。
本実装では、必要なブラシをプライベートフィールドとして一度だけインスタンス化し、コントロールのライフサイクル全体で使い回す。最後に `Dispose` オーバーライドで確実に解放することで、メモリリークとGCスパイクを完全に防いでいる。
3. 文字列描画におけるレイアウトコストの削減
`StringFormat` や `Font` オブジェクトも同様に、描画ループ内での生成を避けるべきだ。フォントはコントロール本体のプロパティ(`Me.Font`)をそのまま流用し、テキストのトリミングやアライメント設定も最小限のオーバーヘッドにとどめている。
—
4. さらにパフォーマンスを極めるためのWindows API活用
もし、数万件ではなく数十万件オーダーのデータをミリ秒単位で制御する必要がある場合、`.Items.Add` を繰り返すアプローチ自体がボトルネックとなる。その場合は、Win32 APIのメッセージを直接叩くことで、UIの再描画を完全に停止させた一括追加を行う。
Public NotInheritable Class NativeMethods
Private Sub New()
End Sub
Public Shared Function SendMessage(hWnd As IntPtr, msg As Integer, wParam As IntPtr, lParam As IntPtr) As IntPtr
End Function
Public Const WM_SETREDRAW As Integer = &HB
”’
Public Shared Sub SuspendDrawing(control As Control)
SendMessage(control.Handle, WM_SETREDRAW, IntPtr.Zero, IntPtr.Zero)
End Sub
Public Shared Sub ResumeDrawing(control As Control)
SendMessage(control.Handle, WM_SETREDRAW, New IntPtr(1), IntPtr.Zero)
control.Refresh()
End Sub
End Class
使用例:
‘ 大量データ追加時のフリーズを防ぐ
NativeMethods.SuspendDrawing(ultraFastCheckedListBox1)
Try
ultraFastCheckedListBox1.Items.Clear()
For i As Integer = 1 To 100000
ultraFastCheckedListBox1.Items.Add($”マスターデータ項目 {i}”)
Next
Finally
NativeMethods.ResumeDrawing(ultraFastCheckedListBox1)
End Try
`WM_SETREDRAW` を `False` にすることで、アイテムが追加されるたびに発生する内部レイアウト計算と再描画リクエストを完全にマスクし、一括処理後に一気に再描画させる。このテクニックを併用すれば、数万件のロードであってもユーザーにストレスを与えることはない。
—
総括
VB.NET / Windows FormsにおけるUIパフォーマンス低下の多くは、フレームワークのデフォルト動作に対する無知と、不適切なオブジェクト管理に起因する。
`DrawMode.OwnerDrawFixed` によるカスタム描画と、GDIリソースの厳格なキャッシュ戦略を組み合わせることで、レガシーなアーキテクチャであってもモダンなWebアプリケーションに匹敵する軽快な操作性を手に入れることは十分に可能だ。
「動けばいい」という妥協を捨て、背後で動くメモリとAPIの挙動を完全に掌握すること。それこそが、真のエンジニアリングである。
