iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
佛心分享-SideProject30

30 天開發一款真正能每天使用的散步 App系列 第 6

定位功能(2)——實作 GPS 定位點過濾

  • 分享至 

  • xImage
  •  

昨天已經完成定位功能,目前 App 可以持續取得位置,地圖也會跟著使用者目前的位置移動。

接下來就要開始實作在地圖上即時顯示散步軌跡。

將 GPS 點位畫到地圖上其實很簡單,只要使用 <Polyline>,就能把一連串座標連成一條路線。

不過真正困難的地方不是畫線,而是 GPS 點位本身。

實際測試後會發現,手機回傳的 GPS 點位並不能全部直接使用。有些點位精度很差,有些會突然跳到很遠的位置,甚至站著不動時,也可能持續在原地漂移。

這些異常資料不只會讓地圖上的路線變得亂七八糟,也會影響距離、速度,以及後續所有散步統計。

因此在畫線之前,需要先過濾 GPS 點位,只將正常的點位加入軌跡。

GPS 點位的處理流程

持續收到 GPS 點位後,不能直接拿來畫在地圖上,而是需要先經過一連串檢查,確認資料沒有問題後,才加入散步軌跡。

在開始實作前,我先把整個資料流整理出來:

收到 GPS 點位
    ↓
GPS Filter
    ↓
儲存點位
    ↓
取出有效點位
    ↓
Polyline 繪製軌跡

畫面上只會顯示通過檢查的點位,而被判定為異常的點位仍然會保留下來,只是不會參與軌跡繪製。至於為什麼不直接刪除這些資料,後面會再說明。

定位為什麼會漂移?

GPS 的定位結果會受到周圍環境影響,在高樓、騎樓、地下道或遮蔽物較多的地方,衛星訊號可能先經過建築物反射,再進入手機,這種現象稱為 Multipath Effect(多徑效應)。

Illustration-of-GPS-signal-propagation-paths-demonstrating-multipath-and-non-line-of

圖片來源:https://www.researchgate.net/figure/Illustration-of-GPS-signal-propagation-paths-demonstrating-multipath-and-non-line-of_fig1_393148149

受到訊號反射或遮蔽影響後,軌跡可能出現:

  • 原地不停抖動
  • 原本的直線變成鋸齒狀
  • 定位突然跳到遠處,再跳回原本位置

沒有任何一個條件能百分之百判斷 GPS 點位是否正常,因此通常會同時檢查精度、速度、距離等多個條件,再綜合決定是否保留這個點位。

設計點位過濾條件

我主要會從以下三個方向來判斷 GPS 點位是否正常:

精度判斷

GPS 每次回傳的位置,都會附帶一個 accuracy 欄位。

它代表目前定位結果可能誤差多少公尺,例如:

  • accuracy = 5m
  • accuracy = 50m
  • accuracy = 120m

我會先將精度太差的資料排除。

至於門檻應該設定多少,其實沒有標準答案,需要依照需求決定。

由於這是一個散步 App,希望路線能盡可能精確,因此我將最大允許誤差設定為 25 公尺。

合理速度判斷

有時候 GPS 精度看起來很正常,但位置卻突然跳到很遠的地方,因此還需要再加上一層速度判斷。

流程很簡單:

  1. 計算目前點位與上一個點位的距離。
  2. 計算兩次定位之間經過多久。
  3. 利用距離與時間推算目前速度。

距離的部分,我使用的是 Haversine Formula,它可以計算地球表面兩個經緯度之間的實際距離,也是許多地圖服務常見的做法。

如果最後算出:

使用者正在以 120 km/h 散步

那很明顯不是正常情況,而是 GPS 發生漂移,這個點位就不應該加入路線。

原地漂移判斷

最後一種情況也很常見。

即使完全站著不動,GPS 還是可能持續更新位置,只是每次通常都只偏移幾公尺。

如果把這些點全部畫到地圖上,同一個地方就會累積大量定位點,看起來像一直在原地繞圈圈。

因此我會再加入一個判斷:當定位點的移動速度很低,而且距離只有幾公尺時,就視為 GPS 漂移,直接忽略這個點位:

if (
  (point.speed ?? calculatedSpeed) < 0.5 &&
  distance < 3
) {
  return 'stationary_drift';
}

代表使用者幾乎沒有真正移動,這樣畫出來的路線也會乾淨許多。

把過濾邏輯集中管理

為了方便維護,我把所有 GPS 點位過濾相關的參數都集中放在同一個設定檔。

之後如果實際測試發現某個門檻太嚴格或太寬鬆,只需要修改這裡即可,不用到處搜尋程式碼。

最終各項初始條件如下:

{
  maxAccuracyMeters: 25, // 最大允許誤差半徑 25 公尺。
  maxSpeedMps: 15, // 最大移動速度每秒 15 公尺。
  stationarySpeedMps: 0.5, // 靜止狀態的判定速度每秒 0.5 公尺。
  stationaryDriftMeters: 3, // 靜止狀態下的飄移容許度 3 公尺。
  maxJumpMeters: 100 // 單次點位最大跳躍距離 100 公尺。
}

除了前面提到的精度、速度與原地漂移之外,實際上還需要檢查座標是否有效、時間是否正確,以及點位有沒有突然跳到過遠的位置。

完整實作如下:

import type { LocationPoint } from '../location/types';

/**
 * GPS 軌跡過濾的限制閾值(Thresholds)
 */
export const GPS_FILTER_THRESHOLDS = {
  maxAccuracyMeters: 25, // 最大允許誤差半徑 25 公尺。
  maxSpeedMps: 15, // 最大移動速度每秒 15 公尺。
  stationarySpeedMps: 0.5, // 靜止狀態的判定速度每秒 0.5 公尺。
  stationaryDriftMeters: 3, // 靜止狀態下的漂移容許度 3 公尺。
  maxJumpMeters: 100, // 單次點位最大跳躍距離 100 公尺。
} as const;

/**
 * 將角度(度數)轉換為弧度(Radian)
 * @param value 角度值
 */
const rad = (value: number) => (value * Math.PI) / 180;

/**
 * 使用大圓距離公式(Haversine Formula)
 * 計算地球表面兩個經緯度之間的實際距離
 *
 * @param a 起點經緯度
 * @param b 終點經緯度
 * @returns 兩點之間的距離,單位為公尺
 */
export function calculateHaversineDistance(
  a: Pick<LocationPoint, 'latitude' | 'longitude'>,
  b: Pick<LocationPoint, 'latitude' | 'longitude'>,
) {
  const dLat = rad(b.latitude - a.latitude);
  const dLon = rad(b.longitude - a.longitude);

  const h =
    Math.sin(dLat / 2) ** 2 +
    Math.cos(rad(a.latitude)) *
      Math.cos(rad(b.latitude)) *
      Math.sin(dLon / 2) ** 2;

  // 12_742_000 是地球平均直徑,單位為公尺
  return 12_742_000 * Math.asin(Math.sqrt(h));
}

/**
 * 計算前後兩個定位點之間的移動速度
 *
 * @param a 前一個定位點
 * @param b 目前定位點
 * @returns 移動速度,單位為公尺/秒
 */
export function calculateSpeedBetweenPoints(
  a: LocationPoint,
  b: LocationPoint,
) {
  const seconds = (b.recordedAt - a.recordedAt) / 1000;

  return seconds > 0
    ? calculateHaversineDistance(a, b) / seconds
    : Infinity;
}

/**
 * GPS 點位被過濾的原因
 */
export type FilterReason =
  | 'invalid_coordinate'
  | 'poor_accuracy'
  | 'non_increasing_time'
  | 'jump'
  | 'unreasonable_speed'
  | 'stationary_drift';

/**
 * 檢查目前收到的 GPS 點位是否合理
 *
 * @param point 目前收到的定位點
 * @param previous 前一個通過過濾的定位點
 * @returns 點位異常時回傳對應原因,正常時回傳 null
 */
export function filterLocationPoint(
  point: LocationPoint,
  previous: LocationPoint | null,
): FilterReason | null {
  // 1. 檢查經緯度是否為有效數值,並且位於合法範圍內
  if (
    !Number.isFinite(point.latitude) ||
    !Number.isFinite(point.longitude) ||
    Math.abs(point.latitude) > 90 ||
    Math.abs(point.longitude) > 180
  ) {
    return 'invalid_coordinate';
  }

  // 2. 檢查 GPS 回傳的精度是否超過允許範圍
  if (
    point.accuracy != null &&
    point.accuracy > GPS_FILTER_THRESHOLDS.maxAccuracyMeters
  ) {
    return 'poor_accuracy';
  }

  // 第一個有效點位沒有前一個點可以比較,直接保留
  if (!previous) {
    return null;
  }

  // 3. 新點位的時間必須晚於上一個有效點位
  if (point.recordedAt <= previous.recordedAt) {
    return 'non_increasing_time';
  }

  const distance = calculateHaversineDistance(previous, point);

  // 4. 檢查是否出現單次距離過大的瞬間跳點
  if (distance > GPS_FILTER_THRESHOLDS.maxJumpMeters) {
    return 'jump';
  }

  const calculatedSpeed = calculateSpeedBetweenPoints(previous, point);

  // 5. 檢查兩個點位之間推算出的速度是否過快
  if (calculatedSpeed > GPS_FILTER_THRESHOLDS.maxSpeedMps) {
    return 'unreasonable_speed';
  }

  // 6. 檢查是否只是站在原地時產生的 GPS 漂移
  const currentSpeed = point.speed ?? calculatedSpeed;

  if (
    currentSpeed < GPS_FILTER_THRESHOLDS.stationarySpeedMps &&
    distance < GPS_FILTER_THRESHOLDS.stationaryDriftMeters
  ) {
    return 'stationary_drift';
  }

  return null;
}

新的定位點都會先經過 filterLocationPoint 檢查。只有函式回傳 null 時,才代表這個點位通過所有條件,可以加入散步軌跡。

如果點位被過濾,函式則會回傳對應的原因,例如精度太差、速度不合理或原地漂移。除了方便後續判斷,也能在 Debug 時知道每個點位為什麼沒有被加入路線。

被過濾的點位需要保存嗎?

需要。

雖然這些點位不會參與軌跡繪製,但我仍然會保留原始資料,並記錄每個點位是否通過過濾以及被過濾的原因。

主要有兩個用途:

  1. 方便 Debug:可以回頭確認是哪一條規則把點位過濾掉,判斷目前的過濾邏輯是否合理。
  2. 方便後續調整參數:可以觀察不同裝置和環境下的 GPS 漂移情況,再決定是否要修改精度、速度或距離門檻。

如果一開始就直接刪除異常點位,後續只會看到處理完成的軌跡,很難知道原始 GPS 到底發生了什麼問題。

不過在判斷下一個點位時,我使用的仍然是前一個通過過濾的有效點位,而不是前一個被過濾的點位。這樣可以避免單一異常點繼續影響後面的距離和速度計算。

將過濾後的點位渲染到地圖上

完成 GPS 點位過濾後,接下來就可以把有效點位畫到地圖上。

我使用的是 react-native-maps,它提供了 Polyline 元件。只要傳入一組依照時間排序的經緯度座標,就能將這些點連成一條路徑:

const coordinates = visiblePoints.map(({ latitude, longitude }) => ({
  latitude,
  longitude,
}));

<MapView style={styles.map}>
  {coordinates.length > 1 && (
    <Polyline
      coordinates={coordinates}
      strokeColor="#206B4B"
      strokeWidth={5}
    />
  )}
</MapView>

這裡使用的 visiblePoints 只包含通過 GPS Filter 的有效點位,因此異常跳點和原地漂移不會直接顯示在地圖上。

另外 Polyline 至少需要兩個座標才能形成線段,所以我也先判斷 coordinates.length > 1,避免只有一個點位時就渲染路線。

測試定位軌跡功能

目前主要先完成 GPS 點位過濾與軌跡繪製,UI 之後再慢慢調整。

現在終於成功在地圖上看見第一條完整的散步軌跡:

實際開發時,不可能每次測試都拿著手機到外面散步,因此可以使用 Android 模擬器提供的 Location 功能,模擬裝置沿著指定路線移動。

新增自動路徑

點擊 Android 模擬器右下角的三個點,進入 Extended controls,接著在左側選擇 Location。

先在地圖上點一下設定起點,再點擊下方的路徑圖示,開始建立模擬路線。

接著在地圖上設定終點,完成後點擊上方的 SAVE ROUTE

輸入路徑名稱後,點擊 Save 儲存。

最後選擇剛才建立的路徑,再點擊右下角的 Play Route,模擬器就會沿著這條路線自動移動。

透過這個方式可以重複播放相同路線,觀察 App 是否持續收到 GPS 點位、過濾條件是否正確,以及地圖上的軌跡有沒有正常更新。

到這裡 GPS 點位已經可以先經過過濾,再即時繪製到地圖上,路線不會再因為部分異常點位而變得亂七八糟。

不過目前定位仍然依賴 App 保持在前景,只要將 App 切到背景或鎖定螢幕,位置就可能停止更新。

所以下一篇要開始實作背景定位(Background Location),讓使用者即使鎖定螢幕或切換到其他 App,散步軌跡依然能持續記錄。


上一篇
定位功能(1)——實作定位功能與地圖顯示
系列文
30 天開發一款真正能每天使用的散步 App6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言