مقالات › موبایل و دسکتاپ
آموزش WPF و الگوی MVVM؛ ساخت برنامههای دسکتاپ قابل نگهداری با سیشارپ
آموزش الگوی MVVM در WPF: Data Binding، Commandها، CommunityToolkit.Mvvm، تزریق وابستگی، ناوبری و ViewModelهای قابل تست در برنامههای دسکتاپ C#.
WPF هنوز یکی از پربازدهترین راههای ساخت برنامههای دسکتاپ ویندوز است: چیدمانهای غنی، Data Binding قدرتمند و یک اکوسیستم بالغ. بسیاری از ابزارهای سازمانی — حسابداری، انبارداری، فروشگاهی و پنلهای کنترل سختافزار — برنامههای WPF هستند. کلید قابل نگهداری ماندن آنها الگوی MVVM (Model–View–ViewModel) است. این راهنما الگو را توضیح میدهد و با ابزارهای مدرن یک برنامهی کوچک میسازد.
چرا MVVM؟
بدون الگو، منطق در Code-Behind (MainWindow.xaml.cs) انباشته میشود: رویداد کلیک دکمه که TextBoxها را میخواند، دیتابیس را صدا میزند و Labelها را بهروز میکند. اول کار میکند، بعد تست یا تغییرش غیرممکن میشود.
MVVM مسئولیتها را جدا میکند:
| لایه | مسئولیت | از چه چیزی خبر دارد |
|---|---|---|
| Model | داده و قوانین کسبوکار (موجودیتها، سرویسها، Repositoryها) | هیچ چیز مرتبط با UI |
| ViewModel | وضعیت و رفتار یک صفحه: Propertyها، Commandها، اعتبارسنجی | Model، نه View |
| View | چیدمان و استایل XAML | به ViewModel متصل (Bind) میشود |
چسب بین اینها Data Binding است: View پراپرتیهای ViewModel را مشاهده و Commandهای آن را اجرا میکند. ViewModel هرگز به یک TextBox دست نمیزند؛ پس مثل هر کلاس دیگری قابل Unit Test است.
مبانی Data Binding
<TextBox Text="{Binding CustomerName, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="{Binding Greeting}" />
<Button Content="ذخیره" Command="{Binding SaveCommand}" />
DataContext پنجره (یا کنترل) تعیین میکند Bindingها از کدام شیء بخوانند. حالتهای Binding:
OneWay— از ViewModel به View (پیشفرض بیشتر پراپرتیها).TwoWay— دوطرفه (پیشفرضTextBox.TextوCheckBox.IsChecked).OneTime— فقط یک بار خوانده میشود.
پیادهسازی دستی INotifyPropertyChanged
برای اینکه View با تغییر پراپرتی بهروز شود، ViewModel رویداد PropertyChanged را اعلام میکند:
public class CustomerViewModel : INotifyPropertyChanged
{
private string _name = "";
public string Name
{
get => _name;
set
{
if (_name == value) return;
_name = value;
OnPropertyChanged();
OnPropertyChanged(nameof(Greeting));
}
}
public string Greeting => $"سلام، {Name}!";
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string? name = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
این حجم کد تکراری برای هر پراپرتی زیاد است؛ به همین دلیل تقریباً همه از یک کتابخانهی کمکی استفاده میکنند.
CommunityToolkit.Mvvm: MVVM مدرن با Source Generator
dotnet new wpf -n InventoryApp
dotnet add package CommunityToolkit.Mvvm
این Toolkit کدهای تکراری را هنگام کامپایل تولید میکند:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
public partial class ProductsViewModel(IProductService products) : ObservableObject
{
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(AddCommand))]
private string _newProductName = "";
[ObservableProperty]
private bool _isLoading;
[ObservableProperty]
private ProductDto? _selectedProduct;
public ObservableCollection<ProductDto> Products { get; } = [];
[RelayCommand]
private async Task LoadAsync()
{
IsLoading = true;
try
{
Products.Clear();
foreach (var p in await products.GetAllAsync())
Products.Add(p);
}
finally { IsLoading = false; }
}
[RelayCommand(CanExecute = nameof(CanAdd))]
private async Task AddAsync()
{
var created = await products.CreateAsync(NewProductName.Trim());
Products.Add(created);
NewProductName = "";
}
private bool CanAdd() => !string.IsNullOrWhiteSpace(NewProductName);
partial void OnSelectedProductChanged(ProductDto? value)
{
// واکنش به تغییر انتخاب؛ مثلاً بارگذاری جزئیات
}
}
چه چیزی تولید میشود:
[ObservableProperty]روی_newProductName← یک پراپرتی عمومیNewProductNameکهPropertyChangedرا اعلام میکند.[RelayCommand]رویAddAsync← یکAddCommandاز نوعIAsyncRelayCommandباIsRunningداخلی و جلوگیری از اجرای همزمان دوباره.NotifyCanExecuteChangedFor← دکمهی افزودن با تایپ کاربر خودکار فعال/غیرفعال میشود.partial void On...Changed← نقطهی اتصال برای واکنش به تغییر پراپرتی.
View
<Window x:Class="InventoryApp.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="انبار" Height="500" Width="800" FlowDirection="RightToLeft">
<DockPanel Margin="16">
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="0,0,0,12">
<TextBox Width="250" Text="{Binding NewProductName, UpdateSourceTrigger=PropertyChanged}" />
<Button Content="افزودن" Margin="8,0,0,0" Command="{Binding AddCommand}" />
<Button Content="بازخوانی" Margin="8,0,0,0" Command="{Binding LoadCommand}" />
</StackPanel>
<ProgressBar DockPanel.Dock="Top" Height="4" IsIndeterminate="True"
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVisibility}}" />
<ListView ItemsSource="{Binding Products}" SelectedItem="{Binding SelectedProduct}">
<ListView.View>
<GridView>
<GridViewColumn Header="نام" DisplayMemberBinding="{Binding Name}" Width="300" />
<GridViewColumn Header="موجودی" DisplayMemberBinding="{Binding Stock}" Width="100" />
</GridView>
</ListView.View>
</ListView>
</DockPanel>
</Window>
ObservableCollection<T> هنگام افزودن یا حذف آیتم به ListView اطلاع میدهد و لیست خودکار بهروز میشود. برای رابط فارسی هم FlowDirection="RightToLeft" کل پنجره را راستچین میکند.
نکته: خروجی خطاهای Binding را فعال کنید (پنجرهی Output در Visual Studio یا پنل XAML Binding Failures). یک غلط تایپی در مسیر Binding در زمان اجرا بیصدا شکست میخورد و این شمارهی یک باگهای «هیچ اتفاقی نمیافتد» در WPF است.
تزریق وابستگی با Generic Host
از همان کانتینر DI در ASP.NET Core استفاده کنید:
public partial class App : Application
{
public static IHost Host { get; } = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder()
.ConfigureServices(services =>
{
services.AddDbContext<AppDbContext>(o => o.UseSqlite("Data Source=inventory.db"));
services.AddScoped<IProductService, ProductService>();
services.AddTransient<ProductsViewModel>();
services.AddSingleton<MainWindow>();
})
.Build();
protected override async void OnStartup(StartupEventArgs e)
{
await Host.StartAsync();
var window = Host.Services.GetRequiredService<MainWindow>();
window.Show();
base.OnStartup(e);
}
protected override async void OnExit(ExitEventArgs e)
{
await Host.StopAsync();
Host.Dispose();
base.OnExit(e);
}
}
public partial class MainWindow : Window
{
public MainWindow(ProductsViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
Loaded += async (_, _) => await viewModel.LoadCommand.ExecuteAsync(null);
}
}
StartupUri را از App.xaml حذف کنید، چون پنجره توسط DI ساخته میشود. حالا Code-Behind خیلی کوچک است و فقط اتصالها را انجام میدهد.
ناوبری بین صفحات
یک الگوی رایج: یک ViewModel پوسته (Shell) ViewModel صفحهی فعلی را نگه میدارد و DataTemplateها هر نوع ViewModel را به یک View نگاشت میکنند:
<Window.Resources>
<DataTemplate DataType="{x:Type vm:ProductsViewModel}"><views:ProductsView /></DataTemplate>
<DataTemplate DataType="{x:Type vm:SettingsViewModel}"><views:SettingsView /></DataTemplate>
</Window.Resources>
<ContentControl Content="{Binding CurrentPage}" />
public partial class ShellViewModel(IServiceProvider services) : ObservableObject
{
[ObservableProperty] private ObservableObject? _currentPage;
[RelayCommand] private void GoToProducts() => CurrentPage = services.GetRequiredService<ProductsViewModel>();
[RelayCommand] private void GoToSettings() => CurrentPage = services.GetRequiredService<SettingsViewModel>();
}
برای ارتباط بین ViewModelها بدون ارجاع مستقیم از WeakReferenceMessenger همین Toolkit استفاده کنید.
اعتبارسنجی
از ObservableValidator ارثبری و از Data Annotationها استفاده کنید:
public partial class CustomerFormViewModel : ObservableValidator
{
[ObservableProperty]
[NotifyDataErrorInfo]
[Required(ErrorMessage = "نام الزامی است.")]
[MinLength(3)]
private string _name = "";
}
WPF رابط INotifyDataErrorInfo را میخواند و حاشیهی قرمز نشان میدهد؛ با Validation.ErrorTemplate آن را سفارشی کنید.
پاسخگو نگه داشتن رابط کاربری
- عملیات I/O را ناهمگام کنید (
awaitروی دیتابیس و HTTP)؛ هرگز ترد UI را با.Resultیا.Wait()مسدود نکنید. - کارهای سنگین پردازنده را با
Task.Runاجرا کنید و بعد ازawaitپراپرتیها را بهروز کنید؛ خودکار به ترد UI برمیگردید. - برای لیستهای خیلی بزرگ،
VirtualizingStackPanelرا فعال نگه دارید (پیشفرضListViewوDataGrid) و آنها را داخلScrollViewerقرار ندهید.
تست ViewModelها
چون ViewModelها به کنترلهای WPF وابسته نیستند، تست ساده است:
[Fact]
public async Task Add_appends_product_and_clears_input()
{
var service = new Mock<IProductService>();
service.Setup(s => s.CreateAsync("Keyboard")).ReturnsAsync(new ProductDto(1, "Keyboard", 0));
var vm = new ProductsViewModel(service.Object) { NewProductName = "Keyboard" };
await vm.AddCommand.ExecuteAsync(null);
Assert.Single(vm.Products);
Assert.Equal("", vm.NewProductName);
}
جمعبندی
MVVM با انتقال وضعیت و رفتار به ViewModelهایی که View XAML به آنها Bind میشود، برنامههای WPF را قابل نگهداری نگه میدارد. CommunityToolkit.Mvvm با [ObservableProperty] و [RelayCommand] کد تکراری را حذف میکند، Generic Host تزریق وابستگی میآورد، DataTemplateها ناوبری را مدیریت میکنند و Commandهای ناهمگام رابط کاربری را پاسخگو نگه میدارند. نتیجه یک برنامهی دسکتاپ است که منطقش به اندازهی هر سرویس بکاند قابل تست است.
پرسشهای متداول
آیا یادگیری WPF هنوز ارزش دارد؟
بله، برای نرمافزار دسکتاپ ویندوز. در .NET مدرن فعالانه نگهداری میشود و در برنامههای سازمانی، صنعتی و فروشگاهی بسیار استفاده میشود. برای دسکتاپ چندسکویی به Avalonia (بسیار شبیه WPF) یا .NET MAUI نگاه کنید.
آیا Code-Behind در MVVM ممنوع است؟
خیر. مسائل صرفاً مربوط به View (انیمیشن، فوکوس، رفتار پنجره) جایشان در Code-Behind یا Behaviorهاست. منطق کسبوکار و وضعیت نه.
آیا میتوان MVVM را در WinForms به کار برد؟
تا حدی؛ WinForms هم Data Binding دارد و CommunityToolkit.Mvvm آنجا هم کار میکند، اما سیستم Binding در WPF بسیار قدرتمندتر است و به همین دلیل MVVM در WPF، Avalonia و MAUI طبیعیتر است.