はじめに:.NET MAUIアプリのパフォーマンス、なぜ妥協できないのか
正直なところ、「動くアプリ」と「速いアプリ」の間にある差は、思っている以上に大きいです。Googleの調査によると、モバイルアプリの読み込みに3秒以上かかると53%のユーザーが離脱するとされています。半分以上ですよ。さらにアプリストアのレビューを覗いてみると一目瞭然なんですが、パフォーマンスへのネガティブな評価は星の数に直結して、ダウンロード数にも深刻な影響を与えます。
.NET MAUI(Multi-platform App UI)は、Android、iOS、Windows、macOSを単一のコードベースで開発できる強力なフレームワークです。ただ、クロスプラットフォームであるがゆえに、パフォーマンスの最適化を怠ると「全プラットフォームで均等に遅い」という残念な状況に陥りかねません。
この記事では、.NET MAUIアプリのパフォーマンスを徹底的に改善するための実践テクニックを解説します。起動時間の短縮、UIレンダリングの最適化、メモリ管理、Native AOTとトリミング、画像キャッシュ戦略、プロファイリングツールの活用まで、幅広くカバーしていきます。.NET 9で導入された最新の最適化機能も含め、すぐ使えるコード例を交えながら見ていきましょう。
起動時間の最適化:ユーザーの第一印象を左右する最重要指標
アプリの起動時間は、ユーザーが最初に感じるパフォーマンス指標です。
ここが遅いと、どれだけ機能が充実していてもユーザーの信頼を失います。筆者も過去にリリースしたアプリで起動に4秒以上かかり、レビューでボロボロに叩かれた経験があります。では、.NET MAUIアプリの起動を高速化するためのテクニックを見ていきましょう。
遅延初期化による起動負荷の分散
すべてのサービスやViewModelをアプリ起動時に一括で初期化するのは、起動時間を悪化させる典型的なパターンです。必要になるまで初期化を遅延させることで、体感速度を大幅に改善できます。
// MauiProgram.cs — 起動時に必要なものだけ即座に登録
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>();
// 起動時に必須のサービスのみSingleton登録
builder.Services.AddSingleton<IAuthService, AuthService>();
builder.Services.AddSingleton<INavigationService, NavigationService>();
// 起動時に不要なサービスはTransientまたはLazy登録
builder.Services.AddTransient<IAnalyticsService, AnalyticsService>();
builder.Services.AddSingleton<Lazy<IReportingService>>(sp =>
new Lazy<IReportingService>(() =>
new ReportingService(sp.GetRequiredService<IApiClient>())));
// ページはTransientで登録(使用時に生成)
builder.Services.AddTransient<MainPage>();
builder.Services.AddTransient<SettingsPage>();
return builder.Build();
}
}
ポイントは、Lazy<T>を使うことで「本当に必要になった瞬間」まで重いサービスの生成を先延ばしにできるところです。
スプラッシュスクリーン中の非同期初期化
バックグラウンドで重い初期化処理を行いつつ、ユーザーにはスプラッシュスクリーンを表示する——このパターンはかなり効果的です。ユーザーからすると「待たされている」感覚がぐっと減ります。
// App.xaml.cs
public partial class App : Application
{
private readonly IServiceProvider _serviceProvider;
public App(IServiceProvider serviceProvider)
{
InitializeComponent();
_serviceProvider = serviceProvider;
}
protected override Window CreateWindow(IActivationState? activationState)
{
// スプラッシュページを初期表示
var window = new Window(new SplashPage());
// 非同期でバックグラウンド初期化を実行
Task.Run(async () =>
{
await InitializeServicesAsync();
// UIスレッドでメインページへ遷移
MainThread.BeginInvokeOnMainThread(() =>
{
window.Page = _serviceProvider
.GetRequiredService<AppShell>();
});
});
return window;
}
private async Task InitializeServicesAsync()
{
// データベースのマイグレーション
var dbService = _serviceProvider
.GetRequiredService<IDatabaseService>();
await dbService.InitializeAsync();
// キャッシュの事前読み込み
var cacheService = _serviceProvider
.GetRequiredService<ICacheService>();
await cacheService.WarmUpAsync();
// アナリティクスの初期化(起動時に不要だが事前に準備)
var analytics = _serviceProvider
.GetRequiredService<IAnalyticsService>();
await analytics.InitializeAsync();
}
}
Handlerの事前登録を最小限にする
.NET MAUIでは、カスタムハンドラーやエフェクトの登録が多すぎると起動時間に影響します。使うハンドラーだけを明示的に登録するのがベストです。
// 不要なハンドラーを除外し、必要なものだけ登録
builder.ConfigureMauiHandlers(handlers =>
{
// 使用するカスタムハンドラーのみ追加
handlers.AddHandler<CustomEntry, CustomEntryHandler>();
// 不要なハンドラーは登録しない
});
コンパイル済みバインディング:リフレクションを排除して8〜20倍高速化
データバインディングは.NET MAUIの中核機能ですが、従来のリフレクションベースのバインディングはパフォーマンスのボトルネックになり得ます。コンパイル済みバインディングを使えば、バインディングの解決速度を8〜20倍に向上できます。これ、設定するだけでこの効果なので、やらない手はないですね。
x:DataTypeの設定
コンパイル済みバインディングを有効にするには、XAMLでx:DataType属性を設定します。
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:viewmodels="clr-namespace:MyApp.ViewModels"
x:Class="MyApp.Views.ProductListPage"
x:DataType="viewmodels:ProductListViewModel">
<StackLayout Padding="16">
<Label Text="{Binding PageTitle}"
FontSize="24"
FontAttributes="Bold" />
<ActivityIndicator IsRunning="{Binding IsLoading}"
IsVisible="{Binding IsLoading}" />
<CollectionView ItemsSource="{Binding Products}">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:Product">
<StackLayout Padding="8">
<Label Text="{Binding Name}"
FontSize="18" />
<Label Text="{Binding Price,
StringFormat='{0:C}'}"
TextColor="Gray" />
</StackLayout>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</StackLayout>
</ContentPage>
ソースコンパイルでさらに最適化
.NET 9以降では、プロジェクトファイルに設定を追加するだけで、ソースジェネレーターによるバインディングのコンパイルが可能になりました。リフレクションを完全に排除できて、トリミングやNative AOTとの互換性も確保されます。
<!-- .csproj ファイルに追加 -->
<PropertyGroup>
<MauiEnableXamlCBindingWithSourceCompilation>true</MauiEnableXamlCBindingWithSourceCompilation>
</PropertyGroup>
この設定を有効にしたうえで、すべてのバインディングに対して正しいx:DataTypeを設定しておけば、ビルド時にバインディングエラーも検出できるようになります。実行時に突然クラッシュするのを防げるのは、かなり大きなメリットです。
コンパイル済みバインディングを一時的に無効化する場面
動的なバインディングがどうしても必要な場面では、x:DataType="{x:Null}"を使って部分的に無効化できます。
<!-- 動的バインディングが必要な箇所のみ無効化 -->
<ContentView x:DataType="{x:Null}">
<Label Text="{Binding DynamicProperty}" />
</ContentView>
ただし、これは本当に必要な場合だけにしてください。可能な限りコンパイル済みバインディングを使う方がパフォーマンス上有利です。
UIレンダリングの最適化:レイアウトの深さとCollectionView
UIの描画速度は、ユーザーが直接体感するパフォーマンスです。特にスクロールの滑らかさやページ遷移の速度は、アプリ全体の品質感に直結します。ここが雑だと「なんかモッサリしたアプリだな」という印象を与えてしまいます。
レイアウトのネスト深度を減らす
レイアウトが深くネストされるほど、レンダリングエンジンは各レイヤーのサイズ計算と配置を再帰的に行う必要があって、描画がどんどん遅くなります。
<!-- 悪い例:深いネスト -->
<StackLayout>
<StackLayout Orientation="Horizontal">
<StackLayout>
<Label Text="{Binding Name}" />
<Label Text="{Binding Description}" />
</StackLayout>
<StackLayout>
<Label Text="{Binding Price}" />
<Button Text="購入" />
</StackLayout>
</StackLayout>
</StackLayout>
<!-- 良い例:Gridで平坦化 -->
<Grid ColumnDefinitions="*,Auto"
RowDefinitions="Auto,Auto"
Padding="8">
<Label Text="{Binding Name}"
Grid.Row="0" Grid.Column="0"
FontSize="18" />
<Label Text="{Binding Description}"
Grid.Row="1" Grid.Column="0"
TextColor="Gray" />
<Label Text="{Binding Price}"
Grid.Row="0" Grid.Column="1"
VerticalOptions="Center" />
<Button Text="購入"
Grid.Row="1" Grid.Column="1" />
</Grid>
Gridを使えば、複雑なレイアウトを1階層で表現できます。StackLayoutの入れ子を4〜5段階から1段階に減らすだけで、レンダリング速度が目に見えて改善されることがあります。実際にやってみると「こんなに違うのか」と驚くはずです。
CollectionViewの最適化
大量のデータを表示するリストは、モバイルアプリのパフォーマンスボトルネックになりやすい箇所です。CollectionViewの仮想化を最大限に活用しましょう。
<!-- 最適化されたCollectionView -->
<Grid RowDefinitions="*">
<!-- 重要:CollectionViewはGridの中に配置し、
行の高さを * にする。
ScrollViewやStackLayoutの中に入れてはいけない -->
<CollectionView ItemsSource="{Binding Products}"
ItemSizingStrategy="MeasureFirstItem"
RemainingItemsThreshold="5"
RemainingItemsThresholdReachedCommand=
"{Binding LoadMoreCommand}">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:Product">
<Grid HeightRequest="80"
ColumnDefinitions="80,*,Auto"
Padding="8">
<Image Source="{Binding ThumbnailUrl}"
Aspect="AspectFill"
WidthRequest="80"
HeightRequest="80" />
<StackLayout Grid.Column="1"
VerticalOptions="Center"
Padding="8,0">
<Label Text="{Binding Name}"
FontSize="16"
LineBreakMode="TailTruncation" />
<Label Text="{Binding Category}"
FontSize="12"
TextColor="Gray" />
</StackLayout>
<Label Grid.Column="2"
Text="{Binding Price,
StringFormat='¥{0:N0}'}"
VerticalOptions="Center"
FontAttributes="Bold" />
</Grid>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</Grid>
ここでのポイントをまとめておきます。
- ItemSizingStrategy="MeasureFirstItem":最初のアイテムのサイズを基準に全アイテムのサイズを推定するので、測定コストを大幅に削減できます
- RemainingItemsThreshold:無限スクロール(インクリメンタルローディング)の実装で、一度に大量のデータを読み込まずに済みます
- GridまたはAbsoluteLayoutの中に配置:ScrollViewやStackLayoutの中にCollectionViewを入れると仮想化が機能しなくなります(これ、本当に重要です)
- 固定のHeightRequest:アイテムの高さを固定にすることで、レイアウト計算のコストが下がります
ScrollView内にCollectionViewを配置しない理由
よくある間違いとして、ScrollViewの中にCollectionViewを配置するケースがあります。
これをやると、CollectionViewの仮想化機能が完全に無効化されて、すべてのアイテムが一度にレンダリングされてしまいます。100件のリストなら100件分のViewが同時に生成される——メモリ使用量が爆発し、スクロールもカクつきます。初めてこの罠にハマったときは原因の特定にかなり時間がかかりました。
<!-- 絶対にやってはいけない -->
<ScrollView>
<StackLayout>
<Label Text="ヘッダー" />
<CollectionView ItemsSource="{Binding Items}" />
<Label Text="フッター" />
</StackLayout>
</ScrollView>
<!-- 正しい方法:HeaderとFooterを活用 -->
<CollectionView ItemsSource="{Binding Items}">
<CollectionView.Header>
<Label Text="ヘッダー" Padding="16" />
</CollectionView.Header>
<CollectionView.Footer>
<Label Text="フッター" Padding="16" />
</CollectionView.Footer>
<CollectionView.ItemTemplate>
<!-- テンプレート -->
</CollectionView.ItemTemplate>
</CollectionView>
メモリ管理:リークを防ぎ、使用量を最小化する
メモリ管理は、特にモバイルデバイスのような限られたリソース環境では本当に重要です。メモリリークはアプリが長時間使われるほど蓄積していって、最終的にはOSによるアプリの強制終了を引き起こします。ユーザーからすると突然アプリが落ちるわけですから、最悪の体験ですよね。
イベントハンドラのリーク防止
C#のイベントハンドラは、登録解除を忘れるとメモリリークの原因になります。特に長寿命のオブジェクトが短寿命のオブジェクトのイベントを購読している場合が危険です。
public partial class ProductDetailPage : ContentPage
{
private readonly INotificationService _notificationService;
public ProductDetailPage(INotificationService notificationService)
{
InitializeComponent();
_notificationService = notificationService;
}
protected override void OnAppearing()
{
base.OnAppearing();
// ページ表示時にイベントを購読
_notificationService.PriceChanged += OnPriceChanged;
}
protected override void OnDisappearing()
{
base.OnDisappearing();
// ページ非表示時にイベントの購読を解除
_notificationService.PriceChanged -= OnPriceChanged;
}
private void OnPriceChanged(object? sender, PriceChangedEventArgs e)
{
MainThread.BeginInvokeOnMainThread(() =>
{
priceLabel.Text = $"¥{e.NewPrice:N0}";
});
}
}
OnAppearingとOnDisappearingのペアでイベントの登録・解除を行うのが、.NET MAUIでの鉄板パターンです。
WeakEventManagerの活用
より安全なアプローチとして、WeakEventManagerを使う方法もあります。イベントの購読者がガベージコレクションの対象になっても自動的にクリーンアップされるので、解除忘れのリスクを減らせます。
public class NotificationService : INotificationService
{
private readonly WeakEventManager _weakEventManager = new();
public event EventHandler<PriceChangedEventArgs> PriceChanged
{
add => _weakEventManager
.AddEventHandler(value, nameof(PriceChanged));
remove => _weakEventManager
.RemoveEventHandler(value, nameof(PriceChanged));
}
public void NotifyPriceChanged(decimal newPrice)
{
_weakEventManager.HandleEvent(
this,
new PriceChangedEventArgs(newPrice),
nameof(PriceChanged));
}
}
画像メモリの管理
画像はモバイルアプリで最もメモリを消費するリソースの一つです。特に高解像度の画像をそのまま読み込むと、あっという間にメモリが逼迫します。
public class ImageOptimizationService
{
// 画像のダウンスケーリング
// 表示サイズより大きな画像をメモリに保持しない
public static ImageSource GetOptimizedImage(
string url, int displayWidth, int displayHeight)
{
// UriImageSourceを使い、キャッシュを有効化
return new UriImageSource
{
Uri = new Uri(url),
CachingEnabled = true,
CacheValidity = TimeSpan.FromDays(7)
};
}
}
// XAMLでの適切な画像サイズ指定
// 不必要に大きな画像を読み込まない
// <Image Source="{Binding ImageUrl}"
// WidthRequest="100"
// HeightRequest="100"
// Aspect="AspectFill" />
FFImageLoading.Mauiによる高度な画像最適化
大量の画像を扱うアプリでは、FFImageLoading.Mauiの導入を検討してみてください。メモリ内ビットマップの自動管理、重複リクエストの排除、Androidでの透過チャンネル最適化(メモリ50%削減)など、なかなか強力な機能が揃っています。
// MauiProgram.cs でFFImageLoadingを有効化
builder.UseFFImageLoading();
// XAML での使用
// <ffimageloading:CachedImage Source="{Binding ImageUrl}"
// DownsampleToViewSize="true"
// LoadingPlaceholder="loading.png"
// ErrorPlaceholder="error.png"
// CacheDuration="7"
// RetryCount="3"
// RetryDelay="500" />
Native AOTとトリミング:アプリサイズ50%削減と起動速度50%向上
.NET 9では、.NET MAUIアプリにおけるNative AOT(Ahead-of-Time)コンパイルとフルトリミングのサポートが大幅に強化されました。iOSアプリではディスク上のアプリサイズが最大50%削減、起動パフォーマンスも約50%向上という驚異的な数値が報告されています。これだけでアップデートする価値がありますよね。
Native AOTの有効化
Native AOTを有効にするには、プロジェクトファイルに以下の設定を追加します。
<!-- .csproj ファイル -->
<PropertyGroup>
<!-- iOS/Mac Catalyst向けNative AOT -->
<PublishAot>true</PublishAot>
<!-- サイズ最適化を優先する場合 -->
<OptimizationPreference>Size</OptimizationPreference>
<!-- フルトリミングの有効化 -->
<TrimMode>full</TrimMode>
<!-- デバッグ情報を除外してさらにサイズ削減 -->
<DebuggerSupport>false</DebuggerSupport>
<EnableUnsafeBinaryFormatterSerialization>false</EnableUnsafeBinaryFormatterSerialization>
</PropertyGroup>
トリミング互換性の確保
Native AOTとフルトリミングを使う場合、リフレクションに依存するコードには注意が必要です。トリマーが「参照されていない」と判断したコードは容赦なく削除されるため、動的にアクセスされる型やメンバーを明示的に保持する必要があります。
// トリミング互換性のためのアノテーション
using System.Diagnostics.CodeAnalysis;
public class PluginLoader
{
// DynamicallyAccessedMembers属性でトリマーに
// 型情報の保持を指示
public T CreateInstance<
[DynamicallyAccessedMembers(
DynamicallyAccessedMemberTypes.PublicConstructors)] T>()
where T : class
{
return Activator.CreateInstance<T>();
}
}
// JSON シリアライゼーションでのSource Generator活用
// リフレクションを使わないシリアライズ
[JsonSerializable(typeof(Product))]
[JsonSerializable(typeof(List<Product>))]
[JsonSerializable(typeof(ApiResponse<Product>))]
public partial class AppJsonContext : JsonSerializerContext
{
}
// 使用例
var json = JsonSerializer.Serialize(
product, AppJsonContext.Default.Product);
var products = JsonSerializer.Deserialize(
responseJson, AppJsonContext.Default.ListProduct);
特にJSONシリアライゼーションでは、Source Generatorを活用したコンテキストクラスの生成が重要です。System.Text.JsonのリフレクションベースのシリアライゼーションはトリミングやNative AOTと互換性がないため、Source Generatorを使って安全かつ高速なシリアライゼーションを実現しましょう。
Native AOT使用時の制限事項
Native AOTを導入する際は、いくつかの制限を理解しておく必要があります。ここを知らずに進めると、ビルドは通るのにランタイムで不可解なクラッシュが起きる…という厄介な状況に陥ることがあります。
- リフレクションの制限:動的な型読み込みや
Assembly.Loadは使用できません - dynamic型の制限:
dynamicキーワードの使用が制限されます - コード生成の制限:
Reflection.Emitによる実行時のコード生成は不可 - サードパーティライブラリの互換性:一部のNuGetパッケージがNative AOTと互換性がない場合があります
ビルド時のトリミング警告は必ず確認して、互換性のないコードパターンを修正してから本番ビルドを行いましょう。
非同期処理とバックグラウンドタスクの最適化
モバイルアプリでは、ネットワーク通信やデータベースアクセスなど、時間のかかる処理を適切に非同期化することがUIの応答性を維持する鍵になります。
UIスレッドをブロックしない
最も基本的でありながら、最も重要なルールです。UIスレッドで同期的に重い処理を実行すると、アプリ全体がフリーズします。そして.Resultを使ったデッドロックは、.NET MAUIでよく見かけるバグの一つです。
// 悪い例:UIスレッドをブロック
public void LoadData()
{
// これはUIスレッドをブロックする
var data = _apiService.GetDataAsync().Result; // デッドロックの危険
Items = new ObservableCollection<Item>(data);
}
// 良い例:非同期で処理
[RelayCommand]
private async Task LoadDataAsync()
{
try
{
IsLoading = true;
// バックグラウンドスレッドでデータ取得
var data = await _apiService.GetDataAsync()
.ConfigureAwait(false);
// UIの更新はメインスレッドで
await MainThread.InvokeOnMainThreadAsync(() =>
{
Items = new ObservableCollection<Item>(data);
});
}
finally
{
IsLoading = false;
}
}
キャンセレーショントークンの適切な使用
ページ遷移時に不要になった非同期処理をキャンセルすることで、無駄なリソース消費を防げます。地味ですが、これをやるかやらないかでアプリの安定性がかなり変わります。
public partial class SearchPage : ContentPage
{
private CancellationTokenSource? _searchCts;
private readonly ISearchService _searchService;
public SearchPage(ISearchService searchService)
{
InitializeComponent();
_searchService = searchService;
}
private async void OnSearchTextChanged(
object sender, TextChangedEventArgs e)
{
// 前回の検索をキャンセル
_searchCts?.Cancel();
_searchCts = new CancellationTokenSource();
var token = _searchCts.Token;
try
{
// デバウンス:300ms待機
await Task.Delay(300, token);
if (token.IsCancellationRequested) return;
var results = await _searchService
.SearchAsync(e.NewTextValue, token);
if (!token.IsCancellationRequested)
{
searchResults.ItemsSource = results;
}
}
catch (OperationCanceledException)
{
// キャンセルは正常な動作
}
}
protected override void OnDisappearing()
{
base.OnDisappearing();
_searchCts?.Cancel();
_searchCts?.Dispose();
}
}
HttpClientの最適化
ネットワーク通信はモバイルアプリのパフォーマンスに大きく影響します。意外と見落とされがちなHttpClientの適切な使い方を見ていきましょう。
HttpClientFactoryの活用
HttpClientを毎回newするのはアンチパターンです。ソケットの枯渇やDNSキャッシュの問題を引き起こします。HttpClientFactoryを使ってコネクションプールを効率的に管理しましょう。
// MauiProgram.cs での設定
builder.Services.AddHttpClient("MainApi", client =>
{
client.BaseAddress = new Uri("https://api.example.com/");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Add("Accept", "application/json");
})
.ConfigurePrimaryHttpMessageHandler(() =>
{
#if ANDROID
// Android固有の最適化
return new Xamarin.Android.Net.AndroidMessageHandler
{
AutomaticDecompression =
DecompressionMethods.GZip | DecompressionMethods.Deflate
};
#elif IOS
// iOS固有の最適化
return new NSUrlSessionHandler
{
AllowAutoRedirect = true
};
#else
return new HttpClientHandler
{
AutomaticDecompression =
DecompressionMethods.GZip | DecompressionMethods.Deflate
};
#endif
});
// サービスクラスでの使用
public class ApiService : IApiService
{
private readonly IHttpClientFactory _httpClientFactory;
public ApiService(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task<List<Product>> GetProductsAsync(
CancellationToken ct = default)
{
using var client = _httpClientFactory.CreateClient("MainApi");
var response = await client.GetAsync("products", ct);
response.EnsureSuccessStatusCode();
return await response.Content
.ReadFromJsonAsync<List<Product>>(
AppJsonContext.Default.ListProduct, ct)
?? new List<Product>();
}
}
プラットフォーム固有のHTTPハンドラーを使うことで、各OSのネイティブなネットワークスタックの恩恵を受けられるのもポイントです。
プロファイリングツール:ボトルネックを可視化する
パフォーマンス最適化を効果的に行うには、推測ではなく計測に基づいて進めることが重要です。「たぶんここが遅いだろう」と勘で最適化を始めると、的外れな労力を費やすことになりがちです。.NET MAUIで使えるプロファイリングツールを紹介します。
dotnet-traceによるCPUプロファイリング
dotnet-traceは、.NET MAUIアプリのCPUトレースを収集するためのクロスプラットフォームツールです。
# ツールのインストール
dotnet tool install --global dotnet-trace
dotnet tool install --global dotnet-dsrouter
dotnet tool install --global dotnet-gcdump
# Android デバイスでのプロファイリング
# 1. 診断ルーターを起動
dotnet-dsrouter android
# 2. 別のターミナルでトレースを収集
dotnet-trace collect \
--diagnostic-port 127.0.0.1:9000 \
--format speedscope
# iOS シミュレータでのプロファイリング
# 1. 診断ルーターを起動
dotnet-dsrouter ios
# 2. トレース収集
dotnet-trace collect \
--diagnostic-port /tmp/maui-app.sock \
--format speedscope
dotnet-gcdumpによるメモリ分析
メモリリークの調査には、dotnet-gcdumpを使ってマネージドヒープのスナップショットを取得するのが効果的です。
# GCダンプの取得
dotnet-gcdump collect --process-id <PID>
# 結果はVisual StudioやPerfViewで分析可能
アプリ内パフォーマンス計測
特定の処理のパフォーマンスをピンポイントで計測したい場合、コード内にタイミング計測を仕込むのも有効です。以下のようなシンプルなユーティリティを用意しておくと便利です。
public class PerformanceTracker
{
private static readonly Dictionary<string, List<long>> _metrics = new();
public static IDisposable Track(string operationName)
{
return new TrackingScope(operationName);
}
private class TrackingScope : IDisposable
{
private readonly string _name;
private readonly Stopwatch _sw;
public TrackingScope(string name)
{
_name = name;
_sw = Stopwatch.StartNew();
}
public void Dispose()
{
_sw.Stop();
if (!_metrics.ContainsKey(_name))
_metrics[_name] = new List<long>();
_metrics[_name].Add(_sw.ElapsedMilliseconds);
Debug.WriteLine(
$"[Performance] {_name}: {_sw.ElapsedMilliseconds}ms");
}
}
public static string GetReport()
{
var sb = new StringBuilder();
sb.AppendLine("=== パフォーマンスレポート ===");
foreach (var (name, times) in _metrics)
{
sb.AppendLine($"{name}:");
sb.AppendLine($" 回数: {times.Count}");
sb.AppendLine($" 平均: {times.Average():F1}ms");
sb.AppendLine($" 最大: {times.Max()}ms");
sb.AppendLine($" 最小: {times.Min()}ms");
}
return sb.ToString();
}
}
// 使用例
public async Task<List<Product>> GetProductsAsync()
{
using (PerformanceTracker.Track("API: GetProducts"))
{
return await _httpClient
.GetFromJsonAsync<List<Product>>("products")
?? new List<Product>();
}
}
usingステートメントを使うことで、スコープの開始と終了を自動的に計測できるのがミソです。
プラットフォーム固有の最適化
クロスプラットフォームフレームワークとはいえ、各プラットフォームの特性を理解した上で最適化することも大切です。共通コードだけでなく、プラットフォーム固有の設定で差が出ることもあります。
Android固有の最適化
<!-- AndroidManifest.xml -->
<!-- ハードウェアアクセラレーションの確認 -->
<application android:hardwareAccelerated="true">
<!-- .csproj でのAndroid固有設定 -->
<PropertyGroup Condition="$(TargetFramework.Contains('android'))">
<!-- R8コード圧縮の有効化 -->
<AndroidLinkTool>r8</AndroidLinkTool>
<!-- 起動プロファイルの活用 -->
<AndroidEnableProfiledAot>true</AndroidEnableProfiledAot>
</PropertyGroup>
iOS固有の最適化
<!-- .csproj でのiOS固有設定 -->
<PropertyGroup Condition="$(TargetFramework.Contains('ios'))">
<!-- AOTコンパイルの有効化(リリースビルド) -->
<MtouchUseLlvm>true</MtouchUseLlvm>
<!-- Interpreterモードの無効化 -->
<UseInterpreter>false</UseInterpreter>
</PropertyGroup>
パフォーマンス最適化チェックリスト
最後に、.NET MAUIアプリのパフォーマンスを改善するためのチェックリストをまとめます。プロジェクトの各フェーズで確認してみてください。全部を一度にやる必要はなくて、まずは自分のアプリで一番ボトルネックになっているところから手をつけるのがおすすめです。
起動時間
- 不要なサービスの初期化を遅延させているか
- スプラッシュスクリーン中にバックグラウンド初期化を行っているか
- カスタムハンドラーの登録を最小限にしているか
- Native AOTまたはプロファイル付きAOTを有効にしているか
UIレンダリング
- レイアウトのネスト深度を最小限にしているか
- CollectionViewを正しいコンテナ内に配置しているか
- ItemSizingStrategyを設定しているか
- コンパイル済みバインディング(x:DataType)を使用しているか
メモリ管理
- イベントハンドラのリーク防止策を実装しているか
- 画像のキャッシュと適切なサイズ指定を行っているか
- 使用していないリソースを適切にDisposeしているか
ネットワーク
- HttpClientFactoryを使用しているか
- プラットフォーム固有のHTTPハンドラーを活用しているか
- キャンセレーショントークンを適切に使用しているか
- JSONシリアライゼーションでSource Generatorを使用しているか
ビルド設定
- リリースビルドでトリミングを有効にしているか
- iOS向けにNative AOTまたはLLVMを有効にしているか
- Android向けにR8コード圧縮を有効にしているか
- 不要なアセットやリソースを含めていないか
まとめ:パフォーマンスは設計段階から考える
パフォーマンス最適化は、リリース直前に慌てて取り組むものではありません。アプリケーションの設計段階から、パフォーマンスを意識した意思決定を積み重ねていくことが大切です。
本記事で紹介したテクニックをすべて一度に導入する必要はありません。まずはプロファイリングツールでボトルネックを特定して、効果の大きい箇所から順に最適化を進めていくのがおすすめです。
個人的な経験からいうと、コンパイル済みバインディングとCollectionViewの最適化は、比較的少ない労力で大きな効果が得られる「ローハンギングフルーツ」です。まだ導入していないなら、ここから始めてみてください。
.NET 9で導入されたNative AOTとフルトリミングのサポートにより、.NET MAUIアプリのパフォーマンスは大きく向上しました。iOS向けではアプリサイズの50%削減と起動速度の50%向上が実現可能です。こうした最新機能を積極的に活用して、ユーザーに最高の体験を提供できるアプリを目指しましょう。
「速いアプリ」は「良いアプリ」の前提条件——この原則を忘れずに開発を進めていきましょう。