Repository Wiki
BeyondDimension/SteamTools

游戏库管理

游戏库管理是 Watt Toolkit(SteamTools)中的核心插件之一,负责读取本机 Steam 库存清单,并以响应式、可筛选、可交互的方式展示用户的全部 Steam 应用(游戏、工具、DLC 等),同时提供启动/安装、隐藏游戏、挂机(AFK)、成就解锁、云存档管理、编辑应用信息等入口。

目的与范围

本页覆盖 BD.WTTS.Client.Plugins.GameList 插件 的完整实现:

  • 插件注册与装配(Plugins/Plugin.cs、csproj 编译符号)
  • 游戏库主页面 GameListPageViewModel 的响应式筛选管线(名称/拼音/App 类型/已安装/云存档)
  • 游戏列表的命令集(启动/安装、隐藏、挂机、成就、云存档、编辑信息等)
  • 页面生命周期与库存刷新触发
  • 插件内的持久化设置 GameLibrarySettings

以下内容有意留给同级页面,本页仅引用不展开:

  • Steam 客户端连接、库存文件(appmanifest_*.acf)解析与 SteamConnectService.SteamApps 数据源的加载细节 —— 见 Steam 连接服务相关页面
  • 账号切换与登录(GameAccount 插件)
  • 成就/云存档子窗口的业务细节由 AchievementAppPageViewModel、CloudArchiveAppPageViewModel 承载,本页仅记录入口调用

概述

Steam 客户端自带的"库"页面在网络不好或库规模巨大时常出现卡顿,且缺少拼音搜索、按类型统计、批量隐藏等能力。GameList 插件的目标是完全基于本地数据重建一个可筛选、可排序、可管理的游戏库视图:

  • 本地化数据源:不请求 Steam Web API,直接消费 SteamConnectService 已解析的本机库清单,离线可用。
  • 多维度筛选:应用名称(含 AppId 匹配与中文拼音/首字母匹配)、应用类型(SteamAppType)、是否已安装、是否有云存档,四个谓词串联成一条 DynamicData 管线,任一条件变化即时重算。
  • 设置持久化:筛选状态(类型多选、已安装开关、云存档开关)通过 GameLibrarySettings 写入应用设置,下次启动自动恢复。
  • 操作入口聚合:列表项提供启动/安装、跳转 Steam 商店页与截图页、打开本地目录、编辑本地显示名、隐藏、挂机、成就解锁、云存档管理等命令。

插件以标准插件机制接入宿主:Plugin.cs 暴露固定 Guid(00000000-0000-0000-0000-000000000003),csproj 通过 WTTS_PLUGIN;WTTS_PLUGIN_GAMELIST 编译符号参与条件编译,并被 BD.WTTS.Client.Avalonia.Designer.HostApp 等宿主工程以 ProjectReference 引用。

架构

Loading diagram...

架构要点说明:

  • Plugin.cs 是插件对外唯一入口,声明模块名(AssemblyInfo.GameList 常量 "GameList")、插件 Id、显示名(Strings.GameList 本地化)与图标(Resources.game),宿主据此把它注册为主界面标签页。
  • GameListPageViewModel 继承 TabItemViewModel,是唯一数据聚合点:它既订阅核心服务 SteamConnectService.SteamApps 的变更流,又把用户筛选偏好读写到 GameLibrarySettings。
  • 其余 Page/Window ViewModel 均为按需弹出的子功能页,通过 IWindowManager.Instance.ShowTaskDialogAsync 模态打开,与主列表解耦。

数据流水线(核心设计)

主列表没有采用"每次筛选后手动刷新集合"的传统做法,而是在构造函数中一次性搭好一条 DynamicData 响应式管线:

csharp
1SteamConnectService.Current.SteamApps 2 .Connect() 3 .Filter(nameFilter) 4 .Filter(typeFilter) 5 .Filter(installFilter) 6 .Filter(isCloudArchiveFilter) 7 .Sort(SortExpressionComparer<SteamApp>.Ascending(x => x.DisplayName).ThenByDescending(s => s.SizeOnDisk)) 8 .ObserveOn(RxSchedulers.MainThreadScheduler) 9 .Bind(out _SteamApps) 10 .Subscribe(_ => 11 { 12 CalcTypeCount(); 13 });

Source: GameListPageViewModel.cs

这样设计的原因:

  1. SteamApps.Connect() 暴露库存源的增量变更(新增/移除/更新项),任何后台刷新(如 RefreshGamesListAsync)完成后,管线自动把变更传播到 UI 绑定集合 _SteamApps,无需手写刷新逻辑。
  2. 四个 Filter 依次串联,每个筛选条件的可观察序列(WhenAnyValue/AutoRefresh)变化时只重建该谓词,链式组合避免了"全量重查"。
  3. 排序规则固定为 名称升序、同名按磁盘占用降序(.ThenByDescending(s => s.SizeOnDisk)),保证重复项(同一游戏多账号清单)中占空间最大的排在前面。
  4. .ObserveOn(RxSchedulers.MainThreadScheduler) 保证所有绑定更新回到 UI 线程,.Bind(out _SteamApps) 直接输出 ReadOnlyObservableCollection 供视图绑定。
  5. 每次管线触发后调用 CalcTypeCount() 重算各 SteamAppType 的计数,用于类型筛选标签上的数量徽标。

主内容:实现走读

1. 插件装配与常量

插件工程 BD.WTTS.Client.Plugins.GameList.csproj 通过自定义编译符号声明插件身份:

xml
<DefineConstants>WTTS_PLUGIN;WTTS_PLUGIN_GAMELIST;$(DefineConstants)</DefineConstants>

Source: BD.WTTS.Client.Plugins.GameList.csproj

插件入口 Plugin.cs 关键成员(均可在源码中对应到常量定义):

csharp
1const string moduleName = AssemblyInfo.GameList; 2 3public override Guid Id => Guid.Parse(AssemblyInfo.GameListId); 4 5public override string Name => Strings.GameList; 6 7public sealed override object? Icon => Resources.game;

Source: Plugin.cs

其中常量集中定义在宿主共享文件中:

csharp
public const string GameList = "GameList"; ... public const string GameListId = "00000000-0000-0000-0000-000000000003";

Source: AssemblyInfo.Constants.cs

固定 Guid 使插件可被宿主的插件注册表稳定识别与去重;WTTS_PLUGIN_GAMELIST 符号允许宿主与共享代码中针对该插件做条件编译。

2. 页面生命周期

csharp
1public override void Activation() 2{ 3 if (IsFirstActivation && !SteamConnectService.Current.SteamApps.Items.Any()) 4 { 5 Task2.InBackground(SteamConnectService.Current.RefreshGamesListAsync); 6 } 7 base.Activation(); 8}

Source: GameListPageViewModel.cs

  • Activation() 在用户首次切到该标签页时触发;若核心服务尚未加载任何库存项,则在后台线程调用 RefreshGamesListAsync 拉起本机清单解析——懒加载 + 后台线程避免阻塞首屏。
  • 构造函数第一行 if (!IApplication.IsDesktop()) throw new NotSupportedException(); 明确该页面仅限桌面端(移动端无本机 Steam 库可读)。

3. 筛选条件与持久化恢复

构造函数中先恢复持久化偏好,再接通变更写回:

csharp
1AppTypeFiltres = new ObservableCollection<EnumModel<SteamAppType>>(EnumModel.GetEnums<SteamAppType>()); 2 3foreach (var type in AppTypeFiltres) 4{ 5 if (GameLibrarySettings.GameTypeFiltres.Value?.Contains(type.Value) == true) 6 { 7 type.Enable = true; 8 } 9} 10 11IsInstalledFilter = GameLibrarySettings.GameInstalledFilter.Value; 12IsCloudArchiveFilter = GameLibrarySettings.GameCloudArchiveFilter.Value; 13 14this.WhenValueChanged(x => x.IsInstalledFilter, false) 15 .Subscribe(s => GameLibrarySettings.GameInstalledFilter.Value = s);

Source: GameListPageViewModel.cs

类型多选使用 AutoRefresh 监听每个 EnumModel.Enable 的翻转,任何勾选变化都会同步回设置并刷新显示字符串:

csharp
1this.WhenAnyValue(x => x.AppTypeFiltres) 2 .Subscribe(type => type? 3 .ToObservableChangeSet() 4 .AutoRefresh(x => x.Enable) 5 .Subscribe(_ => 6 { 7 EnableAppTypeFiltres = AppTypeFiltres.Where(s => s.Enable).ToList(); 8 GameLibrarySettings.GameTypeFiltres.Value = EnableAppTypeFiltres.Select(s => s.Value).ToList(); 9 this.RaisePropertyChanged(nameof(TypeFilterString)); 10 }));

Source: GameListPageViewModel.cs

TypeFilterString 把当前启用类型拼接为逗号分隔的本地化名称,供下拉框显示当前筛选摘要。

4. 名称搜索谓词(含拼音)

csharp
1Func<SteamApp, bool> PredicateName(string? text) 2{ 3 return s => 4 { 5 if (s == null || s.DisplayName == null) 6 return false; 7 if (string.IsNullOrEmpty(text)) 8 return true; 9 if (s.DisplayName.Contains(text, StringComparison.OrdinalIgnoreCase) || 10 s.AppId.ToString().Contains(text, StringComparison.OrdinalIgnoreCase)) 11 { 12 return true; 13 } 14 var pinyinArray = Pinyin.GetPinyin(s.DisplayName, dictPinYinArray); 15 if (Pinyin.SearchCompare(text, s.DisplayName, pinyinArray)) 16 { 17 return true; 18 } 19 return false; 20 }; 21}

Source: GameListPageViewModel.cs

设计意图:中文用户习惯用拼音全拼或首字母搜索(例如输入 wdzs 匹配"我的世界"式名称)。dictPinYinArray 是实例级缓存字典,避免同一显示名重复计算拼音;空搜索直接放行,保证"清空搜索框"瞬时回到全量。同时支持直接粘贴 AppId 精确/模糊定位。

5. 类型 / 已安装 / 云存档谓词

csharp
1Func<SteamApp, bool> PredicateType(IEnumerable<EnumModel<SteamAppType>>? types) 2{ 3 //var types = AppTypeFiltres.Where(x => x.Enable); 4 return (s) => 5 { 6 if (types == null) 7 return false; 8 if (types.Any()) 9 { 10 if (types.Any(x => x.Value == s.Type)) 11 { 12 return true; 13 } 14 } 15 return false; 16 }; 17} 18 19Func<SteamApp, bool> PredicateInstalled(bool isInstalledFilter) 20{ 21 return s => 22 { 23 if (isInstalledFilter) 24 return s.IsInstalled; 25 return true; 26 }; 27} 28 29Func<SteamApp, bool> PredicateCloudArchive(bool isCloudArchiveFilter) 30{ 31 return s => 32 { 33 if (isCloudArchiveFilter) 34 return s.IsCloudArchive; 35 return true; 36 }; 37}

Source: GameListPageViewModel.cs

注意两个边界语义:

  • PredicateType:当 types == null(集合尚未初始化)返回 false,即未勾选任何类型时列表为空而非全显——这是刻意行为,防止首次进入时全量渲染卡顿;源码中保留了被注释的旧实现,说明该语义经过调整。
  • 已安装 / 云存档开关为正向门控:关闭时不过滤(恒真),打开时只保留满足项,PredicateName 的空文本同理。

6. 启动 / 安装命令

csharp
1public static void InstallOrStartApp(SteamApp app) 2{ 3 string url; 4 if (app.IsInstalled) 5 url = string.Format(SteamApiUrls.STEAM_RUNGAME_URL, app.AppId); 6 else 7 url = string.Format(SteamApiUrls.STEAM_INSTALL_URL, app.AppId); 8 Process2.Start(url, useShellExecute: true); 9}

Source: GameListPageViewModel.cs

不直接拉起游戏进程,而是通过 Steam 自身的 URL 协议(STEAM_RUNGAME_URL / STEAM_INSTALL_URL)交给 Steam 客户端处理:已安装则运行,未安装则打开安装对话框。这样做把启动权限、更新检查、反作弊初始化等全部交还给 Steam,避免绕过客户端造成账号风险。

7. 编辑应用信息(模态子页)

csharp
1public static async void EditAppInfoClick(SteamApp app) 2{ 3 if (app == null) return; 4 var vm = new EditAppInfoPageViewModel(ref app); 5 var result = await IWindowManager.Instance.ShowTaskDialogAsync(vm, Strings.GameList_EditAppInfo, 6 pageContent: new EditAppInfoPage(), okButtonText: Strings.Save, isCancelButton: true, disableScroll: true); 7 8 if (result) 9 { 10 vm.SaveEditAppInfo(); 11 } 12}

Source: GameListPageViewModel.cs

EditAppInfoPageViewModel 通过 ref app 直接持有列表中的 SteamApp 引用,确认保存后调用 SaveEditAppInfo() 写回。由于主列表绑定的是 SteamConnectService.SteamApps 源缓存中的同一对象实例,保存后 UI 上的显示名即时更新(配合 AutoRefresh 机制)。

8. 命令集与子功能入口

构造函数尾部集中声明全部交互命令:

csharp
1ShowHideAppCommand = ReactiveCommand.Create(() => 2{ 3 IWindowManager.Instance.ShowTaskDialogAsync(new HideAppsPageViewModel(), Strings.GameList_HideGameManger, 4 pageContent: new HideAppsPage(), isOkButton: false); 5}); 6RefreshAppCommand = ReactiveCommand.CreateFromTask(SteamConnectService.Current.RefreshGamesListAsync); 7 8AddHideAppListCommand = ReactiveCommand.Create<SteamApp>(AddHideAppList); 9AddAFKAppListCommand = ReactiveCommand.Create<SteamApp>(AddAFKAppList); 10InstallOrStartAppCommand = ReactiveCommand.Create<SteamApp>(InstallOrStartApp); 11EditAppInfoClickCommand = ReactiveCommand.Create<SteamApp>(EditAppInfoClick); 12ManageCloudArchive_ClickCommand = ReactiveCommand.Create<SteamApp>(ManageCloudArchive_Click); 13UnlockAchievement_ClickCommand = ReactiveCommand.Create<SteamApp>(UnlockAchievement_Click); 14NavAppToSteamViewCommand = ReactiveCommand.Create<SteamApp>(NavAppToSteamView); 15NavAppScreenshotToSteamViewCommand = ReactiveCommand.Create<SteamApp>(NavAppToSteamView); 16OpenFolderCommand = ReactiveCommand.Create<SteamApp>(OpenFolder); 17OpenLinkUrlCommand = ReactiveCommand.Create<string>(async url => await Browser2.OpenAsync(url));

Source: GameListPageViewModel.cs

各命令与承载子 ViewModel 的对应关系:

命令行为承载组件
ShowHideAppCommand打开隐藏游戏管理对话框HideAppsPageViewModel + HideAppsPage
RefreshAppCommand重新解析本机库存SteamConnectService.RefreshGamesListAsync(无模态,后台执行)
AddHideAppListCommand / AddAFKAppListCommand把单个游戏加入隐藏/挂机列表HideAppsPageViewModel / IdleAppsPageViewModel 数据源
EditAppInfoClickCommand编辑本地显示名等元数据EditAppInfoPageViewModel
ManageCloudArchive_ClickCommand云存档备份/恢复管理CloudArchiveAppPageViewModel
UnlockAchievement_ClickCommand成就解锁管理AchievementAppPageViewModel
NavAppToSteamViewCommand / NavAppScreenshotToSteamViewCommand用系统浏览器打开 Steam 商店/截图页Browser2.OpenAsync
OpenFolderCommand打开游戏安装目录Process2.Start(本地路径)
OpenLinkUrlCommand通用外链跳转Browser2.OpenAsync(url)

注意 NavAppScreenshotToSteamViewCommand 与 NavAppToSteamViewCommand 都绑定到 NavAppToSteamView 方法——截图页跳转当前复用同一实现(源码中两行均指向 NavAppToSteamView),属于尚未拆分的实现细节。

核心流程

用户首次进入"游戏库"标签页到看到可交互列表的完整时序:

Loading diagram...

关键点:管线搭建发生在构造期(一次性),数据填充发生在激活期(按需)。刷新命令 RefreshAppCommand 只调用核心服务的 RefreshGamesListAsync,不触碰任何 UI 状态——所有传播由 DynamicData 变更流完成,这是该页面代码量小而功能完整的根本原因。

数据模型

页面核心展示实体为 SteamApp(定义于核心层,非本插件),本页依赖其以下成员(均在上述代码中出现):

Loading diagram...
  • SteamApp.AppId:Steam 数字 AppId,用于 URL 拼接与搜索匹配。
  • SteamApp.Type:SteamAppType 枚举(游戏/工具/DLC 等),驱动类型筛选与 CalcTypeCount() 统计。
  • SteamApp.IsInstalled / IsCloudArchive:分别对应两个布尔筛选门控。
  • SteamApp.SizeOnDisk:磁盘占用字节数,作为排序次级键。
  • GameLibrarySettings 实现于插件内 Settings/GameLibrarySettings.cs,接口 IGameLibrarySettings(含 BaseType 分部),通过应用级设置系统持久化(具体存储介质由宿主设置框架决定)。

设置项参考

设置项类型默认说明
GameLibrarySettings.GameTypeFiltresList<SteamAppType>(可空)未设置(全不勾选→列表为空)已启用的应用类型筛选集合;PredicateType 在其为空时返回 false
GameLibrarySettings.GameInstalledFilterbool默认关闭开启后仅显示 IsInstalled == true 的项
GameLibrarySettings.GameCloudArchiveFilterbool默认关闭开启后仅显示 IsCloudArchive == true 的项

设置读写均通过响应式订阅自动完成,无需手动保存按钮。

故障模式、边界与并发

场景行为源码依据
非桌面平台实例化页面抛出 NotSupportedException构造函数 if (!IApplication.IsDesktop()) throw new NotSupportedException();(L12-L15)
搜索文本为空 / DisplayName 为 null空文本放行全部;null 显示名的项一律排除PredicateName(L106-L126)
类型筛选未勾选任何项列表为空(PredicateType 返回 false),不是全显L128-L144
首次激活且库存为空后台线程触发 RefreshGamesListAsync,不阻塞 UIActivation()(L92-L99)
库存后台刷新期间变更流增量推送,UI 集合增量更新,CalcTypeCount() 每次重算管线 Subscribe(_ => CalcTypeCount())(L61-L71)
线程安全所有绑定更新经 .ObserveOn(RxSchedulers.MainThreadScheduler) 回到主线程;拼音缓存 dictPinYinArray 为实例字段,仅在 UI 线程访问L59
编辑信息对话框取消result == false 时跳过 SaveEditAppInfo(),不落盘EditAppInfoClick(L190-L200)

性能与运维要点

  • 拼音缓存:dictPinYinArray(Dictionary<string, string[]>)对每个出现过的显示名只计算一次拼音,把拼音匹配从 O(n·L) 降为 O(n) 查表。
  • 排序稳定性:Ascending(DisplayName).ThenByDescending(SizeOnDisk) 的组合在 DynamicData 中以键比较器实现,增删项时只做局部重排而非全排序。
  • 类型计数成本:CalcTypeCount() 对每个类型枚举做一次 Items.Count(...) 全表扫描,库存极大时(数千项 × 枚举数)在每次管线触发都会执行——这是可观测的最主要重复计算点,若需优化可改为在排序阶段一次聚合。
  • 懒加载:清单解析仅在首次激活且无数据时触发一次,之后由 RefreshAppCommand 手动驱动。

扩展点

  • 新增筛选维度:仿照 PredicateInstalled 实现一个 Func<SteamApp, bool> 工厂,用 WhenAnyValue(...).Select(谓词工厂) 生成可观察谓词,再插入 SteamApps.Connect() 管线即可获得完整的响应式 + 持久化行为;同步在 GameLibrarySettings 增加对应属性以记住偏好。
  • 新增列表项命令:在构造函数尾部追加 ReactiveCommand.Create<SteamApp>(handler),并在视图 axaml 中绑定;复杂功能按既有模式封装为独立 Page/Window ViewModel,经 IWindowManager.Instance.ShowTaskDialogAsync 打开。
  • 插件编译隔离:新代码可使用 WTTS_PLUGIN_GAMELIST 符号做条件编译,不影响其他插件产物。

相关链接

Sources

(1 files)