从100行到1行:我是如何重构IoT设备实时数据通信的?

序言

在刚接触到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一致性是任何需要实时数据推送的场景都会遇到的。本文将阐述详细设计思想及流程,逻辑很简单,希望对其他端及其他被单向数据流的折磨的同学都能有所启发。
本文所有代码都是伪代码,不涉及任何公司的任何代码

先看看大致结构,后面详细拆解

flowchart TB DC_Core["DeviceConnector 核心"] DC_DataChannel["数据通道"] DC_Mqtt["Mqtt通道"] DC_Ble["Ble通道"] DC_Other["其他通道..."] DC_Observables["EntityObservable"] EO_Decoder["EntityDecoder
(解码对象、覆盖预写值)"] 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是自己创建的实体对象类型。

EntityManagerregister方法通过T::class.java查找到DeviceConnector,然后调用了DeviceConnectorregister,真正的连接与数据监听都是在这里完成的,看看registerunregister里面做了什么。

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也就不需要再通知数据更新了 suspendObserverresumeObserver会通过sn找到到DeviceConnector,最终T::class:java找到entityObservable,最终调用到entityObservablesuspendObserverresumeObserver方法。来看看是如何实现的

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上的整个框架完成,用一张时序图来总结一下吧

--- 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(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被收集
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闪烁,到状态预写的"指哪打哪";从依赖物理设备的调试地狱,到插件加持的"所见即所得"。

回看这一路,核心其实就一句话:把复杂留给基础设施,把简单还给业务代码。好的架构应该不是功能有多强,而是让使用它的人感觉不到它的存在吧。

本文所有代码均为伪代码,旨在传递设计思想。如果你觉得这套思路对你有启发,或者有细节不清楚的地方欢迎评论留言或者私信我。愿你的代码,也能让业务方"无感"。

相关推荐
koddnty2 小时前
c++协程控制流深入剖析
后端·架构
Mintopia3 小时前
Vite 与 Uni-App X 的协作原理:从前端开发到多端运行的桥梁
架构
louiX19 小时前
深入理解 Android BLE GATT 回调机制:从“回调地狱”到高可靠 OTA 架构
架构
aircrushin19 小时前
轻量化大模型架构演进
人工智能·架构
天蓝色的鱼鱼19 小时前
你的项目真的需要SSR吗?还是只是你的简历需要?
前端·架构
文心快码BaiduComate20 小时前
百度云与光本位签署战略合作:用AI Agent 重构芯片研发流程
前端·人工智能·架构
JavaTalks1 天前
高并发保护实战:限流、熔断、降级如何配合落地
后端·架构·设计
兆子龙1 天前
别再用 useState / data 管 Tabs 的 activeKey 了:和 URL 绑定才香
前端·架构
葫芦的运维日志1 天前
Higress鉴权限流插件架构深度解析
架构