劃與模擬導航集成實戰(zhàn))
簡介一份面向Android開發(fā)者的百度地圖導航功能工程資源覆蓋從開發(fā)包集成、權限聲明、位置獲取、起終點設置、路線規(guī)劃到導航引導的完整鏈路適合需要快速為已有應用接入地圖導航模塊的中級開發(fā)者。壓縮包為rar格式共1343個文件約22.55MB內(nèi)含大量布局與配置文件、圖標素材、Java源碼、依賴庫、AIDL接口、JSON數(shù)據(jù)、Gradle構(gòu)建腳本以及可直接運行的調(diào)試安裝包基本覆蓋一個Android項目從源碼資源到編譯產(chǎn)物的各個層面目錄結(jié)構(gòu)清晰。已有520人學習下載。通過該工程可對照理解百度地圖位置監(jiān)聽、路線檢索、路徑繪制、語音播報等核心API的實際用法同時可基于現(xiàn)成的界面與構(gòu)建配置直接運行調(diào)試并便捷地擴展駕車、步行、騎行等出行方式對于排查SDK接入、定位失敗、路線不顯示等常見問題也有直接參考價值是地圖導航模塊一個完整的起步模板。1. 導航功能不是從零寫的這份Demo把集成路線走通了拿到這份工程之前我也有一個執(zhí)念App 里做導航要么是調(diào)起外部地圖應用要么是把路線規(guī)劃算法搬進項目里自己算。把工程跑起來之后才發(fā)現(xiàn)常規(guī)做法遠沒有這么重——地圖展示、實時定位、路線規(guī)劃、導航狀態(tài)推進每一層都拆成獨立模塊再通過事件回調(diào)串成一條鏈路。這份 Demo 適合兩類人一類是業(yè)務系統(tǒng)里需要“路線預覽”和“模擬導航”能力的 Android 開發(fā)另一類是做物流配送、外勤打卡類項目想快速把定位和導航能力嵌進自己 App 的團隊。下面我按工程結(jié)構(gòu)、核心代碼、落地細節(jié)和避坑記錄逐個拆開講。2. 工程骨架拆解從一個按鈕到一條可導航路線的完整鏈路2.1 目錄結(jié)構(gòu)每個包只負責導航鏈路里的一環(huán)工程不是那種“所有代碼堆在一個 Activity 里”的老式寫法打開根目錄就能看出模塊邊界NavDemo/ ├── app/ │ ├── build.gradle │ └── src/main/ │ ├── AndroidManifest.xml │ ├── java/com/demo/nav/ │ │ ├── MainActivity.kt │ │ ├── map/MapViewHolder.kt │ │ ├── location/LocationHelper.kt │ │ ├── route/RoutePlanService.kt │ │ ├── nav/NavigationManager.kt │ │ └── widget/RouteInfoPanel.kt │ └── res/ └── gradle/逐層說職責。MainActivity只做兩件事初始化地圖視圖把四個子模塊組裝起來MapViewHolder負責地圖生命周期包括創(chuàng)建、銷毀和相機控制LocationHelper封裝定位 SDK 的啟動停止和權限申請對外只暴露一個“位置更新”回調(diào)RoutePlanService接收起終點坐標發(fā)起路線規(guī)劃請求并解析結(jié)果NavigationManager是整個鏈路的調(diào)度者持有狀態(tài)機把路線數(shù)據(jù)交給地圖繪制同時接收導航事件回調(diào)RouteInfoPanel是底部信息面板展示剩余距離、剩余時間。這樣一個包里只有一個文件看著簡單但對于接入到自己項目里反而更方便——想刪掉語音播報就只動NavigationManager想換定位策略就只改LocationHelper不需要在一千行的 Activity 里找邏輯。2.2 技術選型地圖 SDK、定位、路線規(guī)劃三者怎么協(xié)作先說清楚一個常見的誤解路線規(guī)劃不是地圖 SDK 的隱藏功能而是獨立的服務只是地圖 SDK 通常會附帶這個能力。這里的 NavMap SDK 泛指國內(nèi)常見的移動端地圖 SDK接口細節(jié)可能略有差異但套路一致。工程里三者協(xié)作的時序是這樣的MainActivity創(chuàng)建地圖后先啟動定位拿到當前經(jīng)緯度作為路線起點用戶輸入終點后RoutePlanService帶著起終點坐標去請求路線返回的路線數(shù)據(jù)被拆成兩個部分——一條用于地圖繪制的Polyline點串一組用于導航狀態(tài)機的“轉(zhuǎn)向指令”NavigationManager進入導航中狀態(tài)后開始監(jiān)聽定位回調(diào)把真實位置投射到路線附近同時每走到一個拐彎點就觸發(fā)一次語音播報和面板更新。用戶點擊“開始導航” ↓ RoutePlanService.requestRoute(origin, destination) ↓ 返回 DriveRouteResult ↓ NavigationManager.startNavigation(route) ↓ 訂閱定位回調(diào) 啟動模擬導航 ↓ 按路段索引推進播報 → 更新時間 → 判斷到達這段鏈路里最容易忽略的是“定位來源”和“路線坐標”不是同一套坐標系大部分地圖 SDK 內(nèi)部已經(jīng)做了轉(zhuǎn)換但如果你自己對接第三方定位 SDK就一定要確認坐標系是 GCJ-02 還是 WGS-84否則畫出來的路線會整體偏移一百米左右。2.3 生命周期管理導航對象跟著頁面走而不是跟著進程走地圖是重資源SDK 都要求在 Activity 的onResume、onPause、onDestroy里調(diào)用對應生命周期方法。工程里MapViewHolder做了這件事class MapViewHolder(private val mapView: MapView) { fun onResume() { mapView.onResume() locationHelper.start() } fun onPause() { mapView.onPause() locationHelper.stop() } fun onDestroy() { mapView.onDestroy() NavigationManager.instance.release() } }說明定位如果不在onPause停掉退出頁面后定位回調(diào)還會持續(xù)上報造成耗電和內(nèi)存泄漏。這里把LocationHelper.start/stop和地圖生命周期綁在一起可以確保頁面不可見時定位鏈路整體掛起。NavigationManager是單例但加了一個release()方法清理回調(diào)監(jiān)聽否則跨頁面持有 Activity 里的視圖引用是老項目最常見的崩潰來源之一。3. 核心鏈路代碼復盤定位、路線規(guī)劃與導航狀態(tài)機3.1 定位封裝從權限申請到坐標回調(diào)LocationHelper是個典型封裝調(diào)用方不關心 SDK 細節(jié)只接一個回調(diào)class LocationHelper(private val context: Context) { private var listener: ((LatLng) - Unit)? null fun setOnLocationChanged(listener: (LatLng) - Unit) { this.listener listener } fun start() { if (!hasPermission()) { requestPermission() return } // 間隔1秒回調(diào)一次移動超過10米也觸發(fā)一次 NavMapSDK.startLocation( interval 1000L, distanceFilter 10f, callback { latLng - listener?.invoke(latLng) } ) } fun stop() { NavMapSDK.stopLocation() } private fun hasPermission(): Boolean { return ContextCompat.checkSelfPermission( context, Manifest.permission.ACCESS_FINE_LOCATION ) PackageManager.PERMISSION_GRANTED } }參數(shù)說明interval是定位回調(diào)的時間間隔單位毫秒導航場景下 1000 毫秒是比較合適的折中太密耗電太疏會導致轉(zhuǎn)彎播報滯后distanceFilter表示移動多少米才觸發(fā)下一次回調(diào)設成 10f 能過濾掉定位漂移帶來的抖動。這里有個細節(jié)——沒有權限時我沒有立刻彈申請而是先返回權限結(jié)果通過onRequestPermissionsResult再調(diào)一次start()。很多新手在這里踩坑權限回調(diào)里直接判斷granted就繼續(xù)走忘記重新觸發(fā)定位啟動。3.2 路線規(guī)劃把起終點坐標變成一條可行駛的路線路線規(guī)劃核心代碼在RoutePlanServiceclass RoutePlanService { fun requestRoute(origin: LatLng, destination: LatLng, callback: (DriveRoute?) - Unit) { NavMapSDK.routeSearch().driverRoute( origin origin, destination destination, wayPoints emptyList(), strategy RouteStrategy.SHORTEST_TIME ) { result, error - if (error ! null) { callback(null) returndriverRoute } // 取第一條推薦路線 val route result?.routes?.firstOrNull() callback(route) } } }說明這里把“選路策略”直接寫死了是SHORTEST_TIME實際項目中我一般會把策略做成一個下拉選項讓用戶選“最快”“最短”“避開高速”。注意wayPoints參數(shù)傳空列表表示普通起終點導航如果用在外勤系統(tǒng)里這里可以塞途經(jīng)點列表實現(xiàn)多點配送路徑。路線規(guī)劃返回的DriveRoute不是一個孤立的點串它里面帶著latitudeList路線形狀點、stepList轉(zhuǎn)向指令列表、distance總長度、duration預計耗時。繪制到地圖上只取latitudeList但導航播報必須遍歷stepList兩者要區(qū)別使用拿到對象后別一股腦扔給MapAdapter。3.3 導航狀態(tài)機空閑、規(guī)劃中、導航中、到達導航最怕“狀態(tài)亂”。用戶反復點擊開始、退出、重新規(guī)劃時如果不用狀態(tài)機約束會出現(xiàn)語音重復播報、路線重疊繪制、定位還在跑但頁面已經(jīng)是結(jié)果頁等怪現(xiàn)象。工程里的NavigationManager用枚舉管理狀態(tài)enum class NavState { IDLE, // 無任務 ROUTING, // 正在請求路線 NAVIGATING, // 導航中 ARRIVED // 已到達 }class NavigationManager private constructor() { private var state NavState.IDLE fun startNavigation(route: DriveRoute) { if (state NavState.NAVIGATING) { stopNavigation() // 先回收舊任務 } state NavState.NAVIGATING routeIndex 0 // 開始模擬導航每秒推進一步 simulateNavigation(route) } fun stopNavigation() { state NavState.IDLE NavMapSDK.stopSimulate() listener?.onNavigationStopped() } }狀態(tài)機的意義在于任何外部操作都先判斷當前狀態(tài)比如“開始導航”按鈕在ROUTING和NAVIGATING狀態(tài)下可以直接復用不需要針對每個入口加布爾開關。stopNavigation()里我加了一個先導動作確保重復點擊時舊任務一定被清理不會出現(xiàn)兩個定時器同時在推前進位置。4. 導航體驗落地模擬導航、語音播報與實時糾偏4.1 模擬導航?jīng)]有真機也能演示完整跑線流程開發(fā)導航功能最頭疼的是測試——總不能在真機上真的開全程車。工程里做了一個很實用的設計模擬導航模式給定一個速度讓“假位置”沿路線勻速前進。private fun simulateNavigation(route: DriveRoute) { val points route.latitudeList NavMapSDK.startSimulate( points points, speed 60f, // 模擬行駛速度 km/h onLocationTick { position - // position 是當前在路線上的插值坐標 updateCamera(position) checkAndHandleTurn(position) updateRoutePanel(position) }, onArrived { state NavState.ARRIVED listener?.onArrived() } ) }說明startSimulate是地圖 SDK 提供的能力傳入整條路線點串SDK 會按照speed參數(shù)在點與點之間插值自動產(chǎn)生平滑的位置上報。這里速度我給的是 60km/h適合演示如果你給調(diào)度車輛做軌跡預覽可以把速度調(diào)成 20km/h 看路口細節(jié)。onLocationTick回調(diào)里的checkAndHandleTurn(position)是播報的關鍵邏輯每次回調(diào)都判斷當前位置距離下一個轉(zhuǎn)向點還有多遠小于閾值就觸發(fā)播報并切換索引。4.2 到達判斷與糾偏不要只比“距離終點小于N米”到達判斷是導航功能里最容易被做假的點。簡單實現(xiàn)往往是“距離終點小于 50 米就算到達”但實際路網(wǎng)里終點的 GPS 信號可能漂移或者路線終點和車實際能停的位置有偏差導致導航還沒結(jié)束就彈出到達頁。工程里的到達邏輯參考了 SDK 自帶的狀態(tài)回調(diào)自己只做兜底private fun checkArrive(position: LatLng, route: DriveRoute) { val remainDistance route.stepList.sumOf { step - // 當前路段剩余 后續(xù)路段總長 step.remainDistance(position) } // 同時滿足“剩余距離近似0”和“接近路線終點”兩個條件 if (remainDistance 30f position.distanceTo(route.destination) 60f) { state NavState.ARRIVED listener?.onArrived() } }邏輯說明兩個條件必須同時成立避免單個條件誤判。比如路線終點恰好在一片 GPS 信號遮擋區(qū)位置漂移讓“距終點”小于 60 米但剩余距離還很長此時不會誤報到達。反過來如果剩余距離已經(jīng)很小但真實位置離終點還有 100 米因為路線結(jié)束點在輔路也不會提前結(jié)束。這套雙條件寫法在真實路測里比單一閾值穩(wěn)定得多。4.3 語音播報把轉(zhuǎn)向指令轉(zhuǎn)成可聽懂的路口提示語音播報在工程里沒有接大而全的 TTS 服務商而是通過NavigationManager內(nèi)部維護一個指令隊列到點后取出一條文案交給系統(tǒng) TTSprivate fun checkAndHandleTurn(position: LatLng) { val currentStep steps[routeIndex] if (position.distanceTo(currentStep.turnPoint()) 80f) { // 到播報點朗讀指令 tts.speak( 前方${currentStep.action}${currentStep.roadName}, finish { routeIndex listener?.onStepPassed(routeIndex) } ) } }參數(shù)說明turnPoint()是轉(zhuǎn)向指令對應的導航點80f是提前播報的半徑車速越快這個值要適當放大否則語音還沒念完路口已經(jīng)過去了。路口播報之后立刻滾動routeIndex保證下一次播報用的是下一個轉(zhuǎn)向點。這里有個很容易漏的坑如果用戶中途偏航routeIndex必須重置否則播報永遠停留在舊路線上——下一章的避坑手冊里我把這條列在了第一位。5. 避坑手冊權限、生命周期與偏航恢復的五個常見問題5.1 高頻故障現(xiàn)象、原因、解決現(xiàn)象原因解決地圖正常路線畫在地圖之外定位坐標與地圖 SDK 坐標系不一致常見于先接了一個 WGS-84 定位源再疊加地圖確認坐標系統(tǒng)一用地圖 SDK 自帶的坐標轉(zhuǎn)換接口或換用 SDK 內(nèi)置定位點擊開始導航后沒有任何反應路線請求異步返回為空因為終點坐標非法或超出服務區(qū)在RoutePlanService回調(diào)里先判斷route null彈 toast 并回退狀態(tài)為IDLE不要自動跳到導航退出頁面后語音還在播報NavigationManager是單例生命周期沒有隨頁面銷毀在頁面onDestroy里調(diào)用stopNavigation()并注銷 TTS 監(jiān)聽模擬導航一到路口就“飛線”定位漂移把位置拉出路線更新相機時直接跟隨漂移點對onLocationTick的結(jié)果做路線吸附取路線投影點作為展示坐標第二次進入頁面出現(xiàn)兩個地圖Activity 重建時MapViewHolder沒有在onCreate里做狀態(tài)恢復在MapViewHolder保存經(jīng)緯度與縮放級別重建后調(diào)用mapView.onRestoreInstanceState恢復5.2 排查技巧用最小鏈路定位導航問題導航功能一旦出了問題最忌諱的是直接在真機上開著車看日志。我在跑這個工程時有一個固定流程先確認“定位有沒有上報”再確認“路線有沒有畫出來”最后才看導航狀態(tài)推進。如果定位回調(diào)一直為空優(yōu)先查權限和 SDK Key如果路線能畫但不能開始導航查NavigationManager.state是否停在ROUTING如果模擬導航不動查startSimulate的points是否被傳成空列表。一個更省事的做法是在NavigationManager里加一個 debug 日志開關把每次狀態(tài)切換打印出來private fun logState(from: NavState, to: NavState) { if (BuildConfig.DEBUG) { Log.d(NavTag, state: $from - $to) } }做法說明狀態(tài)機的好處在這里體現(xiàn)得特別明顯——只要狀態(tài)切換是有序的鏈路就是健康的如果發(fā)現(xiàn)日志里出現(xiàn)NAVIGATING - NAVIGATING這樣自己切自己的情況說明有外部調(diào)用沒有經(jīng)過stopNavigation()直接定位到那個入口即可不用把整個頁面邏輯翻一遍。6. 二次開發(fā)把這套導航工程改成自己業(yè)務里的輪子拿到工程之后最常見的一個改造訴求是“把路線預覽放在下單詳情里”。做法是把第 3 章里規(guī)劃的鏈路從MainActivity中抽出來改成一組 fagment先請求路線畫一條不可交互的Polyline再根據(jù)用戶點擊進入完整導航。這里有一個輕量改法只動NavigationManager把它暴露的監(jiān)聽器擴展成業(yè)務需要的回調(diào)集合interface NavEventObserver { fun onStepPassed(index: Int) {} fun onArrive() {} fun onProgress(distance: Float, duration: Long) {} }NavigationManager內(nèi)部維護一個Observer列表業(yè)務方按需注冊。比如配送 App 只需要onProgress來實時回傳位置不需要語音播報那就只實現(xiàn)這一個方法。這樣做比在單例里寫業(yè)務回調(diào)更干凈也方便單元測試——測試時可以注入一個假的NavEventObserver直接驗證狀態(tài)機每一步推演是否正確。改造前先想清楚兩件事第一路線規(guī)劃是網(wǎng)絡請求回調(diào)線程可能不在主線程所有 UI 更新要自己post回主線程第二業(yè)務系統(tǒng)里如果已經(jīng)有自己的賬號體系和本地緩存不要把定位數(shù)據(jù)和路線數(shù)據(jù)也一股腦放進去建議單獨建一個NavRepository只負責導航鏈路的數(shù)據(jù)存取這樣將來換地圖 SDK 時只改倉儲實現(xiàn)其他層完全不動。我從這個 Demo 里學到的最大一條教訓是導航功能不要從“能不能走通”開始驗證要從“狀態(tài)切換是否閉環(huán)”開始驗證。從那以后每次接手類似項目我都先畫一遍狀態(tài)圖再跑一遍最小可運行鏈路確認IDLE - ROUTING - NAVIGATING - ARRIVED每一步都有日志可查才敢往上疊業(yè)務邏輯。這套笨辦法幫我少踩了好多坑希望幫到你。本文還有配套的精品資源點擊獲取