クラウド Mac で検証する iOS ジオフェンスの距離とヒステリシス

クラウド Mac で検証する iOS ジオフェンスの距離とヒステリシス

クラウド Mac で検証する iOS ジオフェンスの距離とヒステリシス

同じユーザーが店舗の境界付近に立っているだけなのに、アプリには「入場、退場、入場」と続けて記録される。そんなとき、まず測位頻度を調整するのは早計です。距離計算、精度フィルタリング、状態遷移を切り分け、クラウド Mac 上で固定サンプルを再生してこそ、業務ルールによる状態の揺れなのか、システムイベントと実際の位置が食い違っているのかを判断できます。

ここで検証するのは、アプリ独自のジオフェンス判定ルールです。システムの領域監視を置き換えるものではありません。全工程でオフラインの座標データを使い、ユーザーの移動履歴は収集せず、リモートマシンの所在地にも依存しません。

入場と退場の判定条件を先に明文化する

この例では、中心からの距離が 80 メートル以下なら入場、120 メートル以上なら退場と判定し、その間では直前の状態を維持します。水平位置精度が 0〜30 メートルの範囲に入っていなければ、その測定では状態を更新しません。これらの数値はテスト用のルールであり、あらゆる製品に使える推奨値ではありません。

「信頼できるサンプルがない」ことと「確実に範囲外にいる」ことも区別する必要があります。初回起動時は不明状態にし、画面に何か表示したいという理由で退場済みに初期化してはいけません。負の精度値、有限でない数値、有効範囲外の座標はいずれも拒否します。

水平位置精度は測定品質を示す情報であり、ユーザーの実際のずれを表す距離ではありません。距離からそのまま差し引いて、ユーザーが確実にジオフェンス内にいると判断してはいけません。

独立したパッケージで距離計算を固定する

まず、クラウド Mac の通常の作業ディレクトリにテスト用パッケージを作成します。

mkdir GeoPolicy
cd GeoPolicy
swift package init --type library

Sources/GeoPolicy/GeoPolicy.swift を次の実装に置き換えます。球面上の距離はメートル単位で求め、角度は先にラジアンへ変換します。三角関数を使えば、日付変更線をまたぐ経度差も正しく扱えます。緯度と経度の差を、そのまま平面上の距離として扱わないでください。

import Foundation

struct Point {
    let latitude: Double
    let longitude: Double

    var valid: Bool {
        latitude.isFinite && longitude.isFinite &&
        (-90...90).contains(latitude) &&
        (-180...180).contains(longitude)
    }
}

func meters(_ a: Point, _ b: Point) -> Double? {
    guard a.valid, b.valid else { return nil }
    let radians = Double.pi / 180
    let p1 = a.latitude * radians
    let p2 = b.latitude * radians
    let dp = (b.latitude - a.latitude) * radians
    let dl = (b.longitude - a.longitude) * radians
    let h = pow(sin(dp / 2), 2) +
        cos(p1) * cos(p2) * pow(sin(dl / 2), 2)
    return 6_371_000 * 2 * asin(sqrt(min(1, max(0, h))))
}

これは地球を球面とみなす近似であり、本記事のルール検証には適していますが、測量精度を保証するアルゴリズムではありません。本番コードでシステムの距離計算 API を使っているなら、テストでも同じ API を呼び出してください。この補助実装だけを検証し、アプリでは別の計算式を使う、という構成は避けます。

3 つの状態と 2 つのしきい値で境界の揺れを抑える

続けて判定関数を追加します。不明状態のときに中間帯のサンプルを受け取っても、不明のままにします。すでに範囲内または範囲外であれば、中間帯では直前の状態を維持するだけです。無効な測定で状態を遷移させてはいけません。来店特典の付与など、業務上の副作用も発生させないようにします。

enum FenceState: Equatable {
    case unknown, outside, inside
}

func nextState(
    previous: FenceState,
    distance: Double,
    accuracy: Double
) -> FenceState {
    guard distance.isFinite, distance >= 0,
          accuracy.isFinite, (0...30).contains(accuracy)
    else { return previous }

    if distance <= 80 { return .inside }
    if distance >= 120 { return .outside }
    return previous
}

実際のアプリに組み込む際は、最初にサンプルの時刻を検証します。古い位置情報で、より新しい位置情報を上書きしてはいけません。時刻の検証はアダプター層に置き、テストでは現在時刻を注入してください。純粋な判定ロジックからシステム時計を直接読み取らないようにします。API の仕様上、精度値のゼロは、無効を示す負の値には該当しません。ゼロを受け入れるかどうかは、別途、明確な業務上の根拠に基づいて決める必要があります。

測位待ちに頼らず境界条件をテストする

Tests/GeoPolicyTests/GeoPolicyTests.swift を次のテストに置き換えます。状態の境界テストには距離の値を直接渡し、座標変換の誤差によって、しきい値と等しい場合の判定が曖昧にならないようにします。距離関数は別に検証します。

import XCTest
@testable import GeoPolicy

final class GeoPolicyTests: XCTestCase {
    func testDistanceAcrossDateLine() throws {
        let a = Point(latitude: 0, longitude: 179.999)
        let b = Point(latitude: 0, longitude: -179.999)
        let distance = try XCTUnwrap(meters(a, b))
        XCTAssertEqual(distance, 222.39, accuracy: 0.5)
        XCTAssertEqual(
            distance,
            try XCTUnwrap(meters(b, a)),
            accuracy: 0.001
        )
        XCTAssertEqual(try XCTUnwrap(meters(a, a)), 0)
        XCTAssertNil(meters(
            Point(latitude: 91, longitude: 0), b
        ))
    }

    func testHysteresisAndRejectedFixes() {
        var state = FenceState.outside
        let inputs = [79.0, 85, 110, 119.9, 120, 100, 80]
        let expected: [FenceState] = [
            .inside, .inside, .inside, .inside,
            .outside, .outside, .inside
        ]
        for (distance, result) in zip(inputs, expected) {
            state = nextState(
                previous: state, distance: distance, accuracy: 10
            )
            XCTAssertEqual(state, result)
        }
        for accuracy in [-1.0, 30.01, .nan, .infinity] {
            XCTAssertEqual(nextState(
                previous: .outside, distance: 20, accuracy: accuracy
            ), .outside)
        }
        XCTAssertEqual(nextState(
            previous: .unknown, distance: 100, accuracy: 10
        ), .unknown)
        XCTAssertEqual(nextState(
            previous: .outside, distance: 80, accuracy: 30
        ), .inside)
    }
}

swift test を実行します。ここでの 222.39 メートルは、例の座標と球面上の距離計算式から得られる値であり、ノードのネットワークや実機で測定したデータではありません。続いて、負の距離、無限大の距離、極域の座標、対蹠点のサンプルも追加してください。数値誤差は明示的な許容差で扱い、浮動小数点数を文字列にして比較しないようにします。

ルールのテストをアプリのパイプラインにつなぐ

まず純粋なロジックのテストをマージ前の必須チェックにし、そのうえで iOS のテストターゲットで位置情報アダプター層を検証します。古いサンプルが破棄されるか、位置情報の許可が取り消された後に状態をどう表示するか、無効なサンプルでは拒否理由の記録だけにとどまるかを確認します。業務処理への通知は、有効な状態変化があった場合にのみ送ります。同じ内部状態が繰り返されても、重複計上してはいけません。

記録するのは、サンプル番号、距離、精度、変更前の状態、変更後の状態、拒否理由だけで十分です。通常の失敗レポートに実際の座標一式を保存する必要はありません。固定サンプルは判定ルールのバージョンと一緒にコミットし、半径を変更するときは期待結果もあわせてレビューします。

最後に、実機での受け入れテストを別途行います。システムのジオフェンスコールバック、バックグラウンドでの起動、実際の測位誤差は、このオフラインテストの対象外です。結果は「ルールの検証に合格」と「実機動作の検証に合格」を分けて記録し、シミュレーターを一度実行しただけで両方を確認したことにしないでください。

よくある質問

入場と退場で距離のしきい値を変える理由は?

測位誤差で単一の境界を何度もまたぐと、状態が反転し続けます。入場半径を小さく、退場半径を大きくし、その間では直前の状態を維持します。

単体テストだけでバックグラウンド動作も保証できますか?

できません。検証対象は距離計算と業務上の状態遷移です。システムイベント、バックグラウンド起動、権限変更、実際の測位品質は実機で確認します。

ビルド専用のクラウド Mac を

物理マシン1台をまるごと専有でき、仮想マシンではありません。ビルド量に合わせてマシンと利用期間を選べば、アーカイブ、署名、キャッシュを専用ノードに保管できます。

クラウド Mac のプランを選ぶ

次のアーカイブは、専有クラウド Mac でビルド

ビルド量に合わせてマシンを選び、チームの所在地に合わせてノードを選択。プランを確認してから、ツールチェーンの移行を始めましょう。