Warum Performance in .NET MAUI entscheidend ist
Hand aufs Herz: Mobile Nutzer sind unglaublich ungeduldig. Studien zeigen, dass über 50 % der Anwender eine App deinstallieren, wenn sie länger als drei Sekunden zum Starten braucht. Drei Sekunden! In der Welt der plattformübergreifenden Entwicklung mit .NET MAUI kommt noch eine zusätzliche Herausforderung dazu — der gleiche Code muss auf Android, iOS, macOS und Windows performant laufen, obwohl jede Plattform ihre eigenen Rendering-Engines, Speicherverwaltungsmechanismen und Einschränkungen mitbringt.
Die gute Nachricht: Mit .NET 10 und den neuesten MAUI-Verbesserungen stehen uns richtig starke Werkzeuge zur Verfügung. Von XAML Source Generation über Compiled Bindings bis hin zu NativeAOT — es gibt viel Potenzial. In diesem Leitfaden gehen wir praxisnah durch, wie Sie die Performance Ihrer .NET MAUI-App systematisch optimieren können.
Startzeit-Optimierung: Der erste Eindruck zählt
Die Startzeit ist der kritischste Performance-Indikator. Punkt.
Nutzer erwarten eine nahezu sofortige Reaktion, und jede Millisekunde zählt. Bei .NET MAUI setzt sich die Startzeit aus mehreren Phasen zusammen: dem nativen Plattform-Start, der Runtime-Initialisierung, dem Aufbau des Dependency-Injection-Containers und dem Rendern der ersten Seite.
MauiProgram.cs schlank halten
Der häufigste Fehler, den ich in Projekten sehe, ist die Registrierung zu vieler Services beim Start. Jeder Service, den Sie in MauiProgram.cs registrieren, erhöht die Startzeit — besonders wenn Singleton-Instanzen sofort erstellt werden.
// ❌ Schlecht: Alle Services sofort als Singleton registrieren
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>();
// Diese Services werden SOFORT erstellt
builder.Services.AddSingleton(new DatabaseService());
builder.Services.AddSingleton(new AnalyticsService());
builder.Services.AddSingleton(new CacheService());
builder.Services.AddSingleton(new SyncService());
return builder.Build();
}
// ✅ Besser: Lazy-Registrierung und Transient wo möglich
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>();
// Services erst bei Bedarf erstellen
builder.Services.AddSingleton<IDatabaseService, DatabaseService>();
builder.Services.AddTransient<IAnalyticsService, AnalyticsService>();
builder.Services.AddSingleton<ICacheService, CacheService>();
// Lazy-Pattern für schwere Initialisierungen
builder.Services.AddSingleton<Lazy<ISyncService>>(sp =>
new Lazy<ISyncService>(() => sp.GetRequiredService<ISyncService>()));
builder.Services.AddSingleton<ISyncService, SyncService>();
return builder.Build();
}
Shell-Routen mit Lazy Loading
Wenn Sie Shell verwenden, sollten Routen so registriert werden, dass Seiten erst beim tatsächlichen Navigieren geladen werden. Vermeiden Sie es, alle Seiten als ShellContent-Elemente direkt im XAML zu definieren:
// In MauiProgram.cs — Seiten als Transient registrieren
builder.Services.AddTransient<ProduktListeSeite>();
builder.Services.AddTransient<ProduktDetailSeite>();
builder.Services.AddTransient<EinstellungenSeite>();
// Shell-Routen registrieren (werden erst bei Navigation instanziiert)
Routing.RegisterRoute("produktDetail", typeof(ProduktDetailSeite));
Routing.RegisterRoute("einstellungen", typeof(EinstellungenSeite));
<!-- ❌ Schlecht: Alle Tabs laden sofort ihre Seiten -->
<TabBar>
<Tab Title="Produkte">
<ShellContent ContentTemplate="{DataTemplate local:ProduktListeSeite}" />
</Tab>
<Tab Title="Einstellungen">
<ShellContent ContentTemplate="{DataTemplate local:EinstellungenSeite}" />
</Tab>
</TabBar>
<!-- ✅ Besser: ContentTemplate sorgt für Lazy Loading -->
<TabBar>
<Tab Title="Produkte" Icon="produkte.png">
<ShellContent
ContentTemplate="{DataTemplate local:ProduktListeSeite}"
Route="produkte" />
</Tab>
<Tab Title="Einstellungen" Icon="settings.png">
<ShellContent
ContentTemplate="{DataTemplate local:EinstellungenSeite}"
Route="einstellungen" />
</Tab>
</TabBar>
Der Schlüssel ist die Verwendung von ContentTemplate mit DataTemplate — die Seite wird erst erstellt, wenn der Nutzer den entsprechenden Tab auswählt. Bei Apps mit vielen Tabs kann das die initiale Startzeit erheblich drücken.
Splash-Screen sinnvoll einsetzen
Während Sie an der tatsächlichen Startzeit arbeiten, nutzen Sie den Splash-Screen als Wahrnehmungsoptimierung. Ein gut gestalteter Splash-Screen überbrückt die Ladezeit und gibt dem Nutzer das Gefühl einer schnelleren App:
<!-- In der .csproj-Datei -->
<MauiSplashScreen Include="Resources\Splash\splash.svg"
Color="#1a1a2e"
BaseSize="128,128" />
Aber Vorsicht: Verlängern Sie den Splash-Screen nicht künstlich! Manche Entwickler fügen absichtlich Verzögerungen ein — das ist kontraproduktiv und macht die User Experience schlimmer, nicht besser.
XAML Source Generation: Ein echter Gamechanger
Eine der bedeutendsten Neuerungen in .NET MAUI 10 ist die XAML Source Generation. Bisher wurde XAML zur Laufzeit interpretiert — der Parser las die XAML-Dateien, erstellte Objekte per Reflection und baute den visuellen Baum auf. Ehrlich gesagt war dieser Prozess langsam und fehleranfällig.
Mit XAML Source Generation wird der XAML-Code bereits zur Kompilierungszeit in optimierten C#-Code umgewandelt. Das Ergebnis: bis zu 25 % schnelleres UI-Rendering und frühzeitige Fehlererkennung beim Build.
XAML Source Generation aktivieren
Die Aktivierung ist denkbar einfach — fügen Sie einfach diese Eigenschaft in Ihre Projektdatei ein:
<PropertyGroup>
<TargetFrameworks>net10.0-android;net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
<OutputType>Exe</OutputType>
<UseMaui>true</UseMaui>
<!-- XAML Source Generation aktivieren -->
<MauiXamlInflator>SourceGen</MauiXamlInflator>
</PropertyGroup>
Was passiert hinter den Kulissen?
Wenn Source Generation aktiviert ist, erzeugt der Compiler für jede XAML-Datei eine entsprechende C#-Klasse mit generiertem Initialisierungscode. Anstatt XAML zur Laufzeit zu parsen, wird der visuelle Baum direkt in kompiliertem Code aufgebaut:
// Automatisch generierter Code (vereinfacht)
// Anstatt XAML zur Laufzeit zu interpretieren:
public partial class MainPage : ContentPage
{
private void InitializeComponent()
{
// Generierter Code — kein Reflection, kein XML-Parsing
var grid = new Grid();
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Star });
var label = new Label();
label.Text = "Willkommen";
label.FontSize = 24;
label.HorizontalOptions = LayoutOptions.Center;
Grid.SetRow(label, 0);
grid.Children.Add(label);
Content = grid;
}
}
Dieser Ansatz eliminiert den Overhead des XAML-Parsers vollständig. Besonders bei komplexen Seiten mit vielen Elementen ist der Unterschied deutlich spürbar — und die Performance ist konsistent zwischen Debug- und Release-Builds.
Einschränkungen beachten
Ein wichtiger Punkt: XAML Source Generation erfordert, dass alle Bindings kompiliert sind. String-basierte Bindings werden nicht unterstützt. Stellen Sie also sicher, dass Sie das x:DataType-Attribut konsequent verwenden:
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:MeineApp.ViewModels"
x:Class="MeineApp.Views.ProduktListeSeite"
x:DataType="vm:ProduktListeViewModel">
<!-- ✅ Compiled Binding — funktioniert mit Source Generation -->
<Label Text="{Binding Titel}" />
<!-- ❌ String-basiertes Binding — nicht kompatibel -->
<Label Text="{Binding Path=Titel, Source={x:Reference someElement}}" />
</ContentPage>
Compiled Bindings: Typsicherheit und Geschwindigkeit
Compiled Bindings sind eng mit XAML Source Generation verknüpft und gehören meiner Meinung nach zu den wirkungsvollsten Performance-Optimierungen in .NET MAUI. Klassische Bindings werden per Reflection aufgelöst — ein langsamer Prozess, bei dem zur Laufzeit nach Eigenschaften gesucht wird. Compiled Bindings hingegen werden bereits beim Build aufgelöst.
Compiled Bindings korrekt einrichten
Der Schlüssel ist das x:DataType-Attribut. Es teilt dem Compiler mit, welchen Typ der BindingContext hat, damit Bindings statisch aufgelöst werden können:
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:MeineApp.ViewModels"
xmlns:models="clr-namespace:MeineApp.Models"
x:Class="MeineApp.Views.ProduktListeSeite"
x:DataType="vm:ProduktListeViewModel">
<Grid RowDefinitions="Auto,*" Padding="16">
<!-- Compiled Binding auf ViewModel-Ebene -->
<Label Text="{Binding SeitenTitel}"
FontSize="24"
FontAttributes="Bold"
Grid.Row="0" />
<CollectionView ItemsSource="{Binding Produkte}"
Grid.Row="1">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:Produkt">
<!-- Innerhalb des DataTemplate wechselt der DataType -->
<VerticalStackLayout Padding="8">
<Label Text="{Binding Name}"
FontSize="18"
FontAttributes="Bold" />
<Label Text="{Binding Beschreibung}"
FontSize="14"
TextColor="Gray" />
<Label Text="{Binding Preis, StringFormat='{0:C}'}"
FontSize="16"
TextColor="Green" />
</VerticalStackLayout>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</Grid>
</ContentPage>
Performance-Vergleich: Compiled vs. Klassische Bindings
Der Unterschied ist messbar — und ziemlich beeindruckend:
- Klassische Bindings: Verwenden Reflection zur Laufzeit, um Eigenschaften aufzulösen. Bei Listen mit Hunderten von Items kann das zu spürbaren Verzögerungen beim Scrollen führen.
- Compiled Bindings: Generieren direkten Property-Zugriff ohne Reflection. Das bedeutet eine Geschwindigkeitssteigerung von 8–10x bei der Binding-Auflösung.
Obendrein bieten Compiled Bindings den Vorteil der Typsicherheit: Tippfehler in Binding-Pfaden werden als Build-Fehler gemeldet, anstatt zur Laufzeit stillschweigend zu versagen. Das allein ist schon Gold wert.
Migration bestehender Bindings
Falls Sie ein bestehendes Projekt migrieren, gehen Sie am besten schrittweise vor. Aktivieren Sie zunächst Warnungen für nicht-kompilierte Bindings:
<PropertyGroup>
<!-- Warnung bei String-basierten Bindings -->
<MauiStrictXamlCompilation>true</MauiStrictXamlCompilation>
</PropertyGroup>
Dann können Sie Datei für Datei das x:DataType-Attribut hinzufügen und die Bindings korrigieren, die der Compiler bemängelt.
NativeAOT: Maximale Startgeschwindigkeit
NativeAOT (Ahead-of-Time Compilation) kompiliert Ihren .NET-Code direkt in nativen Maschinencode — ohne JIT-Compiler zur Laufzeit. Das Ergebnis? Dramatisch kürzere Startzeiten und geringerer Speicherverbrauch.
NativeAOT aktivieren
Derzeit ist NativeAOT für .NET MAUI auf iOS, Mac Catalyst und Windows verfügbar. Android-Unterstützung steht noch aus, ist aber auf der Roadmap:
<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0-ios'">
<PublishAot>true</PublishAot>
<!-- Optionale Optimierungen -->
<OptimizationPreference>Size</OptimizationPreference>
<StripSymbols>true</StripSymbols>
</PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)' == 'net10.0-maccatalyst'">
<PublishAot>true</PublishAot>
</PropertyGroup>
Voraussetzungen für NativeAOT
NativeAOT setzt voraus, dass Ihr Code trimming-kompatibel ist. Was heißt das konkret?
- Keine Reflection: Vermeiden Sie
Type.GetType(),Activator.CreateInstance()und ähnliche reflektionsbasierte Aufrufe. - Compiled Bindings sind Pflicht: String-basierte Bindings funktionieren nicht mit NativeAOT, da sie auf Reflection basieren.
- Source Generators bevorzugen: Verwenden Sie Source Generators für JSON-Serialisierung (
System.Text.Json), Dependency Injection und andere Bereiche, die traditionell Reflection nutzen.
// ❌ Funktioniert NICHT mit NativeAOT
var json = JsonSerializer.Deserialize<Produkt>(jsonString);
// ✅ Funktioniert mit NativeAOT — Source Generator
[JsonSerializable(typeof(Produkt))]
[JsonSerializable(typeof(List<Produkt>))]
internal partial class AppJsonContext : JsonSerializerContext { }
var json = JsonSerializer.Deserialize(
jsonString,
AppJsonContext.Default.Produkt);
Messbarer Unterschied
In der Praxis zeigen sich deutliche Verbesserungen:
- Startzeit auf iOS: Typischerweise 30–50 % schneller als mit der Standard-Runtime.
- App-Größe: Kann um 20–40 % schrumpfen, da nur tatsächlich verwendeter Code im finalen Binary landet.
- Speicherverbrauch: Weniger Overhead, weil kein JIT-Compiler im Speicher gehalten werden muss.
CollectionView-Performance: Große Listen flüssig darstellen
Okay, jetzt wird's spannend. Die Darstellung großer Listen ist eine der anspruchsvollsten Performance-Aufgaben in mobilen Apps. CollectionView in .NET MAUI bietet eingebaute Virtualisierung, aber es gibt so einige Fallstricke, die die Performance komplett ruinieren können.
Virtualisierung richtig nutzen
Die UI-Virtualisierung funktioniert nur, wenn CollectionView die Größe jedes Elements vorhersagen kann. Die häufigste Ursache für schlechte Scroll-Performance? Das Verschachteln von CollectionView in einem ScrollView oder StackLayout:
<!-- ❌ FALSCH: Zerstört die Virtualisierung -->
<ScrollView>
<VerticalStackLayout>
<Label Text="Überschrift" />
<CollectionView ItemsSource="{Binding Produkte}" />
</VerticalStackLayout>
</ScrollView>
<!-- ✅ RICHTIG: CollectionView in Grid mit definierten Zeilen -->
<Grid RowDefinitions="Auto,*">
<Label Text="Überschrift" Grid.Row="0" />
<CollectionView ItemsSource="{Binding Produkte}"
Grid.Row="1" />
</Grid>
Wenn CollectionView in einem ScrollView oder VerticalStackLayout sitzt, versucht das System alle Items auf einmal zu rendern, weil die CollectionView unbegrenzte Höhe erhält. Das Grid mit der *-Zeile gibt der CollectionView eine fixe Höhe — und schon funktioniert die Virtualisierung korrekt. Klingt simpel, ist aber einer der häufigsten Fehler überhaupt.
ItemTemplate optimieren
Halten Sie ItemTemplates so flach und einfach wie möglich. Jede zusätzliche Layout-Verschachtelung verlangsamt das Rendering spürbar:
<!-- ❌ Zu tief verschachtelt -->
<DataTemplate x:DataType="models:Produkt">
<Frame HasShadow="True" CornerRadius="8" Padding="12">
<VerticalStackLayout>
<HorizontalStackLayout>
<VerticalStackLayout>
<Label Text="{Binding Name}" />
<Label Text="{Binding Kategorie}" />
</VerticalStackLayout>
<Label Text="{Binding Preis}" />
</HorizontalStackLayout>
<Label Text="{Binding Beschreibung}" />
</VerticalStackLayout>
</Frame>
</DataTemplate>
<!-- ✅ Flaches Layout mit Grid -->
<DataTemplate x:DataType="models:Produkt">
<Border StrokeShape="RoundRectangle 8"
Stroke="LightGray"
Padding="12"
Margin="0,4">
<Grid ColumnDefinitions="*,Auto"
RowDefinitions="Auto,Auto">
<Label Text="{Binding Name}"
FontSize="16"
FontAttributes="Bold"
Grid.Row="0" Grid.Column="0" />
<Label Text="{Binding Preis, StringFormat='{0:C}'}"
FontSize="16"
Grid.Row="0" Grid.Column="1" />
<Label Text="{Binding Beschreibung}"
FontSize="13"
TextColor="Gray"
Grid.Row="1" Grid.Column="0"
Grid.ColumnSpan="2"
MaxLines="2"
LineBreakMode="TailTruncation" />
</Grid>
</Border>
</DataTemplate>
Inkrementelles Laden großer Datenmengen
Laden Sie nicht alle Daten auf einmal — das ist ein Rezept für Ruckler und hohen Speicherverbrauch. Implementieren Sie stattdessen inkrementelles Laden mit RemainingItemsThreshold:
<CollectionView ItemsSource="{Binding Produkte}"
RemainingItemsThreshold="5"
RemainingItemsThresholdReachedCommand="{Binding WeitereProdukteLadenCommand}">
<CollectionView.Footer>
<ActivityIndicator IsRunning="{Binding LaedetWeitere}"
IsVisible="{Binding LaedetWeitere}"
HorizontalOptions="Center"
Margin="0,16" />
</CollectionView.Footer>
</CollectionView>
public partial class ProduktListeViewModel : ObservableObject
{
private const int SeitenGroesse = 20;
private int _aktuelleSeite = 0;
private bool _alleGeladen = false;
[ObservableProperty]
private bool _laedetWeitere;
[ObservableProperty]
private ObservableCollection<Produkt> _produkte = new();
[RelayCommand]
private async Task WeitereProdukteLadenAsync()
{
if (_alleGeladen || LaedetWeitere)
return;
try
{
LaedetWeitere = true;
_aktuelleSeite++;
var neueProdukte = await _produktService
.HoleProdukteAsync(_aktuelleSeite, SeitenGroesse);
if (neueProdukte.Count() < SeitenGroesse)
_alleGeladen = true;
foreach (var produkt in neueProdukte)
Produkte.Add(produkt);
}
finally
{
LaedetWeitere = false;
}
}
}
Bilder effizient laden
Bilder sind oft der größte Performance-Killer in Listen. Nutzen Sie unbedingt asynchrones Laden und Caching:
<!-- Image-Caching über das FFImageLoading-Nachfolgeprojekt -->
<Image Source="{Binding BildUrl}"
Aspect="AspectFill"
HeightRequest="120"
WidthRequest="120">
<Image.Behaviors>
<toolkit:IconTintColorBehavior TintColor="Transparent" />
</Image.Behaviors>
</Image>
Alternativ können Sie einen eigenen Bild-Cache implementieren, der Bilder lokal zwischenspeichert und nur bei Bedarf vom Server lädt. Das CommunityToolkit.Maui bietet hierfür einige nützliche Helfer.
Speicherverwaltung und Memory Leaks vermeiden
Memory Leaks — das Thema, das niemand mag, aber alle kennen sollten. Sie können zu Abstürzen, Rucklern und erhöhtem Akkuverbrauch führen. Die plattformübergreifende Natur von MAUI macht die Sache noch komplexer, weil native Plattform-Objekte und verwaltete .NET-Objekte zusammenarbeiten müssen.
Event-Handler korrekt entfernen
Die häufigste Ursache für Memory Leaks in MAUI-Apps sind nicht entfernte Event-Handler. Das Problem: Wenn ein Objekt ein Event eines anderen Objekts abonniert, hält der Event-Publisher eine Referenz auf den Subscriber — und verhindert damit die Garbage Collection.
public partial class ProduktDetailSeite : ContentPage
{
private readonly IMessagingService _messaging;
public ProduktDetailSeite(IMessagingService messaging)
{
InitializeComponent();
_messaging = messaging;
}
protected override void OnAppearing()
{
base.OnAppearing();
// Event-Handler registrieren
_messaging.ProduktAktualisiert += OnProduktAktualisiert;
DeviceDisplay.MainDisplayInfoChanged += OnDisplayInfoChanged;
}
protected override void OnDisappearing()
{
base.OnDisappearing();
// ✅ WICHTIG: Event-Handler entfernen!
_messaging.ProduktAktualisiert -= OnProduktAktualisiert;
DeviceDisplay.MainDisplayInfoChanged -= OnDisplayInfoChanged;
}
private void OnProduktAktualisiert(object? sender, ProduktEventArgs e)
{
// Aktualisierungslogik
}
private void OnDisplayInfoChanged(object? sender, DisplayInfoChangedEventArgs e)
{
// Display-Anpassungen
}
}
WeakEventManager für langlebige Subscriptions
Für Szenarien, in denen das manuelle An- und Abmelden unpraktisch ist, gibt's den WeakEventManager:
public class ProduktService : IProduktService
{
private readonly WeakEventManager _weakEventManager = new();
public event EventHandler<ProduktEventArgs> ProduktAktualisiert
{
add => _weakEventManager.AddEventHandler(value);
remove => _weakEventManager.RemoveEventHandler(value);
}
protected void OnProduktAktualisiert(ProduktEventArgs args)
{
_weakEventManager.HandleEvent(this, args, nameof(ProduktAktualisiert));
}
}
Der WeakEventManager verwendet schwache Referenzen. Subscriber können also auch dann vom Garbage Collector freigegeben werden, wenn sie das Event nicht explizit abbestellen. Ziemlich praktisch.
IDisposable konsequent implementieren
Für Services und ViewModels, die native Ressourcen oder Timer halten, sollten Sie das IDisposable-Pattern implementieren:
public class StandortViewModel : ObservableObject, IDisposable
{
private readonly CancellationTokenSource _cts = new();
private IDisposable? _standortSubscription;
private bool _disposed;
public async Task StartStandortVerfolgungAsync()
{
_standortSubscription = Geolocation.Default
.StartListening(new GeolocationListeningRequest
{
MinimumTime = TimeSpan.FromSeconds(10),
MinimumDistance = 50
});
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_cts.Cancel();
_cts.Dispose();
_standortSubscription?.Dispose();
}
}
Profiling-Werkzeuge: Messen statt Raten
Eine der goldenen Regeln der Performance-Optimierung: Niemals optimieren, ohne vorher zu messen. Die Intuition trügt erstaunlich oft — der tatsächliche Engpass liegt selten dort, wo man ihn vermutet.
dotnet-trace für CPU-Profiling
dotnet-trace ist das wichtigste Werkzeug zur Analyse der CPU-Nutzung. Es funktioniert auf allen Plattformen, einschließlich Android und iOS:
# dotnet-trace installieren
dotnet tool install --global dotnet-trace
# Trace auf Android starten
dotnet-trace collect --diagnostic-port /tmp/maui-app-port \
--providers Microsoft-DotNETRuntimeMonoProfiler:0xC900001:4
# Trace in Speedscope-Format konvertieren
dotnet-trace convert trace.nettrace --format speedscope
Das erzeugte Trace können Sie in Speedscope oder Visual Studio analysieren, um Hot Paths zu identifizieren — also die Codepfade, die am meisten CPU-Zeit verbrauchen.
GCDump für Speicheranalyse
Für die Analyse von Speicherproblemen und Memory Leaks nutzen Sie GCDump oder dotnet-gcdump:
# GC-Dump erstellen
dotnet-gcdump collect -p <process-id>
# Alternativ über .NET Meteor in VS Code
# Profiling Mode: GCDump — findet Memory Leaks automatisch
In Visual Studio können Sie auch den integrierten Memory Profiler verwenden. Achten Sie besonders auf:
- Steigende Objektzahlen: Wenn die Anzahl bestimmter Objekte kontinuierlich steigt, ohne je zu sinken, haben Sie wahrscheinlich ein Leak.
- Große verwaltete Heaps: Ein ungewöhnlich großer verwalteter Heap kann auf nicht freigegebene Ressourcen hinweisen.
- GC-Pausenzeiten: Häufige oder lange Garbage-Collection-Pausen können zu sichtbaren UI-Rucklern führen.
.NET Meteor für VS Code
Wer mit VS Code entwickelt, sollte sich .NET Meteor anschauen. Ab Version 4.0 bietet es integrierte .NET Diagnostics-Tools mit zwei Profiling-Modi: Trace für Performance-Engpässe und GCDump für Memory Leaks. Besonders praktisch, wenn Sie kein Visual Studio nutzen.
Layout-Performance: Die unsichtbare Bremse
Layout-Berechnungen sind einer der am häufigsten übersehenen Performance-Killer. Jedes Mal, wenn sich die Größe oder Position eines Elements ändert, muss das Layout-System den gesamten visuellen Baum neu berechnen. Bei komplexen Layouts kann diese Kaskade extrem teuer werden.
Layout-Verschachtelungen minimieren
Jede zusätzliche Verschachtelungsebene multipliziert die Layout-Berechnungen. Die Lösung: Grid statt verschachtelte StackLayouts:
<!-- ❌ 4 Ebenen Layout-Verschachtelung -->
<VerticalStackLayout>
<HorizontalStackLayout>
<VerticalStackLayout>
<Label Text="Name" />
<Label Text="Beschreibung" />
</VerticalStackLayout>
<VerticalStackLayout>
<Label Text="Preis" />
<Label Text="Verfügbarkeit" />
</VerticalStackLayout>
</HorizontalStackLayout>
</VerticalStackLayout>
<!-- ✅ Flaches Grid — eine Ebene -->
<Grid ColumnDefinitions="*,Auto"
RowDefinitions="Auto,Auto">
<Label Text="Name"
Grid.Row="0" Grid.Column="0" />
<Label Text="Preis"
Grid.Row="0" Grid.Column="1" />
<Label Text="Beschreibung"
Grid.Row="1" Grid.Column="0" />
<Label Text="Verfügbarkeit"
Grid.Row="1" Grid.Column="1" />
</Grid>
AbsoluteLayout für Overlays
Für Overlay-Szenarien wie Lade-Indikatoren oder Benachrichtigungen verwenden Sie AbsoluteLayout — es arbeitet ohne aufwendige Layout-Berechnungen:
<AbsoluteLayout>
<!-- Hauptinhalt -->
<Grid AbsoluteLayout.LayoutBounds="0,0,1,1"
AbsoluteLayout.LayoutFlags="All">
<!-- Seiteninhalt hier -->
</Grid>
<!-- Lade-Overlay -->
<Grid AbsoluteLayout.LayoutBounds="0,0,1,1"
AbsoluteLayout.LayoutFlags="All"
BackgroundColor="#80000000"
IsVisible="{Binding IstLaden}">
<ActivityIndicator IsRunning="True"
Color="White"
HorizontalOptions="Center"
VerticalOptions="Center" />
</Grid>
</AbsoluteLayout>
Netzwerk-Performance und Caching
Netzwerkanfragen sind oft der langsamste Teil einer mobilen App. Aber durch intelligentes Caching und optimierte HTTP-Kommunikation können Sie die wahrgenommene Performance drastisch verbessern.
HttpClient richtig konfigurieren
Verwenden Sie IHttpClientFactory anstelle von manuell erstellten HttpClient-Instanzen. Das vermeidet Socket-Exhaustion und nutzt Connection Pooling:
// In MauiProgram.cs
builder.Services.AddHttpClient<IProduktService, ProduktService>(client =>
{
client.BaseAddress = new Uri("https://api.meineapp.de/v1/");
client.Timeout = TimeSpan.FromSeconds(15);
client.DefaultRequestHeaders.Add("Accept", "application/json");
})
.ConfigurePrimaryHttpMessageHandler(() =>
{
// Plattform-nativen Handler verwenden für bessere Performance
#if ANDROID
return new AndroidMessageHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
};
#elif IOS
return new NSUrlSessionHandler
{
AllowAutoRedirect = true
};
#else
return new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
};
#endif
});
Speicher-Cache für API-Antworten
Ein einfacher In-Memory-Cache kann wiederholte API-Aufrufe vermeiden und die App deutlich reaktionsschneller machen:
public class CachedProduktService : IProduktService
{
private readonly HttpClient _httpClient;
private readonly IMemoryCache _cache;
private static readonly TimeSpan CacheDauer = TimeSpan.FromMinutes(5);
public CachedProduktService(HttpClient httpClient, IMemoryCache cache)
{
_httpClient = httpClient;
_cache = cache;
}
public async Task<IEnumerable<Produkt>> HoleProdukteAsync()
{
const string cacheKey = "produkte_liste";
if (_cache.TryGetValue(cacheKey, out IEnumerable<Produkt>? cachedResult)
&& cachedResult is not null)
{
return cachedResult;
}
var response = await _httpClient.GetAsync("produkte");
response.EnsureSuccessStatusCode();
var produkte = await response.Content
.ReadFromJsonAsync(AppJsonContext.Default.ListProdukt);
if (produkte is not null)
{
_cache.Set(cacheKey, produkte, CacheDauer);
}
return produkte ?? Enumerable.Empty<Produkt>();
}
}
Asynchrone Programmierung: Den UI-Thread freihalten
Eine der wichtigsten Regeln für flüssige mobile Apps: Blockieren Sie niemals den UI-Thread. Jede Operation, die länger als 16 Millisekunden dauert (das ist die Zeit für ein einzelnes Frame bei 60 FPS), führt zu sichtbaren Rucklern. In .NET MAUI heißt das: Alle I/O-Operationen, Datenbankzugriffe und Berechnungen müssen asynchron laufen.
ConfigureAwait richtig einsetzen
In einer MAUI-App ist der Synchronisationskontext wichtig. Standardmäßig kehrt await zum aufrufenden Thread zurück — in ViewModels ist das der UI-Thread. Für Service-Klassen, die keinen UI-Zugriff brauchen, können Sie ConfigureAwait(false) verwenden, um den Thread-Pool effizienter zu nutzen:
// In einem Service — kein UI-Zugriff nötig
public class ProduktRepository
{
private readonly SQLiteAsyncConnection _db;
public async Task<List<Produkt>> HoleAlleAsync()
{
// ConfigureAwait(false) — kehrt nicht zum UI-Thread zurück
var ergebnis = await _db.Table<Produkt>()
.ToListAsync()
.ConfigureAwait(false);
// Weitere Verarbeitung auf dem Thread-Pool
return ergebnis
.Where(p => p.IstAktiv)
.OrderBy(p => p.Name)
.ToList();
}
}
// Im ViewModel — UI-Zugriff nötig, KEIN ConfigureAwait(false)
public partial class ProduktListeViewModel : ObservableObject
{
[RelayCommand]
private async Task ProdukteladenAsync()
{
IstLaden = true; // UI-Property — braucht UI-Thread
var produkte = await _repository.HoleAlleAsync();
// Hier sind wir wieder auf dem UI-Thread
Produkte = new ObservableCollection<Produkt>(produkte);
IstLaden = false;
}
}
Parallelisierung bei mehreren API-Aufrufen
Wenn Sie beim Seitenaufbau mehrere unabhängige Datenquellen laden müssen, nutzen Sie Task.WhenAll statt sequentieller Aufrufe. Der Unterschied ist enorm:
// ❌ Sequentiell — langsam
[RelayCommand]
private async Task DatenLadenAsync()
{
var produkte = await _produktService.HoleProdukteAsync(); // 500ms
var kategorien = await _kategorieService.HoleAlleAsync(); // 300ms
var benutzer = await _benutzerService.HoleProfilAsync(); // 200ms
// Gesamt: ~1000ms
}
// ✅ Parallel — schnell
[RelayCommand]
private async Task DatenLadenAsync()
{
var produkteTask = _produktService.HoleProdukteAsync();
var kategorienTask = _kategorieService.HoleAlleAsync();
var benutzerTask = _benutzerService.HoleProfilAsync();
await Task.WhenAll(produkteTask, kategorienTask, benutzerTask);
// Gesamt: ~500ms (Maximum der drei Einzelzeiten)
Produkte = new ObservableCollection<Produkt>(produkteTask.Result);
Kategorien = new ObservableCollection<Kategorie>(kategorienTask.Result);
Benutzer = benutzerTask.Result;
}
Debouncing für Sucheingaben
Bei Echtzeit-Suchen sollten Sie nicht bei jedem Tastendruck eine API-Anfrage feuern. Implementieren Sie stattdessen ein Debouncing-Muster, das erst nach einer kurzen Pause den Suchaufruf startet:
public partial class SuchViewModel : ObservableObject
{
private CancellationTokenSource? _suchCts;
[ObservableProperty]
private string _suchBegriff = string.Empty;
partial void OnSuchBegriffChanged(string value)
{
// Vorherige Suche abbrechen
_suchCts?.Cancel();
_suchCts = new CancellationTokenSource();
_ = SucheNachVerzoegerungAsync(value, _suchCts.Token);
}
private async Task SucheNachVerzoegerungAsync(
string suchBegriff, CancellationToken ct)
{
try
{
// 300ms warten — wird abgebrochen, wenn
// der Nutzer weitertippt
await Task.Delay(300, ct);
if (string.IsNullOrWhiteSpace(suchBegriff))
{
Ergebnisse.Clear();
return;
}
var ergebnisse = await _suchService
.SucheAsync(suchBegriff, ct);
Ergebnisse = new ObservableCollection<Produkt>(ergebnisse);
}
catch (OperationCanceledException)
{
// Erwartet — Suche wurde durch neue Eingabe abgebrochen
}
}
}
Plattformspezifische Optimierungen
Obwohl .NET MAUI plattformübergreifend arbeitet, gibt es Situationen, in denen plattformspezifische Optimierungen einfach notwendig sind. Jede Plattform hat ihre eigenen Stärken und Schwächen.
Android: Startup-Tracing und AOT-Profile
Auf Android können Sie ein Startup-Tracing-Profil erstellen, das die häufig genutzten Methoden beim App-Start vorselektiert und optimiert. Das verbessert die Kaltstartzeit erheblich:
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<!-- Android-spezifische Optimierungen -->
<AndroidEnableProfiledAot>true</AndroidEnableProfiledAot>
<AndroidStripILAfterAOT>true</AndroidStripILAfterAOT>
<EnableLLVM>true</EnableLLVM>
<RunAOTCompilation>true</RunAOTCompilation>
</PropertyGroup>
Achten Sie auf Android auch besonders auf die Größe Ihrer APK/AAB-Dateien. Aktivieren Sie Linking, damit nicht benötigte Assemblies entfernt werden:
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<!-- Linking aktivieren -->
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>full</TrimMode>
</PropertyGroup>
iOS: Reduzierte Animationskomplexität
Auf iOS können aufwendige Animationen (besonders auf älteren Geräten) zu Performance-Problemen führen. Respektieren Sie die Systemeinstellung für reduzierte Bewegung und bieten Sie einfachere Animationen als Alternative:
public static class AnimationsHelper
{
public static async Task AnimiereEinblendungAsync(VisualElement element)
{
// Systemeinstellung für reduzierte Bewegung prüfen
if (DeviceInfo.Current.Platform == DevicePlatform.iOS)
{
// Auf iOS: Barrierefreiheitseinstellung respektieren
var reduzierteBewegung = UIKit.UIAccessibility
.IsReduceMotionEnabled;
if (reduzierteBewegung)
{
// Einfaches Einblenden ohne Bewegung
element.Opacity = 0;
await element.FadeTo(1, 250);
return;
}
}
// Standardanimation mit Bewegung
element.Opacity = 0;
element.TranslationY = 50;
await Task.WhenAll(
element.FadeTo(1, 400, Easing.CubicOut),
element.TranslateTo(0, 0, 400, Easing.CubicOut)
);
}
}
Windows: Hardware-Beschleunigung nutzen
Auf Windows verbessern Sie die Rendering-Performance, indem Sie sicherstellen, dass die Hardware-Beschleunigung aktiv ist und Sie für hochauflösende Displays optimieren. Vermeiden Sie Software-Rendering durch vorsichtigen Einsatz komplexer visueller Effekte und aktivieren Sie Bitmap-Caching für statische Inhalte.
Trimming und App-Größe optimieren
Die Größe Ihrer App beeinflusst nicht nur die Download-Zeit im App Store, sondern auch Startzeit und Speicherverbrauch. Durch Trimming lassen sich .NET MAUI-Apps erheblich verkleinern, indem nicht verwendeter Code aus dem finalen Binary entfernt wird.
Trimming korrekt konfigurieren
Aktivieren Sie Full Trimming für Release-Builds:
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>full</TrimMode>
<!-- Warnungen für nicht-trimbare Muster aktivieren -->
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
<!-- Interpreter für Debug-Zwecke deaktivieren -->
<UseInterpreter>false</UseInterpreter>
</PropertyGroup>
Wichtig: Full Trimming ist aggressiver als Standard-Trimming. Es entfernt nicht nur ungenutzte Assemblies, sondern auch einzelne Typen und Methoden, die nicht statisch erreichbar sind. Das kann zu Laufzeitfehlern führen, wenn Ihr Code Reflection nutzt — nehmen Sie Trimming-Warnungen also ernst.
Nicht benötigte Plattformen ausschließen
Wenn Ihre App nicht alle Plattformen unterstützen muss, entfernen Sie die entsprechenden Target Frameworks. Klingt offensichtlich, wird aber oft vergessen. Jedes entfernte Target reduziert die Build-Zeit und vermeidet unnötige Abhängigkeiten:
<PropertyGroup>
<!-- Nur die benötigten Plattformen -->
<TargetFrameworks>net10.0-android;net10.0-ios</TargetFrameworks>
<!-- Statt aller Plattformen:
<TargetFrameworks>net10.0-android;net10.0-ios;net10.0-maccatalyst;net10.0-windows10.0.19041.0</TargetFrameworks>
-->
</PropertyGroup>
Ressourcen-Optimierung
Bilder und andere Ressourcen machen oft den größten Anteil an der App-Größe aus. Hier ein paar bewährte Ansätze:
- SVG statt PNG: Verwenden Sie SVG-Grafiken wo möglich — skalierbar und deutlich kleiner als Bitmap-Bilder in verschiedenen Auflösungen.
- Bildkompression: Komprimieren Sie alle Bitmap-Bilder vor dem Einbetten. Tools wie TinyPNG oder ImageOptim können die Dateigröße um 50–80 % reduzieren, ohne sichtbaren Qualitätsverlust.
- Schriftarten prüfen: Eingebettete Schriftarten können mehrere Megabyte groß sein. Nutzen Sie Subset-Schriftarten, die nur die tatsächlich benötigten Zeichen enthalten.
Performance-Checkliste für .NET MAUI-Projekte
Zum Abschluss eine praktische Checkliste, die Sie bei jedem Projekt durchgehen sollten:
- XAML Source Generation aktivieren: Setzen Sie
<MauiXamlInflator>SourceGen</MauiXamlInflator>in der Projektdatei. - Compiled Bindings durchgehend verwenden: Fügen Sie
x:DataTypein alle XAML-Dateien ein und eliminieren Sie String-basierte Bindings. - NativeAOT evaluieren: Prüfen Sie, ob Ihr Code trimming-kompatibel ist, und aktivieren Sie NativeAOT für iOS und Mac Catalyst.
- CollectionView korrekt einbetten: Verwenden Sie Grid mit
*-Zeilen statt ScrollView/StackLayout-Verschachtelungen. - ItemTemplates flach halten: Grid statt verschachtelte StackLayouts in DataTemplates.
- Inkrementelles Laden implementieren:
RemainingItemsThresholdfür große Listen nutzen. - Event-Handler entfernen: Alle Event-Subscriptions in
OnDisappearingbereinigen. - DI-Registrierungen überprüfen: Richtige Lifetimes (Singleton, Transient, Scoped) und Lazy-Loading für schwere Services.
- HttpClient über IHttpClientFactory: Socket-Exhaustion vermeiden und plattform-native Handler nutzen.
- Profiling vor Optimierung: Erst messen mit dotnet-trace und GCDump, dann optimieren.
Fazit
Performance-Optimierung in .NET MAUI ist kein einmaliger Vorgang, sondern ein kontinuierlicher Prozess. Aber die gute Nachricht: Mit den Werkzeugen aus .NET 10 — XAML Source Generation, Compiled Bindings und NativeAOT — haben Sie mächtige Hebel zur Hand.
Mein Tipp: Fangen Sie mit den einfachsten Maßnahmen an. Aktivieren Sie XAML Source Generation, stellen Sie auf Compiled Bindings um und überprüfen Sie Ihre CollectionView-Layouts. Allein diese drei Schritte können einen riesigen Unterschied machen. Danach nutzen Sie dotnet-trace und GCDump, um die verbleibenden Engpässe aufzuspüren.
Und denken Sie dran: Die beste Optimierung ist die, die Sie messen können. Setzen Sie Performance-Benchmarks, tracken Sie Ihre Startzeiten und reagieren Sie proaktiv auf Regressionen. Wer Performance-Tests in die CI/CD-Pipeline integriert, kann sicher sein, dass neue Features keine Regressionen einführen.
Die plattformübergreifende Entwicklung mit .NET MAUI muss keinen Performance-Kompromiss bedeuten. Mit den hier vorgestellten Techniken können Sie Apps bauen, die sich wie native Anwendungen anfühlen. Und Ihre Nutzer werden es Ihnen danken — mit besseren Bewertungen und höherer Retention.