位置情報に連動して通知を出す小さな機能を、Dolice で個人開発しているアプリに足そうとしたときのことです。「この場所に着いたら、その場所向けの静かな一枚を表示する」——それだけの仕組みのために、全国のスポットを30件ほどジオフェンスとして登録しました。
手元の iPhone で近所の数件は正しく反応します。ところが、登録順で後ろのほうにあるスポットは、実際にその場所へ足を運んでも沈黙したままでした。ログには何のエラーも出ません。Rork Max が生成した CLLocationManager のコードは、見た目にはどこも壊れていない。
原因は、コードではなく iOS 側の上限でした。region monitoring は1つのアプリにつき20件までしか監視できません。 21件目以降は例外も警告も出さず、ただ静かに無視されます。ここでは、その上限を前提にした「近い順に20件だけを登録し直す」設計と、Rork Max の生成物に足すための実装をまとめます。
21件目が沈黙する理由
CLLocationManager の region monitoring(startMonitoring(for:))には、Apple が明記している制約があります。監視できるリージョンは 1アプリあたり最大20件 。この上限を超えて登録しようとしても、startMonitoring(for:) はクラッシュしませんし、false を返すわけでもありません。20件を超えた分は monitoredRegions に加わらないだけで、didEnterRegion は永遠に呼ばれない状態になります。
つまり症状はこうです。
登録した順番 monitoredRegions に入るか didEnterRegion が呼ばれるか
1〜20件目 入る 呼ばれる
21件目以降 入らない(無言) 呼ばれない
Rork Max に「10個の場所でジオフェンス通知を出したい」と伝えると、素直に for ループで全件を startMonitoring(for:) に渡すコードを書いてくれます。10件なら問題は起きません。上限に気づかないまま登録数が増えていったとき、初めて「後半だけ反応しない」という再現性の低い不具合として表面化します。
私自身、この切り分けにいちばん時間を溶かしたのは「ログにエラーが出ない」ことでした。Core Location は上限超過を失敗として扱わないため、print を仕込んでも「登録した」というログだけが並びます。まず確認すべきは、登録処理の直後に manager.monitoredRegions.count を出力することです。ここが21以上にならず、20で頭打ちになっていれば、原因はコードのバグではなく上限だと確定できます。
for region in regions {
manager. startMonitoring ( for : region)
}
// 登録したつもりの件数と、実際に監視されている件数を必ず突き合わせる
print ( "要求: \( regions. count ) 件 / 実際に監視中: \( manager. monitoredRegions . count ) 件" )
要求: 30 件 / 実際に監視中: 20 件 と出た瞬間に、話は「なぜ反応しないのか」から「どの20件を選ぶべきか」に変わります。
上限を消すのではなく、賢く使い回す
20件という上限は、アプリの設定では引き上げられません。増やす方向の解決策は存在しないので、設計を「多数のジオフェンスを、常に近い20件だけ実際に監視する」という形に変えます。論理的なジオフェンスの集合はアプリ側に好きなだけ持っておき、CLLocationManager に渡すのは、そのうちユーザーの現在地に近い順で20件だけ。ユーザーが移動したら、20件の顔ぶれを入れ替える。この「動的再登録」が核心です。
問題は、いつ入れ替えるかです。ここで startMonitoringSignificantLocationChanges()(大幅な位置変更の監視)を使います。これは region monitoring とは別の仕組みで、おおよそ 500m 程度の移動、かつ数分に一度 という粗い粒度で位置更新を通知します。粒度が粗いぶん消費電力が小さく、アプリが終了していても OS がアプリを起こして更新を届けます。ジオフェンスそのものの精度(半径100m前後)には粗すぎますが、「20件を入れ替える頻度」としてはちょうどよい粒度です。
流れを整理すると、次のようになります。
役割 使う API 粒度・特性
実際の到着判定 region monitoring 半径100m前後・最大20件
20件を入れ替える引き金 significant location change 約500m・低消費電力・終了後も起動
全ジオフェンスの保持 自前のストア(配列やDB) 件数無制限
「精度の高い監視は少数だけに絞り、その入れ替えは粗くて省電力な監視に任せる」という二段構えです。全国に数百のスポットを持っていても、実際に CLLocationManager へ載るのは常に手元の20件だけになります。
動的再登録マネージャの実装
ここからは、そのまま Rork Max の生成コードに足せる薄いラッパーです。全ジオフェンスの候補(GeoTarget の配列)を保持し、現在地に近い20件だけを再登録します。
import CoreLocation
struct GeoTarget {
let id: String
let coordinate: CLLocationCoordinate2D
let radius: CLLocationDistance // 100〜150m を推奨(後述)
}
final class GeofenceCoordinator : NSObject , CLLocationManagerDelegate {
private let manager = CLLocationManager ()
private var allTargets: [GeoTarget] = []
// iOS の上限は20。安全マージンとして18件に抑える(理由は後述)
private let maxActiveRegions = 18
var onEnter: (( String ) -> Void ) ?
override init () {
super . init ()
manager.delegate = self
manager.allowsBackgroundLocationUpdates = true
}
func start ( with targets: [GeoTarget]) {
allTargets = targets
manager. requestAlwaysAuthorization ()
// 20件の入れ替えを駆動する粗い監視
manager. startMonitoringSignificantLocationChanges ()
if let here = manager.location {
reregisterNearest ( to : here.coordinate)
}
}
/// 現在地に近い順で maxActiveRegions 件だけを監視し直す
private func reregisterNearest ( to center: CLLocationCoordinate2D) {
let origin = CLLocation ( latitude : center.latitude, longitude : center.longitude)
let nearest = allTargets
. sorted {
let a = CLLocation ( latitude : $0 .coordinate.latitude, longitude : $0 .coordinate.longitude)
let b = CLLocation ( latitude : $1 .coordinate.latitude, longitude : $1 .coordinate.longitude)
return a. distance ( from : origin) < b. distance ( from : origin)
}
. prefix (maxActiveRegions)
let nextIDs = Set (nearest. map (\.id))
// すでに監視中で、今回の20件に含まれないものだけ解除する
for region in manager.monitoredRegions where ! nextIDs. contains (region.identifier) {
manager. stopMonitoring ( for : region)
}
// 新たに監視対象になったものだけ登録する
let currentIDs = Set (manager.monitoredRegions. map (\.identifier))
for target in nearest where ! currentIDs. contains (target.id) {
let region = CLCircularRegion (
center : target.coordinate,
radius : target.radius,
identifier : target.id
)
region.notifyOnEntry = true
region.notifyOnExit = false
manager. startMonitoring ( for : region)
}
}
func locationManager ( _ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let here = locations. last else { return }
reregisterNearest ( to : here.coordinate)
}
func locationManager ( _ manager: CLLocationManager, didEnterRegion region: CLRegion) {
onEnter ? (region.identifier)
}
}
このコードで意図的に選んでいる点が二つあります。ひとつは、毎回すべてを解除してから登録し直すのではなく、差分だけを更新している こと。stopMonitoring と startMonitoring はそれ自体がシステムへの登録操作なので、20件を毎回まるごと入れ替えると、境界付近を行き来したときに無用な再登録が頻発します。今回の20件に残るものはそのまま触らないことで、この揺れを抑えています。
もうひとつは、上限の20件をそのまま使わず 18件に抑えている ことです。region monitoring の枠は、自分のコードだけでなくアプリ内の他の機能や一部のライブラリも消費し得ます。枠を使い切ると、後から登録した2件がまた静かに無視されます。数件の余白を残しておくと、この手の取り合いに悩まされにくくなります。
実機でしか露見しない落とし穴
シミュレータで位置をシミュレートしても、region monitoring はまともに再現できません。この機能は実機で試すことが前提です。Rork Max はブラウザ内のストリーミングシミュレータと、QR で自分の iPhone に直接インストールする経路の両方を持っているので、後者で実機に入れて歩いて確かめるのが確実です。実機ならではの落とし穴を、踏みやすい順に挙げます。まず全体像を先に置きます。
すでにリージョンの内側にいると到着イベントが発火しない
半径を小さくしすぎると、その精度では判定されない
Always 権限と Background Modes を自分で補わないと、閉じている間に反応しない
すでにリージョンの内側にいると、didEnterRegion は呼ばれない。 到着イベントは「外から内へ境界を越えた瞬間」に発火します。アプリを起動した時点でその場所に立っていると、越境が起きないため通知は来ません。起動直後や再登録の直後に manager.requestState(for:) を呼び、didDetermineState で .inside を受け取ったら自分で到着扱いにする必要があります。
func locationManager ( _ manager: CLLocationManager,
didDetermineState state: CLRegionState,
for region: CLRegion) {
if state == .inside {
onEnter ? (region.identifier) // 越境なしで内側にいた場合の補完
}
}
半径を小さくしすぎない。 CLCircularRegion の半径は、端末やハードウェアの都合で実効的な下限があり、100mを大きく下回る値を指定しても、その通りの精度では判定されません。ピンポイントで反応させたい気持ちはわかりますが、100〜150m 程度に留めることを推奨します。そのほうが、到着を取りこぼしにくくなります。
Always 権限が要る。 アプリを閉じている間も到着を拾うには、「常に許可(Always)」が必要です。いきなり Always を求めると拒否されやすいので、まず When In Use で許可を得て、機能を使う文脈で Always への格上げを促すのが現実的です。あわせて Info.plist に NSLocationAlwaysAndWhenInUseUsageDescription と NSLocationWhenInUseUsageDescription を、Signing & Capabilities の Background Modes に Location updates を入れておきます。ここは Rork Max の生成コードが書ききれない領域なので、自分の手で補うことになります。権限設計の考え方は、位置情報が動かないときの切り分けを整理した位置情報の許可が反映されないときの直し方 も併せて読むと、つまずきの全体像がつかめます。
補う場所 入れるもの
Info.plist NSLocationAlwaysAndWhenInUseUsageDescription / NSLocationWhenInUseUsageDescription
Signing & Capabilities Background Modes → Location updates
コード allowsBackgroundLocationUpdates = true / requestAlwaysAuthorization()
iOS 17 以降の CLMonitor という選択肢
iOS 17 からは、region monitoring の後継として CLMonitor が用意されています。CLMonitoringCondition を async の流れで扱え、監視状態が構造化されているため、旧来の delegate ベースよりも見通しよく書けます。到着判定を新規に書き起こすなら、こちらを検討する価値があります。
ただし、CLMonitor に移っても「近い順に絞って使い回す」という設計そのものは残ります。監視できる条件の数には依然として現実的な上限があり、数百のジオフェンスを一度に載せられるわけではないからです。API が新しくなっても、多数の候補から手元の少数を選び直すという考え方は変わりません。この記事のマネージャで組み立てた「全候補は自前で持ち、近いものだけを OS に載せる」構造は、そのまま CLMonitor へ載せ替えられます。対応 OS を iOS 17 以降に引ける段階なら移行を、旧 OS も支えるうちは region monitoring を、というのが私の場合の使い分けです。
次の一歩
まずは、いま登録しているジオフェンス処理の直後に manager.monitoredRegions.count を出力する一行だけ足してみてください。ここが20で止まっているなら、この記事のマネージャに置き換える価値があります。20未満で収まっているなら、動的再登録はまだ要りません——先回りして複雑にしないことも、個人開発では大事な判断だと思っています。
位置情報まわりは、実機で歩いて初めてわかることが多い領域です。私もまだ端末ごとの挙動差に驚くことがありますが、上限を「バグ」ではなく「前提」として設計に織り込めると、ずいぶん扱いやすくなります。お読みいただきありがとうございました。