C#

【C#】ListViewの仮想化 ソート処理 追加メモ

ListViewで大量データを扱った場合に処理が遅くなってしまったので、仮想化を試してみました。

しかし、処理速度がかなり早くなったと喜んでみたものの、ソートをしようとカラムヘッダをクリックしたら異常終了。調べてみたところ、ListViewの仮想化をするとソート処理ができなくなるらしいです・・・。

とりあえず、ListView仮想化時のソート処理を頑張って実装したので、メモとして残しておきます。

プログラム作成

ListView仮想化時のソート処理を作成していきます。

実装内容

実装したい処理は、以下になります。

  • ListViewのカラムヘッダをクリックして、ソートを行う。
  • ソート処理は、カラムヘッダのクリックイベントから呼び出す。
  • 各カラムごとに、文字列、数値、日時、自然検索から選択したソートが行える。

ソートクラス

ソートクラスには、クリックされたカラム(縦列)のデータをソートする処理を実装します。

ソート比較条件は、以下になります。

  • 文字列比較
  • 整数比較
  • 日時比較
  • 自然検索

基本的には、文字列を変換しながら比較する感じです。

public class ListViewSort
{
	/// <summary>
	/// 2 つの Unicode 文字列を比較
	/// </summary>
	/// <param name="x"></param>
	/// <param name="y"></param>
	/// <returns></returns>
	[System.Runtime.InteropServices.DllImport("shlwapi.dll",
	CharSet = System.Runtime.InteropServices.CharSet.Unicode,
	ExactSpelling = true)]
	private static extern int StrCmpLogicalW(string x, string y);

	private int lastSortCol = 0;							// 前回ソートカラム
	private SortOrder lastSortOrder = SortOrder.Ascending;	// 前回ソート向き
	private List<CompareType>? lstType = null;				// 比較種類リスト


	/// <summary>
	/// 比較種類
	/// </summary>
	public enum CompareType
	{
		typeStr,        // 文字列
		typeInt,        // 整数
		typeDateTime,   // 日時
		typeNature      // 自然順
	}

	/// <summary>
	/// 比較種類追加(カラムごと)
	/// </summary>
	/// <param name="type">比較種類</param>
	public void addCompareType(CompareType type)
	{
		if (lstType == null)
		{
			lstType = new List<CompareType>();
		}

		lstType.Add(type);
	}

	/// <summary>
	/// リストソート
	/// </summary>
	/// <param name="tagList">ListView仮想化 リストデータ</param>
	/// <param name="col">選択カラム番号</param>
	/// <returns></returns>
	public List<ListViewItem> SortList(List<ListViewItem> tagList, int col)
	{
		if (tagList == null)
		{
			return new List<ListViewItem>();
		}

		// 前回ソートカラムと同じカラムが選択された場合に、ソート向きを切り替える
		if (lastSortCol == col)
		{
			if (lastSortOrder == SortOrder.Ascending)
			{
				lastSortOrder = SortOrder.Descending;
			}
			else if (lastSortOrder == SortOrder.Descending)
			{
				lastSortOrder = SortOrder.Ascending;
			}
		}

		lastSortCol = col;

		if (lstType != null && lstType.Count > col)
		{
			switch (lstType[col])
			{
				case CompareType.typeStr:       // 文字列
					tagList.Sort((a, b) => CompareStr(a, b, col, lastSortOrder));
					break;
				case CompareType.typeInt:       // 数値
					tagList.Sort((a, b) => CompareImt(a, b, col, lastSortOrder));
					break;
				case CompareType.typeDateTime:  // 日時
					tagList.Sort((a, b) => CompareDateTime(a, b, col, lastSortOrder));
					break;
				case CompareType.typeNature:    // 自然検索
					tagList.Sort((a, b) => CompareNature(a, b, col, lastSortOrder));
					break;
				default:
					break;
			}
		}

		return tagList;
	}


	/// <summary>
	/// 文字列ソート
	/// </summary>
	/// <param name="x">比較データ</param>
	/// <param name="y">比較データ</param>
	/// <param name="col">選択カラム番号</param
	/// <param name="order">ソート向き</param>
	/// <returns></returns>
	private int CompareStr(object x, object y, int col, SortOrder order)
	{
		ListViewItem a = (ListViewItem)x;
		ListViewItem b = (ListViewItem)y;

		int ret = a.SubItems[col].Text.CompareTo(b.SubItems[col].Text);

		if (order == SortOrder.Descending)
		{
			ret = -ret;
		}

		return ret;
	}

	/// <summary>
	/// 数値ソート
	/// </summary>
	/// <param name="x">比較データ</param>
	/// <param name="y">比較データ</param>
	/// <param name="col">選択カラム番号</param
	/// <param name="order">ソート向き</param>
	/// <returns></returns>
	private int CompareImt(object x, object y, int col, SortOrder order)
	{
		ListViewItem a = (ListViewItem)x;
		ListViewItem b = (ListViewItem)y;

		Int32.TryParse(a.SubItems[col].Text, out int numA);
		Int32.TryParse(b.SubItems[col].Text, out int numB);

		int ret = numA.CompareTo(numB);

		if (order == SortOrder.Descending)
		{
			ret = -ret;
		}

		return ret;
	}

	/// <summary>
	/// 日時ソート
	/// </summary>
	/// <param name="x">比較データ</param>
	/// <param name="y">比較データ</param>
	/// <param name="col">選択カラム番号</param
	/// <param name="order">ソート向き</param>
	/// <returns></returns>
	private int CompareDateTime(object x, object y, int col, SortOrder order)
	{
		ListViewItem a = (ListViewItem)x;
		ListViewItem b = (ListViewItem)y;

		DateTime.TryParse(a.SubItems[col].Text, out DateTime dateA);
		DateTime.TryParse(b.SubItems[col].Text, out DateTime dateB);

		int ret = dateA.CompareTo(dateB);

		if (order == SortOrder.Descending)
		{
			ret = -ret;
		}

		return ret;
	}

	/// <summary>
	/// 自然ソート
	/// </summary>
	/// <param name="x"></param>
	/// <param name="y"></param>
	/// <param name="col"></param
	/// <param name="order"></param>
	/// <returns></returns>
	private int CompareNature(object x, object y, int col, SortOrder order)
	{
		ListViewItem a = (ListViewItem)x;
		ListViewItem b = (ListViewItem)y;

		int ret = StrCmpLogicalW(a.SubItems[col].Text,
					b.SubItems[col].Text);

		if (order == SortOrder.Descending)
		{
			ret = -ret;
		}

		return ret;
	}
}

簡単にプログラムの説明を記載します。

9~12行目で、自然検索を行う「StrCmpLogicalW()」関数を使用するために、DLLからの読み込みを行っています。

22~28行目で、今回作成する検索条件を enum で指定しています。小数比較など行いたい場合は、ここに条件を追加してください。

ここから関数ごとの説明を記載していきます。

addCompareType() 関数

【引数】

  • CompareType type:比較種類(enum)

【戻り値】

  • なし

カラムごとのソート比較条件を追加する関数になります。

第1引数のtypeをリストに追加するだけですが、カラム順に追加する必要があります。

一番最初に追加した条件が、一番目のカラム(ListViewの左端)のソート条件になります。

SortList() 関数

【引数】

  • List tagList:ListView仮想化 リストデータ
  • int col:選択カラム番号

【戻り値】

  • List<ListViewItem>:ListView表示データ

実際にソート処理を行う関数です。

現在ListViewに表示しているデータをソートして、戻してあげる処理になります。

58~68行目で、選択されたカラムが前回選択されたものと同じ場合は、ソートの昇順と降順を入れ替える処理を行っています。

72~91行目で、実際にソートする比較条件ごとの関数の呼び出しを行っています。

Listのソート処理をラムダ式で記述しています。

よく見るラムダ式の書き方は、以下になります。リストデータを昇順にソートする場合の記述になります。

list.Sort((a, b) => a – b);

ラムダ式を作成するには、ラムダ演算子の左辺に入力パラメーターを指定し、右辺に式を指定します(Microsoftより)。

今回は、左辺の引数がListViewのアイテム(object型)になっていて、右辺の式が関数になっています。

CompareStr() 関数

【引数】

  • object x:比較データ
  • object y:比較データ
  • int col:選択カラム番号
  • SortOrder order:ソート向き(昇順 or 降順)

【戻り値】

  • int:比較結果

文字列比較を行っています。ラムダ式右辺の式にあたる処理になります。

以下の関数を使用して、比較結果を戻します。

a.Compare(b);

【戻り値】

  • 0より小さい値 :a の位置が b よりも前です(マイナスの値)。
  • 0 : a の位置が、並べ替え順序において b と同じです。
  • 0 より大きい値 :a の位置が b よりも後ろです。または、b が null です。

110行目で比較処理を行っています。

112行目~115行目で降順にソートするために、110行目の比較結果を反転しています。結果がマイナスの場合はプラスに、プラスの場合はマイナスにします。

CompareImt() 関数

CompareStr() 関数と処理内容は、ほぼ同じです。

異なるのは、133~134行目で比較対象の値をInt型に変換してから、136行目で比較を行っていることです。

CompareDateTime() 関数

CompareStr() 関数と処理内容は、ほぼ同じです。

異なるのは、159~160行目で比較対象の値をDateTime型に変換してから、162行目で比較を行っていることです。

CompareNature() 関数

CompareStr() 関数と処理内容は、ほぼ同じです。

異なるのは、185行目で StrCmpLogicalW() 関数を呼び出していることです。

使用方法

作成したクラスを実際に使用してみます。

最初に、Windowsフォームアプリで、以下の画面を作成します。

フォーム

画面には、以下の部品を配置しています。

  • ListView : listView1

以下のプログラムを記述します。

public partial class Form1 : Form
{
	// リストビューデータ
	private List<ListViewItem> listItem = new List<ListViewItem>();

	// リストソートクラス
	private ListViewSort lvSort;

	// テストデータ配列
	private string[,] aryTestData = new string[10, 4] {
		{ "a001", "100", "2022/01/02 01:02:03", "2"},
		{ "a002", "50", "2022/11/03 01:02:03", "10"},
		{ "b001", "111", "2022/01/01 01:02:03", "21"},
		{ "b002", "33", "2022/03/03 01:02:03", "3"},
		{ "b003", "a2", "2021/01/01 01:02:03", "30"},
		{ "c001", "3", "2022/02/22 01:02:03", "111"},
		{ "c002", "50", "2023/01/01 01:02:03", "1"},
		{ "d001", "11", "2020/03/21 01:02:03", "22"},
		{ "e002", "1", "2022/12/12 01:02:03", "01"},
		{ "f003", "99", "2022/01/01 10:10:10", "05"},
	};

	/// <summary>
	/// コンストラクタ
	/// </summary>
	public Form1()
	{
		InitializeComponent();
	}

	/// <summary>
	/// フォームロード
	/// </summary>
	/// <param name="sender"></param>
	/// <param name="e"></param>
	private void Form1_Load(object sender, EventArgs e)
	{
		// リストビューのヘッダー設定
		listView1.View = View.Details;
		listView1.Clear();
		listView1.Columns.Add("文字列", 100, HorizontalAlignment.Left);
		listView1.Columns.Add("数値", 100, HorizontalAlignment.Right);
		listView1.Columns.Add("日時", 200, HorizontalAlignment.Right);
		listView1.Columns.Add("自然検索", 100, HorizontalAlignment.Left);

		// リストビューデータ表示イベント設定
		listView1.RetrieveVirtualItem += new RetrieveVirtualItemEventHandler(listView1_RetrieveVirtualItem);

		// 仮想モード オン
		listView1.VirtualMode = true;
		listView1.VirtualListSize = 0;

		// テストデータ追加
		for (int i = 0; i < 10; i++)
		{
			ListViewItem item = new ListViewItem(aryTestData[i, 0]);

			// 各列の内容を設定
			item.SubItems.Add(aryTestData[i, 1]);
			item.SubItems.Add(aryTestData[i, 2]);
			item.SubItems.Add(aryTestData[i, 3]);

			listItem.Add(item);
		}

		listView1.VirtualListSize = listItem.Count;

		// リストビュー カラムクリックイベント設定
		this.listView1.ColumnClick += new ColumnClickEventHandler(ColumnClick);

		// リストソートクラス設定
		lvSort = new ListViewSort();
		lvSort.addCompareType(ListViewSort.CompareType.typeStr);        // 1カラム目 文字列ソート
		lvSort.addCompareType(ListViewSort.CompareType.typeInt);        // 2カラム目 数値ソート
		lvSort.addCompareType(ListViewSort.CompareType.typeDateTime);   // 3カラム目 日時ソート
		lvSort.addCompareType(ListViewSort.CompareType.typeNature);     // 4カラム目 自然検索
	}

	/// <summary>
	/// リストビューデータ表示(仮想)
	/// </summary>
	/// <param name="sender"></param>
	/// <param name="e"></param>
	private void listView1_RetrieveVirtualItem(object? sender, RetrieveVirtualItemEventArgs e)
	{
		if (e.ItemIndex < listItem.Count)
		{
			if (e.Item == null)
			{
				e.Item = new ListViewItem();
			}

			e.Item = listItem[e.ItemIndex];
		}
	}

	/// <summary>
	/// リストビューカラムクリックイベント
	/// </summary>
	/// <param name="o"></param>
	/// <param name="e"></param>
	private void ColumnClick(object? o, ColumnClickEventArgs e)
	{
		SortManager(e.Column);
	}

	/// <summary>
	/// ソート管理
	/// </summary>
	/// <param name="col"></param>
	private void SortManager(int col)
	{
		// リストソート
		listItem = lvSort.SortList(listItem, col);

		// リフレッシュ
		listView1.Refresh();
	}
}

簡単にプログラムの説明を記載します。

36~77行目のフォームロードで、ListViewの初期設定とデータ設定を行っています。

39~44行目で、ListViewのヘッダカラムの設定を行っています。

47~51行目で、ListViewを仮想化するための設定を行っています。

54~66行目で、ListViewにテストデータの設定を行っています。

69行目で、ListViewのヘッダカラムをクリックした際のイベント設定を行っています。

72~76行目で、ListViewSortクラスの設定を行っています。

72行目でインスタンスを作成して、73~76行目のaddCompareType()でソートの比較種類を追加しています。ListViewの1番左端のカラムから追加しています。

84~95行目は、ListView仮想化時のデータ表示処理です。

102~105行目のカラムヘッダをクリックした際のイベントでは、SortManager()関数を呼び出しています。引数には、クリックしたカラムヘッダの番号を渡しています。

111~118行目の SortManager()関数では、作成したListViewSortクラスのソート処理「SortList()」を呼び出しています。

カラムヘッダがクリックしたらListViewSortクラスのソート処理を呼び出して、ListViewをリフレッシュしている感じです。

実行結果

上記で作成したプログラムを実行してみます。

テストデータがListViewに表示されました。仮想化が正常に動作しているようです。

それでは、ソートの確認をしてみます。

まず最初に、文字列カラムのヘッダをクリックしてみます。

文字列カラムのデータが降順にソートされました。もう一度クリックすると昇順に戻ります。正常に動作したようです。

次に数値カラムのヘッダをクリックしてみます。

数値で昇順にソートされました。文字列を数値に変換してから比較できているようです。

次に日時カラムのヘッダをクリックしてみます。

日時で昇順にソートされました。日時データに変換してから比較できているようですが、文字列比較でも同じ結果になりそうな気もします。

続いて自然検索カラムのヘッダをクリックしてみます。

「1」の次に「11」が並ばず、ちゃんと自然な感じで昇順ソートされています。このソート方式は、ファイル名のソートなどで便利かもしれません。

全体的に、ちゃんとソートできたみたいです。ListView仮想化時のソートがとりあえず動作するようになりましたが、この方法が正しいのかは良くわかりません。

他の方法もありそうな気もします。

とりあえず機能を満たせたようなので、よかったです。