อัปเดต: 23 มิถุนายน 2026
การปรับแต่งประสิทธิภาพ .NET MAUI ในปี 2026 คือกระบวนการลดเวลาเปิดแอป (startup time) ลดการใช้หน่วยความจำ และทำให้ UI ตอบสนองได้ลื่นไหลผ่านการเปิดใช้ Native AOT บน iOS, การจัดการ CollectionView ให้ virtualize ถูกต้อง, การป้องกัน memory leak จาก event handler และการใช้ CompiledBinding แทน reflection-based binding ในทุก View บทความนี้สรุปเทคนิคที่ทดสอบกับ .NET 9 MAUI และโปรเจกต์โปรดักชันจริง พร้อมโค้ดที่นำไปใช้ได้ทันที
- เปิด Native AOT บน iOS และ Full-AOT ช่วยลด cold start ได้ 30–50% และลดขนาดไบนารีลงประมาณ 20%
- ใช้
CompiledBinding (x:DataType) ทุก View เพื่อให้ binding เร็วกว่า reflection-based binding ถึง 8–20 เท่า
- ปัญหา scroll กระตุกใน
CollectionView ส่วนใหญ่มาจาก template ที่ซับซ้อนเกินไป ไม่ได้ใช้ ItemsUpdatingScrollMode หรือไม่ได้ recycle
- Memory leak ที่พบบ่อยที่สุดมาจากการ subscribe event โดยไม่ unsubscribe และจาก
Messaging ที่ผูกกับ Page ที่ถูกทำลายแล้ว
- ใช้
MauiImage จาก resizetizer และ CachedImage เพื่อลดเวลา decode รูปและประหยัด RAM
- เปิด R2R (ReadyToRun) บน Android และเลือก SDK Style ที่ถูกต้องเพื่อลด startup time ฝั่ง Java side
ทำไมแอป .NET MAUI ถึงเปิดช้าและกระตุก
สาเหตุที่แอป .NET MAUI ดูช้ากว่าที่ควรจะเป็น มักไม่ใช่ตัว framework เอง แต่เกิดจากการตั้งค่า build configuration ที่ไม่เหมาะกับ Release และโค้ดในเลเยอร์ ViewModel/View ที่ทำงานหนักบน main thread ตอนเปิดแอป จากประสบการณ์การ debug โปรดักชันหลายแอป ปัญหา 90% มาจากสี่หมวด: (1) startup ที่ยังใช้ JIT แทน AOT, (2) data binding ที่ใช้ reflection ทุก property, (3) CollectionView template ที่ซ้อน Grid/StackLayout หลายชั้น และ (4) memory leak จาก event handler ที่ลืม detach
ใน .NET 9 ทีม MAUI ปรับ layout engine และ Handler architecture ให้เร็วขึ้นและรองรับ trimming ดีขึ้นมาก ดังนั้นถ้ายังใช้ .NET 7 หรือ .NET 8 อยู่ การอัปเกรดเป็น .NET 9 คือขั้นแรกที่ให้ผลตอบแทนสูงสุดต่อแรงที่ลงไป
ผมเคยเจอบั๊กตอนไล่จับ startup lag ของแอปตัวหนึ่ง สุดท้ายพบว่าปัญหาอยู่ที่ service ตัวเดียวใน DI ถ้าไม่มีเครื่องมือที่ใช่ คงจะหาไม่เจออีกหลายชั่วโมง เครื่องมือหลักที่ผมใช้ในงานทุกวันคือ dotnet-trace, PerfView สำหรับ Windows, Xcode Instruments สำหรับ iOS และ Android Studio Profiler สำหรับ Android สำหรับการวัด startup time ที่ตรงไปตรงมา ให้ใช้ logging ฝัง stopwatch ใน MauiProgram เพื่อดูว่าเวลาส่วนใหญ่หายไปกับอะไร
public static MauiApp CreateMauiApp()
{
var sw = Stopwatch.StartNew();
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
});
builder.Services.AddSingleton<IDataService, DataService>();
builder.Services.AddTransient<MainViewModel>();
builder.Services.AddTransient<MainPage>();
var app = builder.Build();
Debug.WriteLine($"[Startup] MauiApp built in {sw.ElapsedMilliseconds} ms");
return app;
}
ถ้าตัวเลขเกิน 500 ms บนเครื่องเก่า แสดงว่าน่าจะมี service ใน DI ที่หนักเกินไป (เช่น initialize HttpClient พร้อม certificate, เปิด SQLite พร้อม migration) ให้ย้ายงานเหล่านั้นไป background หลัง OnAppearing ของหน้าแรกแล้วใช้ Lazy<T> ใน DI registration
ลด Startup Time ด้วย Native AOT และ R2R
วิธีที่ให้ผลแรงที่สุดในการลด cold start คือการเปิด Ahead-of-Time compilation ใน Release build ฝั่ง iOS ใช้ Full AOT (ค่าเริ่มต้นอยู่แล้ว) ส่วนฝั่ง Android ให้เปิด RunAOTCompilation และ PublishTrimmed เพื่อให้โค้ดถูก compile เป็น native ล่วงหน้า ไม่ต้องรอ JIT ตอน runtime สำหรับ Windows ใช้ ReadyToRun (R2R) ผ่าน PublishReadyToRun
<PropertyGroup Condition="'$(Configuration)' == 'Release' and $(TargetFramework.Contains('-android'))">
<RunAOTCompilation>true</RunAOTCompilation>
<AndroidEnableProfiledAot>true</AndroidEnableProfiledAot>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>full</TrimMode>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Release' and $(TargetFramework.Contains('-ios'))">
<MtouchLink>SdkOnly</MtouchLink>
<UseInterpreter>false</UseInterpreter>
<EnableLLVM>true</EnableLLVM>
</PropertyGroup>
Profiled AOT คืออะไรและเปิดเมื่อไหร่
Profiled AOT บน Android ใช้ profile data จาก Microsoft เพื่อรู้ว่า method ไหนถูกเรียกตอน startup จริง แล้ว AOT เฉพาะส่วนนั้น ช่วยลดขนาด APK และเวลา cold start ได้ดีกว่า full AOT ในหลายกรณี เปิดด้วย <AndroidEnableProfiledAot>true</AndroidEnableProfiledAot> ใน Release
ใช้ Compiled Binding แทน Reflection-Based
Compiled Binding ทำงานเร็วกว่า binding แบบเดิม 8–20 เท่า เพราะ XAML compiler จะ generate code สำหรับการอ่าน/เขียน property แทนที่จะใช้ reflection ตอน runtime วิธีเปิดง่ายมาก เพียงเพิ่ม x:DataType บนทุก root element ของ View หรือ DataTemplate
<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:DataType="vm:ProductListViewModel"
x:Class="MyApp.Views.ProductListPage">
<CollectionView ItemsSource="{Binding Products}"
ItemsUpdatingScrollMode="KeepItemsInView">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="vm:ProductItemViewModel">
<Grid Padding="12" ColumnDefinitions="Auto,*,Auto">
<Image Source="{Binding ThumbnailUrl}"
HeightRequest="48" WidthRequest="48" />
<Label Grid.Column="1"
Text="{Binding Name}"
VerticalOptions="Center" />
<Label Grid.Column="2"
Text="{Binding Price, StringFormat='฿{0:N0}'}"
FontAttributes="Bold" />
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</ContentPage>
ตั้งค่าใน csproj ให้บังคับ compiled binding ทั่วโปรเจกต์ และให้ XAML compiler error ถ้ามี View ไหนลืมประกาศ x:DataType
<PropertyGroup>
<MauiXamlCBindingWithSourceCompilation>true</MauiXamlCBindingWithSourceCompilation>
</PropertyGroup>
CollectionView เป็นจุดที่เจอปัญหา performance บ่อยที่สุด ปกติแล้วการ scroll ที่กระตุกมาจากสามสาเหตุหลัก: template ที่ซับซ้อนเกินไปจน inflate ช้า, การโหลดรูปแบบ synchronous, และ ObservableCollection ที่ raise event ทุก item
1. ทำให้ ItemTemplate แบนที่สุด
หลีกเลี่ยงการซ้อน StackLayout หลายชั้น ใช้ Grid ที่กำหนด ColumnDefinitions/RowDefinitions ชัดเจน เพราะ measure pass ของ Grid เร็วกว่ามาก ลด Border/Frame ที่ไม่จำเป็น และ inline สีพื้นหลังแทนการใช้ BoxView ครอบ
2. ใช้ ItemsUpdatingScrollMode และ ItemSizingStrategy ที่ถูกต้อง
<CollectionView
ItemsSource="{Binding Items}"
ItemsUpdatingScrollMode="KeepItemsInView"
ItemSizingStrategy="MeasureFirstItem"
RemainingItemsThreshold="6"
RemainingItemsThresholdReachedCommand="{Binding LoadMoreCommand}" />
MeasureFirstItem บอก MAUI ว่าทุก item มีขนาดเท่ากัน framework จะคำนวณครั้งเดียวแล้ว reuse ซึ่งเร็วกว่า MeasureAllItems มากเมื่อ list ยาว ใช้ RemainingItemsThreshold เพื่อ implement infinite scroll แบบประหยัด memory
3. Batch update แทน Add ทีละ item
การ Add() เข้า ObservableCollection ทีละตัวจะ raise CollectionChanged ทุกครั้ง ทำให้ UI re-layout หลายรอบ ใช้ collection ที่รองรับ batch update เช่น ObservableRangeCollection จาก CommunityToolkit หรือ replace reference ทั้งก้อน
// ❌ ช้า: raise event 100 ครั้ง
foreach (var item in newItems)
Items.Add(item);
// ✅ เร็ว: raise event ครั้งเดียว
Items = new ObservableCollection<Product>(newItems);
// ✅ หรือใช้ ObservableRangeCollection
items.AddRange(newItems);
สำหรับการสร้างหน้า list ที่ผูกกับฐานข้อมูลในเครื่อง อ่านต่อใน คู่มือ .NET MAUI SQLite Offline-First ที่อธิบายการ query แบบ paged แทนการดึงทั้ง table
วิธีหาและแก้ Memory Leak ใน .NET MAUI
Memory leak ใน MAUI ตรวจจับยากเพราะอาการคือ "ใช้ไปเรื่อย ๆ แล้วช้าลง" ไม่ใช่ crash ทันที ใช้ dotnet-gcdump หรือ Xcode Instruments → Allocations เพื่อจับ object ที่ไม่ถูก GC หลัง Navigation.PopAsync()
สาเหตุที่พบบ่อย 4 อันดับ
- Event handler ที่ไม่ unsubscribe: Page subscribe event ของ singleton service แล้วลืม unsubscribe ตอน
OnDisappearing
- Static event: เช่น
Application.Current.RequestedThemeChanged ถ้า subscribe จาก Page จะถือ reference ไว้
- Messaging:
WeakReferenceMessenger จาก CommunityToolkit ปลอดภัยกว่า StrongReferenceMessenger
- Timer/Task ที่ยังทำงาน:
Dispatcher.StartTimer ที่ return true อยู่จะถือ closure ไว้
public partial class ProductPage : ContentPage
{
private readonly IConnectivity _connectivity;
public ProductPage(IConnectivity connectivity)
{
InitializeComponent();
_connectivity = connectivity;
}
protected override void OnAppearing()
{
base.OnAppearing();
_connectivity.ConnectivityChanged += OnConnectivityChanged;
}
protected override void OnDisappearing()
{
base.OnDisappearing();
// ❗ สำคัญมาก: ไม่ unsubscribe = page ถูกถือไว้ตลอดอายุแอป
_connectivity.ConnectivityChanged -= OnConnectivityChanged;
}
private void OnConnectivityChanged(object sender, ConnectivityChangedEventArgs e)
{
// อัปเดต UI ตามสถานะเครือข่าย
}
}
ปรับการโหลดรูปภาพและทรัพยากร
รูปภาพคือสาเหตุอันดับหนึ่งของการกินหน่วยความจำใน MAUI list views ใช้ MauiImage ใน csproj เพื่อให้ resizetizer สร้างรูปขนาด density-specific ให้อัตโนมัติ และตั้ง BaseSize ที่เหมาะสมเพื่อไม่ให้รูปใหญ่เกินจำเป็น
<ItemGroup>
<MauiImage Include="Resources\Images\*" />
<MauiImage Update="Resources\Images\product_thumb.svg" BaseSize="48,48" />
<MauiImage Update="Resources\Images\hero.png" BaseSize="375,200" />
</ItemGroup>
สำหรับรูปจากอินเทอร์เน็ตใน CollectionView ให้ใช้ library อย่าง FFImageLoading.MAUI หรือ Plainer.MAUI ที่มี caching, downsampling และโหลด async ในตัว การโหลดด้วย Image.Source = uri ตรง ๆ จะ decode ที่ resolution เต็มและถือไว้ใน RAM
<ffimageloading:CachedImage
Source="{Binding ThumbnailUrl}"
DownsampleToViewSize="True"
CacheDuration="7"
LoadingPlaceholder="placeholder.png"
HeightRequest="48"
WidthRequest="48" />
ฟอนต์และไอคอน
โหลดเฉพาะฟอนต์ที่ใช้จริงใน ConfigureFonts การโหลดทุก weight ของ Inter หรือ Roboto ทั้งหมดเพิ่ม APK ขนาด 2–4 MB ใช้ icon font (เช่น Material Symbols) เป็นไฟล์ .ttf เดียวแทนการ ship SVG หลายสิบไฟล์
เช็กลิสต์ก่อน ship ขึ้น Production
ก่อนส่งแอปขึ้น Store ให้ตรวจรายการต่อไปนี้ทั้งหมด แต่ละข้อใช้เวลาไม่นานแต่ป้องกันปัญหาประสิทธิภาพที่เจอตอนผู้ใช้จริงเปิดบนเครื่องเก่า:
| หัวข้อ | Debug | Release (ก่อน Ship) |
| Configuration | Debug | Release |
| AOT compilation | ปิด | เปิด (Android: RunAOTCompilation + Profiled) |
| Trimming | ปิด | เปิด (TrimMode=full) + ทดสอบ runtime reflection |
| Compiled Binding | ใช้ก็ได้ | บังคับทุก View (x:DataType) |
| Image resizetizing | n/a | BaseSize ทุกตัวที่ใช้ใน list |
| Memory leak test | n/a | navigate 20 รอบ memory นิ่ง |
| Cold start (เครื่องเก่า) | ไม่ต้องวัด | ≤ 2.5 วินาที |
| APK/IPA size | ไม่จำกัด | ≤ 60 MB (เปรียบเทียบกับเวอร์ชันก่อนหน้า) |
เมื่อทุกข้อผ่านแล้ว ขั้นถัดไปคือการทำ profile บนเครื่องจริงรุ่นต่ำสุดที่ตลาดเป้าหมายใช้ (เช่น iPhone SE 2020 และ Android low-end สเปก 4 GB RAM) ไม่ใช่บน emulator ที่ใช้ CPU ของเครื่อง dev ตัวเลขจาก emulator มักดูดีเกินจริง สำหรับการอัปเกรดโปรเจกต์เก่าก่อนปรับ performance ให้อ่าน คู่มือย้ายแอปจาก Xamarin.Forms ไป .NET MAUI เพื่อให้แน่ใจว่าเริ่มต้นจาก baseline ที่ถูกต้อง
คำถามที่พบบ่อย
.NET MAUI ช้ากว่า Flutter จริงหรือไม่?
ในการทดสอบทั่วไป Flutter มี startup time เร็วกว่าเล็กน้อยเนื่องจาก rendering engine ของตัวเอง แต่ MAUI ที่ปรับแต่งด้วย AOT + Compiled Binding + CollectionView ที่ optimize แล้วใกล้เคียงกันมาก ความแตกต่างจริง ๆ มักมาจากคุณภาพโค้ดมากกว่า framework
เปิด Trimming แล้วแอป crash ตอน deserialize JSON ต้องทำอย่างไร?
ใช้ JsonSerializerContext ที่ generate ตอน compile time แทน reflection-based serialization หรือเพิ่ม [DynamicDependency] attribute เพื่อบอก linker ว่าให้รักษา type ที่ใช้ผ่าน reflection ไว้
ทำไม CollectionView ของผม scroll กระตุกแม้ item น้อย?
สาเหตุมักมาจาก ItemTemplate ที่ใช้ StackLayout ซ้อนหลายชั้น, รูปภาพที่โหลดที่ resolution เต็ม, หรือ binding แบบ reflection ลองเปลี่ยนเป็น Grid, ใช้ CachedImage พร้อม downsampling และเพิ่ม x:DataType
Native AOT บน iOS รองรับใน .NET 9 หรือยัง?
.NET 9 รองรับ Native AOT บน iOS แบบ experimental สำหรับโปรเจกต์ MAUI หลายส่วนยังไม่พร้อม production แต่ Full AOT (ค่าเริ่มต้น) บน iOS ทำงานสมบูรณ์อยู่แล้วและให้ผลลัพธ์ใกล้เคียงกัน ส่วน Android ใช้ Mono AOT ที่เสถียร
จะวัด startup time ของ MAUI app บนเครื่องจริงได้อย่างไร?
บน Android ใช้ adb shell am start -W เพื่อดู TotalTime ส่วน iOS ใช้ Xcode Instruments → App Launch template เลือก "Time to first frame" เป็น metric หลัก ค่าควรต่ำกว่า 2.5 วินาทีบนเครื่องสเปกต่ำสุดที่รองรับ