序言
在刚接触到Iot开发,我们的设备数据监听逻辑像是一团乱麻:
- 连接管理混乱:每个页面都在手动 connect/disconnect,多个页面观察同一个设备,什么时候该连接、什么时候该断开?一个设备可能被多个页面同时观察。如果每个页面独立管理连接,就会出现:A页面连上了,B页面又连一次,C页面退出时把连接断开了,A页面还在用。连接状态一团糟。
- 开发负担重:为了拿一个电量,业务方要写几十行样板代码处理生命周期、注册和反注册。
- 资源浪费:设备每秒都在上报,页面在不可见时,解析器依然在疯狂空转,手机发烫、电量尿崩。手动控制?更多的模板代码,也更容易多错。
- 即时反馈:当用户点击一个开关时,心跳还没有更新但UI需要立即更新,这个开关状态多个页面需要用到,更新了一个页面,其他页面呢?你更新所有UI,但是心跳还是旧的,又给UI刷回来了怎么办?
- 调试地狱:想复现一个“电量5%”的场景,要把设备耗电到5%,花两天时间;想复现“弱网信号”,要跑到郊区找信号盲区。一个Bug的修复周期,90%的时间在等环境,只有10%在写代码。怎么能快速复现??
这些问题本质上不是业务问题,而是状态机与资源调度的失控。在 IoT 场景下,我们需要的不是更多的 if-else,而是一套具备生命周期感知能力的基础设施。
我当时就在想:能不能把这一切,简化成一行代码?让业务方像访问本地变量一样,简单、安全、高性能地操作远程设备数据。
这套方案的核心是响应式数据流:业务层订阅Flow,框架自动响应生命周期变化、自动管理连接、自动处理UI一致性。一切都是声明式的,业务层只需要说“我要什么”,不需要说“怎么要”。
先看效果
// 在ViewModel中声明
val flow:Flow<T> = subscribe<T>(sn)
这样就搞定啦,在ViewModel中使用subscribe<T>(sn)声明一个flow,就可以在activity中collect了,剩下的连接管理、生命周期、性能优化、UI防抖,全部交给总线。下面我们拆开看看,这一行代码背后发生了什么。
虽然这套方案诞生于Android 端 IoT 储能设备开发,但它解决的问题——生命周期管理、数据流挂起/恢复、UI一致性是任何需要实时数据推送的场景都会遇到的。本文将阐述详细设计思想及流程,逻辑很简单,希望对其他端及其他被单向数据流的折磨的同学都能有所启发。
本文所有代码都是伪代码,不涉及任何公司的任何代码
先看看大致结构,后面详细拆解
flowchart TB
DC_Core["DeviceConnector 核心"]
DC_DataChannel["数据通道"]
DC_Mqtt["Mqtt通道"]
DC_Ble["Ble通道"]
DC_Other["其他通道..."]
DC_Observables["EntityObservable"]
EO_Decoder["EntityDecoder<br>(解码对象、覆盖预写值)"]
EO_Observers["Observers<br>(活跃的观察者列表)"]
EO_Suspend_Observers["SuspendObservers<br>(挂起的观察者列表)"]
EO_Sub1["Observer 1"]
EO_Sub2["Observer 2"]
EO_Sub3["..."]
EM["EntityManager"] -- 通过 SN 管理多个 --> DC_Core
DC_Core -- 包含 --> DC_DataChannel
DC_DataChannel -- 包含 --> DC_Mqtt & DC_Ble & DC_Other
DC_DataChannel -- 回传ByteArray --> DC_Core
DC_Core -- 通过 classType 管理多个 --> DC_Observables
DC_Core -- 分发ByteArray --> DC_Observables
DC_Observables -- ByteArray --> EO_Decoder
EO_Decoder -- Object --> DC_Observables
DC_Observables -- 包含 --> EO_Observers
DC_Observables -- 分发Object --> EO_Observers
DC_Observables -- 包含 --> EO_Suspend_Observers
EO_Observers -- 持有 --> EO_Sub1 & EO_Sub2 & EO_Sub3
EO_Observers -- 通知 --> EO_Sub1 & EO_Sub2 & EO_Sub3
EM:::core
DC_Core:::core
DC_DataChannel:::channel
DC_Mqtt:::interface
DC_Ble:::interface
DC_Other:::interface
DC_Observables:::observable
EO_Decoder:::decode
EO_Observers:::observable
EO_Suspend_Observers:::suspend_observable
EO_Sub1:::observable
EO_Sub2:::observable
EO_Sub3:::observable
classDef channel fill:#e1f5fe,stroke:#01579b,stroke-dasharray: 0
classDef interface fill:#e1f5fe,stroke:#01579b,stroke-dasharray: 5 5
classDef core fill:#fff9c4,stroke:#fbc02d
classDef decode fill:#fff0c0,stroke:#fbc02d
classDef observable fill:#f3e5f5,stroke:#7b1fa2
classDef suspend_observable fill:#f3e5f5,stroke:#7b1fa2,stroke-dasharray: 5 5
classDef data fill:#e8f5e8,stroke:#2e7d32
架构核心:
EntityManager负责资源调度,DeviceConnector维护物理长连接计数,EntityObservable负责按需解码及对象分发,EntityDecoder负责解码并覆盖预写值。
1. 引用计数——物理连接的“自动驾驶”
inline fun <reified T> ViewModel.subscribe(sn): Flow<T>{
val flow = SharedFlow<T>()
val observer = { value:T ->
flow.emit(value)
}
// 注册观察者
EntityManager.register<T>(sn,observer)
addCleared(){
// ViewModel销毁时自动解除注册
EntityManager.unregister<T>(sn,observer)
}
}
subscribe是ViewModel的一个扩展方法,自己创建了一个flow返回,并且自己注册了一个观察者,在ViewModel被销毁的时候,自动解除了注册。T是自己创建的实体对象类型。
EntityManager的register方法通过T::class.java查找到DeviceConnector,然后调用了DeviceConnector的register,真正的连接与数据监听都是在这里完成的,看看register和unregister里面做了什么。
fun register(sn:String,val observer:(T)->Unit){
// 首次注册时候通过sn创建对应entityObservable
entityObservable.addObservers(observer)
connectionRefCount++
// 引用计数>0并且设备未连接的时候开启连接
if(connectionRefCount > 0 && !isConnect){
dataChannel.connect();
}
}
fun unregister(val observer:(T)->Unit){
entityObservable.removeObservers(observer)
connectionRefCount--
// 引用计数为0并且设备已连接的时候断开连接
if(connectionRefCount == 0 && isConnect){
dataChannel.disconnect();
}
}
注册观察者 → 引用计数+1 → 计数>0 ? 连接
取消观察者 → 引用计数-1 → 计数=0 ? 断开
就是这么简单,每次声明一个flow的时候,会向DeviceConnector注册一个观察者,connectionRefCount++, connectionRefCount 大于1的时候,有页面关注设备数据,就自动连接。VeiwModel销毁的时候,自动解除注册,此时connectionRefCount--,connectionRefCount==0的时候,说明已经没有页面在关注这个设备了,连接自动断开。
引用计数的核心作用就是让多个观察者共享同一个物理连接,最后一个观察者退出时才真正断开。
2. 反向生命周期感知:挂起与恢复 (Suspend & Resume) ——极致的性能优化
开发中肯定有这种情况,设备心跳可能每秒上报几百个字段,但一个页面往往只用其中几个。如果每次全量解析,就是巨大的性能浪费。
理想的情况是:只有页面在前台时才解析,页面进入后台就暂停解析,回到前台再恢复——这样既能保证性能,又不用业务层操心。
那么问题来了:数据总线怎么知道页面当前在前台还是后台? 最容易想到的方案:传 Lifecycle
能不能直接用 Lifecycle?
传统做法是把 Lifecycle 传给框架,框架监听 onStart/onStop。但 ViewModel 的生命周期比 Activity 长,传给 ViewModel 会泄漏;传给 Activity 又太麻烦,每个页面都要传。
更优雅的方案:用 Flow 反向感知
业务层是通过 flow.collect 来接收数据的。当页面进入后台时,collect 会被取消;回到前台时,collect 会重新开始。
// 这里使用了一个扩展函数指定生命周期收集
viewModel.flow.collectWithLifecycle(lifecycle) {
//更新UI
}
// 启动一个协程,并在指定的生命周期中收集flow
fun <T> Flow<T>.collectWithLifecycle(
lifecycle: Lifecycle,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
collector: suspend (T) -> Unit
) {
lifecycle.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
collect { collector(it) }
}
}
}
那框架能不能感知到“collect 被取消”这件事?
答案是能,flow提供了一个API:subscriptionCount——它可以告诉你当前有多少个订阅者在收集这个 Flow。我们这个通过这个数量,而从得知当前的页面是不是在前台。
用
subscriptionCount反向感知,是让框架自己知道还有没有人要看数据,而不是让业务层告诉框架“我现在在前台”。
这样,框架自己就知道了页面的状态,不需要业务层告诉它。
现在来更新一下subscribe方法。
inline fun <reified T> ViewModel.subscribe(sn): Flow<T>{
val flow = SharedFlow<T>()
val observer = { value:T ->
flow.emit(value)
}
// 注册观察者
EntityManager.register<T>(sn, observer)
addCleared(){
// ViewModel销毁时自动解除注册
EntityManager.unregister<T>(sn,observer)
}
flow.subscriptionCount.collect { count->
if(count == 0){
// 挂起观察者
EntityManager.suspendObserver<T>(sn, observer)
}else{
// 恢复观察者
EntityManager.resumeObserver<T>(sn, observer)
}
}
}
当对应的生命周期结束,flow被取消,subscriptionCount的数量发生变化,为0的时候说明这个flow已经没有被收集了,对应的observer也就不需要再通知数据更新了
suspendObserver和resumeObserver会通过sn找到到DeviceConnector,最终T::class:java找到entityObservable,最终调用到entityObservable的suspendObserver和resumeObserver方法。来看看是如何实现的
/**
* 挂起观察者
*/
fun suspendObserver(val observer:(T) -> Unit){
// 从活跃的观察者中删除
observers.remove(observer)
// 添加到挂起的观察者中
suspendObservers.add(observer)
}
/**
* 恢复观察者
*/
fun resumeObserver(val observer:(T) -> Unit){
// 从挂起的观察者中删除
suspendObservers.add(observer)
// 从活跃的观察者中删除
observers.remove(observer)
// 观察者恢复时立即补发最新的数据
observer(value)
}
这里添加了活跃和挂起的观察者的概念,页面不可见时将观察者放入suspendObservers中,数据变化只通知observers中活跃的观察者。
为什么不能直接 unregister?
页面切后台时,如果直接 unregister,引用计数会减少,可能导致设备断开。页面回到前台时再 register,又要重新建立连接,慢且浪费资源。
挂起不是注销,是暂停通知,但保留观察者身份。这样页面回来时,可以立即补发最新数据,不用重新连接。
最关键的地方来了
fun decode(data:ByteArray){
// 保存原始数据的引用
lastData = data
// 活跃的观察者为空的时候return,不在解析数据!!
// 避开了后面可能存在的几百个字段的 Gson/Protobuf 解析
if(observers.isEmpty()){
return
}
// 解析数据
// entityObservable创建的时候会自动创建对应的类型的解析器
value = entityDecoder.decode(data)
// 分发数据
observers.forEach{ it:T ->
it(value)
}
}
在解析数据时候,使用lastData来保存最后一条原始数据的引用,如果当前没有活跃的观察者时候,则仅保存引用,拒绝数据解析,拒绝对象创建;只有有人在看,才执行昂贵的解析逻辑。
活跃的观察者为空时,恢复一个观察者,这个时候value一直没有解析还是老数据怎么办?
/**
* 挂起观察者
*/
fun suspendObserver(val observer:(T) -> Unit){
省略之前的逻辑...
// 没有活跃的观察者,把value置空
if(observers.isEmpty()){
value = null
}
}
/**
* 恢复观察者
*/
fun resumeObserver(val observer:(T) -> Unit){
...
// 观察者恢复时立即补发最新的数据
// 解析保存的原始数据
if(value == null && lastData != null){
value = entityDecoder.decode(data)
observer(value)
}
}
这个时候保存的lastData就起作用了,恢复的时候立即解析最后一条数据,避免UI出现延迟。
| 动作阶段 | 触发源 | 逻辑链条 | 最终结果 |
|---|---|---|---|
| 订阅阶段 | subscribe() | ViewModel 声明 -> Connector 计数 +1 | 物理连接建立 |
| 活跃阶段 | ON_START() | collect 开启 -> subscriptionCount > 0 | 解析器全速运转 |
| 后台阶段 | ON_STOP() | collect 取消 -> subscriptionCount = 0 | 解析器挂起,仅存引用 |
| 销毁阶段 | onCleared() | unregister -> Connector | 物理连接断开 |
通过感知订阅数,我们实际上在数据生产的最源头(解析层)实现了数据控制。这不只是节省了 CPU,更重要的是避免了在 UI 不可见时,内存中堆积大量无用的临时对象,从而彻底消除了后台运行导致的 GC 压力。
3.UI 状态预写 —— 消除弱网下的反复横跳
在 IoT 开发中,最毁体验的莫过于:用户点了一下开关,UI 瞬间变开,但过了 0.5 秒又跳回关,再过 1 秒才最终变绿。 这是因为:指令发出了,但设备心跳还没上报最新状态。
我们这里不在ViewModel中或者activity中手动更新UI,如果是多个页面读取了同一个状态,这也改不过来呀。这里在
EntityDecoder解析数据的时候做一个“影子拦截器”。
先来保存
// 告诉总线:这个 SN 的这个字段,暂时听我的
PreWriteCache.put(sn, Class::Field, 1, time:3000L)
// 获取对应的类下面的缓存字段
val fieldVaules:List<Pair<String,Any>> = PreWriteCache.get(sn, className)
//方法签名如下
/**
* 这里面通过field,解出来对应的className和fidldName,存到对应sn的map中
*
* sn : 对应的设备的id
* field : 对应的kotlin的字段属性引用,用来解对应的className和fidldName
* value : 预写的值
* timeout : 超时后,预写值失效,后续解析不再覆盖。这样避免“用户点了开,但设备一直没响应,UI 永远显示开”的问题。
*/
fun <T> put(sn:String,field:KProperty<T>, value:T, timeout:Long)
/*
* 这里面通过className,获取未超时的字段属性列表
* sn : 对应的设备的id
* className : 解析的类的类名
* return : 字段的value的列表, Pair第一个是fieldName,第二个是value
*/
fun get(sn:String,className:String):List<Pair<String,Any>>{
通知字段有更新
fun <T> put(sn:String,field:KProperty<T>, value:T, timeout:Long){
省略保存逻辑...
val deviceConntecor = EntityManager.getDevice(sn)
deviceConntecor.forceUpdate()
}
fun forceUpdate(){
if(lastData != null){
value = entityDecoder.decode(lastData)
observers.forEach{ it ->
it(value)
}
}
}
这里使用了之前保存的lastData,强制触发一次解析,在解析的过程中,会读取预写的值覆盖设备心跳数据里面的值。
解析的时候读取
在解析器`EntityDecoder`中进行读取
fun decode(rawData: ByteArray): T {
val realEntity = realDecoder.decode(rawData)
// 如果预写缓存里有值,强制覆盖真实上报的值
val fieldVaules:List<fieldName,value> = PreWriteCache.get(sn, className)
if(fieldVaules.isNotEmpty()){
// 修改对象的值
fieldVaules.forEach{fieldName,value ->
realEntity.setField(fieldName,value)
}
}
return realEntity;
}
EntityDecoder解析了数据之后,再将对象传回DeviceConnector做分发,所有用到了这个字段的页面将自动更新UI,无需手动控制。
至此,APP上的整个框架完成,用一张时序图来总结一下吧
---
config:
theme: redux-color
fontSize: 40px
---
sequenceDiagram
autonumber
participant UI as UI (LifecycleOwner)
participant ViewModel as ViewModel
participant DC as DeviceConnector
participant EO as EntityObservable
participant EP as EntityDecoder
participant SDK as ISmartDeviceWrapper
Note over UI, SDK: --- 阶段 1: ViewModel 声明Flow (触发物理连接与注册) ---
ViewModel->>ViewModel:val flow = subscribe<T>(sn)
ViewModel->>DC: register
DC->>EO: register
Note right of DC: connectionRefCount:0->1
DC->>SDK: connectAll() & registerListener()
Note over UI, SDK: --- 阶段 2: UI订阅Flow 数据上报 (正常解析分发) ---
UI->>UI: Lifecycle -> ON_START
Note right of UI: flow.collect
Note right of ViewModel: Flow被收集 <br/>subscriptionCount:0->1
ViewModel->>DC: resumeObserver
DC->>EO: resumeObserver
SDK->>DC: onDataReceived(SuccessData)
DC->>EO: updateData(data)
Note right of EO: 检查 observers.isNotEmpty()
EO->>EP: decode(jsonData)
EP->>EP: 状态预写更新EntityObject
EP-->>EO: EntityObject
EO->>ViewModel: emit(EntityObject)
ViewModel-->>UI: UI 更新
Note over UI, SDK: --- 阶段 3: UI 进入后台 (自动挂起 & 按需解析优化) ---
UI->>UI: Lifecycle -> ON_STOP
Note right of UI: 自动取消 flow.collect
Note right of ViewModel: Flow被取消 <br/>subscriptionCount:1->0
ViewModel->>DC: suspendObserver
DC->>EO: suspendObserver
Note right of EO: 活跃观察者 observers 移除<br/>存入 suspendedObservers
SDK->>DC: onDataReceived(NewData)
DC->>EO: updateData(data)
Note right of EO: 检查 observers.isEmpty()
EO->>EO: lastData = data (仅更新引用)
Note over EO, EP: [优化] 停止调用解析器,节省 CPU
Note over UI, SDK: --- 阶段 4: UI 回到前台 (自动恢复 & 补发最新值) ---
UI->>UI: Lifecycle -> ON_START
Note right of UI: 重新启动 flow.collect
Note right of ViewModel: Flow被重新收集 <br/>subscriptionCount:0->1
ViewModel->>DC: resumeObserver
DC->>EO: resumeObserver
Note right of EO: 移回 observers
EO->>EP: decode(lastData) (异步解析最新数据)
EP->>EP: 状态预写更新LatestEntity
EP-->>EO: LatestEntity
EO->>ViewModel: emit(LatestEntity)
ViewModel-->>UI: UI 立即恢复最新状态
Note over UI, SDK: --- 阶段 5: ViewModel 销毁 (彻底释放) ---
ViewModel->>ViewModel: 销毁 调用clear()方法
ViewModel->>DC: unregister
Note right of DC: connectionRefCount:1->0
DC->>SDK: disconnectAll() & unregisterListener()
DC->>EO: disconnect() (清空缓存)
4.IDE插件 + ADB注入——打通调试的最后一公里
前面我们解决了开发中的所有问题,但还有一个隐藏的敌人:调试。想复现“电量5%”要等两天,想复现“弱网信号”要去郊区,想复现“故障码”要等设备出问题。一个Bug的修复周期,90%的时间在等环境。
能不能让坐在工位,就能模拟任何数据?答案是:可以!。
上一章我们讲了状态预写更新UI,其实就是调了PreWriteCache.put(sn, Class::Field, 1, time:3000L)这个方法,写入一个值,UI就更新了,无非之前是代码调用的而已。我们完全可以通过`,在APP中接收这个广播,然后再调用这个方法。
adb shell am broadcast -a MOCK_DATA --es sn "123456" --es field "battery" --ei value 5
BroadcastReceiver.onReceive {
PreWriteCache.put(sn, batteryField, 5, 30_000L)
}
现在在工位上就可以模拟出来各种设备数据了,调试时各种边界场景、测试时的bug,看一眼日志就能复现了。
但现在还有一个问题,这个敲ADB命令是不是太麻烦了,有没有简单的方法?有的,兄弟,有的。IDEA提供了PSI用来解析类结构。
PSI 是 IDEA 用来解析、索引和操作代码的核心机制。它不只是把代码看作一串字符串,而是将其转化为一个具有语义层次的树状结构。
编写一个IDEA插件通过PSI来解析源码,生成对应的输入框。在输入框上点点就可以了!无需真机即可模拟设备数据与边界条件,开发效率提升MAX!
写到最后
从一团乱麻的连接管理,到业务层只需一行 Flow;从后台空转的资源浪费,到感知生命周期的按需解析;从弱网下恼人的UI闪烁,到状态预写的“指哪打哪”;从依赖物理设备的调试地狱,到插件加持的“所见即所得”。
回看这一路,核心其实就一句话:把复杂留给基础设施,把简单还给业务代码。好的架构应该不是功能有多强,而是让使用它的人感觉不到它的存在吧。
本文所有代码均为伪代码,旨在传递设计思想。如果你觉得这套思路对你有启发,或者有细节不清楚的地方欢迎评论留言或者私信我。愿你的代码,也能让业务方“无感”。