راستش را بخواهید، هیچ تجربهای دردناکتر از این نیست که اپلیکیشن .NET MAUI شما در شبیهساز عالی کار کند ولی روی گوشی واقعی کاربر، شخصیت دیگری به خود بگیرد. زمان راهاندازی ناگهان به ۴ ثانیه میرسد، اسکرول CollectionView با ۲۰۰ آیتم لرزش پیدا میکند و مصرف حافظه با هر بار باز و بسته کردن صفحه، چند مگابایت بالاتر میخزد. اعدادی که قبلا «خوب» به نظر میرسیدند، حالا معنای دیگری دارند.
این مقاله یک راهنمای عملی و بهروز برای .NET MAUI 9 در سال ۲۰۲۶ است که سه گلوگاه اصلی عملکرد — یعنی زمان راهاندازی، عملکرد لیستها و مدیریت حافظه — را با کد قابل کپی پوشش میدهد. هیچ بحث تئوری اضافهای هم در کار نیست؛ فقط چیزهایی که خودم در پروژههای واقعی پیاده کردهام و نتیجه گرفتهام.
این محتوا ادامهی منطقی سری معماری ماست. بعد از MVVM، SQLite، REST API و Offline-First، حالا نوبت آن است که اپلیکیشن را برای کاربر نهایی روان کنیم.
اول اندازهگیری، بعد بهینهسازی
قانون طلایی بهینهسازی این است: بدون اندازهگیری، هر تغییری حدس و گمان است. (و حدس و گمان معمولا به جایی میرسد که کد را پیچیدهتر میکند بدون اینکه واقعا چیزی سریعتر شود.) خوشبختانه در .NET MAUI چند ابزار قابل اتکا داریم:
- dotnet-trace برای پروفایل CPU و رویدادهای رانتایم
- dotnet-counters برای متریکهای زنده مثل GC و حافظه
- Visual Studio Diagnostic Tools برای تحلیل allocation و CPU sampling
- Android Studio Profiler برای متریکهای پایینسطح اندروید (CPU، Memory، Energy)
- Xcode Instruments برای پروفایل iOS با Time Profiler و Allocations
برای پروفایل سریع روی اندروید، این دستور را پس از اتصال دستگاه اجرا کنید:
dotnet-trace collect --process-id $(adb shell pidof com.yourcompany.yourapp) \
--providers Microsoft-Windows-DotNETRuntime:0x1F000080018:5
نتیجه یک فایل .nettrace است که در PerfView یا Visual Studio باز میشود و دقیقا نشان میدهد زمان CPU کجا صرف شده. این چیزی است که نباید قبل از بهینهسازی از آن صرف نظر کنید.
بخش اول: کاهش زمان راهاندازی
کاربر اندرویدی متوسط اگر اپ ظرف ۲ ثانیه باز نشود، احساس کندی میکند. روی iOS این حد سختگیرانهتر است. هدف ما رساندن «cold start» به زیر ۲ ثانیه روی دستگاههای متوسط ۲۰۲۲ به بعد است — یعنی همان دستگاهی که اکثر کاربران واقعی شما در دست دارند، نه پرچمدار آخرین مدل.
۱. فعالسازی AOT روی iOS و NativeAOT برای کتابخانهها
iOS به طور پیشفرض از Full AOT استفاده میکند، ولی اطمینان حاصل کنید که در پیکربندی Release این تنظیمات فعال است:
<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)|$(Platform)' == 'Release|net9.0-ios|AnyCPU'">
<MtouchLink>SdkOnly</MtouchLink>
<CodesignKey>Apple Distribution</CodesignKey>
<UseInterpreter>false</UseInterpreter>
<MtouchUseLlvm>true</MtouchUseLlvm>
</PropertyGroup>
گزینهی MtouchUseLlvm حدود ۱۰ تا ۱۵ درصد سرعت اجرای کد را افزایش میدهد، ولی در عوض زمان build طولانیتر میشود. اگر تیم شما CI سریع نمیخواهد، این یک معاملهی منصفانه است.
۲. AOT روی اندروید با StartupTracing
در .NET MAUI 9، دو سوییچ کلیدی برای کاهش زمان راهاندازی اندروید وجود دارد:
<PropertyGroup Condition="'$(Configuration)' == 'Release' and $(TargetFramework.Contains('-android'))">
<RunAOTCompilation>true</RunAOTCompilation>
<AndroidStripILAfterAOT>true</AndroidStripILAfterAOT>
<AndroidEnableProfiledAot>true</AndroidEnableProfiledAot>
<EnableLLVM>true</EnableLLVM>
</PropertyGroup>
AndroidEnableProfiledAot تنها مسیرهای پرکاربرد JIT را پیشکامپایل میکند — چیزی که اندازه APK را در کنترل نگه میدارد. AndroidStripILAfterAOT هم پس از AOT کد IL را حذف میکند و چند مگابایت از حجم میکاهد. در یکی از پروژههایم همین دو سوییچ، حدود ۸۰۰ میلیثانیه از cold start کم کرد.
۳. Trimming هوشمندانه
Trimming کد بلااستفاده را حذف میکند، ولی اگر بدون آگاهی فعال شود، در زمان اجرا با MissingMethodException روبهرو میشوید. (آن هم فقط در Release، که قشنگترین بخش ماجراست.) تنظیم درست برای Release چنین است:
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>full</TrimMode>
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
</PropertyGroup>
هر هشدار IL2026 یا IL2104 در زمان build را جدی بگیرید؛ اینها معمولا به کدی اشاره دارند که با Reflection کار میکند — مثل JsonSerializer روی نوعهای ناشناخته یا DI با ActivatorUtilities.
برای JSON، به جای reflection از Source Generator استفاده کنید:
[JsonSerializable(typeof(Product))]
[JsonSerializable(typeof(List<Product>))]
internal partial class AppJsonContext : JsonSerializerContext { }
// استفاده
var json = JsonSerializer.Serialize(product, AppJsonContext.Default.Product);
var products = JsonSerializer.Deserialize(stream, AppJsonContext.Default.ListProduct);
این یک حرکت دو در یک است: هم trimming-friendly است و هم سریعتر از reflection اجرا میشود. اگر هنوز روی JsonSerializerOptions پیشفرض هستید، صادقانه میگویم: وقت تغییر است.
۴. حذف هندلرهای استفادهنشده
به طور پیشفرض، .NET MAUI تمام هندلرهای کنترلها را در MauiProgram ثبت میکند. اگر در اپ شما مثلا WebView استفاده نمیشود، خب چرا نگهاش دارید؟
builder.ConfigureMauiHandlers(handlers =>
{
handlers.RemoveHandler<WebView>();
handlers.RemoveHandler<TimePicker>();
});
این کار چند ده میلیثانیه از زمان آمادهسازی container DI میکاهد و تاثیرش روی اپهای ساده محسوس است. کوچک ولی رایگان.
۵. به تعویق انداختن کارهای غیرضروری
هر چه در سازندهی App یا AppShell اجرا شود، در مسیر بحرانی startup قرار دارد. این کارها را به OnAppearing یا یک تسک پسزمینه منتقل کنید:
public partial class App : Application
{
public App(IServiceProvider services)
{
InitializeComponent();
MainPage = new AppShell();
// پس از نمایش UI، در پسزمینه اجرا کن
Dispatcher.DispatchAsync(async () =>
{
await Task.Yield();
await services.GetRequiredService<AnalyticsService>().InitializeAsync();
await services.GetRequiredService<CrashReportingService>().InitializeAsync();
await services.GetRequiredService<SyncService>().StartAsync();
});
}
}
قانون شخصی من: اگر چیزی برای اولین فریم رندر لازم نیست، نباید در سازنده باشد. همین.
بخش دوم: عملکرد CollectionView
پربازدیدترین گلوگاه عملکرد در اپهای .NET MAUI، همین CollectionView است. اسکرول لرزان، تاخیر در اولین رندر و فریز کردن هنگام بهروزرسانی — همگی ریشههای مشخصی دارند.
۱. ItemsUpdatingScrollMode را درست تنظیم کنید
اگر دادهها به صورت زنده اضافه میشوند (مثلا چت یا feed)، رفتار پیشفرض میتواند موقعیت اسکرول را به هم بزند:
<CollectionView ItemsSource="{Binding Messages}"
ItemsUpdatingScrollMode="KeepScrollOffset"
RemainingItemsThreshold="5"
RemainingItemsThresholdReachedCommand="{Binding LoadMoreCommand}">
KeepScrollOffset زمانی که آیتم جدید در ابتدا یا انتهای لیست اضافه میشود، اسکرول را در محل فعلی کاربر نگه میدارد. تجربهی بسیار طبیعیتری ایجاد میکند — درست مثل اپهای پیامرسان مدرن.
۲. ItemTemplate را ساده و سبک نگه دارید
هر DataTemplate پیچیده برای هر آیتم لیست رندر میشود. این الگوی غلط را تشخیص دهید:
<!-- ضدالگو: Grid تو در تو با StackLayout -->
<DataTemplate x:DataType="model:Product">
<StackLayout Padding="16">
<StackLayout Orientation="Horizontal">
<Image Source="{Binding ImageUrl}" />
<StackLayout>
<Label Text="{Binding Name}" />
<Label Text="{Binding Price}" />
</StackLayout>
</StackLayout>
</StackLayout>
</DataTemplate>
به جای آن از یک Grid تخت استفاده کنید:
<DataTemplate x:DataType="model:Product">
<Grid Padding="16" ColumnDefinitions="64,*,Auto" ColumnSpacing="12"
RowDefinitions="Auto,Auto">
<Image Grid.RowSpan="2" Source="{Binding ImageUrl}"
WidthRequest="64" HeightRequest="64" Aspect="AspectFill" />
<Label Grid.Column="1" Grid.Row="0"
Text="{Binding Name}" FontAttributes="Bold" />
<Label Grid.Column="1" Grid.Row="1"
Text="{Binding Description}" MaxLines="2" LineBreakMode="TailTruncation" />
<Label Grid.Column="2" Grid.RowSpan="2"
Text="{Binding Price, StringFormat='{0:C}'}" VerticalOptions="Center" />
</Grid>
</DataTemplate>
یک Grid تخت در مقایسه با StackLayoutهای تو در تو، measure/arrange سریعتری دارد. در بنچمارکهای ما روی Pixel 6، حدود ۳۰ تا ۴۰ درصد سرعت اسکرول بهبود پیدا کرد. اگر هنوز فکر میکنید فرق چندانی نمیکند، یکبار خودتان امتحان کنید.
۳. Compiled Bindings (x:DataType) را فراموش نکنید
بدون x:DataType، MAUI هر مسیر binding را در زمان اجرا با reflection حل میکند. با ست کردن آن، binding در زمان build کامپایل میشود و سرعت چند برابر میشود. در نمونهی بالا توجه کنید که x:DataType="model:Product" ست شده.
برای پروژههای جدید این تنظیم را در فایل csproj اضافه کنید تا کامپایلر هشدار بدهد اگر فراموش کردید:
<PropertyGroup>
<MauiStrictXamlCompilation>true</MauiStrictXamlCompilation>
</PropertyGroup>
۴. ObservableCollection را با ObservableRangeCollection جایگزین کنید
ObservableCollection برای هر آیتمی که اضافه میکنید یک رویداد NotifyCollectionChanged میفرستد. اگر ۱۰۰ آیتم اضافه کنید، ۱۰۰ بار UI بهروزرسانی میشود. این رفتار، خودش بهتنهایی میتواند یک اپ را زانو بزند.
// نصب: dotnet add package CommunityToolkit.Mvvm
using CommunityToolkit.Mvvm.Collections;
public partial class FeedViewModel : ObservableObject
{
public ObservableGroupedCollection<string, Post> Posts { get; } = new();
[RelayCommand]
private async Task LoadAsync()
{
var posts = await _api.GetPostsAsync();
var grouped = posts.GroupBy(p => p.Date.ToString("yyyy-MM-dd"))
.Select(g => new ObservableGroup<string, Post>(g.Key, g));
Posts.SyncWith(grouped); // یک رویداد واحد
}
}
اگر در پروژهتان از CommunityToolkit استفاده نمیکنید، نسخهی سادهی این الگو با AddRange سفارشی هم قابل پیادهسازی است.
۵. تصاویر را با اندازهی واقعی مصرف کنید
یک تصویر ۲۰۰۰ در ۲۰۰۰ پیکسلی که در یک Image با WidthRequest="64" نمایش داده میشود، حدود ۱۶ مگابایت RAM مصرف میکند. این عامل اصلی OOM در لیستهای بزرگ است — و متاسفانه یکی از رایجترین اشتباهات.
برای resize خودکار، از FFImageLoading.Maui یا قابلیت بومی .NET MAUI 9 استفاده کنید. مثال با FFImageLoading:
<ffimg:CachedImage Source="{Binding ImageUrl}"
DownsampleToViewSize="True"
CacheDuration="7.00:00:00"
FadeAnimationEnabled="False" />
FadeAnimationEnabled="False" در لیستها فریمریت بهتری میدهد، چون انیمیشن fade برای هر سل سربار GPU دارد. زیبایی فدای روانی — معاملهای که اکثر کاربران اصلا متوجهش نمیشوند.
بخش سوم: مدیریت حافظه و جلوگیری از Memory Leak
نشت حافظه در MAUI معمولا از چهار منبع میآید: event handler نگه داشتهشده، static reference، رویداد Messaging Center، و navigation stack بدون پاکسازی. در ادامه به ترتیب سراغشان میرویم.
۱. Weak Event Pattern برای پیامرسانی بین ViewModelها
به جای MessagingCenter قدیمی (که در MAUI 9 deprecated است) از WeakReferenceMessenger استفاده کنید:
public partial class CartViewModel : ObservableObject, IRecipient<ProductAddedMessage>
{
public CartViewModel(IMessenger messenger)
{
messenger.RegisterAll(this);
}
public void Receive(ProductAddedMessage message)
{
Items.Add(message.Value);
}
}
چون messenger به صورت ضعیف به receiver رفرنس دارد، حتی اگر فراموش کنید UnregisterAll را صدا بزنید، GC قادر است ViewModel را آزاد کند. ولی برای صراحت، توصیه میکنم در OnNavigatedFrom همیشه پاکسازی کنید — «شاید کار کند» جای «حتما کار میکند» را نمیگیرد.
۲. مراقب چرخهی Page-to-ViewModel باشید
اگر page به ViewModel رفرنس دارد و ViewModel هم به page رفرنس دارد (مثلا برای navigation)، یک چرخه ساخته میشود که حتی GC هم نمیتواند آن را آزاد کند. چرا؟ چون CLR چرخههای مدیریتشده را بلد است، ولی هندلرهای native در طرف دیگر هنوز زندهاند.
راهحل ساده است: ViewModel نباید هیچ رفرنسی به view نگه دارد. به جای آن از یک INavigationService abstract شده استفاده کنید:
public interface INavigationService
{
Task GoToAsync(string route, IDictionary<string, object>? parameters = null);
Task GoBackAsync();
}
public class ShellNavigationService : INavigationService
{
public Task GoToAsync(string route, IDictionary<string, object>? parameters = null) =>
parameters is null
? Shell.Current.GoToAsync(route)
: Shell.Current.GoToAsync(route, parameters);
public Task GoBackAsync() => Shell.Current.GoToAsync("..");
}
۳. در disposable به HttpClient متعهد بمانید
هرگز new HttpClient() را در ViewModel نزنید. از IHttpClientFactory استفاده کنید (به مقاله REST API ما رجوع کنید). برای resource های لوکال مثل FileStream و دیتابیس هم، هر using حساب شده است:
public async Task<byte[]> ReadFileAsync(string path)
{
await using var stream = File.OpenRead(path);
using var memory = new MemoryStream();
await stream.CopyToAsync(memory);
return memory.ToArray();
}
۴. ابزار تشخیص leak: HeapShot و dotnet-gcdump
برای گرفتن snapshot از heap اپ در حال اجرا:
dotnet-gcdump collect -p $(adb shell pidof com.yourcompany.yourapp)
فایل .gcdump در PerfView یا Visual Studio باز میشود و میتوانید ببینید چه نوعهایی فعالاند. تست سادهای که خودم همیشه انجام میدهم: اگر بعد از navigation back، تعداد instance های یک ViewModel صفر نشد، یک leak دارید. تمام.
بخش چهارم: نکات سریع برای Layout و XAML
- FlowDirection را در Root ست کنید نه در هر صفحه — برای پروژههای فارسی این تنظیم در
App.xamlیاAppShell.xamlکافی است. - VisualStateManager سبکتر از Trigger است — بهویژه برای دکمههای با حالت Pressed/Disabled.
- BindingContext را یک بار ست کنید — تغییر مکرر آن باعث reset شدن تمام bindingها و رفرش UI میشود.
- Fonts را به صورت Embedded ثبت کنید — لود فونت از فایل سیستم در زمان اجرا کند است.
- Shadow و Glow GPU heavy هستند — در لیستها از Border با CornerRadius به جای DropShadow استفاده کنید.
بخش پنجم: چکلیست عملی قبل از انتشار
- اپ را در Release با AOT و LLVM build کنید و روی دستگاه low-end تست کنید (نه شبیهساز).
- زمان cold start را با
adb shell am start -Wاندازه بگیرید. - اسکرول CollectionView را با ۵۰۰+ آیتم تست کنید و به فریمریت Profiler نگاه کنید (هدف: ۶۰ FPS).
- ده بار navigation رفت و برگشت انجام دهید و gcdump بگیرید — نباید نشتی ببینید.
- حجم نهایی APK/IPA را زیر ۵۰ مگابایت نگه دارید (با AAB روی Play Store اغلب کاربر کمتر از این دانلود میکند).
- روی شبکهی ضعیف (3G simulator) رفتار اپ را تست کنید — مهمترین تست offline-first.
پرسشهای متداول
چرا اپ MAUI من در Release کندتر از Debug است؟
این تقریبا همیشه به دلیل غیرفعال بودن AOT یا LLVM در پیکربندی Release است. در Debug از Mono interpreter استفاده میشود که زمان build سریعتری دارد ولی اجرای کندتری. تنظیمات بخش «AOT روی اندروید» را اعمال کنید و دوباره بنچمارک بگیرید.
تفاوت CollectionView و ListView در عملکرد چیست؟
ListView از معماری قدیمی Xamarin.Forms به ارث رسیده و در .NET MAUI فقط برای سازگاری حفظ شده است. CollectionView از CollectionView بومی iOS و RecyclerView اندروید استفاده میکند، virtualization بهتری دارد و حافظهی به مراتب کمتری مصرف میکند. در پروژههای جدید همیشه از CollectionView استفاده کنید — هیچ استثنایی هم ندارد.
آیا فعال کردن Trimming خطرناک است؟
نه، تا زمانی که هشدارهای IL را جدی بگیرید. Trimming کد بلااستفاده را در زمان build حذف میکند، اما اگر کدی از reflection برای یافتن نوعها استفاده کند (مثل JSON deserialization)، trimmer ممکن است آن نوع را اشتباها حذف کند. راهحل: استفاده از Source Generator برای JSON و ست کردن DynamicallyAccessedMembersAttribute روی متدهایی که با reflection کار میکنند.
چطور بفهمم memory leak دارم؟
سادهترین تست: ۱۰ بار به یک صفحه navigate کنید و برگردید. سپس با dotnet-gcdump یک snapshot بگیرید. در PerfView، تعداد instance های ViewModel آن صفحه را بشمارید. اگر بیشتر از یک عدد بود (در حالی که فقط یکی روی stack است)، leak دارید. منبع معمول: event handler ستشده روی static event، یا messenger registration بدون unregister.
کدام پروفایلر برای .NET MAUI بهترین است؟
برای CPU profiling در سطح کد .NET، dotnet-trace + PerfView بهترین گزینه است (هم روی اندروید و هم iOS کار میکند). برای متریکهای native (مصرف باتری، رندرینگ GPU، شبکه) از پروفایلر اختصاصی هر پلتفرم استفاده کنید: Android Studio Profiler یا Xcode Instruments. ترکیب هر دو بهترین دید را میدهد.
جمعبندی
عملکرد یک قابلیت نیست، بلکه یک عادت روزانه است. سه چیز را همیشه قبل از merge هر فیچر بررسی کنید: زمان startup تغییری نکرده باشد، اسکرول لیستها به ۶۰ FPS نزدیک باشد و navigation رفت و برگشت leak تولید نکند. با چکلیست این مقاله و ابزارهای پروفایل، اپ MAUI شما میتواند رقیب اپهای native در تجربهی کاربری باشد — و باور کنید، این هدف دور از دسترسی نیست.
در مقالهی بعدی این سری به سراغ Testing Strategy در .NET MAUI میرویم: unit test برای ViewModelها، UI test با Appium و integration test با emulatorهای CI/CD.