295 lines
7.0 KiB
Markdown
295 lines
7.0 KiB
Markdown
# MVVM Rules / Quy Tắc MVVM
|
|
|
|
Hướng dẫn chi tiết về Model-View-ViewModel pattern với CommunityToolkit.Mvvm.
|
|
|
|
## Core Principles / Nguyên Tắc Cốt Lõi
|
|
|
|
1. **Separation of Concerns**: View chỉ xử lý UI, ViewModel xử lý logic
|
|
2. **Testability**: ViewModel có thể unit test độc lập
|
|
3. **No Code-behind Logic**: Không viết business logic trong `.xaml.cs`
|
|
|
|
---
|
|
|
|
## CommunityToolkit.Mvvm Setup
|
|
|
|
```xml
|
|
<!-- .csproj -->
|
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.3.2" />
|
|
<PackageReference Include="CommunityToolkit.Maui" Version="9.1.0" />
|
|
```
|
|
|
|
```csharp
|
|
// MauiProgram.cs
|
|
builder.UseMauiCommunityToolkit();
|
|
```
|
|
|
|
---
|
|
|
|
## ViewModel Patterns
|
|
|
|
### 1. ObservableProperty (Auto-generate INotifyPropertyChanged)
|
|
|
|
```csharp
|
|
// ✅ ĐÚNG: Source Generator pattern
|
|
public partial class ProductViewModel : ObservableObject
|
|
{
|
|
[ObservableProperty]
|
|
private string _name = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private decimal _price;
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(FullDescription))]
|
|
private string _category = string.Empty;
|
|
|
|
// Computed property
|
|
public string FullDescription => $"{Name} - {Category}";
|
|
}
|
|
```
|
|
|
|
```csharp
|
|
// ❌ SAI: Manual implementation (verbose)
|
|
public class ProductViewModel : INotifyPropertyChanged
|
|
{
|
|
private string _name;
|
|
public string Name
|
|
{
|
|
get => _name;
|
|
set => SetProperty(ref _name, value);
|
|
}
|
|
// ... repetitive code
|
|
}
|
|
```
|
|
|
|
### 2. RelayCommand (Auto-generate ICommand)
|
|
|
|
```csharp
|
|
public partial class ProductListViewModel : ObservableObject
|
|
{
|
|
[ObservableProperty]
|
|
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
|
|
private bool _hasChanges;
|
|
|
|
// Async command với cancellation
|
|
[RelayCommand]
|
|
private async Task LoadDataAsync(CancellationToken token)
|
|
{
|
|
var products = await _service.GetAllAsync(token);
|
|
Products = new ObservableCollection<Product>(products);
|
|
}
|
|
|
|
// Command with CanExecute
|
|
[RelayCommand(CanExecute = nameof(CanSave))]
|
|
private async Task SaveAsync()
|
|
{
|
|
await _service.SaveAsync(CurrentProduct);
|
|
HasChanges = false;
|
|
}
|
|
|
|
private bool CanSave() => HasChanges && CurrentProduct != null;
|
|
|
|
// Command with parameter
|
|
[RelayCommand]
|
|
private void SelectProduct(Product product)
|
|
{
|
|
SelectedProduct = product;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. WeakReferenceMessenger (Event Aggregation)
|
|
|
|
```csharp
|
|
// Send message
|
|
WeakReferenceMessenger.Default.Send(new ProductUpdatedMessage(product));
|
|
|
|
// Receive message (register in constructor)
|
|
public partial class ProductListViewModel : ObservableObject, IRecipient<ProductUpdatedMessage>
|
|
{
|
|
public ProductListViewModel()
|
|
{
|
|
WeakReferenceMessenger.Default.Register(this);
|
|
}
|
|
|
|
public void Receive(ProductUpdatedMessage message)
|
|
{
|
|
// Handle product update
|
|
var product = Products.FirstOrDefault(p => p.Id == message.Product.Id);
|
|
if (product != null)
|
|
{
|
|
var index = Products.IndexOf(product);
|
|
Products[index] = message.Product;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Message definition
|
|
public record ProductUpdatedMessage(Product Product);
|
|
```
|
|
|
|
---
|
|
|
|
## View-ViewModel Binding
|
|
|
|
### Constructor Injection Pattern
|
|
|
|
```csharp
|
|
// View (ProductListPage.xaml.cs)
|
|
public partial class ProductListPage : ContentPage
|
|
{
|
|
public ProductListPage(ProductListViewModel viewModel)
|
|
{
|
|
InitializeComponent();
|
|
BindingContext = viewModel;
|
|
}
|
|
|
|
protected override async void OnNavigatedTo(NavigatedToEventArgs args)
|
|
{
|
|
base.OnNavigatedTo(args);
|
|
|
|
// Trigger data loading
|
|
if (BindingContext is ProductListViewModel vm)
|
|
{
|
|
await vm.LoadDataCommand.ExecuteAsync(null);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### XAML Binding
|
|
|
|
```xml
|
|
<ContentPage x:DataType="vm:ProductListViewModel">
|
|
|
|
<!-- Property Binding -->
|
|
<Label Text="{Binding Title}" />
|
|
|
|
<!-- Command Binding -->
|
|
<Button Text="Save" Command="{Binding SaveCommand}" />
|
|
|
|
<!-- Command with Parameter -->
|
|
<CollectionView ItemsSource="{Binding Products}">
|
|
<CollectionView.ItemTemplate>
|
|
<DataTemplate x:DataType="models:Product">
|
|
<Grid>
|
|
<Grid.GestureRecognizers>
|
|
<TapGestureRecognizer
|
|
Command="{Binding Source={RelativeSource AncestorType={x:Type vm:ProductListViewModel}}, Path=SelectProductCommand}"
|
|
CommandParameter="{Binding .}" />
|
|
</Grid.GestureRecognizers>
|
|
<Label Text="{Binding Name}" />
|
|
</Grid>
|
|
</DataTemplate>
|
|
</CollectionView.ItemTemplate>
|
|
</CollectionView>
|
|
|
|
<!-- IsRefreshing Binding -->
|
|
<RefreshView IsRefreshing="{Binding IsLoading}"
|
|
Command="{Binding RefreshCommand}">
|
|
<!-- Content -->
|
|
</RefreshView>
|
|
</ContentPage>
|
|
```
|
|
|
|
---
|
|
|
|
## Validation Pattern
|
|
|
|
```csharp
|
|
public partial class ProductFormViewModel : ObservableValidator
|
|
{
|
|
[ObservableProperty]
|
|
[NotifyDataErrorInfo]
|
|
[Required(ErrorMessage = "Name is required")]
|
|
[MinLength(3, ErrorMessage = "Name must be at least 3 characters")]
|
|
private string _name = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
[NotifyDataErrorInfo]
|
|
[Range(0.01, 999999, ErrorMessage = "Price must be positive")]
|
|
private decimal _price;
|
|
|
|
[RelayCommand]
|
|
private async Task SubmitAsync()
|
|
{
|
|
ValidateAllProperties();
|
|
|
|
if (HasErrors)
|
|
{
|
|
var errors = GetErrors();
|
|
// Handle validation errors
|
|
return;
|
|
}
|
|
|
|
await _service.CreateProductAsync(Name, Price);
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Common Mistakes / Lỗi Thường Gặp
|
|
|
|
### 1. Logic in Code-behind
|
|
|
|
```csharp
|
|
// ❌ SAI
|
|
public partial class ProductPage : ContentPage
|
|
{
|
|
private async void OnSaveClicked(object sender, EventArgs e)
|
|
{
|
|
var product = new Product { Name = NameEntry.Text };
|
|
await _service.SaveAsync(product);
|
|
}
|
|
}
|
|
|
|
// ✅ ĐÚNG: Logic trong ViewModel
|
|
// ViewModel
|
|
[RelayCommand]
|
|
private async Task SaveAsync() => await _service.SaveAsync(CurrentProduct);
|
|
|
|
// XAML
|
|
<Button Text="Save" Command="{Binding SaveCommand}" />
|
|
```
|
|
|
|
### 2. Missing [ObservableProperty] partial keyword
|
|
|
|
```csharp
|
|
// ❌ SAI: Missing partial
|
|
public class MyViewModel : ObservableObject
|
|
{
|
|
[ObservableProperty]
|
|
private string _name; // Won't generate property!
|
|
}
|
|
|
|
// ✅ ĐÚNG
|
|
public partial class MyViewModel : ObservableObject
|
|
{
|
|
[ObservableProperty]
|
|
private string _name;
|
|
}
|
|
```
|
|
|
|
### 3. Direct Collection Modification
|
|
|
|
```csharp
|
|
// ❌ SAI: UI won't update
|
|
Products.ToList().ForEach(p => p.IsSelected = false);
|
|
|
|
// ✅ ĐÚNG: Use ObservableCollection methods
|
|
foreach (var product in Products)
|
|
{
|
|
product.IsSelected = false;
|
|
}
|
|
OnPropertyChanged(nameof(Products));
|
|
```
|
|
|
|
---
|
|
|
|
## Resources
|
|
|
|
- [CommunityToolkit.Mvvm Docs](https://learn.microsoft.com/en-us/dotnet/communitytoolkit/mvvm/)
|
|
- [Shell Navigation](./shell-nav.md)
|
|
- [DI Setup](./di-setup.md)
|