「.NET MAUI で Android の OnDestroy が発火しない」――ログを眺めていると、OnSaveInstanceState は出るのに OnDestroy は沈黙、という現象に出会いがちです。実はこれは不具合ではなく、Android の「アプリをどう離脱・終了したか」によってライフサイクルが変わるのが本質です。本記事では、.NET MAUI における具体的な登録コード、観測できるログ例、原因の正体、そして堅牢な対処方針までを一気通貫で解説します。
.NET MAUI での前提:ライフサイクルの登録方法
.NET MAUI では MauiProgram.cs の ConfigureLifecycleEvents を使うと、ネイティブの Android Activity ライフサイクルに直接フックできます。まずは、典型的な登録コードを示します。
using Android.App;
using Android.OS;
using Microsoft.Maui;
using Microsoft.Maui.Hosting;
namespace YourApp;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureLifecycleEvents(events =>
{
#if ANDROID
events.AddAndroid(android =>
{
android
.OnCreate((activity, bundle) =>
{
Android.Util.Log.Debug("LIFECYCLE", "OnCreate");
})
.OnStart((activity) => Android.Util.Log.Debug("LIFECYCLE", "OnStart"))
.OnResume((activity) => Android.Util.Log.Debug("LIFECYCLE", "OnResume"))
.OnPause((activity) => Android.Util.Log.Debug("LIFECYCLE", "OnPause"))
.OnStop((activity) => Android.Util.Log.Debug("LIFECYCLE", "OnStop"))
.OnSaveInstanceState((activity, outState) =>
{
Android.Util.Log.Debug("LIFECYCLE", "OnSaveInstanceState");
// 最小限の UI 状態の保存(例:スクロール位置や選択中タブなど)
outState.PutString("ui_key", "value");
})
.OnDestroy((activity) =>
{
// 破棄理由のヒント
bool finishing = activity.IsFinishing; // 例:戻るボタンで finish()
bool changing = activity.IsChangingConfigurations; // 例:画面回転など構成変更
Android.Util.Log.Debug("LIFECYCLE",
$"OnDestroy finishing={finishing} changing={changing}");
// 最終的な破棄時のクリーンアップ
// ただし OnDestroy 非保証の前提で "最後の砦" 程度に留める
});
});
#endif
});
return builder.Build();
}
}
上記のように登録しても、状況によって OnDestroy が出たり出なかったりします。次章で、どの操作で何が起きるかを整理します。
Android の標準挙動:終了・離脱の仕方でイベントは変わる
「アプリを終える・離れる操作」によって、Activity がたどるイベントは異なります。以下は現実的な観測例と、その意図の整理です(端末や OS バージョンで順序や有無が前後することがあります)。
| 終了・離脱操作 | 主に発生するイベント | 要点・解説 |
|---|---|---|
| 戻るボタンで終了 | OnPause → OnStop → OnDestroy(多くの場合 OnSaveInstanceState なし) | finish() が呼ばれ、Activity が明示的に破棄されます。IsFinishing == true になりやすい。 |
| ホームボタンで離脱 | OnPause → OnStop(しばしば OnSaveInstanceState が呼ばれる) | アプリはバックグラウンド待機。Activity は残り、プロセスも存続。OnDestroy は呼ばれません。 |
| 履歴(Overview)でカードをスワイプして終了 | OnPause → OnStop → OnSaveInstanceState → OnDestroy | 多くの端末で、一度 UI 状態を軽量保存してから Activity を最終破棄します。 |
| 画面回転などの構成変更 | OnPause → OnStop → OnSaveInstanceState → OnDestroy → 新しい Activity の OnCreate… | 旧 Activity は破棄されるが再生成前提。activity.IsChangingConfigurations == true がヒント。 |
| OS によるプロセス・キル(メモリ圧迫等) | コールバック無し(OnDestroy も来ない) | バックグラウンド中に容赦なく殺されることがある。だから OnDestroy 頼みは危険。 |
結論:ホームで離れた・OSに殺された等のケースでは OnDestroy は来ないのが標準挙動で、バグではありません。
質問の核心に答える:なぜ OnDestroy が出ないのか?
- 原因:Android の設計上、
OnDestroyは「Activity を今まさに片付ける」ときだけ呼ばれます。ホーム離脱やプロセス・キルでは「まだ残ってよし/予告なく消す」ため、OnDestroyは保証されません。 - 対処:破棄に依存しない設計にする。つまり、早い段階(
OnPause/OnStop)で安全にリソース解放し、UI 状態はOnSaveInstanceStateで最小保存、永続データは確実にディスクへ(DB/Preferences/SecureStorage)。
観測を確かにする:ログで比較する
次のようにログを仕込むと、操作ごとの違いを目で確認できます。
// 既出の ConfigureLifecycleEvents をベースに、より詳細なログを追加
android
.OnPause((a) => Android.Util.Log.Debug("LIFECYCLE", "OnPause"))
.OnStop((a) => Android.Util.Log.Debug("LIFECYCLE", "OnStop"))
.OnSaveInstanceState((a, b) => Android.Util.Log.Debug("LIFECYCLE", "OnSaveInstanceState"))
.OnDestroy((a) =>
{
Android.Util.Log.Debug(
"LIFECYCLE",
$"OnDestroy finishing={a.IsFinishing} changing={a.IsChangingConfigurations}");
});
ADB で操作をスクリプト化すると再現が容易です(必要に応じて実機・エミュレータで)。
# 戻るボタン相当
adb shell input keyevent KEYCODE_BACK
# ホーム相当
adb shell input keyevent KEYCODE_HOME
# 最近アプリ表示(スワイプ操作は手動が確実)
adb shell input keyevent KEYCODE_APP_SWITCH
# (参考)強制終了:コールバックは来ない
# adb shell am force-stop your.package.name
設計の指針:安全なリソース解放と状態保存
1) リソース解放は「早め・複層」で行う
OnPause:ユーザーがアプリを離れる直前。UI の即時停止(アニメ・センサー購読・一時停止)。OnStop:画面が見えなくなった段階。重いリソース(カメラ・マイク・ロケーション・メディアプレイヤー等)の解除。OnDestroy:最後の砦。来ない可能性を前提に、ここだけに解放を寄せない。
public sealed class LocationService : IAsyncDisposable
{
readonly CancellationTokenSource _cts = new();
public async Task StartAsync()
{
// 位置情報の購読開始など
}
public async Task StopAsync()
{
// 購読停止やハンドラ解除
_cts.Cancel();
}
public ValueTask DisposeAsync()
{
_cts.Cancel();
_cts.Dispose();
return ValueTask.CompletedTask;
}
}
// Maui 側で
LocationService? _location;
android.OnStop(async a =>
{
if (_location != null)
await _location.StopAsync(); // 見えなくなった時点で止める
});
android.OnDestroy(a => _location?.DisposeAsync().AsTask().Wait());
2) UI 状態・一時データは OnSaveInstanceState へ
OnSaveInstanceState は 小さな UI 状態(選択インデックス、スクロール位置、入力中文字列の一部など)の保存に向きます。大きなオブジェクトや画像バイナリは避け、復元は必要最小限にしましょう。Binder のトランザクション制限に当たりやすく、肥大化はクラッシュ要因になります。
android.OnSaveInstanceState((activity, outState) =>
{
// 例:選択中タブとスクロール位置だけ保存
outState.PutInt("selected_tab", viewModel.SelectedIndex);
outState.PutInt("list_scroll", myRecyclerView?.ComputeVerticalScrollOffset() ?? 0);
});
3) 永続化は確実にディスクへ
ユーザーデータや設定値は Preferences / SecureStorage / ローカルDB に保存し、「ここだけは絶対失えない」ものは OnPause 〜 OnStop の早い段階でコミットしておきます。
// 例:Preferences に即時反映
Preferences.Set("draft_title", viewModel.DraftTitle);
// 例:重要情報は SecureStorage
await SecureStorage.SetAsync("token", tokenString);
4) 「終了検知」に依存しない
Android には「アプリ終了イベント」はありません。OnDestroy は Activity の片付けであり、プロセス終了やタスク終了の通知ではありません。終了時だけ実行したい処理という発想を捨て、「離脱時・不可視化時にやるべきこと」へ落とし込むのが安定します。
5) 構成変更(回転等)を見分ける
構成変更では OnDestroy が来ますが、再生成される前提です。重い破棄・解放を避けたい場合は activity.IsChangingConfigurations を見て分岐します。
android.OnDestroy(activity =>
{
if (activity.IsChangingConfigurations)
{
Android.Util.Log.Debug("LIFECYCLE", "Rotation etc - skip heavy dispose");
return;
}
// 本当に終わるときだけ重い解放
ReleaseBigCaches();
});
6) どうしてもプロセス範囲のフックが必要なら
キャッシュ削減などのヒントには、Application の OnTrimMemory(メモリ圧迫通知)を使う設計もあります。.NET MAUI でも ConfigureLifecycleEvents と組み合わせて「メモリが厳しい時にキャッシュを捨てる」などの戦略を立てられます。
よくある誤解とアンチパターン
- 誤解:
OnDestroyは常に呼ばれる → ×。ホーム離脱・プロセス・キルでは呼ばれません。 - 誤解:
OnSaveInstanceStateは終了時にしか呼ばれない → ×。画面回転や他画面遷移でも呼ばれます。 - 誤解:「終了検知できれば全部そこで保存」→ ×。遅すぎます。
OnPause/OnStopを主戦場に。 - アンチパターン:巨大データを
Bundleに突っ込む → クラッシュ予備軍。
テストの実践プロトコル
「自分の端末ではどう鳴るか」を確かめることが大切です。以下の観測観点をチェックリスト化しましょう。
| 観測観点 | 期待 | 検証方法 |
|---|---|---|
戻る終了時に OnDestroy | 多くの端末で呼ばれる(IsFinishing=true) | 戻るキー操作後のログを確認 |
ホーム離脱時に OnDestroy 非発火 | OnPause / OnStop まで | ホーム押下→再度アプリ復帰のログ比較 |
| Overview スワイプ終了 | OnSaveInstanceState → OnDestroy の並び | カードをスワイプで消し、直後のログを見る |
| 画面回転での挙動 | IsChangingConfigurations=true の OnDestroy | 回転前後で保存・復元の整合を確認 |
| バックグラウンド中のプロセス・デス | コールバックなし(復帰時に新規起動) | 重いアプリ併用などで再現/現象理解 |
Page/ViewModel レイヤーでの補強(.NET MAUI 流)
Activity レベルだけでなく、XAML ページや ViewModel の寿命でも対策を重ねると堅牢です。
- ページの
OnAppearing/OnDisappearing:センサー購読やタイマー開始・停止の境界に最適。 - IDisposable / IAsyncDisposable:ViewModel で外部リソースを握るなら、遷移時に確実に破棄。
- CancellationToken:非同期処理はキャンセルトークンを通し、
OnStop/OnDisappearingから中断可能に。
public partial class SamplePage : ContentPage
{
readonly CancellationTokenSource _uiCts = new();
protected override void OnAppearing()
{
base.OnAppearing();
StartTimer(_uiCts.Token);
}
protected override void OnDisappearing()
{
_uiCts.Cancel(); // 画面離脱で確実に停止
base.OnDisappearing();
}
void StartTimer(CancellationToken token)
{
Dispatcher.StartTimer(TimeSpan.FromSeconds(1), () =>
{
if (token.IsCancellationRequested) return false;
// UI 更新
return true;
});
}
}
「終了っぽい」が実は違うケース
以下のケースは「ユーザー視点では終わった感じ」でも、システム視点では終了していません。OnDestroy が出ない理由になります。
- ホーム離脱:アプリは残るので、通知・ショートカット・最近のアプリから即復帰できます。
- 他 Activity への遷移:今回の Activity は見えないが、戻るスタックに積まれているだけ。
- 分割画面/ピクチャ・イン・ピクチャ:可視・不可視の境界が複雑化し、停止・再開が頻繁に。
安定運用のための実践 Tips
- ログタグ統一:
"LIFECYCLE"などに統一してadb logcat LIFECYCLE:D *:Sで絞り込み。 - 中途保存のタイミング:ドラフトや入力値は
OnPauseでコミット。「最後にまとめて」は禁物。 - 構成変更の最適化:回転で重い破棄・再生成を避けるため、再生成前提のキャッシュ設計にする。
- 重い I/O の抑制:
OnStopで毎回 DB フラッシュは重い。差分書き込みやスロットリングを。 - エッジ端末で検証:OEM カスタムの差が出やすい。2〜3メーカーでの実機テストを習慣化。
トラブルシューティング:ケース別の見立て
ケースA:ホームに戻ると OnDestroy が来ない
仕様通りです。OnPause/OnStop を基点に解放・保存しましょう。気になるリークは、OnStop でカメラ・マイク・位置情報・メディア等を確実に閉じることで解消されます。
ケースB:画面回転時に重い再初期化でカクつく
OnDestroy は来ていますが構成変更です。activity.IsChangingConfigurations を見て、破棄や再初期化を軽くするかスキップしましょう。「本当に終わるとき」だけ重い解放を行うのがポイント。
ケースC:バックグラウンド滞在中に OS に殺され、復帰時に入力が消える
これは プロセス・デスです。OnDestroy を待つのは無理筋。入力やドラフトを小刻みに Preferences/DB へ反映しておくと耐性が上がります。UI 状態(カーソル位置など)は OnSaveInstanceState に。
メイン Activity を直接拡張したい場合(.NET MAUI)
.NET MAUI は既定で MauiAppCompatActivity を使う単一 Activity 構成です。必要に応じて MainActivity 側でオーバーライドも可能です。
using Android.App;
using Android.OS;
using Microsoft.Maui;
namespace YourApp;
[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, ConfigurationChanges =
Android.Content.PM.ConfigChanges.Orientation |
Android.Content.PM.ConfigChanges.ScreenSize |
Android.Content.PM.ConfigChanges.UiMode |
Android.Content.PM.ConfigChanges.ScreenLayout |
Android.Content.PM.ConfigChanges.SmallestScreenSize)]
public class MainActivity : MauiAppCompatActivity
{
protected override void OnSaveInstanceState(Bundle outState)
{
base.OnSaveInstanceState(outState);
Android.Util.Log.Debug("LIFECYCLE", "MainActivity.OnSaveInstanceState");
}
protected override void OnDestroy()
{
Android.Util.Log.Debug("LIFECYCLE",
$"MainActivity.OnDestroy finishing={IsFinishing} changing={IsChangingConfigurations}");
base.OnDestroy();
}
}
ただし、.NET MAUI のクロスプラットフォーム性を保つには、まずは ConfigureLifecycleEvents で事足りる設計を優先すると保守性が高くなります。
実運用でのサンプル設計(まとめ)
| 関心事 | 推奨フック | 実装の要点 |
|---|---|---|
| 入力中ドラフトの保全 | OnPause(ページの OnDisappearing でも可) | Preferences/DB に即時保存。差分更新で負荷低減。 |
| センサーやカメラの解放 | OnStop | 見えなくなったら必ず止める。再表示で再初期化。 |
| UI 状態(軽量)の保存 | OnSaveInstanceState | Bundle は軽く。ID/インデックス/位置など最小限のみ。 |
| 最終破棄時の念押し | OnDestroy | 来ない前提。構成変更なら重い破棄を避ける分岐を。 |
| メモリ圧迫対策 | OnTrimMemory(Application) | キャッシュ削減などの緊急対応に。 |
FAQ
Q1. 戻るボタンでも OnSaveInstanceState が出ることがある?
A. 端末・OS・遷移状況により呼ばれることがあります。大切なのは「出るかもしれない/出ないかもしれない」を前提に、保存・解放の重複を恐れず堅牢化することです。
Q2. 「終了しました」を検知して最後に 1 回だけ同期したい。
A. その発想は Android では困難です。OnPause で小刻みに同期する設計に改める方が現実的で事故が少ないです。
Q3. OnDestroy が来ないとリークが怖い。
A. だからこそ、OnStop で重いものは確実に止め、OnDisappearing や IDisposable を徹底して「そもそも握りっぱなしにしない」方針にしましょう。
総括
本件の正体はバグではなく、Android の設計思想そのものです。OnDestroy は「呼ばれないこともある」コールバックであり、ホーム離脱・プロセス・デスでは沈黙します。早めの保存・早めの解放・軽量な UI 状態保存・重い破棄の条件分岐という 4 点を守れば、.NET MAUI の Android でも予測可能で堅牢な挙動を獲得できます。今日からログを整え、終了手段ごとの動作を観測し、設計に反映させていきましょう。
付録:最小検証プロジェクトの雛形
まずはイベントの鳴り方だけを観測する小さなプロジェクトを作ってみると理解が深まります。
// App.xaml.cs
public partial class App : Application
{
public App()
{
InitializeComponent();
MainPage = new NavigationPage(new MainPage());
}
}
// MainPage.xaml.cs
public partial class MainPage : ContentPage
{
public MainPage()
{
InitializeComponent();
}
protected override void OnAppearing()
{
base.OnAppearing();
System.Diagnostics.Debug.WriteLine("PAGE OnAppearing");
}
protected override void OnDisappearing()
{
System.Diagnostics.Debug.WriteLine("PAGE OnDisappearing");
base.OnDisappearing();
}
}
// MauiProgram.cs(前掲の ConfigureLifecycleEvents を貼る)
この最小構成でも、戻る・ホーム・Overview スワイプ・回転の違いがはっきり見えます。まずは「観測できる真実」を掴むところから始めましょう。
結論(短く実務向け)
- 挙動の要約:戻る終了=
OnDestroyが来やすい/ホーム離脱=来ない/Overview スワイプ=OnSaveInstanceState→OnDestroy。 - 設計の型:
OnPause(即時保存)→OnStop(重い解放)→OnDestroy(最後の砦)。 - 落とし穴回避:
OnDestroy依存を捨てる・Bundle肥大を避ける・構成変更を見分ける。
参考チェックリスト(プロジェクトに貼っておくと便利)
- [ ]
ConfigureLifecycleEventsで主要イベントにログを仕込んだ - [ ] 戻る/ホーム/Overview スワイプ/回転の 4 ケースでログを採取した
- [ ]
OnPauseでドラフト保存・OnStopで重い解放を実装した - [ ]
OnSaveInstanceStateは軽量データのみを保存する - [ ]
IsChangingConfigurationsで回転時の重い破棄を抑制した - [ ] ViewModel の
IDisposable/キャンセル連携を整備した - [ ] メモリ圧迫時のキャッシュ削減(
OnTrimMemory)方針を定めた
最後に:この記事の要点を 1 文で
「OnDestroy は呼ばれないことがある」――だから OnPause/OnStop を主戦場に、OnSaveInstanceState で軽量保存、OnDestroy は最後の砦という複層防御で設計する。

コメント