iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
佛心分享-SideProject30

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

散步越久地圖越卡?GPS 軌跡的效能優化

  • 分享至 

  • xImage
  •  

完成散步功能後,我開始測試長時間散步。

剛開始一切都很順,但走久了之後,地圖開始變得不太流暢。

一開始我以為是 React Native Maps 本身效能不好,或是 GPS 更新太頻繁。

但實際檢查程式後才發現,核心問題不是降低 GPS 更新頻率,而是地圖每次都重新處理歷史路線。

因此今天要處理的不是降低定位精度,而是讓一條越走越長的 GPS 軌跡,在長時間散步後仍然能保持流暢。

原本的路線是怎麼畫出來的?

最基本的做法,就是每收到一個新的 GPS 點,就把它加入目前的路線:

setVisiblePoints((points) => [...points, newPoint]);

接著再轉成 Polyline 需要的格式:

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

<Polyline coordinates={coordinates} />

看起來只是新增一個 GPS 點,但實際上每次更新都會經過:

收到新的 GPS 點
    ↓
建立新的 points 陣列
    ↓
重新建立 coordinates
    ↓
Polyline 收到新的座標
    ↓
地圖重新處理路線

假設目前已經累積 1,000 個 GPS 點,第 1,001 個點加入時,程式並不是只新增最後一個座標,而是重新建立包含 1,001 個座標的新陣列,再整份交給地圖。

散步時間越長,每次更新需要處理的資料就越多。

如果每 5 秒收到一個位置,一小時大約就會累積 720 個 GPS 點,散步兩三個小時後,很容易超過上千個座標。

所以真正造成效能下降的,不是「GPS 點很多」這件事本身,而是每新增一個點,都要重新處理前面所有的點

不能直接刪掉 GPS 點位

既然點位越來越多,第一個想到的方法通常就是「那就少存一點 GPS 點。」

但這樣會碰到另一個問題。

完整 GPS 資料除了畫地圖之外,還會用來:

  • SQLite 暫存
  • GPS 批次同步
  • 後端正式統計
  • 散步恢復

如果直接刪掉原始點位,正式距離、速度等統計結果都可能受到影響。

因此我沒有修改 GPS 的保存方式,而是把資料拆成兩個用途:

rawPoints
完整 GPS 資料

displayPoints
只負責地圖顯示

rawPoints 永遠保持完整,用於資料同步與正式統計。

displayPoints 則只負責畫地圖,可以依照顯示需求進行簡化。

這裡其實有一個很重要的觀念:

正式資料和畫面資料,不一定需要完全相同。

地圖只需要讓使用者看出「實際走過的路線」,不需要把每一個 GPS 點都畫出來。

因此這次優化的方向不是減少 GPS 資料,而是讓完整資料繼續保存,同時減少地圖需要處理的資料量

用 Douglas–Peucker 建立 displayPoints

既然不能直接刪掉 GPS 點,接下來要解決的問題就是:

displayPoints 要怎麼從完整 GPS 資料中產生?

最簡單的方法有很多,例如:

  • 每 5 個 GPS 點保留一個
  • 每隔幾秒保留一個點
  • 距離上一個保留點不到 5 公尺就忽略

這些方法都很容易實作,但都有一個共同問題:

它們只是在刪 GPS 點,沒有考慮整條路線的形狀。

例如下面是一條幾乎完全筆直的路線:

A ─ B ─ C ─ D ─ E

B、C、D 幾乎都位於同一直線上,即使全部刪掉,畫面看起來也不會有太大的差異。

但如果路線中間有一個轉角:

A ─ B
      │
      C
      │
      D

如果只是固定每隔幾個點保留一次,很可能剛好把 B 或 C 這種真正重要的轉折點刪掉。

最後地圖可能會直接把 A 和 D 連成一條斜線,與實際走過的路線產生明顯差異。

我真正想保留的不是「哪些 GPS 點」,而是整條路線的形狀

因此最後選擇了 Douglas–Peucker 演算法,它是一種用來簡化 Polyline 的演算法。

Douglas–Peucker 怎麼判斷哪些點可以刪掉?

它不是固定每隔幾個點刪掉一個,而是會先拿一段路線的起點和終點連成一條線,再檢查中間的 GPS 點。

例如:

A ─ ─ ─ ─ ─ E
    B   C   D

演算法會計算 B、C、D 各自距離 A → E 這條線有多遠。

假設距離最遠的是 C:

A ───────── E
        C

如果 C 距離這條線只有 1 公尺,而目前設定的容許誤差是 3 公尺,就代表整段路線其實非常接近一條直線。

這時就可以把中間的點移除:

A ───────── E

但如果 C 距離這條線有 10 公尺:

A ───────── E
      \
       C

就代表 C 明顯改變了路線的形狀,因此不能刪掉。

保留 C 之後,演算法會把原本的路線拆成兩段:

A ─── C ─── E

接著分別對 A → C 和 C → E 再做一次相同的判斷。

這個過程會持續遞迴,直到每一段中都沒有需要保留的關鍵點。

因此最後留下來的不是固定數量的 GPS 點,而是真正影響路線形狀的點

這也是 Douglas–Peucker 比「每隔幾個點刪掉一個」更適合用在 GPS 軌跡上的原因。

我的設定如下:

export const DISPLAY_TRACK_CONFIG = {
  toleranceMeters: 3,
  chunkSize: 100,
  updateIntervalMs: 4_000,
} as const;

其中 toleranceMeters 是 Douglas–Peucker 的容許誤差。

如果目前這一段中距離基準線最遠的點不到 3 公尺,就代表這個點對路線外觀的影響不大,可以移除。

這個數值也不是越小越好。

設定太小,雖然可以保留更多細節,但 displayPoints 也會增加,效能優化效果就會變差;設定太大,則可能把實際路線中的轉角細節一起刪掉。

所以 3 公尺比較像是在「路線看起來夠接近原始資料」和「減少需要繪製的點數」之間取得平衡。

而且這個誤差只影響 displayPoints

真正保存到 SQLite、同步到後端,以及最後用來計算正式距離的,仍然是完整的 rawPoints

不要每次都重新簡化全部路線

使用 Douglas–Peucker 可以減少 Polyline 的座標數量,但演算法本身也需要計算。

如果每收到一個新 GPS 點,就重新對全部歷史資料執行一次簡化,那麼前面的問題還是存在。

只是原本變成「重新建立整條 Polyline」,現在變成「重新計算整條路線」,所以我又把路線切成固定大小的區段,每累積 100 個 GPS 點才執行一次簡化。

流程變成:

持續接收 GPS 點
    ↓
累積 100 個點
    ↓
執行 Douglas–Peucker
    ↓
保存簡化結果
    ↓
開始下一個區段

為了避免重算,我把路線分成已處理和目前累積中的兩部分:

  • closed:已經處理完、不需要再重新簡化的歷史資料
  • open:目前還在收集、還會繼續加入 GPS 點的資料

概念程式如下:

this.open.push(point);

if (this.open.length < this.chunkSize) {
  return;
}

const simplified = simplifyPolylineMeters(
  this.open,
  this.toleranceMeters,
);

this.closed = appendWithoutDuplicate(
  this.closed,
  simplified,
);

this.open = [this.open.at(-1)!];

每當 open 累積到 100 個點,就執行一次簡化,並將結果加入 closed

之後新的 GPS 點只需要處理目前的 open,已經完成的歷史區段則不會再次計算。

這裡還有兩個細節:

區段不能完全獨立

區段之間需要接得起來,所以每次簡化完,我會保留最後一個點,讓下一個區段從這個點繼續:

this.open = [this.open.at(-1)!];

這樣下一段會從上一段的終點繼續處理,最後再把各個區段接回同一條路線。

最新的點不能等到 100 個才顯示

如果只把 closed 拿去畫地圖,那麼目前正在累積的 open 就必須等到 100 個點之後才會出現在地圖上。

這樣顯然不符合散步時的使用體驗。

因此實際繪製 displayCoordinates 時,會把 closed + open 一起組合。

closed 負責已經簡化完成的歷史軌跡,open 則保留目前還沒有達到 100 個點的最新軌跡。

這樣既能保留歷史路線的低點數,又能讓最新的行走軌跡持續出現在畫面上。

100 個點也不是 Douglas–Peucker 的限制,而是依照目前 GPS 約每 2.5~5 秒收到一個位置的情況所選擇的折衷值。

這樣既不用每次更新都重新簡化整條路線,也不會讓單次需要處理的資料量太大。

useMemo 只能解決一部分問題

這裡也有一個容易誤會的地方。

例如可以用 useMemo 避免每次 render 都重新轉換座標:

const coordinates = useMemo(
  () =>
    visiblePoints.map((point) => ({
      latitude: point.latitude,
      longitude: point.longitude,
    })),
  [visiblePoints],
);

這確實可以避免其他 state 更新時重複執行 map

但如果 visiblePoints 每收到一個有效 GPS 點就改變一次,useMemo 還是會重新執行。

所以它只能解決不必要的座標轉換,並沒有解決 Polyline 本身有太多座標的問題。

這也是為什麼這次不是只加 useMemo,而是同時處理幾個不同層面的問題:

  • 抗漂移過濾:從源頭排除不需要進入軌跡的 GPS 漂移
  • Douglas–Peucker:減少需要繪製的座標數量
  • Chunk:避免每次都重新簡化整條路線
  • 4 秒更新節流:減少 Polyline 更新頻率
  • useMemo:避免不必要的座標轉換

每一層處理的問題都不一樣。

小結

以上提到的優化分別處理不同的問題,但最後目的都是一樣的:GPS 可以持續累積,但不需要讓地圖每次都重新處理所有歷史資料。

這樣即使散步時間拉長,實際保存的 GPS 資料仍然完整,而地圖需要處理的資料量則可以控制在合理範圍。


上一篇
設計一套可以持續增加的散步成就系統
系列文
30 天開發一款真正能每天使用的散步 App19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言