本文へスキップ
すべての記事

Androidの通知アクションを正しく作る:アプリを開かずに服用済みを記録する方法

Androidの通知アクションボタンの実践ガイド — PendingIntent、アクティビティ・トランポリン制限、goAsync()、そしてRoomの状態を安全に更新する方法。

MFKAPPS 1 分で読めます

リマインダーを閉じるためにアプリを開く必要があるなら、誰も望んでいない手順を追加してしまっています。通知の本来の意味は、それ自体がインターフェースであることです — ユーザーはそれに反応し、そのまま次に進みます。服薬リマインダーであるOldSchoolは、まさにこれに依存しています。ロック画面の服用済みスヌーズスキップの各ボタンは即座に動作し、正しいデータを更新し、ユーザーが求めていないActivityを決して起動してはなりません。ここでは、それがどう構築されているか、そしてAndroidが必要以上に難しくしている2、3の点を説明します。

「とりあえずアプリを開く」の問題

リマインダー通知の素朴な実装は、ボタンを押す画面へアプリを開く一回のタップです。これは本来1つで済むはずのことに対して2つ余分な操作を加えています。また、毎回Activityをコールドスタートすることも意味します — 他に十数個のアプリがメモリに常駐しているミドルレンジのスマートフォンでは、「リマインダーを見た」から「完了としてマークされた」までの間に目に見える遅延が生じます。

解決策はNotificationCompat.Actionです。通知に直接付随し、Activityを一切前面に出さずに発火するボタンです。

val takenIntent = Intent(context, DoseActionReceiver::class.java).apply {
    action = ACTION_MARK_TAKEN
    putExtra(EXTRA_DOSE_ID, doseId)
}
val takenPendingIntent = PendingIntent.getBroadcast(
    context,
    doseId.hashCode(),
    takenIntent,
    PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)

NotificationCompat.Builder(context, CHANNEL_ID)
    .setContentTitle("Metformin — 500mg")
    .addAction(R.drawable.ic_check, "Taken", takenPendingIntent)
    .addAction(R.drawable.ic_snooze, "Snooze", snoozePendingIntent)
    .addAction(R.drawable.ic_skip, "Skip", skipPendingIntent)
    .build()

3つのボタン、3つのPendingIntent。それぞれがActivityではなくBroadcastReceiverを指しています。この違いこそが、すべてのカギです。

なぜActivityではなくreceiverなのか

アクションをActivity経由で処理したくなる気持ちはわかります — すでに1つあり、すでにデータベースと会話しています。しかしそれはやめましょう。Android 12以降、通知アクションから直接Activityを起動すること(通知本体のメインタップターゲットからではなく)は「アクティビティ・トランポリン」として制限されており、まだ動作する場合でも、望まないUIのフラッシュ、タスクスイッチャーへのエントリ、そしてより遅い応答を意味します。BroadcastReceiverはこれらのいずれも行いません — 実行し、仕事をこなし、通知の更新以外に目に見えることは何も起きません。

FLAG_IMMUTABLEも重要です。Android 12以降、RemoteInputのように明示的にFLAG_MUTABLEを必要とするもの以外では、可変なPendingIntentはデフォルトでブロックされます。単純なアクションボタンに可変性は不要です — immutableとして設定して先に進みましょう。

receiver内で実際の処理を行う:goAsync()

BroadcastReceiver.onReceive()の厄介な点は、ほぼ即座に返ることが期待されていることです — 数ミリ秒であり、バックグラウンドでRoomに書き込む時間ではありません。書き込みが終わる前に返ってしまうと、一部のOEMスキンではプロセスが書き込みの途中で強制終了される可能性があります。

goAsync()はこの追加時間を正しく確保します。

class DoseActionReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val doseId = intent.getLongExtra(EXTRA_DOSE_ID, -1)
        val pendingResult = goAsync()

        CoroutineScope(Dispatchers.IO).launch {
            try {
                doseDao.markTaken(doseId, timestamp = System.currentTimeMillis())
                NotificationManagerCompat.from(context).cancel(doseId.toInt())
            } finally {
                pendingResult.finish()
            }
        }
    }
}

goAsync()はおよそ10秒の延長された寿命を与えてくれます — Roomへの書き込みには十分ですが、フォアグラウンドサービスを正当化するには足りません。pendingResult.finish()は必ずfinallyブロックで呼び出してください。例外パスでこれを忘れると、システムが解放できないwakelockを保持し続けることになり、やがてあなたのreceiverが問題のある動作をしていると判断されてしまいます。

通知とデータベースを正直に保つ

用量を「服用済み」としてマークすることは、単なるデータベースの行ではありません — 通知自体も消えるか変化しなければ、ユーザーは再びそれをタップし、なぜ何も起きないのか不思議に思うでしょう。書き込み後にNotificationManagerCompat.cancel()を呼ぶのは最低限です。スヌーズについては、2つ目の通知パイプラインを新たに作るのではなく、元のリマインダーが使ったのと同じAlarmManager/WorkManager経路で再スケジュールします(このスケジューリング部分についてはWorkManagerと正確なアラームに関する記事を参照)。1つのリマインダー、1つのコードパス — 最初に発火しても3回目に発火しても関係ありません。

もう一つのよくある間違いは、誰も監視していないメモリ内の状態を更新することです。当日ビューがRoomのFlowによって支えられているなら、receiverの書き込みだけで十分です — 明示的な同期コードなしに、次に表示されたときにUIが再構成されます。そうでなければ、ユーザーがアプリに戻った瞬間に古いデータが表示され、結局は実際には観察者が欠けているだけの競合状態をデバッグする羽目になります。

これがもたらすもの

紙の上での測定可能な違いは劇的ではありません — 画面が1つ減り、タップが1つ減るだけです — しかしそれはリマインダーの体感を変えます。表示された場所で1秒足らずで自己完結する通知は、あなたが与えた2秒を尊重しているように感じられます。アプリのコールド起動を要求する通知は摩擦として感じられ、その摩擦こそが、リマインダーを使うものから習慣的にスワイプして消すものへと変えてしまうのです。

「今すぐこれに反応する」タイプの通知を構築しているなら — リマインダー、タスクの後押し、配送確認、何であれ — このパターンはドメインを問わず通用します。アクションはBroadcastReceiver経由で処理し、goAsync()でウィンドウを拡張し、既存のデータレイヤーを通して書き込み、変化を手動でプッシュする代わりにリアクティブなUIに拾わせる。それは、欠けているときにしかユーザーが気づかない機能のための、ささやかな配管作業です。