游戏库管理
游戏库管理是 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 引用。
架构
架构要点说明:
Plugin.cs是插件对外唯一入口,声明模块名(AssemblyInfo.GameList常量"GameList")、插件 Id、显示名(Strings.GameList本地化)与图标(Resources.game),宿主据此把它注册为主界面标签页。GameListPageViewModel继承TabItemViewModel,是唯一数据聚合点:它既订阅核心服务SteamConnectService.SteamApps的变更流,又把用户筛选偏好读写到GameLibrarySettings。- 其余 Page/Window ViewModel 均为按需弹出的子功能页,通过
IWindowManager.Instance.ShowTaskDialogAsync模态打开,与主列表解耦。
数据流水线(核心设计)
主列表没有采用"每次筛选后手动刷新集合"的传统做法,而是在构造函数中一次性搭好一条 DynamicData 响应式管线:
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
这样设计的原因:
SteamApps.Connect()暴露库存源的增量变更(新增/移除/更新项),任何后台刷新(如RefreshGamesListAsync)完成后,管线自动把变更传播到 UI 绑定集合_SteamApps,无需手写刷新逻辑。- 四个
Filter依次串联,每个筛选条件的可观察序列(WhenAnyValue/AutoRefresh)变化时只重建该谓词,链式组合避免了"全量重查"。 - 排序规则固定为 名称升序、同名按磁盘占用降序(
.ThenByDescending(s => s.SizeOnDisk)),保证重复项(同一游戏多账号清单)中占空间最大的排在前面。 .ObserveOn(RxSchedulers.MainThreadScheduler)保证所有绑定更新回到 UI 线程,.Bind(out _SteamApps)直接输出ReadOnlyObservableCollection供视图绑定。- 每次管线触发后调用
CalcTypeCount()重算各SteamAppType的计数,用于类型筛选标签上的数量徽标。
主内容:实现走读
1. 插件装配与常量
插件工程 BD.WTTS.Client.Plugins.GameList.csproj 通过自定义编译符号声明插件身份:
<DefineConstants>WTTS_PLUGIN;WTTS_PLUGIN_GAMELIST;$(DefineConstants)</DefineConstants>Source: BD.WTTS.Client.Plugins.GameList.csproj
插件入口 Plugin.cs 关键成员(均可在源码中对应到常量定义):
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
其中常量集中定义在宿主共享文件中:
public const string GameList = "GameList";
...
public const string GameListId = "00000000-0000-0000-0000-000000000003";Source: AssemblyInfo.Constants.cs
固定 Guid 使插件可被宿主的插件注册表稳定识别与去重;WTTS_PLUGIN_GAMELIST 符号允许宿主与共享代码中针对该插件做条件编译。
2. 页面生命周期
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. 筛选条件与持久化恢复
构造函数中先恢复持久化偏好,再接通变更写回:
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 的翻转,任何勾选变化都会同步回设置并刷新显示字符串:
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. 名称搜索谓词(含拼音)
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. 类型 / 已安装 / 云存档谓词
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. 启动 / 安装命令
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. 编辑应用信息(模态子页)
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. 命令集与子功能入口
构造函数尾部集中声明全部交互命令:
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),属于尚未拆分的实现细节。
核心流程
用户首次进入"游戏库"标签页到看到可交互列表的完整时序:
关键点:管线搭建发生在构造期(一次性),数据填充发生在激活期(按需)。刷新命令 RefreshAppCommand 只调用核心服务的 RefreshGamesListAsync,不触碰任何 UI 状态——所有传播由 DynamicData 变更流完成,这是该页面代码量小而功能完整的根本原因。
数据模型
页面核心展示实体为 SteamApp(定义于核心层,非本插件),本页依赖其以下成员(均在上述代码中出现):
SteamApp.AppId:Steam 数字 AppId,用于 URL 拼接与搜索匹配。SteamApp.Type:SteamAppType枚举(游戏/工具/DLC 等),驱动类型筛选与CalcTypeCount()统计。SteamApp.IsInstalled/IsCloudArchive:分别对应两个布尔筛选门控。SteamApp.SizeOnDisk:磁盘占用字节数,作为排序次级键。GameLibrarySettings实现于插件内Settings/GameLibrarySettings.cs,接口IGameLibrarySettings(含BaseType分部),通过应用级设置系统持久化(具体存储介质由宿主设置框架决定)。
设置项参考
| 设置项 | 类型 | 默认 | 说明 |
|---|---|---|---|
GameLibrarySettings.GameTypeFiltres | List<SteamAppType>(可空) | 未设置(全不勾选→列表为空) | 已启用的应用类型筛选集合;PredicateType 在其为空时返回 false |
GameLibrarySettings.GameInstalledFilter | bool | 默认关闭 | 开启后仅显示 IsInstalled == true 的项 |
GameLibrarySettings.GameCloudArchiveFilter | bool | 默认关闭 | 开启后仅显示 IsCloudArchive == true 的项 |
设置读写均通过响应式订阅自动完成,无需手动保存按钮。
故障模式、边界与并发
| 场景 | 行为 | 源码依据 |
|---|---|---|
| 非桌面平台实例化页面 | 抛出 NotSupportedException | 构造函数 if (!IApplication.IsDesktop()) throw new NotSupportedException();(L12-L15) |
搜索文本为空 / DisplayName 为 null | 空文本放行全部;null 显示名的项一律排除 | PredicateName(L106-L126) |
| 类型筛选未勾选任何项 | 列表为空(PredicateType 返回 false),不是全显 | L128-L144 |
| 首次激活且库存为空 | 后台线程触发 RefreshGamesListAsync,不阻塞 UI | Activation()(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符号做条件编译,不影响其他插件产物。
相关链接
- 插件入口源码:Plugin.cs
- 主页面源码:GameListPageViewModel.cs
- 设置接口:IGameLibrarySettings.cs
- 工程文件:BD.WTTS.Client.Plugins.GameList.csproj
- Steam 连接与库存数据源(
SteamConnectService)、账号管理(GameAccount插件)等内容由同级页面单独介绍。