Repository Wiki
BeyondDimension/SteamTools

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 逻辑不能依赖任何具体框架的控件类型。项目采用的方案是:

  1. 核心库只定义纯 C# 视图模型,通过 ReactiveUI 的 ReactiveObject 属性通知机制实现数据绑定;
  2. 各 UI 框架项目只做 View 层,把视图模型绑定到对应的 XAML 页面;
  3. 导航以 Type(页面类型)为令牌,而不是持有页面实例,由各平台实现 INavigationService 决定如何构造/缓存页面。

这样一套视图模型可以同时服务于多个 UI 框架,业务逻辑与界面彻底解耦。

关键概念

概念说明
ViewModelBase所有视图模型的抽象基类,管理生命周期与订阅回收
TabItemViewModel标签页项视图模型,主窗口侧边栏/标签导航的基本单元
ViewModelActivatorReactiveUI 的激活器,配合 WhenActivated 实现"进入页面才激活、离开页面即停用"
IsSingleInstance标记视图模型是否全局唯一;单例 VM 释放时需通过 IViewModelManager 注销
INavigationService.Instance导航服务静态单例,内部通过 Ioc.Get<INavigationService>() 从 DI 容器解析平台实现
NavigationTransitionEffect导航过渡动画枚举(转场效果)
*.props.cs分部类约定:属性声明放在 .props.cs,方法逻辑放在同名主文件

Architecture

Loading diagram...

架构要点解读:

  • 分层清晰:View 层(平台相关)→ ViewModel 层(平台无关核心库)→ 服务层(接口 + 平台实现)。INavigationService 只暴露 Type 与 object viewModel,不泄漏任何平台类型。
  • 继承链:ViewModelBase → ItemViewModel → TabItemViewModel → 具体页面 VM。TabItemViewModel 同时是"标签项数据"与"导航目标声明"(通过 PageType)。
  • 两条获取服务的路径:导航通过静态属性 INavigationService.Instance(惰性 Ioc.Get);视图模型内部依赖则通过 Ioc 直接解析。
  • 本地化是基类内建的:TabItemViewModel 构造时即订阅 ResourceService,语言切换时自动重发 Name 通知,子类无需重复处理。

视图模型继承体系

ViewModelBase —— 生命周期与资源回收

ViewModelBase 是整个 UI 层的根抽象类,继承自 BaseNotifyPropertyChanged(属性变更通知基础),并实现三个接口:

csharp
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 管理全局唯一实例。

激活/停用状态标志

csharp
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

csharp
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 之上叠加了三类能力:托盘菜单参与、页面类型映射、本地化重通知。

csharp
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

关键成员解读:

成员类型用途
IsTaskBarSubMenubool是否作为系统托盘子菜单显示;getter 组合了字段值与 MenuItems.Any_Nullable()——只有拥有子菜单项时才可见,这是"空菜单不显示"的短路式防御
MenuItemsIList<MenuItemViewModel>?托盘子菜单项集合,虚属性允许子类覆盖
PageTypeType?该标签对应的页面 View 类型(init 只读),是导航到该页的"钥匙"
Initialize()void虚初始化钩子,默认空实现,由宿主在合适时机调用
ItemsIEnumerable<ItemViewModel>?子项列表(用于嵌套列表型页面)
Name(继承自 IReadOnlyName)显示名,随语言切换自动重发通知

本地化自动刷新的设计意图:构造函数中 ResourceService.Subscribe(...) 订阅语言变更事件,回调里只对 Name 调一次 RaisePropertyChanged。.AddTo(this) 把订阅挂到自身 CompositeDisposable,VM 释放时自动退订。这样任何派生的页面标签在切换语言后立即更新标题,子类完全不用感知本地化机制。

数据模型型 ViewModel

除了页面级 VM,项目里还存在大量"数据模型 VM"——直接继承 ReactiveObject(或 ReactiveSerializationObject)而非 ViewModelBase,用于列表中的单项:

csharp
public sealed class BotViewModel : ReactiveObject

Source: BotViewModel.cs

csharp
public class ProxyDomainViewModel : ReactiveObject

Source: ProxyDomainViewModel.cs

这类 VM 没有 Activator 生命周期(不参与页面激活),只提供属性变更通知以驱动列表绑定;需要持久化的则改继承 ReactiveSerializationObject 并配合 [MP2Obj(MP2SerializeLayout.Explicit)] 显式序列化布局,如 XunYouGameViewModel。

分部类组织:*.props.cs 约定

大型页面 VM 普遍拆成两个文件:属性声明在 XxxPageViewModel.props.cs,方法/命令逻辑在 XxxPageViewModel.cs,两处声明同名分部类:

csharp
public partial class ArchiSteamFarmExePathSettingsPageViewModel : ViewModelBase

Source: ArchiSteamFarmExePathSettingsPageViewModel.cs

csharp
public sealed partial class ArchiSteamFarmPlusPageViewModel : TabItemViewModel

Source: ArchiSteamFarmPlusPageViewModel.props.cs

这样拆分的意图:属性(绑定目标)与行为(命令/异步逻辑)分文件维护,降低单个文件体积,同时保持一个类的单一继承声明——继承基类只在 .props.cs 中声明一次。

导航服务 INavigationService

接口契约

csharp
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

这是导航能力的完整门面,逐个方法说明:

方法参数行为
GetViewModelToPageContentviewModel、isCreateInstance = true由视图模型取页面内容。isCreateInstance=false 时只查缓存不新建实例,用于探测"页面是否已存在"
Navigatet: Type?、effect = None、useCache = true前进导航到指定页面类型;可指定转场动画;默认复用缓存实例
GoBackt: Type? = null后退导航;传入类型可限定"至少退回到哪个页面"
NavigateFromContextdataContext、transitionInfo = None从数据上下文(VM 实例)发起导航——用于 VM 内部无法拿到页面 Type 的场景
ShowControlDefinitionOverlaytargetType: Type显示控件定义的覆盖层(Overlay UI)
ClearOverlay无清除覆盖层

为什么用 Type 而不是实例作为导航令牌:接口位于平台无关的核心库,而页面 View 是平台相关类型。以 Type 为令牌让核心库完全不引用任何 View 类型,同时天然支持实例缓存(useCache)——同一页面重复导航时复用既有 VM/Page,避免状态丢失与重复构造。

静态 Instance 属性的设计:static INavigationService Instance { get; } = Ioc.Get<INavigationService>(); 是一个静态属性初始化器,首次访问时从 DI 容器解析平台实现并固定下来。这为无法走构造函数注入的场景(如托盘菜单回调、XAML 静态命令)提供了便捷入口,代价是首次访问时机构建与全局可变单例——因此它定位为"便捷入口",正式服务仍应通过 Ioc/构造注入获取。

实际调用示例:托盘菜单导航

csharp
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:导航的端到端时序

Loading diagram...

时序要点:

  1. 导航入口通常来自 UI 事件(按钮/托盘菜单命令),一律通过 INavigationService.Instance 静态单例进入。
  2. VM 激活与页面挂载联动:页面进入可视树时激活 ViewModelActivator,触发 WhenActivated 回调链(Activation() + 注册停用钩子)——这解释了为什么 VM 逻辑能"只在页面可见时运行"。
  3. 停用是自动的:离开页面时 ReactiveUI 释放该激活周期的 disposables,进而触发 Disposable.Create(Deactivation),把 IsDeactivation 置真。
  4. 缓存复用:useCache=true(默认)时同一页面类型复用实例,VM 状态得以保留;Activation/Deactivation 循环可以反复发生而 Dispose 只发生一次。

视图模型生命周期状态机

Loading diagram...

边界条件说明:

  • IsFirstActivation 只在第一次 Activation() 时被置为 false,此后每次重新进入页面都是普通激活。
  • Dispose 幂等(Disposed 标志 + if (!Disposed) 守卫),重复调用安全。
  • 单例 VM(IsSingleInstance == true)在 Dispose 时额外调用 IViewModelManager.Instance.Dispose(this) 完成全局注销,且该调用被 try/catch 包裹防止单点失败。

Usage Examples

编写一个页面标签视图模型(分部类模式)

csharp
public sealed partial class ArchiSteamFarmPlusPageViewModel : TabItemViewModel

Source: ArchiSteamFarmPlusPageViewModel.props.cs

csharp
public partial class ArchiSteamFarmExePathSettingsPageViewModel : ViewModelBase

Source: ArchiSteamFarmExePathSettingsPageViewModel.cs

实践要点:

  • 主窗口标签页继承 TabItemViewModel;弹窗/二级页面继承 ViewModelBase。
  • 属性放 .props.cs,命令与异步逻辑放主文件,两文件声明同一个分部类。

在命令中发起导航(含转场与缓存控制)

csharp
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

csharp
public class ProxyDomainGroupViewModel : ReactiveObject

Source: ProxyDomainGroupViewModel.cs

csharp
public partial class XunYouGameViewModel : ReactiveSerializationObject, IReactiveObject

Source: XunYouGameViewModel.cs

列表项 VM 只需属性通知即可直接继承 ReactiveObject;需要落盘持久化的加上 ReactiveSerializationObject 基类与 MessagePack 特性。

Configuration Options

选项类型默认值说明
IsSingleInstance(VM 重写)bool(protected virtual)false标记单例视图模型;为 true 时 Dispose 会向 IViewModelManager 注销本实例
NavigationTransitionEffect(Navigate 参数)枚举None页面切换动画效果
useCache(Navigate 参数)booltrue是否复用已缓存页面实例;false 时每次导航新建
isCreateInstance(GetViewModelToPageContent 参数)booltrue是否允许创建新实例;false 用于只读探测
IsTaskBarSubMenu(VM 属性)booltrue是否出现在系统托盘子菜单;实际可见性还需 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 即可让页面出现在托盘子菜单。

Sources

(3 files)
src/BD.WTTS.Client/Services/UI/Widgets
src/BD.WTTS.Client/UI/ViewModels/Abstractions
src/BD.WTTS.Client/UI/ViewModels/Abstractions/Items/Tabs