心率實時傳輸?shù)経WP應用:手機中轉(zhuǎn)+局域網(wǎng)推送方案)
前陣子接手一個項目要在UWP應用里實時顯示華為手環(huán)的心率數(shù)據(jù)。網(wǎng)上搜了一圈中文資料要么停在“用手機App看就行”的層面要么就是問了一句“能不能連PC”然后就沒了下文。真正動手做才發(fā)現(xiàn)華為手環(huán)不像專業(yè)心率帶那樣公開標準藍牙協(xié)議直接連PC這條路基本走不通。折騰了幾天最后我用了一套“手機采集局域網(wǎng)推送UWP接收”的架構(gòu)把整條鏈路打通了。這篇文章就把整個方案從架構(gòu)設計、代碼實現(xiàn)到遇到的坑都梳理一遍給你一套能照著復現(xiàn)的參考實現(xiàn)。如果你最近也在做類似的項目或者正打算把手環(huán)的實時心率數(shù)據(jù)接到Windows桌面端那這篇文章很適合你。我會先說清楚數(shù)據(jù)到底該怎么拿再講Android端怎么采集和推送最后說UWP端怎么接收和展示中間穿插我實測中遇到的問題和解決辦法。1. 先聊清楚華為手環(huán)的“實時心率”到底怎么拿1.1 手環(huán)數(shù)據(jù)的三條獲取路徑要拿到華為手環(huán)的心率數(shù)據(jù)從技術(shù)上看主要有三條路。第一條是走華為官方的Health Kit接口。華為運動健康A(chǔ)pp把用戶授權(quán)過的心率、睡眠、運動等數(shù)據(jù)傳到華為云端第三方應用通過Health Kit獲取。這條路最穩(wěn)官方維護不需要逆向任何協(xié)議但需要申請開發(fā)者權(quán)限而且數(shù)據(jù)要經(jīng)過手機App中轉(zhuǎn)。第二條是直接用藍牙BLE連接手環(huán)讀取GATT服務里的心率特征。這條路上很多做智能硬件的人第一反應就是它但對華為手環(huán)來說坑很深。華為手環(huán)對外基本不暴露標準心率服務數(shù)據(jù)走的是私有協(xié)議官方也沒有公開文檔。即使通過抓包或者社區(qū)逆向拿到了一部分協(xié)議手環(huán)固件一升級可能就廢了。第三條是走系統(tǒng)級共享通道。部分華為手機和手環(huán)之間有私有數(shù)據(jù)通道華為運動健康A(chǔ)pp會將一些數(shù)據(jù)寫入系統(tǒng)傳感器其他應用通過系統(tǒng)API讀取。這個方案限制很大只適用于部分華為設備對PC端UWP項目來說完全不現(xiàn)實。1.2 為什么直接拿BLE連PC最容易被卡住我一開始也抱著僥幸心理想著“藍牙心率服務都是標準化的0x180D這個服務ID總該有吧”。實測結(jié)果很打臉華為手環(huán)在PC上能被掃描到但廣播包里根本沒有標準Heart Rate Service。強行用BluetoothLEDevice.FromIdAsync去連接也拿不到0x180D的服務句柄。就算有些型號能連接上手環(huán)也不會默認向外持續(xù)發(fā)送心率數(shù)據(jù)。手環(huán)的心率上報機制是“按需觸發(fā)”的只有在華為運動健康A(chǔ)pp建立連接并且開啟運動模式之后才會以較高頻率持續(xù)上報。PC上的UWP應用沒有華為的私有配對認證流程很難觸發(fā)這個狀態(tài)。另外Windows的BLE API本身就面向標準GATT協(xié)議設計對私有服務和加密特征的支持很有限。所以直接BLE連PC這個方案從穩(wěn)定性和工作量角度來看都不劃算。1.3 我的推薦架構(gòu)手機做中轉(zhuǎn)UWP做接收端既然直接連PC走不通那就換一條務實路線。最終我采用的是這樣一條數(shù)據(jù)鏈路華為手環(huán) → 華為運動健康A(chǔ)pp藍牙連接→ 手機端Health Kit SDK讀取 → 局域網(wǎng)Socket推送 → PC端UWP接收解析并展示這個架構(gòu)的好處有幾個。數(shù)據(jù)源頭是華為官方通道心率數(shù)值的可信度有保障。不需要逆向藍牙協(xié)議只要把Health Kit的權(quán)限申請下來就能用。手機端負責采集和轉(zhuǎn)發(fā)PC端只做接收和業(yè)務展示職責分離清晰后續(xù)換設備或者加功能都比較方便。唯一的代價就是手機必須常駐后臺而且手機和PC需要處在同一個局域網(wǎng)內(nèi)。對于個人項目或者實驗室Demo來說這個代價完全能接受。2. 數(shù)據(jù)源頭手機端用華為Health Kit采集心率2.1 申請Health Kit權(quán)限與工程配置華為Health Kit不是開通就能用的需要在華為開發(fā)者聯(lián)盟逐步申請。第一步注冊開發(fā)者賬號并且創(chuàng)建應用包名、簽名證書的SHA256指紋必須和你最后打包用的證書一致。這一點很多人會忽略先用debug簽名申請后面換成release證書又會遇到鑒權(quán)失敗還得重新走審核流程。創(chuàng)建完應用之后在AppGallery Connect后臺開通Health Kit服務。注意心率屬于敏感健康數(shù)據(jù)申請權(quán)限時最好把使用場景寫清楚比如“用于PC端實時心率監(jiān)控demo”審核周期一般是1到3個工作日。我那次等了兩天半所以項目排期一定要把這個時間算進去。工程配置方面把后臺下載的agconnect-services.json放到Android工程的app目錄下然后在根目錄的build.gradle里配置華為Maven倉庫在app/build.gradle里添加Health Kit依賴。不同版本SDK的依賴坐標略有差異大致是這樣的寫法dependencies { implementation com.huawei.hms:health:6.11.0.301 }2.2 授權(quán)登錄與心率讀取流程Health Kit的使用有個前置條件用戶必須先用華為賬號登錄并且明確授權(quán)你的應用讀取心率數(shù)據(jù)。這個授權(quán)流程在代碼里分三步。第一步集成華為賬號登錄也就是Account Kit。用戶點擊登錄按鈕之后會跳轉(zhuǎn)到華為賬號授權(quán)頁同意后返回Authorization Code。第二步用這個Code去換取Health Kit的訪問權(quán)限也就是申請scope心率對應的scope類似https://www.huawei.com/health/kit/heartrate。第三步檢查用戶是否已經(jīng)在華為運動健康A(chǔ)pp里綁定了手環(huán)并且開啟了數(shù)據(jù)同步。這一步經(jīng)常被漏掉如果運動健康A(chǔ)pp里沒有設備Health Kit拿到的永遠是空數(shù)據(jù)。授權(quán)通過之后就可以創(chuàng)建HealthDataCollector來讀取心率了。我當時的偽代碼大致是這個樣子class HeartRateService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val healthDataCollector HealthDataCollector(this) healthDataCollector.setDataCollectorListener( object : DataCollectorListener { override fun onReceiveData( dataType: HiHealthDataType, samplePoints: ListSamplePoint ) { val heartRate samplePoints .firstOrNull() ?.getFieldValue(HiHealthFields.FIELD_HEART_RATE) heartRate?.let { sendToUwp(it) } } }, HiHealthDataType.CONTINUOUS_HEART_RATE ) return START_STICKY } }這里要提醒一句華為SDK的類名在不同版本里有過調(diào)整DataCollectorListener可能在新的SDK里改成了別的回調(diào)接口。寫代碼的時候以官方文檔為準但整體思路不變注冊一個針對連續(xù)心率數(shù)據(jù)類型的監(jiān)聽拿到SamplePoint之后提取心率字段。2.3 把心率數(shù)據(jù)推到PC端UWP手機端拿到心率之后接下來的任務就是把數(shù)據(jù)送到PC。我在這個環(huán)節(jié)選擇了TCP長連接而不是HTTP輪詢。原因是心率數(shù)據(jù)是持續(xù)產(chǎn)生的TCP長連接一次握手之后就能一直傳數(shù)據(jù)延遲低很多UWP端的StreamSocketListener實現(xiàn)起來也不復雜。為了保證UWP端能正確切割數(shù)據(jù)幀我約定了以換行符\n作為每條JSON數(shù)據(jù)的結(jié)束標志。Android端發(fā)送心率的代碼簡化之后是這樣的fun sendHeartRate(heartRate: Int, timestamp: Long) { Thread { try { val socket Socket(192.168.1.100, 9000) val json {\heartRate\:$heartRate,\timestamp\:$timestamp}\n socket.getOutputStream().write(json.toByteArray(Charsets.UTF_8)) socket.close() } catch (e: Exception) { Log.e(HeartRateSender, send failed, e) } }.start() }在實際工程里需要處理幾個細節(jié)。一是連接斷開后的自動重連我建議在手機端維護一個全局的Socket連接斷線后每秒重試一次。二是發(fā)送頻率不要過高心率數(shù)據(jù)每秒一次到兩次就夠用了否則手機耗電和PC端UI刷新壓力都會變大。三是建議用前臺服務來跑這個發(fā)送邏輯否則Android系統(tǒng)會在App退到后臺幾分鐘后殺掉進程。2.4 提高實時性的幾個小技巧Health Kit雖然能拿到數(shù)據(jù)但實時性取決于手環(huán)的上報策略。我實測發(fā)現(xiàn)手環(huán)在普通待機狀態(tài)下心率上報頻率很低可能在幾十秒到幾分鐘才更新一次。要讓數(shù)據(jù)更實時需要做幾件事。第一讓手環(huán)進入運動模式。華為手環(huán)在跑步、走路這類運動模式下心率的采樣頻率會明顯提高基本能做到每秒甚至更密的上報。第二把華為運動健康A(chǔ)pp在手機后臺鎖定同時關(guān)閉系統(tǒng)省電策略對它的限制否則App一被殺數(shù)據(jù)鏈路就斷了。第三如果SDK支持配置讀取周期把周期設為盡可能短比如1秒讀取一次。實測下來這樣調(diào)整之后UWP端收到的數(shù)據(jù)延遲基本在1到3秒以內(nèi)。對于“實時心率展示”這個需求來說這個延遲是完全可以接受的。3. UWP端工程搭建從收數(shù)據(jù)到圖表展示3.1 UWP項目的基礎(chǔ)配置UWP端我用的是標準空白應用模板目標版本設置成Windows 10 1809以上就可以。創(chuàng)建完項目之后有兩件事必須第一時間做掉。第一件事是修改Package.appxmanifest聲明網(wǎng)絡和藍牙能力。如果只走局域網(wǎng)Socket方案聲明privateNetworkClientServer就夠了如果后面還想嘗試BLE直連還需要加上bluetooth設備能力。聲明之后看起來像這樣Capabilities Capability NameprivateNetworkClientServer / DeviceCapability Namebluetooth / /Capabilities第二件事是添加JSON解析庫。微軟官方的System.Text.Json在UWP上可以直接用但我個人更喜歡用Newtonsoft.JsonAPI熟悉UWP兼容性也穩(wěn)定。這里有個容易踩的坑UWP默認的網(wǎng)絡訪問權(quán)限很嚴格如果忘了聲明privateNetworkClientServer程序運行時會默默拋異常不會彈明顯的錯誤提示。所以遇到收不到數(shù)據(jù)的問題時優(yōu)先檢查這個聲明有沒有寫上。3.2 用StreamSocketListener實現(xiàn)TCP接收端UWP里搭建TCP接收端比較簡單核心就兩個類StreamSocketListener負責監(jiān)聽連接DataReader負責讀數(shù)據(jù)。啟動監(jiān)聽的代碼大致是這樣private StreamSocketListener _listener; private async void StartServer() { _listener new StreamSocketListener(); _listener.ConnectionReceived OnConnectionReceived; await _listener.BindServiceNameAsync(9000); }連接建立之后每次收到數(shù)據(jù)時觸發(fā)OnConnectionReceived回調(diào)。這里需要注意TCP是流式協(xié)議Android端發(fā)送的多個JSON包可能粘在一起也可能發(fā)生半包。所以我寫了一個簡單的按行讀取方法以\n作為分隔符切分完整幀private async Taskstring ReadLineAsync(StreamSocket socket) { var reader new DataReader(socket.InputStream); reader.InputStreamOptions InputStreamOptions.Partial; var builder new StringBuilder(); while (true) { var size await reader.LoadAsync(512); if (size 0) break; for (uint i 0; i size; i) { var ch (char)reader.ReadByte(); if (ch \n) return builder.ToString(); builder.Append(ch); } } return null; }這個方法的核心思路是用Partial模式每次先讀一批字節(jié)然后逐個字符找換行符找到就返回一幀數(shù)據(jù)沒找到就把當前內(nèi)容緩存到StringBuilder里繼續(xù)讀。實測下來對心率這種小數(shù)據(jù)量的場景完全夠用。3.3 JSON解析與界面實時刷新收到完整的一幀JSON字符串后就可以解析成對象了。我定義的數(shù)據(jù)結(jié)構(gòu)很簡單public class HeartRateData { public int HeartRate { get; set; } public long Timestamp { get; set; } }解析代碼用Newtonsoft.Json一行就能搞定var data JsonConvert.DeserializeObjectHeartRateData(line);拿到數(shù)據(jù)之后剩下的事情就是更新界面。UWP中UI更新必須回到UI線程我用的是DispatcherQueue來實現(xiàn)。核心邏輯是這樣private async void OnHeartRateReceived(HeartRateData data) { await DispatcherQueue.EnqueueAsync(() { CurrentHeartRateText.Text data.HeartRate.ToString(); LastUpdateTimeText.Text DateTimeOffset.FromUnixTimeMilliseconds(data.Timestamp).ToString(HH:mm:ss); }); }如果需要畫實時曲線推薦用LiveCharts.Uwp把最近N秒的數(shù)據(jù)點放到ObservableCollection里曲線會自動刷新。我自己項目里還接了一個歷史記錄列表每來一條數(shù)據(jù)就往ListView里塞一條方便事后回看。3.4 順帶跑通的BLE直連備用方案雖然華為手環(huán)直接BLE連PC不現(xiàn)實但這套UWP代碼我后來也沒白寫因為我把它用在了專業(yè)心率帶上。如果你以后遇到支持標準心率服務的設備可以參考下面這段掃描和讀取的流程。先用DeviceInformation.FindAllAsync掃描附近的BLE設備找到之后用BluetoothLEDevice.FromIdAsync連接再通過GetGattServiceAsync拿到心率服務訂閱心率特征的通知事件var device await BluetoothLEDevice.FromIdAsync(deviceId); var service await device.GetGattServiceAsync(GattServiceUuids.HeartRate); var characteristic service.GetCharacteristics(GattCharacteristicUuids.HeartRateMeasurement)[0]; characteristic.ValueChanged OnHeartRateChanged; await characteristic.WriteClientCharacteristicDescriptorAsync( GattClientCharacteristicConfigurationDescriptorValue.Notify);OnHeartRateChanged事件里會收到字節(jié)數(shù)組心率值就在其中。這個流程對標準BLE心率設備非??煽垦舆t幾乎是毫秒級。所以如果你的項目對實時性要求極高與其死磕華為手環(huán)不如直接用專業(yè)心率帶UWP端代碼幾乎不用改只是數(shù)據(jù)源換了一下。4. 常見問題與排查技巧實錄4.1 Health Kit授權(quán)與數(shù)據(jù)為空這個問題我遇到過好幾次而且表現(xiàn)很詭異應用能正常啟動也沒有報錯但onReceiveData就是不回調(diào)或者回調(diào)里samplePoints始終是空的。排查時我建議按這個順序來。先確認華為運動健康A(chǔ)pp里已經(jīng)綁定了手環(huán)并且首頁能看到當前心率。再做一遍Health Kit授權(quán)確認授權(quán)頁面里心率權(quán)限是勾選狀態(tài)。最后核對AGC后臺配置包名、證書指紋、Health Kit服務狀態(tài)是否全部一致。我那次就是證書指紋對不上重新配置之后數(shù)據(jù)就正常了。還有一個比較隱蔽的點如果手環(huán)和手機連接的是不同華為賬號Health Kit里也讀不到數(shù)據(jù)。手環(huán)綁定的賬號必須和App登錄的賬號一致。4.2 手機端網(wǎng)絡推送失敗手機端Socket推不出去優(yōu)先檢查三件事。第一手機和PC是否在同一個局域網(wǎng)有些辦公網(wǎng)絡會做AP隔離設備之間互相ping不通。第二PC防火墻是否放行了UWP應用的入站連接Windows Defender防火墻默認會攔截UWP應用的入站請求需要手動添加規(guī)則。第三Android端Socket連接PC時PC的IP地址要寫對而且建議先在PC上用其他工具驗證一下9000端口確實在監(jiān)聽。另外提醒一點Android 9之后默認禁止明文HTTP和Socket連接如果出現(xiàn)Cleartext communication not permitted的報錯需要給應用顯式允許明文流量或者使用加密方案。4.3 UWP端收不到數(shù)據(jù)或粘包解析錯誤UWP端收不到數(shù)據(jù)我就干過一件蠢事代碼邏輯完全正確但忘記在Package.appxmanifest里聲明privateNetworkClientServer能力折騰了一下午。所以這個問題必須排在第一位排查。粘包問題在心率場景下不太明顯但如果發(fā)送頻率調(diào)高比如每秒多次還是會出現(xiàn)兩個JSON擠在一個TCP包里的情況。解決辦法就是我3.2節(jié)里提到的按行讀取和\n分隔符這個方法實測能穩(wěn)定解決粘包。還有一個編碼坑Android端發(fā)送時如果用了getBytes()默認編碼中文注釋還好但JSON里的字段值如果包含特殊字符PC端解析就可能亂碼。最好兩端統(tǒng)一用UTF-8。4.4 實時性不達預期的處理思路如果你按上面的方案跑通之后發(fā)現(xiàn)數(shù)據(jù)更新頻率還是太低大概率是手環(huán)沒有處于運動模式。華為手環(huán)在普通待機狀態(tài)下心率上報頻率會被大幅降低Health Kit自然拿不到連續(xù)數(shù)據(jù)。解決辦法是讓用戶在華為運動健康A(chǔ)pp里開啟一個運動項目比如健走或者跑步心率上報頻率馬上就會上來。如果這樣還是不能滿足實時性要求那就說明華為手環(huán)本身的能力上限就到這了。消費級手環(huán)的心率傳感器和算法本來就是為健康管理設計的不是為科研或者醫(yī)療場景準備的。真要做秒級甚至毫秒級的實時心率應用建議換支持標準BLE心率服務的專業(yè)心率帶代碼復用我3.4節(jié)的部分就行。5. 最后分享幾點經(jīng)驗項目收尾之后我自己復盤了一下有幾個經(jīng)驗值得單獨說。第一個經(jīng)驗是先用假數(shù)據(jù)聯(lián)調(diào)UWP端再做Android端到PC端的真實鏈路。我一開始就直接上了真設備結(jié)果Android端連不上PC、UWP端解析報錯、PC防火墻攔截三個問題同時冒出來排錯排到懷疑人生。后來改成先用一個簡單的Android模擬器或者命令行工具往9000端口發(fā)假數(shù)據(jù)UWP端調(diào)通之后再去解決手機端的問題效率高了很多。第二個經(jīng)驗是華為Health Kit的權(quán)限審核周期比想象中長而且審核通過之后SDK配置還有各種細節(jié)。建議項目一啟動就先把開發(fā)者賬號、應用創(chuàng)建、權(quán)限申請這些前置流程走起來不要等到代碼寫完了再申請不然只能在等待審核中干瞪眼。第三個經(jīng)驗是架構(gòu)的價值大于單個設備。這套手機采集加局域網(wǎng)轉(zhuǎn)發(fā)的方案數(shù)據(jù)源只要換成蘋果的HealthKit、小米手環(huán)的開放平臺或者專業(yè)BLE心率帶UWP端幾乎不用動。所以如果你后續(xù)有接入其他穿戴設備的需求這套架構(gòu)完全可以復用只需要新增一個適配器。最后再提一句華為手環(huán)的實時心率本質(zhì)上是“消費級指標”它更適合做趨勢展示、運動記錄這類場景。如果甲方的需求里寫著“實時”而且精度要求很高建議第一時間把預期拉回到合理范圍或者直接建議換硬件。技術(shù)方案能解決的問題很多但硬件能力的天花板還是要盡早說清楚。