اگه تا حالا با توسعه اپلیکیشنهای موبایل و چندپلتفرمی کار کرده باشید، احتمالاً میدونید که معماری نرمافزار چقدر میتونه یک پروژه رو نجات بده — یا نابودش کنه. معماری MVVM (Model-View-ViewModel) یکی از بهترین الگوهای معماری برای توسعه اپلیکیشنهای .NET MAUI هست. چرا؟ چون با جداسازی منطق کسبوکار از رابط کاربری، نگهداری و تست کد رو خیلی راحتتر میکنه.
.NET MAUI به عنوان فریمورک چندپلتفرمی مایکروسافت، بهتون اجازه میده اپلیکیشنهای Android، iOS، macOS و Windows رو با یه کدبیس واحد بسازید. حالا وقتی این فریمورک رو با MVVM و ابزار فوقالعادهای مثل CommunityToolkit.Mvvm ترکیب کنید، تجربه توسعه واقعاً لذتبخش میشه.
خب، بیایید شروع کنیم. توی این مقاله قراره معماری MVVM رو توی .NET MAUI به صورت عمیق بررسی کنیم. از CommunityToolkit.Mvvm و تزریق وابستگی (Dependency Injection) گرفته تا ناوبری Shell — همه رو با جزئیات توضیح میدیم. در کنارش هم یه اپلیکیشن واقعی مدیریت وظایف (Task Management) رو قدم به قدم پیادهسازی میکنیم.
چرا MVVM برای .NET MAUI اهمیت دارد؟
قبل از اینکه بریم سراغ کد، بذارید یه نگاهی به مزایای اصلی MVVM بندازیم. صادقانه بگم، وقتی اولین بار با MVVM آشنا شدم فکر میکردم پیچیدگی اضافهای به پروژه تحمیل میکنه. ولی بعد از چند پروژه، فهمیدم که این الگو واقعاً ارزشش رو داره.
- جداسازی دغدغهها (Separation of Concerns): منطق کسبوکار از رابط کاربری جدا میشه و کد خواناتر و قابل استفاده مجدد میشه.
- تستپذیری بالا: با جداسازی ViewModel از View، میتونید منطق کسبوکار رو بدون نیاز به رابط کاربری تست کنید.
- همکاری تیمی بهتر: طراحان روی View کار میکنن و توسعهدهندهها روی ViewModel و Model تمرکز دارن.
- پشتیبانی از Data Binding: .NET MAUI از Data Binding قدرتمند پشتیبانی میکنه — و این دقیقاً قلب تپنده MVVM هست.
راهاندازی پروژه و نصب پکیجهای لازم
برای شروع، یه پروژه .NET MAUI جدید ایجاد میکنیم. میتونید از Visual Studio 2022 یا خط فرمان استفاده کنید:
dotnet new maui -n TaskManagerApp
cd TaskManagerApp
حالا باید پکیج CommunityToolkit.Mvvm نسخه 8.4 یا بالاتر رو نصب کنیم. این پکیج Source Generators خیلی خوبی داره که کدنویسی MVVM رو به شدت سادهتر میکنه:
dotnet add package CommunityToolkit.Mvvm --version 8.4.0
همچنین برای دسترسی به ابزارهای اضافی مثل WeakReferenceMessenger، پکیج زیر رو هم اضافه کنید:
dotnet add package CommunityToolkit.Maui --version 10.0.0
بعد از نصب پکیجها، باید فایل MauiProgram.cs رو تنظیم کنیم که توی بخشهای بعدی بهش میرسیم.
مبانی الگوی MVVM
الگوی MVVM از سه لایه اصلی تشکیل شده که هر کدوم وظیفه مشخصی دارن. بیایید هر کدوم رو جداگانه بررسی کنیم.
Model (مدل)
Model نمایانگر دادهها و منطق کسبوکار اپلیکیشن هست. یه سری کلاس ساده POCO هستن که ساختار دادهها رو تعریف میکنن. نکته مهم اینه که Model هیچ اطلاعی از View یا ViewModel نداره — کاملاً مستقل عمل میکنه.
// مدل وظیفه برای اپلیکیشن مدیریت وظایف
public class TaskModel
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public DateTime DueDate { get; set; }
public bool IsCompleted { get; set; }
public Priority TaskPriority { get; set; }
}
public enum Priority
{
Low,
Medium,
High
}
View (نما)
View لایه رابط کاربری هست که با XAML یا C# تعریف میشه. کارش نمایش دادهها و تعامل با کاربره. توی .NET MAUI، View معمولاً یه ContentPage، Shell یا یکی از کنترلهای MAUI هست.
ViewModel (مدل نما)
ViewModel پل ارتباطی بین Model و View هست. دادهها رو از Model میگیره، برای نمایش آمادهشون میکنه و دستورات (Commands) رو مدیریت میکنه. از طریق INotifyPropertyChanged هم تغییرات رو به View اطلاع میده.
استفاده از CommunityToolkit.Mvvm و Source Generators
راستش رو بخواید، قبل از CommunityToolkit زندگی توسعهدهندهها خیلی سختتر بود. مجبور بودید کلی کد تکراری برای INotifyPropertyChanged و ICommand بنویسید. ولی CommunityToolkit.Mvvm با Source Generators این درد رو تا حد زیادی کم کرده.
استفاده از [ObservableProperty]
با attribute مربوط به [ObservableProperty]، خیلی راحت میتونید یه فیلد رو به یه property قابل مشاهده تبدیل کنید. توی نسخه 8.4 به بعد، پشتیبانی از partial properties هم اضافه شده که با AOT (Ahead-of-Time Compilation) سازگاره:
using CommunityToolkit.Mvvm.ComponentModel;
public partial class TaskViewModel : ObservableObject
{
// روش قدیمی - استفاده از فیلد
[ObservableProperty]
private string title = string.Empty;
// روش جدید در نسخه 8.4+ - استفاده از partial property
[ObservableProperty]
public partial string Description { get; set; }
[ObservableProperty]
public partial bool IsCompleted { get; set; }
// Source Generator به صورت خودکار property های مورد نیاز را میسازد
}
مزیت partial properties اینه که کد تولید شده سبکتره و با AOT سازگارتر — که برای عملکرد بهتر روی iOS و Android واقعاً مهمه.
استفاده از [RelayCommand]
برای ساخت دستورات (Commands) هم از [RelayCommand] استفاده میکنیم. این attribute متدهای معمولی رو به ICommand تبدیل میکنه:
using CommunityToolkit.Mvvm.Input;
public partial class TaskViewModel : ObservableObject
{
[RelayCommand]
private async Task SaveTask()
{
// منطق ذخیره وظیفه
await taskService.SaveAsync(currentTask);
}
[RelayCommand]
private void DeleteTask(int taskId)
{
// منطق حذف وظیفه
taskService.Delete(taskId);
}
// Source Generator به صورت خودکار SaveTaskCommand و DeleteTaskCommand را میسازد
}
یه قابلیت جالب دیگه اینه که میتونید شرایطی برای فعال یا غیرفعال بودن دستور تعریف کنید:
[RelayCommand(CanExecute = nameof(CanSaveTask))]
private async Task SaveTask()
{
await taskService.SaveAsync(currentTask);
}
private bool CanSaveTask()
{
return !string.IsNullOrWhiteSpace(Title);
}
تزریق وابستگی در .NET MAUI
.NET MAUI از Microsoft.Extensions.DependencyInjection به صورت پیشفرض پشتیبانی میکنه. خبر خوب اینه که اگه قبلاً با ASP.NET Core کار کرده باشید، همون الگوها رو اینجا هم میتونید استفاده کنید.
ثبت سرویسها در MauiProgram.cs
فایل MauiProgram.cs نقطه شروع اپلیکیشن هست و جاییه که سرویسها، ViewModelها و Viewها رو ثبت میکنیم:
using Microsoft.Extensions.Logging;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
});
// ثبت سرویسها
builder.Services.AddSingleton<ITaskService, TaskService>();
builder.Services.AddSingleton<IDatabaseService, DatabaseService>();
// ثبت ViewModels
builder.Services.AddTransient<TaskListViewModel>();
builder.Services.AddTransient<TaskDetailViewModel>();
// ثبت Views/Pages
builder.Services.AddTransient<TaskListPage>();
builder.Services.AddTransient<TaskDetailPage>();
#if DEBUG
builder.Logging.AddDebug();
#endif
return builder.Build();
}
}
شاید براتون سوال پیش بیاد که فرق AddSingleton و AddTransient چیه. خلاصهاش اینه:
- AddSingleton: یه نمونه واحد از سرویس در کل عمر اپلیکیشن ایجاد میشه. برای سرویسهایی که state نگه میدارن مناسبه.
- AddTransient: هر بار که سرویس درخواست بشه، یه نمونه جدید ساخته میشه. معمولاً برای ViewModelها و Pageها استفاده میشه.
- AddScoped: توی MAUI کمتر استفاده میشه، ولی یه نمونه برای هر scope میسازه.
تزریق وابستگی در ViewModel
بعد از ثبت سرویسها، میتونید اونها رو از طریق constructor توی ViewModel تزریق کنید:
public partial class TaskListViewModel : ObservableObject
{
private readonly ITaskService taskService;
private readonly INavigationService navigationService;
public TaskListViewModel(
ITaskService taskService,
INavigationService navigationService)
{
this.taskService = taskService;
this.navigationService = navigationService;
}
[ObservableProperty]
public partial ObservableCollection<TaskModel> Tasks { get; set; } = new();
[RelayCommand]
private async Task LoadTasksAsync()
{
var tasks = await taskService.GetAllTasksAsync();
Tasks = new ObservableCollection<TaskModel>(tasks);
}
}
ساخت یک مثال عملی: اپلیکیشن مدیریت وظایف
خب، حالا که مفاهیم پایه رو یاد گرفتیم، وقتشه دست به کد بشیم! بیایید یه اپلیکیشن کامل مدیریت وظایف بسازیم که عملیات CRUD داشته باشه.
ایجاد مدل وظیفه (TaskModel)
اول از همه مدل داده رو با جزئیات بیشتری تعریف میکنیم:
namespace TaskManagerApp.Models;
public class TaskModel
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public DateTime DueDate { get; set; } = DateTime.Now.AddDays(1);
public bool IsCompleted { get; set; }
public Priority TaskPriority { get; set; } = Priority.Medium;
public DateTime CreatedAt { get; set; } = DateTime.Now;
public DateTime? CompletedAt { get; set; }
}
public enum Priority
{
Low = 0,
Medium = 1,
High = 2
}
ایجاد سرویس مدیریت وظایف
یه interface و implementation برای مدیریت وظایف میسازیم. توی پروژههای واقعی احتمالاً از SQLite یا یه دیتابیس دیگه استفاده میکنید، ولی اینجا برای سادگی از یه لیست in-memory استفاده میکنیم:
namespace TaskManagerApp.Services;
public interface ITaskService
{
Task<List<TaskModel>> GetAllTasksAsync();
Task<TaskModel?> GetTaskByIdAsync(int id);
Task<int> SaveTaskAsync(TaskModel task);
Task<bool> DeleteTaskAsync(int id);
Task<bool> ToggleTaskCompletionAsync(int id);
}
public class TaskService : ITaskService
{
private readonly List<TaskModel> tasks = new();
private int nextId = 1;
public Task<List<TaskModel>> GetAllTasksAsync()
{
return Task.FromResult(
tasks.OrderByDescending(t => t.CreatedAt).ToList());
}
public Task<TaskModel?> GetTaskByIdAsync(int id)
{
var task = tasks.FirstOrDefault(t => t.Id == id);
return Task.FromResult(task);
}
public Task<int> SaveTaskAsync(TaskModel task)
{
if (task.Id == 0)
{
task.Id = nextId++;
task.CreatedAt = DateTime.Now;
tasks.Add(task);
}
else
{
var existing = tasks.FirstOrDefault(t => t.Id == task.Id);
if (existing != null)
{
existing.Title = task.Title;
existing.Description = task.Description;
existing.DueDate = task.DueDate;
existing.TaskPriority = task.TaskPriority;
}
}
return Task.FromResult(task.Id);
}
public Task<bool> DeleteTaskAsync(int id)
{
var task = tasks.FirstOrDefault(t => t.Id == id);
if (task != null)
{
tasks.Remove(task);
return Task.FromResult(true);
}
return Task.FromResult(false);
}
public Task<bool> ToggleTaskCompletionAsync(int id)
{
var task = tasks.FirstOrDefault(t => t.Id == id);
if (task != null)
{
task.IsCompleted = !task.IsCompleted;
task.CompletedAt = task.IsCompleted ? DateTime.Now : null;
return Task.FromResult(true);
}
return Task.FromResult(false);
}
}
ساخت TaskListViewModel
ViewModel لیست وظایف با قابلیتهای CRUD کامل. این بخش رو دقیق نگاه کنید چون تقریباً همه مفاهیمی که تا الان یاد گرفتیم رو توش میبینید:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Collections.ObjectModel;
namespace TaskManagerApp.ViewModels;
public partial class TaskListViewModel : ObservableObject
{
private readonly ITaskService taskService;
public TaskListViewModel(ITaskService taskService)
{
this.taskService = taskService;
}
[ObservableProperty]
public partial ObservableCollection<TaskModel> Tasks { get; set; } = new();
[ObservableProperty]
public partial bool IsLoading { get; set; }
[ObservableProperty]
public partial bool IsRefreshing { get; set; }
[ObservableProperty]
public partial string SearchText { get; set; } = string.Empty;
public async Task InitializeAsync()
{
await LoadTasksAsync();
}
[RelayCommand]
private async Task LoadTasksAsync()
{
if (IsLoading) return;
try
{
IsLoading = true;
var tasks = await taskService.GetAllTasksAsync();
Tasks.Clear();
foreach (var task in tasks)
{
Tasks.Add(task);
}
}
catch (Exception ex)
{
await Shell.Current.DisplayAlert(
"خطا",
$"خطا در بارگذاری وظایف: {ex.Message}",
"باشه");
}
finally
{
IsLoading = false;
}
}
[RelayCommand]
private async Task NavigateToAddTask()
{
await Shell.Current.GoToAsync("taskdetail");
}
[RelayCommand]
private async Task NavigateToTaskDetail(TaskModel task)
{
var navigationParameter = new Dictionary<string, object>
{
{ "Task", task }
};
await Shell.Current.GoToAsync("taskdetail", navigationParameter);
}
[RelayCommand]
private async Task ToggleTaskCompletion(TaskModel task)
{
await taskService.ToggleTaskCompletionAsync(task.Id);
await LoadTasksAsync();
}
[RelayCommand]
private async Task DeleteTask(TaskModel task)
{
bool confirm = await Shell.Current.DisplayAlert(
"تأیید حذف",
$"آیا مطمئن هستید که میخواهید '{task.Title}' را حذف کنید؟",
"بله", "خیر");
if (confirm)
{
await taskService.DeleteTaskAsync(task.Id);
await LoadTasksAsync();
}
}
}
ساخت TaskListPage با CollectionView
یه نکته مهم: توی .NET MAUI 10، ListView منسوخ شده و CollectionView جاش رو گرفته. CollectionView هم عملکرد بهتری داره و هم انعطافپذیرتره. پس حتماً ازش استفاده کنید:
<?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:viewmodels="clr-namespace:TaskManagerApp.ViewModels"
xmlns:models="clr-namespace:TaskManagerApp.Models"
x:Class="TaskManagerApp.Views.TaskListPage"
x:DataType="viewmodels:TaskListViewModel"
Title="لیست وظایف"
FlowDirection="RightToLeft">
<Grid RowDefinitions="Auto,*,Auto" Padding="10">
<SearchBar Grid.Row="0"
Placeholder="جستجوی وظایف..."
Text="{Binding SearchText}"
Margin="0,0,0,10"/>
<RefreshView Grid.Row="1"
IsRefreshing="{Binding IsRefreshing}"
Command="{Binding LoadTasksCommand}">
<CollectionView ItemsSource="{Binding Tasks}"
SelectionMode="None">
<CollectionView.EmptyView>
<StackLayout VerticalOptions="Center"
HorizontalOptions="Center">
<Label Text="هنوز وظیفهای ندارید"
FontSize="18" TextColor="Gray"/>
</StackLayout>
</CollectionView.EmptyView>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:TaskModel">
<SwipeView>
<SwipeView.RightItems>
<SwipeItems>
<SwipeItem Text="حذف"
BackgroundColor="Red"
Command="{Binding Source={RelativeSource
AncestorType={x:Type viewmodels:TaskListViewModel}},
Path=DeleteTaskCommand}"
CommandParameter="{Binding .}"/>
</SwipeItems>
</SwipeView.RightItems>
<Frame Margin="5" Padding="10">
<Frame.GestureRecognizers>
<TapGestureRecognizer
Command="{Binding Source={RelativeSource
AncestorType={x:Type viewmodels:TaskListViewModel}},
Path=NavigateToTaskDetailCommand}"
CommandParameter="{Binding .}"/>
</Frame.GestureRecognizers>
<Grid ColumnDefinitions="Auto,*,Auto">
<CheckBox Grid.Column="0"
IsChecked="{Binding IsCompleted}"/>
<StackLayout Grid.Column="1" Spacing="5">
<Label Text="{Binding Title}"
FontSize="16" FontAttributes="Bold"/>
<Label Text="{Binding Description}"
FontSize="12" TextColor="Gray"
LineBreakMode="TailTruncation"/>
</StackLayout>
<Label Grid.Column="2"
Text="{Binding TaskPriority}"
VerticalOptions="Center"/>
</Grid>
</Frame>
</SwipeView>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</RefreshView>
<Button Grid.Row="2"
Text="افزودن وظیفه جدید"
Command="{Binding NavigateToAddTaskCommand}"
Margin="0,10,0,0"/>
</Grid>
</ContentPage>
توی code-behind صفحه هم ViewModel رو تزریق میکنیم. ساده و تمیز:
namespace TaskManagerApp.Views;
public partial class TaskListPage : ContentPage
{
private readonly TaskListViewModel viewModel;
public TaskListPage(TaskListViewModel viewModel)
{
InitializeComponent();
this.viewModel = viewModel;
BindingContext = viewModel;
}
protected override async void OnAppearing()
{
base.OnAppearing();
await viewModel.InitializeAsync();
}
}
ناوبری Shell با پارامترها
Shell Navigation یکی از قدرتمندترین ویژگیهای .NET MAUI هست و ناوبری مبتنی بر URI رو فراهم میکنه. من شخصاً خیلی از این روش ناوبری خوشم میاد چون هم سادهست و هم خوانایی بالایی داره.
تنظیم Shell Routing
اول باید route ها رو توی AppShell.xaml یا code-behind ثبت کنیم:
namespace TaskManagerApp;
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
Routing.RegisterRoute("taskdetail", typeof(TaskDetailPage));
}
}
ساخت TaskDetailViewModel با QueryProperty
برای دریافت پارامترها توی صفحه مقصد، از QueryPropertyAttribute استفاده میکنیم:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace TaskManagerApp.ViewModels;
[QueryProperty(nameof(Task), "Task")]
public partial class TaskDetailViewModel : ObservableObject
{
private readonly ITaskService taskService;
public TaskDetailViewModel(ITaskService taskService)
{
this.taskService = taskService;
}
[ObservableProperty]
public partial TaskModel? Task { get; set; }
[ObservableProperty]
public partial string Title { get; set; } = string.Empty;
[ObservableProperty]
public partial string Description { get; set; } = string.Empty;
[ObservableProperty]
public partial DateTime DueDate { get; set; } = DateTime.Now.AddDays(1);
[ObservableProperty]
public partial Priority SelectedPriority { get; set; } = Priority.Medium;
[ObservableProperty]
public partial bool IsEditMode { get; set; }
// زمانی که Task از طریق navigation تنظیم میشود
partial void OnTaskChanged(TaskModel? value)
{
if (value != null)
{
IsEditMode = true;
Title = value.Title;
Description = value.Description;
DueDate = value.DueDate;
SelectedPriority = value.TaskPriority;
}
else
{
IsEditMode = false;
Title = string.Empty;
Description = string.Empty;
DueDate = DateTime.Now.AddDays(1);
SelectedPriority = Priority.Medium;
}
}
[RelayCommand(CanExecute = nameof(CanSaveTask))]
private async Task SaveTask()
{
var taskToSave = Task ?? new TaskModel();
taskToSave.Title = Title;
taskToSave.Description = Description;
taskToSave.DueDate = DueDate;
taskToSave.TaskPriority = SelectedPriority;
await taskService.SaveTaskAsync(taskToSave);
await Shell.Current.GoToAsync("..");
}
private bool CanSaveTask() => !string.IsNullOrWhiteSpace(Title);
[RelayCommand]
private async Task Cancel()
{
await Shell.Current.GoToAsync("..");
}
}
یه نکته: توی نسخههای جدید CommunityToolkit، میتونید از partial void OnPropertyChanged برای واکنش به تغییرات property استفاده کنید. خیلی تمیزتر از override کردن متد OnPropertyChanged هست.
الگوهای پیشرفته در MVVM
استفاده از WeakReferenceMessenger
WeakReferenceMessenger یه سیستم پیامرسانی loosely-coupled هست که به بخشهای مختلف اپلیکیشن اجازه میده بدون وابستگی مستقیم با هم ارتباط داشته باشن. اگه با Event Aggregator توی فریمورکهای دیگه آشنا باشید، مفهوم مشابهی داره:
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.Mvvm.Messaging.Messages;
// تعریف پیام سفارشی
public class TaskUpdatedMessage : ValueChangedMessage<TaskModel>
{
public TaskUpdatedMessage(TaskModel value) : base(value) { }
}
// ارسال پیام از ViewModel
WeakReferenceMessenger.Default.Send(new TaskUpdatedMessage(savedTask));
// دریافت پیام در ViewModel دیگر
public partial class TaskListViewModel : ObservableObject,
IRecipient<TaskUpdatedMessage>
{
public TaskListViewModel(ITaskService taskService)
{
this.taskService = taskService;
WeakReferenceMessenger.Default.Register(this);
}
public void Receive(TaskUpdatedMessage message)
{
// بهروزرسانی لیست وظایف
LoadTasksCommand.ExecuteAsync(null);
}
}
دستورات Async با پشتیبانی از لغو (Cancel)
یکی از قابلیتهای خوب CommunityToolkit.Mvvm پشتیبانی از دستورات async با امکان لغو هست. مخصوصاً وقتی عملیات طولانی دارید (مثلاً دانلود فایل)، این ویژگی خیلی به کار میاد:
[RelayCommand(IncludeCancelCommand = true)]
private async Task LoadDataAsync(CancellationToken cancellationToken)
{
try
{
IsLoading = true;
var items = await taskService.GetAllTasksAsync(cancellationToken);
foreach (var item in items)
{
cancellationToken.ThrowIfCancellationRequested();
Tasks.Add(item);
}
}
catch (OperationCanceledException)
{
// عملیات لغو شد
}
finally
{
IsLoading = false;
}
}
// Source Generator دو Command میسازد:
// LoadDataCommand و LoadDataCancelCommand
و توی XAML به راحتی به هر دو دستور دسترسی دارید:
<Button Text="بارگذاری دادهها" Command="{Binding LoadDataCommand}"/>
<Button Text="لغو" Command="{Binding LoadDataCancelCommand}"/>
اعتبارسنجی (Validation) با CommunityToolkit
اعتبارسنجی دادهها هم با ObservableValidator خیلی ساده میشه. این کلاس از Data Annotations استاندارد پشتیبانی میکنه:
using System.ComponentModel.DataAnnotations;
using CommunityToolkit.Mvvm.ComponentModel;
public partial class TaskDetailViewModel : ObservableValidator
{
[ObservableProperty]
[Required(ErrorMessage = "عنوان وظیفه الزامی است")]
[MinLength(3, ErrorMessage = "عنوان باید حداقل 3 کاراکتر باشد")]
public partial string Title { get; set; } = string.Empty;
[ObservableProperty]
[MaxLength(500, ErrorMessage = "توضیحات نمیتواند بیش از 500 کاراکتر باشد")]
public partial string Description { get; set; } = string.Empty;
[RelayCommand]
private async Task SaveTask()
{
ValidateAllProperties();
if (HasErrors)
{
var errors = GetErrors().Select(e => e.ErrorMessage);
await Shell.Current.DisplayAlert(
"خطای اعتبارسنجی",
string.Join("\n", errors), "باشه");
return;
}
await taskService.SaveTaskAsync(currentTask);
}
}
تست واحد (Unit Testing) ViewModel ها
یکی از بزرگترین مزایای MVVM تستپذیری بالاشه. صادقانه بگم، وقتی میتونید بدون بالا آوردن UI تست بنویسید، سرعت توسعهتون به شکل محسوسی افزایش پیدا میکنه.
نصب پکیجهای تست
dotnet add package xunit
dotnet add package xunit.runner.visualstudio
dotnet add package Moq
dotnet add package FluentAssertions
نوشتن تست برای ViewModel
using Xunit;
using Moq;
using FluentAssertions;
namespace TaskManagerApp.Tests;
public class TaskListViewModelTests
{
private readonly Mock<ITaskService> mockTaskService;
private readonly TaskListViewModel viewModel;
public TaskListViewModelTests()
{
mockTaskService = new Mock<ITaskService>();
viewModel = new TaskListViewModel(mockTaskService.Object);
}
[Fact]
public async Task LoadTasksAsync_ShouldPopulateTasksList()
{
// آمادهسازی
var expectedTasks = new List<TaskModel>
{
new() { Id = 1, Title = "وظیفه اول" },
new() { Id = 2, Title = "وظیفه دوم" }
};
mockTaskService
.Setup(s => s.GetAllTasksAsync())
.ReturnsAsync(expectedTasks);
// اجرا
await viewModel.LoadTasksCommand.ExecuteAsync(null);
// بررسی
viewModel.Tasks.Should().HaveCount(2);
viewModel.Tasks[0].Title.Should().Be("وظیفه اول");
}
[Fact]
public async Task DeleteTask_ShouldCallServiceAndReload()
{
// آمادهسازی
var task = new TaskModel { Id = 1, Title = "وظیفه تست" };
mockTaskService
.Setup(s => s.DeleteTaskAsync(1))
.ReturnsAsync(true);
mockTaskService
.Setup(s => s.GetAllTasksAsync())
.ReturnsAsync(new List<TaskModel>());
// اجرا
await viewModel.DeleteTaskCommand.ExecuteAsync(task);
// بررسی
mockTaskService.Verify(s => s.DeleteTaskAsync(1), Times.Once);
}
[Fact]
public async Task ToggleCompletion_ShouldCallService()
{
// آمادهسازی
var task = new TaskModel { Id = 1, IsCompleted = false };
mockTaskService
.Setup(s => s.ToggleTaskCompletionAsync(1))
.ReturnsAsync(true);
// اجرا
await viewModel.ToggleTaskCompletionCommand.ExecuteAsync(task);
// بررسی
mockTaskService.Verify(
s => s.ToggleTaskCompletionAsync(1), Times.Once);
}
}
بهترین شیوهها و اشتباهات رایج
بهترین شیوهها
بعد از کار با چندین پروژه MVVM توی .NET MAUI، این نکتهها رو یاد گرفتم:
- از partial properties استفاده کنید: توی نسخه 8.4+، partial properties برای AOT safety بهترن و کد تمیزتری تولید میکنن.
- ViewModel رو کوچک نگه دارید: اگه ViewModel خیلی شلوغ شد، تقسیمش کنید یا از سرویسهای کمکی استفاده کنید.
- async/await رو درست استفاده کنید: همیشه از
Taskبرای عملیات async استفاده کنید و تا جای ممکن ازasync voidدوری کنید (مگه توی event handler ها). - تزریق وابستگی رو رعایت کنید: هیچوقت توی ViewModel از
newبرای ساخت dependency استفاده نکنید. همیشه constructor injection بزنید. - از CollectionView استفاده کنید: ListView توی .NET MAUI 10 منسوخ شده. CollectionView عملکرد بهتری داره.
- Memory leak رو جدی بگیرید: از WeakReferenceMessenger استفاده کنید و event handler ها رو حتماً unregister کنید.
- تست بنویسید: حداقل برای منطق حیاتی کسبوکار تست واحد بنویسید.
اشتباهات رایج
و اینها اشتباهاتی هستن که خیلیها (از جمله خودم!) مرتکب میشن:
- دسترسی مستقیم به UI از ViewModel: به جای
Shell.Currentمستقیم توی ViewModel، یه سرویس ناوبری بسازید و تزریقش کنید. تستپذیریتون خیلی بهتر میشه. - فراموش کردن BindingContext: اگه Binding هاتون کار نمیکنه، اول چک کنید BindingContext رو تنظیم کردید یا نه!
- استفاده نادرست از ObservableCollection: برای آپدیت لیست، آیتمها رو Add/Remove کنید. کل collection رو عوض نکنید، مگه اینکه از
[ObservableProperty]استفاده کرده باشید. - نادیده گرفتن lifecycle صفحه: از
OnAppearingوOnDisappearingبرای مدیریت lifecycle استفاده کنید وگرنه مشکلات حافظه سراغتون میاد. - عدم استفاده از CancellationToken: عملیات طولانی بدون CancellationToken = کاربر ناراضی. همیشه امکان لغو بذارید.
نمونه ViewModel بهینهشده
در انتها، بیایید یه نمونه بهینهشده از ViewModel رو ببینیم که همه بهترین شیوههای بالا رو رعایت کرده:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
namespace TaskManagerApp.ViewModels;
public partial class OptimizedTaskViewModel : ObservableValidator
{
private readonly ITaskService taskService;
private readonly INavigationService navigationService;
private readonly IDialogService dialogService;
public OptimizedTaskViewModel(
ITaskService taskService,
INavigationService navigationService,
IDialogService dialogService)
{
this.taskService = taskService;
this.navigationService = navigationService;
this.dialogService = dialogService;
WeakReferenceMessenger.Default
.Register<TaskUpdatedMessage>(this, OnTaskUpdated);
}
[ObservableProperty]
[Required]
[MinLength(3)]
public partial string Title { get; set; } = string.Empty;
[ObservableProperty]
public partial bool IsBusy { get; set; }
[RelayCommand(CanExecute = nameof(CanSave),
IncludeCancelCommand = true)]
private async Task SaveAsync(CancellationToken cancellationToken)
{
ValidateAllProperties();
if (HasErrors)
{
await dialogService.ShowErrorAsync("لطفاً خطاها را برطرف کنید");
return;
}
try
{
IsBusy = true;
await taskService.SaveTaskAsync(currentTask, cancellationToken);
await navigationService.GoBackAsync();
}
catch (Exception ex)
{
await dialogService.ShowErrorAsync($"خطا: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
private bool CanSave() =>
!IsBusy && !string.IsNullOrWhiteSpace(Title);
private void OnTaskUpdated(
object recipient, TaskUpdatedMessage message)
{
// مدیریت پیام دریافتی
}
public void Cleanup()
{
WeakReferenceMessenger.Default
.Unregister<TaskUpdatedMessage>(this);
}
}
نتیجهگیری
معماری MVVM در ترکیب با .NET MAUI و ابزار قدرتمند CommunityToolkit.Mvvm واقعاً تجربه توسعه فوقالعادهای ارائه میده. با Source Generators، تزریق وابستگی و ناوبری Shell میتونید اپلیکیشنهای چندپلتفرمی حرفهای بسازید که هم قابل نگهداری باشن و هم تستپذیر.
خلاصه نکات کلیدی که توی این مقاله بررسی کردیم:
- MVVM جداسازی واضحی بین UI و منطق کسبوکار ایجاد میکنه
- CommunityToolkit.Mvvm با Source Generators کدنویسی رو خیلی سادهتر کرده
- نسخه 8.4+ از partial properties پشتیبانی میکنه که AOT-safe هستن
- تزریق وابستگی توی .NET MAUI مشابه ASP.NET Core عمل میکنه
- Shell Navigation ناوبری مبتنی بر URI با پارامتر رو فراهم میکنه
- CollectionView جایگزین ListView شده و عملکرد بهتری داره
- WeakReferenceMessenger برای ارتباط loosely-coupled بین بخشهای مختلف اپلیکیشن استفاده میشه
- تست واحد ViewModel ها سادهست و باید بخشی از روند توسعه باشه
با رعایت بهترین شیوهها و اجتناب از اشتباهات رایج، میتونید اپلیکیشنهای باکیفیت بسازید. یادتون باشه معماری خوب شاید اول وقتگیر به نظر برسه، ولی توی بلندمدت کلی از وقت و هزینه نگهداریتون صرفهجویی میکنه.
برای ادامه مسیر یادگیری، پیشنهاد میکنم:
- پروژههای نمونه بیشتری رو مطالعه کنید
- الگوهای پیشرفتهتر مثل Repository Pattern، Unit of Work و Clean Architecture رو بررسی کنید
- با Reactive Extensions (Rx) آشنا بشید
- توی پروژههای واقعی تجربه کسب کنید
- مستندات رسمی Microsoft و CommunityToolkit رو دنبال کنید
موفق باشید!