「.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 で次のように追加します。
<ItemGroup>
<AndroidLinkerDescriptor Include="linker.xml" />
</ItemGroup>
属性での温存: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<string> GetAccessTokenAsync(IEnumerable<string> 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 =>
{
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) =>
{
var token = await GetAccessTokenAsync(new[] { "Files.Read" }, CancellationToken.None);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}), http);
});
アプリ起動時に「軽量 GET」でウォームアップします。
var client = Services.GetRequiredService<GraphServiceClient>();
// 存在チェック等で軽く叩き、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(最低限)
<manifest>
<uses-permission android:name="android.permission.INTERNET" />
<application>
<!-- MSAL 用のリダイレクト捕捉 -->
<activity android:name="microsoft.identity.client.BrowserTabActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="msalYOUR_CLIENT_ID" android:host="auth" />
</intent-filter>
</activity>
</application>
</manifest>
保存ユーティリティ(例外包含ログつき)
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 を検証)
- IDE を閉じる。
- プロジェクトの
bin/objを削除。 - Android 実機を再起動またはアプリをアンインストールし、キャッシュをクリア。
- Release(リンカ「None」→安定したら必要最小限の温存へ)でビルド・配布。
- 起動直後にウォームアップ処理が走ることをログで確認。
仕上げ:安全に最適化を戻す
切り分けで安定を確認できたら、パフォーマンスとサイズを両立するために「温存しつつ最適化」を段階的に戻します。
- リンカ:
SDK Only→ 問題が出る領域のみlinker.xmlで温存。 - AOT:CPU 負荷が高い端末で体感差が出るなら有効化。ただし温存設定は維持。
- HTTP:
ConnectTimeout/PooledConnectionLifetimeを調整し、初回の体感を短縮。
本記事の手順(非同期化・リトライ・リンカ温存・ウォームアップ・権限見直し)を適用すれば、「初回だけ落ちる」Graph ダウンロード問題は再現しなくなり、実機の Release ビルドでも安定して OneDrive のファイルを取得できるようになります。

コメント