.NET MAUI(Android Release)× Microsoft Graph のOneDriveダウンロードで初回のみJava.Lang.RuntimeExceptionが出る原因と対処法

「.NET MAUI(Android 実機/Release)」で Microsoft Graph API から OneDrive のファイルをダウンロードすると、最初の 1 回だけ Java.Lang.RuntimeException: Exception_WasThrown が発生して落ちる――エミュレーター(Debug)では起きない。この“初回だけ失敗する”現象の正体を、リンカ(トリミング)や AOT、ネットワーク初期化、同期 I/O の落とし穴という観点で体系的に切り分け、すぐに適用できる実装・設定・診断手順をまとめます。

目次

.NET MAUI × Graph(Android Release)で起きる「初回のみ Java.Lang.RuntimeException」の要点

まずは現象の芯を一枚の表に凝縮します。該当箇所から着手すれば、ほとんどのケースで即日解消できます。

区分詳細根拠となる振る舞い/典型症状対策の第一選択
リンカ/AOT 最適化Release では IL トリマーにより未参照コードが除去され、Graph SDK・MSAL が内部で反射参照する型が欠落。初回にだけ動的ロードが走って例外。Debug(リンカ無効)では再現しない/型解決やデシリアライズで失敗ログが混じる。Linker を「None」または「SDK Only」。もしくは linker.xml/Preserve 属性で必要型を温存。
ネットワーク・初期化遅延実機の初回呼び出し時、TLS ハンドシェイク・DNS キャッシュ・MSAL のトークンキャッシュ初期化が未完了でタイムアウト。2 回目以降は成功、回線や端末を変えると発生タイミングがずれる。軽量エンドポイントでのウォームアップ+リトライ(指数バックオフ)。
同期 I/O 呼び出しCopyTo など同期 API を UI スレッドで実行し、Android 側のスレッド制約違反やブロッキングを誘発。初回の大型ダウンロード時のみ固まる/NetworkOnMainThreadException 風のふるまい。CopyToAsync 等の完全非同期化+UI スレッド回避(await 徹底)。
保存先の権限・ストレージ外部ストレージ直書きやパス不正で例外が Java 側へ伝播。保存先をアプリ固有領域にすると再現しない。FileSystem.CacheDirectory/AppDataDirectory を使用。共有ストレージは SAF 経由。

最小再現コード(NG 例と OK 例)

問題を引き寄せやすい実装は次のような「同期コピー+UI スレッド実行」です。

// ❌ NG:UI スレッドで同期コピー
// graphClient と filePath は既存とする
using var responseStream = await graphClient
    .Me.Drive.Root.ItemWithPath(filePath)
    .Content.GetAsync(); // ← ここは非同期だが…

using var dest = new MemoryStream();
responseStream.CopyTo(dest); // ← 同期コピーでブロックしやすい
return dest.ToArray();

推奨は「ダウンロードから保存までを完全非同期化」し、UI をふさがないことです。

// ✅ OK:完全非同期+キャンセル対応+メモリ確保
public static async Task<string> DownloadToCacheAsync(
    GraphServiceClient graphClient,
    string filePath,
    CancellationToken ct)
{
    using var stream = await graphClient
        .Me.Drive.Root.ItemWithPath(filePath)
        .Content.GetAsync(ct);

    var tempPath = Path.Combine(FileSystem.CacheDirectory, Path.GetFileName(filePath));
    await using var dest = File.Open(tempPath, FileMode.Create, FileAccess.Write, FileShare.None);
    // バッファを明示し GC 圧力を低減
    await stream.CopyToAsync(dest, bufferSize: 81920, ct);
    await dest.FlushAsync(ct);
    return tempPath;
}

即効性の高い実装テンプレート(リトライ+ウォームアップ)

「初回だけ失敗」への現実解は、軽量呼び出しでスタックを温めた上で、短い待機をはさむリトライを実装することです。

static async Task<Stream> GetGraphContentWithRetryAsync(
    GraphServiceClient graphClient, string path, CancellationToken ct)
{
    // ウォームアップ:HEAD 相当の軽量確認(存在チェック)
    _ = await graphClient.Me.Drive.Root.ItemWithPath(path).GetAsync(ct);

    const int MaxRetries = 5;
    var delay = TimeSpan.FromMilliseconds(300);

    for (var i = 0; i < MaxRetries; i++)
    {
        try
        {
            return await graphClient
                .Me.Drive.Root.ItemWithPath(path)
                .Content.GetAsync(ct);
        }
        catch (Java.Lang.RuntimeException) when (i < MaxRetries - 1)
        {
            await Task.Delay(delay, ct);
            // 指数バックオフ
            delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2);
        }
        catch (TaskCanceledException) when (i < MaxRetries - 1)
        {
            await Task.Delay(delay, ct);
            delay = TimeSpan.FromMilliseconds(delay.TotalMilliseconds * 2);
        }
    }

    throw new IOException("Graph のダウンロードに繰り返し失敗しました。");
}

リンカ(トリミング)による欠落を止める

Release では既定でトリミングが有効です。Graph SDK(v4/v5)や MSAL は内部で反射・ジェネリック経由のシリアライザを使うため、未参照扱いの型が削除されると、初回のデシリアライズで落ちやすくなります。まずは「切り分けのために」リンカを止め、再現が消えるかを確認します。

プロジェクト設定(csproj)でリンカを無効化して検証

<PropertyGroup Condition="'$(Configuration)|$(TargetFramework)'=='Release|net8.0-android'">
  <AndroidLinkMode>None</AndroidLinkMode>         <!-- 切り分け用 -->
  <PublishTrimmed>false</PublishTrimmed>
  <RunAOTCompilation>false</RunAOTCompilation>     <!-- AOT も一時停止 -->
</PropertyGroup>

これで安定するなら、次に「必要な型だけを温存して最適化は活かす」へ進みます。

linker.xml で Microsoft.Graph/Kiota/MSAL を保持

<linker>
  <assembly fullname="Microsoft.Graph">
    <type fullname="Microsoft.Graph.*" preserve="all" />
    <type fullname="Microsoft.Graph.Models.*" preserve="all" />
  </assembly>




 

ファイルは AndroidLinkSkip の対象としてビルドに含め、csproj で次のように追加します。

&lt;ItemGroup&gt;
  &lt;AndroidLinkerDescriptor Include="linker.xml" /&gt;
&lt;/ItemGroup&gt;

属性での温存:Preserve/DynamicDependency

アプリ側コードに属性を付けて温存する方法も有効です。Graph のモデルやラッパー型を自作している場合は次のように保護します。

using System.Diagnostics.CodeAnalysis;
using Android.Runtime;

// 例:DriveItem を反射で生成するシリアライザ対策
[DynamicDependency(DynamicallyAccessedMemberTypes.PublicConstructors, typeof(Microsoft.Graph.Models.DriveItem))]
internal static class TrimmingRoots { }

// 既定では参照が無いメンバーも保持

[assembly: Preserve(AllMembers = true)]

完全非同期化と UI スレッド回避の徹底

Android ではネットワークを UI スレッドで実行すると厳しく制限されます。以下は“やってはいけない”パターンと回避策です。

アンチパターン理由代替案
Task.Run(...).Result/.GetAwaiter().GetResult()同期戻しでデッドロックや StrictMode 違反。await を末端まで貫通、UI は async void ハンドラから await。
stream.CopyTo(dest)同期コピーで長時間ブロック。await stream.CopyToAsync(dest, 81920, ct)
巨大な MemoryStream を都度確保GC 圧。初回だけ LOH の割り当てで遅延。ファイルへ直書き/容量指定の MemoryStream。

認証(MSAL)初期化の落とし穴と初回対策

初回だけ例外が出る背景には、MSAL のトークンキャッシュが空で、暗号化ストアの初期化やリダイレクト URI の検証が一瞬重くなることがあります。次のポイントを確認しましょう。

  • Android のリダイレクト URI は msal{CLIENT_ID}://auth 形式。AndroidManifest.xml のインテントフィルタと一致させる。
  • アプリ起動時に 軽量なスコープ(例:User.Read)で サイレント取得を一度試すことで暗号化ストアをウォームアップ。
  • トークン取得は DI で 1 箇所に集約し、Graph 呼び出しの都度 AcquireTokenSilent を先に試す。
// MSAL 準備(例)
var pca = PublicClientApplicationBuilder.Create(ClientId)
    .WithRedirectUri($"msal{ClientId}://auth")
    .Build();

async Task&lt;string&gt; GetAccessTokenAsync(IEnumerable&lt;string&gt; scopes, CancellationToken ct)
{
    var account = (await pca.GetAccountsAsync()).FirstOrDefault();
    try
    {
        var res = await pca.AcquireTokenSilent(scopes, account).ExecuteAsync(ct);
        return res.AccessToken;
    }
    catch (MsalUiRequiredException)
    {
        var res = await pca.AcquireTokenInteractive(scopes)
            .WithParentActivityOrWindow(Microsoft.Maui.ApplicationModel.Platform.CurrentActivity)
            .ExecuteAsync(ct);
        return res.AccessToken;
    }
}

GraphServiceClient の初期化と DI、HTTP ハンドラ設定

Graph SDK は内部に HTTP ハンドラパイプラインを持ちます。Release での安定性重視なら、接続タイムアウトやリトライを明示し、1 度だけ作ってアプリ全体で使い回します。

// DI 登録(MauiProgram.cs)
builder.Services.AddSingleton(sp =&gt;
{
    var http = new HttpClient(new SocketsHttpHandler
    {
        ConnectTimeout = TimeSpan.FromSeconds(10),
        PooledConnectionLifetime = TimeSpan.FromMinutes(5),
        AutomaticDecompression = System.Net.DecompressionMethods.All
    });

    return new GraphServiceClient(new DelegateAuthenticationProvider(async (req) =&gt;
    {
        var token = await GetAccessTokenAsync(new[] { "Files.Read" }, CancellationToken.None);
        req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
    }), http);
});

アプリ起動時に「軽量 GET」でウォームアップします。

var client = Services.GetRequiredService&lt;GraphServiceClient&gt;();
// 存在チェック等で軽く叩き、TLS/リゾルバ/キャッシュを温める
_ = await client.Me.Drive.Root.GetAsync();

保存先はアプリ固有ディレクトリへ(権限まわり)

Release で顕在化しやすいのが保存先の権限問題です。まずは共有ストレージを避け、アプリ固有領域へ保存してください。

var savePath = Path.Combine(FileSystem.AppDataDirectory, "download.bin");
await using var f = File.Open(savePath, FileMode.Create, FileAccess.Write, FileShare.None);
await stream.CopyToAsync(f, 81920, ct);

外部(Downloads 等)へ保存する必要がある場合は、Storage Access Framework(SAF)経由で URI に書き込む方式に切り替えます。READ/WRITE_EXTERNAL_STORAGE の従来権限は最新端末では効かないケースが多く、初回だけ失敗→2 回目以降のキャッシュヒットで成功、という歪な症状を生みます。

再現条件の観測方法(ログ/テレメトリ)

「初回だけ」を捕まえるには、タイムラインをログで可視化するのが一番速いです。

var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
    Debug.WriteLine($"[DL] start: {filePath}");
    using var s = await GetGraphContentWithRetryAsync(graphClient, filePath, ct);
    Debug.WriteLine($"[DL] stream ready +{sw.ElapsedMilliseconds}ms");
    await SaveAsync(s, ct);
    Debug.WriteLine($"[DL] saved +{sw.ElapsedMilliseconds}ms");
}
catch (Exception ex)
{
    // 端末・ビルド・ネットワーク情報も併記
    var env = new
    {
        BuildConfig = "Release",
        AndroidVersion = Android.OS.Build.VERSION.Release,
        Device = $"{Android.OS.Build.Manufacturer} {Android.OS.Build.Model}",
        Network = Connectivity.Current.NetworkAccess.ToString(),
    };
    Debug.WriteLine($"[DL][ERR] +{sw.ElapsedMilliseconds}ms {ex} {System.Text.Json.JsonSerializer.Serialize(env)}");
    throw;
}

「Debug で再現しない」の正体

Debug では以下が原因マスクとして働きます。

  • リンカが無効:反射参照される型が削除されない。
  • 最適化が弱い:初期化順の違いがエラーを呼びにくい。
  • ネットワーク遅延が相対的に小さい:開発用 Wi‑Fi/PC のプロキシ経由。

つまり「Debug で動く」は、Release の正しさの証明にはなりません。Release 相当の設定で実機検証するルーチンを常設しましょう。

Graph SDK v4 と v5(Kiota)の書き方差分と注意点

SDK 世代差での落とし穴にも触れておきます。

項目v4 系v5(Kiota)リンカ配慮
クライアント作成DelegateAuthenticationProvider を渡すだけRequestAdapter/TokenProvider を明示することが多いKiota のアセンブリを linker.xml で保持
パス表現Me.Drive/Drives[driveId]同様(ItemWithPath 等の API 名が微差)モデルのデシリアライズに必要な型を温存
リトライ内蔵ハンドラあり。必要に応じて外側で Polly同様。HTTP パイプラインに追加ハンドラを差し込める外部ライブラリ利用時は型保持に注意

チェックリスト(これだけやれば安定する)

  • ダウンロード~保存までを完全に 非同期 化(CopyToAsync、await 徹底)。
  • Graph 呼び出しに リトライ(指数バックオフ)を導入。
  • Release で リンカを一旦無効 → 再現消失を確認 → linker.xml/Preserve で必要型を温存。
  • 保存先は アプリ固有領域(FileSystem.AppDataDirectory/CacheDirectory)。
  • アプリ起動時に ウォームアップ(軽量 GET/MSAL サイレント取得)。
  • ビルド前に bin/obj を削除 してクリーンビルド。
  • 詳細ログ を残し、初回のみの遅延・失敗点を特定。

トラブルシューティングの深掘り

1. 例外が Java 側で RuntimeException に見える理由

.NET(Mono)の例外が JNI 経由で伝播すると、Java 側では Java.Lang.RuntimeException に丸められることがあります。メッセージが Exception_WasThrown のように素っ気ない場合でも、原因は .NET 例外 であることが多い点に注意が必要です。したがって、C# 側の try/catch とログで「何の .NET 例外が根にあるか」を確実に記録してください。

2. 初回だけ発生する I/O とメモリ圧

大きなファイルを最初に触るタイミングで JIT/AOT のページフォールトや TLS セッション確立が重なり、UI スレッドを占有してしまうことがあります。非同期化に加え、MemoryStream の容量をあらかじめ確保する、または直接ファイルへ書き出してメモリピークを抑えるのが効果的です。

3. AOT の影響

Android Release で AOT を有効化すると、リフレクションのコストは減る一方で、動的生成へより敏感になります。AOT を使う場合は DynamicDependency/Preserve で“動的に触る型”を必ずルート化してください。

4. ストレージ API の世代差

Android の外部ストレージ仕様は世代ごとに変遷しています。共有領域へ書く必要がなければ、まずはアプリ固有領域に保存して問題の切り分けを行い、要件に応じて SAF で共有ディレクトリへ移動させる 2 段構えを推奨します。

設定サンプル集

AndroidManifest(最低限)

&lt;manifest&gt;
  &lt;uses-permission android:name="android.permission.INTERNET" /&gt;
  &lt;application&gt;
    &lt;!-- MSAL 用のリダイレクト捕捉 --&gt;
    &lt;activity android:name="microsoft.identity.client.BrowserTabActivity"&gt;
      &lt;intent-filter&gt;
        &lt;action android:name="android.intent.action.VIEW" /&gt;
        &lt;category android:name="android.intent.category.DEFAULT" /&gt;
        &lt;category android:name="android.intent.category.BROWSABLE" /&gt;
        &lt;data android:scheme="msalYOUR_CLIENT_ID" android:host="auth" /&gt;
      &lt;/intent-filter&gt;
    &lt;/activity&gt;
  &lt;/application&gt;
&lt;/manifest&gt;

保存ユーティリティ(例外包含ログつき)

public static async Task SaveToFileAsync(Stream src, string fullPath, CancellationToken ct)
{
    Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);

```
try
{
    await using var dst = File.Open(fullPath, FileMode.Create, FileAccess.Write, FileShare.None);
    await src.CopyToAsync(dst, 81920, ct);
    await dst.FlushAsync(ct);
}
catch (Exception ex)
{
    // 呼び出し元にも伝えるが、まずディスクに足跡を残す
    var log = Path.Combine(FileSystem.CacheDirectory, "download_error.log");
    await File.AppendAllTextAsync(log, $"[{DateTimeOffset.Now}] {ex}\n", ct);
    throw;
}
```

} 

品質・運用のための追加 Tips

  • CancellationToken を UI 操作(キャンセルボタン)に連動させ、長大ファイル時の離脱体験を向上。
  • 複数ファイルを扱うなら Polly のポリシーで一括管理(再試行・タイムアウト・サーキットブレーカー)。
  • スロットリング(429)を受けたら Retry-After を尊重。Graph はヘッダーで待機時間を指示します。
  • ログに 端末情報/OS バージョン/接続種別 を含め、端末依存の再現を可視化。
  • Graph SDK の更新時は Release 実機で 先に回して、トリミング設定の回帰を検知。

よくある Q&A

Q: エミュレーターでは起きません。端末固有不具合でしょうか?
A: 多くはリンカまたは初期化のタイミング差です。まずリンカ無効で検証し、ウォームアップ+リトライを入れてから端末差を見ると原因が立ち上がります。

Q: Drives["me"] での指定は安全ですか?
A: 自分のドライブなら Me.Drive を使うのが明快です。共有ドライブや特定ドライブ ID を扱う場合のみ Drives[driveId] を選びます。

Q: それでも初回のみ落ちます。
A: ① 非同期化(CopyToAsync)② リンカ無効で再現消失を確認 ③ ウォームアップ+指数リトライ ④ 保存先をアプリ固有領域へ、の順に必ず当ててください。ここまでで解消するケースが大半です。

まとめ

「Android 実機・Release の初回だけ Java.Lang.RuntimeException」という現象の多くは、リンカで消された型・ネットワーク初期化の遅延・同期 I/O の 3 点が重なって生じます。対処はシンプルで、非同期の徹底/リトライ/ウォームアップ/トリミング制御を施せば安定します。加えて、保存先をアプリ固有領域に寄せ、詳細ログで初回の挙動を観測することで、再発を防ぎながら安全に Release 品質へ移行できます。
本稿のテンプレートをベースに、プロジェクトごとに最小の温存設定(linker.xml)と運用ルール(ウォームアップ、DI、ログ)を整備すれば、OneDrive からのファイル取得は再現性高く、ユーザー体験を損なわずに提供できます。


実装スニペットの“使いどころ”早見表

症状貼るコード/設定狙い
初回だけ失敗・2 回目以降 OKウォームアップ+リトライ関数ネットワーク初期化・トークンキャッシュの遅延吸収
Debug では再現せず Release でのみ<AndroidLinkMode>None</AndroidLinkMode> で切り分けリンカ原因の有無を即断
UI が固まる/落ちるCopyToAsync/完全非同期化UI スレッド占有を排除
端末によってだけ落ちる保存先をアプリ固有領域に変更ストレージ権限の差異を避ける

クリーン&再ビルド手順(確実に Release を検証)

  1. IDE を閉じる。
  2. プロジェクトの bin/obj を削除。
  3. Android 実機を再起動またはアプリをアンインストールし、キャッシュをクリア。
  4. Release(リンカ「None」→安定したら必要最小限の温存へ)でビルド・配布。
  5. 起動直後にウォームアップ処理が走ることをログで確認。

仕上げ:安全に最適化を戻す

切り分けで安定を確認できたら、パフォーマンスとサイズを両立するために「温存しつつ最適化」を段階的に戻します。

  • リンカ:SDK Only → 問題が出る領域のみ linker.xml で温存。
  • AOT:CPU 負荷が高い端末で体感差が出るなら有効化。ただし温存設定は維持。
  • HTTP:ConnectTimeout/PooledConnectionLifetime を調整し、初回の体感を短縮。

本記事の手順(非同期化・リトライ・リンカ温存・ウォームアップ・権限見直し)を適用すれば、「初回だけ落ちる」Graph ダウンロード問題は再現しなくなり、実機の Release ビルドでも安定して OneDrive のファイルを取得できるようになります。

この記事を書いた人

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

コメント

コメントする

目次