Dlaczego wydajność aplikacji mobilnych decyduje o sukcesie projektu
Każdy deweloper mobilny prędzej czy później trafia na ten sam mur — aplikacja, która na emulatorze i flagowym smartfonie śmigała jak marzenie, na urządzeniach użytkowników końcowych zaczyna się zachowywać zupełnie inaczej. Klatki spadają, przewijanie zacina się, a czas uruchomienia jest na tyle długi, że użytkownik zdąży stracić cierpliwość zanim zobaczy pierwszy ekran.
Badania Google mówią jasno: 53% użytkowników porzuca aplikację mobilną, jeśli ładuje się dłużej niż 3 sekundy. Optymalizacja wydajności to więc nie luksus — to konieczność.
.NET MAUI jako wieloplatformowy framework przeszedł ogromną ewolucję w kwestii wydajności. Wraz z .NET 9, a zwłaszcza .NET 10, platforma oferuje coraz potężniejsze narzędzia — od NativeAOT i pełnego trimmingu, przez skompilowane bindowania, aż po nowe handlery CollectionView. Problem w tym, że sam framework to dopiero połowa sukcesu. Druga połowa leży po stronie dewelopera — i sposobu, w jaki projektuje, pisze i profiluje swój kod.
W tym przewodniku przejdziemy przez każdy kluczowy aspekt optymalizacji wydajności w .NET MAUI. Zaczniemy od profilowania, potem zajmiemy się czasem startu, skompilowanymi bindowaniami, listami, zarządzaniem pamięcią i na koniec NativeAOT. Każda sekcja zawiera praktyczne przykłady kodu, które możesz wziąć i od razu zastosować w swoich projektach.
Profilowanie — zawsze przed optymalizacją
Złota zasada optymalizacji brzmi: nigdy nie optymalizuj kodu, którego nie sprofilowałeś. Serio. Intuicja dewelopera, nawet doświadczonego, potrafi być zdradliwa. To, co wydaje się wąskim gardłem, często okazuje się marginalne, a prawdziwy problem czai się w zupełnie innym miejscu.
Dlatego pierwszym krokiem każdej optymalizacji musi być pomiar.
Narzędzia diagnostyczne .NET
Ekosystem .NET oferuje zestaw globalnych narzędzi diagnostycznych, które działają z aplikacjami .NET MAUI na wszystkich platformach. Oto trzy, które warto zainstalować na starcie:
# Instalacja narzędzi diagnostycznych
dotnet tool install -g dotnet-trace
dotnet tool install -g dotnet-dsrouter
dotnet tool install -g dotnet-gcdump
dotnet-trace to narzędzie do zbierania śladów CPU i danych wydajnościowych. Pozwala nagrać sesję profilowania i zapisać ją w formacie .nettrace lub .speedscope, dając wgląd w czas spędzony w każdej metodzie aplikacji.
dotnet-dsrouter pełni rolę routera diagnostycznego — przekierowuje połączenia diagnostyczne ze zdalnych urządzeń (telefon z Androidem, iPhone) na maszynę lokalną. Bez niego profilowanie na fizycznych urządzeniach jest po prostu niemożliwe.
dotnet-gcdump zbiera zrzuty pamięci zarządzanej (GC dumps), umożliwiając analizę alokacji obiektów i wykrywanie wycieków pamięci. Przydaje się szczególnie wtedy, gdy podejrzewasz, że coś nie jest prawidłowo zwalniane.
Profilowanie na urządzeniu mobilnym
Żeby rozpocząć profilowanie aplikacji .NET MAUI na urządzeniu mobilnym, wykonaj następujące kroki:
# 1. Uruchom router diagnostyczny (w osobnym terminalu)
dotnet-dsrouter android-emu
# 2. Uruchom aplikację z włączoną diagnostyką
# W pliku .csproj dodaj:
# <RuntimeHostConfigurationOption
# Include="System.Diagnostics.Tracing.DiagnosticSourceEventSource"
# Value="true" />
# 3. Zbierz ślad CPU (30 sekund)
dotnet-trace collect --process-id <PID> --duration 00:00:30
# 4. Otwórz wynik w Speedscope lub PerfView
# dotnet-trace convert trace.nettrace --format Speedscope
Po otwarciu wyników w Speedscope zobaczysz flame graph — wykres płomieniowy, na którym szerokość każdego bloku odpowiada czasowi wykonania danej funkcji. Im szerszy blok, tym więcej czasu procesor spędził w tej metodzie. Szczerze mówiąc, jak już raz zaczniesz czytać te wykresy, nie wyobrażasz sobie optymalizacji bez nich.
PerfView na Windows
Dla aplikacji działających na Windows, PerfView pozostaje chyba najprostszym i najbardziej wszechstronnym narzędziem profilującym. Pozwala przechwytywać zdarzenia ETW (Event Tracing for Windows) związane ze startem aplikacji WindowsAppSdk i .NET MAUI, co daje niezwykle precyzyjny obraz tego, co dzieje się podczas uruchamiania.
.NET Meteor — profilowanie w VS Code
Dla deweloperów preferujących Visual Studio Code, rozszerzenie .NET Meteor w połączeniu ze Speedscope oferuje kompletne środowisko do profilowania aplikacji .NET MAUI. Można uruchamiać, debugować i profilować aplikacje bezpośrednio z poziomu edytora kodu — bez przełączania się do osobnych narzędzi.
Krytyczna uwaga: zawsze profiluj kompilacje Release, a nie Debug. Kompilacje Debug używają interpretera (UseInterpreter=true) dla wsparcia C# Hot Reload, co drastycznie wpływa na wydajność i daje nierealistyczne wyniki. Profilowanie kompilacji Debug to jak testowanie aerodynamiki samochodu z otwartymi oknami — wyniki nie mają nic wspólnego z rzeczywistością.
Optymalizacja czasu uruchomienia aplikacji
Czas startu to pierwszy kontakt użytkownika z Twoją aplikacją i jednocześnie jedna z najczęściej zgłaszanych bolączek w .NET MAUI. Na szczęście jest kilka sprawdzonych technik, które mogą go znacząco skrócić.
Używaj Shell zamiast TabbedPage
Aplikacje oparte na Shell mają kluczową przewagę wydajnościową — strony tworzone są na żądanie, w odpowiedzi na nawigację, a nie przy starcie aplikacji. W przeciwieństwie do TabbedPage, gdzie wszystkie zakładki inicjalizowane są od razu, Shell odkłada tworzenie stron do momentu, gdy użytkownik faktycznie do nich nawiguje. To ogromna różnica, zwłaszcza w aplikacjach z wieloma zakładkami.
<!-- AppShell.xaml — strony ładowane na żądanie -->
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:views="clr-namespace:MojaAplikacja.Views"
x:Class="MojaAplikacja.AppShell">
<!-- Każda zakładka tworzy swoją stronę dopiero po kliknięciu -->
<FlyoutItem Title="Strona główna">
<ShellContent ContentTemplate="{DataTemplate views:StronaGlowna}" />
</FlyoutItem>
<FlyoutItem Title="Produkty">
<ShellContent ContentTemplate="{DataTemplate views:StronaProdukty}" />
</FlyoutItem>
<FlyoutItem Title="Ustawienia">
<ShellContent ContentTemplate="{DataTemplate views:StronaUstawienia}" />
</FlyoutItem>
</Shell>
Wzorzec leniwej inicjalizacji (Lazy Task Pattern)
Jednym z najczęstszych błędów jest wykonywanie zbyt wielu operacji podczas startu aplikacji. Pobieranie danych z API, inicjalizacja baz danych, konfiguracja usług analitycznych — to wszystko powinno być odroczone. Wzorzec leniwej inicjalizacji pozwala opóźnić tworzenie usług do momentu ich pierwszego użycia:
public class LazyService<T> where T : class
{
private readonly Lazy<Task<T>> _lazyTask;
public LazyService(Func<Task<T>> factory)
{
_lazyTask = new Lazy<Task<T>>(factory);
}
public Task<T> Value => _lazyTask.Value;
}
// Rejestracja w MauiProgram.cs
builder.Services.AddSingleton(sp =>
new LazyService<BazaDanychService>(async () =>
{
var serwis = new BazaDanychService();
await serwis.InicjalizujAsync();
return serwis;
}));
// Użycie w ViewModelu
public class ProduktViewModel : ObservableObject
{
private readonly LazyService<BazaDanychService> _bazaDanych;
public ProduktViewModel(LazyService<BazaDanychService> bazaDanych)
{
_bazaDanych = bazaDanych;
}
[RelayCommand]
private async Task ZaladujProduktyAsync()
{
// Baza danych inicjalizowana dopiero tutaj, przy pierwszym użyciu
var db = await _bazaDanych.Value;
var produkty = await db.PobierzProduktyAsync();
// ...
}
}
Minimalizacja rejestracji w kontenerze DI
Każda rejestracja w kontenerze Dependency Injection to dodatkowy czas startu. Brzmi banalnie, ale potrafi się naprawdę zsumować. Rejestruj tylko te usługi, które są faktycznie potrzebne, i unikaj zagnieżdżonych zależności rekonstruowanych przy każdej nawigacji. Stosuj AddSingleton dla usług bezstanowych, a AddTransient tylko tam, gdzie to rzeczywiście konieczne:
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>();
// Singletony — tworzone raz, używane wielokrotnie
builder.Services.AddSingleton<IHttpClientFactory>(sp =>
{
var factory = new DefaultHttpClientFactory();
return factory;
});
builder.Services.AddSingleton<IApiService, ApiService>();
builder.Services.AddSingleton<ICacheService, CacheService>();
// Transienty — tylko tam, gdzie każda instancja musi być nowa
builder.Services.AddTransient<SzczegolyProduktuViewModel>();
builder.Services.AddTransient<SzczegolyProduktuPage>();
return builder.Build();
}
}
Skompilowane bindowania — do 20x szybsze wiązanie danych
Tradycyjne bindowania w .NET MAUI opierają się na refleksji — silnik bindowania rozwiązuje ścieżki w runtime, co wprowadza zauważalny narzut wydajnościowy. Skompilowane bindowania eliminują ten problem, kompilując wyrażenia już na etapie budowania aplikacji.
I tu zaczynają się konkretne liczby.
Konkretne liczby wydajnościowe
Benchmarki Microsoftu mówią same za siebie:
- OneWay/TwoWay binding — skompilowane bindowanie rozwiązywane jest ~8 razy szybciej niż klasyczne
- OneTime binding — tu różnica jest jeszcze większa, bo aż ~20 razy szybciej
- Ustawienie BindingContext — ~5 razy szybciej w skompilowanych bindowaniach
To nie są marginalne różnice. To rząd wielkości, który wpływa na odczuwalną responsywność interfejsu, szczególnie na listach z setkami elementów.
Implementacja w XAML z atrybutem x:DataType
Aby aktywować skompilowane bindowania w XAML, wystarczy dodać atrybut x:DataType do elementu wizualnego. Kompilator automatycznie wygeneruje efektywne gettery i settery zamiast korzystania z refleksji:
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:MojaAplikacja.ViewModels"
x:Class="MojaAplikacja.Views.ListaProduktyPage"
x:DataType="vm:ListaProduktyViewModel">
<!-- Bindowania na poziomie strony — kompilowane -->
<Grid RowDefinitions="Auto,*" Padding="16">
<SearchBar Text="{Binding FrazaWyszukiwania}"
SearchCommand="{Binding WyszukajCommand}"
Placeholder="Szukaj produktów..." />
<CollectionView Grid.Row="1"
ItemsSource="{Binding Produkty}"
RemainingItemsThreshold="5"
RemainingItemsThresholdReachedCommand="{Binding ZaladujWiecejCommand}">
<CollectionView.ItemTemplate>
<!-- Zmiana x:DataType wewnątrz DataTemplate -->
<DataTemplate x:DataType="vm:ProduktItemViewModel">
<Border StrokeThickness="0" Margin="0,4">
<Grid ColumnDefinitions="80,*"
Padding="8" ColumnSpacing="12">
<Image Source="{Binding ObrazekUrl}"
Aspect="AspectFill"
HeightRequest="80"
WidthRequest="80" />
<VerticalStackLayout Grid.Column="1"
VerticalOptions="Center">
<Label Text="{Binding Nazwa}"
FontAttributes="Bold"
FontSize="16" />
<Label Text="{Binding Cena, StringFormat='{0:C}'}"
FontSize="14"
TextColor="Gray" />
<Label Text="{Binding Kategoria}"
FontSize="12"
TextColor="DarkGray" />
</VerticalStackLayout>
</Grid>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</Grid>
</ContentPage>
Skompilowane bindowania w kodzie C# (.NET 9+)
Nowością w .NET 9 jest możliwość definiowania skompilowanych bindowań bezpośrednio w kodzie C# za pomocą wyrażeń lambda. Koniec z ciągami znaków w ścieżkach bindowania (i koniec z literówkami, które powodowały ciche błędy):
// Klasyczne bindowanie (wolne, oparte na refleksji)
var label = new Label();
label.SetBinding(Label.TextProperty, "Nazwa");
// Skompilowane bindowanie w kodzie (.NET 9+)
var label = new Label();
label.SetBinding(
Label.TextProperty,
static (ProduktItemViewModel vm) => vm.Nazwa);
// Dwukierunkowe skompilowane bindowanie
var entry = new Entry();
entry.SetBinding(
Entry.TextProperty,
getter: static (ProduktItemViewModel vm) => vm.Nazwa,
setter: static (ProduktItemViewModel vm, string value) => vm.Nazwa = value,
mode: BindingMode.TwoWay);
Ważna zmiana w .NET 9: kompilator domyślnie generuje ostrzeżenia dla bindowań, które nie używają skompilowanej formy. W aplikacjach NativeAOT i z pełnym trimmingiem skompilowane bindowania są wymagane — refleksyjne bindowania po prostu nie zadziałają. Warto się do tego przyzwyczaić już teraz.
Efektywne zarządzanie listami i CollectionView
Listy to jeden z najczęściej występujących elementów UI w aplikacjach mobilnych — i jednocześnie jedno z największych źródeł problemów wydajnościowych. Nieprawidłowo zaimplementowana lista potrafi zamienić płynne 60 FPS w jąkającą się prezentację, którą trudno uzasadnić przed klientem.
Inkrementalne ładowanie danych
Jednym z najczęstszych błędów jest ładowanie wszystkich rekordów z API na raz. Przy setkach czy tysiącach elementów to niepotrzebne zużycie pamięci, opóźnione wyświetlenie pierwszych wyników i obciążenie sieci. Rozwiązanie? Inkrementalne ładowanie danych, czyli klasyczny infinite scrolling:
public partial class ListaProduktyViewModel : ObservableObject
{
private readonly IApiService _apiService;
private int _aktualnaStrona = 0;
private const int RozmiarStrony = 20;
private bool _czyLaduje = false;
private bool _czyJestWiecej = true;
[ObservableProperty]
private ObservableCollection<ProduktItemViewModel> _produkty = new();
public ListaProduktyViewModel(IApiService apiService)
{
_apiService = apiService;
}
[RelayCommand]
private async Task ZaladujWiecejAsync()
{
if (_czyLaduje || !_czyJestWiecej)
return;
_czyLaduje = true;
try
{
var wyniki = await _apiService.PobierzProduktyAsync(
strona: _aktualnaStrona,
rozmiar: RozmiarStrony);
if (wyniki.Count < RozmiarStrony)
_czyJestWiecej = false;
foreach (var produkt in wyniki)
{
Produkty.Add(new ProduktItemViewModel(produkt));
}
_aktualnaStrona++;
}
finally
{
_czyLaduje = false;
}
}
}
Nowe handlery CollectionView w .NET 9/10
W .NET 9 pojawiły się opcjonalne nowe handlery dla CollectionView i CarouselView na iOS i Mac Catalyst, oparte na nowszych API UICollectionView. Oferują lepszą wydajność i stabilność. W .NET 10 te handlery stają się domyślne, więc jeśli jeszcze ich nie testujesz — czas zacząć:
// Opcjonalne włączenie w .NET 9 (domyślne w .NET 10)
#if IOS || MACCATALYST
builder.ConfigureMauiHandlers(handlers =>
{
handlers.AddHandler<CollectionView,
Microsoft.Maui.Controls.Handlers.Items2.CollectionViewHandler2>();
handlers.AddHandler<CarouselView,
Microsoft.Maui.Controls.Handlers.Items2.CarouselViewHandler2>();
});
#endif
Spłaszczanie hierarchii layoutów
Głęboko zagnieżdżone layouty to cichy zabójca wydajności. Każdy poziom zagnieżdżenia wymaga dodatkowych obliczeń pomiarowych i renderowania. Sam kiedyś spędziłem godziny szukając przyczyny zacinania się listy, zanim zorientowałem się, że problem tkwił w czterech poziomach zagnieżdżonych StackLayout wewnątrz DataTemplate.
Zamiast zagnieżdżać StackLayout w StackLayout, użyj Grid z definicjami wierszy i kolumn:
<!-- ŹLE — głębokie zagnieżdżenie -->
<VerticalStackLayout>
<HorizontalStackLayout>
<VerticalStackLayout>
<Label Text="{Binding Tytul}" />
<Label Text="{Binding Opis}" />
</VerticalStackLayout>
<Image Source="{Binding Obrazek}" />
</HorizontalStackLayout>
</VerticalStackLayout>
<!-- DOBRZE — płaski Grid -->
<Grid ColumnDefinitions="*,80" RowDefinitions="Auto,Auto">
<Label Text="{Binding Tytul}" Grid.Row="0" Grid.Column="0" />
<Label Text="{Binding Opis}" Grid.Row="1" Grid.Column="0" />
<Image Source="{Binding Obrazek}"
Grid.Row="0" Grid.Column="1" Grid.RowSpan="2" />
</Grid>
Zarządzanie pamięcią i eliminacja wycieków
Wycieki pamięci to chyba najbardziej podstępny problem wydajnościowy w .NET MAUI. Aplikacja może działać idealnie przez pierwsze minuty, ale po wielokrotnej nawigacji między stronami zaczyna zużywać coraz więcej pamięci, robi się coraz wolniejsza i w końcu zostaje zamknięta przez system operacyjny.
Najgorsze jest to, że wycieki często nie ujawniają się podczas zwykłego testowania.
Najczęstsze wzorce wycieków pamięci
Na podstawie analizy zgłoszeń na GitHub i doświadczeń społeczności, oto najczęstsze źródła wycieków pamięci w .NET MAUI:
1. Niezwolnione subskrypcje zdarzeń — to zdecydowanie najczęstsza przyczyna. Jeśli obiekt A subskrybuje zdarzenie obiektu B, to B trzyma silną referencję do A. Dopóki B żyje, A nie może zostać zebrany przez garbage collector:
// PROBLEM — wyciek pamięci
public partial class SzczegolyPage : ContentPage
{
private readonly ISerwisZdarzen _serwis;
public SzczegolyPage(ISerwisZdarzen serwis)
{
_serwis = serwis;
_serwis.DaneZmienione += NaDaneZmienione; // Subskrypcja
InitializeComponent();
}
private void NaDaneZmienione(object sender, EventArgs e)
{
// Obsługa zdarzenia
}
// Brak wypisania się ze zdarzenia = wyciek pamięci!
}
// ROZWIĄZANIE — zawsze wypisuj się ze zdarzeń
public partial class SzczegolyPage : ContentPage
{
private readonly ISerwisZdarzen _serwis;
public SzczegolyPage(ISerwisZdarzen serwis)
{
_serwis = serwis;
InitializeComponent();
}
protected override void OnAppearing()
{
base.OnAppearing();
_serwis.DaneZmienione += NaDaneZmienione;
}
protected override void OnDisappearing()
{
base.OnDisappearing();
_serwis.DaneZmienione -= NaDaneZmienione; // Zawsze wypisuj się!
}
private void NaDaneZmienione(object sender, EventArgs e)
{
// Obsługa zdarzenia
}
}
2. DisconnectHandler nie jest wywoływany automatycznie — to pułapka, która łapie wielu deweloperów (w tym mnie na początku). Każdy handler platformowy ma implementację DisconnectHandler odpowiedzialną za czyszczenie natywnych zasobów. Problem w tym, że .NET MAUI celowo nie wywołuje tej metody automatycznie. Musisz zrobić to sam:
public partial class MojaStrona : ContentPage
{
protected override void OnNavigatedFrom(NavigatedFromEventArgs args)
{
base.OnNavigatedFrom(args);
// Ręczne wywołanie DisconnectHandler dla kontrolek
// wymagających czyszczenia natywnych zasobów
foreach (var element in Content.GetVisualTreeDescendants())
{
if (element is IView view && view.Handler != null)
{
(view.Handler as IElementHandler)?.DisconnectHandler();
}
}
}
}
3. Propagacja wycieków przez drzewo wizualne — wycieki w .NET MAUI mają tendencję do propagacji przez cały wizualny drzewo za pośrednictwem referencji takich jak Parent, Content, ItemsSource i Children. Jeden wyciekający element potrafi zatrzymać w pamięci całą stronę wraz z jej BindingContextem. To trochę jak efekt domina.
4. CollectionView z Behaviors i VisualStateGroups — elementy CollectionView z globalnie zadeklarowanymi VisualStateGroup lub dołączonymi Behaviors są szczególnie podatne na wycieki, ponieważ wirtualizacja utrudnia ręczne czyszczenie.
Strategia obronna — czyszczenie przy nawigacji
Najbardziej niezawodna strategia polega na aktywnym czyszczeniu zasobów strony przy opuszczaniu nawigacji. Poniżej bazowa klasa strony implementująca ten wzorzec:
public abstract class BazowaCzyszczacaPage : ContentPage
{
protected override void OnNavigatedFrom(NavigatedFromEventArgs args)
{
base.OnNavigatedFrom(args);
// Sprawdź, czy strona została zdjęta ze stosu nawigacji
if (!Navigation.NavigationStack.Contains(this))
{
WyczyscZasoby();
}
}
protected virtual void WyczyscZasoby()
{
// Wyczyść ItemsSource w CollectionView
var collectionViews = Content?.GetVisualTreeDescendants()
.OfType<CollectionView>();
if (collectionViews != null)
{
foreach (var cv in collectionViews)
{
cv.ItemsSource = null;
}
}
// Wyczyść BindingContext
BindingContext = null;
}
}
Wykrywanie wycieków za pomocą MemoryToolkit.Maui
Projekt MemoryToolkit.Maui to moim zdaniem nieocenione narzędzie do automatycznego wykrywania wycieków pamięci podczas developmentu. Dodaje efekt wizualny do kontrolek, pozwalający szybko zidentyfikować obiekty, które nie zostały zebrane przez GC:
// Instalacja: dotnet add package AdamE.MemoryToolkit.Maui
// W MauiProgram.cs (tylko Debug)
#if DEBUG
builder.UseLeakDetection(collectionTarget: new DebugLeakCollectionTarget());
#endif
Dzięki temu każda strona, która nie zostanie prawidłowo zwolniona z pamięci, wyświetli wyraźne ostrzeżenie wizualne. Zdecydowanie warto to mieć włączone od samego początku projektu — łatwiej zapobiegać wyciekom niż je potem tropić.
NativeAOT i trimming — mniejsze i szybsze aplikacje
NativeAOT (Native Ahead-of-Time) to jedna z najważniejszych funkcji wydajnościowych w nowoczesnym .NET. Zamiast kompilować kod IL do kodu natywnego w runtime (JIT), NativeAOT generuje natywny kod maszynowy już na etapie budowania aplikacji.
Korzyści NativeAOT
- Szybszy start — eliminacja kompilacji JIT oznacza, że aplikacja startuje praktycznie natychmiast. Testy pokazują nawet 2-krotne przyspieszenie czasu uruchomienia.
- Mniejszy rozmiar — w połączeniu z trimmingiem aplikacje NativeAOT mogą być nawet 2,5 razy mniejsze. W testach prostej aplikacji rozmiar spadł z 89,5 MiB do 37 MiB — to spora różnica.
- Niższe zużycie pamięci — brak narzutu JIT i mniejszy footprint pamięciowy runtime.
Włączanie NativeAOT w projekcie .NET MAUI
NativeAOT jest obecnie wspierane na iOS, Mac Catalyst i Windows. Android jeszcze nie oferuje pełnego wsparcia (choć to się zmienia). Oto jak włączyć NativeAOT w pliku projektu:
<!-- W pliku .csproj -->
<PropertyGroup Condition="'$(TargetFramework)' == 'net9.0-ios'">
<PublishAot>true</PublishAot>
</PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' == 'net9.0-maccatalyst'">
<PublishAot>true</PublishAot>
</PropertyGroup>
<!-- Pełny trimming — wymagany dla NativeAOT -->
<PropertyGroup>
<TrimMode>full</TrimMode>
</PropertyGroup>
Pełny trimming w .NET 10
W .NET 10 ostrzeżenia trimmera są domyślnie włączone. Wcześniej były wyciszane, bo sama biblioteka bazowa generowała ostrzeżenia, których nie dało się naprawić. W .NET 9 wszystkie te ostrzeżenia na iOS zostały rozwiązane, więc w .NET 10 aktywacja jest domyślna. To dobra wiadomość — zachęca do tworzenia bardziej trim-friendly aplikacji od samego początku.
<!-- Wyłączenie ostrzeżeń trimmera (niezalecane, ale możliwe) -->
<PropertyGroup>
<SuppressTrimAnalysisWarnings>true</SuppressTrimAnalysisWarnings>
</PropertyGroup>
Przygotowanie kodu na NativeAOT
Przejście na NativeAOT wymaga pewnych zmian w kodzie. Kluczowe zasady, o których trzeba pamiętać:
- Skompilowane bindowania są obowiązkowe — refleksyjne bindowania nie działają z NativeAOT
- Unikaj dynamicznej generacji kodu —
System.Reflection.Emitnie jest wspierane - Serializacja JSON — używaj generatorów źródeł (
JsonSerializerContext) zamiast refleksji:
// Generatory źródeł dla System.Text.Json — kompatybilne z NativeAOT
[JsonSerializable(typeof(List<Produkt>))]
[JsonSerializable(typeof(OdpowiedzApi<List<Produkt>>))]
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
public partial class AppJsonContext : JsonSerializerContext
{
}
// Użycie
var produkty = JsonSerializer.Deserialize(
jsonString,
AppJsonContext.Default.ListProdukt);
// Rejestracja w HttpClient
builder.Services.AddHttpClient<IApiService, ApiService>(client =>
{
client.BaseAddress = new Uri("https://api.example.com");
}).AddHttpMessageHandler<AuthHandler>();
Optymalizacja operacji asynchronicznych
Programowanie asynchroniczne jest kluczowe dla utrzymania responsywności UI. Ale (i to jest ważne „ale") nieprawidłowe użycie async/await potrafi spowodować więcej szkód niż pożytku.
Unikaj asynchronicznych konstruktorów
Konstruktory w C# nie mogą być asynchroniczne. Częstym antypattern jest wywoływanie .Result lub .Wait() w konstruktorze, co prowadzi do deadlocków i zablokowania wątku UI. Jeśli widzisz to w swoim kodzie — napraw to od razu:
// ŹLE — blokuje wątek UI
public class MojViewModel : ObservableObject
{
public MojViewModel(IApiService api)
{
// NIGDY TAK NIE RÓB!
var dane = api.PobierzDaneAsync().Result;
}
}
// DOBRZE — użyj zdarzenia cyklu życia
public partial class MojViewModel : ObservableObject
{
private readonly IApiService _api;
[ObservableProperty]
private bool _czyLadowanie = true;
public MojViewModel(IApiService api)
{
_api = api;
}
[RelayCommand]
public async Task InicjalizujAsync()
{
CzyLadowanie = true;
try
{
var dane = await _api.PobierzDaneAsync();
// Przetwarzaj dane...
}
finally
{
CzyLadowanie = false;
}
}
}
// W stronie — wywołaj inicjalizację w OnAppearing
public partial class MojaStrona : ContentPage
{
private readonly MojViewModel _vm;
private bool _zainicjalizowany = false;
public MojaStrona(MojViewModel vm)
{
InitializeComponent();
BindingContext = _vm = vm;
}
protected override async void OnAppearing()
{
base.OnAppearing();
if (!_zainicjalizowany)
{
await _vm.InicjalizujCommand.ExecuteAsync(null);
_zainicjalizowany = true;
}
}
}
ConfigureAwait(false) w kodzie bibliotecznym
W kodzie, który nie potrzebuje dostępu do kontekstu synchronizacji UI (serwisy, repozytoria), stosuj ConfigureAwait(false). Unikniesz niepotrzebnego przełączania kontekstu, a to przekłada się na mniejszy narzut przy dużej liczbie operacji asynchronicznych:
public class ApiService : IApiService
{
private readonly HttpClient _httpClient;
public async Task<List<Produkt>> PobierzProduktyAsync(
int strona, int rozmiar)
{
var response = await _httpClient
.GetAsync($"produkty?strona={strona}&rozmiar={rozmiar}")
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var json = await response.Content
.ReadAsStringAsync()
.ConfigureAwait(false);
return JsonSerializer.Deserialize(
json,
AppJsonContext.Default.ListProdukt) ?? new();
}
}
Pomiar czasu startu — mierz zanim optymalizujesz
Żeby zmierzyć czas startu aplikacji na wszystkich platformach w lekki sposób, możesz wykorzystać logowanie znaczników czasu w kluczowych punktach inicjalizacji. To prosta technika, ale daje zaskakująco dobre rezultaty:
public static class MauiProgram
{
private static readonly Stopwatch _stopwatch = Stopwatch.StartNew();
public static MauiApp CreateMauiApp()
{
ZalogujCzas("CreateMauiApp - start");
var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>();
ZalogujCzas("Po UseMauiApp");
// Rejestracja usług...
ZalogujCzas("Po rejestracji usług");
var app = builder.Build();
ZalogujCzas("Po Build()");
return app;
}
private static void ZalogujCzas(string etap)
{
Debug.WriteLine($"[STARTUP] {etap}: {_stopwatch.ElapsedMilliseconds}ms");
}
}
Dane z tych logów pozwalają precyzyjnie zidentyfikować, które etapy inicjalizacji zabierają najwięcej czasu. Dzięki temu możesz skupić wysiłek optymalizacyjny tam, gdzie przyniesie największy efekt — zamiast strzelać na oślep.
Diagnostyka wydajności w .NET 10
.NET 10 wprowadza kompleksową diagnostykę i śledzenie metryk wydajności dla aplikacji .NET MAUI, ze szczególnym naciskiem na monitorowanie wydajności layoutów. Architektura jest rozszerzalna, co oznacza, że w przyszłości pojawią się dodatkowe możliwości obserwacji i profilowania. Do tego dochodzą ulepszenia kompilatora JIT — lepsze inlining, dewirtualizacja metod, alokacja na stosie i optymalizacja pętli — które przekładają się na mierzalne redukcje zużycia pamięci i czasu pauz GC. Brzmi obiecująco i warto śledzić postępy w tym obszarze.
Podsumowanie — lista kontrolna optymalizacji
Na zakończenie zestawiłem kompletną listę kontrolną optymalizacji wydajności .NET MAUI. Traktuj ją jako punkt odniesienia przy każdym projekcie:
- Profiluj przed optymalizacją — używaj dotnet-trace, dotnet-gcdump i PerfView. Nigdy nie optymalizuj na podstawie przeczucia.
- Testuj na kompilacjach Release — wyniki Debug są niereprezentatywne ze względu na interpreter i brak AOT.
- Używaj Shell Navigation — strony ładowane na żądanie zamiast przy starcie.
- Stosuj skompilowane bindowania — dodawaj
x:DataTypewszędzie. W .NET 9+ kompilator ostrzega o brakujących skompilowanych bindowaniach. - Spłaszczaj hierarchie layoutów — preferuj Grid nad zagnieżdżone StackLayout.
- Implementuj inkrementalne ładowanie list — używaj
RemainingItemsThresholdw CollectionView. - Wypisuj się ze zdarzeń — subskrybuj w
OnAppearing, wypisuj wOnDisappearing. - Ręcznie wywołuj DisconnectHandler — .NET MAUI nie robi tego automatycznie.
- Minimalizuj rejestracje DI — używaj Lazy<T> dla ciężkich usług.
- Unikaj .Result i .Wait() — zawsze używaj async/await.
- Włącz NativeAOT (iOS/Mac/Windows) — do 2x szybszy start, 2,5x mniejszy rozmiar.
- Używaj pełnego trimmingu — w .NET 10 ostrzeżenia trimmera są domyślnie włączone.
- Przygotuj serializację JSON na NativeAOT — generatory źródeł zamiast refleksji.
- Używaj MemoryToolkit.Maui w trybie Debug — automatyczne wykrywanie wycieków pamięci.
- Mierz czas startu — dodaj logi w kluczowych punktach inicjalizacji i śledź regresje.
Optymalizacja wydajności to nie jednorazowa czynność, lecz ciągły proces. Każda nowa funkcja, każda biblioteka zewnętrzna i każda zmiana w modelu danych może wpłynąć na wydajność. Kluczem jest systematyczne profilowanie, świadomość najczęstszych pułapek i konsekwentne stosowanie sprawdzonych wzorców. Aplikacja .NET MAUI zbudowana z myślą o wydajności nie tylko działa szybciej — tworzy lepsze wrażenia dla użytkowników, co przekłada się na wyższe oceny w sklepach i mniej odinstalowań. A o to przecież w tym wszystkim chodzi.