Model-View-ViewModel (MVVM) 模式會強制執行三個軟體層之間的分隔:XAML 使用者介面,稱為檢視、基礎數據、稱為模型,以及檢視與模型之間的媒介,稱為 viewmodel。 檢視和 viewmodel 通常會透過 XAML 中定義的數據系結來連接。 BindingContext檢視的 通常是 viewmodel 的實例。
重要
.NET 多平臺應用程式 UI (.NET MAUI) 會將系結更新駐入至 UI 執行緒。 使用MVVM時,這可讓您從任何線程更新數據系結 ViewModel 屬性,並使用 .NET MAUI 的系結引擎將更新帶入 UI 線程。
實作MVVM模式的方法有很多種,本文著重於簡單的方法。 它使用檢視和視圖模型,而不是模型,著重於這兩個層之間的資料繫結。 如需有關在 .NET MAUI 中使用 MVVM 模式的詳細說明,請參閱《使用 .NET MAUI 的企業應用程式模式》中的「Model-View-ViewModel (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"
x:DataType="sys:DateTime">
<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 值會設定為 BindingContext 上的 StackLayout。 當您在一個元素上設定BindingContext時,此設定將傳遞給該元素的所有子級。 這表示 StackLayout 的所有子項都有相同的 BindingContext,而且它們可以包含綁定到該對象屬性的內容:
不過,問題是當頁面建構和初始化時,日期和時間會設定一次,而且永遠不會變更。
警告
在衍生自BindableObject 的類別中,只有BindableProperty 類型的屬性是可綁定的。 例如,VisualElement.IsLoaded 和 Element.Parent 是不可繫結的。
XAML 頁面可以顯示一律顯示目前時間的時鐘,但需要額外的程式代碼。 MVVM 模式是 .NET MAUI 應用程式在視覺對象與基礎數據之間從屬性系結數據時的自然選擇。 在MVVM方面思考時,模型和 viewmodel 是完全以程式代碼撰寫的類別。 檢視通常是一個 XAML 檔案,可透過數據系結參考 ViewModel 中定義的屬性。 在MVVM中,模型會忽略 viewmodel,而 viewmodel 則忽略檢視。 不過,您通常會量身定制 viewmodel 所公開的類型,使其與 UI 相關聯的類型一緻。
注意
在MVVM的簡單範例中,例如這裡所示的範例,通常沒有模型,而且模式只涉及到與資料繫結的視圖和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));
}
Viewmodels 通常會實作 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"
x:DataType="local:ClockViewModel">
<ContentPage.BindingContext>
<local:ClockViewModel />
</ContentPage.BindingContext>
<Label Text="{Binding DateTime, StringFormat='{0:T}'}"
FontSize="18"
HorizontalOptions="Center"
VerticalOptions="Center" />
</ContentPage>
在這個範例中,ClockViewModel 透過屬性元素標籤被設定為 BindingContext 的 ContentPage。 或者,程序代碼後置檔可以具現化視圖模型。
Binding 標記延伸在 Text 屬性上的 Label 格式化 DateTime 屬性。 下列螢幕快照顯示結果:
透過 viewmodel 的頁面顯示日期和時間的螢幕截圖。
此外,可以將屬性用句點分隔,以存取 viewmodel 屬性中的個別屬性DateTime。
<Label Text="{Binding DateTime.Second, StringFormat='{0}'}" … >
互動式MVVM
MVVM 通常會與雙向數據系結搭配使用,以根據基礎數據模型進行互動式檢視。
下列範例顯示 HslViewModel 如何將一個值轉換成 Color,再轉換成 Hue、Saturation 和 Luminosity 值,並再轉回原值:
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));
}
在此範例中,Hue、Saturation 和 Luminosity 屬性的變更會導致 Color 屬性變更,而 Color 屬性的變更會導致其他三個屬性變更。 這似乎是無限迴圈,除了屬性已變更的情況,viewmodel 不會引發 PropertyChanged 事件。
下列 XAML 範例包含 BoxView,其 Color 屬性系結至 viewmodel 的對應屬性,以及三個 Slider 和三個 Label 系結至 Hue、Saturation 和 Luminosity 屬性的視圖:
<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"
x:DataType="local:HslViewModel">
<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。 這可讓 Slider 從 viewmodel 初始化。 當 viewmodel 具現化時,它的 Color 屬性會設定為 Aqua。 變更滑桿會設定 ViewModel 中屬性的新值,然後計算新的色彩:
MVVM 使用雙向數據綁定。
命令
有時候,應用程式的需求超越了屬性繫結,需要使用者發起會影響 viewmodel 的某些命令。 這些命令通常是透過按鈕點擊或手指點選發出訊號,傳統上會在程式碼後置檔案中,處理 Clicked 的 Button 事件或 Tapped 的 TapGestureRecognizer 事件。
命令介面提供了一種更加適合MVVM架構的命令實作替代方法。 viewmodel 可以包含命令,這些命令是在視圖中響應特定活動的方法,例如 Button 點擊。 資料繫結會被定義在這些命令與按鈕之間。
若要允許 Button 與 viewmodel 之間的資料繫結,Button 定義了兩個屬性:
- 型別
Command的System.Windows.Input.ICommand CommandParameter的類型Object
注意
許多其他控制件也會定義
介面 ICommand 定義於 System.Windows.Input 命名空間中,由兩個方法和一個事件所組成:
void Execute(object arg)bool CanExecute(object arg)event EventHandler CanExecuteChanged
viewmodel 可以定義一個ICommand類型的屬性。 然後,您可以將這些屬性系結至 Command 屬性的每個 Button 或其他元素,或是實現這個介面的自定義檢視。 您可以選擇性地設定 CommandParameter 屬性,以識別系結至此 ViewModel 屬性的個別 Button 物件(或其他元素)。 在內部,每當用戶點選 Button 時,Button 會呼叫 Execute 方法,並將其 CommandParameter 傳遞給 Execute 方法。
CanExecute 方法和 CanExecuteChanged 事件用於當前可能無效的 Button 點擊情況,在這種情況下,Button 應該停用自身。 當首次設定Command屬性或每當CanExecuteChanged事件引發時,Button會呼叫CanExecute。 如果 CanExecute 傳回 false,則 Button 停用本身,而且不會產生 Execute 呼叫。
您可以使用 .NET MAUI 中包含的 Command 類別或 Command<T> 類別來實作 ICommand 介面。 這兩個類別定義了數個建構函式,外加一個可由 viewmodel 呼叫的方法,以強制 Command 對象觸發 CanExecuteChanged 事件。
以下範例展示了一個視圖模型,用於簡單鍵盤以輸入電話號碼:
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));
}
在此範例中,Execute 和 CanExecute 方法被定義為建構子中的 Lambda 函式。 viewmodel 假設 AddCharCommand 屬性系結至數個按鈕(或具有命令介面的其他控制項)的 Command 屬性,每個控制項都是由 CommandParameter 所識別。 這些按鈕將字元新增至 InputString 屬性,然後將其格式化為 DisplayText 屬性的電話號碼。 另外還有類型為ICommand名為DeleteCharCommand的第二個屬性。 這會綁定至退格鍵,但如果沒有任何字元可刪除,則應該停用該按鈕。
下列範例顯示使用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"
x:DataType="local:KeypadViewModel">
<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="⇦" 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>
在此範例中,AddCharCommand ,其 CommandParameter 與出現在 Button 上的字元相同:
使用 MVVM 和命令的計算機截圖。
流覽範例