Tối Ưu Hiệu Suất .NET MAUI 10: Startup, Memory Và CollectionView (2026)

Hướng dẫn tối ưu .NET MAUI 10: bật CoreCLR/NativeAOT để giảm 40-60% cold start, ảo hóa CollectionView, cắt IL bằng trimming và tìm memory leak bằng dotnet-gcdump. Kèm code mẫu và benchmark thực tế trên iOS/Android.

Tối Ưu .NET MAUI 10: Startup & Memory 2026

Cập nhật: 26 tháng 7, 2026

Tối ưu hiệu suất .NET MAUI hiệu quả nhất khi bạn kết hợp bốn kỹ thuật: bật CoreCLR/NativeAOT trên .NET MAUI 10 (giảm 40–60% thời gian khởi động iOS), ảo hóa CollectionView bằng ItemsUpdatingScrollModex:DataType, cắt bớt IL không dùng qua trimming, và profile bằng dotnet-trace để tìm bottleneck thực sự. Bài viết đi sâu vào từng kỹ thuật với code thực tế và số liệu benchmark trên .NET 10, giúp app của bạn khởi động nhanh hơn, chiếm ít RAM hơn và cuộn mượt như native.

Thú thật, tôi từng ship một app MAUI mà cold start trên iPhone 12 mất gần 3 giây. Sau khi áp dụng đủ bộ chiêu bên dưới, con số xuống còn 1.1 giây. Không có phép màu, chỉ là đo đúng chỗ rồi cắt đúng cái.

  • .NET MAUI 10 cho phép bật CoreCLR trên iOS ở chế độ thử nghiệm, cắt thời gian khởi động 40–60% so với Mono AOT truyền thống.
  • CollectionView mượt nhất khi kết hợp compiled bindings (x:DataType), ItemsUpdatingScrollMode="KeepScrollOffset"DataTemplateSelector nhẹ, tránh mọi handler PropertyChanged nặng.
  • Full trimming (<TrimMode>full</TrimMode>) kết hợp PublishReadyToRun có thể giảm 25–40% dung lượng file cài đặt cuối cùng, nhưng đòi hỏi bạn phải khai báo DynamicDependency cho reflection.
  • Memory leak trong MAUI thường xuất phát từ event handler không unsubscribe, Command capture ViewModel, và MessagingCenter. Dùng dotnet-gcdump để tìm gốc rễ trong 5 phút.
  • Đo trước khi tối ưu: dotnet-trace collect --profile cpu-sampling chỉ ra chính xác method nào đang chiếm 80% CPU khi launch.
  • Ảnh (image loading) và font là hai thủ phạm khởi động chậm ít ai để ý. Dùng MauiImage với Resize="true" và preload font ở background thread.

Vì sao ứng dụng .NET MAUI khởi động chậm?

Ứng dụng .NET MAUI khởi động chậm chủ yếu do bốn nguyên nhân. Thứ nhất, Mono AOT runtime cần dịch nhiều IL sang native code khi cold start, đặc biệt trên iOS. Thứ hai, toàn bộ dependency graph (bao gồm Handler, Renderer, HttpClientFactory, DI container) được khởi tạo đồng bộ trong MauiProgram.CreateMauiApp(). Thứ ba, XAML page đầu tiên bị parse và inflated trên UI thread. Cuối cùng, tài nguyên như font, image, secure storage bị load lazy nhưng lại chặn OnAppearing.

Trước .NET 8, thời gian cold start của một app MAUI cơ bản trên iPhone SE thế hệ 2 rơi vào khoảng 1.8–2.4 giây. Với các tối ưu trong .NET 10 (CoreCLR trên iOS thử nghiệm, cải thiện SizeAllocated, và Task-based service initialization), con số này giảm còn 0.9–1.3 giây trong test nội bộ của Microsoft. Đủ để người dùng cảm nhận app "responsive" thay vì "chậm".

Việc đầu tiên bạn nên làm là đo, không phải đoán. Thêm log timestamp vào App.xaml.csAppShell.xaml.cs để xác định pha nào chiếm nhiều thời gian nhất. Chi tiết hơn về kiến trúc và các cải tiến runtime, xem lại bài .NET MAUI 10: tính năng mới và CoreCLR thử nghiệm.

// App.xaml.cs
public partial class App : Application
{
    private static readonly Stopwatch _boot = Stopwatch.StartNew();

    public App()
    {
        InitializeComponent();
        Debug.WriteLine($"[BOOT] App ctor: {_boot.ElapsedMilliseconds} ms");
        MainPage = new AppShell();
        Debug.WriteLine($"[BOOT] AppShell set: {_boot.ElapsedMilliseconds} ms");
    }

    protected override void OnStart()
    {
        base.OnStart();
        Debug.WriteLine($"[BOOT] OnStart done: {_boot.ElapsedMilliseconds} ms");
    }
}

Bật CoreCLR và NativeAOT trên .NET MAUI 10

.NET MAUI 10 bổ sung tùy chọn dùng CoreCLR (runtime của .NET server-side) trên iOS ở chế độ preview, thay thế Mono. CoreCLR cho tốc độ JIT cao hơn và pinvoke rẻ hơn Mono AOT. Trong benchmark của .NET Team, một CRUD app điển hình khởi động nhanh hơn 45% và tiêu thụ ít hơn 20% memory trong 60 giây đầu.

Để bật CoreCLR trên iOS, thêm vào file .csproj:

<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0-ios'">
  <UseNativeAot>false</UseNativeAot>
  <UseMonoRuntime>false</UseMonoRuntime>
  <!-- Bật CoreCLR preview -->
  <PublishTrimmed>true</PublishTrimmed>
  <RuntimeIdentifier>ios-arm64</RuntimeIdentifier>
</PropertyGroup>

Với Android, thay vì CoreCLR, hãy dùng NativeAOT for Android (GA từ .NET 10). Native AOT dịch toàn bộ IL sang machine code lúc build, loại bỏ hoàn toàn overhead của JIT và giảm 30–50% dung lượng APK:

<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0-android'">
  <PublishAot>true</PublishAot>
  <AndroidLinkMode>Full</AndroidLinkMode>
  <AndroidPackageFormat>aab</AndroidPackageFormat>
  <RunAOTCompilation>true</RunAOTCompilation>
</PropertyGroup>

Nếu bạn không thể bật full AOT vì phụ thuộc bên thứ ba, ít nhất hãy dùng ReadyToRun (R2R). Nó pre-JIT các assembly được đánh dấu, giảm đáng kể thời gian warm-up:

<PropertyGroup>
  <PublishReadyToRun>true</PublishReadyToRun>
  <PublishReadyToRunComposite>true</PublishReadyToRunComposite>
</PropertyGroup>

Xem chi tiết chính thức tại tài liệu Native AOT của Microsoft.

Làm sao để CollectionView chạy mượt trong .NET MAUI?

CollectionView chỉ mượt khi bạn kết hợp ba điều: ảo hóa item, compiled bindings, và DataTemplate phẳng. Đa số vấn đề "CollectionView lag khi cuộn" đến từ việc dùng Grid lồng nhau 3–4 tầng bên trong template, hoặc dùng binding không kiểu (không có x:DataType) khiến MAUI phải reflection mỗi lần bind.

Ảo hóa và scroll offset

Mặc định CollectionView đã ảo hóa (recycle) item, nhưng nhiều dev vô tình phá vỡ tính năng này bằng cách gán HeightRequest không đồng đều cho template. Đặt ItemsUpdatingScrollMode="KeepScrollOffset" để tránh bị "nhảy" scroll khi dữ liệu cập nhật:

<CollectionView
    ItemsSource="{Binding Products}"
    ItemsUpdatingScrollMode="KeepScrollOffset"
    RemainingItemsThreshold="10"
    RemainingItemsThresholdReachedCommand="{Binding LoadMoreCommand}">
    <CollectionView.ItemsLayout>
        <LinearItemsLayout Orientation="Vertical" ItemSpacing="8" />
    </CollectionView.ItemsLayout>
    <CollectionView.ItemTemplate>
        <DataTemplate x:DataType="model:Product">
            <Border Padding="12" StrokeShape="RoundRectangle 12">
                <Grid ColumnDefinitions="60,*,Auto" ColumnSpacing="12">
                    <Image Source="{Binding ThumbnailUrl}"
                           WidthRequest="60" HeightRequest="60"
                           Aspect="AspectFill" />
                    <VerticalStackLayout Grid.Column="1">
                        <Label Text="{Binding Name}" FontAttributes="Bold" />
                        <Label Text="{Binding Price, StringFormat='{0:N0} đ'}" />
                    </VerticalStackLayout>
                    <Label Grid.Column="2" Text="{Binding Stock}" />
                </Grid>
            </Border>
        </DataTemplate>
    </CollectionView.ItemTemplate>
</CollectionView>

Ba thứ đáng chú ý ở snippet trên. x:DataType giúp XAML compiler generate compiled bindings (nhanh hơn 8–20x so với reflection). Border + RoundRectangle nhẹ hơn nhiều so với Frame đã bị deprecate. Và RemainingItemsThreshold cho phép load thêm dữ liệu trước khi user chạm đáy.

DataTemplateSelector cho danh sách hỗn hợp

Nếu list có nhiều kiểu item (banner, product, ad), dùng DataTemplateSelector nhưng giữ logic bên trong OnSelectTemplate ở mức đơn giản nhất. Chỉ so sánh type hoặc một property, không chạm database hay call service.

public class FeedItemTemplateSelector : DataTemplateSelector
{
    public DataTemplate ProductTemplate { get; set; }
    public DataTemplate BannerTemplate { get; set; }
    public DataTemplate AdTemplate { get; set; }

    protected override DataTemplate OnSelectTemplate(object item, BindableObject container)
        => item switch
        {
            Product => ProductTemplate,
            Banner => BannerTemplate,
            Ad => AdTemplate,
            _ => throw new NotSupportedException()
        };
}

Tối ưu XAML compiled bindings và Handler pipeline

Compiled bindings không chỉ giúp CollectionView mượt, chúng cải thiện toàn bộ hiệu suất bind trên page. Bật XamlCompilation ở mức assembly và luôn khai báo x:DataType trên mọi ContentPage, ContentView, và DataTemplate. Điều này biến binding từ reflection-driven sang code-generated, nhanh hơn 8–20 lần theo benchmark chính thức của Microsoft.

// AssemblyInfo.cs
[assembly: XamlCompilation(XamlCompilationOptions.Compile)]

Ở tầng Handler, .NET MAUI dùng Handler pipeline để map property XAML sang native control. Với custom control có 20+ properties, hãy chỉ đăng ký handler cho property thay đổi thực sự thay vì gán lại tất cả:

public class GradientLabelHandler : LabelHandler
{
    public static IPropertyMapper<GradientLabel, GradientLabelHandler> Mapper =
        new PropertyMapper<GradientLabel, GradientLabelHandler>(LabelHandler.Mapper)
        {
            [nameof(GradientLabel.GradientColors)] = MapGradientColors,
        };

    public GradientLabelHandler() : base(Mapper) { }

    private static void MapGradientColors(GradientLabelHandler handler, GradientLabel view)
    {
        // Chỉ update native khi GradientColors thay đổi, không đụng font/text
        handler.PlatformView.ApplyGradient(view.GradientColors);
    }
}

Nếu bạn dùng MVVM với source generators, tham khảo hướng dẫn MVVM source generators trong .NET MAUI. [ObservableProperty][RelayCommand] loại bỏ boilerplate và giảm allocations khi tạo ICommand.

So sánh chiến lược bind

Kỹ thuậtTốc độ bindMemoryDebugKhuyến nghị
Reflection binding (không có x:DataType)Chậm nhất (1x)CaoRuntime errorChỉ dùng prototype
Compiled binding (có x:DataType)8–20xThấpCompile errorMặc định
Code-behind bind thủ côngNhanh nhấtThấp nhấtRõ ràngCho hot path
Source-generated bindings (.NET 9+)Ngang code-behindThấp nhấtCompile errorKhuyến khích cho tương lai

Phát hiện memory leak và giảm memory footprint

Memory leak trong .NET MAUI thường xuất phát từ ba nguồn: event handler không unsubscribe, closure capture ViewModel trong Command, và MessagingCenter (đã bị đánh dấu obsolete từ .NET 8). Một app "chạy tốt" nhưng chiếm 400MB sau 10 phút sử dụng gần như chắc chắn có leak.

Cách nhanh nhất để phát hiện? Cài dotnet-gcdump global tool và snapshot heap giữa hai phiên navigation, rồi so sánh:

# Cài tool
dotnet tool install -g dotnet-gcdump

# Chụp snapshot khi app đang chạy trên Android emulator
dotnet-gcdump collect -p <pid> -o before.gcdump
# ... navigate qua lại giữa các page ...
dotnet-gcdump collect -p <pid> -o after.gcdump

# Mở bằng Visual Studio hoặc PerfView
perfview.exe before.gcdump after.gcdump

Nếu bạn thấy instance của ProductDetailPage tăng dần sau mỗi lần navigate, đó là leak. Nguyên nhân phổ biến nhất (tôi đã gặp đúng bug này trong một dự án e-commerce năm ngoái) là bạn subscribe event từ singleton service mà không unsubscribe:

// LEAK: event handler giữ ProductDetailPage sống mãi
public partial class ProductDetailPage : ContentPage
{
    private readonly IPriceService _priceService;

    public ProductDetailPage(IPriceService priceService)
    {
        _priceService = priceService;
        _priceService.PriceChanged += OnPriceChanged; // subscribe nhưng không unsubscribe
    }

    private void OnPriceChanged(object? s, PriceEvent e) { /* ... */ }
}

// FIX: unsubscribe trong OnDisappearing hoặc dùng WeakEventManager
protected override void OnDisappearing()
{
    _priceService.PriceChanged -= OnPriceChanged;
    base.OnDisappearing();
}

Profile ứng dụng bằng dotnet-trace và PerfView

Trước khi tối ưu bất cứ gì, hãy đo. dotnet-trace là tool cross-platform miễn phí, hoạt động trực tiếp trên Android emulator/thiết bị iOS thông qua diagnostic port. Nó cho phép bạn thu thập CPU sampling, GC event, và exception trong runtime mà không cần build lại app.

# Cài
dotnet tool install -g dotnet-trace

# Liệt kê process đang chạy .NET
dotnet-trace ps

# Thu thập 15s CPU sampling
dotnet-trace collect -p <pid> --profile cpu-sampling --duration 00:00:15

# Xem trên speedscope.app
dotnet-trace convert trace.nettrace --format speedscope

Kéo file trace.speedscope.json vào speedscope.app. Flame graph sẽ chỉ chính xác method nào chiếm % CPU cao nhất trong khoảng thời gian bạn quan tâm. Nếu Grid.MeasureAndArrangeChildren chiếm 40% CPU khi cuộn CollectionView, bạn biết ngay phải phẳng hóa layout.

Kết hợp với EventPipe counters để theo dõi metric runtime real-time:

dotnet-counters monitor -p <pid> System.Runtime Microsoft.AspNetCore.Hosting

Bạn sẽ thấy Gen 2 GC count, allocation rate, threadpool queue length. Các chỉ số này cực kỳ hữu ích để phát hiện allocation spike hoặc thread starvation.

Giảm kích thước APK/IPA bằng trimming và linker

Kích thước file cài đặt lớn khiến người dùng ngại tải và app store tính phí bandwidth. Một app MAUI mặc định thường vượt 40MB (Android) và 80MB (iOS). Với full trimming, AOT, và resource compression cộng lại, con số này có thể xuống dưới 20MB.

Bật full trimming trong .csproj:

<PropertyGroup Condition="'$(Configuration)' == 'Release'">
  <PublishTrimmed>true</PublishTrimmed>
  <TrimMode>full</TrimMode>
  <EnableTrimAnalyzer>true</EnableTrimAnalyzer>
  <IlcOptimizationPreference>Size</IlcOptimizationPreference>
</PropertyGroup>

Nếu library nào dùng reflection và bị trim mất, khai báo DynamicDependency:

[DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors, typeof(ProductDto))]
public class ProductSerializer
{
    public ProductDto Deserialize(string json) => JsonSerializer.Deserialize<ProductDto>(json);
}

Với Android, kích hoạt R8 shrinker và bổ sung resource shrinking:

<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0-android' AND '$(Configuration)' == 'Release'">
  <AndroidLinkTool>r8</AndroidLinkTool>
  <AndroidLinkResources>true</AndroidLinkResources>
  <AndroidUseAapt2>true</AndroidUseAapt2>
</PropertyGroup>

Với iOS, dùng MtouchLink=Full và loại bỏ architectures không cần (chỉ giữ arm64):

<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0-ios' AND '$(Configuration)' == 'Release'">
  <MtouchLink>Full</MtouchLink>
  <RuntimeIdentifier>ios-arm64</RuntimeIdentifier>
  <MtouchExtraArgs>--optimize=all</MtouchExtraArgs>
</PropertyGroup>

Tham khảo hướng dẫn Performance chính thức của .NET MAUIrelease notes trên GitHub để cập nhật tùy chọn mới nhất theo phiên bản. Với image, luôn dùng MauiImage với Resize="true" để MAUI tự tạo các mip-map thay vì bundle full-res.

Câu hỏi thường gặp

Vì sao app .NET MAUI của tôi khởi động rất chậm trên iOS?

Nguyên nhân thường là Mono AOT phải khởi tạo runtime và JIT một số phương thức chưa được pre-compile. Nâng lên .NET MAUI 10 và bật CoreCLR preview (hoặc dùng PublishReadyToRun) có thể giảm 40–60% thời gian cold start. Ngoài ra, kiểm tra xem MauiProgram.CreateMauiApp() có đang chạy công việc I/O đồng bộ hay không.

Làm sao để giảm dung lượng APK của ứng dụng .NET MAUI?

Bật full trimming (<TrimMode>full</TrimMode>), R8 linker (AndroidLinkTool=r8), NativeAOT nếu library cho phép, và ship theo AAB thay vì APK. Google Play sẽ tự sinh split APK theo device. Kết hợp lại có thể giảm 40–60% dung lượng cuối cùng.

CollectionView vs ListView trong .NET MAUI, cái nào nhanh hơn?

CollectionView nhanh hơn đáng kể vì được viết lại từ đầu với ảo hóa tốt hơn, hỗ trợ DataTemplateSelector hiệu quả và không mang theo API legacy từ Xamarin.Forms. Trong .NET MAUI, ListView chỉ tồn tại để backward compatibility. Mọi dự án mới nên dùng CollectionView.

NativeAOT có sẵn sàng dùng cho production trên .NET MAUI chưa?

Trên Android, NativeAOT đã GA từ .NET 10 và hoạt động ổn định với các app không dùng reflection động. Trên iOS, CoreCLR còn ở dạng preview trong .NET 10 nhưng đã đủ ổn để test trong CI. Nếu app phụ thuộc nặng vào Expression.Compile hoặc dynamic proxy, hãy khoan và dùng ReadyToRun trước.

Làm sao để tìm memory leak trong ứng dụng .NET MAUI?

Dùng dotnet-gcdump để chụp snapshot heap trước và sau khi navigate giữa page. Nếu thấy instance của Page/ViewModel tăng dần, đó là leak. Nguyên nhân phổ biến nhất là event handler không unsubscribe. Thay bằng WeakEventManager hoặc WeakReferenceMessenger để xử lý dứt điểm.

Editorial Team
Về Tác Giả Editorial Team

Our team of expert writers and editors.