ListViewの動作が遅い原因を探ってみたところ、オーナードローでの描画にサムネイル画像の用意が間に合っていないことが原因でした。
VirtualModeで表示部分だけの描画される動作も、そもそも描画の準備が間に合っていないためスクロールするたびに待ちが発生している状態でした。
また、VirtualModeで描画されるアイテムの動作も先頭から数件描画し、その後なぜか表示されていない末尾の数件を処理し、再度先頭から再開するような不思議な動作をしているようです。
機能としてのオーナードローを捨てるとして、リストビューの更新とサムネイルの描画を別スレッドで実行し、さらにサムネイル作成を並列処理を行うことで少しの高速化と、UIの操作を妨げることないようにしたいと思います。
成功事例を探したところズバリの記事を見つけました。
作者の方に感謝しながら、ありがたく利用させていただきと思います。
サムネイル画像が大きいのでプレビューは廃止しました。
サムネイル画像のキャッシュ機能が、並列処理を組み込んだおかげで、SQLiteの排他処理とZIPファイルとの整合性で手に余るようになったので、さらに簡易なZIPファイルのみ仕様にしました。
ZIPファイルにサムネイル画像が追加され続けますので無制限にファイルサイズが肥大化します。
プロジェクトの作成
dotnet new winforms --name ImageList2 -f net8.0
cd ImageList2
dotnet add package System.Drawing.Common
dotnet add package Magick.NET.Core
dotnet add package Magick.NET.SystemDrawing
dotnet add package Magick.NET-Q8-AnyCPU
dotnet add package OpenCvSharp4
dotnet add package OpenCvSharp4.Extensions
dotnet add package OpenCvSharp4.runtime.win
ソースコード
FileListViewEntity.cs
namespace ImageList2;
public class FileListViewEntity
{
public string FullPath {get; set;} = "";
public string Name
{
get
{
return Path.GetFileName(FullPath);
}
}
}
FileSystemManager.cs
using System.Diagnostics;
namespace ImageList2;
public class FileSystemManager
{
public string CurrentDirectory = @"";
public string FavoritesPath = @".\favorites.txt";
readonly List<string> favoritesList = [];
public FileSystemManager()
{
CurrentDirectory = Directory.GetCurrentDirectory();
LoadFavoriteFile();
}
void LoadFavoriteFile()
{
if (File.Exists(FavoritesPath) == false) return;
using var sr = new StreamReader(FavoritesPath);
while (sr.Peek() != -1)
{
var path = sr.ReadLine();
if (Directory.Exists(path))
{
favoritesList.Add(path);
}
}
CurrentDirectory = favoritesList[^1];
favoritesList.Remove(CurrentDirectory);
}
public void SaveFavoriteFile()
{
using var sw = new StreamWriter(FavoritesPath, false);
foreach(var path in favoritesList)
{
sw.WriteLine(path);
}
sw.WriteLine(CurrentDirectory);
}
static public bool CheckDirectoryPath(string path)
{
return Directory.Exists(path);
}
/// <summary>
/// ドライブの一覧を返す
/// </summary>
/// <returns>ドライブとカレントディレクトリの階層リスト</returns>
public List<string> GetDriveListAndBreadcrumb()
{
List<string> result = new();
string currentRoot = Path.GetPathRoot(CurrentDirectory) ?? "";
foreach(var drive in Directory.GetLogicalDrives())
{
result.Add(drive);
if (drive.Equals(currentRoot, StringComparison.CurrentCultureIgnoreCase))
{
string tmp = CurrentDirectory;
Stack<string> tmps = new();
while(!currentRoot.Equals(tmp, StringComparison.CurrentCultureIgnoreCase))
{
tmps.Push(tmp);
tmp = Path.GetDirectoryName(tmp) ?? currentRoot;
}
result.AddRange(tmps);
}
}
result.AddRange(favoritesList);
return result;
}
// カレントディレクトリのファイルの一覧を取得
public List<FileListViewEntity> GetCurrentDirectoryFileList()
{
List<FileListViewEntity> list = new();
foreach (var directory in Directory.GetDirectories(CurrentDirectory))
{
var info = new FileInfo(directory);
if (info is null) continue;
if ((info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden) continue;
if ((info.Attributes & FileAttributes.System) == FileAttributes.System) continue;
var e = new FileListViewEntity()
{
FullPath = info.FullName,
};
list.Add(e);
}
foreach (var file in Directory.GetFiles(CurrentDirectory))
{
var info = new FileInfo(file);
if (info is null) continue;
if ((info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden) continue;
if ((info.Attributes & FileAttributes.System) == FileAttributes.System) continue;
var e = new FileListViewEntity()
{
FullPath = info.FullName,
};
list.Add(e);
}
/*
foreach(var path in Directory.EnumerateFileSystemEntries(CurrentDirectory))
{
//var fullPath = Path.GetFullPath(path);
//if (fullPath is null) continue;
var e = new FileListViewEntity()
{
FullPath = path,
};
list.Add(e);
}
*/
return list;
}
// お気に入りに登録されているか
public bool IsFavorite()
{
var path = CurrentDirectory;
return (favoritesList.IndexOf(path) >= 0);
}
// お気に入りに追加
public void AddFavorite()
{
if (IsFavorite()) return;
var path = CurrentDirectory;
favoritesList.Add(path);
}
// お気に入りから削除
public void RemoveFavorite()
{
if (IsFavorite() == false) return;
var path = CurrentDirectory;
favoritesList.Remove(path);
}
}
Form1.AddressBar.cs
using System.ComponentModel;
using System.Xml.Schema;
namespace ImageList2;
partial class Form1
{
// アドレスバーの更新
void UpdateAddressBar()
{
addressBar.BeginUpdate();
addressBar.Items.Clear();
foreach(var f in fsManager.GetDriveListAndBreadcrumb())
{
addressBar.Items.Add(f);
}
int i = addressBar.Items.IndexOf(fsManager.CurrentDirectory);
addressBar.SelectedIndex = i;
addressBar.EndUpdate();
}
// 選択されているIndexが変更された
void AddressBar_SelectedIndexChanged(object? sender, EventArgs e)
{
if (addressBar.SelectedItem is null) return;
if (addressBar.SelectedIndex < 0) return;
// 選択された項目からディレクトリを取り出し
var dir = addressBar.SelectedItem.ToString() ?? "";
// イベントの無限ループ防止
if (dir == "" || fsManager.CurrentDirectory == dir ) return;
// カレントディレクトリ変更
fsManager.CurrentDirectory = dir;
// コンボボックスの更新
UpdateAddressBar();
// ファイルの一覧を更新
UpdateFileListView();
}
// バリデーション
void AddressBar_Validating(object? sender, CancelEventArgs e)
{
var dir = addressBar.Text;
if (!FileSystemManager.CheckDirectoryPath(dir))
{
MessageBox.Show($"{dir}は無効なパス。", "えらー");
addressBar.Text = fsManager.CurrentDirectory;
e.Cancel = true;
return;
}
}
// バリデーション完了
void AddressBar_Validated (object? sender, EventArgs e)
{
fsManager.CurrentDirectory = addressBar.Text;
UpdateAddressBar();
// ファイルの一覧を更新
UpdateFileListView();
}
} // class
Form1.cs
using System.Diagnostics;
namespace ImageList2;
public partial class Form1 : Form
{
FileSystemManager fsManager = new();
public Form1()
{
InitializeComponent();
this.Load += Form1_Load;
this.FormClosing += Form1_FormClosing;
addFavoriteMenuItem.Click += AddFavoriteMenuItem_Click;
removeFavoriteMenuItem.Click += RemoveFavoriteMenuItem_Click;
reloadMenuItem.Click += ReloadMenuItem_Click;
addressBar.SelectedIndexChanged += AddressBar_SelectedIndexChanged;
addressBar.Validating += AddressBar_Validating;
addressBar.Validated += AddressBar_Validated;
fileListView.DoubleClick += FileListView_DoubleClick;
//fileListView.SelectedIndexChanged += FileListView_SelectedIndexChanged;
fileListView.MouseDown += FileListView_MouseDown;
fileListView.RetrieveVirtualItem += FileListView_RetrieveVirtualItem;
//fileListView.DrawItem += fileListView_DrawItem;
// サムネイル保存用zipファイルを開く
ThumnailArchiverAlone.Open();
}
// フォームLoadイベントハンドラ
void Form1_Load(object? sender, EventArgs e)
{
// ファイル一覧の初期化
InitializeFileListView();
var strip = new MenuStrip();
strip.Items.AddRange(new ToolStripItem[]
{
addFavoriteMenuItem,
removeFavoriteMenuItem,
reloadMenuItem,
});
toolBar.Items.Add(addressBar);
//mainPanel.Panel1.Controls.Add(fileListView);
//mainPanel.Panel2.Controls.Add(preViewPicBox);
this.Controls.AddRange(new Control[]
{
fileListView,
//mainPanel,
toolBar,
strip,
});
this.MainMenuStrip = strip;
// アドレスバーの更新
UpdateAddressBar();
// ファイルの一覧を更新
UpdateFileListView();
}
// フォームが閉じられるイベント
void Form1_FormClosing(object? sender, EventArgs e)
{
fsManager.SaveFavoriteFile();
// キャンセル
cancellationTokenSource?.Cancel();
ThumnailArchiverAlone.Close();
}
}
Form1.Designer.cs
namespace ImageList2;
partial class Form1
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
int h = System.Windows.Forms.Screen.PrimaryScreen.Bounds.Height - 180;
this.components = new System.ComponentModel.Container();
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(800, h);
this.Text = "Form1";
}
#endregion
ToolStripMenuItem addFavoriteMenuItem = new()
{
Text = "追加",
};
ToolStripMenuItem removeFavoriteMenuItem = new()
{
Text = "削除",
};
ToolStripMenuItem reloadMenuItem = new()
{
Text = "更新",
};
// ツールバー
ToolStrip toolBar = new()
{
Dock = DockStyle.Top,
};
// アドレスバー
ToolStripComboBox addressBar = new()
{
BackColor = Color.AliceBlue,
Width = 600,
Height = 45,
};
// ファイルの一覧
ListView fileListView = new()
{
Dock = DockStyle.Fill,
View = View.LargeIcon,
VirtualMode = true,
VirtualListSize = 0,
};
/*
// プレビュー画像
PictureBox preViewPicBox = new()
{
SizeMode = PictureBoxSizeMode.Zoom,
Dock = DockStyle.Fill,
};
// メインパネル
SplitContainer mainPanel = new()
{
Dock = DockStyle.Fill,
Orientation = Orientation.Horizontal,
};
*/
}
Form1.fileListView.cs
using System.Diagnostics;
using System.Dynamic;
using System.Reflection.Metadata.Ecma335;
using System.Windows.Forms.Design;
using OpenCvSharp.ImgHash;
namespace ImageList2;
partial class Form1
{
static Size largeIconSize = new(384, 384);
readonly ImageList largeImageList = new()
{
ImageSize = largeIconSize,
};
readonly List<ListViewItem> listViewItemList = [];
CancellationTokenSource? cancellationTokenSource;
// ファイルの一覧を初期化
void InitializeFileListView()
{
fileListView.LargeImageList = largeImageList;
// 項目の追加
fileListView.Columns.Add("名前", 360, HorizontalAlignment.Left);
}
// ファイルの一覧を更新
void UpdateFileListView()
{
// キャンセル
cancellationTokenSource?.Cancel();
// お気に入り
if (fsManager.IsFavorite())
{
addFavoriteMenuItem.Enabled = false;
removeFavoriteMenuItem.Enabled = true;
}
else
{
addFavoriteMenuItem.Enabled = true;
removeFavoriteMenuItem.Enabled = false;
}
// リロードボタン
reloadMenuItem.Enabled = false;
fileListView.BeginUpdate();
fileListView.Items.Clear();
fileListView.EndUpdate();
listViewItemList.Clear();
largeImageList.Images.Clear();
foreach(var e in fsManager.GetCurrentDirectoryFileList())
{
ListViewItem item = new()
{
Text = e.Name,
Tag = new FileInfo(e.FullPath)
};
listViewItemList.Add(item);
}
fileListView.VirtualListSize = listViewItemList.Count;
cancellationTokenSource = new();
var token = cancellationTokenSource.Token;
var _ = Task.Run(()=>ReadImages(token), token);
}
// Parallel.Forによる画像の読み込み
void ReadImages(CancellationToken token)
{
Stopwatch sw = new();
sw.Start();
ParallelOptions options = new()
{
CancellationToken = token,
MaxDegreeOfParallelism = Environment.ProcessorCount,
};
try
{
Parallel.For(0, listViewItemList.Count, options, index=>
{
ReadImage(index, token);
});
}
catch (Exception ex)
{
Debug.WriteLine($"ReadImages:{ex}");
}
sw.Stop();
Debug.Print($"ReadImages:{sw.ElapsedMilliseconds}ms");
// リロード
Invoke((Action)(() =>
{
reloadMenuItem.Enabled = true;
}));
// キャッシュ更新
//var _ = Task.Run(()=>ThumnailManager.AddThumbnail(listViewItemList, largeImageList, token), token);
}
// 画像の読み込み
void ReadImage(int index, CancellationToken token)
{
if (token.IsCancellationRequested) return;
if (this.Created == false) return;
try
{
var info = listViewItemList[index].Tag as FileInfo;
Image? image;
if (info is not null)
{
//image = ThumnailManager.GetThumnail(info.FullName, token);
string entryName = ThumnailArchiverAlone.CreateEntryNameFromFileInfo(info);
//Debug.Print(entryName);
image = ThumnailArchiverAlone.GetImageFromZip(entryName, token);
if (image is null)
{
image = GraphicHelper.LoadFileIcon(info.FullName, largeIconSize.Width, largeIconSize.Height);
if (image is not null)
{
ThumnailArchiverAlone.AddImageToZip(image, entryName, token);
}
}
}
else
{
image = new Bitmap(largeIconSize.Width, largeIconSize.Height);
}
Invoke((Action<int, Image>)((idx, img)=>
{
largeImageList.Images.Add(img);
img.Dispose();
int i = largeImageList.Images.Count - 1;
listViewItemList[idx].ImageIndex = i;
var rect = fileListView.ClientRectangle;
if (rect.IntersectsWith(listViewItemList[idx].Bounds))
{
fileListView.RedrawItems(idx, idx, true);
}
}), index, image);
}
catch (Exception ex)
{
Debug.WriteLine($"ReadImage:{ex}");
}
}
// ファイルの一覧のダブルクリックイベントハンドラ
void FileListView_DoubleClick(Object? sender, EventArgs e)
{
var items = fileListView.SelectedIndices;
if (items.Count <= 0) return;
int i = items[0];
var newPath = Path.Join(fsManager.CurrentDirectory, listViewItemList[i].Text);
if (Directory.Exists(newPath) && newPath != fsManager.CurrentDirectory)
{
// ここにオブザーバーが欲しい
fsManager.CurrentDirectory = newPath;
// アドレスバーの更新
UpdateAddressBar();
// ファイルの一覧を更新
UpdateFileListView();
}
}
void FileListView_RetrieveVirtualItem(Object? sender, RetrieveVirtualItemEventArgs e)
{
e.Item ??= listViewItemList[e.ItemIndex];
}
/*
// ファイル一覧の選択インデックスが変更された
void FileListView_SelectedIndexChanged(Object? sender, EventArgs e)
{
var items = fileListView.SelectedIndices;
if (items.Count <= 0) return;
int i = items[0];
var filePath = Path.Join(fsManager.CurrentDirectory, listViewItemList[i].Text);
if (!File.Exists(filePath)) return;
preViewPicBox.Image?.Dispose();
preViewPicBox.Image = GraphicHelper.LoadImageFile(filePath);
}
*/
void FileListView_MouseDown(object? sender, MouseEventArgs e)
{
if (sender is null) return;
if (e.Button != MouseButtons.Left) return;
var lv = (ListView)sender;
var result = lv.GetItemAt(e.X, e.Y);
if (result is null) return;
string path = Path.Join(fsManager.CurrentDirectory, result.Text);
if (Directory.Exists(path)) return;
var effect = DragDropEffects.Copy;
string[] paths = [path];
IDataObject data = new DataObject(DataFormats.FileDrop, paths);
lv.DoDragDrop(data, effect);
}
}
Form1.Menu.cs
using System.ComponentModel;
using System.Xml.Schema;
namespace ImageList2;
partial class Form1
{
// 追加
void AddFavoriteMenuItem_Click(object? sender, EventArgs e)
{
fsManager.AddFavorite();
UpdateAddressBar();
}
// 削除
void RemoveFavoriteMenuItem_Click(object? sender, EventArgs e)
{
fsManager.RemoveFavorite();
UpdateAddressBar();
}
void ReloadMenuItem_Click(object? sender, EventArgs e)
{
UpdateFileListView();
}
} // class
GraphicHelper.cs
using ImageMagick;
using OpenCvSharp;
using OpenCvSharp.Extensions;
namespace ImageList2;
public static class GraphicHelper
{
static readonly List<string> filenameExtentions =
[
".PNG",
".JPG",
".JPEG",
".JPE",
".JFIF",
".GIF",
".EMF",
".WMF",
".ICO",
".BMP",
".DIB",
".RLE",
".XCF",
".PSD",
".PNG",
".AVI",
".MP4",
];
/*
public static List<string> GetFilenameExtentions()
{
if (filenameExtentions is not null) return filenameExtentions;
filenameExtentions = new();
var decs = System.Drawing.Imaging.ImageCodecInfo.GetImageDecoders();
foreach(var dec in decs)
{
var extstr = dec.FilenameExtension;
var exts = extstr?.Split(";");
if (exts is not null)
{
foreach(var ext in exts)
{
filenameExtentions.Add(ext.Replace("*", ""));
}
}
else
{
if (extstr is not null)
{
filenameExtentions.Add(extstr.Replace("*", ""));
}
}
}
filenameExtentions.Add(".xcf".ToUpper());
filenameExtentions.Add(".psd".ToUpper());
filenameExtentions.Add(".avi".ToUpper());
filenameExtentions.Add(".mp4".ToUpper());
return filenameExtentions;
}
*/
public static bool IsSupported(string path)
{
var ext = Path.GetExtension(path).ToUpper();
return (filenameExtentions.IndexOf(ext) >= 0);
/*
var ext = Path.GetExtension(path).ToUpper();
//if (ext is null) return false;
var exts = GetFilenameExtentions();
return (exts.IndexOf(ext) >= 0);
*/
}
public static Bitmap? LoadImageFile(string filePath)
{
string ext = Path.GetExtension(filePath).ToUpper();
if (IsSupported(filePath) == false)
{
return null;
}
if (ext == ".AVI" || ext == ".MP4")
{
using var mat = new Mat();
using var vc = new VideoCapture(filePath);
if (vc.Read(mat))
{
if (mat.IsContinuous())
{
return BitmapConverter.ToBitmap(mat);;
}
}
return null;
}
if (ext == ".XCF" || ext == ".PSD")
{
using var magick = new MagickImage(filePath);
return magick.ToBitmap();
}
using FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read);
Bitmap bitmap = new Bitmap(fs);
return bitmap;
}
public static Bitmap? LoadFileIcon(string filePath, int w, int h)
{
string? systemRoot = Environment.GetEnvironmentVariable("SystemRoot");
if (systemRoot is null) return null;
string dllPath = Path.Join(systemRoot, @"System32\SHELL32.dll");
if (File.Exists(dllPath) == false) return null;
Icon? icon;
if (Directory.Exists(filePath))
{
icon = Icon.ExtractIcon (dllPath, 3, w);
return icon?.ToBitmap();
}
/*
if (File.Exists(filePath))
{
icon = Icon.ExtractIcon (dllPath, 0, w);
return icon?.ToBitmap();
}
*/
if (IsSupported(filePath) == false)
{
var icon2 = Icon.ExtractAssociatedIcon(filePath);
return icon2?.ToBitmap();
}
using Bitmap? bmp = LoadImageFile(filePath);
if (bmp is null) return bmp;
Bitmap canvas = new(w, h);
Graphics g = Graphics.FromImage(canvas);
g.FillRectangle(new SolidBrush(Color.White), 0, 0, w, h);
float fw = (float)w / (float)bmp.Width;
float fh = (float)h / (float)bmp.Height;
float scale = Math.Min(fw, fh);
fw = bmp.Width * scale;
fh = bmp.Height * scale;
g.DrawImage(bmp, (w - fw) / 2, (h - fh) / 2, fw, fh);
g.Dispose();
return canvas;
}
};
ThumbnailEntity.cs
public class ThumbnailEntity
{
public long ID = 0;
public string? FileName;
public string? LastModified;
}
ThumnailArchiverAlone.cs
using System.IO.Compression;
public static class ThumnailArchiverAlone
{
// ZIPファイル
const string ZIP_FILE = @".\thumnailAlone.zip";
static ZipArchive? zip;
static object lockObj = new object();
static public void Open()
{
if (zip is null)
zip = ZipFile.Open(ZIP_FILE, ZipArchiveMode.Update);
}
static public void Close()
{
zip?.Dispose();
}
/*
zipファイルに画像を追加
*/
static public void AddImageToZip(Image bmp, string entryName, CancellationToken token)
{
if (token.IsCancellationRequested) return;
if (zip is null) return;
lock(lockObj)
{
// zipファイル内のファイル(エントリー)を作成
var entry = zip.CreateEntry(entryName, CompressionLevel.NoCompression); // 無圧縮
// エントリーへ書き込むストリームを作成
using var fs = entry.Open();
// ビットマップをzipファイルへ保存
bmp.Save(fs, System.Drawing.Imaging.ImageFormat.Png);
}
}
/*
zipファイルから画像を取得
*/
static public Image? GetImageFromZip(string entryName, CancellationToken token)
{
if (token.IsCancellationRequested) return null;
if (zip is null) return null;
lock(lockObj)
{
var entry = zip.GetEntry(entryName);
if (entry is null ) return null;
// エントリーへ書き込むストリームを作成
using var fs = entry.Open();
// ビットマップを読み込み
return Bitmap.FromStream(fs);
}
}
/*
FileInfoからentryNameを作成
*/
static public string CreateEntryNameFromFileInfo(FileInfo info)
{
string result = "";
result = info.FullName.Replace("\\", "_");
result = result.Replace(":","_");
result = result.Replace(".","_");
result = result + "_" + info.LastWriteTime.ToString("yyyy_MM_dd_HH_mm_ss") + ".png";
return result;
}
}
筆者の環境のエクスプローラーではサムネイル表示してくれない、.PSDと.XCFファイルがサムネイル表示しれくれる点が地味に便利だったりします。Magic.NETの恩恵でかなりの画像フォーマットに対応させることが出来ます。ちなみに.AVIと.MP4などの動画ファイルはOpenCVSharpで最初のフレームをサムネイル画像として抽出しています。
アプリケーションを二重起動するとサムネイルキャッシュ用のZIPファイルはどうなるのだろう?
コメント