ここメモ — iOS バックグラウンドジオフェンスを Native Region Monitoring 化

| 開発記録 | ここメモ

タグ: #iOS #ジオフェンス #Flutter

「スーパーの近くで牛乳を思い出したい」。ここメモはそれだけのアプリです。でも Flutter の位置情報プラグインは、バックグラウンドでぜんぜん粘ってくれませんでした。iOS ネイティブの Region Monitoring に乗り換えて、権限フローまで組み直した記録です。

アプリの狙い

「ここメモ」は、ToDo を「場所」と紐付けるアプリです。その場所に近づくと通知が飛びます。スーパーの近くで「牛乳を買う」。図書館の近くで「本を返す」。そういう使い方を想定しています。

Flutter プラグインの限界

位置情報系の Flutter プラグイン(geofence_service 等)は、フォアグラウンドではしっかり動きます。つまずいたのはその先でした。バックグラウンドでの精度、電池の消費、iOS の制約。どれも課題として残りました。

特に iOS がしんどかったです。アプリが完全終了した状態からの復帰。長時間バックグラウンドでの監視。この二つで、プラグインが期待どおりに動かないケースが何度も出ました(プラグインに任せられると思い込んでいました)。

Native Region Monitoring への移行

iOS には CLLocationManager.startMonitoring(for:) があります。OS が責任を持って地域監視してくれる API です。これならアプリが終了していても、境界を超えたタイミングで OS がアプリを起こして通知してくれます。

この API を Swift で呼ぶラッパーを書きました。Flutter 側からは Method Channel 経由で制御します。

ネイティブ側の最小実装はこの程度に収まります。always 権限が前提なので、起動時に権限状態を確認するロジックも合わせて入れます。

final class GeofenceController: NSObject, CLLocationManagerDelegate {
  private let manager = CLLocationManager()

  override init() {
    super.init()
    manager.delegate = self
  }

  func register(id: String, lat: Double, lng: Double, radius: Double) {
    let center = CLLocationCoordinate2D(latitude: lat, longitude: lng)
    let region = CLCircularRegion(
      center: center,
      radius: min(radius, manager.maximumRegionMonitoringDistance),
      identifier: id
    )
    region.notifyOnEntry = true
    region.notifyOnExit = false
    manager.startMonitoring(for: region)
  }

  func locationManager(_ m: CLLocationManager, didEnterRegion r: CLRegion) {
    NotificationBridge.fireEnter(id: r.identifier)
  }
}

Dart 側は MethodChannel をラップした薄い API として公開しました。画面側は永続化された ToDo から登録するだけです。

class GeofenceBridge {
  static const _channel = MethodChannel('kokomemo/geofence');

  Future<void> register(GeoTodo todo) async {
    await _channel.invokeMethod('register', {
      'id': todo.id,
      'lat': todo.lat,
      'lng': todo.lng,
      'radius': todo.radiusMeters,
    });
  }

  Future<void> unregister(String id) async {
    await _channel.invokeMethod('unregister', {'id': id});
  }
}

バックグラウンドの安定化

移行と合わせて、以下の整備をしました。

  • UNUserNotificationCenter.delegate を AppDelegate で明示設定
  • iOS フォアグラウンドでも通知バナーを表示
  • 通知許可詳細とアクティブ通知のダンプを取得(デバッグ用)
  • 起動 60 秒後に自動テスト通知を発火(実機検証用)

下の二つはどちらも実機検証のためです。ジオフェンスは机の上では試せません。ここがいちばんしんどいところでした。

権限フローの再設計

位置情報と通知の権限フローは、ユーザーから見て複雑になりがちです。必要なものが並びます。

  • 位置情報「常に許可」が必要
  • 通知の許可も必要
  • iOS の Motion 使用許可(NSMotionUsageDescription)も必要

これらを段階的に取得していくフローに設計し直しました。「常に許可」が取れなかったときのフォールバックも入れています。設定アプリへの誘導も組み込みました。

App Check と Cloud Functions

ここメモには ToDo の共有機能があります。家族や友人と ToDo を共有できます。Firestore へのアクセスは App Check で守りました。招待・復元・FCM 通知などのサーバー処理は Cloud Functions に集約しています。

リージョンは asia-northeast1 に固定しました。レイテンシを下げるためです。

バナー広告のアダプティブ化

マネタイズはバナー広告です。メモ一覧の上部に置いています。当初は固定サイズでした。今は画面サイズに応じてアダプティブに変わります。Android の画面比率がとにかく多様だからです。

まとめ

  • iOS のバックグラウンド位置情報は Native Region Monitoring 一択
  • 権限フローは段階化して、ユーザーが理解できる順番で
  • バックエンド連携は App Check と Cloud Functions の組み合わせ

プラットフォームの API を本気で使い倒すと、Flutter プラグインだけでは届かない領域に手が届きます。ネイティブを書くのは遠回りに見えて、結局いちばん近い道でした。