雲端 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))))
}

這是球面近似,適合本文的策略測試,不是測繪等級的演算法。如果正式環境的程式碼使用系統距離介面,測試也應呼叫同一個介面;不要只驗證這份輔助實作,卻讓應用程式執行另一套公式。

用三態與雙門檻消除邊界抖動

接著加入策略函式。未知狀態遇到中間帶的樣本時,仍然維持未知;已經在內或在外時,中間帶只保留原值。無效測量不觸發狀態轉移,也不應觸發到店獎勵等業務副作用。

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
}

實際串接時,先檢查樣本時間:過期的位置不能覆蓋較新的位置。時間檢查應放在適配層,並透過注入目前時間來測試,不要在純策略邏輯中直接讀取系統時鐘。依介面契約,精度為零不屬於以負值表示的無效標記;是否接受零值,應另有明確的業務依據。

讓測試涵蓋邊界,不必等待定位

將 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

獨享整台實體機,非虛擬機。依建置量選擇機型與租用期間,讓封存、簽署與快取都留在自己的節點上。

選擇雲端 Mac 方案

下一次封存,就在專屬雲端 Mac 上完成

依建置量選擇主機,依團隊所在地選擇節點。先確認方案,再開始移轉工具鏈。