MVVM Community Toolkit για .NET MAUI: [ObservableProperty], [RelayCommand] και Source Generators (2026)
Πώς χρησιμοποιείται το MVVM Community Toolkit σε .NET MAUI: [ObservableProperty], [RelayCommand], ObservableValidator και WeakReferenceMessenger, με πραγματικό κώδικα και παρατηρήσεις από παραγωγή.
Το MVVM Community Toolkit για .NET MAUI είναι μια βιβλιοθήκη της Microsoft (πακέτο CommunityToolkit.Mvvm) που, μέσω Roslyn source generators και των attributes [ObservableProperty] και [RelayCommand], παράγει αυτόματα boilerplate κώδικα για INotifyPropertyChanged, ICommand και validation, ώστε ένα ViewModel να γράφεται με το ένα τρίτο του κώδικα. Στον οδηγό αυτό δείχνω πώς το χρησιμοποιώ σε πραγματικές εφαρμογές .NET MAUI 9/10, τι κώδικα παράγει πραγματικά κάτω από την επιφάνεια, και ποια patterns σας γλιτώνουν από leaks και NullReferenceException.
Το CommunityToolkit.Mvvm 8.4 είναι διαφορετικό πακέτο από το CommunityToolkit.Maui. Χρειάζεστε και τα δύο για πλήρη λειτουργικότητα σε MAUI εφαρμογή.
Το [ObservableProperty] παράγει property με backing field, OnPropertyChanged κλήσεις, καθώς και τα partial methods OnXxxChanging/OnXxxChanged για hooks.
Το [RelayCommand] παράγει IRelayCommand ή IAsyncRelayCommand ανάλογα με την υπογραφή της μεθόδου. Υποστηρίζει CanExecute, cancellation token και concurrent execution control.
Το ObservableValidator ενσωματώνει τα DataAnnotations στο MVVM pipeline, ώστε validation errors να πυροδοτούν επαναχρωματισμό των πεδίων στη View.
Οι source generators τρέχουν κατά τη σύνταξη, οπότε δεν έχετε runtime reflection κόστος. Αυτό είναι κρίσιμο στο NativeAOT publishing στο .NET MAUI.
Το WeakReferenceMessenger λύνει την επικοινωνία μεταξύ ViewModels χωρίς κυκλικές αναφορές, αλλά μόνο αν τηρήσετε τους κανόνες lifecycle που περιγράφω παρακάτω.
Τι είναι το MVVM Community Toolkit;
Το MVVM Community Toolkit είναι open-source βιβλιοθήκη της Microsoft που υλοποιεί τα τυπικά MVVM primitives, δηλαδή ObservableObject, RelayCommand, ObservableValidator, IMessenger, με έμφαση στην αποδοτικότητα και στη μηδενική εξάρτηση από συγκεκριμένο UI framework. Έτσι, τρέχει ίδιο σε .NET MAUI, WPF, WinUI, Uno και Avalonia. Στην έκδοση 8.4 (Σεπτέμβριος 2026), το πακέτο βασίζεται εξ ολοκλήρου σε Roslyn source generators και δεν κάνει runtime reflection ούτε emit για τα attributes.
Στην πράξη, αντικαθιστά περίπου 40 γραμμές τυπικού MVVM boilerplate (ιδιωτικό field, public property, SetProperty, RelayCommand instantiation στον constructor) με 3 γραμμές: ένα partial class, μία μέθοδο και ένα attribute. Η Microsoft το προτείνει επίσημα ως τη default MVVM βιβλιοθήκη για νέα MAUI projects, και το ίδιο το επίσημο MAUI template το εγκαθιστά όταν επιλέξετε την προεπιλεγμένη αρχιτεκτονική.
Ένα λεπτό αλλά κρίσιμο σημείο. Το πακέτο λέγεται CommunityToolkit.Mvvm. Δεν είναι το ίδιο με το CommunityToolkit.Maui (που περιέχει controls, behaviors, converters για UI). Στην πράξη, μια σοβαρή MAUI εφαρμογή σχεδόν πάντα εγκαθιστά και τα δύο, γιατί έχουν πλήρως συμπληρωματικούς σκοπούς.
Εγκατάσταση και ρύθμιση σε ένα MAUI project
Ξεκινήστε προσθέτοντας το πακέτο μέσω dotnet CLI ή από το NuGet Package Manager του Visual Studio 2026. Στο .csproj ενός καθαρού MAUI project η γραμμή είναι:
Για να δουλέψουν τα attributes, κάθε ViewModel πρέπει να δηλωθεί ως partial class. Αυτό είναι απαίτηση του Roslyn: ο source generator παράγει τη «δεύτερη μισή» της κλάσης σε ένα κρυφό αρχείο στο obj/Generated. Αν ξεχάσετε το partial, θα πάρετε το error MVVMTK0033. Το έχω πάθει προσωπικά και έχασα κάτι σαν 20 λεπτά ψάχνοντας γιατί κάποιο property «δεν αλλάζει». Ένα από αυτά τα bugs που σε κάνει να μισήσεις τη ζωή σου για δέκα λεπτά, μετά χαμογελάς.
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace MyApp.ViewModels;
public partial class MainViewModel : ObservableObject
{
[ObservableProperty]
private string _title = "Γεια σου, MAUI";
[RelayCommand]
private void Refresh() => Title = DateTime.Now.ToString("HH:mm:ss");
}
Στη συνέχεια, καταχωρήστε το ViewModel και τη σελίδα στον DI container μέσα στο MauiProgram.cs. Αυτό συνδυάζεται πολύ καλά με το pattern που περιγράφω στον οδηγό για Shell navigation και MVVM στο .NET MAUI:
builder.Services.AddSingleton<MainViewModel>();
builder.Services.AddSingleton<MainPage>();
// Transient για σελίδες που ανοιγοκλείνουν συχνά:
builder.Services.AddTransient<DetailsViewModel>();
builder.Services.AddTransient<DetailsPage>();
Το [ObservableProperty] attribute βήμα-βήμα
Το [ObservableProperty] τοποθετείται πάνω σε ένα private field με underscore prefix ή lowercase name, και ο generator παράγει το αντίστοιχο public property. Ο κανόνας ονοματοδοσίας: _title γίνεται Title, name γίνεται Name. Οτιδήποτε άλλο θα δώσει warning και θα σας κάνει τη ζωή δύσκολη σε refactoring.
public partial class UserViewModel : ObservableObject
{
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FullName))]
[NotifyPropertyChangedFor(nameof(IsValid))]
private string _firstName = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FullName))]
[NotifyPropertyChangedFor(nameof(IsValid))]
private string _lastName = string.Empty;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SubmitCommand))]
private bool _isBusy;
public string FullName => $"{FirstName} {LastName}".Trim();
public bool IsValid => !string.IsNullOrWhiteSpace(FirstName)
&& !string.IsNullOrWhiteSpace(LastName);
partial void OnFirstNameChanging(string? oldValue, string newValue)
{
// Hook πριν αλλάξει το value, π.χ. για validation ή logging.
}
partial void OnFirstNameChanged(string value)
{
// Hook αφού άλλαξε, π.χ. για side effects όπως save-on-edit.
}
}
Τα δύο attributes που δείχνω παραπάνω αξίζουν ξεχωριστή αναφορά:
[NotifyPropertyChangedFor]: Όταν αλλάζει το FirstName, ο generator καλεί επιπλέον OnPropertyChanged(nameof(FullName)). Αυτό είναι απαραίτητο για computed properties, αλλιώς η View δεν θα ανανεώσει το FullName.
[NotifyCanExecuteChangedFor]: Όταν αλλάζει το IsBusy, καλείται SubmitCommand.NotifyCanExecuteChanged(), ώστε το κουμπί στη XAML να ενεργοποιηθεί ή να απενεργοποιηθεί χωρίς να γράψετε extra κώδικα.
Τα partial void hooks OnXxxChanging/OnXxxChanged είναι το «αόρατο» πράγμα που κάνει το toolkit εξαιρετικά ευέλικτο. Δεν χρειάζεται να τα υλοποιήσετε. Αν υπάρχουν, ο generator τα καλεί. Αν δεν υπάρχουν, το JIT τα αφαιρεί εντελώς.
Το [RelayCommand] για sync και async ενέργειες
Το [RelayCommand] εφαρμόζεται σε μεθόδους και παράγει την αντίστοιχη public property τύπου IRelayCommand ή IAsyncRelayCommand. Ο κανόνας είναι απλός: Refresh() γίνεται RefreshCommand, SaveAsync() γίνεται SaveCommand (αφαιρεί το suffix «Async»).
CanExecute = nameof(CanSubmit): δένει το command με ένα property ή μέθοδο boolean. Συνδυάστε το με [NotifyCanExecuteChangedFor] στο IsRefreshing για αυτόματο refresh του button state.
AllowConcurrentExecutions = false: προεπιλογή. Αν ο χρήστης πατήσει διπλά το refresh, το δεύτερο tap αγνοείται μέχρι να τελειώσει το πρώτο. Απαραίτητο για network operations.
IncludeCancelCommand = true: παράγει επιπλέον LoadPageCancelCommand, το οποίο ακυρώνει την τρέχουσα εκτέλεση μέσω του CancellationToken. Πολύ χρήσιμο για SearchBar patterns όπου κάθε keystroke ξεκινά νέα αναζήτηση.
Validation με ObservableValidator και DataAnnotations
Για φόρμες, αντί να κληρονομήσετε από ObservableObject, κληρονομήστε από ObservableValidator. Παίρνετε ενσωματωμένη υποστήριξη για System.ComponentModel.DataAnnotations:
using System.ComponentModel.DataAnnotations;
using CommunityToolkit.Mvvm.ComponentModel;
public partial class LoginViewModel : ObservableValidator
{
[ObservableProperty]
[Required(ErrorMessage = "Το email είναι υποχρεωτικό.")]
[EmailAddress(ErrorMessage = "Μη έγκυρο email.")]
[NotifyDataErrorInfo]
private string _email = string.Empty;
[ObservableProperty]
[Required(ErrorMessage = "Ο κωδικός είναι υποχρεωτικός.")]
[MinLength(8, ErrorMessage = "Τουλάχιστον 8 χαρακτήρες.")]
[NotifyDataErrorInfo]
private string _password = string.Empty;
[RelayCommand]
private void Submit()
{
ValidateAllProperties();
if (HasErrors) return;
// ... signIn(Email, Password)
}
}
Ο ObservableValidator υλοποιεί INotifyDataErrorInfo, άρα η MAUI View μπορεί να bindάρει σε HasErrors ή να χρησιμοποιήσει converters για να δείξει κόκκινο border όταν το πεδίο είναι invalid. Το [NotifyDataErrorInfo] attribute λέει στον generator να καλέσει ValidateProperty αυτόματα σε κάθε setter, οπότε δεν χρειάζεστε το τυπικό «TextChanged, μετά Validate» χειρωνακτικά.
Για πιο σύνθετους κανόνες (cross-field validation, async calls σε API), γράψτε CustomValidationAttribute ή override ValidateProperty. Στην πράξη, το DataAnnotations πιάνει το 80% των περιπτώσεων στις mobile φόρμες. Στο τελευταίο μου project είχαμε μόνο δύο custom attributes για ολόκληρη τη signup ροή, τα υπόλοιπα έγιναν με stock annotations.
Messenger, DI και επικοινωνία μεταξύ ViewModels
Το WeakReferenceMessenger.Default λύνει το κλασικό MAUI πρόβλημα: πώς λέει το DetailsViewModel στο MainViewModel ότι το αντικείμενο άλλαξε, χωρίς να κρατά reference σε αυτό; Ο messenger κρατά weak references, άρα δεν εμποδίζει το GC να συλλέξει ένα ViewModel μιας κλειστής σελίδας.
// Ορίζουμε message ως record
public record ProductUpdatedMessage(int ProductId);
// Sender
WeakReferenceMessenger.Default.Send(new ProductUpdatedMessage(42));
// Receiver, κληρονομεί από ObservableRecipient που κάνει register/unregister αυτόματα
public partial class MainViewModel : ObservableRecipient,
IRecipient<ProductUpdatedMessage>
{
public MainViewModel() => IsActive = true; // ενεργοποιεί το subscription
public void Receive(ProductUpdatedMessage message)
{
// Ανανεώστε το item με Id == message.ProductId
}
}
Ο κανόνας που μαθαίνω πάντα τους juniors: αν κληρονομείτε από ObservableRecipient, θέστε IsActive = true στη σελίδα OnAppearing (ή στον constructor αν το ViewModel είναι transient). Ο toolkit θα κάνει register και unregister αυτόματα. Αν χρησιμοποιείτε σκέτο WeakReferenceMessenger.Default.Register, να θυμάστε να καλέσετε και Unregister, γιατί το weak reference δεν σας γλιτώνει από double-delivery όταν επανεκκινείται η ίδια σελίδα με deep link. Ειλικρινά, αυτό είναι από τα πιο συχνά bugs που βλέπω σε PR reviews.
Τι παράγουν οι Source Generators κάτω από την επιφάνεια
Για να καταλάβετε τι πραγματικά τρέχει, ενεργοποιήστε το EmitCompilerGeneratedFiles στο csproj και ρίξτε μια ματιά:
Θα δείτε ότι για κάθε [ObservableProperty] private string _title, ο generator παράγει κάτι σαν:
public string Title
{
get => _title;
set
{
if (!EqualityComparer<string>.Default.Equals(_title, value))
{
OnTitleChanging(value);
OnTitleChanging(default, value);
OnPropertyChanging(global::CommunityToolkit.Mvvm.ComponentModel.__Internals.__KnownINotifyPropertyChangingArgs.Title);
_title = value;
OnTitleChanged(value);
OnPropertyChanged(__KnownINotifyPropertyChangedArgs.Title);
}
}
}
Δύο πράγματα αξίζουν προσοχή. Πρώτον, χρησιμοποιεί προ-αποθηκευμένα PropertyChangedEventArgs instances αντί να τα κατασκευάζει σε κάθε set. Αυτό εξοικονομεί allocations, κάτι κρίσιμο σε CollectionView με χιλιάδες items. Δεύτερον, δεν υπάρχει καμία reflection ή Expression.Compile, οπότε ο κώδικας είναι πλήρως συμβατός με trimming και NativeAOT. Λεπτομέρειες στο MVVM Toolkit generator documentation και στο CommunityToolkit/dotnet GitHub releases για το πλήρες changelog.
Συμβατότητα με NativeAOT και trimming
Στο .NET 10 (Νοέμβριος 2025), το MAUI υποστηρίζει επίσημα NativeAOT publishing για iOS και Mac Catalyst. Επειδή ο toolkit βασίζεται σε source generation και όχι σε runtime reflection, τα ViewModels σας είναι AOT-safe by default. Κάτι που ΔΕΝ ισχύει με πολλά άλλα MVVM frameworks. Αυτό μειώνει το startup time μιας μεσαίας MAUI εφαρμογής κατά περίπου 30 έως 40% σε iPhone 12+ (βάσει δοκιμών που έκανα τον Ιούλιο 2026 σε εμπορική εφαρμογή catalog με 60 σελίδες).
Για ένα εμβάθυνμα στο τι σημαίνει αυτό για compiled bindings και startup, δείτε τον σχετικό οδηγό βελτιστοποίησης απόδοσης .NET MAUI. Για offline φόρμες που θέλουν να πυροδοτούν επικυρώσεις πριν από το save στη τοπική βάση SQLite και EF Core, ο συνδυασμός ObservableValidator με [RelayCommand(CanExecute)] είναι πρακτικά το de-facto pattern.
Πίνακας σύγκρισης: MVVM Toolkit vs χειρωνακτικό MVVM
Χαρακτηριστικό
Χειρωνακτικό MVVM
MVVM Community Toolkit
Γραμμές κώδικα ανά property
~6-8
1 (attribute)
Reflection κατά το runtime
Συχνά (SetField με Expression)
Καμία
NativeAOT-safe
Εξαρτάται
Ναι, εγγυημένα
Allocation-free PropertyChanged
Απαιτεί προσοχή
Προεπιλογή
Καμπύλη εκμάθησης
Χαμηλή αρχικά, boilerplate μεγαλώνει
Απαιτεί partial & attributes
Runtime dependencies
Καμία
CommunityToolkit.Mvvm (~200KB)
Async command patterns
Χειρωνακτικά
Ενσωματωμένα με cancellation
Συχνά ερωτήματα
Ποια είναι η διαφορά μεταξύ CommunityToolkit.Mvvm και CommunityToolkit.Maui;
Είναι δύο εντελώς διαφορετικά πακέτα. Το CommunityToolkit.Mvvm παρέχει MVVM primitives (ObservableObject, RelayCommand, Messenger) και είναι UI-agnostic. Το CommunityToolkit.Maui παρέχει MAUI-specific behaviors, converters, controls και effects. Μια πλήρης εφαρμογή τυπικά εγκαθιστά και τα δύο.
Γιατί το ViewModel μου πρέπει να είναι partial class;
Επειδή οι Roslyn source generators παράγουν επιπλέον κώδικα (τα generated properties, commands, hooks) σε δεύτερο αρχείο. Η C# απαιτεί όλες οι κλάσεις που διαμοιράζουν κώδικα σε πολλαπλά αρχεία να δηλωθούν ως partial. Χωρίς αυτό, θα λάβετε compile error MVVMTK0033.
Μπορώ να χρησιμοποιήσω το MVVM Toolkit με NativeAOT στο MAUI;
Ναι, το CommunityToolkit.Mvvm είναι πλήρως AOT-compatible από την έκδοση 8.2 και μετά. Επειδή βασίζεται σε source generation και όχι σε runtime reflection, δεν χρειάζεται trimming annotations. Στο .NET 10 το MAUI υποστηρίζει επίσημα NativeAOT publishing για iOS και MacCatalyst.
Πώς κάνω validation σε φόρμα χωρίς DataAnnotations;
Κληρονομήστε από ObservableValidator και κάντε override τη μέθοδο ValidateProperty, ή χρησιμοποιήστε [CustomValidation] με static μέθοδο που επιστρέφει ValidationResult. Και οι δύο προσεγγίσεις υποστηρίζουν cross-field και async κανόνες που τα stock DataAnnotations δεν πιάνουν.
Πότε πρέπει να χρησιμοποιώ WeakReferenceMessenger αντί για event ή DI;
Χρησιμοποιήστε τον messenger όταν ο sender και ο receiver βρίσκονται σε διαφορετικές σελίδες χωρίς άμεση σχέση (π.χ. edit page ενημερώνει list page). Για tightly coupled scenarios, προτιμήστε DI με shared service. Για UI events μέσα στην ίδια σελίδα, τα events ή [RelayCommand] είναι απλούστερα.
Πρακτικός οδηγός για τη μετάβαση από Xamarin.Forms σε .NET MAUI το 2026: Upgrade Assistant, μετατροπή Renderers σε Handlers, DI με MauiProgram.cs και χρονοδιάγραμμα από πραγματικά production projects.
Πλήρης οδηγός ειδοποιήσεων push στο .NET MAUI 10 με τοπικές ειδοποιήσεις, Firebase Cloud Messaging, Apple Push Notification Service και Azure Notification Hubs. Πρακτικά παραδείγματα κώδικα βήμα προς βήμα.
Πρακτικός οδηγός για τοπικές βάσεις δεδομένων στο .NET MAUI με SQLite-net και Entity Framework Core. CRUD, offline-first αρχιτεκτονική, sync patterns και βελτιστοποίηση απόδοσης με παραδείγματα κώδικα.