بنية MVVM في .NET MAUI: دليل عملي لبناء تطبيقات نظيفة مع CommunityToolkit

دليل عملي شامل لبناء تطبيقات .NET MAUI باستخدام نمط MVVM مع CommunityToolkit.Mvvm. يغطي ObservableProperty وRelayCommand وحقن التبعيات وطبقة الخدمات والتنقل واختبار الوحدة مع أمثلة كاملة.

إذا كنت تبني تطبيقات متعددة المنصات باستخدام .NET MAUI، فأنت بالتأكيد سمعت عن نمط MVVM (Model-View-ViewModel). لكن خلّني أكون صريحاً معك — هذا النمط ليس مجرد "خيار تقني" تضيفه لسيرتك الذاتية. هو فعلياً الأساس الذي يحدد إن كان تطبيقك سيعيش طويلاً أم ستتحول صيانته إلى كابوس بعد أشهر قليلة.

والخبر الجيد؟ مع مكتبة CommunityToolkit.Mvvm ونظام حقن التبعيات المدمج في .NET MAUI، أصبح تطبيق هذا النمط أسهل بكثير مما تتوقع.

في هذا الدليل، سنمر على كل ما تحتاج معرفته لبناء تطبيقات .NET MAUI بمعمارية نظيفة واحترافية — من الأساسيات إلى الأمثلة العملية الكاملة. فلنبدأ.

ما هو نمط MVVM ولماذا هو مهم؟

نمط MVVM هو نمط تصميم معماري يفصل بين ثلاث طبقات رئيسية في التطبيق:

  • Model (النموذج): يمثل البيانات ومنطق الأعمال — الكيانات وقواعد التحقق والخدمات
  • View (العرض): واجهة المستخدم المرئية — صفحات XAML والعناصر البصرية
  • ViewModel (نموذج العرض): الوسيط بين النموذج والعرض — يحتوي على منطق العرض ويدير حالة الواجهة

طيب، لماذا هذا الفصل مهم جداً؟

لأنه يحقق لك فوائد جوهرية تحسّها فعلاً مع نمو المشروع:

  • قابلية الاختبار: تقدر تكتب اختبارات وحدة لـ ViewModel بمعزل تام عن واجهة المستخدم
  • إعادة الاستخدام: نفس الـ ViewModel يمكن استخدامه مع واجهات مختلفة
  • الصيانة: تغيير الواجهة لا يؤثر على منطق الأعمال والعكس صحيح
  • التعاون: مصممو الواجهات والمطورون يشتغلون بشكل متوازٍ دون ما يعطّلون بعض

وفي .NET MAUI تحديداً، يعتبر نمط MVVM الخيار الموصى به رسمياً من مايكروسوفت لبناء التطبيقات الإنتاجية والتجارية.

CommunityToolkit.Mvvm: المكتبة المثالية لتطبيق MVVM

مكتبة CommunityToolkit.Mvvm (المعروفة أيضاً بـ MVVM Toolkit) هي مكتبة حديثة وخفيفة طورتها مايكروسوفت كجزء من .NET Foundation. صراحةً، ما يميزها عن البدائل عدة أمور:

  • لا تبعيات خارجية: المكتبة مستقلة تماماً ولا تعتمد على أي حزمة أخرى
  • مولدات الشفرة المصدرية (Source Generators): تولد الكود المتكرر تلقائياً وقت الترجمة — وهذه نقطة قوة حقيقية
  • مرونة عالية: استخدم ما تحتاجه فقط دون الالتزام بكل شيء
  • دعم رسمي: تستخدمها تطبيقات مايكروسوفت الداخلية مثل Microsoft Store
  • متوافقة مع AOT: الإصدار 8.4 يدعم الخصائص الجزئية (Partial Properties) المتوافقة مع التجميع المسبق

تثبيت المكتبة

لإضافة المكتبة لمشروعك، الأمر بسيط:

dotnet add package CommunityToolkit.Mvvm

أو أضف المرجع مباشرة في ملف .csproj:

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

ObservableProperty: وداعاً للكود المتكرر

في السابق، كان إنشاء خاصية تدعم إشعارات التغيير يتطلب كتابة كود مطول ومتكرر بشكل مزعج. لنرَ الطريقة التقليدية أولاً:

public class ProductViewModel : ObservableObject
{
    private string _name;
    public string Name
    {
        get => _name;
        set => SetProperty(ref _name, value);
    }

    private decimal _price;
    public decimal Price
    {
        get => _price;
        set => SetProperty(ref _price, value);
    }

    private string _description;
    public string Description
    {
        get => _description;
        set => SetProperty(ref _description, value);
    }
}

تخيل عندك ViewModel فيه 15 خاصية — ستكتب هذا النمط 15 مرة! هذا كود ممل وعرضة للأخطاء.

مع السمة [ObservableProperty]، نفس الشيء يصبح:

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

    [ObservableProperty]
    private decimal _price;

    [ObservableProperty]
    private string _description;
}

فرق كبير، صح؟ مولد الشفرة المصدرية يولد تلقائياً خصائص عامة باسم Name وPrice وDescription مع دعم كامل لإشعارات التغيير. لاحظ أن الصنف يجب أن يكون partial لأن الكود المولد سيكون في ملف جزئي مكمل.

الخصائص الجزئية (Partial Properties) — الطريقة الأحدث

مع الإصدار 8.4 من CommunityToolkit.Mvvm، صار بإمكانك استخدام الخصائص الجزئية بدلاً من الحقول. هذه الطريقة متوافقة تماماً مع التجميع المسبق (AOT) وتوفر تجربة تطوير أفضل:

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

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

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

الميزة الإضافية هنا إنك تقدر تضغط Ctrl+Click في Visual Studio للانتقال مباشرة للكود المولد ورؤية التنفيذ الفعلي. أداة مفيدة جداً لفهم ما يحصل خلف الكواليس.

دوال الاستجابة للتغيير

عندما تستخدم [ObservableProperty]، يتم توليد دالتين جزئيتين يمكنك تنفيذهما لإضافة منطق مخصص:

public partial class ProductViewModel : ObservableObject
{
    [ObservableProperty]
    private decimal _price;

    // تُستدعى قبل تغيير القيمة
    partial void OnPriceChanging(decimal value)
    {
        // يمكنك التحقق من القيمة الجديدة هنا
        Debug.WriteLine($"السعر سيتغير إلى: {value}");
    }

    // تُستدعى بعد تغيير القيمة
    partial void OnPriceChanged(decimal value)
    {
        // تحديث الحسابات المرتبطة
        OnPropertyChanged(nameof(FormattedPrice));
    }

    public string FormattedPrice => Price.ToString("C");
}

إشعار خصائص مرتبطة

في كثير من الأحيان، تغيير خاصية واحدة يستوجب تحديث خصائص أخرى مرتبطة بها. هنا تأتي السمة [NotifyPropertyChangedFor]:

public partial class OrderViewModel : ObservableObject
{
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(TotalPrice))]
    private int _quantity;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(TotalPrice))]
    private decimal _unitPrice;

    public decimal TotalPrice => Quantity * UnitPrice;
}

الآن عند تغيير الكمية أو سعر الوحدة، الواجهة تتحدث تلقائياً وتعرض السعر الإجمالي الجديد. بدون أي كود إضافي منك.

RelayCommand: أوامر بسطر واحد

الأوامر (Commands) هي الطريقة التي يتفاعل بها المستخدم مع التطبيق في نمط MVVM — كل ضغطة زر تتحول إلى أمر. ومع السمة [RelayCommand]، إنشاء الأوامر صار بسيطاً للغاية:

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

    [ObservableProperty]
    private decimal _price;

    // أمر متزامن بسيط
    [RelayCommand]
    private void ResetForm()
    {
        Name = string.Empty;
        Price = 0;
    }

    // أمر مع معامل
    [RelayCommand]
    private void UpdatePrice(decimal newPrice)
    {
        Price = newPrice;
    }
}

مولد الشفرة سيُنشئ تلقائياً خاصية ResetFormCommand وخاصية UpdatePriceCommand يمكنك ربطهما مباشرة في XAML.

الأوامر غير المتزامنة (AsyncRelayCommand)

في التطبيقات الحقيقية، معظم العمليات تكون غير متزامنة — جلب بيانات من خادم، حفظ في قاعدة بيانات، رفع ملفات. السمة [RelayCommand] ذكية بما يكفي لتكتشف الدوال غير المتزامنة وتولد AsyncRelayCommand تلقائياً:

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

    [ObservableProperty]
    private ObservableCollection<Product> _products = new();

    [ObservableProperty]
    private bool _isLoading;

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

    [RelayCommand]
    private async Task LoadProductsAsync()
    {
        IsLoading = true;
        try
        {
            var result = await _productService.GetAllAsync();
            Products = new ObservableCollection<Product>(result);
        }
        finally
        {
            IsLoading = false;
        }
    }

    [RelayCommand]
    private async Task DeleteProductAsync(Product product)
    {
        await _productService.DeleteAsync(product.Id);
        Products.Remove(product);
    }
}

وهنا ميزة مهمة جداً يغفل عنها كثيرون: AsyncRelayCommand يمنع التنفيذ المتزامن افتراضياً. يعني لو المستخدم ضغط الزر مرتين بسرعة، الأمر لن ينفذ مرتين. هذا وحده يحميك من فئة كبيرة من الأخطاء الشائعة.

التحكم في تفعيل الأوامر (CanExecute)

أحياناً تريد تعطيل زر معين حتى يتم استيفاء شروط محددة — مثل عدم تفعيل زر تسجيل الدخول إلا بعد ملء اسم المستخدم وكلمة المرور. استخدم السمة [NotifyCanExecuteChangedFor] مع دالة شرط:

public partial class LoginViewModel : ObservableObject
{
    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(LoginCommand))]
    private string _username;

    [ObservableProperty]
    [NotifyCanExecuteChangedFor(nameof(LoginCommand))]
    private string _password;

    [RelayCommand(CanExecute = nameof(CanLogin))]
    private async Task LoginAsync()
    {
        // تنفيذ عملية تسجيل الدخول
        await _authService.LoginAsync(Username, Password);
    }

    private bool CanLogin()
    {
        return !string.IsNullOrWhiteSpace(Username)
            && !string.IsNullOrWhiteSpace(Password);
    }
}

كل مرة يتغير فيها اسم المستخدم أو كلمة المرور، يتم إعادة تقييم CanLogin، والواجهة تلقائياً تفعّل أو تعطّل زر تسجيل الدخول. تجربة مستخدم ممتازة بأقل جهد.

حقن التبعيات في .NET MAUI: أساس البنية النظيفة

حقن التبعيات (Dependency Injection) هو من أهم المبادئ المعمارية في التطبيقات الحديثة. وأفضل شيء إن .NET MAUI يوفره مدمجاً باستخدام Microsoft.Extensions.DependencyInjection — نفس النظام المستخدم في ASP.NET Core. فلو سبق لك التعامل مع ASP.NET Core، ستجد نفسك في بيئة مألوفة تماماً.

تسجيل الخدمات في MauiProgram

نقطة البداية هي ملف MauiProgram.cs حيث تسجل كل مكونات التطبيق:

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

        // تسجيل الخدمات
        builder.Services.AddSingleton<IProductService, ProductService>();
        builder.Services.AddSingleton<IAuthService, AuthService>();
        builder.Services.AddSingleton<INavigationService, NavigationService>();

        // تسجيل HttpClient
        builder.Services.AddHttpClient("ApiClient", client =>
        {
            client.BaseAddress = new Uri("https://api.example.com/");
            client.DefaultRequestHeaders.Add("Accept", "application/json");
        });

        // تسجيل ViewModels
        builder.Services.AddTransient<ProductListViewModel>();
        builder.Services.AddTransient<ProductDetailViewModel>();
        builder.Services.AddSingleton<LoginViewModel>();

        // تسجيل الصفحات
        builder.Services.AddTransient<ProductListPage>();
        builder.Services.AddTransient<ProductDetailPage>();
        builder.Services.AddSingleton<LoginPage>();

        return builder.Build();
    }
}

فهم أنماط دورة الحياة

هذه النقطة مهمة جداً ويخطئ فيها كثير من المطورين. اختيار نمط دورة الحياة الصحيح يفرق كثير:

  • Singleton: نسخة واحدة طوال عمر التطبيق — مناسب للخدمات التي تحتفظ بحالة مشتركة مثل خدمة المصادقة أو إعدادات التطبيق
  • Transient: نسخة جديدة كل مرة — مناسب للصفحات والـ ViewModels التي لا تحتاج الاحتفاظ بحالتها بين الاستخدامات
  • Scoped: نسخة واحدة ضمن نطاق محدد — أقل استخداماً في تطبيقات MAUI لكنه مفيد في بعض السيناريوهات

القاعدة العامة التي أتبعها: Transient للصفحات والـ ViewModels، وSingleton للخدمات التي تشارك الحالة.

الحقن عبر المُنشئ (Constructor Injection)

الطريقة المفضلة (والأوضح) لحقن التبعيات هي عبر المُنشئ. عندما يحتاج ViewModel خدمة معينة، ببساطة يطلبها كمعامل:

public partial class ProductListViewModel : ObservableObject
{
    private readonly IProductService _productService;
    private readonly INavigationService _navigationService;

    // الحاوية تحقن الخدمات تلقائياً
    public ProductListViewModel(
        IProductService productService,
        INavigationService navigationService)
    {
        _productService = productService;
        _navigationService = navigationService;
    }

    [ObservableProperty]
    private ObservableCollection<Product> _products = new();

    [RelayCommand]
    private async Task LoadProductsAsync()
    {
        var result = await _productService.GetAllAsync();
        Products = new ObservableCollection<Product>(result);
    }

    [RelayCommand]
    private async Task GoToDetailAsync(Product product)
    {
        await _navigationService.NavigateToAsync<ProductDetailPage>(
            new Dictionary<string, object>
            {
                { "ProductId", product.Id }
            });
    }
}

وفي الصفحة، يتم حقن الـ ViewModel بنفس الطريقة:

public partial class ProductListPage : ContentPage
{
    public ProductListPage(ProductListViewModel viewModel)
    {
        InitializeComponent();
        BindingContext = viewModel;
    }
}

حاوية حقن التبعيات تتكفل بإنشاء كل شيء تلقائياً. عندما تطلب صفحة المنتجات، الحاوية تُنشئ الـ ViewModel وتحقن فيه كل الخدمات المطلوبة. أنت ما تحتاج تسوي شيء يدوياً.

التنقل مع MVVM في .NET MAUI

بصراحة، التنقل بين الصفحات من أكثر الأمور تعقيداً في تطبيقات MVVM. التحدي الأساسي واضح: كيف تنفذ التنقل من ViewModel بدون ما يعرف ViewModel أي شيء عن واجهة المستخدم؟

التنقل باستخدام Shell

Shell في .NET MAUI يتكامل مباشرة مع حقن التبعيات. عند تسجيل المسارات، يتم إنشاء الصفحات تلقائياً عبر الحاوية:

// تسجيل المسارات في AppShell.xaml.cs
public partial class AppShell : Shell
{
    public AppShell()
    {
        InitializeComponent();

        Routing.RegisterRoute(nameof(ProductDetailPage), typeof(ProductDetailPage));
        Routing.RegisterRoute(nameof(CheckoutPage), typeof(CheckoutPage));
    }
}

خدمة التنقل المخصصة

للحفاظ على فصل الاهتمامات (وهو مبدأ أساسي في MVVM)، أنشئ خدمة تنقل تغلف منطق Shell:

public interface INavigationService
{
    Task NavigateToAsync<TPage>(
        IDictionary<string, object>? parameters = null) where TPage : Page;
    Task GoBackAsync();
}

public class NavigationService : INavigationService
{
    public async Task NavigateToAsync<TPage>(
        IDictionary<string, object>? parameters = null) where TPage : Page
    {
        var route = typeof(TPage).Name;
        if (parameters != null)
        {
            await Shell.Current.GoToAsync(route, parameters);
        }
        else
        {
            await Shell.Current.GoToAsync(route);
        }
    }

    public async Task GoBackAsync()
    {
        await Shell.Current.GoToAsync("..");
    }
}

استقبال المعاملات في ViewModel

لاستقبال المعاملات المرسلة أثناء التنقل، استخدم واجهة IQueryAttributable:

public partial class ProductDetailViewModel : ObservableObject, IQueryAttributable
{
    private readonly IProductService _productService;

    [ObservableProperty]
    private Product _product;

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

    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        if (query.TryGetValue("ProductId", out var idObj) && idObj is int productId)
        {
            LoadProductCommand.Execute(productId);
        }
    }

    [RelayCommand]
    private async Task LoadProductAsync(int productId)
    {
        Product = await _productService.GetByIdAsync(productId);
    }
}

بناء طبقة الخدمات (Service Layer)

طبقة الخدمات هي قلب التطبيق — تحتوي على منطق الأعمال والتواصل مع المصادر الخارجية. المبدأ بسيط: كل خدمة تُعرَّف بواجهة (Interface) ويتم تنفيذها بشكل منفصل.

// واجهة الخدمة
public interface IProductService
{
    Task<IEnumerable<Product>> GetAllAsync();
    Task<Product> GetByIdAsync(int id);
    Task<Product> CreateAsync(Product product);
    Task UpdateAsync(Product product);
    Task DeleteAsync(int id);
}

// التنفيذ
public class ProductService : IProductService
{
    private readonly HttpClient _httpClient;

    public ProductService(IHttpClientFactory httpClientFactory)
    {
        _httpClient = httpClientFactory.CreateClient("ApiClient");
    }

    public async Task<IEnumerable<Product>> GetAllAsync()
    {
        var response = await _httpClient.GetAsync("products");
        response.EnsureSuccessStatusCode();
        return await response.Content
            .ReadFromJsonAsync<IEnumerable<Product>>();
    }

    public async Task<Product> GetByIdAsync(int id)
    {
        var response = await _httpClient.GetAsync($"products/{id}");
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<Product>();
    }

    public async Task<Product> CreateAsync(Product product)
    {
        var response = await _httpClient.PostAsJsonAsync("products", product);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<Product>();
    }

    public async Task UpdateAsync(Product product)
    {
        var response = await _httpClient.PutAsJsonAsync(
            $"products/{product.Id}", product);
        response.EnsureSuccessStatusCode();
    }

    public async Task DeleteAsync(int id)
    {
        var response = await _httpClient.DeleteAsync($"products/{id}");
        response.EnsureSuccessStatusCode();
    }
}

نموذج البيانات (Model)

طبقة النموذج تحتوي على الكيانات التي تمثل بيانات التطبيق. اجعلها بسيطة ونظيفة:

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public string ImageUrl { get; set; } = string.Empty;
    public string Category { get; set; } = string.Empty;
    public bool IsAvailable { get; set; }
}

ربط كل شيء معاً: صفحة XAML كاملة

بعد ما أنشأنا ViewModel والخدمات والنموذج، حان الوقت نربطها في صفحة XAML حقيقية. هنا تتجلى قوة MVVM فعلاً:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:vm="clr-namespace:MyApp.ViewModels"
             x:Class="MyApp.Views.ProductListPage"
             x:DataType="vm:ProductListViewModel"
             Title="المنتجات">

    <RefreshView IsRefreshing="{Binding IsLoading}"
                 Command="{Binding LoadProductsCommand}">
        <CollectionView ItemsSource="{Binding Products}"
                        SelectionMode="Single"
                        SelectionChangedCommand="{Binding GoToDetailCommand}"
                        SelectionChangedCommandParameter=
                            "{Binding SelectedItem,
                             Source={RelativeSource Self}}">

            <CollectionView.EmptyView>
                <VerticalStackLayout HorizontalOptions="Center"
                                     VerticalOptions="Center"
                                     Spacing="10">
                    <Label Text="لا توجد منتجات"
                           FontSize="18"
                           HorizontalTextAlignment="Center" />
                    <Button Text="تحديث"
                            Command="{Binding LoadProductsCommand}" />
                </VerticalStackLayout>
            </CollectionView.EmptyView>

            <CollectionView.ItemTemplate>
                <DataTemplate x:DataType="models:Product">
                    <Frame Margin="10" Padding="15" CornerRadius="10">
                        <Grid ColumnDefinitions="80,*"
                              ColumnSpacing="15">
                            <Image Source="{Binding ImageUrl}"
                                   WidthRequest="80"
                                   HeightRequest="80"
                                   Aspect="AspectFill" />
                            <VerticalStackLayout Grid.Column="1"
                                                 Spacing="5">
                                <Label Text="{Binding Name}"
                                       FontSize="16"
                                       FontAttributes="Bold" />
                                <Label Text="{Binding Description}"
                                       FontSize="13"
                                       TextColor="Gray"
                                       MaxLines="2" />
                                <Label Text="{Binding Price,
                                              StringFormat='{0:C}'}"
                                       FontSize="15"
                                       TextColor="Green"
                                       FontAttributes="Bold" />
                            </VerticalStackLayout>
                        </Grid>
                    </Frame>
                </DataTemplate>
            </CollectionView.ItemTemplate>
        </CollectionView>
    </RefreshView>
</ContentPage>

لاحظ استخدام x:DataType — هذا ما يُعرف بربط البيانات المترجم (Compiled Bindings). يوفر أداءً أفضل ويكشف أخطاء الربط وقت الترجمة بدلاً من وقت التشغيل. نصيحتي: لا تهمل هذه الخطوة أبداً.

هيكل المشروع الموصى به

تنظيم ملفات المشروع بشكل صحيح يوفر عليك وقتاً كبيراً مع نمو التطبيق. هذا الهيكل يعمل بشكل ممتاز لمعظم المشاريع:

MyMauiApp/
├── Models/
│   ├── Product.cs
│   ├── User.cs
│   └── Order.cs
├── ViewModels/
│   ├── ProductListViewModel.cs
│   ├── ProductDetailViewModel.cs
│   └── LoginViewModel.cs
├── Views/
│   ├── ProductListPage.xaml
│   ├── ProductListPage.xaml.cs
│   ├── ProductDetailPage.xaml
│   ├── ProductDetailPage.xaml.cs
│   └── LoginPage.xaml
├── Services/
│   ├── Interfaces/
│   │   ├── IProductService.cs
│   │   ├── IAuthService.cs
│   │   └── INavigationService.cs
│   └── Implementations/
│       ├── ProductService.cs
│       ├── AuthService.cs
│       └── NavigationService.cs
├── App.xaml
├── AppShell.xaml
└── MauiProgram.cs

في المشاريع الأكبر، يمكنك اعتماد هيكل Clean Architecture بفصل الطبقات في مشاريع مستقلة:

Solution/
├── MyApp.Domain/        # الكيانات وقواعد الأعمال
├── MyApp.Application/   # حالات الاستخدام والواجهات
├── MyApp.Infrastructure/# التنفيذ (API, DB, إلخ)
└── MyApp.MAUI/          # واجهة المستخدم والـ ViewModels

اختبار الوحدة لـ ViewModels

هنا تظهر القيمة الحقيقية لكل ما بنيناه. من أكبر فوائد نمط MVVM أنك تستطيع اختبار منطق التطبيق بالكامل بدون تشغيل واجهة المستخدم. لنكتب اختبارات لـ ProductListViewModel:

public class ProductListViewModelTests
{
    [Fact]
    public async Task LoadProducts_ShouldPopulateList()
    {
        // Arrange
        var mockProducts = new List<Product>
        {
            new() { Id = 1, Name = "منتج 1", Price = 29.99m },
            new() { Id = 2, Name = "منتج 2", Price = 49.99m }
        };

        var mockService = new Mock<IProductService>();
        mockService.Setup(s => s.GetAllAsync())
            .ReturnsAsync(mockProducts);

        var mockNav = new Mock<INavigationService>();
        var viewModel = new ProductListViewModel(
            mockService.Object, mockNav.Object);

        // Act
        await viewModel.LoadProductsCommand.ExecuteAsync(null);

        // Assert
        Assert.Equal(2, viewModel.Products.Count);
        Assert.Equal("منتج 1", viewModel.Products[0].Name);
    }

    [Fact]
    public async Task DeleteProduct_ShouldRemoveFromList()
    {
        // Arrange
        var product = new Product { Id = 1, Name = "منتج حذف" };
        var mockService = new Mock<IProductService>();
        mockService.Setup(s => s.DeleteAsync(1))
            .Returns(Task.CompletedTask);

        var mockNav = new Mock<INavigationService>();
        var viewModel = new ProductListViewModel(
            mockService.Object, mockNav.Object);
        viewModel.Products.Add(product);

        // Act
        await viewModel.DeleteProductCommand.ExecuteAsync(product);

        // Assert
        Assert.Empty(viewModel.Products);
        mockService.Verify(s => s.DeleteAsync(1), Times.Once);
    }
}

هذا بالضبط السبب اللي يخلي حقن التبعيات مهم — تقدر تستبدل الخدمات الحقيقية بنسخ وهمية (Mocks) في الاختبارات. النتيجة: اختبارات سريعة ومعزولة وموثوقة.

أنماط مضادة يجب تجنبها

بعد ما تعلمنا الممارسات الصحيحة، خلونا نتعرف على الأخطاء الشائعة. صدقني، شفت هذه الأخطاء تتكرر كثير في المشاريع الحقيقية.

1. ViewModel العملاق (God ViewModel)

هو الـ ViewModel الذي يتولى كل شيء — جلب البيانات والتنقل والتحقق ومنطق الأعمال وإدارة الحالة. والنتيجة؟ ملف ضخم يصعب فهمه واختباره وصيانته.

الحل: طبق مبدأ المسؤولية الواحدة. قسّم الـ ViewModel الكبير إلى وحدات أصغر متخصصة، وانقل منطق الأعمال إلى الخدمات.

2. منطق في Code-Behind

وضع المنطق البرمجي في ملفات code-behind (مثل MainPage.xaml.cs) يُفقدك كل مزايا MVVM. هذا الكود لا يمكن اختباره بسهولة ولا إعادة استخدامه.

الحل: استخدم ربط البيانات والأوامر لنقل كل المنطق إلى ViewModel. ملف code-behind يجب أن يحتوي فقط على مُنشئ الصفحة وحالات استثنائية محدودة جداً.

3. الحالة الثابتة (Static State)

الاعتماد على متغيرات ثابتة (static) لتخزين حالة التطبيق يسبب مشاكل صعبة التتبع — خاصة مع التنقل والتعددية. وأحياناً تظهر أخطاء عشوائية يصعب إعادة إنتاجها.

الحل: استخدم حقن التبعيات مع نمط Singleton للحالة المشتركة. نفس النتيجة لكن مع تحكم أفضل وقابلية للاختبار.

4. الربط الوثيق بين الطبقات

إنشاء الخدمات مباشرة داخل ViewModel باستخدام new يربطهما بشكل وثيق ويمنع الاختبار المستقل:

// خطأ — ربط وثيق
public class BadViewModel
{
    private readonly ProductService _service = new ProductService();
}

// صحيح — حقن التبعيات
public class GoodViewModel
{
    private readonly IProductService _service;
    public GoodViewModel(IProductService service) => _service = service;
}

اختيار إطار MVVM المناسب

في منظومة .NET MAUI، لديك عدة خيارات لأطر عمل MVVM. إليك مقارنة سريعة تساعدك في الاختيار:

  • CommunityToolkit.Mvvm: الأنسب للمشاريع البسيطة إلى المتوسطة. خفيف وبدون تبعيات وكوده طبيعي ومألوف. مثالي لمن يريد الحد الأدنى من "سحر الإطار" والحد الأقصى من الشفافية
  • Prism: الأنسب للتطبيقات المؤسسية المعقدة. يوفر إدارة مناطق متقدمة وتجميع أحداث ونظام تنقل قوي
  • ReactiveUI: الأنسب لمن يفضل البرمجة التفاعلية (Reactive Programming). يستخدم تدفقات البيانات بدلاً من الأحداث التقليدية

رأيي الشخصي: للأغلبية العظمى من المشاريع، CommunityToolkit.Mvvm هو الخيار الأفضل. بسيط وقوي ومدعوم رسمياً.

نصائح متقدمة لبنية أفضل

استخدام Messenger للتواصل بين ViewModels

أحياناً تحتاج ViewModels مختلفة تتواصل فيما بينها. بدلاً من الربط المباشر (الذي يخلق تبعيات غير مرغوبة)، استخدم نمط المرسل (Messenger) المدمج في CommunityToolkit.Mvvm:

// تعريف الرسالة
public record ProductUpdatedMessage(Product Product);

// إرسال الرسالة
WeakReferenceMessenger.Default.Send(
    new ProductUpdatedMessage(updatedProduct));

// استقبال الرسالة في ViewModel آخر
public partial class DashboardViewModel : ObservableRecipient
{
    public DashboardViewModel()
    {
        IsActive = true;
    }

    protected override void OnActivated()
    {
        Messenger.Register<DashboardViewModel, ProductUpdatedMessage>(
            this, (r, m) =>
            {
                // تحديث البيانات عند تلقي الرسالة
                r.RefreshDashboardCommand.Execute(null);
            });
    }
}

استخدام ربط البيانات المترجم (Compiled Bindings)

دائماً استخدم x:DataType في صفحات XAML. الفوائد تستحق:

  • اكتشاف أخطاء الربط وقت الترجمة بدلاً من وقت التشغيل
  • أداء أفضل لأن الربط لا يعتمد على الانعكاس (Reflection)
  • دعم أفضل لـ IntelliSense في المحرر

إدارة حالة التحميل والأخطاء

فكرة عملية جداً: أنشئ ViewModel أساسي يتعامل مع الحالات المشتركة بين كل الصفحات:

public partial class BaseViewModel : ObservableObject
{
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(IsNotBusy))]
    private bool _isBusy;

    [ObservableProperty]
    private string _errorMessage;

    [ObservableProperty]
    private bool _hasError;

    public bool IsNotBusy => !IsBusy;

    protected async Task ExecuteAsync(
        Func<Task> operation,
        string errorMsg = "حدث خطأ غير متوقع")
    {
        if (IsBusy) return;

        try
        {
            IsBusy = true;
            HasError = false;
            await operation();
        }
        catch (Exception ex)
        {
            HasError = true;
            ErrorMessage = errorMsg;
            Debug.WriteLine($"Error: {ex.Message}");
        }
        finally
        {
            IsBusy = false;
        }
    }
}

الآن أي ViewModel يرث من هذا الصنف الأساسي يقدر يستخدم ExecuteAsync لتغليف أي عملية غير متزامنة بمعالجة موحدة للأخطاء وحالة التحميل. هذا يوفر عليك تكرار نفس الكود في كل ViewModel.

الخلاصة

بناء تطبيقات .NET MAUI بمعمارية MVVM نظيفة ليس ترفاً أو تعقيداً زائداً — بل هو ضرورة لأي مشروع يتجاوز مرحلة النموذج الأولي. ومع CommunityToolkit.Mvvm ونظام حقن التبعيات المدمج، المعادلة صارت أبسط من أي وقت مضى.

خلاصة ما تعلمناه:

  • استخدم [ObservableProperty] و[RelayCommand] للتخلص من الكود المتكرر
  • اعتمد حقن التبعيات عبر المُنشئ لكل الخدمات والـ ViewModels
  • أنشئ واجهات (Interfaces) لكل خدمة لتسهيل الاختبار والاستبدال
  • نظم ملفاتك في هيكل واضح يفصل بين الطبقات
  • اكتب اختبارات وحدة لـ ViewModels للتأكد من صحة المنطق
  • تجنب الأنماط المضادة كالـ ViewModel العملاق والحالة الثابتة

نصيحتي الأخيرة: ابدأ بالأساسيات التي شرحناها هنا، ثم وسّع تدريجياً حسب احتياجات مشروعك. لا تحاول تطبيق كل شيء دفعة واحدة — المعمارية الجيدة تنمو مع التطبيق، وليس قبله.

عن الكاتب Editorial Team

Our team of expert writers and editors.