ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于.NET 10与WinForm的LOL助手第二版:异步数据流与界面重绘实战

基于.NET 10与WinForm的LOL助手第二版:异步数据流与界面重绘实战 1. 从能跑到好用LOL助手第二版到底要解决什么第一版能跑起来之后我盯着那个窗体看了很久心里清楚这东西离能用还差得远。第一版基本就是把英雄列表拉下来、点一下显示个名字功能上算是验证了数据链路能通但真要让一个玩家愿意开着它打游戏光靠这点东西远远不够。所以第二版的核心目标很明确把第一版里那些凑合能用的地方全部重做让它变成一个真正能挂在旁边、不碍事、信息一眼能看清的小工具。具体来说第二版要解决三个层面的问题。第一个层面是数据实时性第一版是手动点按钮刷新这在游戏里根本不现实你不可能一边补刀一边去点刷新。第二个层面是界面可用性第一版用的是默认的 WinForm 控件样式灰扑扑的按钮和表格放在游戏旁边特别突兀而且信息密度太低一屏看不到几个英雄。第三个层面是资源占用助手类工具最忌讳的就是抢游戏资源第一版我图省事用了同步请求界面一卡一卡的这在团战的时候是致命的。这篇文章就是围绕这三个层面展开把第二版从架构调整到界面重绘、从异步数据流到资源控制的完整过程讲清楚。适合已经用 .NET 和 WinForm 做过小工具、想进一步提升项目质量的开发者也适合对桌面助手类应用感兴趣、想了解这类工具背后工程取舍的人。我不会只贴代码更多是讲清楚每个决策背后的原因以及我实际踩过的坑。关键词里提到的 .NET 10、WinForm、LOL助手这三个词基本框定了技术栈和场景。.NET 10 作为当前较新的 LTS 方向版本在 WinForm 上的改进其实不少尤其是对高 DPI 和异步的支持这些在第二版里都用上了。下面我按实际开发顺序从架构调整开始讲。2. 第二版的架构调整为什么把数据层和界面层彻底拆开2.1 第一版架构的问题出在哪第一版我是直接在 Form 的代码里写 HTTP 请求按钮点击事件里await一下拿到 JSON 直接往 ListView 里塞。这种写法在 demo 阶段没问题但到了第二版就暴露出几个硬伤。首先是职责混乱Form 类既管界面又管网络又管数据解析一个文件几百行改一处牵动全身。其次是无法复用我想在托盘图标上显示当前对局信息结果发现数据逻辑全绑在 Form 上托盘那边根本拿不到。最后是测试困难我想单独验证一下数据解析对不对得把整个窗体跑起来。所以第二版第一件事就是拆层。我采用的是最朴素的三层结构数据访问层负责所有网络请求和 JSON 解析业务逻辑层负责数据加工和状态管理界面层只负责显示和用户交互。这个结构不新鲜但在 WinForm 小工具里很多人会忽略觉得就一个小工具搞那么复杂干嘛。我的实际体会是越是小工具越要拆因为小工具的迭代往往很频繁拆开之后每次改动的影响面可控。2.2 数据访问层的具体设计数据访问层我定义了一个接口叫IDataProvider里面就几个方法获取英雄列表、获取当前对局信息、获取玩家战绩。接口的意义在于将来如果数据来源变了比如从网页接口换成别的渠道我只需要换一个实现类界面层完全不用动。这个思路在 .NET 里很常见就是依赖注入那套但我不打算引入完整的 DI 容器小工具没必要手动在启动时 new 一个实例传进去就够了。public interface IDataProvider { TaskListHeroInfo GetHeroListAsync(CancellationToken token); TaskMatchInfo? GetCurrentMatchAsync(string summonerName, CancellationToken token); }这里有个细节值得说所有方法都带CancellationToken。第一版我没加这个结果窗体关闭的时候请求还在跑偶尔会抛异常。加上取消令牌之后窗体关闭时统一取消所有进行中的请求干净利落。这是我在实际使用中踩过的坑看起来是小问题但用户关闭程序时弹个错误框体验直接崩掉。2.3 业务逻辑层的状态管理业务逻辑层我放了一个AppState类用单例模式持有当前的对局状态、英雄列表缓存、用户配置。为什么用单例因为整个应用只有一个状态源多个窗体主窗体、托盘、设置窗体都要读同一份数据用单例最直接。但单例有个坑就是线程安全。数据是从后台线程更新的界面是在 UI 线程读的如果不加锁偶尔会读到半更新的状态。我的处理方式是所有状态更新都通过一个方法走方法内部用lock保护更新完之后触发一个事件通知界面刷新。事件在 UI 线程上触发这样界面层订阅事件后直接更新控件就行不用自己操心跨线程。这个模式在 WinForm 里很实用比Invoke满天飞要清爽得多。public event EventHandler? StateChanged; public void UpdateMatch(MatchInfo info) { lock (_syncRoot) { _currentMatch info; } StateChanged?.Invoke(this, EventArgs.Empty); }界面层订阅StateChanged在事件处理里更新控件。因为事件是在后台线程触发的界面层需要Invoke一下但至少这个Invoke是集中在一处的不是散落各处。3. 异步数据流让助手在游戏运行时保持隐形3.1 为什么同步请求会毁掉体验第一版我用的是HttpClient.GetStringAsync().Result这个.Result是万恶之源。它会阻塞当前线程而 WinForm 的 UI 线程一旦被阻塞整个界面就卡住鼠标点不动、窗口拖不动。在游戏里助手卡一下你可能就漏了一个信号。第二版全部改成async/await从按钮点击到数据更新整条链路都是异步的UI 线程永远不被阻塞。但异步也不是银弹用不好照样出问题。我遇到的一个典型问题是请求堆积。比如我设了个定时器每 3 秒刷新一次对局信息如果某次请求特别慢超过了 3 秒下一次请求又发出来了结果就是多个请求同时在跑返回顺序还可能乱掉。解决办法是用一个标志位或者SemaphoreSlim控制同一时间只允许一个请求在跑。private readonly SemaphoreSlim _refreshLock new(1, 1); private async Task RefreshAsync() { if (!await _refreshLock.WaitAsync(0)) return; // 已有请求在跑直接跳过 try { var data await _provider.GetCurrentMatchAsync(_summonerName, _cts.Token); _state.UpdateMatch(data); } finally { _refreshLock.Release(); } }WaitAsync(0)的意思是如果拿不到锁就立刻返回 false这样就不会排队等待直接跳过这次刷新。这个技巧在轮询场景里特别好用能有效防止请求堆积。3.2 定时刷新的节奏控制刷新频率是个需要权衡的参数。太快了浪费资源太慢了信息滞后。我实测下来对局信息 5 秒一次比较合适英雄列表这种不常变的数据启动时拉一次就够缓存起来。但这里有个细节游戏加载阶段和对局进行阶段数据变化的频率是不一样的。加载阶段英雄选择变化快可以 2 秒一次进入对局后基本稳定5 秒甚至 10 秒都行。我的做法是根据当前状态动态调整间隔。用一个Timer每次触发后根据状态重新设置下一次的间隔。这样既保证了关键阶段的信息及时性又避免了全程高频刷新。提示定时器建议用System.Threading.Timer而不是 WinForm 的System.Windows.Forms.Timer因为前者在后台线程触发不会占用 UI 线程。但要注意回调里更新界面需要Invoke。3.3 取消令牌的统一管理前面提到所有请求都带CancellationToken这些令牌从哪来我在AppState里维护了一个CancellationTokenSource窗体关闭、用户切换账号、程序进入后台时统一Cancel掉。这样所有进行中的请求会立刻收到取消信号不会在程序退出后还在后台跑。这里有个容易忽略的点CancellationTokenSource用完要Dispose否则会有资源泄漏。我一般是在窗体Dispose方法里统一释放。另外取消之后如果代码里捕获了OperationCanceledException记得不要当成错误弹窗静默处理就行。我第一版就是没处理这个关闭程序时弹了个任务已取消的框特别尴尬。4. 界面重绘让 WinForm 看起来不像 WinForm4.1 默认控件的年代感从哪来WinForm 默认控件的样式是 Windows 经典风格按钮是灰色立体边框表格是白底黑线放在游戏旁边就像两个时代的东西。第二版我花了不少时间在界面上目标不是做得多华丽而是融入游戏环境。具体做法是深色背景、无边框窗体、自定义绘制的按钮和列表项。深色背景好办设置BackColor就行。无边框窗体需要把FormBorderStyle设为None然后自己实现拖动和关闭按钮。拖动很简单监听MouseDown和MouseMove调用ReleaseCapture和SendMessage这两个系统 API 就能实现。关闭按钮就是一个自定义的Label或者PictureBox点击时Close()。[DllImport(user32.dll)] private static extern bool ReleaseCapture(); [DllImport(user32.dll)] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, int wParam, int lParam); private void OnTitleBarMouseDown(object sender, MouseEventArgs e) { if (e.Button MouseButtons.Left) { ReleaseCapture(); SendMessage(Handle, 0xA1, 0x2, 0); } }这段代码是 WinForm 无边框窗体拖动的标准做法0xA1是WM_NCLBUTTONDOWN0x2是HTCAPTION意思是假装点在标题栏上系统就会帮你处理拖动。4.2 自定义列表项信息密度和可读性的平衡英雄列表第一版用的是ListView每一项就一个英雄名信息密度太低。第二版我改成了自绘的FlowLayoutPanel每个英雄是一个自定义的UserControl里面包含头像、名字、胜率、场次。这样一屏能显示更多信息而且布局灵活。自绘的关键是OnPaint方法所有绘制逻辑写在这里。头像用Graphics.DrawImage画文字用TextRenderer.DrawText。这里有个性能坑如果每次OnPaint都重新加载图片会非常慢。我的做法是图片加载一次缓存起来OnPaint里直接用缓存的Image对象。protected override void OnPaint(PaintEventArgs e) { var g e.Graphics; g.SmoothingMode SmoothingMode.AntiAlias; g.InterpolationMode InterpolationMode.HighQualityBicubic; if (_avatar ! null) g.DrawImage(_avatar, new Rectangle(4, 4, 48, 48)); TextRenderer.DrawText(g, _heroName, _nameFont, new Point(60, 8), Color.White); TextRenderer.DrawText(g, $胜率 {_winRate:P0}, _subFont, new Point(60, 30), Color.FromArgb(180, 180, 180)); }SmoothingMode.AntiAlias开抗锯齿InterpolationMode.HighQualityBicubic让缩放后的头像更清晰。这两个设置对视觉效果的提升很明显尤其是头像这种小图。4.3 高 DPI 适配一个容易被忽略的坑现在很多玩家用的是 2K 甚至 4K 显示器系统缩放不是 100%。WinForm 默认对高 DPI 的支持不太好界面会模糊或者错位。.NET 10 在这方面有改进但需要手动配置。在app.manifest里加上 DPI 感知声明然后在程序启动时调用Application.SetHighDpiMode(HighDpiMode.PerMonitorV2)。application xmlnsurn:schemas-microsoft-com:asm.v3 windowsSettings dpiAwareness xmlnshttp://schemas.microsoft.com/SMI/2016/WindowsSettingsPerMonitorV2/dpiAwareness /windowsSettings /applicationPerMonitorV2的意思是每个显示器独立处理 DPI这样多显示器不同缩放比例的场景也能正确显示。设置完之后自绘的坐标和字体大小都要按 DPI 缩放比例调整否则在高 DPI 下会显得特别小。我一般是在OnPaint里根据DeviceDpi计算一个缩放系数所有尺寸乘以这个系数。注意高 DPI 适配一定要在项目早期就做后期再补会非常痛苦因为所有硬编码的坐标都要改。5. 资源占用控制助手不能成为游戏的负担5.1 内存占用的优化思路助手类工具常驻后台内存占用要控制好。第一版我没注意跑久了内存涨到几百兆虽然不至于影响游戏但看着不舒服。第二版做了几件事图片资源及时释放、避免大对象堆分配、定期清理缓存。图片资源是最容易泄漏的。Image.FromFile加载的图片会持有文件句柄如果不用了不Dispose文件一直被占用内存也不释放。我的做法是加载后立刻Clone一份然后Dispose原对象或者直接用using包起来。英雄头像这种小图我统一在启动时加载到一个字典里程序退出时统一释放。private readonly Dictionarystring, Image _avatarCache new(); private Image? GetAvatar(string heroId) { if (_avatarCache.TryGetValue(heroId, out var img)) return img; var path Path.Combine(_avatarDir, ${heroId}.png); if (!File.Exists(path)) return null; using var fs new FileStream(path, FileMode.Open, FileAccess.Read); var img2 Image.FromStream(fs); _avatarCache[heroId] img2; return img2; }用FileStream加载而不是Image.FromFile是因为FromStream不会锁定文件加载完流关闭后文件就可以被其他程序访问。这个细节很多人不知道FromFile会一直锁着文件直到Image被释放。5.2 CPU 占用的控制CPU 占用主要来自两个方面定时刷新和界面重绘。定时刷新前面说了通过动态调整间隔来控制。界面重绘的优化关键是减少无效重绘。WinForm 里如果频繁调用Invalidate会导致 CPU 飙升。我的做法是只在数据真正变化时才触发重绘而且用Invalidate(rect)只重绘变化区域而不是整个控件。另外DoubleBuffered属性要设为true这样绘制在后台缓冲区完成避免闪烁也能减少重绘次数。自定义控件默认不开双缓冲需要在构造函数里手动设置。public HeroCard() { SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); DoubleBuffered true; }AllPaintingInWmPaint和UserPaint配合让所有绘制都走OnPaintOptimizedDoubleBuffer开启双缓冲。这三个标志一起设自绘控件的性能和视觉效果都能兼顾。5.3 网络请求的资源控制网络请求方面HttpClient要复用不要每次请求都 new 一个。HttpClient内部维护连接池频繁创建销毁会导致连接耗尽。我是在DataProvider里持有一个静态的HttpClient实例整个应用生命周期共用。private static readonly HttpClient _http new(new HttpClientHandler { AutomaticDecompression DecompressionMethods.GZip | DecompressionMethods.Deflate }) { Timeout TimeSpan.FromSeconds(10) };AutomaticDecompression开启压缩能减少传输数据量。Timeout设 10 秒避免请求卡死。这些配置看起来简单但对稳定性的提升很明显。6. 打包与分发让用户双击就能用6.1 为什么选择单文件发布第二版做完之后要分发给朋友用总不能让他们装 .NET 运行时。.NET 10 支持单文件发布把所有依赖打包成一个 exe用户双击就能跑。发布命令很简单dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFiletrue--self-contained true表示自带运行时PublishSingleFile表示打包成单文件。这样生成的 exe 大概几十兆虽然比框架依赖发布大但用户不用装任何东西体验好很多。不过单文件发布有个坑首次启动慢。因为运行时要从 exe 里解压出来第一次启动可能要几秒。解决办法是开启PublishReadyToRun提前编译好能显著提升启动速度。dotnet publish -c Release -r win-x64 --self-contained true \ -p:PublishSingleFiletrue -p:PublishReadyToRuntruePublishReadyToRun会增加打包体积但启动速度提升明显我觉得这个取舍是值得的。6.2 配置文件的外部化单文件发布后配置文件如果打包在里面用户就没法改了。我的做法是把配置项比如刷新间隔、数据源地址放在 exe 同目录的config.json里程序启动时读取。这样用户想调整参数直接改 json 就行不用重新编译。var configPath Path.Combine(AppContext.BaseDirectory, config.json); var json File.ReadAllText(configPath); var config JsonSerializer.DeserializeAppConfig(json);AppContext.BaseDirectory在单文件发布下指向 exe 所在目录这个属性比Assembly.Location更可靠后者在单文件模式下可能返回空。6.3 版本更新与兼容性分发出去之后难免要更新。我的做法是在程序里加一个版本检查启动时请求一个版本号如果发现新版本就提示用户。更新方式很简单就是下载新的 exe 覆盖旧的。但这里要注意如果程序正在运行exe 文件是被锁定的没法覆盖。所以更新逻辑要放在程序退出时执行或者用一个独立的更新器进程。这个功能第二版还没做但架构上预留了接口。我的经验是小工具一旦分发给多人使用更新机制迟早要做不如一开始就留好口子。7. 实际开发中踩过的几个坑7.1 跨线程更新界面的隐蔽问题前面提到用事件通知界面刷新事件在后台线程触发界面层需要Invoke。但这里有个隐蔽的坑如果事件触发时窗体已经关闭Invoke会抛ObjectDisposedException。我一开始没处理关闭程序时偶尔报错。解决办法是在Invoke之前检查IsDisposed和IsHandleCreated。private void OnStateChanged(object? sender, EventArgs e) { if (IsDisposed || !IsHandleCreated) return; BeginInvoke(new Action(UpdateUi)); }用BeginInvoke而不是Invoke因为BeginInvoke是异步的不会阻塞后台线程。Invoke会等 UI 线程处理完才返回如果 UI 线程正忙后台线程就卡住了。7.2 JSON 解析的容错处理数据接口返回的 JSON 偶尔会有字段缺失或者类型不对第一版我直接反序列化遇到异常就崩了。第二版加了容错用JsonSerializerOptions配置PropertyNameCaseInsensitive和AllowTrailingCommas并且对可能为 null 的字段做默认值处理。var options new JsonSerializerOptions { PropertyNameCaseInsensitive true, AllowTrailingCommas true, ReadCommentHandling JsonCommentHandling.Skip };PropertyNameCaseInsensitive让大小写不敏感接口偶尔改个大小写不至于崩。AllowTrailingCommas容忍多余的逗号有些接口拼接 JSON 时会多出逗号。这些配置能显著提升健壮性。7.3 托盘图标的资源释放助手类工具一般都有托盘图标最小化到托盘。托盘图标是个NotifyIcon用完要Dispose否则程序退出后图标还留在托盘里鼠标划过去才消失。我是在窗体FormClosing事件里手动Dispose。protected override void OnFormClosing(FormClosingEventArgs e) { _notifyIcon.Visible false; _notifyIcon.Dispose(); _cts.Cancel(); _cts.Dispose(); base.OnFormClosing(e); }顺序很重要先隐藏图标再释放否则可能残留。CancellationTokenSource也要取消并释放确保所有后台任务都停下来。8. 后续可以继续做的方向第二版到这里基本达到了能用且好用的标准但还有几个方向可以继续打磨。一个是数据可视化比如把近期战绩做成折线图这个用 WinForm 自带的Chart控件或者第三方库都能做。另一个是多账号支持现在只能查一个账号如果做成多账号切换会更实用。还有就是自动更新前面提到的更新机制可以补上。我个人在实际使用中体会最深的一点是助手类工具的价值不在于功能多而在于不打扰。它应该像一个安静的副驾驶你需要的时候信息就在那里不需要的时候完全感觉不到它的存在。第二版在资源占用和界面融入上做的这些工作本质上都是在追求这个目标。如果你也在做类似的小工具我的建议是先把不打扰这件事做好再考虑加功能。
返回列表