模型-视图-视图模型模式

注意

本电子书于 2017 年春季出版,之后再未更新。 书中有许多内容仍然很有价值,但有些材料已经过时。

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

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

MVVM 模式

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

MVVM 模式

图 2-1:MVVM 模式

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

使用 MVVM 模式的好处如下:

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

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

视图

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

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

提示

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

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

视图模型

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

提示

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

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

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

提示

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

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

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

建模

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

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

将视图模型连接到视图

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

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

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

提示

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

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

以声明方式创建视图模型

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

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

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

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

以编程方式创建视图模型

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

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

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

创建定义为数据模板的视图

视图可以定义为数据模板,并与视图模型类型关联。 数据模板可以作为资源来定义,也可以在将显示视图模型的控件内以内联方式定义。 控件的内容是视图模型实例,数据模板用于直观地表示它。 这种技术是先实例化视图模型,然后再创建视图的示例。

使用视图模型定位器自动创建视图模型

视图模型定位器是一个自定义类,用于管理视图模型的实例化及其与视图的关联。 在 eShopOnContainers 移动应用中,ViewModelLocator 类有一个附加属性 AutoWireViewModel,用于将视图模型与视图关联起来。 在视图的 XAML 中,该附加属性设置为 true,表示视图模型应自动连接到视图,如下面的代码示例所示:

viewModelBase:ViewModelLocator.AutoWireViewModel="true"

AutoWireViewModel 属性是一个初始化为 false 的可绑定属性,当其值发生变化时,将调用 OnAutoWireViewModelChanged 事件处理程序。 此方法解析视图的视图模型。 下面的代码示例说明了如何实现这一点:

private static void OnAutoWireViewModelChanged(BindableObject bindable, object oldValue, object newValue)  
{  
    var view = bindable as Element;  
    if (view == null)  
    {  
        return;  
    }  

    var viewType = view.GetType();  
    var viewName = viewType.FullName.Replace(".Views.", ".ViewModels.");  
    var viewAssemblyName = viewType.GetTypeInfo().Assembly.FullName;  
    var viewModelName = string.Format(  
        CultureInfo.InvariantCulture, "{0}Model, {1}", viewName, viewAssemblyName);  

    var viewModelType = Type.GetType(viewModelName);  
    if (viewModelType == null)  
    {  
        return;  
    }  
    var viewModel = _container.Resolve(viewModelType);  
    view.BindingContext = viewModel;  
}

OnAutoWireViewModelChanged 方法尝试使用基于约定的方法来解析视图模型。 此约定假定:

  • 视图模型与视图类型位于同一程序集中。
  • 视图位于 .Views 子命名空间。
  • 视图模型位于 .ViewModels 子命名空间。
  • 视图模型名称与视图名称相对应,并以“ViewModel”结尾。

最后,OnAutoWireViewModelChanged 方法会将视图类型的 BindingContext 设置为已解析的视图模型类型。 有关解析视图模型类型的更多信息,请参阅解析

此方法的优点是,应用有一个类,负责视图模型的实例化及其与视图的连接。

提示

使用视图模型定位器,以便于替换。 视图模型定位器还可用作依赖项替代实现的替代点,例如用于单元测试或设计时数据。

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

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

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

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

eShopOnContainers 移动应用使用 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)  
    {  
        ...  
    }  
}

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

eShopOnContainers 移动应用中的每个视图模型类都派生自 ViewModelBase 类,而该类又派生自 ExtendedBindableObject 类。 因此,每个视图模型类都使用 ExtendedBindableObject 类中的 RaisePropertyChanged 方法来提供属性更改通知。 以下代码示例演示了 eShopOnContainers 移动应用如何使用 Lambda 表达式调用属性更改通知:

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

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

使用命令和行为的 UI 交互

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

命令提供了一种表示可绑定到 UI 中控件的操作的便捷方法。 它们封装了实现该操作的代码,并帮助保持操作与其在视图中的可视化表示形式分离。 Xamarin.Forms 包含可以声明方式连接到命令的控件,当用户与控件交互时,这些控件将调用该命令。

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

实现命令

视图模型通常会公开命令属性,以便从视图绑定,这些属性是实现 ICommand 接口的对象实例。 许多 Xamarin.Forms 控件提供了 Command 属性,该属性可以数据绑定到由视图模型提供的 ICommand 对象。 ICommand 接口定义了一个 Execute 方法(用于封装操作本身)、一个 CanExecute 方法(用于指示是否可以调用该命令)以及一个 CanExecuteChanged 事件(当发生影响是否应执行该命令的更改时引发)。 由 Xamarin.Forms 提供的 CommandCommand<T> 类实现了 ICommand 接口,其中 TExecuteCanExecute 的参数类型。

在视图模型中,应该为视图模型中每个类型为 ICommand 的公共属性提供一个类型为 CommandCommand<T> 的对象。 CommandCommand<T> 构造函数需要一个 Action 回叫对象,该对象会在调用 ICommand.Execute 方法时被调用。 CanExecute 方法是一个可选的构造函数参数,是一个返回 boolFunc

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

public ICommand RegisterCommand => new Command(Register);

该命令通过返回对 ICommand 的引用的属性向视图公开。 在 Command 对象上调用 Execute 方法时,它只是通过 Command 构造函数中指定的委托将调用转发到视图模型中的方法。

在指定命令的 Execute 委托时,命令可以使用 asyncawait 关键字调用异步方法。 这表明回调是 Task 并且应等待。 例如,以下代码演示了如何通过指定 SignInAsync 视图模型方法的委托来构造表示登录命令的 Command 实例:

public ICommand SignInCommand => new Command(async () => await SignInAsync());

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

public ICommand NavigateCommand => new Command<string>(NavigateAsync);

CommandCommand<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 将自动调用目标命令。 命令参数(如果提供)将作为参数传递给命令的 Execute 委托。

实现行为

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

通过附加属性附加到控件的行为称为“附加行为”。 然后,该行为可以使用它所附加到的元素的公开 API 来向视图可视化树中的该控件或其他控件添加功能。 eShopOnContainers 移动应用包含 LineColorBehavior 类,它是一个附加行为。 有关此行为的详细信息,请参阅显示验证错误

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

在 eShopOnContainers 移动应用中,BindableBehavior<T> 类派生自 Behavior<T> 类。 BindableBehavior<T> 类的目的是为任何需要将行为的 BindingContext 设置为附加控件的 Xamarin.Forms 行为提供基类。

BindableBehavior<T> 类提供一个可替代的 OnAttachedTo 方法(用于设置行为的 BindingContext)以及一个可替代的 OnDetachingFrom 方法(用于清理 BindingContext)。 此外,该类还在 AssociatedObject 属性中存储对附加控件的引用。

eShopOnContainers 移动应用包含一个 EventToCommandBehavior 类,该类在事件发生时执行命令。 此类派生自 BindableBehavior<T> 类,以便在使用该行为时,该行为可以绑定到并执行由 Command 属性指定的 ICommand。 以下代码示例演示 EventToCommandBehavior 类:

public class EventToCommandBehavior : BindableBehavior<View>  
{  
    ...  
    protected override void OnAttachedTo(View visualElement)  
    {  
        base.OnAttachedTo(visualElement);  

        var events = AssociatedObject.GetType().GetRuntimeEvents().ToArray();  
        if (events.Any())  
        {  
            _eventInfo = events.FirstOrDefault(e => e.Name == EventName);  
            if (_eventInfo == null)  
                throw new ArgumentException(string.Format(  
                        "EventToCommand: Can't find any event named '{0}' on attached type",   
                        EventName));  

            AddEventHandler(_eventInfo, AssociatedObject, OnFired);  
        }  
    }  

    protected override void OnDetachingFrom(View view)  
    {  
        if (_handler != null)  
            _eventInfo.RemoveEventHandler(AssociatedObject, _handler);  

        base.OnDetachingFrom(view);  
    }  

    private void AddEventHandler(  
            EventInfo eventInfo, object item, Action<object, EventArgs> action)  
    {  
        ...  
    }  

    private void OnFired(object sender, EventArgs eventArgs)  
    {  
        ...  
    }  
}

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

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

从视图中调用行为

EventToCommandBehavior 特别适用于将命令附加到不支持命令的控件。 例如,当列出用户订单的 ListView 上的 ItemTapped 事件触发时,ProfileView 使用 EventToCommandBehavior 执行 OrderDetailCommand,如下代码所示:

<ListView>  
    <ListView.Behaviors>  
        <behaviors:EventToCommandBehavior             
            EventName="ItemTapped"  
            Command="{Binding OrderDetailCommand}"  
            EventArgsConverter="{StaticResource ItemTappedEventArgsConverter}" />  
    </ListView.Behaviors>  
    ...  
</ListView>

在运行时,EventToCommandBehavior 将响应与 ListView 的交互。 当 ListView 中的项被选中时,ItemTapped 事件将触发,从而执行 ProfileViewModel 中的 OrderDetailCommand。 默认情况下,将事件的事件参数传递给命令。 当数据在源和目标之间传递时,EventArgsConverter 属性中指定的转换器会对数据进行转换,并从 ItemTappedEventArgs 返回 ListViewItem。 因此,在执行 OrderDetailCommand 时,所选的 Order 将作为参数传递给已注册的 Action。

有关行为的详细信息,请参阅行为

总结

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

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