MVVM Source Generators Trong .NET MAUI: Hướng Dẫn Thực Tế Với CommunityToolkit.Mvvm 8.4

Hướng dẫn sử dụng MVVM Source Generators trong .NET MAUI với CommunityToolkit.Mvvm 8.4 — từ ObservableProperty, RelayCommand đến BindableProperty. Kèm cú pháp partial properties, ví dụ thực tế và mẹo sửa lỗi thường gặp.

Giới thiệu

Nếu bạn đang phát triển ứng dụng .NET MAUI theo kiến trúc MVVM, chắc hẳn bạn đã quá quen với cảm giác này: viết hàng trăm dòng boilerplate cho mỗi ViewModel. Nào là INotifyPropertyChanged, rồi setter gọi OnPropertyChanged, rồi khai báo ICommand xong lại phải khởi tạo RelayCommand. Mỗi property mới thêm vào kéo theo 10-15 dòng code lặp đi lặp lại.

Thú thật, mình từng có ViewModel dài hơn 300 dòng mà logic thực sự chỉ chiếm chưa đầy 50 dòng. Phần còn lại? Toàn boilerplate.

CommunityToolkit.Mvvm (hay còn gọi là MVVM Toolkit) giải quyết triệt để vấn đề này bằng Source Generators — tự động sinh code ngay tại thời điểm biên dịch. Và với phiên bản 8.4 đi kèm .NET 9 SDK, toolkit còn hỗ trợ partial properties, cho cú pháp tự nhiên hơn hẳn và tương thích hoàn toàn với NativeAOT.

Trong bài viết này, chúng ta sẽ đi từ cách cài đặt, sử dụng các attribute cốt lõi như [ObservableProperty][RelayCommand], cho đến các tính năng nâng cao và [BindableProperty] mới nhất từ CommunityToolkit.Maui 14.0. Tất cả đều kèm code thực tế bạn có thể copy về dùng ngay.

Tại sao cần MVVM Source Generators?

Vấn đề với cách viết MVVM truyền thống

Cứ nhìn một ViewModel điển hình không dùng source generator là thấy ngay vấn đề:

public class TaskViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;

    private string _title = string.Empty;
    public string Title
    {
        get => _title;
        set
        {
            if (_title != value)
            {
                _title = value;
                PropertyChanged?.Invoke(this,
                    new PropertyChangedEventArgs(nameof(Title)));
                PropertyChanged?.Invoke(this,
                    new PropertyChangedEventArgs(nameof(CanSave)));
                SaveCommand.NotifyCanExecuteChanged();
            }
        }
    }

    private bool _isCompleted;
    public bool IsCompleted
    {
        get => _isCompleted;
        set
        {
            if (_isCompleted != value)
            {
                _isCompleted = value;
                PropertyChanged?.Invoke(this,
                    new PropertyChangedEventArgs(nameof(IsCompleted)));
            }
        }
    }

    public bool CanSave => !string.IsNullOrWhiteSpace(Title);

    public RelayCommand SaveCommand { get; }

    public TaskViewModel()
    {
        SaveCommand = new RelayCommand(Save, () => CanSave);
    }

    private void Save()
    {
        // Logic lưu task
    }
}

Chỉ với 2 property và 1 command mà đã gần 50 dòng code. Giờ tưởng tượng một ViewModel thực tế với 10-15 properties — code sẽ cồng kềnh kinh khủng, và rất dễ mắc lỗi do copy-paste sai tên property.

Giải pháp: Source Generators tự động sinh code

Với CommunityToolkit.Mvvm, ViewModel trên rút gọn còn thế này:

public partial class TaskViewModel : ObservableObject
{
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(CanSave))]
    [NotifyCanExecuteChangedFor(nameof(SaveCommand))]
    public partial string Title { get; set; }

    [ObservableProperty]
    public partial bool IsCompleted { get; set; }

    public bool CanSave => !string.IsNullOrWhiteSpace(Title);

    [RelayCommand(CanExecute = nameof(CanSave))]
    private void Save()
    {
        // Logic lưu task
    }
}

Từ gần 50 dòng xuống chưa đầy 20. Mọi boilerplate được source generator sinh ra tự động tại thời điểm biên dịch — bạn không cần viết, không cần maintain.

Cài đặt CommunityToolkit.Mvvm

Thêm NuGet package

Cài đặt qua CLI hoặc NuGet Package Manager:

dotnet add package CommunityToolkit.Mvvm --version 8.4.2

Hoặc thêm trực tiếp vào file .csproj:

<ItemGroup>
    <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
</ItemGroup>

Bật partial properties (nên làm)

Để dùng cú pháp partial properties mới nhất (từ 8.4), bạn cần bật C# preview language version:

<PropertyGroup>
    <LangVersion>preview</LangVersion>
</PropertyGroup>

Lý do là code được sinh ra sử dụng từ khóa field — một tính năng mới của C# có sẵn từ .NET 9 SDK. Nếu bạn đang dùng .NET 9 hoặc .NET 10 thì hoàn toàn yên tâm, nó đã khá ổn định rồi.

[ObservableProperty] — Tự động tạo Observable Properties

Cú pháp mới với partial properties (8.4+)

Đây là cách được khuyến nghị từ phiên bản 8.4 trở đi, và nói thật là cú pháp đẹp hơn hẳn:

using CommunityToolkit.Mvvm.ComponentModel;

public partial class ProductViewModel : ObservableObject
{
    [ObservableProperty]
    public partial string Name { get; set; }

    [ObservableProperty]
    public partial decimal Price { get; set; }

    [ObservableProperty]
    public partial bool IsAvailable { get; set; }
}

Source generator sẽ tự động sinh backing field, gọi OnPropertyChanged trong setter, và tạo các phương thức hook OnNameChanging, OnNameChanged để bạn can thiệp khi cần.

Cú pháp cũ với fields (8.3 trở về)

Nếu chưa thể bật LangVersion preview, bạn vẫn dùng được cú pháp field kiểu cũ:

public partial class ProductViewModel : ObservableObject
{
    [ObservableProperty]
    private string _name = string.Empty;

    [ObservableProperty]
    private decimal _price;
}

Field _name sẽ được sinh thành property Name (viết hoa chữ cái đầu, bỏ dấu gạch dưới). Cú pháp này vẫn chạy tốt, nhưng không nên dùng cho dự án mới nữa.

So sánh hai cú pháp

Tiêu chíPartial Properties (8.4+)Fields (8.3 trở về)
Cú pháppublic partial string Name { get; set; }private string _name;
Hỗ trợ NativeAOTĐầy đủHạn chế
Custom access modifiersCó (private set, protected set)Không
Từ khóa required, sealed, overrideKhông
Nullability annotationsChính xác hơnCơ bản
Yêu cầuLangVersion previewKhông cần

Hook methods: OnChanging và OnChanged

Source generator tự tạo ra các partial methods mà bạn có thể override để chèn logic khi giá trị thay đổi. Khá tiện cho việc validate hoặc log:

public partial class ProductViewModel : ObservableObject
{
    [ObservableProperty]
    public partial decimal Price { get; set; }

    // Được gọi TRƯỚC khi giá trị thay đổi
    partial void OnPriceChanging(decimal oldValue, decimal newValue)
    {
        Debug.WriteLine($"Price đang thay đổi từ {oldValue} sang {newValue}");
    }

    // Được gọi SAU khi giá trị đã thay đổi
    partial void OnPriceChanged(decimal value)
    {
        Debug.WriteLine($"Price đã được cập nhật thành {value}");
    }
}

Thông báo thay đổi cho property khác

Trường hợp rất hay gặp: khi một property thay đổi, bạn cần UI cập nhật theo một property khác (thường là computed property). Dùng [NotifyPropertyChangedFor]:

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FullName))]
public partial string FirstName { get; set; }

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FullName))]
public partial string LastName { get; set; }

public string FullName => $"{FirstName} {LastName}";

Mỗi khi FirstName hoặc LastName thay đổi, UI tự động cập nhật FullName. Không cần code thêm gì.

[RelayCommand] — Tự động tạo Commands

Command đồng bộ

Chỉ cần gắn [RelayCommand] lên một method là source generator sinh ra command tương ứng cho bạn:

public partial class CounterViewModel : ObservableObject
{
    [ObservableProperty]
    public partial int Count { get; set; }

    [RelayCommand]
    private void Increment()
    {
        Count++;
    }

    [RelayCommand]
    private void Reset()
    {
        Count = 0;
    }
}

Quy tắc đặt tên đơn giản: method Increment sẽ sinh ra command IncrementCommand. Trong XAML, bind như bình thường:

<Button Text="Tăng" Command="{Binding IncrementCommand}" />
<Button Text="Reset" Command="{Binding ResetCommand}" />

Command bất đồng bộ (Async)

Khi method trả về Task, source generator tự động tạo AsyncRelayCommand. Đây là phần mình thích nhất:

[RelayCommand]
private async Task LoadDataAsync(CancellationToken cancellationToken)
{
    IsBusy = true;
    try
    {
        var items = await _dataService.GetItemsAsync(cancellationToken);
        Items = new ObservableCollection<Item>(items);
    }
    finally
    {
        IsBusy = false;
    }
}

Vài điểm đáng chú ý về AsyncRelayCommand:

  • Mặc định không cho phép thực thi đồng thời — command đang chạy thì nút tự disable. Cực kỳ hữu ích để tránh người dùng nhấn liên tục.
  • Muốn cho phép chạy đồng thời? Thêm: [RelayCommand(AllowConcurrentExecutions = true)]
  • Khi có tham số CancellationToken, toolkit sẽ tự động sinh thêm LoadDataCancelCommand nếu bạn set IncludeCancelCommand = true.

Command với tham số

[RelayCommand]
private void DeleteItem(Item item)
{
    Items.Remove(item);
}

[RelayCommand]
private async Task NavigateToDetail(int itemId)
{
    await Shell.Current.GoToAsync($"detail?id={itemId}");
}

Trong XAML:

<Button Text="Xóa"
        Command="{Binding DeleteItemCommand}"
        CommandParameter="{Binding .}" />

CanExecute — Kiểm soát khi nào command được phép chạy

Đây là combo mạnh nhất khi kết hợp [RelayCommand] với CanExecute[NotifyCanExecuteChangedFor]:

public partial class LoginViewModel : ObservableObject
{
    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(LoginCommand))]
    public partial string Username { get; set; }

    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(LoginCommand))]
    public partial string Password { get; set; }

    private bool CanLogin()
        => !string.IsNullOrWhiteSpace(Username)
        && !string.IsNullOrWhiteSpace(Password);

    [RelayCommand(CanExecute = nameof(CanLogin))]
    private async Task LoginAsync()
    {
        // Gọi API đăng nhập
        var result = await _authService.LoginAsync(Username, Password);
        if (result.Success)
        {
            await Shell.Current.GoToAsync("//main");
        }
    }
}

Nút Login tự disable khi Username hoặc Password trống, và enable ngay khi cả hai có giá trị. Không cần viết thêm dòng nào cả. Theo mình, đây là một trong những tính năng tiết kiệm thời gian nhất của toolkit.

[BindableProperty] — Source Generator cho Custom Controls (Mới 2026)

Đầu năm 2026, CommunityToolkit.Maui 14.0 giới thiệu một source generator hoàn toàn mới: [BindableProperty]. Ai từng phải viết BindableProperty.Create() thủ công khi tạo custom control sẽ hiểu ngay tại sao tính năng này đáng mong chờ đến vậy.

Cách viết cũ (thủ công)

public class RatingControl : ContentView
{
    public static readonly BindableProperty ValueProperty =
        BindableProperty.Create(
            nameof(Value),
            typeof(int),
            typeof(RatingControl),
            defaultValue: 0,
            propertyChanged: OnValueChanged);

    public int Value
    {
        get => (int)GetValue(ValueProperty);
        set => SetValue(ValueProperty, value);
    }

    private static void OnValueChanged(
        BindableObject bindable, object oldValue, object newValue)
    {
        var control = (RatingControl)bindable;
        control.UpdateStars();
    }
}

Dài, lặp lại, và rất dễ gõ sai tên property trong chuỗi string (mình từng debug mất cả buổi chiều chỉ vì typo ở đây).

Cách viết mới với [BindableProperty]

Đầu tiên, cài CommunityToolkit.Maui 14.0+:

dotnet add package CommunityToolkit.Maui --version 14.0.0

Sau đó opt-in vào tính năng experimental:

<PropertyGroup>
    <NoWarn>MCTEXP001</NoWarn>
</PropertyGroup>

Và viết custom control:

using CommunityToolkit.Maui;

public partial class RatingControl : ContentView
{
    [BindableProperty]
    public partial int Value { get; set; }
}

Từ hơn 20 dòng boilerplate xuống còn đúng 1 dòng. Source generator lo hết phần còn lại — sinh ValueProperty, getter/setter gọi GetValue/SetValue, và các callback cần thiết.

Lưu ý quan trọng: Tính năng này hiện ở trạng thái experimental, nên API có thể thay đổi. Tuy nhiên, bản thân CommunityToolkit.Maui đã dùng nó nội bộ để sinh toàn bộ BindableProperty của thư viện, nên độ ổn định thực tế khá cao.

Ví dụ thực tế: ViewModel quản lý danh sách công việc

Nói lý thuyết nhiều rồi. Giờ hãy xây dựng một ViewModel hoàn chỉnh cho ứng dụng Todo App để thấy mọi thứ hoạt động cùng nhau trong thực tế:

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Collections.ObjectModel;

namespace MyTodoApp.ViewModels;

public partial class TodoListViewModel : ObservableObject
{
    private readonly ITodoService _todoService;

    public TodoListViewModel(ITodoService todoService)
    {
        _todoService = todoService;
    }

    // --- Observable Properties ---

    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(AddTodoCommand))]
    public partial string NewTodoTitle { get; set; }

    [ObservableProperty]
    public partial bool IsBusy { get; set; }

    [ObservableProperty]
    public partial string StatusMessage { get; set; }

    [ObservableProperty]
    public partial ObservableCollection<TodoItem> Todos { get; set; }
        = new();

    // --- Commands ---

    private bool CanAddTodo()
        => !string.IsNullOrWhiteSpace(NewTodoTitle) && !IsBusy;

    [RelayCommand(CanExecute = nameof(CanAddTodo))]
    private async Task AddTodoAsync()
    {
        IsBusy = true;
        try
        {
            var newItem = new TodoItem
            {
                Title = NewTodoTitle.Trim(),
                CreatedAt = DateTime.UtcNow
            };

            await _todoService.SaveAsync(newItem);
            Todos.Add(newItem);

            NewTodoTitle = string.Empty;
            StatusMessage = "Đã thêm công việc mới!";
        }
        catch (Exception ex)
        {
            StatusMessage = $"Lỗi: {ex.Message}";
        }
        finally
        {
            IsBusy = false;
        }
    }

    [RelayCommand]
    private async Task ToggleCompleteAsync(TodoItem item)
    {
        item.IsCompleted = !item.IsCompleted;
        await _todoService.SaveAsync(item);
    }

    [RelayCommand]
    private async Task DeleteTodoAsync(TodoItem item)
    {
        await _todoService.DeleteAsync(item);
        Todos.Remove(item);
        StatusMessage = $"Đã xóa \"{item.Title}\"";
    }

    [RelayCommand]
    private async Task LoadTodosAsync(CancellationToken cancellationToken)
    {
        IsBusy = true;
        try
        {
            var items = await _todoService
                .GetAllAsync(cancellationToken);
            Todos = new ObservableCollection<TodoItem>(items);
        }
        finally
        {
            IsBusy = false;
        }
    }
}

Đăng ký với Dependency Injection

// MauiProgram.cs
public static MauiApp CreateMauiApp()
{
    var builder = MauiApp.CreateBuilder();
    builder.UseMauiApp<App>();

    // Đăng ký services
    builder.Services.AddSingleton<ITodoService, TodoService>();

    // Đăng ký ViewModels
    builder.Services.AddTransient<TodoListViewModel>();

    // Đăng ký Pages
    builder.Services.AddTransient<TodoListPage>();

    return builder.Build();
}

Bind trong XAML

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:vm="clr-namespace:MyTodoApp.ViewModels"
             x:Class="MyTodoApp.Views.TodoListPage"
             x:DataType="vm:TodoListViewModel">

    <VerticalStackLayout Padding="16" Spacing="12">

        <!-- Input mới -->
        <HorizontalStackLayout Spacing="8">
            <Entry Text="{Binding NewTodoTitle}"
                   Placeholder="Nhập công việc mới..."
                   HorizontalOptions="FillAndExpand" />
            <Button Text="Thêm"
                    Command="{Binding AddTodoCommand}" />
        </HorizontalStackLayout>

        <!-- Trạng thái -->
        <Label Text="{Binding StatusMessage}"
               TextColor="Gray"
               IsVisible="{Binding StatusMessage,
                   Converter={StaticResource StringToBoolConverter}}" />

        <!-- Danh sách -->
        <CollectionView ItemsSource="{Binding Todos}">
            <CollectionView.ItemTemplate>
                <DataTemplate x:DataType="models:TodoItem">
                    <SwipeView>
                        <SwipeView.RightItems>
                            <SwipeItems>
                                <SwipeItem Text="Xóa"
                                    BackgroundColor="Red"
                                    Command="{Binding
                                        Source={RelativeSource
                                            AncestorType={x:Type vm:TodoListViewModel}},
                                        Path=DeleteTodoCommand}"
                                    CommandParameter="{Binding .}" />
                            </SwipeItems>
                        </SwipeView.RightItems>
                        <Grid ColumnDefinitions="Auto,*" Padding="8">
                            <CheckBox IsChecked="{Binding IsCompleted}"
                                Grid.Column="0" />
                            <Label Text="{Binding Title}"
                                Grid.Column="1"
                                VerticalOptions="Center" />
                        </Grid>
                    </SwipeView>
                </DataTemplate>
            </CollectionView.ItemTemplate>
        </CollectionView>

        <!-- Loading indicator -->
        <ActivityIndicator IsRunning="{Binding IsBusy}"
                           IsVisible="{Binding IsBusy}" />
    </VerticalStackLayout>
</ContentPage>

Cách kiểm tra code được sinh ra

Một lợi thế lớn của source generators so với reflection là bạn có thể xem và debug code được sinh ra. Điều này giúp rất nhiều khi có gì đó không hoạt động đúng. Trong Visual Studio:

  1. Mở Solution Explorer
  2. Expand project → DependenciesAnalyzers
  3. Tìm CommunityToolkit.Mvvm.SourceGenerators
  4. Expand để thấy các file .g.cs được sinh ra cho mỗi ViewModel

Bạn sẽ thấy chính xác code mà source generator tạo ra — backing fields, property setters với OnPropertyChanged, command initialization, tất tần tật. Mình hay vào đây xem mỗi khi thắc mắc "nó sinh ra cái gì cho mình vậy?".

Các lỗi thường gặp và cách khắc phục

1. Quên khai báo partial class

Đây là lỗi phổ biến nhất, đặc biệt với người mới dùng. Source generators bắt buộc class phải là partial. Quên là lỗi biên dịch ngay. Và nếu class nằm lồng trong class khác, tất cả class cha cũng phải là partial.

// Sai - thiếu partial
public class MyViewModel : ObservableObject { }

// Đúng
public partial class MyViewModel : ObservableObject { }

2. Lỗi MVVMTK0045 sau khi nâng cấp lên 8.4

Nếu nâng từ 8.3 lên 8.4 mà chưa chuyển sang partial properties, bạn sẽ gặp warning MVVMTK0045. Hai cách xử lý:

  • Khuyến nghị: Chuyển toàn bộ sang partial properties và thêm <LangVersion>preview</LangVersion>
  • Tạm thời: Giữ nguyên 8.3.2 cho đến khi sẵn sàng migrate (không có gì vội cả)

3. Property không cập nhật UI

Kiểm tra ngay: ViewModel có kế thừa ObservableObject không? Đây là điều kiện bắt buộc để [ObservableProperty] hoạt động. Thiếu cái này thì mọi thứ đều im lặng, không lỗi gì nhưng UI cũng chẳng cập nhật.

4. Command không disable đúng cách

Chín phần mười lỗi này là do thiếu [NotifyCanExecuteChangedFor] trên property ảnh hưởng đến điều kiện CanExecute. Nếu thiếu, UI không biết khi nào cần kiểm tra lại trạng thái command — nút cứ disable mãi hoặc enable mãi.

Câu hỏi thường gặp (FAQ)

CommunityToolkit.Mvvm có miễn phí không? Dùng trong dự án thương mại được không?

Hoàn toàn miễn phí và mã nguồn mở dưới giấy phép MIT. Nó được duy trì bởi Microsoft, là một phần của .NET Foundation. Bạn thoải mái dùng trong mọi dự án, kể cả thương mại.

Source generators có ảnh hưởng đến hiệu suất runtime không?

Không. Source generators chạy tại thời điểm biên dịch, không phải runtime. Code được sinh ra là C# thuần, không dùng reflection. Thực tế thì code sinh ra còn tối ưu hơn code viết tay thông thường, và đặc biệt tương thích tốt với NativeAOT nhờ không phụ thuộc reflection.

Có thể dùng CommunityToolkit.Mvvm mà không dùng source generators không?

Được. Thư viện cung cấp các base class như ObservableObject, RelayCommand, AsyncRelayCommand, và WeakReferenceMessenger hoạt động độc lập. Source generators là tùy chọn — bạn có thể áp dụng dần, chuyển từng ViewModel một, không cần migrate cả dự án cùng lúc.

Khác biệt giữa [ObservableProperty] và [BindableProperty] là gì?

[ObservableProperty] dùng cho ViewModel — sinh property với INotifyPropertyChanged để cập nhật UI qua data binding. [BindableProperty] dùng cho custom controls (kế thừa BindableObject như ContentView, Button) — sinh BindableProperty.Create() cần thiết cho hệ thống binding của .NET MAUI. Hai attribute phục vụ mục đích khác nhau và thuộc hai package riêng biệt. Đừng nhầm lẫn nhé!

Nên dùng partial properties hay fields cho ObservableProperty?

Nếu dùng .NET 9 SDK trở lên, chọn partial properties không cần suy nghĩ. Cú pháp tự nhiên hơn, hỗ trợ đầy đủ NativeAOT, cho phép custom access modifiers (private set, protected set), và là hướng phát triển chính thức. Visual Studio còn có code fixer tự động chuyển từ fields sang partial properties chỉ với một click.

Về Tác Giả Editorial Team

Our team of expert writers and editors.