花粉症の朝、家を出る5分前に「今日マスクいる」とだけ鳴ってくれたら十分なのに、既存アプリはどれも「通知時刻を自分で設定してください」から始まる。花粉症歴が長いほど、朝は鼻と目でもう余力がないので、アプリの設定画面を開く気力がまず無い(設定画面で力尽きてそのままアンインストール、というのを自分でやった)。
自社で出している PollenShield は、この「設定画面を開く気力」を要求しない側に振り切った iOS アプリです。App Store で配信していて、コピーも「設定は、ゼロ。」の一行だけ。起動後は位置情報と通知だけ許可してもらって、あとはこちらが勝手に決めていく。
ところが「設定ゼロ」を本気で名乗ろうとすると、ユーザーに聞けばワンタップで済むことを、全部アプリ側で判定しないといけなくなる。自宅はどこか、何時に家を出るか、引っ越したかどうか。この記事は、その「ユーザーに一度も訊かない」を成立させるために、PollenShield のコードの中でこっそり決めた閾値と、それを決めた時に一番怖かったこと、の記録です。
この記事で扱うこと
- 「自宅」をユーザーに聞かずに決めるための、夜間滞在の 4 つの閾値
- 「家を出た瞬間」を秒精度で掴みつつ、ゴミ出しを出発と数えないための二段確定
- 「引っ越した」を勝手に気づいて過去を捨てるための連続日数条件
- ユーザーに一度も訊かない代わりに、開発者の自分だけは全部見えるデバッグ画面
前提として、位置情報は authorizedAlways が取れている想定です。Visit / Region Monitoring はこれが無いと始まらない。
ゼロ設定を名乗るなら、自宅も通勤時刻もアプリが勝手に決めないといけない
LP のコピーを「設定は、ゼロ。」にした時点で、実装側には次の約束が残ります。
- 住所入力は無い(自宅はこちらが勝手に特定する)
- 通知時刻の設定は無い(出発時刻はこちらが勝手に学習する)
- 「あなたは都心ですか郊外ですか」みたいな分岐も無い(行動で判断する)
最初の設計では startMonitoringSignificantLocationChanges()(通称 SLC)1 本でやろうとしていました。省電力で楽だし、「設定ゼロ」の看板にも合う、と。ところが徒歩通勤のユーザーで試すほど、家を出てから通知が飛ぶまでが 5 分どころか 10 分以上ズレる。LP に「家を出る5分前」と書いているのに、実装は「家を出て10分後」を返すわけで、これは文字通り嘘の広告になる(笑えるところではある)。
SLC は基地局の切り替わりで発火するので、概ね 500m 動かないと何も起きない。駅まで徒歩 5 分の人は、通知タイミング的に「家を出る前」ではなく「改札を通ったあと」に届く。これはもう設定画面を出して「通知時刻を選んでください」と謝りながら逃げるしかない設計です。逃げないためには、Core Location の別の API を引っ張ってきて役割分担させることになった、というのがこの記事の骨格。
役割分担はざっくりこうです。
- 夜間の滞在パターンから「自宅」を学習する係 → Visit Monitoring
- 自宅の外周を跨いだ瞬間を「出発」として掴む係 → Region Monitoring
- Visit が取りこぼした日の保険 → SLC
設定画面を出さない代わりに、この 3 つがサボらずに動く前提を揃えておく、というのが今回の仕事でした。
「自宅」を勝手に決める — 夜に何時間、何日、どのくらいの精度で、を同時に要求する理由
Visit Monitoring は、OS が「一定時間滞在した場所」を勝手に拾って didVisit で投げてくれます。帰宅から翌朝までの滞在が 1 イベントで飛んでくるので、夜間滞在のクラスタだけ拾えば、その人の自宅がだいたい決まる。
func locationManager(_ manager: CLLocationManager, didVisit visit: CLVisit) {
let event = VisitEvent(
lat: visit.coordinate.latitude,
lng: visit.coordinate.longitude,
arrivalDate: visit.arrivalDate,
// 離脱未確定時は Date.distantFuture が返る仕様
departureDate: visit.departureDate,
horizontalAccuracy: visit.horizontalAccuracy
)
learner.record(visit: event)
}
ここで最初にハマったのは、まだ離脱していない visit が departureDate = .distantFuture で返ってくる仕様。「終わっていない visit」を雑に departure > arrival でソートすると未来の日付が入り込んで、中央値が翌世紀にワープします(学習ログに西暦 4001 年が出てきて血の気が引いた)。最初は != Date.distantFuture の厳密等価で弾いていたのですが、TestFlight 配布後にこの判定が取りこぼすケースが出てきて、滞在中レコードが重複したり stay 値が異常値になったりするバグとして表面化しました。
実機の CLVisit.departureDate は、離脱未確定時に「ぴったり Date.distantFuture」ではなく distantFuture - 1s 級の sentinel 近傍値が混じることがあります(Apple ドキュメントにも "invalid data を含み得る" と明記されている)。厳密等価で比較するとこの近傍値を「離脱済み」として扱ってしまい、開いたままの visit がそのまま stay 計算に流れて値が壊れる。今は isOpen ヘルパーで「departureDate が今から 10 年以上先なら継続中扱い」という閾値ベース判定に置き換えています。実ユースケースで 10 年連続滞在は無いので、distantFuture 近傍も遠未来値もまとめて 1 本の閾値で吸収できる。
var isOpen: Bool {
departureDate.timeIntervalSinceNow > 10 * 365 * 86400
}
// 学習評価ではこれで弾く
guard !visit.isOpen, visit.departureDate > visit.arrivalDate else { return }
「ユーザーに訊かない代わりに閾値で全部決め切る」と書いておきながら、API ドキュメントの sentinel 仕様を厳密等価で読みに行って滑ったのは正直しょうもない初手で("invalid data を含み得る" の一行を、もう少し本気で読むべきだった)、ここも結局「==」を「閾値」に置き直すことで落ち着いた、というのが地味に今回の記事の縮図でもあります。
もう1つ、horizontalAccuracy は負値(無効)や 500m 級の雑な値が平気で混ざります。100m 以内に絞らないと、隣駅あたりが自宅候補に混ざって重心が線路沿いに引っ張られる。
guard visit.horizontalAccuracy > 0,
visit.horizontalAccuracy <= 100 else { return }
ここまではフィルタの話。本題は「いくつ夜を見たら自宅と決めていいか」です。
素朴な案は「3夜連続で同じクラスタ」。これで実装してテストしていたら、東京に3連泊のホテル出張で自宅が書き換わる未来が余裕で見えてしまった(出張から帰って来たら通知が虎ノ門基準で飛んでくるのは、設定ゼロを名乗るアプリとして一番出してはいけないバグ)。ユーザーに「今の自宅はここで合ってますか?」と聞けないなら、判定側に複数の独立した条件を AND で要求するしかない。
結果、PollenShield の自宅確定ロジックは次の 4 つを同時に満たす時だけトリガーするようにしました。
| 条件 | 閾値 | これが無いと起きること |
|---|---|---|
| ユニーク夜数 | 5 夜以上 | 3 泊のホテル滞在で自宅が書き換わる |
| 合計滞在時間 | 1,200 分(20時間)以上 | 毎晩 30 分だけ立ち寄るバーが自宅認定 |
| クラスタ 90 パーセンタイル半径 | 150m 未満 | 複数拠点の中間点(実在しない路上)が自宅になる |
| 夜間帯との重複 | 22:00-6:00 と 4 時間以上 | 深夜まで開いてるカフェが自宅認定 |
どれか 1 つを緩めると、このリストの右列がそのまま再現します。夜勤明けに深夜カフェで長居する人の自宅が虎ノ門のカフェになる、みたいなバグを設定画面なしで救うには、閾値を複数重ねるしか方法が無い、というのが設計上の結論でした(「ユーザーに聞けば2秒なんだよな」と何度か思ったのは正直に書いておきたい)。
「家を出た瞬間」を勝手に決める — ゴミ出しと出発を分ける 300m × 10 分ルール
自宅が確定したら、その座標を中心に半径 150m の CLCircularRegion を登録します。これで「半径を跨いだ瞬間」が秒〜数十秒で didExitRegion に飛んでくる。SLC の分オーダー遅延がここで消えます。
func updateHomeRegionMonitoring(coordinate: CLLocationCoordinate2D, radius: Double) {
guard CLLocationManager.isMonitoringAvailable(for: CLCircularRegion.self) else { return }
// 既存 monitoring region は全解除してから再登録 (ID 重複回避)
for region in manager.monitoredRegions {
manager.stopMonitoring(for: region)
}
let region = CLCircularRegion(
center: coordinate,
radius: radius,
identifier: "com.example.app.home"
)
region.notifyOnEntry = false
region.notifyOnExit = true
manager.startMonitoring(for: region)
}
func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) {
guard region.identifier == "com.example.app.home" else { return }
learner.record(regionExit: Date())
}
半径は 150m にしています。狭すぎると GPS 誤差で勝手に発火するし、広すぎるとマンションの敷地を出ただけでは動かない。notifyOnEntry を切っているのは、欲しいのが出発の瞬間だけで、帰宅は学習にも通知にも使わないからです(両方 ON にしてデバッグログが洪水になり、肝心のイベントが視認できなくなる事故を一度やった)。
ここで問題なのは、Region Monitoring が律儀にゴミ出しも拾うことです。朝 6 時にゴミを出しに 5 分だけ家を出て戻る、を「出発」に数えると、出発時刻の中央値が毎日少しずつ前倒しになって、気付いたら花粉通知が 5 時台に鳴りはじめる(ゴミ収集日は早い、という現実に実装が勝てなくなる)。
そこで exit イベントを即出発にはせず、家から 300m 以上離れている状態が 10 分続いたら確定、という二段構えにしています。
private struct PendingDeparture {
let timestamp: Date
let monitorStart: Date
}
private var pendingDeparture: PendingDeparture?
func record(regionExit timestamp: Date) {
guard store.isHomeConfirmed else { return }
// 即時は tentative 登録のみ
pendingDeparture = PendingDeparture(
timestamp: timestamp,
monitorStart: Date()
)
appendDeparture(timestamp: timestamp, tentative: true)
confirmationTimer.scheduleConfirmation(after: 10 * 60) { [weak self] in
self?.finalizePendingIfStillPending()
}
}
func record(location: CLLocation) {
guard let pending = pendingDeparture,
let home = store.homeCoordinate else { return }
let homeLoc = CLLocation(latitude: home.latitude, longitude: home.longitude)
let dist = location.distance(from: homeLoc)
let elapsed = location.timestamp.timeIntervalSince(pending.monitorStart)
if dist < 300 {
// 300m 以内に戻ってきた → ゴミ出し等と判断してキャンセル
cancelPendingDeparture()
return
}
if dist >= 300, elapsed >= 10 * 60 {
// 距離と時間の両方を満たした → 早期確定
confirmPendingDeparture()
}
}
ポイントは、tentative エントリを一度記録に積んでから、10 分経過前に戻ってきたら巻き戻す、という経路を必ず用意することです。これをサボると「朝ゴミ出しで 3 分だけ出た日」がそのまま出発履歴に残って、学習値が数週間ずれて戻ってこない(「なんで俺の出発時刻 5:47 になってるんだ」という問い合わせをユーザーから受ける形のバグは、設定ゼロを名乗る以上、絶対に避けないといけない)。
SLC は record(location:) のフォールバックに降格させています。Visit も Region も届かなかった日に、SLC 経由で pending を確定させる最後の保険、という位置づけ。
「引っ越した」を勝手に気づく — 5 夜連続 1.5km 超で過去をリセット
ユーザーに何も聞かない設計で一番怖いのが引っ越しです。旧自宅の CLCircularRegion が OS 側に残り続けて、誰もいない場所を永遠に監視する未来が普通に待っている。バッテリー的にも、学習精度的にも、花粉通知アプリとしての信用的にも、ここを放置するのは無理。
PollenShield では「確定済みの自宅から 1.5km 以上離れた夜間滞在が 5 夜連続」で出たら引っ越しと見なし、ローカルの自宅情報も OS 側の region 監視も両方クリアしています。
private func resetForRelocation() {
store.homeCoordinate = nil
store.isHomeConfirmed = false
store.homeCandidates = []
store.departureReasons = []
// OS 側の CLCircularRegion 監視も解除
onHomeCleared?()
}
コールバック経由で LocationService.stopHomeRegionMonitoring() を呼び、monitoredRegions から自宅 identifier のものだけを選んで stopMonitoring(for:) します。
func stopHomeRegionMonitoring() {
for region in manager.monitoredRegions where region.identifier == "com.example.app.home" {
manager.stopMonitoring(for: region)
}
}
「全 region を一律 stop」にしない理由は単純で、将来「職場」や「保育園」を別 region として追加したくなった時に、巻き込み事故が起きるから。今の自分を信用すると未来の自分が泣くパターンで、identifier フィルタは最初から入れておくのが精神衛生上よかった(1 度コメントアウトで「あとで考える」にしかけて、コードレビューで差し戻された)。
1.5km と 5 夜の組み合わせは、出張・実家帰省・長期旅行で書き換わらない線を引くため、自宅の 4 閾値と同じで「短い期間・近い距離の誤判定を切るために両方強めに取る」発想です。3 夜で十分では、と一瞬思ったけど、正月の実家 4 泊で自宅が親の家に書き換わると、2 月から始まる花粉シーズンを実家基準の予報で過ごすことになる(しかもユーザーはそれに気づけない、設定画面を開かないので)。
ユーザーに一度も質問しないために、デバッグ画面だけは全部見せる
外向きに聞かない代わりに、中向けに全部見る、というのが「設定ゼロ」をバグらせないためのトレードオフでした。DEBUG ビルド限定で、学習状態を剥き出しにする画面を 1 枚作っています。
- 現在の自宅座標(確定 / 未確定)
- 直近の Visit / Region exit イベント
- クラスタ候補の統計(ユニーク夜数・合計滞在時間・90%タイル半径)
- 出発時刻の中央値(曜日グループ別)
このデバッグ画面、最初は「便利だから作る」くらいの気持ちだったのが、実運用で 2 回助けられました。1 回目は、デバッグ画面から全リセットした後に monitoredRegions が空にならず、「削除したはずの自宅」が OS 側にまだ居る状態を発見した時。2 回目は、夜勤の友人に TestFlight で入れてもらったら、夜間帯の重複条件が効きすぎて自宅が 3 週間確定しなかった時で、これはデバッグ画面が無かったら「なんか通知来ないですね」で終わっていた(夜勤帯は正直、Phase 2 で改めて設計する話として諦めた)。
確認ポイントは地味にこの 4 つだけ。
- デバッグから全リセット後、
CLLocationManager.monitoredRegionsが空になる - 自宅確定後、玄関から 150m 離れた瞬間に
didExitRegionが飛ぶ - ゴミ出し(5 分で戻る)では出発として確定しない
- 出張先 3 泊では自宅が書き換わらない
実装上の細かい地雷も書き残しておきます。
- 既確定ユーザーの復元:
authorizedAlwaysが既に付与されている起動では、authorizationStatusの delegate 通知が飛ばないことがあります。init 時点で「自宅は学習済みなのに Region Monitoring が復元されていない」を自前で拾わないと、アプリ再インストール後のヘビーユーザーが「通知が来なくなった」と沈黙する。 - 中央値(median)を徹底: 出発時刻は平均ではなく median で推定しています。深夜 1 時の外れ値 1 件で学習値が大きく前倒しになる事故は、median を入れるだけで消えます。
- 祝日テーブルは静的に持つ: 平日・土・日だけでグループ化すると、祝日の遅番が「平日」扱いで混ざって中央値を汚す。内閣府の祝日表を静的テーブルで持つのが結局一番安いコスト。
まとめ — ゼロ設定は「何もしない」ではなく「全部こっちで決め切る」
PollenShield のコア体験は「朝、家を出る直前に 1 通知が鳴るだけ」で、それ以外のすべてはアプリが勝手に決めきるためのインフラでしかありません。設定ゼロというコピーは、実装から見ると「ユーザーに訊かない代わりに、閾値を全部自分で決めて責任を取る」という意味に読み替わる。
今回の設計で割り切ったのは、だいたいこの 3 箇所でした。
- 自宅確定は、ユニーク 5 夜 × 1,200 分 × 90% 半径 150m × 夜間帯 4 時間重複、を AND で要求
- 出発確定は、region exit を tentative に積んでから 300m × 10 分で本登録、戻ってきたら即巻き戻し
- 引っ越しは、1.5km 以遠の夜間滞在が 5 夜連続で確定、ローカルと OS region を両方クリア
どれもユーザーに 1 タップ聞けば 0.1 秒で決まる話を、1 週間以上かけてアプリ側で推定している、という構造です(設定画面を出せば一瞬なのだけ、もう何度も書きますが本当にそう)。それでも「設定は、ゼロ。」の一行を本気で守るなら、この 1 週間をサボれない、というのが PollenShield を App Store に出してから改めて効いた学びでした。位置情報まわりのバグは再現コストが高いので、閾値の可視化とデバッグ画面だけは最初から作り込む、が結局いちばんの近道でした。
- プロダクトLP: pollen-shield.vercel.app
- App Store 配信: PollenShield
「設定は、ゼロ。」の実物は App Store からどうぞ。
