You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

861 lines
31 KiB

using AuroraDesk.Presentation.ViewModels.Base;
using Microsoft.Extensions.Logging;
using ReactiveUI;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Reactive;
using System.Reactive.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Media.Imaging;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
namespace AuroraDesk.Presentation.ViewModels.Pages;
/// <summary>
/// 图片浏览页面 ViewModel
/// 支持百万级图片,实现真正的虚拟化和按需加载(类似 Windows 11)
/// 核心策略:
/// 1. 只保存文件路径列表(最小内存占用)
/// 2. 只创建可见区域的项目(虚拟化窗口)
/// 3. 缩略图按需加载(只在可见时加载)
/// 4. 后台异步处理,不阻塞UI
/// </summary>
public class ImageGalleryPageViewModel : RoutableViewModel
{
private string _selectedDirectory = string.Empty;
private ObservableCollection<ImageItem> _images = new();
private ImageItem? _selectedImage;
private bool _isLoading;
private int _totalImageCount;
private int _loadedImageCount;
private string _statusMessage = "请选择一个包含图片的目录";
private CancellationTokenSource? _loadingCancellationTokenSource;
// 虚拟化核心:只保存文件路径列表(非常小的内存占用)
private List<string> _allFilePaths = new();
// 虚拟化窗口:当前可见区域的前后各多加载的项目数
private const int VirtualizationWindowSize = 500; // 前后各500个,共1000个
// 当前虚拟化窗口的起始索引
private int _virtualizationWindowStart = 0;
private readonly ILogger<ImageGalleryPageViewModel>? _logger;
// 支持的图片格式
private static readonly string[] SupportedImageExtensions =
{
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".ico", ".svg"
};
public ObservableCollection<ImageItem> Images
{
get => _images;
set => this.RaiseAndSetIfChanged(ref _images, value);
}
public ImageItem? SelectedImage
{
get => _selectedImage;
set => this.RaiseAndSetIfChanged(ref _selectedImage, value);
}
public bool IsLoading
{
get => _isLoading;
set => this.RaiseAndSetIfChanged(ref _isLoading, value);
}
public string SelectedDirectory
{
get => _selectedDirectory;
set => this.RaiseAndSetIfChanged(ref _selectedDirectory, value);
}
public int TotalImageCount
{
get => _totalImageCount;
set => this.RaiseAndSetIfChanged(ref _totalImageCount, value);
}
public int LoadedImageCount
{
get => _loadedImageCount;
set => this.RaiseAndSetIfChanged(ref _loadedImageCount, value);
}
public string StatusMessage
{
get => _statusMessage;
set => this.RaiseAndSetIfChanged(ref _statusMessage, value);
}
// 命令
public ReactiveCommand<Unit, Unit> SelectDirectoryCommand { get; }
public ReactiveCommand<ImageItem, Unit> SelectImageCommand { get; }
/// <summary>
/// 构造函数
/// </summary>
/// <param name="hostScreen">宿主 Screen</param>
/// <param name="logger">日志记录器</param>
public ImageGalleryPageViewModel(
IScreen hostScreen,
ILogger<ImageGalleryPageViewModel>? logger = null)
: base(hostScreen, "ImageGallery")
{
_logger = logger;
SelectDirectoryCommand = ReactiveCommand.CreateFromTask(SelectDirectoryAsync);
SelectImageCommand = ReactiveCommand.Create<ImageItem>(SelectImage);
_logger?.LogInformation("ImageGalleryPageViewModel 已创建");
}
/// <summary>
/// 选择目录并加载图片
/// </summary>
private async Task SelectDirectoryAsync()
{
try
{
// 获取顶级窗口用于显示对话框
var app = Avalonia.Application.Current;
TopLevel? topLevel = null;
if (app?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
topLevel = desktop.MainWindow;
}
if (topLevel?.StorageProvider == null)
{
_logger?.LogWarning("无法获取 StorageProvider");
StatusMessage = "无法打开文件对话框";
return;
}
// 打开文件夹选择对话框
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(
new FolderPickerOpenOptions
{
Title = "选择包含图片的文件夹",
AllowMultiple = false
});
if (folders.Count > 0 && folders[0].TryGetLocalPath() is { } path)
{
SelectedDirectory = path;
await LoadImagesFromDirectoryAsync(path);
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "选择目录时发生错误");
StatusMessage = $"选择目录失败: {ex.Message}";
}
}
/// <summary>
/// 从目录加载图片(真正的虚拟化加载,类似 Windows 11)
/// 核心策略:
/// 1. 只保存文件路径列表(最小内存占用)
/// 2. 只创建虚拟化窗口内的项目(初始窗口大小:前1000个)
/// 3. 缩略图按需加载(延迟加载,避免同时加载太多)
/// </summary>
private async Task LoadImagesFromDirectoryAsync(string directoryPath)
{
// 取消之前的加载任务
_loadingCancellationTokenSource?.Cancel();
_loadingCancellationTokenSource?.Dispose();
_loadingCancellationTokenSource = new CancellationTokenSource();
var cancellationToken = _loadingCancellationTokenSource.Token;
try
{
IsLoading = true;
StatusMessage = "正在扫描图片文件...";
Images.Clear();
SelectedImage = null;
LoadedImageCount = 0;
_allFilePaths.Clear();
_virtualizationWindowStart = 0;
// 清除之前的缩略图缓存,释放内存
ImageItem.ClearThumbnailCache();
TotalImageCount = 0;
_logger?.LogInformation("开始从目录加载图片: {Directory}", directoryPath);
if (!Directory.Exists(directoryPath))
{
StatusMessage = "目录不存在";
IsLoading = false;
return;
}
// 第一步:快速收集所有文件路径(只保存路径字符串,内存占用极小)
await Dispatcher.UIThread.InvokeAsync(() =>
{
StatusMessage = "正在扫描图片文件...";
}, DispatcherPriority.Background);
await Task.Run(async () =>
{
try
{
// 使用异步枚举器,流式处理文件
await foreach (var filePath in EnumerateImageFilesAsync(directoryPath, cancellationToken))
{
if (cancellationToken.IsCancellationRequested)
break;
_allFilePaths.Add(filePath);
// 每找到1000个文件就更新一次UI(不阻塞)
if (_allFilePaths.Count % 1000 == 0)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
TotalImageCount = _allFilePaths.Count;
StatusMessage = $"已找到 {TotalImageCount} 个图片文件...";
}, DispatcherPriority.Background);
// 让UI有时间响应
await Task.Yield();
}
}
}
catch (OperationCanceledException)
{
_logger?.LogInformation("文件扫描已取消");
return;
}
}, cancellationToken);
if (cancellationToken.IsCancellationRequested)
{
return;
}
TotalImageCount = _allFilePaths.Count;
_logger?.LogInformation("找到 {Count} 张图片,准备虚拟化加载", TotalImageCount);
if (TotalImageCount == 0)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
StatusMessage = "未找到图片文件";
IsLoading = false;
}, DispatcherPriority.Background);
return;
}
// 第二步:只创建虚拟化窗口内的项目(初始窗口:前1000个)
// 这是关键优化:类似 Windows 11,只创建可见区域的项目,而不是所有项目
await Dispatcher.UIThread.InvokeAsync(() =>
{
StatusMessage = $"正在准备图片...";
}, DispatcherPriority.Background);
// 创建初始虚拟化窗口的项目(前 VirtualizationWindowSize * 2 个)
var initialWindowSize = Math.Min(VirtualizationWindowSize * 2, TotalImageCount);
var initialFilePaths = _allFilePaths.Take(initialWindowSize).ToList();
// 在后台线程批量创建 ImageItem(异步获取文件信息,避免阻塞)
var initialItems = await Task.Run(async () =>
{
var items = new List<ImageItem>();
var semaphore = new SemaphoreSlim(Environment.ProcessorCount * 2); // 限制并发数
var tasks = new List<Task>();
foreach (var filePath in initialFilePaths)
{
if (cancellationToken.IsCancellationRequested)
break;
await semaphore.WaitAsync(cancellationToken);
var task = Task.Run(() =>
{
try
{
var fileName = Path.GetFileName(filePath);
var item = new ImageItem
{
FilePath = filePath,
FileName = fileName,
};
// 获取文件信息(同步操作,因为 FileInfo 是同步的)
try
{
var fileInfo = new FileInfo(filePath);
if (fileInfo.Exists)
{
item.FileSize = fileInfo.Length;
item.LastModified = fileInfo.LastWriteTime;
}
}
catch
{
// 忽略文件信息获取失败
}
lock (items)
{
items.Add(item);
}
}
finally
{
semaphore.Release();
}
}, cancellationToken);
tasks.Add(task);
}
await Task.WhenAll(tasks);
return items.OrderBy(x => _allFilePaths.IndexOf(x.FilePath)).ToList();
}, cancellationToken);
// 立即添加到UI并显示
await Dispatcher.UIThread.InvokeAsync(() =>
{
_logger?.LogInformation("开始添加虚拟化窗口项目,数量: {Count}", initialItems.Count);
foreach (var item in initialItems)
{
_images.Add(item);
// 关键优化:对于初始可见窗口内的项目,触发缩略图加载
// 这样用户可以看到缩略图逐步加载,体验更好
item.EnsureThumbnailLoaded();
}
LoadedImageCount = _images.Count;
_isLoading = false;
this.RaisePropertyChanged(nameof(IsLoading));
_logger?.LogInformation("虚拟化窗口已加载,集合大小: {Count},总文件数: {Total}", _images.Count, TotalImageCount);
StatusMessage = $"已显示 {LoadedImageCount} / {TotalImageCount} 张图片(虚拟化显示,支持百万级图片)";
}, DispatcherPriority.Normal);
_logger?.LogInformation("图片加载完成,虚拟化窗口大小: {WindowSize},总文件数: {Total}", initialItems.Count, TotalImageCount);
}
catch (OperationCanceledException)
{
_logger?.LogInformation("图片加载已取消");
await Dispatcher.UIThread.InvokeAsync(() =>
{
StatusMessage = "加载已取消";
IsLoading = false;
}, DispatcherPriority.Background);
}
catch (Exception ex)
{
_logger?.LogError(ex, "加载图片时发生错误");
await Dispatcher.UIThread.InvokeAsync(() =>
{
StatusMessage = $"加载图片失败: {ex.Message}";
IsLoading = false;
}, DispatcherPriority.Background);
}
}
/// <summary>
/// 根据滚动位置更新虚拟化窗口(动态加载/卸载项目)
/// 当用户滚动到接近窗口边界时,自动加载更多项目
/// </summary>
/// <param name="visibleStartIndex">当前可见区域的起始索引</param>
/// <param name="visibleEndIndex">当前可见区域的结束索引</param>
public async Task UpdateVirtualizationWindowAsync(int visibleStartIndex, int visibleEndIndex)
{
if (_allFilePaths.Count == 0 || IsLoading)
return;
// 计算新的虚拟化窗口范围
var newWindowStart = Math.Max(0, visibleStartIndex - VirtualizationWindowSize);
var newWindowEnd = Math.Min(_allFilePaths.Count, visibleEndIndex + VirtualizationWindowSize);
// 如果窗口没有显著变化,不需要更新
if (newWindowStart >= _virtualizationWindowStart && newWindowEnd <= _virtualizationWindowStart + _images.Count)
{
return;
}
_logger?.LogDebug("更新虚拟化窗口: {Start} - {End} (可见: {VisibleStart} - {VisibleEnd})",
newWindowStart, newWindowEnd, visibleStartIndex, visibleEndIndex);
try
{
// 计算需要移除的项目(超出窗口范围的项目)
var itemsToRemove = new List<ImageItem>();
for (int i = _images.Count - 1; i >= 0; i--)
{
var item = _images[i];
var index = _allFilePaths.IndexOf(item.FilePath);
if (index < newWindowStart || index >= newWindowEnd)
{
itemsToRemove.Add(item);
}
}
// 计算需要添加的项目(在新窗口内但不在当前集合中的)
var currentIndices = new HashSet<int>(_images.Select(x => _allFilePaths.IndexOf(x.FilePath)));
var itemsToAdd = new List<string>();
for (int i = newWindowStart; i < newWindowEnd; i++)
{
if (!currentIndices.Contains(i))
{
itemsToAdd.Add(_allFilePaths[i]);
}
}
// 在UI线程更新集合
await Dispatcher.UIThread.InvokeAsync(() =>
{
// 移除超出窗口的项目
foreach (var item in itemsToRemove)
{
_images.Remove(item);
}
// 添加新窗口内的项目(按索引顺序插入)
var newItems = itemsToAdd.Select(filePath =>
{
var fileName = Path.GetFileName(filePath);
var item = new ImageItem
{
FilePath = filePath,
FileName = fileName,
};
// 异步获取文件信息
_ = Task.Run(async () =>
{
try
{
var fileInfo = new FileInfo(filePath);
if (fileInfo.Exists)
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
item.FileSize = fileInfo.Length;
item.LastModified = fileInfo.LastWriteTime;
});
}
}
catch
{
// 忽略失败
}
});
return item;
}).ToList();
// 按索引排序后插入
foreach (var item in newItems.OrderBy(x => _allFilePaths.IndexOf(x.FilePath)))
{
var index = _allFilePaths.IndexOf(item.FilePath);
var insertIndex = _images.TakeWhile(i => _allFilePaths.IndexOf(i.FilePath) < index).Count();
_images.Insert(insertIndex, item);
// 对于新加载到窗口内的项目,触发缩略图加载
// 如果项目在可见区域内,会立即加载;否则会在进入可见区域时加载
item.EnsureThumbnailLoaded();
}
_virtualizationWindowStart = newWindowStart;
LoadedImageCount = _images.Count;
StatusMessage = $"已加载 {LoadedImageCount} / {TotalImageCount} 张图片(虚拟化显示)";
}, DispatcherPriority.Background);
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "更新虚拟化窗口时出错");
}
}
/// <summary>
/// 异步枚举图片文件(流式处理,不等待全部完成)
/// </summary>
private async IAsyncEnumerable<string> EnumerateImageFilesAsync(
string directoryPath,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var dirQueue = new Queue<string>();
var fileQueue = new Queue<string>();
dirQueue.Enqueue(directoryPath);
// 在后台线程持续扫描目录和文件
var scanTask = Task.Run(() =>
{
while ((dirQueue.Count > 0 || fileQueue.Count > 0) && !cancellationToken.IsCancellationRequested)
{
string? currentDir = null;
lock (dirQueue)
{
if (dirQueue.Count > 0)
{
currentDir = dirQueue.Dequeue();
}
}
if (currentDir == null)
{
Thread.Sleep(10); // 等待新目录
continue;
}
try
{
// 枚举当前目录的文件
var files = new List<string>();
foreach (var filePath in Directory.EnumerateFiles(currentDir, "*.*", SearchOption.TopDirectoryOnly))
{
if (cancellationToken.IsCancellationRequested)
return;
var extension = Path.GetExtension(filePath).ToLowerInvariant();
if (Array.IndexOf(SupportedImageExtensions, extension) >= 0)
{
files.Add(filePath);
}
}
// 将找到的文件添加到文件队列
lock (fileQueue)
{
foreach (var file in files)
{
fileQueue.Enqueue(file);
}
}
// 枚举子目录
var dirs = new List<string>();
foreach (var subDir in Directory.EnumerateDirectories(currentDir, "*", SearchOption.TopDirectoryOnly))
{
if (cancellationToken.IsCancellationRequested)
return;
dirs.Add(subDir);
}
// 将子目录添加到目录队列
lock (dirQueue)
{
foreach (var dir in dirs)
{
dirQueue.Enqueue(dir);
}
}
}
catch (UnauthorizedAccessException)
{
// 跳过无权限访问的目录
_logger?.LogWarning("无权限访问目录: {Directory}", currentDir);
}
catch (DirectoryNotFoundException)
{
// 跳过不存在的目录
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "处理目录时出错: {Directory}", currentDir);
}
}
}, cancellationToken);
// 流式返回找到的文件
while (!scanTask.IsCompleted || fileQueue.Count > 0)
{
if (cancellationToken.IsCancellationRequested)
yield break;
string? filePath = null;
lock (fileQueue)
{
if (fileQueue.Count > 0)
{
filePath = fileQueue.Dequeue();
}
}
if (filePath != null)
{
yield return filePath;
}
else
{
// 没有文件时稍等片刻
await Task.Delay(10, cancellationToken);
}
}
}
private void SelectImage(ImageItem image)
{
if (image == null) return;
SelectedImage = image;
_logger?.LogInformation("选择图片: {FileName}", image.FileName);
}
/// <summary>
/// 清理资源(取消正在进行的加载任务)
/// </summary>
public void CancelLoading()
{
_loadingCancellationTokenSource?.Cancel();
_loadingCancellationTokenSource?.Dispose();
_loadingCancellationTokenSource = null;
}
/// <summary>
/// 清除缩略图缓存(在切换目录时调用,释放内存)
/// </summary>
public static void ClearThumbnailCache()
{
ImageItem.ClearThumbnailCache();
}
}
/// <summary>
/// 图片项模型
/// </summary>
public class ImageItem : ReactiveObject
{
// 缩略图缓存(线程安全,静态共享)
private static readonly ConcurrentDictionary<string, Bitmap?> _thumbnailCache = new();
// 缩略图大小(与UI中的184x184一致)
private const int ThumbnailSize = 184;
private string _filePath = string.Empty;
private string _fileName = string.Empty;
private long _fileSize;
private DateTime _lastModified;
private Bitmap? _thumbnailSource;
private bool _isLoadingThumbnail;
public string FilePath
{
get => _filePath;
set
{
var oldValue = _filePath;
this.RaiseAndSetIfChanged(ref _filePath, value);
// 注意:不再自动触发缩略图加载,而是通过 EnsureThumbnailLoadedAsync 手动触发
// 这样可以实现真正的按需加载:只在项目可见时才加载缩略图
}
}
/// <summary>
/// 确保缩略图已加载(按需加载,类似 Windows 11)
/// 应该在项目进入可见区域时调用此方法
/// </summary>
public void EnsureThumbnailLoaded()
{
// 如果已经加载或正在加载,则不重复加载
if (_thumbnailSource != null || _isLoadingThumbnail || string.IsNullOrEmpty(_filePath))
return;
// 异步加载缩略图(不阻塞)
_ = DelayedLoadThumbnailAsync();
}
/// <summary>
/// 延迟加载缩略图(避免同时触发大量加载)
/// </summary>
private async Task DelayedLoadThumbnailAsync()
{
// 延迟 50-200ms,让UI有时间渲染,并且避免同时加载太多缩略图
await Task.Delay(new Random().Next(50, 200));
// 如果 FilePath 没有改变,才加载缩略图
if (!string.IsNullOrEmpty(_filePath) && _thumbnailSource == null && !_isLoadingThumbnail)
{
await LoadThumbnailAsync();
}
}
public string FileName
{
get => _fileName;
set => this.RaiseAndSetIfChanged(ref _fileName, value);
}
public long FileSize
{
get => _fileSize;
set => this.RaiseAndSetIfChanged(ref _fileSize, value);
}
public DateTime LastModified
{
get => _lastModified;
set => this.RaiseAndSetIfChanged(ref _lastModified, value);
}
/// <summary>
/// 缩略图源(异步加载,类似 Windows 11)
/// </summary>
public Bitmap? ThumbnailSource
{
get => _thumbnailSource;
private set => this.RaiseAndSetIfChanged(ref _thumbnailSource, value);
}
/// <summary>
/// 是否正在加载缩略图
/// </summary>
public bool IsLoadingThumbnail
{
get => _isLoadingThumbnail;
private set => this.RaiseAndSetIfChanged(ref _isLoadingThumbnail, value);
}
/// <summary>
/// 异步加载缩略图(184x184,类似 Windows 11)
/// </summary>
private async Task LoadThumbnailAsync()
{
if (string.IsNullOrEmpty(_filePath) || !File.Exists(_filePath))
{
ThumbnailSource = null;
return;
}
IsLoadingThumbnail = true;
try
{
// 在后台线程加载缩略图
var thumbnail = await Task.Run(() => GetThumbnailFromConverterAsync(_filePath));
// 在UI线程更新
await Dispatcher.UIThread.InvokeAsync(() =>
{
ThumbnailSource = thumbnail;
IsLoadingThumbnail = false;
});
}
catch
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
ThumbnailSource = null;
IsLoadingThumbnail = false;
});
}
}
/// <summary>
/// 使用 ImageSharp 生成真正的缩略图(184x184,类似 Windows 11)
/// 支持缓存,避免重复生成
/// </summary>
private Bitmap? GetThumbnailFromConverterAsync(string filePath)
{
// 检查缓存
if (_thumbnailCache.TryGetValue(filePath, out var cached))
{
return cached;
}
try
{
// 使用 ImageSharp 加载并生成缩略图
using var image = SixLabors.ImageSharp.Image.Load(filePath);
// 计算缩略图尺寸(保持宽高比)
var sourceWidth = image.Width;
var sourceHeight = image.Height;
int thumbnailWidth, thumbnailHeight;
if (sourceWidth <= ThumbnailSize && sourceHeight <= ThumbnailSize)
{
// 如果原图小于缩略图尺寸,直接使用原图尺寸
thumbnailWidth = sourceWidth;
thumbnailHeight = sourceHeight;
}
else
{
// 计算缩放比例,保持宽高比
var scale = Math.Min(
(double)ThumbnailSize / sourceWidth,
(double)ThumbnailSize / sourceHeight);
thumbnailWidth = (int)(sourceWidth * scale);
thumbnailHeight = (int)(sourceHeight * scale);
}
// 生成缩略图
image.Mutate(x => x.Resize(new ResizeOptions
{
Size = new Size(thumbnailWidth, thumbnailHeight),
Mode = ResizeMode.Max, // 保持宽高比,确保不超过指定尺寸
Sampler = KnownResamplers.Lanczos3 // 高质量缩放算法
}));
// 将 ImageSharp 图像转换为 Avalonia Bitmap
using var memoryStream = new MemoryStream();
image.SaveAsPng(memoryStream); // 保存为 PNG 格式
memoryStream.Position = 0;
var bitmap = new Bitmap(memoryStream);
// 缓存缩略图
_thumbnailCache.TryAdd(filePath, bitmap);
return bitmap;
}
catch
{
// 加载失败,缓存 null 避免重复尝试
_thumbnailCache.TryAdd(filePath, null);
return null;
}
}
/// <summary>
/// 清除缩略图缓存(静态方法,用于清理资源)
/// </summary>
public static void ClearThumbnailCache()
{
foreach (var bitmap in _thumbnailCache.Values)
{
bitmap?.Dispose();
}
_thumbnailCache.Clear();
}
/// <summary>
/// 格式化文件大小
/// </summary>
public string FormattedFileSize
{
get
{
if (_fileSize < 1024) return $"{_fileSize} B";
if (_fileSize < 1024 * 1024) return $"{_fileSize / 1024.0:F2} KB";
return $"{_fileSize / (1024.0 * 1024.0):F2} MB";
}
}
}