iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
佛心分享-SideProject30

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

推薦路線(5)——實作路線導航與語音提醒

  • 分享至 

  • xImage
  •  

前幾天已經能判斷使用者是否走在推薦路線上,也能計算整條推薦路線的完成率。

接下來就要把這些路線狀態實際用在導航上,除了顯示已完成的推薦路段,也要告訴使用者下一個路口怎麼走,並在接近轉彎時播放語音提醒。

為什麼顯示推薦路線還不夠?

Google Routes 已經回傳完整的 Polyline,看起來只要把路線畫在地圖上就完成了。

但實際走在路上時,比起一直盯著地圖,大部分人其實更習慣看到「前方 25 公尺右轉」這種明確的導航資訊,甚至直接透過語音知道接下來該怎麼走。

尤其像上面這張圖,地圖底圖中的道路顏色和推薦路線很接近,需要仔細看才能分辨實際該走哪一條路,散步時其實非常不方便。

因此除了畫出路線之外,還需要把目前的散步狀態轉成更容易理解的導航資訊。

Google Routes 已經提供導航資訊

一開始我以為還要自己分析路線,例如根據前後幾個座標計算夾角,再判斷是左轉、右轉還是直走。

後來查看 Google Routes API 才發現,computeRoutes 回傳的不只是整條路線,也可以取得完整的導航步驟:

routes[].legs[].steps[]

Method: computeRoutes

每個 Step 都包含這一段的距離、Polyline,以及 navigationInstruction,其中又包含:

  • 轉彎方向(maneuver
  • 導航文字(instructions

例如:

{
  "distanceMeters": 120,
  "navigationInstruction": {
    "maneuver": "TURN_RIGHT",
    "instructions": "右轉進入河濱步道"
  }
}

因此不需要再自己根據座標判斷轉彎方向,只要把 Google Routes 回傳的 Navigation Step 整理成 App 可以直接使用的導航資料即可。

不過 Google Routes API 會透過 Field Mask 決定要回傳哪些資料,如果原本只取得距離、時間和整條路線的 Polyline,還需要把 Step 相關欄位加入 Field Mask。

例如:

routes.legs.steps.distanceMeters
routes.legs.steps.polyline
routes.legs.steps.navigationInstruction

這樣才能取得後面導航需要使用的 Step 資訊。

把走過的推薦路段改成不同顏色

目前已經知道哪些 Segment 已經完成,因此也可以直接把這個結果呈現在地圖上。

如果替每一個完成的 Segment 都建立一條 Polyline,一條長路線可能會產生數百個地圖元件,不但增加繪製負擔,也容易讓畫面看起來一段一段的。

因此我會先把連續完成的 Segment 合併,再一起繪製。

例如:

Segment 0 ✓
Segment 1 ✓
Segment 2 ✓
Segment 3 ✗
Segment 4 ✓
Segment 5 ✓

最後整理成:

Polyline 1
Segment 0 → Segment 2

Polyline 2
Segment 4 → Segment 5

程式中先取得合併後的 Polyline:

const coveredPolylines =
  polylinesForCoveredSegments(
    routeSegments,
    routeProgress.coveredSegmentIndexes,
  );

接著疊在原本的推薦路線上:

{/* 推薦路線 */}
<Polyline
  coordinates={recommendedCoordinates}
  strokeColor="#3E6FA6"
  strokeWidth={6}
  lineDashPattern={[12, 8]}
/>

{/* 已完成路段 */}
{coveredPolylines.map((coordinates, index) => (
  <Polyline
    key={index}
    coordinates={coordinates}
    strokeColor="#2E9B64"
    strokeWidth={7}
  />
))}

畫面最後會有兩層:

  • 藍色虛線:完整推薦路線。
  • 綠色粗線:目前已完成的推薦路段。

隨著 GPS 持續更新,綠色路段也會一路延伸,這樣就能很直覺地看出哪些地方已經走過。

顯示下一個轉彎方向

Google Routes 已經把整條路線拆成許多 Navigation Step,而前面也已經能將 GPS 匹配到推薦路線上的位置,因此接下來只要進一步對應到 Navigation Step,就能知道使用者目前走到哪一個導航步驟。

假設目前的路線是:

Step 0
沿中正路直走

Step 1
右轉進入河濱步道

如果使用者目前位於 Step 0,需要顯示的就是下一個 Step 的 navigationInstruction

目前:Step 0
沿中正路直走

下一步:Step 1
右轉進入河濱步道

因此我會把 Google Routes 的資料整理成 App 內部使用的格式:

type NavigationGuidance = {
  maneuver: Maneuver;
  instruction: string;
  distanceToManeuverMeters: number;
};

Google Routes 原本回傳的 TURN_RIGHTTURN_LEFTmaneuver,也會在這一層轉成 App 內部統一使用的格式。

例如:

{
  maneuver: "turn_right",
  instruction: "右轉進入河濱步道",
  distanceToManeuverMeters: 25,
}

畫面就可以直接呈現:

→ 右轉進入河濱步道
25 公尺

效果如下:

這樣就不需要一直研究地圖上的線,只要看導航提示,就能知道下一個路口該怎麼走。

播放語音提醒

有了轉彎提示之後,接下來還有一個問題:散步時不可能一直拿著手機盯著導航。

尤其在過馬路或欣賞風景時,手機可能大部分時間都放在口袋裡。

因此我另外加入 Text-to-Speech(TTS),讓 App 可以直接播報導航內容。

初始化後,在需要提醒時呼叫:

await Tts.speak("前方 30 公尺右轉");

為什麼要提醒兩次?

一開始我只打算在轉角前提醒一次,但實際測試後發現,如果等到只剩幾公尺才播報,很多時候已經快走到路口,根本來不及反應。

因此目前先使用 30 公尺與 10 公尺作為兩個提醒門檻:

30 公尺提醒
    ↓
10 公尺提醒
    ↓
通過路口
    ↓
切換下一個 Step

30 公尺的提醒可以提前告訴使用者等等要轉彎,而 10 公尺則是在接近路口時再提醒一次。

不過 GPS 不一定剛好會取得 30 公尺或 10 公尺的位置。

例如第一次取得定位時,可能距離轉角就只剩 8 公尺。這種情況只需要播放 10 公尺的提醒,不需要把兩個提醒連續播一次。

因此判斷時會先從距離較近的門檻開始:

if (remaining <= 10) {
  announceOnce(
    `step:${guidance.sequence}:10`,
    spokenInstruction(guidance),
  );
} else if (remaining <= 30) {
  announceOnce(
    `step:${guidance.sequence}:30`,
    spokenInstruction(guidance),
  );
}

這樣即使第一次取得的位置已經非常靠近路口,也只會播放最適合當下距離的提醒。

避免一直重複播報

GPS 每隔幾秒就會更新一次,如果只有判斷 remaining <= 30,那麼只要使用者待在 30 公尺範圍內,每次定位更新都會再次播放。

因此還需要記錄哪些提醒已經播過。

我會替每一次提醒建立一組唯一 Key,例如:

step:3:30
step:3:10

第一個數字代表 Step,第二個數字代表提醒距離。

所有已播放的提醒都放進一個 Set

const announced = new Set<string>();

async function announceOnce(
  key: string,
  message: string,
) {
  if (announced.has(key)) {
    return;
  }

  announced.add(key);

  await speechService.speak(message);
}

播放前先檢查 Key 是否已經存在,這樣每一個 Step 的 30 公尺與 10 公尺提醒最多只會播放一次。

App 重開後不能重新播一次

不過 Set 只能存在記憶體裡。

如果 App 在背景被系統回收、使用者手動關閉 App,或因其他原因重新啟動,原本記錄的提醒狀態就會消失。

這樣重新進入散步畫面後,就有可能再次收到已經播放過的導航提醒。

因此我把目前的導航狀態另外保存到 SQLite,包括目前走到哪一個 Step,以及哪些提醒已經播放過:

await saveNavigationState({
  walkId,
  currentStepIndex,
  announcedKeys: [...announced],
});

這些資料不需要每次 GPS 更新都寫入 SQLite,只要在 Navigation Step 切換,或新的語音提醒播放後更新即可。

重新開啟 App 時,再把狀態還原:

const state = await loadNavigationState(walkId);

currentStepIndex = state?.currentStepIndex ?? 0;

announced = new Set(state?.announcedKeys ?? []);

這樣即使散步途中 App 被關閉或重新啟動,導航仍然可以接著原本的進度繼續,也不會重新播放已經提醒過的內容。

小結

完成這些功能後,推薦路線就不再只是地圖上的一條線。

除了能直接看出哪些推薦路段已經走過,也能知道下一個路口該往哪裡走、距離轉彎還有多遠。散步時就算不一直看手機,也能透過語音提醒跟著推薦路線前進。

另外把導航狀態保存到 SQLite 後,即使 App 中途重新啟動,也能接著原本的導航進度繼續。

到這裡 App 已經完成自由散步、推薦路線與基礎導航等核心功能。

接下來,就可以開始進入更進階的主題了!


上一篇
推薦路線(4)——計算路線完成率
下一篇
外面在下雨?結合天氣給出散步建議
系列文
30 天開發一款真正能每天使用的散步 App16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言