0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flutter × iOS で「アプリを開かずに服薬記録」を実現した — 通知 BGハンドラの制約と戦った話

0
Last updated at Posted at 2026-06-14

はじめに

MedTap という服薬管理アプリを個人開発しています。

このアプリのコアコンセプトは 「アプリを開かなくて済む服薬アプリ」 です。通知のアクションボタン(「服薬した」/「あとで」)をタップするだけで、アプリを起動しないままデータベースに記録が残ります。

image.png

一見シンプルに聞こえますが、実装してみると 「アプリが起動していない状態でDartコードを動かす」 という制約に直面しました。本記事はその制約と格闘した記録です。


iOS アクション付き通知の仕組み

Flutter で iOS の actionable 通知を実装するには flutter_local_notifications を使います。通知に「服薬した(TAKE)」「あとで(SNOOZE)」ボタンを付けるには、まず iOS カテゴリを登録します。

// iOS カテゴリ登録(アプリ起動時に一度だけ実行)
const DarwinInitializationSettings(
  notificationCategories: [
    DarwinNotificationCategory(
      'MED_REMINDER',
      actions: [
        DarwinNotificationAction.plain('TAKE', '服薬した'),
        DarwinNotificationAction.plain('SNOOZE', 'あとで'),
      ],
    ),
  ],
)

ユーザーがボタンをタップしたとき、アプリがフォアグラウンド・バックグラウンド・終了状態 のどれであっても呼ばれる関数を flutter_local_notifications に登録できます。

FlutterLocalNotificationsPlugin().initialize(
  initSettings,
  onDidReceiveNotificationResponse: notificationResponseHandler,         // FG
  onDidReceiveBackgroundNotificationResponse: notificationBackgroundHandler, // BG/終了
);

フォアグラウンドハンドラonDidReceiveNotificationResponse)はメインアイソレートで動くため、Riverpod も非同期処理も自由に使えます。

問題は バックグラウンドハンドラonDidReceiveBackgroundNotificationResponse)です。


@pragma('vm:entry-point') とは何か

BGハンドラには必ずこのアノテーションが必要です。

@pragma('vm:entry-point')
void notificationBackgroundHandler(NotificationResponse response) {
  // ...
}

Flutter は tree-shaking によってリリースビルドで使われていないコードを削除します。@pragma('vm:entry-point') はその対象から除外する指示です。これを書き忘れると、デバッグビルドは動くがリリースビルドで動かない、という最悪のバグになります。


BGアイソレートの3大制約

BGハンドラは メインアイソレートとは別の Dart アイソレート で動きます。この制約が実装を難しくする根本原因です。

制約1: Riverpod が使えない

メインアプリで使っている ProviderContainer はメインアイソレートのものです。BGアイソレートからはアクセスできないため、既存の Provider を経由した DB アクセスや Service クラスをそのまま呼ぶことができません。

→ DB アクセス・Service クラスをすべて手動で初期化し直す必要があります。

制約2: Supabase(ネットワーク処理)は呼べない

iOS はバックグラウンドハンドラに 数秒以内の完了 を要求します(明確な制限値は非公開ですが、実装上数秒で打ち切られます)。ネットワーク越しの Supabase API を呼ぼうとすると、iOS がハンドラを中断してしまいます。

→ ローカルDBへの書き込みだけで完結させ、Supabase 同期はメインアプリに任せる。

制約3: UI に触れない

別アイソレートから Flutter の Widget ツリーを操作することはできません。BGハンドラ内での Navigator.pushref.read(someProvider) は実行時エラーになります。


なぜ同期で書く必要があるのか

BGハンドラのシグネチャは void 関数 です。iOS ネイティブ側はこの関数が返った後に Dart の Future が完了するのを待ちません。

@pragma('vm:entry-point')
void notificationBackgroundHandler(NotificationResponse response) {
  // ← void。iOS はここが return したら処理完了とみなす
}

await を使った非同期 DB 書き込みは「書き込めているかもしれないし、途中で打ち切られているかもしれない」状態になります。ユーザーが「服薬した」を押した記録を確実に残すには、関数が return するより前に同期的に書き終える必要があります。

Drift の ORM はすべてのメソッドが async です。そのため 同期的な最初の書き込みだけ sqlite3 パッケージを直接使い、その後の非同期処理(スヌーズ再スケジュールなど)は通常の Drift 経由で行う二段構えにしました。


同期DBパスの取得(iOS限定トリック)

「同期で書く必要がある」となると次の壁は「BGアイソレートでどうやって DB ファイルを同期的に開くか」です。

通常は path_providergetApplicationDocumentsDirectory() を使いますが、これは async 関数です。void ハンドラが return する前に DB を開き、書き込みまで終える必要があります。

iOS アプリのドキュメントディレクトリは $HOME/Documents/ に固定されています。Platform.environment['HOME'] は同期的に取得できるため、これを使ってパスを計算します。

File? _dbFileSyncIfPossible() {
  if (!Platform.isIOS) return null;
  final home = Platform.environment['HOME'];
  if (home == null || home.isEmpty) return null;
  return File(path.join(home, 'Documents', 'medtap.sqlite'));
}

取得できなかった場合(Android や Edge ケース)は、async でフォールバックします。

Future<AppDatabase> _openDb() async {
  final dbFolder = await getApplicationDocumentsDirectory();
  return AppDatabase.forFile(File(path.join(dbFolder.path, 'medtap.sqlite')));
}

// 同期パスが取れればそちらを優先、ダメなら async フォールバック
final db = _openDbSyncIfPossible() ?? await _openDb();

sqlite3 での同期書き込み

以上を踏まえた BGハンドラ全体の構造です。

@pragma('vm:entry-point')
void notificationBackgroundHandler(NotificationResponse response) {
  _ensureBackgroundIsolateInitialized(); // WidgetsFlutterBinding 初期化
  final actionHandledAt = DateTime.now().toUtc();

  // ① 同期的に生 sqlite3 で DB 書き込み(void 関数が return する前に完了させる)
  if (_recordBackgroundActionSynchronously(response, actionHandledAt: actionHandledAt)) {
    _signalMainAppRefresh(); // ② メインアプリへシグナル
    _updateWidgetAfterBgAction().ignore(); // ③ WidgetKit 更新(非同期・fire-and-forget)
  }

  // ④ 残りの非同期処理(スヌーズ再スケジュールなど)← ここでは Drift を使う
  _handleBackgroundAction(response, actionHandledAt: actionHandledAt).ignore();
}

生 sqlite3 での書き込みは BEGIN IMMEDIATE トランザクションで囲んでいます。通知ボタンを素早く2回タップしたり、2端末から同時に操作したりしたとき、在庫数の二重減算(TOCTOU 競合)を防ぐためです。PRAGMA busy_timeout=3000 も設定しており、別プロセスがロックを持っていた場合に最大3秒待機します。

db.execute('PRAGMA busy_timeout=3000');
db.execute('BEGIN IMMEDIATE');
try {
  // SELECT → 在庫数更新 → intake_logs INSERT を原子化
  db.execute('UPDATE medications SET stock_count = stock_count - ? ...', [delta]);
  db.execute('''
    INSERT INTO intake_logs (...) VALUES (...)
    ON CONFLICT(medication_id, scheduled_at) DO UPDATE SET
      status = 'taken', ...
  ''');
  db.execute('COMMIT');
} catch (e) {
  db.execute('ROLLBACK');
  rethrow;
}

ON CONFLICT ... WHERE status != 'taken' で taken-wins を SQL で強制

「あとで」ボタンのスヌーズ記録は、すでに「服薬した」が記録されていたら上書きしないという taken-wins ルールが必要です。これを SQL レベルで次のように書きました。

INSERT INTO intake_logs (...) VALUES (...)
ON CONFLICT(medication_id, scheduled_at) DO UPDATE SET
  status = excluded.status,
  ...
WHERE intake_logs.status != 'taken'  -- ← taken は絶対に上書きしない

Dart 側でも if (existing.status == 'taken') return; のガードを入れていますが、SQL で二重に防ぐことで「ガードをすり抜けてしまうレースコンディション」への備えになっています。


アイソレート間通信: IsolateNameServer

BGハンドラでDBに書き込んだ後、メインアプリが起動中なら画面をリフレッシュしてほしいです。しかしアイソレート間で直接オブジェクトを共有することはできません。

Flutter には IsolateNameServer という仕組みがあり、名前付き SendPort を登録・取得できます。

// メインアプリ側(起動時に ReceivePort を登録しておく)
final receivePort = ReceivePort();
IsolateNameServer.registerPortWithName(receivePort.sendPort, kBgRefreshPortName);
receivePort.listen((_) {
  // Riverpod の Provider を invalidate してリフレッシュ
  ref.invalidate(todayPlanProvider);
});

// BGハンドラ側(書き込み完了後にシグナルを送る)
void _signalMainAppRefresh() {
  IsolateNameServer.lookupPortByName(kBgRefreshPortName)?.send(null);
}

lookupPortByName はメインアプリが ReceivePort をその名前で登録していなければ null を返すだけなので、アプリが完全終了中でも安全に呼べます。


iOS 64通知上限との戦い

Flutter の flutter_local_notificationszonedSchedule を使って通知を予約する場合、iOS は 最大 64件 しかペンディング通知を持てません。

この上限を踏む主な原因は 自動追い通知(follow-up) です。MedTap では「あとで」を押さなくても、服薬時刻に通知を送り、そのまま無視された場合はスヌーズ間隔ごとに諦め時刻まで自動で再通知します。

例えば薬1種類・スヌーズ30分・諦め180分なら、1ドースあたり最大7件(プライマリ1件+follow-up 6件)の通知が登録されます。薬が3種類・朝昼晩であれば 3 × 3 × 7 = 63件 で即座に上限です。

rolling-window で60件に抑制

MedTap のスケジューラーは 「今日以降で発火時刻が早いものから60件」 だけをスケジュールする rolling-window 方式にしました(64のうち4件は安全マージン)。

// 最大60日分の候補を生成し、時刻順でソートして先頭60件だけ採用
candidates.sort((a, b) => a.fireTime.compareTo(b.fireTime));

final result = <int, ({tz.TZDateTime fireTime, NotificationPayload payload})>{};
for (final c in candidates) {
  if (result.length >= 60) break; // ← 60件で打ち切り
  result.putIfAbsent(c.id, () => (fireTime: c.fireTime, payload: c.payload));
}

アプリがフォアグラウンドに戻るたびに runMaintenance() を呼び、差分だけ更新します(キャンセル+再スケジュール)。

crc32 で決定論的な通知IDを生成

差分更新するには「同じ dose(薬 × 日付 × 時刻)の通知ID」を何度計算しても同じ値が出る必要があります。BGハンドラからでも計算できる純粋関数でなければなりません。

static int _notificationId(
  String medId, String label, int hour, int minute, String yyyyMMdd,
) {
  final key = '$medId|$label|${hour.toString().padLeft(2, '0')}${minute.toString().padLeft(2, '0')}|$yyyyMMdd';
  final crc = _crc32(key);
  return crc & 0x7fffffff; // 正の int32 に収める
}

static final List<int> _crc32Table = List.generate(256, (i) {
  var c = i;
  for (var j = 0; j < 8; j++) {
    c = (c & 1) != 0 ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
  }
  return c;
});

static int _crc32(String data) {
  var crc = 0xFFFFFFFF;
  for (final byte in data.codeUnits) {
    crc = (crc >>> 8) ^ _crc32Table[(crc ^ byte) & 0xFF];
  }
  return (crc ^ 0xFFFFFFFF);
}

medId|label|HHmm|yyyyMMdd の組み合わせが一意なので、CRC32 衝突のリスクは実用上無視できます(同じアプリ内の通知ID同士が衝突する確率は極めて低い)。


まとめ: ハマりポイントの一覧

ハマりポイント 解決策
@pragma('vm:entry-point') を忘れてリリースビルドだけ動かない 必ず付ける
BGハンドラで Riverpod の Provider が使えない 手動で DB を開き直す
Supabase 呼び出しが iOS に中断される ローカルDBだけに書き、同期は後回し
BGハンドラが void で async 書き込みが保証されない sqlite3 で同期書き込み
async でDBパスを取得する時間がない Platform.environment['HOME'] で同期取得(iOS限定)
2端末 / 2プロセスで在庫が二重減算される BEGIN IMMEDIATE + SELECT/UPDATE/INSERT を原子化
BGで taken を書いた後に snoozed で上書きされる ON CONFLICT ... WHERE status != 'taken'
別アイソレートからメインアプリをリフレッシュできない IsolateNameServer + SendPort
iOS 64通知上限で将来の通知が予約できない rolling-window(60件上限)+ 差分更新
通知IDが毎回変わってキャンセルできない crc32 で決定論的ID生成

おわりに

「通知ボタンをタップするだけで記録できる」という体験は非常に小さな UX 改善ですが、実装の裏側にはアイソレート・同期IO・SQLトランザクション・iOS通知制限といった層が積み重なっています。

Flutter は通常の UI 開発では隠蔽してくれているこれらの制約が、BGハンドラという特殊なコンテキストに入った途端に一気に噴き出してきます。

同じような「通知ボタンでサイレントDB書き込み」を実装しようとしている方の参考になれば幸いです。


MedTap は App Store にて公開中です。
https://apps.apple.com/jp/app/medtap-%E6%9C%8D%E8%96%AC%E7%AE%A1%E7%90%86-%E3%81%8A%E8%96%AC%E3%83%AA%E3%83%9E%E3%82%A4%E3%83%B3%E3%83%80%E3%83%BC/id6772442502

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?