数据绑定和 MVVM

浏览示例。 浏览示例

模型-视图-视图模型 (MVVM) 模式在三个软件层之间强制执行分离,这三个软件层是:称为视图的 XAML 用户界面、称为模型的基础数据,以及称为 viewmodel 的视图和模型之间的中间层。 视图和 viewmodel 通常通过 XAML 中定义的数据绑定进行连接。 视图的 BindingContext 通常是 viewmodel 的实例。

重要说明

.NET Multi-platform App UI (.NET MAUI) 会将绑定更新封送到 UI 线程。 使用 MVVM 时,可以使用为 UI 线程提供更新的 .NET MAUI 绑定引擎对任何线程中的数据绑定 viewmodel 属性进行更新。

有多种方法可以实现 MVVM 模式,本文重点介绍一种简单的方法。 它使用视图和 viewmodel(而不是模型)来专注于在两个层之间进行数据绑定。 有关在 .NET MAUI 中使用 MVVM 模式的详细说明,请参阅使用 .NET MAUI 的企业应用程序模式中的模型-视图-视图模型 (MVVM)。 有关帮助实现 MVVM 模式的教程,请参阅使用 MVVM 概念升级应用

简单 MVVM

XAML 标记扩展中,你已了解如何定义新的 XML 命名空间声明,以允许 XAML 文件引用其他程序集中的类。 以下示例使用 x:Static 标记扩展从 System 命名空间中的静态 DateTime.Now 属性获取当前日期和时间:

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:sys="clr-namespace:System;assembly=netstandard"
             x:Class="XamlSamples.OneShotDateTimePage"
             Title="One-Shot DateTime Page">

    <VerticalStackLayout BindingContext="{x:Static sys:DateTime.Now}"
                         Spacing="25" Padding="30,0"
                         VerticalOptions="Center" HorizontalOptions="Center">

        <Label Text="{Binding Year, StringFormat='The year is {0}'}" />
        <Label Text="{Binding StringFormat='The month is {0:MMMM}'}" />
        <Label Text="{Binding Day, StringFormat='The day is {0}'}" />
        <Label Text="{Binding StringFormat='The time is {0:T}'}" />

    </VerticalStackLayout>

</ContentPage>

在此示例中,检索的 DateTime 值已设置为 StackLayout 上的 BindingContext。 在某元素上设置 BindingContext 时,该元素的所有子级都会继承它。 这意味着,StackLayout 的所有子级都包含相同的 BindingContext,并且它们可以包含与该对象属性的绑定:

显示日期和时间的页面的屏幕截图。

但问题是,在构造和初始化页面时,日期和时间只设置一次,并且永远不会更改。

警告

在派生自 BindableObject 的类中,只有类型 BindableProperty 的属性是可绑定的。 例如,VisualElement.IsLoadedElement.Parent 是不可绑定的。

XAML 页面可以显示始终指示当前时间的时钟,但它需要额外的代码。 当从视觉对象与基础数据之间的属性绑定数据时,最适合 .NET MAUI 应用的就是 MVVM 模式。 从 MVVM 角度来看,模型和 viewmodel 是完全用代码编写的类。 该视图通常是一个 XAML 文件,它通过数据绑定引用 viewmodel 中定义的属性。 在 MVVM 中,模型对 viewmodel 未知,且 viewmodel 对视图未知。 但是,通常可以根据与 UI 关联的类型定制 viewmodel 公开的类型。

注意

在 MVVM 的简单示例中,如此处的示例,通常根本没有模型,并且模式只涉及与数据绑定链接的视图和 viewmodel。

以下示例显示了时钟的 viewmodel,其中包含名为 DateTime 的每秒更新的单个属性:

using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace XamlSamples;

class ClockViewModel: INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    private DateTime _dateTime;
    private Timer _timer;

    public DateTime DateTime
    {
        get => _dateTime;
        set
        {
            if (_dateTime != value)
            {
                _dateTime = value;
                OnPropertyChanged(); // reports this property
            }
        }
    }

    public ClockViewModel()
    {
        this.DateTime = DateTime.Now;

        // Update the DateTime property every second.
        _timer = new Timer(new TimerCallback((s) => this.DateTime = DateTime.Now),
                           null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
    }

    ~ClockViewModel() =>
        _timer.Dispose();

    public void OnPropertyChanged([CallerMemberName] string name = "") =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

viewmodel 通常实现 INotifyPropertyChanged 接口,只要其中一个属性发生更改,就能够让某个类引发 PropertyChanged 事件。 .NET MAUI 中的数据绑定机制将处理程序附加到此 PropertyChanged 事件,以便在属性更改时收到通知,并使用新值持续更新目标。 在前面的代码示例中,OnPropertyChanged 方法处理引发事件,同时自动确定属性源名称:DateTime

下面的示例显示了使用 ClockViewModel 的 XAML:

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:XamlSamples"
             x:Class="XamlSamples.ClockPage"
             Title="Clock Page">
    <ContentPage.BindingContext>
        <local:ClockViewModel />
    </ContentPage.BindingContext>

    <Label Text="{Binding DateTime, StringFormat='{0:T}'}"
           FontSize="18"
           HorizontalOptions="Center"
           VerticalOptions="Center" />
</ContentPage>

在此示例中,ClockViewModel 通过使用属性元素标记,被设置为 ContentPageBindingContext。 或者,代码隐藏文件会实例化 viewmodel。

LabelText 属性上的 Binding 标记扩展会格式化 DateTime 属性。 以下屏幕截图显示了结果:

通过视图模型显示日期和时间的页面的屏幕截图。

此外,可以使用句点分隔属性,以便访问 viewmodel 的 DateTime 属性的各个属性:

<Label Text="{Binding DateTime.Second, StringFormat='{0}'}" … >

交互式 MVVM

MVVM 通常与双向数据绑定一起用于实现基于基础数据模型的交互式视图。

以下示例显示了 HslViewModelColor 值转换为 HueSaturationLuminosity 值,然后再次返回:

using System.ComponentModel;
using System.Runtime.CompilerServices;

namespace XamlSamples;

class HslViewModel: INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    private float _hue, _saturation, _luminosity;
    private Color _color;

    public float Hue
    {
        get => _hue;
        set
        {
            if (_hue != value)
                Color = Color.FromHsla(value, _saturation, _luminosity);
        }
    }

    public float Saturation
    {
        get => _saturation;
        set
        {
            if (_saturation != value)
                Color = Color.FromHsla(_hue, value, _luminosity);
        }
    }

    public float Luminosity
    {
        get => _luminosity;
        set
        {
            if (_luminosity != value)
                Color = Color.FromHsla(_hue, _saturation, value);
        }
    }

    public Color Color
    {
        get => _color;
        set
        {
            if (_color != value)
            {
                _color = value;
                _hue = _color.GetHue();
                _saturation = _color.GetSaturation();
                _luminosity = _color.GetLuminosity();

                OnPropertyChanged("Hue");
                OnPropertyChanged("Saturation");
                OnPropertyChanged("Luminosity");
                OnPropertyChanged(); // reports this property
            }
        }
    }

    public void OnPropertyChanged([CallerMemberName] string name = "") =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

在此示例中,更改 HueSaturationLuminosity 属性会导致 Color 属性发生更改,而更改 Color 属性会导致其他三个属性发生更改。 这似乎是无限循环,只是 viewmodel 没有调用 PropertyChanged 事件,除非属性已更改。

以下 XAML 示例包含一个 BoxView,其 Color 属性绑定到 viewmodel 的 Color 属性,而三个 Slider 和三个 Label 视图绑定到 HueSaturationLuminosity 属性:

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:XamlSamples"
             x:Class="XamlSamples.HslColorScrollPage"
             Title="HSL Color Scroll Page">
    <ContentPage.BindingContext>
        <local:HslViewModel Color="Aqua" />
    </ContentPage.BindingContext>

    <VerticalStackLayout Padding="10, 0, 10, 30">
        <BoxView Color="{Binding Color}"
                 HeightRequest="100"
                 WidthRequest="100"
                 HorizontalOptions="Center" />
        <Label Text="{Binding Hue, StringFormat='Hue = {0:F2}'}"
               HorizontalOptions="Center" />
        <Slider Value="{Binding Hue}"
                Margin="20,0,20,0" />
        <Label Text="{Binding Saturation, StringFormat='Saturation = {0:F2}'}"
               HorizontalOptions="Center" />
        <Slider Value="{Binding Saturation}"
                Margin="20,0,20,0" />
        <Label Text="{Binding Luminosity, StringFormat='Luminosity = {0:F2}'}"
               HorizontalOptions="Center" />
        <Slider Value="{Binding Luminosity}"
                Margin="20,0,20,0" />
    </VerticalStackLayout>
</ContentPage>

每个 Label 上的绑定都是默认的 OneWay。 它只需要显示该值。 但是,每个 Slider 上的默认绑定都是 TwoWay。 这允许从 viewmodel 中对 Slider 进行初始化。 实例化 viewmodel 时,其 Color 属性设置为 AquaSlider 中的更改会为 viewmodel 中的属性设置新值,然后计算新颜色:

使用双向数据绑定的 MVVM。

命令

有时,应用的需求超出了属性绑定层面,它要求用户启动影响 viewmodel 中某些内容的命令。 这些命令通常通过点击按钮或手指敲击触发信号,往往是以下两个事件的处理程序中的代码隐藏文件中处理它们:ButtonClicked 事件或 TapGestureRecognizerTapped 事件。

命令接口提供了另一种实现命令的方法,这种方法更适合 MVVM 体系结构。 viewmodel 可以包含命令,这些命令是针对视图中的特定活动(例如 Button 单击)而执行的方法。 在这些命令和 Button 之间定义了数据绑定。

为允许在 Button 和 viewmodel 之间进行数据绑定,Button 定义了两个属性:

注意

许多其他控件也定义 CommandCommandParameter 属性。

ICommand 接口在 System.Windows.Input 命名空间中定义,它由两个方法和一个事件组成:

  • void Execute(object arg)
  • bool CanExecute(object arg)
  • event EventHandler CanExecuteChanged

此 viewmodel 可以定义类型 ICommand 的属性。 然后,可以将这些属性绑定到每个 Button 或其他元素的 Command 属性,或者绑定到实现此接口的自定义视图。 可以选择设置 CommandParameter 属性,以标识绑定到此 viewmodel 属性的各个 Button 对象(或其他元素)。 在内部,只要用户点击 ButtonButton 就会调用 Execute 方法,并将其 CommandParameter 传递给 Execute 方法。

CanExecute 方法和 CanExecuteChanged 事件用于 Button 点击当前可能无效的情况,在这种情况下,Button 应自行禁用。 Button 在首次设置 Command 属性以及每次引发 CanExecuteChanged 事件时调用 CanExecute。 如果 CanExecute 返回 false,则 Button 将自行禁用,并且不会生成 Execute 调用。

可以使用 .NET MAUI 中包含的 CommandCommand<T> 类来实现 ICommand 接口。 这两个类定义多个构造函数以及 viewmodel 可以调用的 ChangeCanExecute 方法,以强制 Command 对象引发 CanExecuteChanged 事件。

以下示例显示用于输入电话号码的简单键盘的 viewmodel:

using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;

namespace XamlSamples;

class KeypadViewModel: INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    private string _inputString = "";
    private string _displayText = "";
    private char[] _specialChars = { '*', '#' };

    public ICommand AddCharCommand { get; private set; }
    public ICommand DeleteCharCommand { get; private set; }

    public string InputString
    {
        get => _inputString;
        private set
        {
            if (_inputString != value)
            {
                _inputString = value;
                OnPropertyChanged();
                DisplayText = FormatText(_inputString);

                // Perhaps the delete button must be enabled/disabled.
                ((Command)DeleteCharCommand).ChangeCanExecute();
            }
        }
    }

    public string DisplayText
    {
        get => _displayText;
        private set
        {
            if (_displayText != value)
            {
                _displayText = value;
                OnPropertyChanged();
            }
        }
    }

    public KeypadViewModel()
    {
        // Command to add the key to the input string
        AddCharCommand = new Command<string>((key) => InputString += key);

        // Command to delete a character from the input string when allowed
        DeleteCharCommand =
            new Command(
                // Command will strip a character from the input string
                () => InputString = InputString.Substring(0, InputString.Length - 1),

                // CanExecute is processed here to return true when there's something to delete
                () => InputString.Length > 0
            );
    }

    string FormatText(string str)
    {
        bool hasNonNumbers = str.IndexOfAny(_specialChars) != -1;
        string formatted = str;

        // Format the string based on the type of data and the length
        if (hasNonNumbers || str.Length < 4 || str.Length > 10)
        {
            // Special characters exist, or the string is too small or large for special formatting
            // Do nothing
        }

        else if (str.Length < 8)
            formatted = string.Format("{0}-{1}", str.Substring(0, 3), str.Substring(3));

        else
            formatted = string.Format("({0}) {1}-{2}", str.Substring(0, 3), str.Substring(3, 3), str.Substring(6));

        return formatted;
    }


    public void OnPropertyChanged([CallerMemberName] string name = "") =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

在此示例中,命令的 ExecuteCanExecute 方法在构造函数中定义为 lambda 函数。 viewmodel 假定 AddCharCommand 属性绑定到多个按钮(或包含命令接口的任何其他控件)的 Command 属性,每个按钮都由 CommandParameter 进行标识。 这些按钮将字符添加到 InputString 属性,然后格式化为 DisplayText 属性的电话号码。 还有另外一个名为 DeleteCharCommand 的属性,其类型为 ICommand。 它绑定到退格按钮,但如果没有要删除的字符,则应禁用该按钮。

以下示例显示使用 KeypadViewModel 的 XAML:

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:XamlSamples"
             x:Class="XamlSamples.KeypadPage"
             Title="Keypad Page">
    <ContentPage.BindingContext>
        <local:KeypadViewModel />
    </ContentPage.BindingContext>

    <Grid HorizontalOptions="Center" VerticalOptions="Center">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="80" />
            <ColumnDefinition Width="80" />
            <ColumnDefinition Width="80" />
        </Grid.ColumnDefinitions>

        <Label Text="{Binding DisplayText}"
               Margin="0,0,10,0" FontSize="20" LineBreakMode="HeadTruncation"
               VerticalTextAlignment="Center" HorizontalTextAlignment="End"
               Grid.ColumnSpan="2" />

        <Button Text="&#x21E6;" Command="{Binding DeleteCharCommand}" Grid.Column="2"/>

        <Button Text="1" Command="{Binding AddCharCommand}" CommandParameter="1" Grid.Row="1" />
        <Button Text="2" Command="{Binding AddCharCommand}" CommandParameter="2" Grid.Row="1" Grid.Column="1" />
        <Button Text="3" Command="{Binding AddCharCommand}" CommandParameter="3" Grid.Row="1" Grid.Column="2" />

        <Button Text="4" Command="{Binding AddCharCommand}" CommandParameter="4" Grid.Row="2" />
        <Button Text="5" Command="{Binding AddCharCommand}" CommandParameter="5" Grid.Row="2" Grid.Column="1" />
        <Button Text="6" Command="{Binding AddCharCommand}" CommandParameter="6" Grid.Row="2" Grid.Column="2" />

        <Button Text="7" Command="{Binding AddCharCommand}" CommandParameter="7" Grid.Row="3" />
        <Button Text="8" Command="{Binding AddCharCommand}" CommandParameter="8" Grid.Row="3" Grid.Column="1" />
        <Button Text="9" Command="{Binding AddCharCommand}" CommandParameter="9" Grid.Row="3" Grid.Column="2" />

        <Button Text="*" Command="{Binding AddCharCommand}" CommandParameter="*" Grid.Row="4" />
        <Button Text="0" Command="{Binding AddCharCommand}" CommandParameter="0" Grid.Row="4" Grid.Column="1" />
        <Button Text="#" Command="{Binding AddCharCommand}" CommandParameter="#" Grid.Row="4" Grid.Column="2" />
    </Grid>
</ContentPage>

在此示例中,绑定到 DeleteCharCommand 的第一个 ButtonCommand 属性。 另一个按钮绑定到 AddCharCommand,其 CommandParameterButton 上显示的字符相同:

使用 MVVM 和命令的计算器的屏幕截图。