MVVM ב-.NET MAUI 10 עם CommunityToolkit.Mvvm: מדריך מעשי לארכיטקטורה יציבה בפרודקשן

מדריך מעשי ל-MVVM ב-.NET MAUI 10 עם CommunityToolkit.Mvvm 8.4.2: [ObservableProperty], [RelayCommand], partial properties, WeakReferenceMessenger ובדיקות ViewModels ב-xUnit ללא טעינת שכבת ה-UI.

MVVM ב-.NET MAUI 10: מדריך 2026

עודכן: 14 ביולי 2026

MVVM ב-.NET MAUI 10 הוא דפוס הארכיטקטורה המומלץ להפרדה בין ה-UI ללוגיקה, וכיום המימוש הסטנדרטי בפרודקשן הוא CommunityToolkit.Mvvm 8.4.2, ספריית מיקרוסופט שמייצרת את כל קוד ה-boilerplate של INotifyPropertyChanged ו-ICommand אוטומטית באמצעות Source Generators. במדריך הזה אני עובר על הדפוסים שאני מיישם בשלוש אפליקציות פרודקשן: [ObservableProperty] על partial properties, [RelayCommand] עבור פעולות אסינכרוניות, החלפת MessagingCenter ב-WeakReferenceMessenger, ומתי בדיוק לשבור את הכללים.

  • הגרסה הנוכחית של CommunityToolkit.Mvvm היא 8.4.2, עם תמיכה מלאה ב-partial properties דרך C# 13/14 והסרת דרישת ה-<LangVersion>preview</LangVersion> ברוסלין 5.0.
  • Source Generators מייצרים את קוד ה-INotifyPropertyChanged בזמן קומפילציה (אין overhead בזמן ריצה), וכל מחלקה שמשתמשת ב-[ObservableProperty] חייבת להיות partial.
  • MessagingCenter מיושן ב-.NET MAUI 10 ואינו מומלץ לשימוש חדש. יש להחליף אותו ב-WeakReferenceMessenger שמונע דליפות זיכרון בין ViewModels.
  • הזרקת ViewModel דרך Constructor Injection עם רישום Transient היא ברירת המחדל הנכונה. Singleton עבור services בלבד, אף פעם לא עבור ViewModels שקשורים לניווט.
  • ViewModel שנכתב נכון עם MVVM Toolkit הוא ניתן לבדיקה במלואו ב-xUnit בלי לטעון את שכבת ה-UI של MAUI.

למה MVVM עדיין הדפוס הנכון ב-.NET MAUI 10

ראיתי הרבה מפתחים שעברו מ-Xamarin.Forms ל-MAUI ותהו אם MVVM עדיין רלוונטי בעולם של Blazor Hybrid, Handlers חדשים ו-AI Copilot שכותב Code-Behind ב-OnClicked. התשובה הפשוטה: כן, יותר מתמיד. כשה-UI ב-XAML וה-ViewModel ב-POCO טהור, מקבלים שכבה אחת לבדיקות יחידה, שכבה שנייה לבדיקות UI, וממשק אחד ברור לניווט. באפליקציה אחת שלי, כל שכבת ה-ViewModel נבדקה ב-1,400 טסטים של xUnit בלי טעינה של רכיב MAUI בודד, וזה מה שמאפשר את זה.

הבעיה עם MVVM ה"ידני" הישן היא ההיקף של boilerplate. שדה, property עם get/set, קריאה ל-OnPropertyChanged, ואז backing field ל-ICommand עם CanExecute. בקוד production ריאלי, זה 40-60 שורות ל-ViewModel מינימלי. CommunityToolkit.Mvvm ממיר את כל זה ל-3-4 שורות אמיתיות של קוד, כשה-Source Generators כותבים את היתר בזמן קומפילציה. יש הבדל מהותי בין ספריה שרצה בזמן ריצה לבין ספריה שמזריקה קוד: כאן אין reflection, אין overhead, אין קסם ב-runtime. הכל נראה בקומפיילר.

עוד סיבה מעשית: כשמעבירים אפליקציית Xamarin ל-MAUI, כפי שסקרתי במדריך ההגירה מ-Xamarin.Forms ל-.NET MAUI 10, MVVM Toolkit הוא הדרך הכי חלקה להחליף את ViewModelBase הישן שלכם. אין תלות ב-MAUI עצמו, כך שהספריה עובדת גם ב-WPF, WinUI ו-Uno Platform. המשמעות היא שקוד ה-ViewModel ניתן להעברה בין פלטפורמות שונות של .NET.

התקנת CommunityToolkit.Mvvm וקונפיגורציה ראשונית

מתחילים בהתקנה של החבילה ב-.csproj של הפרויקט הראשי. שימו לב שיש שתי חבילות דומות בשם: CommunityToolkit.Mvvm (ספריית MVVM כללית, מה שאנחנו רוצים) ו-CommunityToolkit.Maui (רכיבי UI, behaviors ו-converters, שימושי אבל נושא נפרד). הן משלימות זו את זו ואפשר להתקין את שתיהן.

<ItemGroup>
  <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
  <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
</ItemGroup>

<PropertyGroup>
  <LangVersion>latest</LangVersion>
</PropertyGroup>

ב-C# 14 (הזמין ב-.NET 10 SDK) לא צריך יותר <LangVersion>preview</LangVersion> כפי שנדרש ב-8.4.0, כי זה עודכן ל-Roslyn 5.0. אחרי ההתקנה תראו ב-Dependencies → Analyzers תיקייה חדשה בשם CommunityToolkit.Mvvm.SourceGenerators. שם יופיע הקוד שנוצר בקומפילציה. מומלץ להסתכל שם בפעם הראשונה כדי להבין מה בדיוק הספריה יוצרת עבורכם.

הרישום של ה-Container של DI ב-MauiProgram.cs:

public static MauiApp CreateMauiApp()
{
    var builder = MauiApp.CreateBuilder();
    builder
        .UseMauiApp<App>()
        .ConfigureFonts(fonts =>
        {
            fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
        });

    // Services (Singleton, משותפים לכל האפליקציה)
    builder.Services.AddSingleton<IUserService, UserService>();
    builder.Services.AddSingleton<IHttpClientFactory, HttpClientFactory>();

    // ViewModels (Transient, instance חדש בכל ניווט)
    builder.Services.AddTransient<MainPageViewModel>();
    builder.Services.AddTransient<ProductDetailsViewModel>();

    // Pages (Transient, נדרש לתאימות עם Shell Routing)
    builder.Services.AddTransient<MainPage>();
    builder.Services.AddTransient<ProductDetailsPage>();

    return builder.Build();
}

מה זה [ObservableProperty] ואיך Source Generators עובדים

ה-attribute [ObservableProperty] הוא הלב של MVVM Toolkit. אתם מכריזים על שדה פרטי, מוסיפים את ה-attribute, וה-generator יוצר לכם את ה-property המלא עם OnPropertyChanged, event ל-PropertyChanging, ואופציה ל-hooks כמו OnCountChanged(int oldValue, int newValue). הכל בזמן קומפילציה: אין reflection, אין impact על ביצועים בזמן ריצה. זה חשוב במיוחד למכשירים ניידים, כמו שהסברתי במאמר על אופטימיזציית ביצועים ב-.NET MAUI 10.

הנה ViewModel מלא של דף פרטי מוצר בסגנון MVVM Toolkit:

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

public partial class ProductDetailsViewModel : ObservableObject
{
    private readonly IProductService _productService;

    public ProductDetailsViewModel(IProductService productService)
    {
        _productService = productService;
    }

    [ObservableProperty]
    private Product? _product;

    [ObservableProperty]
    private bool _isBusy;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(HasError))]
    private string? _errorMessage;

    public bool HasError => !string.IsNullOrEmpty(ErrorMessage);

    partial void OnProductChanged(Product? value)
    {
        // Hook אוטומטי שנוצר על ידי ה-generator
        System.Diagnostics.Debug.WriteLine($"Product changed: {value?.Name}");
    }
}

שימו לב לשלושה דברים חשובים: הכיתה מוגדרת partial (חובה, אחרת ה-generator לא יכול להוסיף לה קוד); השדות מתחילים בקו תחתון (הקונבנציה של הספריה, שמייצרת Product, IsBusy, ErrorMessage ב-PascalCase); ו-[NotifyPropertyChangedFor] גורם לשינוי ב-ErrorMessage להפעיל אירוע PropertyChanged גם עבור HasError, בלי צורך בקוד ידני להתחשב בזה.

מתי להשתמש ב-RelayCommand ומתי ב-AsyncRelayCommand

ה-Command המובנה של MAUI הוא בסיסי מדי לפרודקשן. הוא לא תומך ב-async, אין לו CanExecuteChanged אוטומטי בזמן ביצוע, ואין לו ביטול (cancellation). זה הופך אותו לבעיה ברגע שיש קריאת HTTP שלוקחת שנייה, המשתמש מקליק פעמיים על הכפתור, ואתם שולחים שתי בקשות במקביל. [RelayCommand] פותר את זה אוטומטית.

public partial class ProductDetailsViewModel : ObservableObject
{
    private readonly IProductService _productService;

    public ProductDetailsViewModel(IProductService productService)
    {
        _productService = productService;
    }

    [ObservableProperty]
    private Product? _product;

    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(AddToCartCommand))]
    private int _quantity = 1;

    [RelayCommand]
    private async Task LoadProductAsync(int productId, CancellationToken ct)
    {
        try
        {
            IsBusy = true;
            Product = await _productService.GetByIdAsync(productId, ct);
        }
        catch (OperationCanceledException)
        {
            // המשתמש ניווט החוצה, להתעלם
        }
        catch (Exception ex)
        {
            ErrorMessage = ex.Message;
        }
        finally
        {
            IsBusy = false;
        }
    }

    [RelayCommand(CanExecute = nameof(CanAddToCart))]
    private async Task AddToCartAsync() =>
        await _productService.AddToCartAsync(Product!.Id, Quantity);

    private bool CanAddToCart() => Product is not null && Quantity > 0;

    [ObservableProperty]
    private bool _isBusy;

    [ObservableProperty]
    private string? _errorMessage;
}

ה-generator יוצר עבורכם AddToCartCommand ו-LoadProductCommand כ-IAsyncRelayCommand. שני דברים חשובים כאן: LoadProductAsync מקבל CancellationToken אוטומטית, כי MVVM Toolkit מעביר טוקן שמתבטל כשה-command מופעל שוב, כך שאם המשתמש לוחץ פעמיים, הבקשה הראשונה מבוטלת. שנית, [NotifyCanExecuteChangedFor] על השדה _quantity מפעיל CanExecuteChanged על AddToCartCommand בכל שינוי, והכפתור ב-UI מתעדכן אוטומטית.

Partial Properties: מה השתנה ב-8.4

הפיצ'ר הגדול של גרסה 8.4 (דצמבר 2024) הוא תמיכה ב-partial properties. במקום להוסיף [ObservableProperty] על שדה ולתת ל-generator לייצר את ה-property, מכריזים ישירות על partial property. זה משתלב טוב יותר עם C#: מודיפיירים מותאמים אישית לכל accessor, attributes על ה-property עצמו במקום על השדה, ותמיכה מלאה ב-new, sealed, override ו-required.

public partial class SettingsViewModel : ObservableObject
{
    // הסגנון החדש (8.4+, C# 13+)
    [ObservableProperty]
    public partial string DisplayName { get; set; }

    [ObservableProperty]
    public required partial string ApiKey { get; set; }

    // סגנון ישן (עדיין נתמך, אבל בדרך החוצה)
    [ObservableProperty]
    private string _oldStyleField = string.Empty;
}

יש code fixer רשמי שמעביר את כל ה-solution שלכם מהסגנון הישן ליישום החדש בקליק אחד. אני ממליץ לעשות את זה בפרויקטים חדשים, אבל להשאיר קוד קיים כפי שהוא. הסגנון הישן עדיין נתמך במלואו וזאת עבודת רגרסיה מיותרת. לפי הכרזת מיקרוסופט על הגרסה 8.4, שני הסגנונות ימשיכו לחיות במקביל.

Messaging, DI ומחזורי חיים של Services

ב-Xamarin.Forms השתמשנו ב-MessagingCenter להעברת אירועים בין ViewModels לא קשורים. ב-.NET MAUI 10 MessagingCenter עדיין קיים אבל הוא מיושן ולא מומלץ. הוא תופס הפניות חזקות ל-subscribers, מה שגורם לדליפות זיכרון כשה-ViewModel לא בטל את ה-subscribe במפורש. WeakReferenceMessenger של MVVM Toolkit הוא ההחלפה הישירה.

// הגדרת הודעה
public sealed record CartUpdatedMessage(int ItemCount);

// ה-ViewModel ששולח
public partial class ProductDetailsViewModel : ObservableObject
{
    [RelayCommand]
    private async Task AddToCartAsync()
    {
        var count = await _cartService.AddAsync(Product!.Id);
        WeakReferenceMessenger.Default.Send(new CartUpdatedMessage(count));
    }
}

// ה-ViewModel שמאזין
public partial class ShellViewModel : ObservableObject, IRecipient<CartUpdatedMessage>
{
    public ShellViewModel()
    {
        WeakReferenceMessenger.Default.Register(this);
    }

    public void Receive(CartUpdatedMessage message)
    {
        CartBadgeCount = message.ItemCount;
    }

    [ObservableProperty]
    private int _cartBadgeCount;
}

ה-WeakReferenceMessenger תופס weak reference ל-recipient, אז גם אם שכחתם לקרוא ל-Unregister ב-OnDisappearing, ה-GC יאסוף את ה-ViewModel כרגיל. זה פותר את דליפת הזיכרון הכי נפוצה שראיתי ב-Xamarin.Forms (הכיתי את הבאג הזה בעצמי בשילוח של אפליקציה גדולה לפני כשנתיים, וזה לא היה כיף).

לגבי מחזורי חיים ב-DI: יש שלוש טעויות שאני רואה שוב ושוב. ראשית, רישום HttpClient כ-Transient. לא, זה חייב להיות Singleton או בשימוש עם IHttpClientFactory (Socket exhaustion). שנית, רישום Pages כ-Singleton, כי Shell Routing לא יטעין את הדף מחדש. שלישית, שימוש ב-AddScoped. כפי שציינתי, אין scope ב-MAUI כמו ב-ASP.NET, אז זה מתנהג כמו Singleton בפועל.

ObservableValidator עבור טפסים ואימות

לטפסים עם אימות (login, registration, checkout), במקום לכתוב לוגיקת אימות ידנית ב-set, ה-ObservableValidator יורש מ-ObservableObject ומיישם INotifyDataErrorInfo, הממשק שה-Entry ב-MAUI קורא אליו כדי להציג אדום/ירוק אוטומטית.

using System.ComponentModel.DataAnnotations;
using CommunityToolkit.Mvvm.ComponentModel;

public partial class LoginViewModel : ObservableValidator
{
    [ObservableProperty]
    [Required(ErrorMessage = "שדה חובה")]
    [EmailAddress(ErrorMessage = "פורמט אימייל לא תקין")]
    [NotifyDataErrorInfo]
    private string _email = string.Empty;

    [ObservableProperty]
    [Required(ErrorMessage = "שדה חובה")]
    [MinLength(8, ErrorMessage = "לפחות 8 תווים")]
    [NotifyDataErrorInfo]
    private string _password = string.Empty;

    [RelayCommand]
    private async Task LoginAsync()
    {
        ValidateAllProperties();
        if (HasErrors) return;

        // המשך לוגיקת התחברות...
    }
}

שימו לב ל-[NotifyDataErrorInfo] על השדה. זה מפעיל אימות בכל שינוי של השדה ומעדכן את ה-UI. עבור טפסים ארוכים אני קורא ל-ValidateAllProperties() רק בלחיצה על submit ולא בכל keystroke. זה משפר UX ומונע הצגת שגיאות "שדה חובה" כשהמשתמש עוד לא הגיע לשדה.

בדיקת ViewModels בלי לטעון את MAUI

יתרון גדול של MVVM כשהוא נכתב נכון: ViewModel לא צריך להיטען יחד עם רכיבי MAUI. אפשר לבדוק אותו בפרויקט xUnit רגיל בלי TargetFramework של mobile. (הגדרה כזאת חסכה לי שעות בכל בילד ב-CI, בכנות.)

public class ProductDetailsViewModelTests
{
    [Fact]
    public async Task LoadProduct_SetsProductAndClearsIsBusy()
    {
        var mockService = new Mock<IProductService>();
        mockService.Setup(s => s.GetByIdAsync(42, It.IsAny<CancellationToken>()))
                   .ReturnsAsync(new Product { Id = 42, Name = "Widget" });

        var sut = new ProductDetailsViewModel(mockService.Object);

        await sut.LoadProductCommand.ExecuteAsync(42);

        Assert.NotNull(sut.Product);
        Assert.Equal("Widget", sut.Product!.Name);
        Assert.False(sut.IsBusy);
    }

    [Fact]
    public async Task LoadProduct_WhenServiceThrows_SetsErrorMessage()
    {
        var mockService = new Mock<IProductService>();
        mockService.Setup(s => s.GetByIdAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
                   .ThrowsAsync(new HttpRequestException("Network down"));

        var sut = new ProductDetailsViewModel(mockService.Object);

        await sut.LoadProductCommand.ExecuteAsync(1);

        Assert.Equal("Network down", sut.ErrorMessage);
        Assert.True(sut.HasError);
    }
}

שלוש אפליקציות שלי שוברות כמעט את כל הבאגים בשכבת ה-ViewModel בבדיקות יחידה כאלה. שכבת UI טסטים (Appium או .NET MAUI UITest) שמורה לבדיקות end-to-end של flows, לא ל-logic. זה גם רץ בזמן קומפילציה של CI ב-30 שניות במקום 6 דקות של emulator boot.

מלכודות נפוצות שראיתי בפרודקשן

אחרי חמש שנים של MAUI ו-MVVM Toolkit בפרודקשן, אלה החמש הכי נפוצות: (1) שכחה של partial על הכיתה, שכשהודעת השגיאה מסתירה את הבעיה האמיתית. (2) קריאה ל-OnPropertyChanged ידנית על שדה שיש עליו [ObservableProperty], שמייצרת שני events. (3) [RelayCommand] על מתודה שהיא לא private, שעובד אבל יוצר compiler warning וכפילות. (4) שימוש ב-Ioc.Default.GetService במקום Constructor Injection, שהופך את ה-ViewModel לבלתי ניתן לבדיקה. (5) הצבת לוגיקה כבדה (מיון, סינון של אלפי איטמים) ב-set של Observable Property, שקופאת את ה-UI בגלל ש-PropertyChanged רץ ב-UI thread.

לפרטים נוספים על ה-analyzers שמתריעים על חלק מהמלכודות האלה אוטומטית, ראו את התיעוד הרשמי של MVVM Toolkit וGitHub Releases של CommunityToolkit/dotnet.

שאלות נפוצות

האם CommunityToolkit.Mvvm חובה ב-.NET MAUI 10?

לא חובה, אבל בפועל כל צוות פרודקשן מכניס אותה. הכתיבה של INotifyPropertyChanged ו-ICommand ידנית לוקחת 40-60 שורות ל-ViewModel, ו-Source Generators מורידים את זה ל-3-4 שורות. אין overhead בזמן ריצה כי הכל מייוצר בקומפילציה.

מה ההבדל בין [ObservableProperty] ל-INotifyPropertyChanged הרגיל?

[ObservableProperty] הוא attribute שגורם ל-Source Generator לייצר את קוד ה-INotifyPropertyChanged בזמן קומפילציה. אתם מכריזים על שדה, וה-generator כותב את ה-property. מבחינת runtime זה בדיוק אותו קוד, פשוט כתוב אוטומטית. אין reflection ואין overhead.

MessagingCenter או WeakReferenceMessenger?

WeakReferenceMessenger. MessagingCenter יוצר דליפות זיכרון בגלל hard references ל-subscribers, והוא מיושן ב-.NET MAUI 10. ההגירה טכנית פשוטה: Subscribe/Send/Unsubscribe הופכים ל-Register/Send/Unregister על ה-WeakReferenceMessenger.Default.

איך רושמים ViewModels ב-Dependency Injection?

Transient כברירת מחדל: builder.Services.AddTransient<ProductViewModel>(). זה מבטיח instance חדש בכל ניווט. Singleton רק ל-services שאמורים לחיות לאורך כל האפליקציה (auth, config, http factory). ל-AddScoped אין טעם ב-MAUI, כי הוא מתנהג כמו Singleton בהיעדר request scope.

האם partial properties תומכים בכל גרסאות C#?

Partial properties דורשים C# 13 (יצא עם .NET 9) או חדש יותר. ב-CommunityToolkit.Mvvm 8.4.0 היה צריך <LangVersion>preview</LangVersion>, אבל מ-8.4.2 עם Roslyn 5.0 (C# 14 ב-.NET 10 SDK) זה עובד ישירות. אם אתם עדיין על .NET 8, המשיכו עם הסגנון הישן של שדות.

Marcus Chen
אודות הכותב Marcus Chen

Senior mobile architect with a decade of cross-platform experience. Spent the last five years going deep on .NET MAUI in production.