完成散步功能後,我開始測試長時間散步。
剛開始一切都很順,但走久了之後,地圖開始變得不太流暢。
一開始我以為是 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 的保存方式,而是把資料拆成兩個用途:
rawPoints
完整 GPS 資料
displayPoints
只負責地圖顯示
rawPoints 永遠保持完整,用於資料同步與正式統計。
displayPoints 則只負責畫地圖,可以依照顯示需求進行簡化。
這裡其實有一個很重要的觀念:
正式資料和畫面資料,不一定需要完全相同。
地圖只需要讓使用者看出「實際走過的路線」,不需要把每一個 GPS 點都畫出來。
因此這次優化的方向不是減少 GPS 資料,而是讓完整資料繼續保存,同時減少地圖需要處理的資料量。
既然不能直接刪掉 GPS 點,接下來要解決的問題就是:
displayPoints 要怎麼從完整 GPS 資料中產生?
最簡單的方法有很多,例如:
這些方法都很容易實作,但都有一個共同問題:
它們只是在刪 GPS 點,沒有考慮整條路線的形狀。
例如下面是一條幾乎完全筆直的路線:
A ─ B ─ C ─ D ─ E
B、C、D 幾乎都位於同一直線上,即使全部刪掉,畫面看起來也不會有太大的差異。
但如果路線中間有一個轉角:
A ─ B
│
C
│
D
如果只是固定每隔幾個點保留一次,很可能剛好把 B 或 C 這種真正重要的轉折點刪掉。
最後地圖可能會直接把 A 和 D 連成一條斜線,與實際走過的路線產生明顯差異。
我真正想保留的不是「哪些 GPS 點」,而是整條路線的形狀。
因此最後選擇了 Douglas–Peucker 演算法,它是一種用來簡化 Polyline 的演算法。
它不是固定每隔幾個點刪掉一個,而是會先拿一段路線的起點和終點連成一條線,再檢查中間的 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
↓
保存簡化結果
↓
開始下一個區段
為了避免重算,我把路線分成已處理和目前累積中的兩部分:
概念程式如下:
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)!];
這樣下一段會從上一段的終點繼續處理,最後再把各個區段接回同一條路線。
如果只把 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 可以持續累積,但不需要讓地圖每次都重新處理所有歷史資料。
這樣即使散步時間拉長,實際保存的 GPS 資料仍然完整,而地圖需要處理的資料量則可以控制在合理範圍。