花粉症の朝、家を出る5分前に「今日マスクいる」とだけ鳴ってくれたら十分なのに、既存アプリはどれも「通知時刻を自分で設定してください」から始まる。花粉症歴が長いほど朝は余力がなく、アプリの設定画面を開く気力がまず無い。
自社で出している PollenShield は、この「設定画面を開く気力」を要求しない側に振り切った iOS アプリです。App Store で配信、コピーも「設定は、ゼロ。」の一行だけです。
ところが「設定ゼロ」を本気で名乗ると、ワンタップで済むことを全部アプリ側で判定しないといけなくなります。自宅はどこか、何時に家を出るか、引っ越したかどうか。この記事は PollenShield のコードでこっそり決めた閾値の記録です。
この記事で扱うこと
- 「自宅」を聞かずに決めるための、夜間滞在の 4 つの閾値
- 「家を出た瞬間」を秒精度で掴む、ゴミ出しを出発と数えない二段確定
- 「引っ越した」を勝手に気づく連続日数条件
- ユーザーに訊かない代わりの、開発者だけ見えるデバッグ画面
前提として、位置情報は authorizedAlways が取れている想定です。
ゼロ設定を名乗るなら、自宅も通勤時刻もアプリが勝手に決めないといけない
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 で返ってくる仕様です。雑に departure > arrival でソートすると未来の日付が入り込み、中央値が翌世紀にワープします(学習ログに西暦 4001 年)。最初は != Date.distantFuture の厳密等価で弾いていました。TestFlight 配布後にこの判定が取りこぼし、レコード重複や stay 異常値のバグとして表面化しました。
実機の CLVisit.departureDate は distantFuture - 1s 級の sentinel 近傍値が混じることがあります。厳密等価で比較すると近傍値を「離脱済み」として扱い、開いたままの visit が stay 計算に流れて値が壊れます。今は isOpen ヘルパーで「departureDate が今から 10 年以上先なら継続中扱い」という閾値ベース判定に置き換えています。実ユースケースで 10 年連続滞在は無いので、1 本の閾値で吸収できます。
var isOpen: Bool {
departureDate.timeIntervalSinceNow > 10 * 365 * 86400
}
// 学習評価ではこれで弾く
guard !visit.isOpen, visit.departureDate > visit.arrivalDate else { return }
「ユーザーに訊かない代わりに閾値で全部決め切る」と書いておきながら、sentinel 仕様を厳密等価で読んで滑ったのはしょうもない初手でした。結局「==」を「閾値」に置き直すことで落ち着いた、というのが今回の記事の縮図でもあります。
もう1つ、horizontalAccuracy は負値(無効)や 500m 級の雑な値が混ざります。100m 以内に絞らないと隣駅が自宅候補に混ざり重心が線路沿いに引っ張られます。
guard visit.horizontalAccuracy > 0,
visit.horizontalAccuracy <= 100 else { return }
ここまではフィルタの話。本題は「何夜見たら自宅と決めていいか」です。
素朴な案は「3夜連続で同じクラスタ」。実装してテストしたら、東京に3連泊のホテル出張で自宅が書き換わる未来が見えてしまいました。出張帰りに通知が虎ノ門基準で飛んでくるのは、設定ゼロを名乗るアプリとして一番出してはいけないバグです。ユーザーに聞けないなら、判定側に複数の独立した条件を AND で要求するしかありません。
結果、自宅確定ロジックは次の 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 を切っているのは、欲しいのが出発の瞬間だけだからです。帰宅は学習にも通知にも使いません。
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 も届かなかった日の最後の保険です。
「引っ越した」を勝手に気づく — 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() を呼びます。自宅 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.5km と 5 夜の組み合わせは、出張・実家帰省・長期旅行で書き換わらない線を引くためです。3 夜で十分では、と一瞬思いましたが、正月の実家 4 泊で自宅が書き換わると 2 月から始まる花粉シーズンを実家基準の予報で過ごすことになります。
ユーザーに一度も質問しないために、デバッグ画面だけは全部見せる
外向きに聞かない代わりに中向けに全部見る、というのがトレードオフでした。DEBUG ビルド限定で学習状態を剥き出しにする画面を 1 枚作っています。
- 現在の自宅座標(確定 / 未確定)
- 直近の Visit / Region exit イベント
- クラスタ候補の統計(夜数・滞在時間・90%タイル半径)
- 出発時刻の中央値(曜日別)
このデバッグ画面、最初は「便利だから作る」くらいの気持ちでしたが、実運用で 2 回助けられました。1 回目は、全リセット後に monitoredRegions が空にならず「削除したはずの自宅」が OS 側にまだ居る状態を発見した時。2 回目は、夜勤の友人に TestFlight で入れてもらったら夜間帯の重複条件が効きすぎて自宅が 3 週間確定しなかった時です。デバッグ画面が無ければ「なんか通知来ないですね」で終わっていました。
確認は 4 つだけ。
- 全リセット後、
CLLocationManager.monitoredRegionsが空になる - 自宅確定後、150m 離れた瞬間に
didExitRegionが飛ぶ - ゴミ出し(5 分で戻る)では出発確定しない
- 出張先 3 泊では自宅が書き換わらない
細かい地雷も書き残します。
- 既確定ユーザーの復元:
authorizedAlwaysが既に付与されている起動では delegate 通知が飛ばないことがあります。init 時点で復元漏れを自前で拾わないと、再インストール後のヘビーユーザーが「通知が来なくなった」と沈黙します。 - 中央値(median)を徹底: 出発時刻は平均でなく median で推定します。深夜 1 時の外れ値 1 件で学習値が前倒しになる事故は median で消えます。
- 祝日テーブルは静的に持つ: 平日・土・日だけでグループ化すると祝日の遅番が「平日」扱いで混ざります。内閣府の祝日表を静的テーブルで持つのが一番安いコストです。
まとめ — ゼロ設定は「何もしない」ではなく「全部こっちで決め切る」
PollenShield のコア体験は「朝、家を出る直前に 1 通知が鳴るだけ」で、それ以外はアプリが勝手に決めきるインフラでしかありません。
今回割り切ったのは、この 3 箇所でした。
- 自宅確定は、5 夜 × 1,200 分 × 90% 半径 150m × 夜間帯 4 時間重複を AND で要求
- 出発確定は、region exit を tentative に積み 300m × 10 分で本登録、戻れば即巻き戻し
- 引っ越しは、1.5km 以遠の夜間滞在が 5 夜連続で確定、両方クリア
どれもユーザーに 1 タップ聞けば 0.1 秒で決まる話を、1 週間以上かけてアプリ側で推定している構造です。それでも「設定は、ゼロ。」を本気で守るなら、この 1 週間をサボれません。位置情報バグは再現コストが高いので、閾値の可視化とデバッグ画面だけは最初から作り込むのが近道でした。
- プロダクトLP: pollen-shield.vercel.app
- App Store 配信: PollenShield
「設定は、ゼロ。」の実物は App Store からどうぞ。



