MVVM 视图模型与导航
SteamTools(Watt Toolkit)桌面客户端基于 MVVM(Model-View-ViewModel) 模式构建整个 UI 层:所有页面、弹窗与列表项都由派生自 ViewModelBase / TabItemViewModel 的视图模型驱动,而页面之间的跳转则统一收敛到 INavigationService 这个平台无关的导航抽象上。本页深入讲解这套视图模型基类的生命周期机制、分层继承体系、导航服务的契约与实际控制流。
Purpose and Scope
本页覆盖以下内容(属于 ui.mvvm-navigation 主题边界内):
- 视图模型基座:
ViewModelBase的激活/停用(Activation/Deactivation)生命周期、CompositeDisposable订阅回收、IsSingleInstance单例视图模型与IViewModelManager的释放协议。 - 标签页视图模型:
TabItemViewModel的托盘菜单、本地化重通知(ResourceService.Subscribe)、PageType导航映射与Items子项结构。 - 导航服务:
INavigationService的完整方法契约(Navigate/GoBack/NavigateFromContext/ Overlay),以及静态Instance单例(Ioc.Get)的取用方式。 - 代码组织惯例:
*.props.cs分部类(partial class)把属性与逻辑拆分到两个文件的模式。
以下内容有意留给兄弟页面,本页只做交叉引用:
- 具体某个页面的业务实现(如加速器页面、ASF 页面)→ 属于各插件/页面自身的文档。
- DI 容器与
Ioc的整体注册机制 → 属于依赖注入/服务装配主题。 ReactiveObject/BaseNotifyPropertyChanged底层通知管线 → 属于响应式基础设施主题。
Overview
为什么需要统一的视图模型基座
Watt Toolkit 是一个跨平台多 UI 框架项目(Avalonia、WPF 等共享同一套 BD.WTTS.Client 核心库),因此 UI 逻辑不能依赖任何具体框架的控件类型。项目采用的方案是:
- 核心库只定义纯 C# 视图模型,通过 ReactiveUI 的
ReactiveObject属性通知机制实现数据绑定; - 各 UI 框架项目只做 View 层,把视图模型绑定到对应的 XAML 页面;
- 导航以
Type(页面类型)为令牌,而不是持有页面实例,由各平台实现INavigationService决定如何构造/缓存页面。
这样一套视图模型可以同时服务于多个 UI 框架,业务逻辑与界面彻底解耦。
关键概念
| 概念 | 说明 |
|---|---|
ViewModelBase | 所有视图模型的抽象基类,管理生命周期与订阅回收 |
TabItemViewModel | 标签页项视图模型,主窗口侧边栏/标签导航的基本单元 |
ViewModelActivator | ReactiveUI 的激活器,配合 WhenActivated 实现"进入页面才激活、离开页面即停用" |
IsSingleInstance | 标记视图模型是否全局唯一;单例 VM 释放时需通过 IViewModelManager 注销 |
INavigationService.Instance | 导航服务静态单例,内部通过 Ioc.Get<INavigationService>() 从 DI 容器解析平台实现 |
NavigationTransitionEffect | 导航过渡动画枚举(转场效果) |
*.props.cs | 分部类约定:属性声明放在 .props.cs,方法逻辑放在同名主文件 |
Architecture
架构要点解读:
- 分层清晰:View 层(平台相关)→ ViewModel 层(平台无关核心库)→ 服务层(接口 + 平台实现)。
INavigationService只暴露Type与object viewModel,不泄漏任何平台类型。 - 继承链:
ViewModelBase→ItemViewModel→TabItemViewModel→ 具体页面 VM。TabItemViewModel同时是"标签项数据"与"导航目标声明"(通过PageType)。 - 两条获取服务的路径:导航通过静态属性
INavigationService.Instance(惰性Ioc.Get);视图模型内部依赖则通过Ioc直接解析。 - 本地化是基类内建的:
TabItemViewModel构造时即订阅ResourceService,语言切换时自动重发Name通知,子类无需重复处理。
视图模型继承体系
ViewModelBase —— 生命周期与资源回收
ViewModelBase 是整个 UI 层的根抽象类,继承自 BaseNotifyPropertyChanged(属性变更通知基础),并实现三个接口:
1public abstract partial class ViewModelBase : BaseNotifyPropertyChanged, IViewModelBase, IActivatableViewModel, IDisposable
2{
3 /// <summary>
4 /// 当前视图模型是否为单例
5 /// </summary>
6 [IgnoreDataMember, MPIgnore, MP2Ignore, N_JsonIgnore, S_JsonIgnore]
7 protected virtual bool IsSingleInstance { get; }
8
9 [IgnoreDataMember, MPIgnore, MP2Ignore, N_JsonIgnore, S_JsonIgnore]
10 public CompositeDisposable CompositeDisposable { get; private set; } = new();
11
12 [IgnoreDataMember, MPIgnore, MP2Ignore, N_JsonIgnore, S_JsonIgnore]
13 public ViewModelActivator Activator { get; protected set; }
14
15 public ViewModelBase()
16 {
17 Activator = new ViewModelActivator();
18
19 this.WhenActivated(disposables =>
20 {
21 Activation();
22
23 CompositeDisposable.Add(Disposable.Create(Deactivation)
24 .DisposeWith(disposables));
25 });
26 }
27}Source: ViewModelBase.cs
这段构造函数是整个生命周期机制的枢纽,值得逐行分析:
ViewModelActivator:ReactiveUI 的激活器。View 层绑定 VM 后调用激活,页面离开时触发反激活,从而让"仅在页面可见期间才需要运行的逻辑"(如定时器、订阅)有明确的起止点。this.WhenActivated(...):把回调挂到激活器上。注意回调里做了两件事:先调用虚方法Activation()(子类重写做进入页面的初始化),再向disposables注册一个Disposable.Create(Deactivation)——即当激活周期结束时自动调用虚方法Deactivation()。CompositeDisposable与disposables的分工:disposables是"本次激活周期"的订阅容器(随页面离开释放);CompositeDisposable是"整个 VM 生命周期"的订阅容器(随 VM Dispose 释放)。子类应根据订阅的作用域选择挂到哪一个。IsSingleInstance:虚属性且默认未赋值(即false),单例页面 VM 通过重写返回true,配合IViewModelManager管理全局唯一实例。
激活/停用状态标志
1[IgnoreDataMember, MPIgnore, MP2Ignore, N_JsonIgnore, S_JsonIgnore]
2public bool IsFirstActivation = true;
3
4[IgnoreDataMember, MPIgnore, MP2Ignore, N_JsonIgnore, S_JsonIgnore]
5public bool IsDeactivation = false;
6
7[IgnoreDataMember, MPIgnore, MP2Ignore, N_JsonIgnore, S_JsonIgnore]
8public bool Disposed { get; private set; }
9
10public virtual void Activation()
11{
12 if (IsFirstActivation)
13 {
14 IsFirstActivation = false;
15 }
16 IsDeactivation = false;
17}
18
19public virtual void Deactivation()
20{
21 IsDeactivation = true;
22}Source: ViewModelBase.cs
三个布尔标志刻画了 VM 的状态机:IsFirstActivation 让子类可以区分"首次进入页面"(做一次性加载)与"再次进入"(做轻量刷新);IsDeactivation 供外部查询当前是否处于停用态;Disposed 保证释放幂等。注意字段上的一串特性(IgnoreDataMember、MPIgnore、MP2Ignore、N_JsonIgnore、S_JsonIgnore)——这是项目中所有 UI 状态字段的标准做法,防止 VM 被 MessagePack/JSON 等多种序列化器误序列化。
释放协议:单例 VM 必须回到 IViewModelManager
1protected virtual void Dispose(bool disposing)
2{
3 if (!Disposed)
4 {
5 if (disposing)
6 {
7 if (IsSingleInstance)
8 {
9 try
10 {
11 IViewModelManager.Instance.Dispose(this);
12 }
13 catch (Exception ex)
14 {
15 Log.Error(nameof(ViewModelBase), ex, "Dispose fail.");
16 }
17 }
18
19 // 释放托管状态(托管对象)
20 Activator?.Dispose();
21 CompositeDisposable?.Dispose();
22 }
23
24 // 释放未托管的资源(未托管的对象)并重写终结器
25 // 将大型字段设置为 null
26 Activator = null!;
27 CompositeDisposable = null!;
28 Disposed = true;
29 }
30}
31
32public void Dispose()
33{
34 // 不要更改此代码。请将清理代码放入"Dispose(bool disposing)"方法中
35 Dispose(disposing: true);
36 GC.SuppressFinalize(this);
37}Source: ViewModelBase.cs
设计意图:
- 标准的
Dispose(bool)模式:Dispose()只做转发并抑制终结器,真正的清理在受保护的虚方法中,子类可重写扩展。 - 单例的注销被 try/catch 包裹:注销失败只记日志(
Log.Error)不抛出,避免一个 VM 释放失败中断整批 VM 的回收——这是典型的"尽力而为清理"策略。 Activator与CompositeDisposable一并释放后置null!:既切断事件引用帮助 GC,也使后续误用快速失败。
TabItemViewModel —— 标签页视图模型
TabItemViewModel 是主窗口标签/侧边栏导航单元的基类,在 ItemViewModel 之上叠加了三类能力:托盘菜单参与、页面类型映射、本地化重通知。
1public abstract partial class TabItemViewModel : ItemViewModel, IReadOnlyName
2{
3 #region TaskBarSubMenu 托盘菜单
4
5 bool _IsTaskBarSubMenu = true;
6
7 public bool IsTaskBarSubMenu
8 {
9 get => _IsTaskBarSubMenu && MenuItems.Any_Nullable();
10 protected set => this.RaiseAndSetIfChanged(ref _IsTaskBarSubMenu, value);
11 }
12
13 IList<MenuItemViewModel>? _MenuItems;
14
15 [IgnoreDataMember, MPIgnore, MP2Ignore, N_JsonIgnore, S_JsonIgnore]
16 public virtual IList<MenuItemViewModel>? MenuItems
17 {
18 get => _MenuItems;
19 protected set => this.RaiseAndSetIfChanged(ref _MenuItems, value);
20 }
21
22 #endregion
23
24 public virtual Type? PageType { get; init; }
25
26 protected TabItemViewModel() : base()
27 {
28 ResourceService.Subscribe(() =>
29 {
30 this.RaisePropertyChanged(nameof(Name));
31 }).AddTo(this);
32 }
33
34 public virtual void Initialize()
35 {
36
37 }
38
39 IEnumerable<ItemViewModel>? _Items;
40
41 public IEnumerable<ItemViewModel>? Items
42 {
43 get => _Items;
44 protected set => this.RaiseAndSetIfChanged(ref _Items, value);
45 }
46}Source: TabItemViewModel.cs
关键成员解读:
| 成员 | 类型 | 用途 |
|---|---|---|
IsTaskBarSubMenu | bool | 是否作为系统托盘子菜单显示;getter 组合了字段值与 MenuItems.Any_Nullable()——只有拥有子菜单项时才可见,这是"空菜单不显示"的短路式防御 |
MenuItems | IList<MenuItemViewModel>? | 托盘子菜单项集合,虚属性允许子类覆盖 |
PageType | Type? | 该标签对应的页面 View 类型(init 只读),是导航到该页的"钥匙" |
Initialize() | void | 虚初始化钩子,默认空实现,由宿主在合适时机调用 |
Items | IEnumerable<ItemViewModel>? | 子项列表(用于嵌套列表型页面) |
Name | (继承自 IReadOnlyName) | 显示名,随语言切换自动重发通知 |
本地化自动刷新的设计意图:构造函数中 ResourceService.Subscribe(...) 订阅语言变更事件,回调里只对 Name 调一次 RaisePropertyChanged。.AddTo(this) 把订阅挂到自身 CompositeDisposable,VM 释放时自动退订。这样任何派生的页面标签在切换语言后立即更新标题,子类完全不用感知本地化机制。
数据模型型 ViewModel
除了页面级 VM,项目里还存在大量"数据模型 VM"——直接继承 ReactiveObject(或 ReactiveSerializationObject)而非 ViewModelBase,用于列表中的单项:
public sealed class BotViewModel : ReactiveObjectSource: BotViewModel.cs
public class ProxyDomainViewModel : ReactiveObjectSource: ProxyDomainViewModel.cs
这类 VM 没有 Activator 生命周期(不参与页面激活),只提供属性变更通知以驱动列表绑定;需要持久化的则改继承 ReactiveSerializationObject 并配合 [MP2Obj(MP2SerializeLayout.Explicit)] 显式序列化布局,如 XunYouGameViewModel。
分部类组织:*.props.cs 约定
大型页面 VM 普遍拆成两个文件:属性声明在 XxxPageViewModel.props.cs,方法/命令逻辑在 XxxPageViewModel.cs,两处声明同名分部类:
public partial class ArchiSteamFarmExePathSettingsPageViewModel : ViewModelBaseSource: ArchiSteamFarmExePathSettingsPageViewModel.cs
public sealed partial class ArchiSteamFarmPlusPageViewModel : TabItemViewModelSource: ArchiSteamFarmPlusPageViewModel.props.cs
这样拆分的意图:属性(绑定目标)与行为(命令/异步逻辑)分文件维护,降低单个文件体积,同时保持一个类的单一继承声明——继承基类只在 .props.cs 中声明一次。
导航服务 INavigationService
接口契约
1public interface INavigationService
2{
3 static INavigationService Instance { get; } = Ioc.Get<INavigationService>();
4
5 object? GetViewModelToPageContent(object viewModel, bool isCreateInstance = true);
6
7 void Navigate(Type? t, NavigationTransitionEffect effect = NavigationTransitionEffect.None, bool useCache = true);
8
9 void GoBack(Type? t = null);
10
11 void NavigateFromContext(object dataContext, NavigationTransitionEffect transitionInfo = NavigationTransitionEffect.None);
12
13 void ShowControlDefinitionOverlay(Type targetType);
14
15 void ClearOverlay();
16}Source: INavigationService.cs
这是导航能力的完整门面,逐个方法说明:
| 方法 | 参数 | 行为 |
|---|---|---|
GetViewModelToPageContent | viewModel、isCreateInstance = true | 由视图模型取页面内容。isCreateInstance=false 时只查缓存不新建实例,用于探测"页面是否已存在" |
Navigate | t: Type?、effect = None、useCache = true | 前进导航到指定页面类型;可指定转场动画;默认复用缓存实例 |
GoBack | t: Type? = null | 后退导航;传入类型可限定"至少退回到哪个页面" |
NavigateFromContext | dataContext、transitionInfo = None | 从数据上下文(VM 实例)发起导航——用于 VM 内部无法拿到页面 Type 的场景 |
ShowControlDefinitionOverlay | targetType: Type | 显示控件定义的覆盖层(Overlay UI) |
ClearOverlay | 无 | 清除覆盖层 |
为什么用 Type 而不是实例作为导航令牌:接口位于平台无关的核心库,而页面 View 是平台相关类型。以 Type 为令牌让核心库完全不引用任何 View 类型,同时天然支持实例缓存(useCache)——同一页面重复导航时复用既有 VM/Page,避免状态丢失与重复构造。
静态 Instance 属性的设计:static INavigationService Instance { get; } = Ioc.Get<INavigationService>(); 是一个静态属性初始化器,首次访问时从 DI 容器解析平台实现并固定下来。这为无法走构造函数注入的场景(如托盘菜单回调、XAML 静态命令)提供了便捷入口,代价是首次访问时机构建与全局可变单例——因此它定位为"便捷入口",正式服务仍应通过 Ioc/构造注入获取。
实际调用示例:托盘菜单导航
menus.Add(new NativeMenuItem { Header = Strings.Settings, Command = ReactiveCommand.Create(() => { INavigationService.Instance.Navigate(typeof(SettingsPage)); }) });Source: App.axaml.cs
这是导航最典型的使用形态:在托盘 NativeMenuItem 的命令里,通过 INavigationService.Instance.Navigate(typeof(SettingsPage)) 跳到设置页。注意这里:
- 使用默认参数
effect = None、useCache = true(复用设置页实例,保留用户已填内容); ReactiveCommand.Create包装为可绑定命令,供NativeMenuItem.Command绑定;- VM/View 代码不需要知道设置页如何被构造——这一切由平台
INavigationService实现封装。
Core Flow:导航的端到端时序
时序要点:
- 导航入口通常来自 UI 事件(按钮/托盘菜单命令),一律通过
INavigationService.Instance静态单例进入。 - VM 激活与页面挂载联动:页面进入可视树时激活
ViewModelActivator,触发WhenActivated回调链(Activation()+ 注册停用钩子)——这解释了为什么 VM 逻辑能"只在页面可见时运行"。 - 停用是自动的:离开页面时 ReactiveUI 释放该激活周期的
disposables,进而触发Disposable.Create(Deactivation),把IsDeactivation置真。 - 缓存复用:
useCache=true(默认)时同一页面类型复用实例,VM 状态得以保留;Activation/Deactivation循环可以反复发生而Dispose只发生一次。
视图模型生命周期状态机
边界条件说明:
IsFirstActivation只在第一次Activation()时被置为false,此后每次重新进入页面都是普通激活。Dispose幂等(Disposed标志 +if (!Disposed)守卫),重复调用安全。- 单例 VM(
IsSingleInstance == true)在 Dispose 时额外调用IViewModelManager.Instance.Dispose(this)完成全局注销,且该调用被 try/catch 包裹防止单点失败。
Usage Examples
编写一个页面标签视图模型(分部类模式)
public sealed partial class ArchiSteamFarmPlusPageViewModel : TabItemViewModelSource: ArchiSteamFarmPlusPageViewModel.props.cs
public partial class ArchiSteamFarmExePathSettingsPageViewModel : ViewModelBaseSource: ArchiSteamFarmExePathSettingsPageViewModel.cs
实践要点:
- 主窗口标签页继承
TabItemViewModel;弹窗/二级页面继承ViewModelBase。 - 属性放
.props.cs,命令与异步逻辑放主文件,两文件声明同一个分部类。
在命令中发起导航(含转场与缓存控制)
menus.Add(new NativeMenuItem { Header = Strings.Settings, Command = ReactiveCommand.Create(() => { INavigationService.Instance.Navigate(typeof(SettingsPage)); }) });Source: App.axaml.cs
可按需改用完整签名控制转场与缓存:Navigate(typeof(SettingsPage), NavigationTransitionEffect.FromRight, useCache: false)——不缓存则每次全新实例,适合一次性向导页。
定义不参与页面生命周期的数据模型 VM
public class ProxyDomainGroupViewModel : ReactiveObjectSource: ProxyDomainGroupViewModel.cs
public partial class XunYouGameViewModel : ReactiveSerializationObject, IReactiveObjectSource: XunYouGameViewModel.cs
列表项 VM 只需属性通知即可直接继承 ReactiveObject;需要落盘持久化的加上 ReactiveSerializationObject 基类与 MessagePack 特性。
Configuration Options
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
IsSingleInstance(VM 重写) | bool(protected virtual) | false | 标记单例视图模型;为 true 时 Dispose 会向 IViewModelManager 注销本实例 |
NavigationTransitionEffect(Navigate 参数) | 枚举 | None | 页面切换动画效果 |
useCache(Navigate 参数) | bool | true | 是否复用已缓存页面实例;false 时每次导航新建 |
isCreateInstance(GetViewModelToPageContent 参数) | bool | true | 是否允许创建新实例;false 用于只读探测 |
IsTaskBarSubMenu(VM 属性) | bool | true | 是否出现在系统托盘子菜单;实际可见性还需 MenuItems 非空 |
PageType(VM 属性) | Type?(init) | null | 标签对应的页面 View 类型,导航映射的声明处 |
API Reference
ViewModelBase.Activation(): void
虚拟生命周期钩子,页面进入可视树(激活周期开始)时由 WhenActivated 回调触发。首次激活会将 IsFirstActivation 置为 false 并将 IsDeactivation 复位为 false。子类重写以执行进入页面的加载逻辑。
ViewModelBase.Deactivation(): void
虚拟生命周期钩子,激活周期结束时通过 Disposable.Create(Deactivation) 自动触发,将 IsDeactivation 置为 true。子类重写以暂停定时器、退订临时事件等。
ViewModelBase.Dispose(disposing: bool): void / Dispose(): void
标准 Dispose 模式。释放 Activator 与 CompositeDisposable;若 IsSingleInstance 为 true,先调用 IViewModelManager.Instance.Dispose(this) 注销(异常仅记录日志)。幂等,由 Disposed 标志守护。
TabItemViewModel.Initialize(): void
虚拟初始化钩子,默认空实现。由宿主在页面装配阶段调用,用于延迟初始化(避免在构造函数中做重活)。
INavigationService.Navigate(t: Type?, effect: NavigationTransitionEffect = None, useCache: bool = true): void
前进导航到指定页面类型。t 为目标页面 View 类型;effect 指定转场动画;useCache 控制是否复用缓存实例。
Parameters:
t(Type?):目标页面类型,可为空。effect(NavigationTransitionEffect):转场效果,默认None。useCache(bool):是否使用缓存实例,默认true。
INavigationService.GoBack(t: Type? = null): void
后退导航。可选传入类型限定回退终点。
INavigationService.GetViewModelToPageContent(viewModel: object, isCreateInstance: bool = true): object?
由视图模型实例解析对应的页面内容。返回页面内容对象;isCreateInstance=false 时若缓存不存在则返回空而不创建。
INavigationService.NavigateFromContext(dataContext: object, transitionInfo: NavigationTransitionEffect = None): void
从数据上下文发起导航,适用于 VM 内无法获取页面 Type 的场景。
INavigationService.ShowControlDefinitionOverlay(targetType: Type): void / ClearOverlay(): void
显示/清除控件定义覆盖层。
Failure Modes, Edge Cases & Concurrency
- 单例注销失败不中断回收:
IViewModelManager.Instance.Dispose(this)被 try/catch 包裹,异常仅写入Log.Error。后果是极端情况下容器可能残留失效引用,但不会影响其他 VM 的释放。 - Dispose 幂等:
Disposed标志 +if (!Disposed)守卫保证重复调用Dispose()安全;释放后Activator/CompositeDisposable被置null!,误用会以 NRE 快速暴露。 - 空托盘菜单不可见:
IsTaskBarSubMenu的 getter 短路_IsTaskBarSubMenu && MenuItems.Any_Nullable(),MenuItems为 null/空时标签不出现在托盘子菜单,避免空菜单项。 - 激活周期与 VM 生命周期解耦:
Activation/Deactivation可循环多次(页面反复进出),而Dispose只发生一次。子类若把一次性初始化写进Activation()而不检查IsFirstActivation,会在每次进入页面时重复执行——基类提供该标志正是为区分这两种场景。 - 静态
Instance的惰性初始化:INavigationService.Instance在首次访问时才从Ioc解析并缓存;若在 DI 容器装配完成前访问会失败。因此该入口适合 UI 层(已过启动装配阶段)使用。 NavigationTransitionEffect默认None:需要动画的调用必须显式传入,接口不做任何隐式默认动画。
Performance / Operational Notes & Extension Points
- 页面实例缓存降低重建成本:
Navigate默认useCache=true,高频切换页面时复用 View/VM 实例,避免重复构造与状态丢失;一次性页面显式传useCache:false以释放内存压力。 - 本地化订阅随 VM 释放自动退订:
ResourceService.Subscribe(...).AddTo(this)把订阅并入CompositeDisposable,VM Dispose 即退订,无需子类手写反订阅,杜绝语言切换事件的订阅泄漏。 .AddTo(this)扩展模式:任何IDisposable订阅都可通过AddTo(viewModel)挂到 VM 的CompositeDisposable,是项目中管理订阅生命周期的标准手法。- 扩展点 1 —— 新增页面:继承
TabItemViewModel(标签页)或ViewModelBase(普通页),在.props.cs声明属性,重写Activation()/Deactivation()挂接生命周期,通过PageType或调用Navigate(typeof(...))接入导航。 - 扩展点 2 —— 新增导航行为:实现
INavigationService接口(GetViewModelToPageContent/Navigate/GoBack/NavigateFromContext/Overlay 两方法),注册到Ioc容器即可替换整套导航(如为测试或新平台提供实现),上层调用代码零改动。 - 扩展点 3 —— 单例页面:重写
IsSingleInstance => true,由IViewModelManager统一管理实例生命周期。 - 扩展点 4 —— 菜单项:为
TabItemViewModel.MenuItems填充MenuItemViewModel即可让页面出现在托盘子菜单。
Related Links
- ViewModelBase.cs — 视图模型根基类与生命周期实现
- TabItemViewModel.cs — 标签页视图模型基类
- INavigationService.cs — 导航服务接口契约
- App.axaml.cs — 托盘菜单导航实际调用处
- BotViewModel.cs — 数据模型型 VM 示例
- XunYouGameViewModel.cs — 可序列化数据模型 VM 示例
- ArchiSteamFarmPlusPageViewModel.props.cs — 页面 VM 分部类模式示例