.NET MAUI アプリが「デバッグの開始(F5)」では正常に動くのに、「デバッグなしで開始(Ctrl+F5)」や Release 相当の実行で突然終了してしまう――この症状は、例外が表に出ない・ビルド最適化で挙動が変わる・トリミング/AOT で必要なコードが削られる、といった要因が重なって起きがちです。この記事では、原因の切り分け方から、Release でも安定して動かすための具体策まで整理します。
症状を整理:MainPage は出るが、DetailsPage で落ちる
開発環境が以下のようなケースを想定します。
- Visual Studio 17.13
- .NET MAUI
- Windows 11
典型的な挙動は次のとおりです。
- 「デバッグの開始(F5)」では正常動作する
- 「デバッグなしで開始」や Release 相当の実行では、起動直後の
MainPageまでは表示される - 画像を選択し、
DetailsPageに遷移しようとすると、しばらくしてアプリが終了して Visual Studio に戻る - 例外メッセージやエラーが画面に出ず、原因が掴めない
この時点で疑うべきは「Release でだけ壊れるロジック」だけではありません。“デバッガが付いているかどうか” と “ビルド・発行(Publish)の設定” の違いが、見た目以上に効いてきます。
Debug と「デバッグなし/Release」で何が違うのか
| 観点 | デバッグの開始(F5) | デバッグなし/Release 相当 |
|---|---|---|
| デバッガの有無 | デバッガがアタッチされ、例外で止まったり詳細を確認できる | デバッガがいないため、未処理例外=即終了になりやすい |
| 例外の見え方 | 「例外がスローされた」ログやブレークで気付きやすい | ユーザー視点では「突然落ちた」に見える(表示されないことが多い) |
| 最適化 | 最適化が弱め、タイミングが変わりにくい | 最適化で処理順やタイミングが変わり、潜在バグが顕在化しやすい |
| トリミング(未使用コード削除) | 多くのケースで無効または弱い | Publish 設定やプロファイルで有効になっていると、リフレクション依存箇所が破綻しやすい |
| AOT(事前コンパイル) | 通常は開発向け設定(プラットフォームにより差) | PublishAot などが有効だと、動的なコード生成や一部のリフレクションが制約を受ける |
| ログの出力先 | Visual Studio の出力ウィンドウで追いやすい | 自前でファイル等に出さない限り、手掛かりが消える |
特に重要なのは次の2点です。
- Ctrl+F5 は「ビルド構成を変える」操作ではなく、「デバッガを付けない」操作です。つまり、同じ Debug 構成でも落ちることがあります(=例外が見えていないだけ)。
- 一方で、MSIX パッケージ化や Publish プロファイルを使った “Release 配布に近い実行”では、トリミングや AOT が絡む確率が上がります。
最初にやるべきこと:Release でも「例外を見える化」する
「エラーメッセージが出ない」状態で原因を推測し続けるのは、時間を溶かします。まずは落ちた瞬間の例外を拾って保存できるようにします。
Windows 側でクラッシュ痕跡を確認する
手早く手掛かりが欲しい場合、Windows の標準機能が役に立ちます。
| 確認先 | 何が分かるか | ポイント |
|---|---|---|
| イベント ビューア(Windows ログ→アプリケーション) | .NET Runtime / Application Error の記録、落ちたモジュール名 | 例外名が出ない場合でも、落ちたプロセス情報が残ることがある |
| 信頼性モニター(Reliability Monitor) | アプリケーション エラーの時系列 | 「いつから」「どの操作で」落ちるかが整理しやすい |
MAUI アプリ側で未処理例外をログに落とす
Release でも確実に残るログを用意します。最低限、次のイベントを拾うだけでも効果があります。
AppDomain.CurrentDomain.UnhandledExceptionTaskScheduler.UnobservedTaskException- (Windows の場合)WinUI の UnhandledException
例として、アプリのローカルデータ領域にログファイルを追記する実装です。
using System.Text;
using Microsoft.Maui.Storage;
public partial class App : Application
{
public App()
{
InitializeComponent();
AppDomain.CurrentDomain.UnhandledException += (s, e) =>
{
LogException("AppDomain.UnhandledException", e.ExceptionObject as Exception);
};
TaskScheduler.UnobservedTaskException += (s, e) =>
{
LogException("TaskScheduler.UnobservedTaskException", e.Exception);
e.SetObserved();
};
#if WINDOWS
// WinUI の未処理例外(Windows 限定)
Microsoft.UI.Xaml.Application.Current.UnhandledException += (s, e) =>
{
LogException("WinUI.UnhandledException", e.Exception);
// ここで Handled=true にするかは要件次第。
// 原因特定中は false のままにして落としてでもログを残す方が追いやすいことが多い。
};
#endif
MainPage = new AppShell();
}
static void LogException(string source, Exception? ex)
{
try
{
var sb = new StringBuilder();
sb.AppendLine($"[{DateTime.Now:O}] {source}");
sb.AppendLine(ex?.ToString() ?? "(null)");
sb.AppendLine();
var path = Path.Combine(FileSystem.AppDataDirectory, "crash.log");
File.AppendAllText(path, sb.ToString());
}
catch
{
// ログ書き込み失敗時はここで二次例外を出さない
}
}
}
ここまでやると、「DetailsPage へ遷移する直前に何の例外が出ているか」が見えるようになります。以降の対処は、その例外を入口に進めるのが最短ルートです。
Release でだけ落ちる代表的な原因
Debug では通ってしまい、Release やデバッガ無しで顕在化しやすい原因を、実務でよく遭遇する順にまとめます。
| 原因パターン | 起きやすい症状 | チェックポイント | 対処の方向性 |
|---|---|---|---|
| 未処理例外が握りつぶされて見えない | 突然終了、Visual Studio に戻るだけ | ログに例外が出るか/イベントログに痕跡があるか | まずログを仕込む。Release でも例外を捕捉して保存 |
| トリミングで必要なメンバーが削除される | 画面遷移時・DI 解決時・シリアライズ時に落ちる | PublishTrimmed/TrimMode が有効か。リフレクション依存箇所がないか | 一時的に無効化して切り分け → 必要箇所を Preserve/Descriptor/ソースジェネレータで保護 |
| AOT(Native AOT 等)の制約 | 特定 API で例外、動的生成が絡む箇所で落ちる | PublishAot/RunAOTCompilation を有効にしていないか | AOT を切って切り分け。AOT 対応の実装に寄せる |
| タイミング依存(非同期・スレッド・レース) | Debug では再現しない不定期クラッシュ | UI スレッド更新をしているか、await の抜けがないか | MainThread/Dispatcher を使い UI 更新を統一。await を徹底 |
| DEBUG 条件付きコードに依存している | Release でだけ機能しない/落ちる | [Conditional(“DEBUG”)] や Debug.Assert の副作用 | 本番ロジックを DEBUG ブロックに入れない。アサートは検証用途に限定 |
| ファイル・リソースの扱いの差 | 画像やファイルの読み込みタイミングで落ちる | 一時ファイルの寿命、アクセス権、コピー漏れ | 選択した画像をアプリ領域へコピーしてから参照する |
原因切り分けの最短手順:AOT とトリミングを「一時的に」外す
ログを仕込んだ上で、次に効くのが「挙動を揃える」切り分けです。もし Release 相当でのみ落ちるなら、まずは AOT/トリミングを無効にして Debug に近づけ、落ちなくなるかを確認します。
Publish を使っている場合、プロジェクトファイル(.csproj)または Publish プロファイルの設定が関係していることがあります。例として .csproj に Release 用の条件付き設定を追加する形を示します。
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<!-- 切り分け目的:まずは削らない/事前コンパイルしない -->
<PublishTrimmed>false</PublishTrimmed>
<TrimMode>none</TrimMode>
<!-- Native AOT を使っている場合のみ。不要なら削除可 -->
<PublishAot>false</PublishAot>
</PropertyGroup>
この状態で「落ちなくなった」なら、かなりの確度で トリミング/AOT 起因です。逆に「落ちるまま」なら、例外内容(ログ)に従って、非同期・UI スレッド・ファイル処理などを重点的に見ます。
注意:トリミングや AOT を完全に切るのは恒久対策としては推奨しにくいことがあります(サイズや起動性能に影響)。ただし、原因特定の初動としては非常に有効です。
トリミングで壊れやすい箇所を「守る」具体策
トリミングが絡むと厄介なのは、コード上で明示的に参照されていない(ように見える)ものが消える点です。MAUI で影響を受けやすい代表は次のとおりです。
- リフレクション(型名やプロパティ名を文字列で扱う)
- JSON シリアライズ(モデル型が暗黙参照)
- Shell のナビゲーションパラメータ受け取り(属性でプロパティ設定)
- 依存性注入(DI)での動的生成・解決
- XAML バインディング(とくに型情報が弱い書き方)
Shell の画面遷移:QueryProperty 依存を減らす
[QueryProperty] は便利ですが、内部的にリフレクションでプロパティに値を流し込みます。トリミングが強い設定だと、プロパティや setter が削除対象になり、遷移時に例外化するケースがあります。
より明示的な受け取り方として、IQueryAttributable を使う方法があります。
using Microsoft.Maui.Controls;
public partial class DetailsPage : ContentPage, IQueryAttributable
{
public DetailsPage()
{
InitializeComponent();
}
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
// 例:画像パスを受け取る
if (query.TryGetValue("imagePath", out var value) && value is string path)
{
// ここで ViewModel に渡すなど
// Image.Source = ImageSource.FromFile(path);
}
}
}
「属性で自動的にセット」ではなく、「自分で辞書から取り出す」形にすると、トリミング耐性が上がり、例外箇所も特定しやすくなります。
JSON:ソースジェネレータを使って “必要な型” を明確にする
System.Text.Json を使っているなら、ソースジェネレータ(JsonSerializerContext)を使うことで、トリミング下でも必要な情報を残しやすくなります。モデルが消える/メンバーが無い扱いになる系の不具合を減らせます。
using System.Text.Json;
using System.Text.Json.Serialization;
public record PhotoInfo(string Path, int Width, int Height);
[JsonSerializable(typeof(PhotoInfo))]
internal partial class AppJsonContext : JsonSerializerContext
{
}
// 使う側
var json = JsonSerializer.Serialize(new PhotoInfo(path, w, h), AppJsonContext.Default.PhotoInfo);
var model = JsonSerializer.Deserialize(json, AppJsonContext.Default.PhotoInfo);
「動的に推論してくれ」に任せるより、「この型を使う」と明示した方が Release で強い実装になります。
XAML:Compiled Bindings(x:DataType)でバインディングを強くする
XAML バインディングが暗黙的だと、型やプロパティ参照が弱くなり、トリミングとの相性が悪化することがあります。可能なら Compiled Bindings を使い、x:DataType を指定して型を固定します。
<ContentPage
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:MyApp.ViewModels"
x:Class="MyApp.Pages.DetailsPage"
x:DataType="vm:DetailsViewModel">
<VerticalStackLayout>
<Image Source="{Binding ImagePath}" />
<Label Text="{Binding Title}" />
</VerticalStackLayout>
</ContentPage>
コンパイル時に参照が確定しやすくなり、タイポも検出できるので、保守性も上がります。
どうしてもリフレクションが必要:Descriptor や属性で明示的に残す
どうしても「文字列で型名を引く」「属性で探索する」といった実装が必要な場合は、トリミングに対して「この型やメンバーは残して」と明示します。代表例が Linker(Trimmer)用の descriptor です。
例:LinkerDescriptor.xml を用意する
<linker>
<assembly fullname="MyApp">
<type fullname="MyApp.Pages.DetailsPage" preserve="all" />
<type fullname="MyApp.ViewModels.DetailsViewModel" preserve="all" />
<type fullname="MyApp.Models.PhotoInfo" preserve="all" />
</assembly>
</linker>
そして .csproj に追加します。
<ItemGroup>
<TrimmerRootDescriptor Include="LinkerDescriptor.xml" />
</ItemGroup>
「守りたい範囲」を大きくしすぎるとトリミングの旨味が減るため、最初は preserve="all" で止血 → 原因が確定したら必要メンバーに絞る、という進め方が現実的です。
画像選択→DetailsPage の流れでハマりやすいポイント
ご相談の症状が「画像選択後に落ちる」なら、トリミング以外にも、画像ファイルの扱いに起因している場合があります。特に Windows では、ユーザーが選択したファイルをそのまま保持して参照するより、アプリ領域にコピーしてから扱う方が安定します。
よくある落とし穴
- 選択したファイルが一時領域扱いで、画面遷移の間に読めなくなる
- ファイルを開いたストリームの寿命が短く、Image が読むタイミングで既に閉じている
- 巨大画像をそのまま読み込み、メモリ圧迫で落ちる(特に連続選択時)
- UI スレッド以外で UI 更新しようとして例外化
安定しやすい実装例:アプリ領域へコピーしてパスを渡す
using Microsoft.Maui.Storage;
async Task CopyToAppDataAsync(FileResult file)
{
var destDir = FileSystem.AppDataDirectory;
var destPath = Path.Combine(destDir, file.FileName);
await using var src = await file.OpenReadAsync();
await using var dest = File.Create(destPath);
await src.CopyToAsync(dest);
return destPath;
}
async Task GoToDetailsAsync()
{
try
{
var file = await FilePicker.PickAsync(new PickOptions
{
PickerTitle = "画像を選択",
FileTypes = FilePickerFileType.Images
});
if (file is null) return;
var localPath = await CopyToAppDataAsync(file);
// Shell ナビゲーションでパスを渡す(大きなバイナリを渡さない)
await Shell.Current.GoToAsync(nameof(DetailsPage), new Dictionary<string, object>
{
["imagePath"] = localPath
});
}
catch (Exception ex)
{
// ここでもログに残すと切り分けが速い
// LogException("GoToDetailsAsync", ex);
throw;
}
}
ポイントは、画面遷移時に画像そのもの(byte[] や Stream)を渡さないことです。ナビゲーションのパラメータは軽量にし、重いデータはアプリの管理下(AppData)へ寄せると、Release での不安定さが減ります。
Release で再現させて潰すためのデバッグ手順
「Release でしか出ない」をそのまま放置すると、修正が当てずっぽうになります。次の手順で、再現性と観測性を上げます。
| 手順 | 目的 | やり方 | 期待する結果 |
|---|---|---|---|
| Debug 構成のまま Ctrl+F5 | 「デバッガ有無」だけの差を確認 | 構成は Debug のまま、デバッグなしで開始 | ここで落ちるなら、未処理例外が見えていない可能性が高い |
| Release 構成で F5 | Release の最適化をかけたまま例外を捕まえる | 構成を Release にしてデバッグ開始 | 例外箇所のスタックトレースが取れる |
| AOT/トリミングを無効化して比較 | トリミング起因かを切り分け | .csproj または Publish 設定で無効化 | 落ちなくなれば、保護すべき型/メンバーがある |
| 遷移直前にログを入れる | 「どこまで進んだか」を可視化 | 画像選択後、ファイルコピー後、GoToAsync 前後でログ | 落ちる直前の処理が特定できる |
特に「Release 構成で F5」は強力です。Release と同じ最適化をかけた状態で、例外が出ればその場で止められるため、調査が一気に短縮されます。
実務で効く最終整理:やることを順番に並べる
最後に、現場での進め方を “迷わない順番” に並べます。
- 未処理例外ログを仕込む(crash.log を残す)
- Debug 構成のまま Ctrl+F5 で再現するか確認(デバッガ差の切り分け)
- 再現するなら、ログに出た例外をそのまま潰す(UI スレッド、ファイル、null、await など)
- 再現しないなら、Release 構成で F5(Release のまま例外を捕まえる)
- Publish/配布相当でのみ落ちるなら、一時的にトリミング/AOT を無効化して切り分け
- 原因がトリミングなら、Compiled Bindings/IQueryAttributable/Json ソースジェネレータ/Descriptorのいずれかで “削られて困る部分” を守る
- 最後にトリミング/AOT を段階的に戻して、サイズ・性能・安定性のバランスを取る
まとめ:Debug で動く=本番で動く、ではない
.NET MAUI で「デバッグ時だけ動く」「デバッグなしで突然落ちる」現象は、単なる偶然ではなく、観測(例外の見え方)と最適化(トリミング/AOT)の差が原因で起きることが多いです。まずは Release でも例外をログに落として原因を確定させ、必要ならトリミングや AOT の設定を切り分け、削られて困る箇所だけを守る――この流れが最短で安定化に繋がります。

コメント