雲端 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 測試目標中涵蓋定位適配層:舊樣本是否被丟棄、授權撤銷後如何呈現狀態,以及無效樣本是否只記錄拒絕原因。只有有效測量造成狀態變化時,才發出業務通知;相同的內部狀態重複出現,不得重複記帳。
保留樣本序號、距離、精度、舊狀態、新狀態與拒絕原因即可;日常的失敗報告不需要儲存完整的真實座標。固定樣本應與策略版本一併提交,修改半徑時也要同步審查預期結果。
最後另外進行實機驗收:系統圍欄回呼、背景喚醒與實際定位誤差,都不在這組離線測試的涵蓋範圍內。驗收結果應分別記錄「規則通過」與「裝置行為通過」,不要用一次模擬器執行取代這兩項驗收。
常見問題
為什麼進入與離開要使用不同門檻?
座標誤差會讓樣本在單一門檻兩側來回跳動。較小的進入半徑搭配較大的離開半徑,可在中間區域保留原狀態。
單元測試通過就代表背景圍欄可靠嗎?
不是。單元測試只驗證距離與業務狀態規則;系統事件、背景喚醒、授權變更與實際定位品質仍須在實機驗收。