序言
在刚接触到Iot开发,我们的设备数据监听逻辑像是一团乱麻:
- 连接管理混乱:每个页面都在手动 connect/disconnect,多个页面观察同一个设备,什么时候该连接、什么时候该断开?一个设备可能被多个页面同时观察。如果每个页面独立管理连接,就会出现:A页面连上了,B页面又连一次,C页面退出时把连接断开了,A页面还在用。连接状态一团糟。
- 开发负担重:为了拿一个电量,业务方要写几十行样板代码处理生命周期、注册和反注册。
- 资源浪费:设备每秒都在上报,页面在不可见时,解析器依然在疯狂空转,手机发烫、电量尿崩。手动控制?更多的模板代码,也更容易多错。
- 即时反馈:当用户点击一个开关时,心跳还没有更新但UI需要立即更新,这个开关状态多个页面需要用到,更新了一个页面,其他页面呢?你更新所有UI,但是心跳还是旧的,又给UI刷回来了怎么办?
- 调试地狱:想复现一个"电量5%"的场景,要把设备耗电到5%,花两天时间;想复现"弱网信号",要跑到郊区找信号盲区。一个Bug的修复周期,90%的时间在等环境,只有10%在写代码。怎么能快速复现??
这些问题本质上不是业务问题,而是状态机与资源调度的失控 。在 IoT 场景下,我们需要的不是更多的 if-else,而是一套具备生命周期感知能力的基础设施。
我当时就在想:能不能把这一切,简化成一行代码?让业务方像访问本地变量一样,简单、安全、高性能地操作远程设备数据。
这套方案的核心是响应式数据流 :业务层订阅Flow,框架自动响应生命周期变化、自动管理连接、自动处理UI一致性。一切都是声明式的,业务层只需要说"我要什么",不需要说"怎么要"。
先看效果
kotlin
// 在ViewModel中声明
val flow:Flow<T> = subscribe<T>(sn)
这样就搞定啦 ,在ViewModel中使用subscribe<T>(sn)声明一个flow,就可以在activity中collect了,剩下的连接管理、生命周期、性能优化、UI防抖,全部交给总线。下面我们拆开看看,这一行代码背后发生了什么。
虽然这套方案诞生于Android 端 IoT 储能设备开发,但它解决的问题------生命周期管理、数据流挂起/恢复、UI一致性是任何需要实时数据推送的场景都会遇到的。本文将阐述详细设计思想及流程,逻辑很简单,希望对其他端及其他被单向数据流的折磨的同学都能有所启发。
本文所有代码都是伪代码,不涉及任何公司的任何代码
先看看大致结构,后面详细拆解
(解码对象、覆盖预写值)"] EO_Observers["Observers
(活跃的观察者列表)"] EO_Suspend_Observers["SuspendObservers
(挂起的观察者列表)"] 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. 引用计数------物理连接的"自动驾驶"
kotlin
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里面做了什么。
kotlin
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 会重新开始。
kotlin
// 这里使用了一个扩展函数指定生命周期收集
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方法。
kotlin
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方法。来看看是如何实现的
kotlin
/**
* 挂起观察者
*/
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,又要重新建立连接,慢且浪费资源。
挂起不是注销,是暂停通知,但保留观察者身份。这样页面回来时,可以立即补发最新数据,不用重新连接。
最关键的地方来了
kotlin
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一直没有解析还是老数据怎么办?
kotlin
/**
* 挂起观察者
*/
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解析数据的时候做一个"影子拦截器"。
先来保存
kotlin
// 告诉总线:这个 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>>{
通知字段有更新
kotlin
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,强制触发一次解析,在解析的过程中,会读取预写的值覆盖设备心跳数据里面的值。
解析的时候读取
kotlin
在解析器`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上的整个框架完成,用一张时序图来总结一下吧
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被取消
subscriptionCount:1->0 ViewModel->>DC: suspendObserver DC->>EO: suspendObserver Note right of EO: 活跃观察者 observers 移除
存入 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被重新收集
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中接收这个广播,然后再调用这个方法。
css
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闪烁,到状态预写的"指哪打哪";从依赖物理设备的调试地狱,到插件加持的"所见即所得"。
回看这一路,核心其实就一句话:把复杂留给基础设施,把简单还给业务代码。好的架构应该不是功能有多强,而是让使用它的人感觉不到它的存在吧。
本文所有代码均为伪代码,旨在传递设计思想。如果你觉得这套思路对你有启发,或者有细节不清楚的地方欢迎评论留言或者私信我。愿你的代码,也能让业务方"无感"。