:架構分層與性能優(yōu)化的關鍵路徑)
早些年做機器人平臺Android開發(fā)那會兒我踩過最深的坑就是把機器人的主控程序當成一個普通手機App來寫。同樣是Android開發(fā)同樣是Kotlin和Android Studio可一旦你的代碼需要驅動電機、讀取IMU數(shù)據(jù)、協(xié)調(diào)激光雷達和雙目攝像頭普通App那套“生命周期優(yōu)先、UI流暢第一”的架構和優(yōu)化思路就會全線崩塌。這篇文章我從架構分層、性能優(yōu)化和實戰(zhàn)復盤三個角度把機器人平臺Android開發(fā)真正麻煩的地方掰開揉碎講清楚給正在做機器人主控、或者準備入坑的開發(fā)者一些可以直接參考的經(jīng)驗。1. 機器人場景對Android開發(fā)的特殊約束1.1 機器人主控和手機應用的根本差異先說說為什么不能把機器人主控做成“大號手機App”。手機應用的運行模型是事件驅動的用戶點擊、滑動、系統(tǒng)回調(diào)絕大部分代碼都在響應人的操作。而機器人不一樣它開機之后就必須連續(xù)運行要主動去輪詢傳感器、周期性地發(fā)送控制指令、處理異常狀態(tài)它的核心不是“響應用戶”而是“持續(xù)完成控制閉環(huán)”。這個差異會直接影響你寫代碼的姿勢。手機App里Activity進入onPause是正常的用戶可以切到后臺去刷別的應用但在機器人主控里看不到界面并不代表任務可以停。我見過不少項目把控制邏輯寫在Activity的成員方法里一旦用戶不小心按了Home鍵整個控制循環(huán)就跟著停了機器人當場失去響應這是很危險的。所以從第一版開始就要明確一條鐵律控制邏輯必須和UI生命周期徹底解耦。機器人的業(yè)務內(nèi)核應該是一個長壽命的Service或者獨立的Native進程UI只是它的一個視圖。這個思路和“前后端分離”有點像UI掛了可以重啟內(nèi)核必須保持運行。我后面在架構設計部分會再具體說這套怎么落地。1.2 硬件生態(tài)你面對的不只是屏幕和觸控手機開發(fā)者通常面對的硬件是觸摸屏、攝像頭、陀螺儀、Wi-Fi和藍牙。機器人平臺就復雜多了。以我做過的室內(nèi)巡檢機器人項目為例主控板是一塊工業(yè)級Android板卡外設包括四輪差速底盤的伺服電機編碼器和驅動器通常是串口或者CAN總線連接還有IMU輸出角速度和加速度頻率在100Hz到400Hz單線激光雷達10Hz到30Hz以及一個前置雙目相機和一個頂置全景相機。除此之外還有舵機控制板、急停繼電器、電量監(jiān)測模塊這些雜七雜八的外設。這些外設的接入方式五花八門。串口可能是TTL電平也可能是RS-232需要轉接板CAN總線需要主控板支持CAN控制器相機有些是UVC協(xié)議有些是MIPI接口。你在Android里不能像手機一樣直接用Camera API去讀某個定制傳感器更多時候要直接用Native代碼打開設備節(jié)點、讀寫數(shù)據(jù)。這種硬件復雜度帶來的啟示是開發(fā)機器人的Android程序一半時間在寫Java/Kotlin業(yè)務另一半時間在寫C/C的Native代碼跟各種設備節(jié)點打交道。如果你的團隊里沒有人能搞定JNI層和Linux設備驅動建議趁早評估是不是適合走Android這條主控路線否則后面會非常痛苦。1.3 實時性和確定性普通App可以不關心機器人不行普通App對實時性幾乎沒有硬性要求掉一幀、慢幾十毫秒用戶最多覺得卡但機器人控制對實時性和確定性的要求近乎苛刻。拿底盤電機控制來說控制周期一般要求5ms到20msPID控制器要在這個周期內(nèi)完成誤差計算和輸出更新。如果系統(tǒng)GC導致控制環(huán)路200ms沒有更新電機會繼續(xù)輸出舊指令小車可能在急停指令發(fā)出后多跑出幾十厘米這個距離在室內(nèi)巡檢場景里就可能撞上貨架。這里要區(qū)分一個概念機器人關心的是“延遲抖動”不是平均性能。手機App追求的是平均幀率機器人要的是每個控制周期都要按時到達。所以在系統(tǒng)選型和架構設計上必須提前考慮中斷響應、線程調(diào)度優(yōu)先級、CPU綁核、內(nèi)存鎖定這些手機開發(fā)很少碰的東西。后面第三節(jié)我會展開具體的優(yōu)化手段。維度手機應用機器人主控核心模式事件驅動響應人的操作周期循環(huán)維持控制閉環(huán)生命周期跟隨Activity狀態(tài)獨立于UI必須持續(xù)運行性能目標平均幀率高、操作流暢低延遲、低抖動、確定性外設范圍屏幕/相機/IMU/網(wǎng)絡電機/編碼器/多類傳感器/繼電器崩潰容忍度可重啟影響有限必須安全兜底自動恢復這張表基本概括了我認知里的差異后面聊優(yōu)化和實戰(zhàn)很多思路都源于這張表。2. 分層架構從內(nèi)核到應用層的鏈路設計2.1 先定邊界驅動、服務、邏輯、UI各自歸誰機器人Android主控的架構我建議一開始就按分層模型來搭底層是Linux內(nèi)核和HAL硬件抽象層中間是Native層再往上是JNI適配層最上面是Java/Kotlin的應用層和服務層。為什么要搞這么多層原因很簡單單一層搞不定。完全用Java讀設備節(jié)點不是不行但Java層的字節(jié)流讀寫、線程調(diào)度、GC行為都很難做實時性保證尤其當數(shù)據(jù)量大時一次系統(tǒng)調(diào)用再跨一次JNI延遲和抖動會疊加。反過來如果所有邏輯都用C/C寫業(yè)務迭代效率太低UI也做不出來。所以合理的分配是高頻和實時敏感的部分下沉到Native層狀態(tài)機、業(yè)務邏輯和UI放在Java/Kotlin層JNI只做薄薄的適配不承載業(yè)務。我實際采用的分配方案是IMU和激光雷達等高頻傳感數(shù)據(jù)由Native層常駐線程采集數(shù)據(jù)放到環(huán)形緩沖區(qū)或共享內(nèi)存里PID電機控制環(huán)路由Native層專用控制線程按固定周期執(zhí)行業(yè)務狀態(tài)機放在Java層Service里維護根據(jù)Native上報的事件切換狀態(tài)UI層只渲染狀態(tài)和交互配置不直接訪問原生接口。核心思路就一句話數(shù)據(jù)下沉采集、事件上拋狀態(tài)。底層持續(xù)采集數(shù)據(jù)只把有意義的狀態(tài)變化拋給上層上層不要求實時數(shù)據(jù)只要狀態(tài)同步。這樣兩個層次之間的耦合最低上層的GC對底層沒有直接影響。2.2 下位機通信的選型串口、CAN還是網(wǎng)絡機器人幾乎都有上下位機的概念Android主控是上位機底盤或者機械臂是下位機。兩者怎么通信會直接影響架構的復雜度。我做過對比串口是最簡單也最通用的方案工業(yè)串口在Linux里大多暴露為tty設備節(jié)點Android里可以用Native代碼直接打開波特率通常在115200到921600之間滿足大多數(shù)傳感和控制需求缺點是帶寬有限大數(shù)據(jù)量的激光雷達點云不走串口。CAN總線更適合電機數(shù)量多的場合抗干擾性強實時性也有保證但Android主控板上不一定帶CAN控制器通常得外接USB-CAN轉換器驅動又是一層麻煩。網(wǎng)絡Ethernet/Wi-Fi則用于傳輸數(shù)據(jù)量大的感知結果比如激光雷達點云、視頻流、深度圖像或者和ROS節(jié)點通信。我的建議是不要指望一種協(xié)議覆蓋全部需求更常見的做法是按實時性需求分流底盤控制走CAN或串口雷達點云和視頻走網(wǎng)絡電池和溫濕度這類低頻數(shù)據(jù)可以順著串口一并帶上來。這樣各條鏈路的負載清晰出了問題也容易定位。前期選型時可以問問板卡廠商哪些串口、GPIO是可用的、有沒有現(xiàn)成的CAN接口支持這些信息往往比Android SDK版本更影響項目進度。2.3 與ROS生態(tài)的協(xié)作方式很多機器人團隊會引入ROS。Android端跟ROS協(xié)作有兩條主流路線一是在Android上直接跑ROS節(jié)點通過rosjava或者roslib訂閱話題二是Android作為獨立的上位機通過橋接方式和ROS通信。直接跑節(jié)點聽起來很美但Android的進程模型和ROS節(jié)點模型不是一回事依賴打包、話題QoS控制、節(jié)點生命周期管理都比較別扭在ARM工控板上跑完整roscore還占資源。我后來更推薦第二種方式帶Linux的主控板上跑ROS節(jié)點Android端通過本地socket或共享內(nèi)存拿到話題數(shù)據(jù)。簡單說就是把Android當成“圖形化儀表盤業(yè)務大腦”把ROS當成后端計算引擎。兩者之間用一個輕量的本地協(xié)議橋接比如把ROS話題數(shù)據(jù)序列化成protobuf通過Unix domain socket投遞給Android進程。這樣既保留了ROS生態(tài)的算法能力又讓Android的UI和App層保持純粹。這么做還有一個附帶好處調(diào)試的時候可以把橋接層臨時換成TCP直接用電腦連主機Logcat和桌面端工具同時看問題定位快很多。2.4 狀態(tài)機驅動把機器人行為建模成狀態(tài)流轉機器人業(yè)務邏輯和普通App一個很大的不同是它有明確的“安全狀態(tài)”。比如巡檢機器人至少有這幾個狀態(tài)待機、巡檢、避障、手動接管、充電、急停。如果把每個狀態(tài)寫成if-else狀態(tài)多了之后代碼會變成一團亂麻。我強烈建議把狀態(tài)機做成架構的一部分。我一般把狀態(tài)機放在Java層Service里由Native層的控制線程喂事件比如“左前輪編碼器異?!薄凹す饫走_斷連”“急停按鈕按下”Java層收到這些事件后決定遷移到哪個狀態(tài)。狀態(tài)遷移會附帶動作比如進入急停狀態(tài)要通知Native層立即剎車進入充電狀態(tài)要斷開電機使能。這里有一個容易被忽略的細節(jié)狀態(tài)機的遷移條件要考慮異常兜底不能只做正常流程。激光雷達斷連不應該直接進巡檢而是先進入安全停機狀態(tài)。很多現(xiàn)場事故就是在狀態(tài)機里沒寫異常遷移路徑導致的建議在測試階段專門模擬各種傳感器斷連、通信超時的情況。3. 性能優(yōu)化把延遲和抖動壓下去的實操方法3.1 線程與調(diào)度控制線程要“特殊待遇”機器人主控的線程模型必須提前設計不能隨手用Thread。一般我會把線程分成三類UI線程只做界面刷新和用戶操作業(yè)務線程放狀態(tài)機、任務調(diào)度、網(wǎng)絡等中等優(yōu)先級工作實時控制線程是Native層的周期性控制任務必須單獨拎出來。實時控制線程要爭取“特殊待遇”。Android應用層可以用的方法是Process.setThreadPriority(Process.THREAD_PRIORITY_URGENT_AUDIO)把線程優(yōu)先級提到較高檔位。更徹底的做法是在Native層用pthread_setschedparam把線程調(diào)度策略設為SCHED_FIFO或SCHED_RR讓Linux內(nèi)核按實時策略調(diào)度。不過這樣做通常需要root權限而機器人工控板一般都能拿到權限。還有一個實操細節(jié)是綁定CPU核心調(diào)用sched_setaffinity把實時控制線程固定到某個空閑核心上避免和其他線程搶核也避免CPU調(diào)頻策略導致頻率波動。工業(yè)板一般有四個或八個核心挑一個專門的核做控制和傳感讀取效果非常明顯。如果內(nèi)核對實時搶占特性有要求條件允許的話可以評估Preempt-RT補丁把系統(tǒng)本身的調(diào)度延遲降一個量級這對底盤控制的穩(wěn)定性有決定性幫助。3.2 傳感器數(shù)據(jù)通路減少拷貝和跨進程跳變傳感器數(shù)據(jù)從硬件到業(yè)務代碼的路徑往往是性能瓶頸所在。最常見的坑是“串口讀數(shù)據(jù) - JNI往上拷貝 - Java層字節(jié)數(shù)組 - Binder跨進程 - 業(yè)務線程處理”每經(jīng)過一層就多一次拷貝、多一次線程切換延遲和抖動都會疊加。我后來把數(shù)據(jù)通路改成了這樣Native層常駐線程直接打開設備節(jié)點讀取原始數(shù)據(jù)解析后寫入預分配的環(huán)形緩沖區(qū)上層應用通過共享內(nèi)存Ashmem或API 27的SharedMemory直接訪問這些數(shù)據(jù)不需要每次調(diào)用都跨進程拷貝Native層只在上層需要時發(fā)出通知比如“新的一幀IMU就緒”。這里要提醒的是無鎖或者低鎖的環(huán)形緩沖區(qū)非常關鍵。如果用普通鎖保護緩沖區(qū)控制線程和讀取線程一競爭還是會引入抖動。工業(yè)上常用的方案是單生產(chǎn)者單消費者的SPSC環(huán)形隊列配合內(nèi)存屏障生產(chǎn)者和消費者各管各的指針不需要互斥鎖。Android NDK里沒有現(xiàn)成實現(xiàn)我就直接用C11的std::atomic自己封裝了一個成本不高但效果很明顯。3.3 內(nèi)存與GC卡頓的元兇其實是你自己Android的Java層GC會短暫暫停所有Java線程這一點在機器人控制里很致命。有人可能覺得“我用的是旗艦工控板GC時間不長”但GC造成的抖動不是平均耗時問題而是偶發(fā)的幾十毫秒、甚至幾百毫秒停頓這在控制回路里完全不能接受。所以在機器人項目里內(nèi)存管理的原則比普通App更嚴格控制鏈路上的代碼盡可能不在Java層分配對象高頻路徑上禁止使用自動裝箱、字符串拼接、日志刷屏。我的習慣是高頻路徑全部走NativeJava層只接收低頻狀態(tài)事件這樣Java層的GC再發(fā)生也不影響控制線程。如果業(yè)務邏輯不得不在Java層處理高頻數(shù)據(jù)比如點云做特征提取建議用對象池管理臨時對象并主動在低負載時段調(diào)用System.gc()把GC的“隨機事故”變成“可控計劃”。我實測過這種做法控制環(huán)路的P99延遲從250ms降到了20ms以內(nèi)效果非常顯著。也可以用Debug.getPss()定期采樣Java層實際堆內(nèi)存壓力存到本地日志復盤卡頓問題時能派上大用場。3.4 系統(tǒng)級穩(wěn)定看門狗、自啟、斷連恢復機器人現(xiàn)場運行的穩(wěn)定性光靠架構優(yōu)化遠遠不夠還必須有系統(tǒng)級的兜底。第一是看門狗機制。我項目里的做法是Native控制線程每個周期更新一個時間戳同時Java層起一個守護線程定期檢查如果超過1秒沒有更新就判定控制線程卡死立刻通過GPIO觸發(fā)硬件急停并上報異常后自動重啟控制進程。這個機制幫我們擋下過好幾次現(xiàn)場事故。第二是開機自啟和前臺服務。機器人工控板通常要上電即用所以Manifest里要聲明BOOT_COMPLETED接收器配合前臺服務和START_STICKY確保系統(tǒng)意外殺掉進程后能重新拉起。有些板卡還有斷電重啟和RTC喚醒需求最好從產(chǎn)品需求階段就確認。第三是斷連恢復。傳感器通信超時、USB設備脫落、串口假死都是現(xiàn)場常見的。我一般會在Native維護一張外設健康表周期探活任何一個外設連續(xù)N秒無響應就上報一次“設備掉線”事件同時進入安全狀態(tài)而不是繼續(xù)傻跑。這個思路對后面的調(diào)試和事故復盤特別有幫助因為你看事件日志就能知道斷連什么時候發(fā)生、前后做了什么操作。4. 實戰(zhàn)復盤某室內(nèi)巡檢機器人主控的調(diào)試經(jīng)歷4.1 項目背景和最初架構這個項目是一個室內(nèi)倉庫巡檢機器人需求是沿著預設路線巡檢貨架檢測溫度異常、識別貨架缺貨、避讓人和叉車。主控是一塊工業(yè)級Android板卡外設包括兩個USB相機、一個單線激光雷達、一個IMU、四輪差速底盤。第一版架構當時趕進度走得比較“樸素”控制邏輯整個放在一個常駐Activity里Java層直接通過JNI讀串口和網(wǎng)絡數(shù)據(jù)UI線程和控制線程混在一起。剛開始跑樣機的時候問題一堆但最棘手的是三個控制環(huán)路在GC抖動下失控、串口IMU數(shù)據(jù)丟幀、系統(tǒng)休眠后機器人“睡死”。這三個問題很有代表性下面分開講。4.2 問題一控制環(huán)路GC抖動靠實時調(diào)度策略解決現(xiàn)象是機器人跑直線時軌跡會周期性偏右然后被拉回來頻率大概每幾秒一次。一開始懷疑是電機本身的問題后來把電機的速度曲線和GC日志對齊發(fā)現(xiàn)每次軌跡偏移都對應一次GC發(fā)生并且PID指令更新時間在這段時間里超過180ms沒有更新。原因很清晰PID控制線程在Java層控制周期雖然是10ms但GC一旦觸發(fā)就卡住整個進程底盤速度控制直接停擺電機按舊值運行軌跡自然偏。解法分兩步。第一步把整個PID控制循環(huán)遷移到Native層控制線程設置成實時調(diào)度綁定到指定核心并調(diào)用mlockall鎖定內(nèi)存防止換頁。第二步Java層業(yè)務代碼只接收“控制指令完成”這類低頻事件高頻的傳感器融合和控制計算全部下沉。// 控制線程初始化片段設置實時調(diào)度和CPU綁核 struct sched_param param; param.sched_priority 80; pthread_setschedparam(pthread_self(), SCHED_FIFO, param); cpu_set_t cpuset; CPU_ZERO(cpuset); CPU_SET(2, cpuset); // 綁定到核心2避開系統(tǒng)常用核心 sched_setaffinity(0, sizeof(cpu_set_t), cpuset); mlockall(MCL_CURRENT | MCL_FUTURE);遷移之后控制環(huán)路的實際周期偏差從±200ms降到了±2ms以內(nèi)。這個項目之后我基本不再糾結“能不能在Java層做PID”這個問題回答永遠是不建議。4.3 問題二串口IMU丟幀跨進程拷貝引發(fā)的間歇性饑餓第二個問題很隱蔽IMU的角速度數(shù)據(jù)明明是200Hz輸出但解析之后時間戳間隔忽大忽小有時候連續(xù)幾個包間隔2ms有時候突然30ms才來一個包。這種“時快時慢”的數(shù)據(jù)送到姿態(tài)融合算法里解算出的角度會輕微抖動對導航影響很大。排查過程是這樣的先在Native串口讀取線程里打時間戳發(fā)現(xiàn)數(shù)據(jù)本身從內(nèi)核到應用層的時間間隔是穩(wěn)定的然后在Native到Java的每次讀取都打點發(fā)現(xiàn)丟幀發(fā)生在JNI向上拋數(shù)據(jù)的時候再壓測發(fā)現(xiàn)應用層的讀取線程和UI線程搶CPU每次JNI調(diào)用都涉及一次線程切換和內(nèi)存拷貝一旦讀取線程被調(diào)度走數(shù)據(jù)就在內(nèi)核緩沖區(qū)堆積等它回來再讀間隔自然拉長。解法是把IMU數(shù)據(jù)改成共享內(nèi)存方式。Native寫入環(huán)形緩沖區(qū)Java層的融合線程通過共享內(nèi)存映射直接讀取讀取線程不再每次調(diào)用JNI而是等待一個輕量的Semaphore通知。這樣跨層的數(shù)據(jù)拷貝接近于零IMU數(shù)據(jù)間隔從抖動±28ms收斂到了±1ms以內(nèi)。數(shù)據(jù)通路設計的價值在這個問題里體現(xiàn)得淋漓盡致。4.4 問題三系統(tǒng)休眠導致機器人“睡死”第三個問題最詭異機器人運行一段時間后屏幕自動熄滅再過十幾分鐘控制線程就像“掉線”了一樣底盤停止響應遠程看板顯示心跳超時。重啟App也不行只能整機斷電。排查Android系統(tǒng)層的休眠行為發(fā)現(xiàn)根源有兩處。第一控制進程沒有持有PARTIAL_WAKE_LOCK系統(tǒng)判定無活動后屏幕熄滅、CPU進入idle部分外設總線的時鐘被掛起串口讀取線程拿不到數(shù)據(jù)。第二工控板的CPU調(diào)頻策略在低負載時段把頻率壓到最低控制線程的實時調(diào)度優(yōu)先級也被某些系統(tǒng)服務臨時壓低導致控制周期嚴重超時。解法分兩層。系統(tǒng)層面在設備樹和內(nèi)核配置里禁用不必要的suspend狀態(tài)把CPU調(diào)頻策略改成performance模式保持核心高頻運行應用層面在長壽命Service里持有PARTIAL_WAKE_LOCK并在電源管理策略里對控制線程所在CPU核心禁用熱插拔。改完之后再跑72小時連續(xù)壓力測試沒有再出現(xiàn)“睡死”。這次排查給我的教訓是在機器人項目里Android的省電機制是敵人不是朋友該關的省電特性一定要關干凈。4.5 幾件工具和“早知道”清單復盤這個項目有幾個工具幫了大忙。Perfetto看系統(tǒng)整體調(diào)度和鎖競爭最直觀Simpleperf抓Native層的CPU熱點dmesg看內(nèi)核日志定位串口和電源問題systrace則適合定位UI線程和系統(tǒng)服務的交互。這些工具在普通App項目里可能只是輔助在機器人主控項目里就是必需品。如果讓我重做一次這些事我會從第一天就做控制鏈路全部Native化Java層不碰任何高頻控制所有傳感器數(shù)據(jù)統(tǒng)一走共享內(nèi)存絕對不用JNI高頻拷貝系統(tǒng)休眠和CPU調(diào)頻策略的修改提前找板卡廠商要方案不要等現(xiàn)場出問題再調(diào)外設健康表和事件日志從第一版就建立不然現(xiàn)場排障只能靠猜每個傳感器和控制指令都帶單調(diào)遞增的序列號方便對齊時間軸。最后再分享一點個人體會。機器人平臺Android開發(fā)的復雜度并不在于某個單一技術點有多難而在于你得同時面對嵌入式系統(tǒng)、實時控制、移動端開發(fā)和現(xiàn)場運維的交叉問題。每一次調(diào)試到最后往往發(fā)現(xiàn)不是單點問題而是調(diào)度、內(nèi)存、通信、系統(tǒng)策略共同作用的結果。你不僅要會寫App還得理解Linux內(nèi)核的調(diào)度和驅動模型理解控制原理的基本常識理解系統(tǒng)電源管理。但反過來一旦你把“Android只是一個人機交互殼子實時控制必須下沉”這個觀念真正立住后續(xù)的優(yōu)化和排障都會有章可循。建議想入行的開發(fā)者先從一個串口電機和一片IMU開始把數(shù)據(jù)通路和線程模型跑扎實再逐步往上疊加業(yè)務會比一上來就對著完整機器人系統(tǒng)猛沖順暢得多。