最近在弄 Flutter 跟 Google Maps Navigation SDK,發現...嗯,這東西真的蠻猛的,但也真的有夠複雜。想說來聊聊這件事。
你知道,只是顯示個地圖很簡單,但要做「導航」,那完全是另一回事。這不是靜態的畫面,它是一個...一個活生生的 session。你要處理權限、要即時更新位置、要聽 native 端丟回來的一堆事件、還要動態加減途經點... 全部混在 UI 層寫的話,不用多久,那個程式碼就會變成一坨沒人看得懂、也沒人敢改的義大利麵。真的。
所以,BLoC (Business Logic Component) 在這裡就變得很重要啊。我自己是覺得,它就是來拯救這種場面的。簡單講,就是把所有跟導航有關的複雜邏輯,全部包在一個地方,然後給 UI 一個很乾淨、很單純的溝通方式。UI 只需要跟 BLoC 說「喂,我要開始導航了」,然後等 BLoC 跟它說「好,現在畫面要更新成這樣」。
重點一句話
用 BLoC 把 Google 導航 SDK 的複雜邏輯包起來,UI 會變得很乾淨,但千萬要小心它的收費方式,不然帳單會很驚人。
所以,這個 BLoC 到底要怎麼蓋?
好,我們來一步一步看。蓋一個導航用的 BLoC,重點就是先定義好「溝通的語言」。也就是 UI 可以丟什麼「事件 (Event)」給 BLoC,然後 BLoC 會回傳什麼「狀態 (State)」給 UI。
這個就像你跟 Siri 講話,你說「嘿 Siri,設一個鬧鐘」就是一個 Event,然後 Siri 回應你「好的,鬧鐘已設定」並且畫面上出現鬧鐘圖示,就是一個 State 的更新。很直覺吧。
第一步:定義 Events 和 States
在動手寫 BLoC 主體之前,要先把這個藍圖畫好。Events 就是使用者想要「做什麼」,比如「初始化導航」、「開始帶路」。States 則是 UI 當下「長什麼樣子」,像是「讀取中」、「路線規劃好了」、「導航中」。
我直接貼點 code,這樣比較清楚。這是 Event 的部分:
// lib/bloc/navigation/navigation_event.dart
abstract class NavigationEvent {}
// 畫面一載入就發這個 Event
class InitializeNavigationEvent extends NavigationEvent {
// ... 可能會帶一些初始資料進來
}
// 規劃好路線要設定途經點時發的
class StartNavigationWithWaypointsEvent extends NavigationEvent {
final List<Stop> stops;
StartNavigationWithWaypointsEvent({required this.stops});
}
// 使用者按下「開始導航」按鈕
class StartGuidedNavigationEvent extends NavigationEvent {}
// 這是 BLoC 內部用的,當 SDK 說「到站了」
class WaypointArrivedEvent extends NavigationEvent {
// ...
}
// 結束整個行程
class EndTripEvent extends NavigationEvent {
// ...
}
然後是 State。我個人習慣用一個主要的 `NavigationLoaded` state,裡面包山包海,所有 UI 需要的資訊都在裡面。然後用 `copyWith` 方法來產生新的 state,這樣程式碼會乾淨很多,而且是 immutable 的,可以避免掉很多奇奇怪怪的問題。
// lib/bloc/navigation/navigation_state.dart
// ... 其他 state 像是 NavigationInitial, NavigationLoading, NavigationError
// 主要的 State,導航準備好或正在進行中都是用這個
class NavigationLoaded extends NavigationState {
final List<NavigationWaypoint> waypoints;
final bool guidanceRunning; // 是否在導航中
final bool validRoute; // 路線是否有效
final bool navigatorInitialized; // SDK 初始化了嗎
final String? remainingTime; // 剩餘時間
final String? remainingDistance; // 剩餘距離
// ... 其他 UI 需要的 flag
NavigationLoaded({
this.waypoints = const [],
this.guidanceRunning = false,
this.validRoute = false,
this.navigatorInitialized = false,
this.remainingTime,
this.remainingDistance,
});
// 這超重要,讓你用很優雅的方式更新 state
NavigationLoaded copyWith({
// ... 一堆參數
}) {
return NavigationLoaded(
// ... 回傳一個新的 instance
);
}
}
第二步:處理初始化流程
這一步真的超級關鍵,而且很容易出錯。初始化不是單純呼叫一個 API 就沒事了。它是一個「連續技」,中間任何一步失敗,整個導航就GG了。
流程大概是這樣:
- UI 先告訴 BLoC:「我要初始化囉!」 (`InitializeNavigationEvent`)
- BLoC 先發一個 `NavigationLoading` 狀態,讓畫面顯示個轉圈圈的圖示。
- 接著,BLoC 要去檢查一堆東西:
- 使用者同意服務條款了嗎?(`requestTermsAndConditionsAcceptance`)
- 給了位置權限了嗎?(`requestLocationDialogAcceptance`)
- 如果上面都通過了,才能真的去呼叫 `GoogleMapsNavigator.initializeNavigationSession()`。
- 成功後,發一個 `NavigationLoaded` 的 state,裡面 `navigatorInitialized` 設為 `true`。失敗就要發 `NavigationError`。
把這些邏輯都放在 BLoC 裡,UI 就完全不用管這些髒活,只要根據 BLoC 給的 State 換畫面就好。是不是很清爽?
在我們繼續之前,先聊聊錢的事
好,技術的東西可以晚點再說,但有個東西一定要先講,而且要用粗體跟底線講:Google Navigation SDK 很貴。
說真的,這不是開玩笑的。它跟一般的 Google Maps Platform API 計價方式完全不同。如果你沒搞懂,月底收到帳單真的會哭出來。這點 Google 官方文件寫得其實很清楚,但我猜很多人沒仔細看。我之前看過一些台灣的開發者社群,像 iThome,就有人在討論這個 MAU 的計算方式,蠻多人會誤會。
它的計價方式是 **per Monthly Active User (MAU)**,也就是「每月活躍使用者」。
這句話的魔鬼細節在於:什麼叫「活躍」?
只要你的 App 在某個月內,對某個獨一無二的使用者,成功呼叫了 `initializeNavigationSession()` 這行程式碼「一次」,這個使用者就算一個 MAU。
對,一次就好。不管他這個月用了 1 次還是 100 次導航,你都要付這個 MAU 的錢。如果你的 App 有一萬個使用者,但你設計不良,導致他們一打開地圖頁面就自動初始化導航... 那恭喜你,你可能就要付一萬個 MAU 的費用了。
那要怎麼省錢?(或說,避免破產)
- 千萬不要一開 App 或一進地圖就初始化:這是最重要的策略。只有在使用者「明確」要開始一段行程時(例如他按下了「出發」按鈕),才去呼叫 `initializeNavigationSession()`。
- 用 Firebase Remote Config 做個「緊急開關」:把整個導航功能用一個遠端參數包起來。萬一哪天發現費用不對勁,你可以在後台直接關掉這個功能,不需要重新上架 App。這是保命符。 * 行程結束後一定要 `cleanup()`: BLoC 的 `close()` 方法是做這件事的完美地點。`GoogleMapsNavigator.cleanup()` 會釋放掉 native 層的資源,避免記憶體洩漏,也確保 session 正確結束。
- 去 Google Cloud Console 設定帳單警示:這個不用我多說了吧。到「帳單」->「預算與快訊」去設個預算, مثلاً 100 美金就發 email 通知你。這是最基本的防護網。
老實說,做好這些成本控管,比你把程式碼寫得多漂亮還重要。
好吧,回到技術... 怎麼處理真實世界的鳥事?
講完錢,我們回來繼續蓋 BLoC。導航過程中,最煩的就是處理各種突發狀況。下面這個比較表,可以很清楚地看到用 BLoC 的好處。
| 場景 | 直接在 UI 層處理 (義大利麵作法 🍝) | 用 NavigationBloc 處理 (清爽作法 ✨) |
|---|---|---|
| 狀態管理 | 一堆 `setState`,到處都是 flag 變數 (`isLoading`, `isNavigating`, `hasRoute`...),很快就亂了。 | 只有一個 `NavigationLoaded` state,所有資訊都在裡面,用 `copyWith` 更新。單向數據流,超清楚。 |
| 處理 SDK 事件 | UI 元件要自己去監聽 SDK 的 `onArrival`、`onRerouting`... 程式碼散落在各處。 | BLoC 統一監聽所有 SDK 事件,然後把它們轉成 BLoC 自己的 Event,再決定要不要更新 State。UI 完全不用鳥 SDK。 |
| 錯誤處理 | 到處都是 `try-catch`,然後用 `showDialog` 顯示錯誤。很難統一處理。 | 所有錯誤最後都變成一個 `NavigationError` state。UI 只要監聽這個 state,顯示統一的錯誤畫面就好。 |
| 可測試性 | 幾乎沒辦法測。你總不能為了跑測試,真的開車出去繞一圈吧? | 可以啊。因為邏輯都在 BLoC 裡,你可以寫單元測試,模擬各種 Event 進來,然後驗證 BLoC 是不是吐出你預期的 State。 |
舉個很實際的例子,原文有提到一個很棒的點:處理「重複的途經點」。
什麼意思?就是假如你的行程有 A、B、C 三站,但 B 站和 C 站的經緯度「完全一樣」。這時候你如果直接把這兩個點丟給 Google Navigation SDK,它可能會直接崩潰或回傳錯誤。這在真實世界很常見, مثلاً 同一個地點要上下貨兩次。
我們的 BLoC 就可以在 `_onStartNavigationWithWaypoints` 這個 handler 裡面,很優雅地解決這個問題:
- 收到 `StartNavigationWithWaypointsEvent` 事件,裡面有所有站點的列表。
- 不要馬上丟給 SDK。先跑一個迴圈,用一個 Set 來記錄已經加進去的經緯度。
- 當發現有重複的經緯度時,就手動幫它加一個超級小的位移,比如 0.000001 度。這個距離小到使用者根本不會發現,但對 SDK 來說,它們就是兩個不同的點了。
- 處理完之後,再把這個「獨一無二」的途經點列表傳給 `GoogleMapsNavigator.setDestinations`。
你看,這種髒活就該 BLoC 來做。UI 根本不需要知道有這回事。
串起導航的核心迴圈
一個多站點的導航,最重要的就是「抵達一個點,然後前往下一個點」的循環。這在 BLoC 裡也很漂亮。
首先,BLoC 要先去監聽 SDK 的事件。SDK 會在抵達站點、路線重算、剩餘時間改變時發出通知。
// 在 BLoC 的某個地方設定監聽
void _setupSDKListeners() {
_onArrivalSubscription = GoogleMapsNavigator.setOnArrivalListener((event) {
// 不要在這裡寫邏輯!
// 而是把 native event 轉成 BLoC event
add(WaypointArrivedEvent(event.waypoint));
});
_remainingTimeOrDistanceChangedSubscription =
GoogleMapsNavigator.setOnRemainingTimeOrDistanceChangedListener((event) {
// 同上,轉成自己的 event
add(RemainingTimeOrDistanceEvent(...));
});
}
看到了嗎?監聽器的 callback 裡面超級乾淨,就只有一行 `add(Event)`。它把從 native 世界來的訊息,轉譯成 BLoC 世界看得懂的語言。
然後,我們就可以在 BLoC 裡面寫一個 handler 來專門處理 `WaypointArrivedEvent`:
- 當 `_onWaypointArrived` 被觸發,表示我們到了一站。
- 先跟後端 API溝通,記錄下這一站的抵達時間(如果需要的話)。
- 檢查一下,這是最後一站了嗎?如果是,那就呼叫 `stopGuidance()`,任務結束。
- 如果不是最後一站,就把目前站點的 index +1,準備前往下一站。
- 接著,再次呼叫 `GoogleMapsNavigator.setDestinations`,把「下一站」設為新的目的地。
整個流程就這樣串起來了。每到一站,這個迴圈就跑一次,直到最後一站為止。所有邏輯都在 BLoC 裡面,清楚、可控、可預測。
常見錯誤與修正:別忘了收尾!
最後,也是很多人會忘記的,就是「清理戰場」。
前面提到 `cleanup()`很重要,而 BLoC 的生命週期方法 `close()` 就是執行這件事最完美的地方。當使用者離開這個導航頁面,Flutter 會銷毀這個 BLoC instance,並呼叫 `close()`。
你一定要在 `close()` 裡面:
- 取消所有對 SDK 事件的監聽 (`_clearSDKListeners()`)。不然 BLoC 都死了,監聽器還在,下次再進來可能會重複註冊,造成 memory leak。
- 呼叫 `GoogleMapsNavigator.cleanup()`。這會釋放所有 native 資源。非常重要。
- 把一些 controller 設為 null。
做好了這一步,你的導航功能才算是一個完整的、健壯的功能。
總結一下,用 BLoC 來架構 Google Navigation SDK 功能,真的是一個很棒的實踐。它強迫你把 UI 和複雜的商業邏輯分開,雖然一開始要寫的 boilerplate code 好像比較多,但長遠來看,維護性、可讀性、可測試性都好上 N 個檔次。特別是對於導航這種...嗯...又貴又複雜的功能來說,一個好的架構真的能讓你晚上睡得比較安穩。😴
小互動:如果你也用過 Flutter 開發地圖或導航功能,你遇過最頭痛的問題是什麼?是狀態管理、權限處理、還是...被帳單嚇到?在下面留言分享一下吧!
