自在学

我们与你共同进步

  • 分类课程
  • 文章
  • 工作台
  • 订阅

  • 关于我们
  • 隐私政策
  • 使用条款

探索

  • 分类课程
  • 文章
  • 工作台
  • 订阅

网站信息

  • 关于我们
  • 隐私政策
  • 使用条款

加入社区

自在学学习社区微信二维码

微信扫码,交流学习

株洲市自在学教育科技有限公司© 2025 - 2026 版权所有

© 2025 - 2026 株洲市自在学教育科技有限公司 版权所有

湘公网安备43020302000292号|湘ICP备2025148919号-1
分类课程工作台文章订阅
分类课程工作台文章价格

C#

  1. 01C# 入门:从第一行代码到可运行的小程序
  2. 02函数与逻辑:从方法契约到 Lambda
  3. 03面向对象:从有效对象到可靠边界
  4. 04C# 类型与引用:从复制语义到安全转换
  5. 05C# 继承与运行时多态
  6. 06C# 接口与抽象:从契约到可测试设计
  7. 07C# 异步与 JSON:从 Task 到可靠数据边界
  8. 08C# 错误处理:从异常传播到可靠边界
  9. 09C# LINQ:从集合流水线到可靠的数据边界
  10. 10C# 委托与事件:从类型安全回调到生命周期管理
  11. 11C# MVVM:从可绑定状态到可测试交互
正在加载课程章节内容
课程编程C#C# MVVM:从可绑定状态到可测试交互

C# MVVM:从可绑定状态到可测试交互

当一个页面只有两个按钮时,把逻辑写进界面事件似乎最省事;等到加载、验证、保存、错误提示和导航同时出现,控件与业务状态就会纠缠在一起。MVVM 的意义不是增加三个文件夹,而是让界面只负责展示,让交互状态可以脱离 UI 独立运行和测试。

本章用“专注任务板”贯穿全部概念:输入任务、添加到列表、筛选完成状态、异步保存、显示错误并打开详情。示例只依赖通用 C# 契约;DataContext 与 BindingContext 等框架差异会明确放在边界处。

View、ViewModel 与 Model 之间的职责和数据流。

学习目标与实现路线

完成本章后,你应该能够:

  1. 划清 View、ViewModel、Model 与服务的职责。
  2. 选择 OneWay、TwoWay 等绑定模式,并正确设置绑定上下文。
  3. 用 INotifyPropertyChanged 和 ObservableCollection<T> 发出精确通知。
  4. 用 ICommand、同步/异步 RelayCommand 表达交互意图。
  5. 使用 CommunityToolkit.Mvvm 减少样板,同时读懂生成代码的行为。
  6. 把验证、导航、错误与测试放到清晰边界内。

路线是:先建立职责图,再接通绑定通知,随后处理集合与命令,最后把异步服务、验证和测试组合成完整页面。

本节自测

1
MVVM 最重要的收益是减少代码行数。
2
让 ViewModel 只依赖普通 C# 契约可以带来哪些好处?

三类角色:按变化原因拆分

判断一段代码放哪里,不要只看它“和页面有关”,而要看它因为什么变化:

角色负责不应直接负责
View布局、样式、焦点、动画、控件级行为、绑定声明业务校验、保存流程、直接拼装仓储
ViewModel页面状态、命令、验证消息、服务协调持有窗口、按钮、页面实例
Model / 服务领域规则、实体、数据访问、远程调用XAML、控件可见性、页面导航细节

任务实体保持领域含义:

csharp
public sealed record TodoItem(Guid Id, string Title, bool Completed);
 
public interface ITodoService
{
    Task<IReadOnlyList<TodoItem>> LoadAsync(CancellationToken token);
    Task SaveAsync(IReadOnlyList<TodoItem> items, CancellationToken token);
}

“标题不能为空”既可能是领域不变量,也可能需要即时输入反馈。领域层负责最终拒绝无效数据;ViewModel 可以提前暴露友好的错误状态,但不能替代领域检查。

本节自测

3
按钮按下时播放波纹动画,这段逻辑最适合放在哪一层?
4
只要 ViewModel 在保存前检查空标题,领域或服务边界就无需再次保护数据有效性。

BindingContext / DataContext 与绑定模式

绑定上下文是 View 查找属性和命令的根。WPF、WinUI 常称 DataContext,MAUI 常称 BindingContext;命名不同,作用相同。

csharp
// 框架组合根中建立 View 与 ViewModel 的关系。
view.DataContext = todoViewModel;      // 一类桌面框架
view.BindingContext = todoViewModel;   // 一类跨平台框架

绑定模式决定值的传播方向:

模式方向典型用途
OneWayViewModel → View标题、忙碌状态、统计文本
TwoWayViewModel ↔ View可编辑输入、开关、选择值
OneTime初始读取一次初始化后不会变化的文案
OneWayToSourceView → ViewModel少见的只回传状态场景

输入框还要决定更新时机:每次键入、失去焦点或显式提交。即时验证适合每次键入;昂贵搜索应加防抖,而不是每个字符都发请求。

本节自测

5
只用于显示加载进度的文本通常应采用哪种绑定模式?
6
TwoWay 适合所有属性,因为双向绑定总能让状态同步得更完整。

INotifyPropertyChanged:让单个状态可观察

绑定引擎不会持续轮询。ViewModel 通过 INotifyPropertyChanged 告诉 View 哪个属性失效:

csharp
using System.ComponentModel;
using System.Runtime.CompilerServices;
 
public abstract class ObservableViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;
 
    protected bool SetProperty<T>(
        ref T field,
        T value,
        [CallerMemberName] string? propertyName = null)
    {
        if (EqualityComparer<T>.Default.Equals(field, value))
            return false;
 
        field = value;
        OnPropertyChanged(propertyName);
        return true;
    }
 
    protected void OnPropertyChanged(
        [CallerMemberName] string? propertyName = null) =>
        PropertyChanged?.Invoke(this, new(propertyName));
}

SetProperty 的顺序很重要:比较、赋值、通知。相同值不通知,能避免多余刷新和命令状态重算。

csharp
private string _newTitle = "";
public string NewTitle
{
    get => _newTitle;
    set
    {
        if (SetProperty(ref _newTitle, value))
        {
            OnPropertyChanged(nameof(CanAdd));
        }
    }
}
 
public bool CanAdd => !string.IsNullOrWhiteSpace(NewTitle);

上例说明“派生属性也要通知”。基类提供受保护的 OnPropertyChanged,让子类不必也不能直接触发基类事件。

从 setter、SetProperty 到 PropertyChanged 和界面刷新的通知环。

本节自测

7
属性被赋予相同值时仍重复触发 PropertyChanged,可能造成哪些问题?
8
当 View 直接绑定派生属性 CanAdd 时,NewTitle 改变后只通知 NewTitle 就一定足够。

ObservableCollection:结构变化不等于元素变化

普通 List<T> 不会在增删时通知 UI。ObservableCollection<T> 会发送 CollectionChanged:

csharp
public ObservableCollection<TodoItemViewModel> Items { get; } = [];
 
Items.Add(new TodoItemViewModel(item));
Items.Remove(selected);

它只负责集合结构。若某个元素的 Completed 改变,元素自身仍要实现属性通知。替换整个集合时,也要对集合属性发送 PropertyChanged。

集合通常与 UI 线程有亲和性。后台加载完成后,不要假定能从任意线程直接批量修改已绑定集合;应通过框架调度器回到 UI 线程,或先在后台构造普通列表,再在边界一次更新。

大量数据还要考虑分页、虚拟化和批量通知。逐条向数万项集合 Add,即使逻辑正确,也可能让界面频繁重排。

本节自测

9
ObservableCollection 会自动为集合元素的 Completed 属性变化发送 PropertyChanged。
10
后台线程直接修改已绑定的 ObservableCollection 可能导致哪些后果?

ICommand 与 RelayCommand:把手势变成意图

ICommand 统一表达“能否执行”与“执行什么”:

csharp
public interface ICommand
{
    event EventHandler? CanExecuteChanged;
    bool CanExecute(object? parameter);
    void Execute(object? parameter);
}

教学版 RelayCommand:

csharp
public sealed class RelayCommand(Action execute, Func<bool>? canExecute = null)
    : ICommand
{
    public event EventHandler? CanExecuteChanged;
    public bool CanExecute(object? p) => canExecute?.Invoke() ?? true;
    public void Execute(object? p) => execute();
    public void NotifyCanExecuteChanged() =>
        CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}

命令依赖 NewTitle 时,该属性变化后必须刷新 CanExecute。View 只绑定命令,不再复制“空文本不可添加”的判断。

csharp
AddCommand = new RelayCommand(
    () =>
    {
        Items.Add(new TodoItemViewModel(NewTitle));
        NewTitle = "";
    },
    () => !string.IsNullOrWhiteSpace(NewTitle));

集合通知、同步命令与异步命令状态之间的协作。

本节自测

11
当 CanExecute 依赖 NewTitle 时,NewTitle 改变后应采取什么做法?
12
把整个控件实例作为命令参数,有助于保持 ViewModel 与 UI 解耦。

异步命令:忙碌、错误、取消与重复点击

异步交互必须显式建模,而不是把 async void 塞进同步命令:

csharp
private bool _isBusy;
private string? _errorMessage;
 
public bool IsBusy
{
    get => _isBusy;
    private set => SetProperty(ref _isBusy, value);
}
 
public string? ErrorMessage
{
    get => _errorMessage;
    private set => SetProperty(ref _errorMessage, value);
}
csharp
private async Task SaveAsync(CancellationToken token)
{
    if (IsBusy) return;
 
    try
    {
        IsBusy = true;
        ErrorMessage = null;
        await _todoService.SaveAsync(Items.Select(x => x.Model).ToList(), token);
    }
    catch (OperationCanceledException) when (token.IsCancellationRequested)
    {
        // 用户取消不是业务错误。
    }
    catch (Exception ex)
    {
        ErrorMessage = ToUserMessage(ex);
    }
    finally
    {
        IsBusy = false;
    }
}

命令应在忙碌时禁用或选择明确的并发策略;取消令牌需要一路传到服务。异常应变成可绑定状态或交给统一错误边界,不能静默吞掉。

本节自测

13
为什么通常要在 finally 中把 IsBusy 恢复为 false?
14
用户主动取消与网络错误应始终显示完全相同的错误提示。

CommunityToolkit.Mvvm:减少样板而不隐藏机制

Toolkit 用基类、命令和源生成器实现同一套通知协议:

csharp
public partial class TodoBoardViewModel : ObservableObject
{
    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(AddCommand))]
    private string newTitle = "";
 
    public ObservableCollection<TodoItemViewModel> Items { get; } = [];
 
    [RelayCommand(CanExecute = nameof(CanAdd))]
    private void Add()
    {
        Items.Add(new(NewTitle));
        NewTitle = "";
    }
 
    private bool CanAdd() => !string.IsNullOrWhiteSpace(NewTitle);
}

源生成器创建公开属性、PropertyChanged 通知与 AddCommand。特性不会自动决定架构边界;服务仍应注入,异常仍要处理,命令是否允许并发仍要设计。

MVVM Toolkit 从特性生成属性通知和命令的结构。

本节自测

15
CommunityToolkit.Mvvm 改变了 MVVM 的属性通知原理。
16
使用 RelayCommand 特性后,命令测试仍应覆盖哪些内容?

转换器、格式化与 View 边界

布尔状态到可见性、颜色或图标的转换属于表现层。简单框架可用转换器,支持表达式的框架也可直接声明:

csharp
public sealed class InverseBooleanConverter
{
    public object Convert(object value) => value is bool b && !b;
}

不要把业务判断藏在转换器里。IsOverdue 可以由 ViewModel 或领域对象表达;“过期时显示橙色”由 View 决定。日期、货币等文化格式也优先放绑定格式或本地化服务,而不是把预格式化字符串扩散到领域模型。

Value converter 应保持无副作用、快速、确定。复杂异步操作绝不能放在转换器中,因为绑定引擎可能多次调用它。

本节自测

17
关于“任务过期”和“过期时显示红色”的职责划分,哪一项正确?
18
Value converter 适合执行异步网络请求,因为绑定引擎只会调用它一次。

验证:输入反馈与领域不变量

ViewModel 可以暴露错误集合和总体有效性:

csharp
private string? _titleError;
public string? TitleError
{
    get => _titleError;
    private set => SetProperty(ref _titleError, value);
}
 
private void ValidateTitle()
{
    TitleError = string.IsNullOrWhiteSpace(NewTitle)
        ? "请输入任务标题"
        : NewTitle.Length > 80
            ? "标题不能超过 80 个字符"
            : null;
 
    AddCommand.NotifyCanExecuteChanged();
}

验证发生在字段变化、失焦还是提交时,取决于体验要求。字段级即时反馈要避免用户刚打开页面就看到满屏错误;提交时仍需做完整校验。服务端拒绝、并发冲突等不能被本地验证取代。

本节自测

19
本地验证通过后,远程保存一定会成功。
20
把错误消息设计为可观察属性的主要目的是什么?

导航与服务:依赖接口,不持有页面

ViewModel 不应 new DetailsPage() 或直接调用窗口对象。用窄接口表达意图:

csharp
public interface INavigationService
{
    Task OpenTodoAsync(Guid todoId, CancellationToken token = default);
}
 
public sealed class TodoBoardViewModel(
    ITodoService todos,
    INavigationService navigation) : ObservableViewModel
{
    private async Task OpenAsync(TodoItemViewModel item) =>
        await navigation.OpenTodoAsync(item.Id);
}

接口由应用组合根提供框架实现。相同方法在测试中可替换为记录参数的 Fake。对话框、剪贴板、时钟和文件选择器也适用,但不要为每一行代码制造接口;只隔离会变化、难测试或真正属于平台的边界。

本节自测

21
导航服务接收 todoId 而不是页面实例,主要体现了什么设计?
22
为了可测试性,项目中的所有静态方法都必须包装成接口。

ViewModel 测试:断言状态与协作

测试无需启动 UI。注入 Fake 服务,执行属性或命令,然后断言:

csharp
var service = new FakeTodoService(
[
    new(Guid.NewGuid(), "整理需求", false),
    new(Guid.NewGuid(), "补单元测试", true)
]);
var navigation = new SpyNavigationService();
var vm = new TodoBoardViewModel(service, navigation);
 
await vm.LoadCommand.ExecuteAsync(null);
 
Assert.Equal(2, vm.Items.Count);
Assert.False(vm.IsBusy);
Assert.Null(vm.ErrorMessage);
Assert.Equal(1, service.LoadCalls);

还要覆盖空输入、命令状态变化、服务异常、取消、重复点击与导航参数。属性通知本身可通过订阅 PropertyChanged 验证,但应优先测试用户可观察行为,避免测试过度绑定实现细节。

通过 Fake 服务测试 ViewModel 状态、命令与错误边界。

本节自测

23
ViewModel 单元测试使用 Fake 服务而不查询真实数据库,可以获得哪些好处?
24
如果只测试异步命令的成功路径,可能遗漏哪些缺陷?

常见陷阱与评审清单

陷阱后果修正
ViewModel 持有控件/页面无法脱离 UI 测试注入业务或平台服务接口
setter 忘记通知值已变但界面不刷新统一 SetProperty
集合元素不可观察行内状态不刷新元素 ViewModel 实现通知
CanExecute 不刷新按钮状态滞后依赖属性变化时通知命令
async void 命令异常、等待和测试困难使用异步命令类型
后台线程改绑定集合跨线程异常或竞态在 UI 边界调度更新
所有逻辑塞进 ViewModel“上帝对象”领域规则和基础设施下沉服务
错误只弹框状态不可测试暴露错误状态,由 View 呈现

评审时依次问:状态谁拥有?变化如何通知?命令何时可执行?异步能否取消与恢复?外部依赖能否替换?纯视觉行为是否误入 ViewModel?

本节自测

25
ViewModel 包含的业务规则和基础设施逻辑越多,就越符合 MVVM。
26
为什么只通过弹框呈现错误会削弱 ViewModel 的可测试性?

综合实践:专注任务板

实现下面的完整数据流:

  1. 页面创建时执行 LoadCommand,加载期间显示忙碌状态。
  2. 输入标题后验证,并刷新 AddCommand.CanExecute。
  3. 添加项目触发集合通知,列表立即更新。
  4. 切换完成状态时,元素自身通知,并更新未完成数量。
  5. SaveCommand 防重复执行,支持取消并暴露错误。
  6. OpenCommand 只把任务 ID 交给导航服务。
csharp
public partial class TodoBoardViewModel(
    ITodoService service,
    INavigationService navigation) : ObservableObject
{
    public ObservableCollection<TodoItemViewModel> Items { get; } = [];
 
    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(AddCommand))]
    private string newTitle = "";
 
    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(AddCommand), nameof(SaveCommand))]
    private bool isBusy;
 
    [ObservableProperty]
    private string? errorMessage;
 
    private bool CanAdd() =>
        !IsBusy && !string.IsNullOrWhiteSpace(NewTitle) && NewTitle.Length <= 80;
 
    [RelayCommand(CanExecute = nameof(CanAdd))]
    private void Add()
    {
        Items.Add(new TodoItemViewModel(NewTitle.Trim()));
        NewTitle = "";
    }
 
    [RelayCommand(CanExecute = nameof(CanSave), IncludeCancelCommand = true)]
    private async Task SaveAsync(CancellationToken token)
    {
        try
        {
            IsBusy = true;
            ErrorMessage = null;
            await service.SaveAsync(Items.Select(x => x.ToModel()).ToList(), token);
        }
        catch (OperationCanceledException) when (token.IsCancellationRequested) { }
        catch (Exception ex) { ErrorMessage = ToUserMessage(ex); }
        finally { IsBusy = false; }
    }
 
    private bool CanSave() => !IsBusy && Items.Count > 0;
}

实践任务:为集合变化刷新 SaveCommand;实现 LoadAsync;让元素完成状态更新未完成计数;写出成功、错误、取消、空输入和导航五组测试。

本节自测

27
当 CanSave 依赖 Items.Count 时,集合增删后应做什么?
28
设置 IncludeCancelCommand 后,即使取消令牌没有传到服务,底层操作也会自动停止。

小结:让状态、通知与副作用各归其位

MVVM 的可靠性来自清晰的数据流:View 声明绑定,ViewModel 暴露状态和命令,Model/服务守住业务与外部副作用。INotifyPropertyChanged 处理属性,ObservableCollection 处理集合结构,ICommand 处理交互意图;异步流程还要显式管理忙碌、取消和错误。

Toolkit 能减少样板,却不能代替边界设计。判断架构是否健康的最好方式,是看 ViewModel 能否在没有窗口、页面和真实网络的情况下被完整验证。

本节自测

29
下列哪一项最准确地描述了一条完整的 MVVM 交互链路?
30
下列哪些逻辑通常应该留在 View?
上一章C# 委托与事件:从类型安全回调到生命周期管理