WinForms DataGridViewの検索が遅い原因と高速化:一致行の選択と先頭スクロールを改善する方法

WinFormsのDataGridViewで検索文字列に一致する行を選択し、先頭のヒット行へスクロールする処理が、行数が増えると急に遅くなることがあります。本記事では、遅さの原因を分解し、二重走査の排除、LINQでの一致行Index取得、データソース側検索など、実務で使える高速化パターンをVB.NET中心に解説します。

目次

DataGridViewの検索・選択・スクロールが遅くなる典型パターン

「0列目に検索文字列が含まれる行を全部選択し、先頭の選択行へスクロールする」要件はシンプルですが、行数が多いDataGridViewでは実装の仕方で体感速度が大きく変わります。特に遅くなりやすいのは次の流れです。

  • 全行を走査して一致行を row.Selected = True にする
  • その後で DataGridView.SelectedRows を走査し、最小Index(先頭の選択行)を探す
  • FirstDisplayedScrollingRowIndex に最小Indexをセットしてスクロールする

この実装が重くなりやすい理由は、単純に「行を探す」よりも、選択状態の管理とUI更新にコストが寄っているからです。

処理コストが増えやすいポイント結果として起きること
一致行を次々選択する選択状態の更新、再描画、SelectionChangedなどのイベント発火一致件数が多いほどUIが固まったように見える
SelectedRowsを走査するSelectedRowsコレクションの構築・並び替え・取得が内部的に重くなることがある「先頭の選択行を探す」だけのはずが想像以上に時間がかかる
セル値の比較毎行 ToString() / ToLowerInvariant() を呼ぶと割り当てが増えるCPUとGC負荷が増えて検索が伸びる

つまり、改善の方向性は大きく3つです。

  • 二重走査をやめる(先頭ヒットIndexを最初の走査中に確定させる)
  • 比較処理を軽くする(文字列変換・小文字化の回数を減らす)
  • 大量選択そのものを避ける/見せ方を変える(先頭だけ強調、フィルター、ナビゲーション)

最短で効く改善:二重走査をやめて1回のループで完結させる

「全行走査 → SelectedRows走査」という二重走査は、行数が多いほど効いてきます。最も手堅い改善は、最初の走査中に先頭ヒット行のIndexを保持して、2回目のループを消すことです。

VB.NET(Option Strict On対応)1パス実装

Private Sub SelectMatchesAndScrollFirst(dgv As DataGridView, keyword As String)
    If dgv Is Nothing Then Throw New ArgumentNullException(NameOf(dgv))


dgv.ClearSelection()

Dim kw As String = If(keyword, String.Empty).Trim()
If kw.Length = 0 Then Return

Dim firstHit As Integer = -1

' UI更新が重いケースでは、最低限のレイアウト更新に抑える
dgv.SuspendLayout()
Try
    For Each row As DataGridViewRow In dgv.Rows
        ' 末尾の新規行(AllowUserToAddRows=True)を除外
        If row.IsNewRow Then Continue For

        ' 値がNothingのケースに備える
        Dim text As String = Convert.ToString(row.Cells(0).Value)

        ' .NET Frameworkでも使える:IndexOf + OrdinalIgnoreCase
        If (Not String.IsNullOrEmpty(text)) AndAlso
           text.IndexOf(kw, StringComparison.OrdinalIgnoreCase) >= 0 Then

            row.Selected = True

            ' 先頭ヒットを最初の1回で確定
            If firstHit = -1 Then
                firstHit = row.Index
            End If
        End If
    Next
Finally
    dgv.ResumeLayout()
End Try

' スクロールはヒットがあるときだけ
If firstHit >= 0 AndAlso firstHit < dgv.RowCount Then
    ' 行が非表示(Visible=False)だと例外になることがあるため、必要ならVisibleも確認
    If dgv.Rows(firstHit).Visible Then
        dgv.FirstDisplayedScrollingRowIndex = firstHit
    End If

    ' 先頭ヒット行をアクティブにしてユーザーの視線を誘導
    dgv.CurrentCell = dgv.Rows(firstHit).Cells(0)
End If


End Sub

ポイントは次の通りです。

  • SelectedRowsを後から走査しない:先頭ヒットを firstHit に保持して終了
  • 比較はIndexOfで大文字小文字を無視:毎行の ToLowerInvariant() を避ける
  • Null安全Convert.ToString なら Nothing を空文字として扱える
  • IsNewRowを除外:末尾の新規行で無駄な判定や例外原因を作らない
方式走査回数先頭ヒット確定体感速度実装難易度
二重走査(Rows→SelectedRows)2回以上になりがち後段で確定遅くなりやすい
1パス(Rows走査中にfirstHit保持)1回走査中に確定速くなりやすい

実務ではまずこの1パス化を入れるだけで「先頭の選択行を探す部分が重い」問題が解消するケースが多いです。

受理回答の形:LINQで一致行Indexをまとめて取得してから選択・スクロールする

コードを簡潔にしつつ、先頭ヒットIndexを確実に取得したいなら、LINQで一致行の行Indexをリスト化し、それを選択とスクロールに使う方法が定番です。ポイントは「SelectedRowsを後から歩かない」ことと、「比較コストの高い処理を毎行繰り返さない」ことです。

VB.NET(Option Strict On)LINQ版:Castで明示キャストする

DataGridView.Rows はLINQで扱うと型推論が崩れて Object 扱いになりやすく、Option Strict Onだと暗黙変換が禁止されてエラーになります。そこで Cast(Of DataGridViewRow)() を挟んで型を確定させます。

Private Sub SelectByLinqAndScrollFirst(dgv As DataGridView, keyword As String)
    dgv.ClearSelection()

    Dim kw As String = If(keyword, String.Empty).Trim()
    If kw.Length = 0 Then Return

    ' 一致行のIndexをまとめて取得(Option Strict OnならCast必須)
    Dim hitIndexes As List(Of Integer) =
        dgv.Rows.Cast(Of DataGridViewRow)().
            Where(Function(r) Not r.IsNewRow).
            Where(Function(r)
                      Dim text As String = Convert.ToString(r.Cells(0).Value)
                      Return (Not String.IsNullOrEmpty(text)) AndAlso
                             text.IndexOf(kw, StringComparison.OrdinalIgnoreCase) >= 0
                  End Function).
            Select(Function(r) r.Index).
            ToList()

    ' 選択(一致件数が多い場合、ここ自体が重くなる点は後述)
    For Each i In hitIndexes
        dgv.Rows(i).Selected = True
    Next

    ' 先頭ヒットへスクロール
    If hitIndexes.Count > 0 Then
        Dim firstHit As Integer = hitIndexes(0)
        If dgv.Rows(firstHit).Visible Then
            dgv.FirstDisplayedScrollingRowIndex = firstHit
        End If
        dgv.CurrentCell = dgv.Rows(firstHit).Cells(0)
    End If
End Sub

LINQ版のメリットは「一致判定」「Index抽出」「先頭Indexの取得」が一連の流れで書ける点です。処理としては1回の列挙でIndexを集め、最後に選択とスクロールを行います。

観点1パスFor版LINQ版
可読性手続き的で追いやすい短くまとまりやすい
速度最速になりやすい(余計な中間リストがない)中間リスト生成はあるが、二重走査を避ければ十分速いことが多い
Option Strict On型を明示すれば問題なしCast(Of DataGridViewRow)() がないと躓きやすい

Option Strict Onでのつまずきポイント

「Rowsに対してWhereを書いたらコンパイルエラーになる」という状況は、Rows がジェネリックではなく、LINQが IEnumerable として受けてしまい、要素型が Object と解釈されることが原因です。次の表のように、Castで要素型を確定させるのが最短です。

書き方Option Strict On補足
dgv.Rows.Where(Function(r) ...)NGになりやすいrがObject扱いになり、Cellsなどのメンバー参照で詰まる
dgv.Rows.Cast(Of DataGridViewRow)().Where(...)OK要素型がDataGridViewRowに固定される
dgv.Rows.OfType(Of DataGridViewRow)().Where(...)OK型が混在する場合に安全(通常はCastで十分)

比較処理を軽くする:ToLowerInvariant/ToString連発をやめる

行数が増えるほど、比較の前処理が効いてきます。よくある遅い書き方は、毎行こういう処理をしてしまうパターンです。

  • セル値を毎回 ToString()(または暗黙的にToString)
  • 毎回 ToLowerInvariant() で小文字化
  • Contains で部分一致

この形は「文字列の生成」「小文字化で新しい文字列ができる」のが積み重なり、行数が多いとGCの影響も出やすくなります。おすすめは、検索語は1回だけ整形し、比較はIndexOf(StringComparison)で行う形です。

やりがちおすすめ理由
cellText.ToLowerInvariant().Contains(kwLower)cellText.IndexOf(kw, StringComparison.OrdinalIgnoreCase) >= 0小文字化の新規文字列を作らずに比較できる
毎行 keyword.ToLowerInvariant()事前に kw = keyword.Trim() だけ検索語の前処理は1回で十分
row.Cells(0).Value.ToString()Convert.ToString(row.Cells(0).Value)Nothingに強く、Nullチェックが簡潔

.NET 5+ / .NET 6+(新しめのWinForms)の場合

もしプロジェクトが .NET 5+ / .NET 6+ 以降なら、ContainsStringComparison を渡せるため、より読みやすく書けます(ただし、.NET Frameworkではこのオーバーロードは使えないため注意)。

' .NET 5+ の例(.NET Frameworkではコンパイル不可)
If text.Contains(kw, StringComparison.OrdinalIgnoreCase) Then
    ' ...
End If

「先頭へスクロール」は速いのに「大量行の選択」が遅い場合の対処

検索処理そのものを最適化しても、ヒット件数が多いと「大量行の選択」自体が重いことがあります。DataGridViewはUIコントロールなので、選択行が増えるほど描画やイベントが増え、ユーザー体感としては「処理が遅い」に直結します。

このケースでは、要件を少しだけ現実寄りにすると一気に快適になります。

先頭一致だけ選択してスクロールする(最も体感改善が大きい)

「まずは先頭一致へ飛べれば十分」という業務画面は多いです。全件選択をやめるだけで、ほぼ確実に軽くなります。

Private Sub FocusFirstMatchOnly(dgv As DataGridView, keyword As String)
    dgv.ClearSelection()

    Dim kw As String = If(keyword, String.Empty).Trim()
    If kw.Length = 0 Then Return

    Dim firstHit As Integer = -1

    For Each row As DataGridViewRow In dgv.Rows
        If row.IsNewRow Then Continue For
        Dim text As String = Convert.ToString(row.Cells(0).Value)

        If (Not String.IsNullOrEmpty(text)) AndAlso
           text.IndexOf(kw, StringComparison.OrdinalIgnoreCase) >= 0 Then
            firstHit = row.Index
            Exit For
        End If
    Next

    If firstHit >= 0 Then
        dgv.Rows(firstHit).Selected = True
        dgv.CurrentCell = dgv.Rows(firstHit).Cells(0)
        dgv.FirstDisplayedScrollingRowIndex = firstHit
    End If
End Sub

「先頭一致へ移動」だけなら、比較回数も選択回数も最小で済みます。検索ボックスに入力するたびに動かすようなUI(インクリメンタルサーチ)にも向きます。

一致箇所を「次へ」「前へ」で巡回する(大量選択を避けつつ探しやすい)

全件を同時に選択しなくても、ヒット位置をリスト化しておけばユーザーは十分に辿れます。例えば、検索時に一致行Indexを保持しておき、ボタンで順送りするイメージです。

' フォームのフィールドなどに保持
Private _hitIndexes As List(Of Integer) = New List(Of Integer)
Private _hitPointer As Integer = -1

Private Sub BuildHitIndex(dgv As DataGridView, keyword As String)
    Dim kw As String = If(keyword, String.Empty).Trim()
    _hitIndexes =
        dgv.Rows.Cast(Of DataGridViewRow)().
            Where(Function(r) Not r.IsNewRow).
            Where(Function(r)
                      Dim text As String = Convert.ToString(r.Cells(0).Value)
                      Return (Not String.IsNullOrEmpty(text)) AndAlso
                             text.IndexOf(kw, StringComparison.OrdinalIgnoreCase) >= 0
                  End Function).
            Select(Function(r) r.Index).
            ToList()

    _hitPointer = If(_hitIndexes.Count > 0, 0, -1)
End Sub

Private Sub MoveToHit(dgv As DataGridView, pointer As Integer)
    If pointer < 0 OrElse pointer >= _hitIndexes.Count Then Return
    Dim idx As Integer = _hitIndexes(pointer)

    dgv.ClearSelection()
    dgv.Rows(idx).Selected = True
    dgv.CurrentCell = dgv.Rows(idx).Cells(0)
    dgv.FirstDisplayedScrollingRowIndex = idx
End Sub

この方式なら、行数が多くても「選択は常に1行」なので軽く、かつユーザーはヒットを迷わず辿れます。ヒット件数のラベル表示(例:3 / 28)を併用するとさらに親切です。

SelectionChangedなどのイベントが重い場合は「検索中は無視」する

意外と見落とされがちですが、DataGridViewの選択変更イベントで別処理(詳細表示更新、DB参照、ログ出力など)をしていると、行を選ぶたびにその処理が走って一気に遅くなります。検索処理中だけイベント側を早期Returnするだけで改善することがあります。

Private _isSearching As Boolean = False

Private Sub dgv_SelectionChanged(sender As Object, e As EventArgs) Handles dgv.SelectionChanged
    If _isSearching Then Return

    ' ここに重い処理があると、複数行選択で地獄になりがち
    ' 例:詳細パネルの更新、別クエリ発行など
End Sub

Private Sub SearchWithEventGuard()
    _isSearching = True
    Try
        SelectMatchesAndScrollFirst(dgv, txtKeyword.Text)
    Finally
        _isSearching = False
    End Try
End Sub

「検索処理は速いはずなのに固まる」という場合、UIイベントがボトルネックになっていることがよくあります。

データソース側で検索・フィルターする(大量データなら最も効く)

DataGridViewのセルを直接なめて比較するのは、あくまで「表示のためのUI」を使って検索している状態です。行数が多いなら、データソース側で検索したほうが合理的です。

データソース向いている手法メリット注意点
DataTable / DataViewRowFilter / BindingSource.Filter表示行自体を絞れるので選択・スクロールも軽いFilter文字列のエスケープが必要
BindingList(Of T)検索用の前処理(小文字キー保持)+LINQ検索比較コストを下げられるフィルター機能は標準で弱い(別リストへ差し替え等が必要)
DB(件数が非常に多い)SQL側でLIKE検索して結果だけ表示UIに大量データを持ち込まない検索体験(インクリメンタル等)は設計が必要

DataTable + BindingSourceで「表示を絞る」例

DataGridViewが BindingSource 経由で DataTable にバインドされているなら、Filterで表示行を絞るのが強力です。ヒット行が数十件に減れば、選択やスクロールも一気に軽くなります。

Private Sub ApplyFilterByKeyword(bs As BindingSource, keyword As String)
    Dim kw As String = If(keyword, String.Empty).Trim()

    If kw.Length = 0 Then
        bs.RemoveFilter()
        Return
    End If

    ' Filter/RowFilterはシングルクォートがエスケープ必要
    Dim safe As String = kw.Replace("'", "''")

    ' 例:列名が Code の場合(部分一致)
    bs.Filter = String.Format("Code LIKE '%{0}%'", safe)
End Sub

この方式なら、スクロールは「先頭行(Index 0)」へ行くだけで済みます。さらに、ユーザーも「検索結果だけが表示される」ため迷いにくく、UIとして自然です。

前処理で比較コストを下げる(BindingList(Of T)など)

オブジェクトリストをバインドしている場合は、表示用プロパティとは別に検索用の正規化済みキーを用意すると効きます。例えば、0列目に表示している値の小文字化を都度行うのではなく、モデル側に保持しておきます。

' 例:表示対象のモデル
Public Class ItemRow
    Public Property Code As String

    ' 検索用に正規化した値を保持(初期化時・更新時にだけ作る)
    Public ReadOnly Property CodeKey As String
        Get
            Return If(Code, String.Empty).ToLowerInvariant()
        End Get
    End Property
End Class

検索時は CodeKey を使えば、毎行の ToLowerInvariant() を避けられます。特に「同じデータに対して何度も検索する」画面では差が出ます。

例外・落とし穴:Null、範囲外、非表示行、そして“イベント地獄”

高速化と同じくらい重要なのが、ハマりやすい例外や仕様の把握です。次のような点を押さえておくと、保守が楽になります。

症状原因対策
NullReferenceExceptionが出るCells(0).Value がNothingConvert.ToString を使う/Nullチェックを入れる
FirstDisplayedScrollingRowIndexで例外ヒットなし/範囲外Index/非表示行ヒット時のみ設定し、0~RowCount-1Visible を確認
末尾の空行が引っかかるAllowUserToAddRowsの新規行row.IsNewRow をスキップ
検索のたびに画面が固まる大量行選択+SelectionChanged等で重い処理検索中はイベント処理を抑止/先頭一致だけ選択/フィルターに寄せる
ソート後に“期待した行”に行けない表示順とデータ順の混同表示に合わせるならrow.Index、データ側ならDataBoundItemを基準に考える

特に「SelectionChangedで別処理をしている」ケースは盲点になりやすいので、処理が遅いときはイベントハンドラも含めて疑うのが近道です。

実務でのおすすめパターンまとめ

最後に、要件別に「どの手法を選ぶべきか」を整理します。迷ったら、まずは1パス化(SelectedRows二重走査の排除)を入れ、それでも辛ければ「大量選択をやめる」か「データソース側へ寄せる」の順で検討すると失敗しにくいです。

やりたいことおすすめ理由
一致行を全部選択し、先頭へスクロール1パスFor版(firstHit保持)最小の変更で効果が出やすい
コードを短く読みやすくしたいLINQでIndexリスト化(Cast必須)先頭ヒットの取得が明確で、SelectedRows走査が不要
ヒット件数が多くてUIが重い先頭一致だけ選択/次へ前へ方式選択行数を抑えると体感が劇的に改善
データが多すぎて根本的に遅いデータソース側で検索・フィルターUIに重い処理を背負わせない設計にできる

DataGridViewは便利ですが、「大量データの検索」は設計次第でいくらでも重くなります。二重走査の排除と比較処理の最適化は即効性が高く、さらに必要なら“選択の見せ方”や“検索の場所”を変えることで、行数が増えても快適な検索体験を作れます。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次