线程、Dispatcher 与异步模式
线程、Dispatcher 与异步模式
WPF 线程约束
WPF UI 对象只能在创建它们的 UI 线程访问。包括 Window、Page、FrameworkElement、ObservableCollection 绑定集合、SymbolIcon、InfoBar/Snackbar 控件等。
后台任务、下载进度事件、OCR 推理线程、WGC 帧回调如果要更新 UI 或绑定集合,必须切回 Application.Current.Dispatcher。
已有安全模式
PluginMarketService
插件市场下载队列由统一下载服务的状态事件驱动,但队列是 ObservableCollection<PluginDownloadQueueItem>。服务用 RunOnUiThread 包装集合写入和状态更新:
if (Application.Current?.Dispatcher == null || Application.Current.Dispatcher.CheckAccess())
{
action();
return;
}
Application.Current.Dispatcher.Invoke(action);以后修改下载队列时,应继续通过这个模式更新 _downloadQueueInternal 和队列项属性。
SettingPageViewModel / PluginPageViewModel
这些 ViewModel 在处理下载状态或设置同步事件时先检查 Dispatcher.CheckAccess(),不在 UI 线程时用 Dispatcher.Invoke(...)。
统一文件下载服务
宿主将 IFileDownloadService 注册为单例;应用更新、插件包、SmartBP 模块与 OCR 模型、CUDA 依赖和 WebRenderer Runtime 安装包都通过它创建独立的 IFileDownloadOperation。调用方等待 StartAsync,并用 Pause、Resume、Cancel 控制当前操作,不再维护各自的 HTTP 下载循环。
统一服务是 Downloader 的生命周期薄封装:保持原有的 8 分片、最多 6 路并行配置,直接转发 DownloadProgressChangedEventArgs 中的百分比、已接收字节和速度,不自行重算百分比,也不在服务层节流进度事件。
未完成内容和 Downloader 的自动续传元数据一并保存在最终路径旁的 .download.part;没有额外的 .download.json。暂停、取消和瞬时失败会保留该文件,后续对同一目标路径创建的新操作由 Downloader 发送 HTTP Range 请求续传。启动已有分片前会探测服务端是否仍支持 Range;不支持时丢弃旧分片并由 Downloader 从头下载,避免把完整响应写入旧分片。
业务层应为可续传资产选择稳定的下载目标路径,不要每次重试都创建随机临时文件名。下载完成后服务会原子提交最终文件并删除旁路元数据;安装、解压和校验仍由业务服务负责。
服务端未返回完整大小时,FileDownloadProgress.Percentage 和 TotalBytes 为 null,但 BytesReceived 仍会持续更新。UI 此时应显示不定进度和已接收容量,不得保留某个旧百分比造成“卡住”的假象。
IFileDownloadOperation.StateChanged 可能从后台线程触发。它只提供状态与数值快照,不直接操作 WPF 对象;ViewModel 或含 ObservableCollection 的服务仍须通过 Dispatcher 传播到 UI。
WebRenderer sidecar 的 RemoteAssetFetcher 是受 IPC、内容类型、大小和重定向策略约束的页面资源缓存,不对应用户可控的下载任务,因此保持独立实现。
WindowCaptureService
窗口捕获服务的帧缓存由捕获回调写入、SmartBP 或预览读取。代码用 Lock _frameLock 保护 _currentFrame。生成的 BitmapSource 会 Freeze(),便于跨线程读取。
当 GetCurrentFrame() 发现没有启动捕获时,它通过 Dispatcher 异步弹提示并导航到 SmartBP 页面。
StopCapture 在帧锁下清空最新帧并更新捕获世代,旧 WGC/BitBlt 回调不能在停止或换源后重新写回缓存。WGC 回调与 staging 资源销毁使用同一资源锁,帧池与会话在该锁外 Dispose,避免等待正在退出的回调。Direct3D 设备跨会话复用,只在 DI 释放捕获服务时销毁。
前台行为 HostManager 按 Window ID 协调同步附加/分离,异步文档加载不持有普通锁;只有最新 Attach 请求可以注册宿主,Detach 和 Dispose 会使旧请求失效。FrontedWindowBase 用异步 gate 协调自身 Attach/Detach,隐藏会取消未完成的附加,真正关闭还会取消排队中的布局加载。分离异常在任务内部记录,停止循环失败仍会执行 Detach;已释放宿主的循环 finally 不得重新 Reset 并创建动画 session。
队伍在线图片
Team.Logo 和 Member.Image 对 HTTP/HTTPS URI 只启动一次异步加载,首次读取在图片未就绪时返回 null。RemoteImageLoader 在后台执行下载和 WIC 解码,使用 OnLoad 并冻结图片,再回到首次读取时的 Dispatcher 发布 PropertyChanged。Player 继续通过成员图片通知刷新三个 PictureShown* 派生属性,因此 JSON 导入、选手上场和换边首次显示定妆照都不会在 getter 中等待网络或解码。
修改 URI、显式设置图片或重新导入 Logo 时会取消旧请求;旧结果不能覆盖新值。异步完成直接更新图片缓存,不调用会清空 URI 的公开图片 setter,原 JSON 图片地址保持可导出。失败后记录日志并保持空图片,不在绑定读取时无限重试;重新设置来源或重新导入后可以再次加载。本地文件和 pack URI 延续原加载方式。
DispatcherTimer 与后台 Timer
项目中多个 UI 相关周期任务使用 DispatcherTimer:
| 位置 | 用途 |
|---|---|
SharedDataService | 倒计时 |
SmartBpService | SmartBP 自动流程定时框架 |
SmartBpPageViewModel | 捕获比例刷新 |
WindowCaptureService | BitBlt 拉帧、预览窗口刷新 |
DispatcherTimer 的 Tick 在 UI 线程执行,适合更新绑定状态或 UI。缺点是耗时工作会卡 UI,因此 SmartBP 的 OCR 识别使用 Task.Run 放到后台线程。
不要把 UI 更新逻辑迁移到 System.Threading.Timer 后直接操作 WPF 对象。
OCR 与锁
PaddleOcrProvider 有两把锁:
| 锁 | 保护内容 |
|---|---|
_ocrLock | PaddleOCR Predictor 的创建、运行、切换、重建和释放 |
_downloadLock | 模型下载状态、取消令牌、进度字段 |
RecognizeTextCore 在 _ocrLock 内运行推理;失败时尝试重建当前 OCR predictor 并重试一次。模型切换的原生构造也在同一锁内,避免新 Predictor 初始化与旧实例推理并发使用 Paddle/oneDNN 状态;模型偏好设置通知在锁外发布。下载模型、删除模型、切换模型都要避免和推理实例生命周期竞争。
不要在持有 _ocrLock 时调用可能需要 UI 线程或长期等待用户交互的代码。
SmartBP 后台任务
AutoFillGameDataAsync 用 Task.Run 执行捕获帧裁切和 OCR:
var result = await Task.Run(
() => CaptureAndRecognizeGameData(frame, captureMs, cancellationToken),
cancellationToken);服务层对全部入口共用防重复执行状态。无论调用方是否在后台线程,准备、错误/无结果弹窗、调试快照发布和对局回填统一回到创建服务时的 UI Dispatcher;等待预热时保留 UI 同步上下文,不使用 ConfigureAwait(false) 后直接创建 WPF 弹窗。错误通知自身失败也会记录并在服务边界收口,避免从异步按钮命令抛出第二个异常。进度订阅用异步 Dispatcher 投递,不同步阻塞 OCR 线程。
后台部分读取点击时取得的冻结 BitmapSource,不在预热后重新获取另一帧。回填前重新检查取消状态、对局引用、局次和阵营引用;切局或换边后丢弃旧结果,不能把旧截图写入新对局。调用方取消只取消自身对共享预热的等待,不中断其他请求可能复用的预热任务。
旧版 .bpui 转换同样通过 Task.Run 执行解压、资源复制、SkiaSharp 图片压缩、布局转换和重新打包。转换器本身不访问 WPF 对象;调用方在 await 完成后才在 Dispatcher 线程显示警告、刷新包列表或激活布局包。
async void
当前代码中的 async void 主要出现在 WPF 生命周期/事件处理器:
| 位置 | 原因 |
|---|---|
App.OnStartup | WPF override 签名 |
App.OnExit 和全局异常回调使用同步诊断。WPF 不等待异步 OnExit,因此 Host 停止采用不捕获 UI 同步上下文的有期限同步等待;关闭中的 Dispatcher 不可作为插件停止 continuation 的必要条件。
async void 不应出现在普通业务方法中。事件处理器内必须自行捕获异常、清理状态并通知 UI。
安全修改规则
- 更新 WPF 控件、
ObservableCollection、绑定属性前确认当前线程。 - 下载进度事件、OCR、捕获回调里不要直接弹窗,优先 Dispatcher。
- 后台任务要支持取消令牌,至少不要吞掉
OperationCanceledException后留下错误状态。 - 使用锁保护共享状态时,不要在锁内执行长时间下载、OCR、弹窗或 Dispatcher 同步等待。
- 新增事件订阅时考虑 singleton 生命周期和解绑。
- 前台窗口和插件 v3 控件的创建/访问应在 UI 线程完成。
