模型-视图-视图模型 (MVVM)

提示

此内容摘自电子书《使用 .NET MAUI 的企业应用程序模式》,可在 .NET 文档上获取,也可作为免费可下载的 PDF 脱机阅读。

《使用 .NET MAUI 的企业应用程序模式》电子书包含缩略图。

.NET MAUI 开发人员体验通常涉及在 XAML 中创建用户界面,然后添加在用户界面上运行的代码隐藏。 随着应用的修改以及规模和范围的扩大,可能会出现复杂的维护问题。 这些问题包括 UI 控件和业务逻辑之间的紧密耦合,这增加了进行 UI 修改的成本,以及对此类代码进行单元测试的难度。

MVVM 模式有助于将应用程序的业务和表示逻辑与用户界面 (UI) 清晰分离。 保持应用程序逻辑和 UI 之间的清晰分离有助于解决许多开发问题,并使应用程序更易于测试、维护和演变。 它还可以显著提高代码重用机会,并允许开发人员和 UI 设计人员在开发应用各自的部分时更轻松地进行协作。

MVVM 模式

MVVM 模式中有三个核心组件:模型、视图和视图模型。 每个组件的用途不同。 下图显示了这三个组件之间的关系。

MVVM 模式

除了要了解每个组件的责任外,了解它们如何交互也很重要。 在较高的层次上,视图“了解”视图模型,视图模型“了解”模型,但模型不知道视图模型,而视图模型不知道视图。 因此,视图模型将视图与模型隔离开来,并允许模型独立于视图进行演变。

使用 MVVM 模式的好处如下:

  • 如果现有模型实现封装了现有业务逻辑,则更改它可能很困难或有风险。 在此场景中,视图模型充当模型类的适配器,并阻止你对模型代码进行重大更改。
  • 开发人员可以在不使用视图的情况下为视图模型和模型创建单元测试。 视图模型的单元测试可以执行与视图使用的完全相同的功能。
  • 无需触及视图模型和模型代码即可重新设计应用 UI,前提是视图完全是用 XAML 或 C# 实现的。 因此,新版本的视图应与现有视图模型一起使用。
  • 在开发过程中,设计人员和开发人员可以独立和并发地处理其组件。 设计人员可以专注于视图,而开发人员可以处理视图模型和模型组件。

有效使用 MVVM 的关键在于了解如何将应用代码分解为正确的类以及这些类的交互方式。 下面几节讨论 MVVM 模式中每个类的责任。

视图

视图负责定义用户在屏幕上看到的结构、布局和外观。 理想情况下,每个视图在 XAML 中定义,代码隐藏有限,不包含业务逻辑。 但是,在某些情况下,代码隐藏可能包含用于实现在 XAML 中难以表达的视觉行为的 UI 逻辑,例如动画。

在 .NET MAUI 应用程序中,视图通常是 ContentPage 派生或 ContentView 派生类。 但是,视图也可以由数据模板表示,该模板指定在显示对象时用于直观表示对象的 UI 元素。 数据模板作为视图没有任何代码隐藏,旨在绑定到特定的视图模型类型。

提示

请避免在代码隐藏中启用和禁用 UI 元素。

请确保视图模型负责定义影响视图显示某些方面的逻辑状态更改,例如命令是否可用,或指示操作处于挂起状态。 因此,请通过绑定到视图模型属性来启用和禁用 UI 元素,而不是在代码隐藏中启用和禁用它们。

有多种选项可用于对视图模型执行代码,以响应视图上的交互,例如按钮单击或项选择。 如果控件支持命令,控件的 Command 属性可以数据绑定到视图模型中的 ICommand 属性。 调用控件的命令时,将执行视图模型中的代码。 除了命令,行为还可以附加到视图中的对象,并且可以侦听要调用的命令或要引发的事件。 作为响应,该行为随后可以调用视图模型上的 ICommand 或视图模型上的方法。

视图模型

视图模型实现视图可以数据绑定到的属性和命令,并通过更改通知事件通知视图任何状态更改。 视图模型提供的属性和命令定义了要由 UI 提供的功能,但视图决定了如何显示该功能。

提示

通过异步操作保持 UI 响应。

多平台应用应保持 UI 线程畅通,以提高用户对性能的认知度。 因此,在视图模型中,对 I/O 操作使用异步方法并引发事件以异步通知视图属性更改。

视图模型还负责协调视图与所需的任何模型类的交互。 视图模型与模型类之间通常存在一对多关系。 视图模型可以选择直接向视图公开模型类,以便视图中的控件可以直接数据绑定到它们。 在这种情况下,需要设计模型类来支持数据绑定和更改通知事件。

每个视图模型以一种视图可以轻松使用的形式提供来自模型的数据。 为此,视图模型有时会执行数据转换。 将此数据转换置于视图模型中是一个好主意,因为它提供视图可以绑定到的属性。 例如,视图模型可能会合并两个属性的值,以便于视图显示。

提示

将数据转换集中在转换层中。

还可以将转换器用作位于视图模型和视图之间的独立数据转换层。 例如,当数据需要视图模型不提供的特殊格式时,这可能是必要操作。

为了让视图模型参与与视图的双向数据绑定,其属性必须引发 PropertyChanged 事件。 视图模型通过实现 INotifyPropertyChanged 接口并在属性更改时引发 PropertyChanged 事件来满足此要求。

对于集合,将提供视图友好的 ObservableCollection<T>。 此集合实现集合更改通知,使开发人员不必在集合上实现 INotifyCollectionChanged 接口。

建模

模型类是封装应用数据的非可视类。 因此,可以将模型视为表示应用的域模型,该模型通常包括数据模型以及业务和验证逻辑。 模型对象的示例包括数据传输对象 (DTO)、普通旧 CLR 对象 (POCO) 以及生成的实体和代理对象。

模型类通常与封装数据访问和缓存的服务或存储库结合使用。

将视图模型连接到视图

可以使用 .NET MAUI 的数据绑定功能将视图模型连接到视图。 有许多方法可用于构建视图和视图模型并在运行时关联它们。 这些方法分为两类,称为视图优先组合和视图模型优先组合。 在视图优先组合和视图模型优先组合之间进行选择是一个偏好和复杂性的问题。 但是,所有方法的目标一致,即让视图向其 BindingContext 属性分配一个视图模型。

使用视图优先组合,应用在概念上由连接到它们所依赖的视图模型的视图组成。 这种方法的主要好处是它可以轻松构造松散耦合、可单元测试的应用,因为视图模型不依赖于视图本身。 通过遵循应用的可视结构,也很容易理解应用的结构,而不必跟踪代码执行来了解类是如何创建和关联的。 此外,视图优先构造与 Maui 的导航系统保持一致,该导航系统负责在发生导航时构造页面,这使得视图模型优先组合变得复杂并且与平台不一致。

使用视图模型优先组合,应用在概念上由视图模型组成,其中有一个服务负责为视图模型定位视图。 视图模型优先组合对一些开发人员来说感觉更自然,因为视图创建可以被抽象出来,使他们能够专注于应用的逻辑非 UI 结构。 此外,它还允许其他视图模型创建视图模型。 但是,这种方法通常很复杂,并且很难理解应用的各个部分是如何创建和关联的。

提示

使视图模型和视图保持独立。

视图与数据源中属性的绑定应该是视图对其对应视图模型的主要依赖项。 具体而言,不要从视图模型引用视图类型,如 Button 和 ListView。 按照此处概述的原则,可以单独测试视图模型,从而通过限制范围来降低软件缺陷的可能性。

以下部分讨论了将视图模型连接到视图的主要方法。

以声明方式创建视图模型

最简单的方法是让视图在 XAML 中以声明方式实例化其对应的视图模型。 在构造视图时,也会构造相应的视图模型对象。 以下代码示例演示了此方法:

<ContentPage xmlns:local="clr-namespace:eShop">
    <ContentPage.BindingContext>
        <local:LoginViewModel />
    </ContentPage.BindingContext>
    <!-- Omitted for brevity... -->
</ContentPage>

创建 ContentPage 时,会自动构造 LoginViewModel 的实例并将其设置为视图的 BindingContext

视图对视图模型的这种声明式构造和分配的优点是简单易行,但缺点是它需要视图模型中的默认(无参数)构造函数。

以编程方式创建视图模型

视图可以在代码隐藏文件中包含代码,从而将视图模型分配给其 BindingContext 属性。 这通常在视图的构造函数中完成,如以下代码示例所示:

public LoginView()
{
    InitializeComponent();
    BindingContext = new LoginViewModel(navigationService);
}

视图代码隐藏中视图模型的编程构造和分配的优势在于简单易行。 但是,这种方法的主要缺点是视图需要为视图模型提供任何所需的依赖项。 使用依赖关系注入容器有助于保持视图和视图模型之间的松散耦合。 有关详细信息,请参阅依存关系注入

更新视图以响应基础视图模型或模型中的更改

视图可访问的所有视图模型和模型类都应实现 INotifyPropertyChanged 接口。 在视图模型或模型类中实现此接口允许该类在基础属性值发生更改时向视图中的任何数据绑定控件提供更改通知。

应根据以下要求构建应用,以便正确使用属性更改通知:

  • 如果公共属性值发生更改,始终引发 PropertyChanged 事件。 不要认为因为知道 XAML 绑定是如何发生的,就可以忽略引发 PropertyChanged 事件。
  • 对于其值由视图模型或模型中的其他属性使用的任何计算属性,请始终引发 PropertyChanged 事件。
  • 始终在进行属性更改的方法结束时或当已知对象处于安全状态时引发 PropertyChanged 事件。 引发事件会通过同步调用事件处理程序来中断操作。 如果在操作过程中发生这种情况,当对象处于不安全、部分更新的状态时,它可能会向回调函数公开该对象。 此外,还可以由 PropertyChanged 事件触发级联更改。 级联更改通常需要在级联更改可以安全执行之前完成更新。
  • 如果属性未更改,切勿引发 PropertyChanged 事件。 这意味着,在引发 PropertyChanged 事件之前,必须比较旧值和新值。
  • 如果正在初始化属性,切勿在视图模型的构造函数期间引发 PropertyChanged 事件。 此时视图中的数据绑定控件将不会订阅接收更改通知。
  • 切勿在类的公共方法的单个同步调用中引发多个具有相同属性名称参数的 PropertyChanged 事件。 例如,给定一个后备存储为 _numberOfItems 字段的 NumberOfItems 属性,如果一个方法在循环执行期间将 _numberOfItems 递增 50 次,则在所有工作完成后,它应只会在 NumberOfItems 属性上引发一次属性更改通知。 对于异步方法,请为异步延续链的每个同步段中的给定属性名称引发 PropertyChanged 事件。

提供此功能的一种简单方法是创建 BindableObject 类的扩展。 在此示例中,ExtendedBindableObject 类提供更改通知,如以下代码示例所示:

public abstract class ExtendedBindableObject : BindableObject
{
    public void RaisePropertyChanged<T>(Expression<Func<T>> property)
    {
        var name = GetMemberInfo(property).Name;
        OnPropertyChanged(name);
    }

    private MemberInfo GetMemberInfo(Expression expression)
    {
        // Omitted for brevity ...
    }
}

.NET MAUI 的 BindableObject 类实现 INotifyPropertyChanged 接口,并提供 OnPropertyChanged 方法。 ExtendedBindableObject 类提供 RaisePropertyChanged 方法来调用属性更改通知,并在此过程中使用 BindableObject 类提供的功能。

然后可以从 ExtendedBindableObject 类派生视图模型类。 因此,每个视图模型类都使用 ExtendedBindableObject 类中的 RaisePropertyChanged 方法来提供属性更改通知。 以下代码示例演示 eShop 多平台应用如何使用 Lambda 表达式调用属性更改通知:

public bool IsLogin
{
    get => _isLogin;
    set
    {
        _isLogin = value;
        RaisePropertyChanged(() => IsLogin);
    }
}

以这种方式使用 Lambda 表达式涉及一小部分性能成本,因为必须为每个调用计算 Lambda 表达式。 尽管性能成本很小并且通常不会影响应用,但当有许多更改通知时,成本可能会累积。 但是,这种方法的好处是它在重命名属性时提供了编译时类型安全性和重构支持。

MVVM 框架

MVVM 模式在 .NET 中得到了很好的建立,社区创建了许多有助于简化这种开发的框架。 每个框架都提供一组不同的功能,但它们的标准做法是提供具有 INotifyPropertyChanged 接口实现的通用视图模型。 MVVM 框架的其他功能包括自定义命令、导航帮助程序、依赖关系注入/服务定位符组件和 UI 平台集成。 虽然不是非要使用这些框架,但它们可以加速和标准化开发。 eShop 多平台应用使用 .NET 社区 MVVM 工具包。 选择框架时,应考虑应用程序的需求和团队的优势。 下表列出了适用于 .NET MAUI 的一些更常见的 MVVM 框架。

使用命令和行为的 UI 交互

在多平台应用中,通常会调用操作以响应用户操作(例如按钮单击),这可以通过在代码隐藏文件中创建事件处理程序来实现。 但是,在 MVVM 模式中,实现该操作的责任在于视图模型,应避免在代码隐藏中放置代码。

命令提供了一种表示可绑定到 UI 中控件的操作的便捷方法。 它们封装了实现该操作的代码,并帮助保持操作与其在视图中的可视化表示形式分离。 这样,视图模型就更易于移植到新平台,因为它们不直接依赖于平台的 UI 框架提供的事件。 .NET MAUI 包含可以声明方式连接到命令的控件,当用户与控件交互时,这些控件将调用该命令。

行为还允许控件以声明方式连接到命令。 但是,行为可用于调用与控件引发的一系列事件相关联的操作。 因此,行为解决了许多与启用命令的控件相同的场景,同时提供了更大程度的灵活性和控制。 此外,行为也可用于将命令对象或方法与并非专门设计用于与命令交互的控件相关联。

实现命令

视图模型通常会公开公共属性,以便从实现 ICommand 接口的视图进行绑定。 许多 .NET MAUI 控件和手势提供 Command 属性,该属性可数据绑定到视图模型提供的 ICommand 对象。 按钮控件是最常用的控件之一,它提供了在单击按钮时执行的命令属性。

注意

虽然可以公开视图模型使用的 ICommand 接口的实际实现(例如 Command<T>RelayCommand),但建议将命令公开为 ICommand。 这样,如果以后需要更改实现,便可以轻松地将其换出。

ICommand 接口定义了一个 Execute 方法(用于封装操作本身)、一个 CanExecute 方法(用于指示是否可以调用该命令)以及一个 CanExecuteChanged 事件(当发生影响是否应执行该命令的更改时引发)。 在大多数情况下,我们只会为命令提供 Execute 方法。 有关 ICommand 的更详细概述,请参阅 .NET MAUI 的命令文档。

随 .NET MAUI 一同提供的是实现 ICommand 接口的 CommandCommand<T> 类,其中 TExecuteCanExecute 的参数类型。 CommandCommand<T> 是提供 ICommand 接口所需的最少功能集的基本实现。

注意

许多 MVVM 框架提供了 ICommand 接口的功能更丰富的实现。

CommandCommand<T> 构造函数需要在调用 ICommand.Execute 方法时调用的 Action 回调对象。 CanExecute 方法是一个可选的构造函数参数,是一个返回布尔值的 Func。

eShop 多平台应用使用 RelayCommandAsyncRelayCommand。 新式应用程序的主要优势在于 AsyncRelayCommand 为异步操作提供了更好的功能。

以下代码演示了如何通过为 Register 视图模型方法指定委托来构造表示注册命令的 Command 实例:

public ICommand RegisterCommand { get; }

该命令通过返回对 ICommand 的引用的属性向视图公开。 在 Command 对象上调用 Execute 方法时,它只是通过 Command 构造函数中指定的委托将调用转发到视图模型中的方法。 在指定命令的 Execute 委托时,命令可以使用 async 和 await 关键字调用异步方法。 这表明回调是 Task 并且应等待。 例如,以下代码演示了如何通过指定 SignInAsync 视图模型方法的委托来构造表示登录命令的 ICommand 实例:

public ICommand SignInCommand { get; }
...
SignInCommand = new AsyncRelayCommand(async () => await SignInAsync());

通过使用 AsyncRelayCommand<T> 类实例化命令,可以将参数传递给 ExecuteCanExecute 操作。 例如,以下代码演示了如何使用 AsyncRelayCommand<T> 实例来指示 NavigateAsync 方法将需要字符串类型的参数:

public ICommand NavigateCommand { get; }

...
NavigateCommand = new AsyncRelayCommand<string>(NavigateAsync);

RelayCommandRelayCommand<T> 类中,每个构造函数中 CanExecute 方法的委托都是可选的。 如果未指定委托,则 Command 将为 CanExecute 返回 true。 但是,视图模型可以通过对 Command 对象调用 ChangeCanExecute 方法来指示命令的 CanExecute 状态的变化。 这会引发 CanExecuteChanged 事件。 任何绑定到命令的 UI 控件将更新其启用状态以反映数据绑定命令的可用性。

从视图中调用命令

以下代码示例演示了 LoginView 中的 Grid 如何使用 TapGestureRecognizer 实例绑定到 LoginViewModel 类中的 RegisterCommand

<Grid Grid.Column="1" HorizontalOptions="Center">
    <Label Text="REGISTER" TextColor="Gray"/>
    <Grid.GestureRecognizers>
        <TapGestureRecognizer Command="{Binding RegisterCommand}" NumberOfTapsRequired="1" />
    </Grid.GestureRecognizers>
</Grid>

也可以选择使用 CommandParameter 属性定义命令参数。 预期参数的类型在 ExecuteCanExecute 目标方法中指定。 当用户与附加控件交互时,TapGestureRecognizer 将自动调用目标命令。 CommandParameter(如果提供)将作为参数传递给命令的 Execute 委托。

实现行为

通过行为可将功能添加到 UI 控件,而无需将其子类化。 功能是在行为类中实现的,并附加到控件上,就像它本身就是控件的一部分。 行为使开发人员可以实现那些通常必须以代码隐藏形式编写的代码,因为它直接与控件的 API 进行交互,这样便可简洁地将其附加到控件,并打包以便跨多个视图或应用重用。 在 MVVM 的上下文中,行为是一种很有用的方法,能将控件与命令连接起来。

通过附加属性附加到控件的行为称为“附加行为”。 然后,该行为可以使用它所附加到的元素的公开 API 来向视图可视化树中的该控件或其他控件添加功能。

.NET MAUI 行为是派生自 BehaviorBehavior<T> 类的类,其中 T 是应应用该行为的控件的类型。 这些类提供 OnAttachedToOnDetachingFrom 方法,应该重写这些方法以提供在行为附加到控件和从控件分离时将执行的逻辑。

在 eShop 多平台应用中,BindableBehavior<T> 类派生自 Behavior<T> 类。 BindableBehavior<T> 类的目的是为任何需要将行为的 BindingContext 设置为附加控件的 .NET MAUI 行为提供基类。

BindableBehavior<T> 类提供一个可替代的 OnAttachedTo 方法(用于设置行为的 BindingContext)以及一个可替代的 OnDetachingFrom 方法(用于清理 BindingContext)。

eShop 多平台应用包括由 MAUI 社区工具包提供的 EventToCommandBehavior 类。 EventToCommandBehavior 执行命令以响应发生的事件。 此类派生自 BaseBehavior<View> 类,以便在使用该行为时,该行为可以绑定到并执行由 Command 属性指定的 ICommand。 以下代码示例演示 EventToCommandBehavior 类:

/// <summary>
/// The <see cref="EventToCommandBehavior"/> is a behavior that allows the user to invoke a <see cref="ICommand"/> through an event. It is designed to associate Commands to events exposed by controls that were not designed to support Commands. It allows you to map any arbitrary event on a control to a Command.
/// </summary>
public class EventToCommandBehavior : BaseBehavior<VisualElement>
{
    // Omitted for brevity...

    /// <inheritdoc/>
    protected override void OnAttachedTo(VisualElement bindable)
    {
        base.OnAttachedTo(bindable);
        RegisterEvent();
    }

    /// <inheritdoc/>
    protected override void OnDetachingFrom(VisualElement bindable)
    {
        UnregisterEvent();
        base.OnDetachingFrom(bindable);
    }

    static void OnEventNamePropertyChanged(BindableObject bindable, object oldValue, object newValue)
        => ((EventToCommandBehavior)bindable).RegisterEvent();

    void RegisterEvent()
    {
        UnregisterEvent();

        var eventName = EventName;
        if (View is null || string.IsNullOrWhiteSpace(eventName))
        {
            return;
        }

        eventInfo = View.GetType()?.GetRuntimeEvent(eventName) ??
            throw new ArgumentException($"{nameof(EventToCommandBehavior)}: Couldn't resolve the event.", nameof(EventName));

        ArgumentNullException.ThrowIfNull(eventInfo.EventHandlerType);
        ArgumentNullException.ThrowIfNull(eventHandlerMethodInfo);

        eventHandler = eventHandlerMethodInfo.CreateDelegate(eventInfo.EventHandlerType, this) ??
            throw new ArgumentException($"{nameof(EventToCommandBehavior)}: Couldn't create event handler.", nameof(EventName));

        eventInfo.AddEventHandler(View, eventHandler);
    }

    void UnregisterEvent()
    {
        if (eventInfo is not null && eventHandler is not null)
        {
            eventInfo.RemoveEventHandler(View, eventHandler);
        }

        eventInfo = null;
        eventHandler = null;
    }

    /// <summary>
    /// Virtual method that executes when a Command is invoked
    /// </summary>
    /// <param name="sender"></param>
    /// <param name="eventArgs"></param>
    [Microsoft.Maui.Controls.Internals.Preserve(Conditional = true)]
    protected virtual void OnTriggerHandled(object? sender = null, object? eventArgs = null)
    {
        var parameter = CommandParameter
            ?? EventArgsConverter?.Convert(eventArgs, typeof(object), null, null);

        var command = Command;
        if (command?.CanExecute(parameter) ?? false)
        {
            command.Execute(parameter);
        }
    }
}

OnAttachedToOnDetachingFrom 方法用于为 EventName 属性中定义的事件注册和取消注册事件处理程序。 然后,当事件触发时,将调用 OnTriggerHandled 方法,该方法将执行该命令。

在事件触发时使用 EventToCommandBehavior 执行命令的优点是,命令可与非旨在与命令交互的控件相关联。 此外,这会将事件处理代码移动到视图模型中,可在其中对其进行单元测试。

从视图中调用行为

EventToCommandBehavior 特别适用于将命令附加到不支持命令的控件。 例如,当用户更改密码值时,LoginView 使用 EventToCommandBehavior 执行 ValidateCommand,如以下代码所示:

<Entry
    IsPassword="True"
    Text="{Binding Password.Value, Mode=TwoWay}">
    <!-- Omitted for brevity... -->
    <Entry.Behaviors>
        <mct:EventToCommandBehavior
            EventName="TextChanged"
            Command="{Binding ValidateCommand}" />
    </Entry.Behaviors>
    <!-- Omitted for brevity... -->
</Entry>

在运行时,EventToCommandBehavior 将响应与 Entry 的交互。 当用户在 Entry 字段中键入内容时,将触发 TextChanged 事件,该事件将执行 LoginViewModel 中的 ValidateCommand。 默认情况下,将事件的事件参数传递给命令。 如果需要,可以使用 EventArgsConverter 属性将事件提供的 EventArgs 转换为命令期望作为输入的值。

有关行为的详细信息,请参阅 .NET MAUI 开发人员中心的行为

总结

模型-视图-视图模型 (MVVM) 模式有助于将应用程序的业务和表示逻辑与用户界面 (UI) 清晰分离。 保持应用程序逻辑和 UI 之间的清晰分离有助于解决许多开发问题,并使应用程序更易于测试、维护和演变。 它还可以显著提高代码重用机会,并允许开发人员和 UI 设计人员在开发应用各自的部分时更轻松地进行协作。

使用 MVVM 模式,应用的 UI 以及基础表示和业务逻辑被分成三个独立的类:视图,用于封装 UI 和 UI 逻辑;视图模型,用于封装表示逻辑和状态;以及模型,用于封装应用的业务逻辑和数据。