個人で「まいすた」という iPhone アプリを作っています。行った場所が自動で記録され、毎朝きのうの日記が届く、書かなくていい日記アプリです。
このアプリの土台は「どこに、いつからいつまでいたか」を、利用者が何もしなくても残すことです。この記事では、CLVisit と CLMonitor を組み合わせて滞在を記録する実装と、実機でハマったところを書きます。
- 対象: iOS 26 以降(
CLMonitorは iOS 17、CLServiceSessionは iOS 18 から) - 位置情報の許可は「常に許可」が前提です
全体の構成
| 仕組み | 役割 |
|---|---|
CLVisit |
滞在の検出の主役。電池にやさしく、アプリが終了していても起こしてくれる |
CLMonitor(円の出入り) |
CLVisit が取りこぼす短い立ち寄りを補う |
| 大幅な位置変更(significant-change) | 移動の経路を点で残し、距離を出す |
MKLocalPointsOfInterestRequest |
座標から店の名前を引く |
startUpdatingLocation は使っていません。電池とプライバシーのためです。常に GPS を動かす代わりに、OS の仕組みに起こしてもらう形にしています。
CLVisit:まず主役を立てる
CLVisit は、OS が「ここにしばらくいた」と判断したときに知らせてくれます。電池の消費が小さく、アプリが終了していても起こしてくれます。
nonisolated func locationManager(_ manager: CLLocationManager, didVisit visit: CLVisit) {
// CLVisit は Sendable ではないので、値だけ取り出してから MainActor へ渡す
let coordinate = visit.coordinate
let arrivalDate = visit.arrivalDate
let departureDate = visit.departureDate
let accuracy = visit.horizontalAccuracy
Task { @MainActor in
await self.receiveVisit(coordinate: coordinate, arrivalDate: arrivalDate,
departureDate: departureDate, accuracy: accuracy)
}
}
到着・出発の日時には「分からない」を表す値が入ってきます。
// 両方分からない滞在は捨てる
guard arrivalDate != .distantPast || departureDate != .distantFuture else { return }
// .distantPast:監視を始める前から居た → 到着は不明として扱う
let arrivedAt = arrivalDate == .distantPast ? departureDate : arrivalDate
// .distantFuture:まだ居る → 到着だけの記録として送る
let departedAt: Date? = departureDate == .distantFuture ? nil : departureDate
CLVisit の弱いところ(実測)
実機で使ってみると、CLVisit だけでは足りないことが分かりました。
- 10〜20分より短い滞在は、知らせてこない。コンビニに数分寄った、は残らない
- 出発の知らせが来ないことが多い。ある日の記録では、6件中5件が到着だけでした
- 出発が30分以上遅れて届くことがある。家に帰った後で、前の店の出発が届いた
- 到着の時刻が、実際に着くより前になることがある(自宅に着いたのが 19:28 なのに、到着が 19:23)
CLMonitor:短い立ち寄りを円で補う
CLVisit が取りこぼす短い立ち寄りを、CLMonitor の円の出入りで補います。
考え方は単純です。今いる場所に円を1つ置き、そこから出たら「円を置いた時刻から、出た時刻まで居た」とみなす。出たら、次の場所に新しい円を置き直します。
let monitor = await CLMonitor("MaistaStayMonitor") // 名前にドットを入れない(後述)
// 今の位置に円を置く(半径は精度に合わせて 50〜100m)
await monitor.remove(oldIdentifier)
await monitor.add(
CLMonitor.CircularGeographicCondition(center: location.coordinate, radius: radius),
identifier: identifier,
assuming: .satisfied
)
for try await event in await monitor.events {
switch event.state {
case .unsatisfied: await handleExit(at: event.date) // 円から出た=滞在の終わり
case .unmonitored: await replaceAnchor() // 外されたら置き直す
default: continue
}
}
円を置くときの位置は、精度が 100m 以内で、10秒以内に取れたものだけを使っています。
「寄った」と「通り過ぎた」を分ける
円を使うと、信号待ちや渋滞でも「滞在」になってしまいます。ここは数字を実測で決めました。
- 滞在とみなす下限は 120秒。円から出たと分かるまでに 30〜60秒の遅れがあり、それを吸収する長さです。コンビニに寄った実例が約220秒で、180秒にするとさっと買い物した分を落としてしまいました
- 円の半径は 50m。最初は 100m・90秒にしていましたが、店の前を通っただけなのに「すき家に9分」と記録されたので、50m に絞りました
- 円から出たときの速さが「半径 ÷ 2.5m/s」より速ければ、移動中とみなします
CLVisit と CLMonitor が重なったら
両方を動かしているので、同じ滞在が2回記録されることがあります。端末からは「どちらの仕組みで見つけたか」を一緒に送り、重なった分はサーバー側で1つにまとめています。
7日間で、6人分・33組の重なりがありました。ただし、名前が違う組は自動ではまとめません。調べると25組中8組は、本当に別の店に続けて寄っていたからです。
店の名前を引く
座標から店の名前を引くのは、端末の中で MapKit を使っています(座標をサーバーに送らないためです)。
let span = expanded ? maximumRadius : radius(for: accuracy) // 30m(広げても50m)
let request = MKLocalPointsOfInterestRequest(center: coordinate, radius: span)
request.pointOfInterestFilter = MKPointOfInterestFilter(excluding: excludedCategories)
let response = try await MKLocalSearch(request: request).start()
- 郵便ポスト・ATM・トイレ・EV充電器・駐車場は、候補から外しています
- 精度が悪くても、探す半径は広げません。広げると、隣の店を拾うからです
- 2番目の候補との距離の差が精度の2倍以内なら「どちらか分からない」として、利用者に確認してもらいます
店が見つからなければ、MKReverseGeocodingRequest で住所を引きます(iOS 26 では CLGeocoder が非推奨です)。ただし、これが返す name は距離の上限がなく、85m 離れた店の名前を返してきたことがありました。そうした結果は、確認待ちにしています。
移動の距離
大幅な位置変更(significant-change)は電池の消費が小さく、終了したアプリも起こしてくれます。点を端末に3日分(最大2000点)残し、間の距離を足して移動距離にします。
for (previous, current) in zip(points, points.dropFirst()) {
let step = CLLocation(latitude: previous.latitude, longitude: previous.longitude)
.distance(from: CLLocation(latitude: current.latitude, longitude: current.longitude)) / 1000
guard step >= 0.05, step <= 30 else { continue } // 50m 未満は揺れ、30km 超は飛び
total += step
}
点の間を直線で結ぶので、実際の道のりの 0.6〜0.8 倍くらいになります。
ハマりどころ
ここからが本題かもしれません。どれも実機でしか起きず、原因を突き止めるのに時間がかかりました。
1. CLMonitor の名前にドットを入れると落ちる
CLMonitor("com.example.stay") のようにドットを入れると、NSInternalInconsistencyException (Monitor name is not valid) で落ちます。try では捕まえられません。
しかも、2回目の起動で初めて落ちます。初回はオンボーディングが終わるまで監視を始めないので、気づけませんでした。名前は英数字だけにしましょう。
2. CLServiceSession を持たないと、裏で監視が止まる
iOS 18 から、CLServiceSession(authorization: .always) を前面にいる間に作って持ち続けないと、アプリが裏に回って約10秒で円の監視が止まります。エラーも出ません。
端末のログ(log collect で取り、locationd の GeoFencing を見る)に stopMonitoring isAuthorized:0 が出ていて、やっと分かりました。
3. 起動したら、まず監視の受け口を開く
CLVisit のデリゲートや CLMonitor のイベントのループは、画面より先に、できるだけ早くつなぎます。
起動時の通信を待ってからデリゲートを作っていた時期は、本番で1.5日に1件しか滞在が取れていませんでした。また、CLMonitor は聞いている相手がいないと監視をやめてしまいます。同じ名前のモニターは2回開けないので、ループは閉じずに持ち続けます。
4. ロック中は、モニターのファイルが読めない
CLMonitor は状態をファイルに持っていて、端末を一度ロック解除するまで、そのファイルを読めません。isProtectedDataAvailable を見て判断していたら、4分間の滞在を取りこぼしました。ファイルそのものが読めるかで判断するように直しています。
5. 遅れて届いた「出た」で、新しい円を消さない
古い円の「出た」が遅れて届き、置いたばかりの新しい円を消してしまうことがありました。円を置いた時刻より前の出来事は、無視するようにしています。
6. シミュレーターでは確かめられない
devicectl device simulate location で位置を動かしても、CLMonitor の出入りは起きません。円の監視は、実機を持って歩いて確かめるしかありませんでした。
まとめ
- 滞在の検出は
CLVisitを主役にし、短い立ち寄りをCLMonitorの円1つで補う - 「寄った」と「通り過ぎた」の境目(120秒・50m)は、実際に歩いて決める
- 店の名前は端末の中で MapKit で引き、迷ったら利用者に確認してもらう
- iOS 18 以降は
CLServiceSessionを忘れない。モニターの名前にドットを入れない
位置情報で滞在を記録するアプリを作る方の参考になればうれしいです。
まいすたは、iPhone 専用・14日間無料で使えます。
なお、座標をサーバーに送らないための設計は、Zenn に書きました。