.NET MAUI がデバッグ時だけ動く原因と対処法|ReleaseビルドでクラッシュするAOT・トリミング問題を解決

.NET MAUI アプリが「デバッグの開始(F5)」では正常に動くのに、「デバッグなしで開始(Ctrl+F5)」や Release 相当の実行で突然終了してしまう――この症状は、例外が表に出ない・ビルド最適化で挙動が変わる・トリミング/AOT で必要なコードが削られる、といった要因が重なって起きがちです。この記事では、原因の切り分け方から、Release でも安定して動かすための具体策まで整理します。

日程Fit。無料・登録不要。「いつ空いてる?」を、ひとつのリンクで。リンクを送って、○△×でかんたん日程調整。無料で日程を作る。
目次

症状を整理: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.UnhandledException
  • TaskScheduler.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 構成で F5Release の最適化をかけたまま例外を捕まえる構成を Release にしてデバッグ開始例外箇所のスタックトレースが取れる
AOT/トリミングを無効化して比較トリミング起因かを切り分け.csproj または Publish 設定で無効化落ちなくなれば、保護すべき型/メンバーがある
遷移直前にログを入れる「どこまで進んだか」を可視化画像選択後、ファイルコピー後、GoToAsync 前後でログ落ちる直前の処理が特定できる

特に「Release 構成で F5」は強力です。Release と同じ最適化をかけた状態で、例外が出ればその場で止められるため、調査が一気に短縮されます。

実務で効く最終整理:やることを順番に並べる

最後に、現場での進め方を “迷わない順番” に並べます。

  1. 未処理例外ログを仕込む(crash.log を残す)
  2. Debug 構成のまま Ctrl+F5 で再現するか確認(デバッガ差の切り分け)
  3. 再現するなら、ログに出た例外をそのまま潰す(UI スレッド、ファイル、null、await など)
  4. 再現しないなら、Release 構成で F5(Release のまま例外を捕まえる)
  5. Publish/配布相当でのみ落ちるなら、一時的にトリミング/AOT を無効化して切り分け
  6. 原因がトリミングなら、Compiled Bindings/IQueryAttributable/Json ソースジェネレータ/Descriptorのいずれかで “削られて困る部分” を守る
  7. 最後にトリミング/AOT を段階的に戻して、サイズ・性能・安定性のバランスを取る

まとめ:Debug で動く=本番で動く、ではない

.NET MAUI で「デバッグ時だけ動く」「デバッグなしで突然落ちる」現象は、単なる偶然ではなく、観測(例外の見え方)最適化(トリミング/AOT)の差が原因で起きることが多いです。まずは Release でも例外をログに落として原因を確定させ、必要ならトリミングや AOT の設定を切り分け、削られて困る箇所だけを守る――この流れが最短で安定化に繋がります。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次