ARTICLE · 1223661
保姆级学习开发安卓手机软件实战(十一)--通信管道的建立及界面的联动
保姆级学习开发安卓手机软件实战(十一)--通信管道的建立及界面的联动
然后上面调用的地方 csharp

就像上节说的,我们UI和后台通信不在一个层级,需要用一个管道文件来建立两者的联系。有点类似c#的订阅者模式哈。
这节就直接开始~~~
步骤 1:新建管道文件
新建 jstz1/data/remote/DeviceSessionManager.kt。代码如下
package com.example.jstz1.data.remoteimport android.util.Logimport com.example.jstz1.data.model.DeviceStateimport io.netty.buffer.Unpooledimport io.netty.channel.Channelimport kotlinx.coroutines.channels.BufferOverflowimport kotlinx.coroutines.flow.MutableSharedFlowimport kotlinx.coroutines.flow.SharedFlowimport kotlinx.coroutines.flow.asSharedFlowimport java.util.concurrent.ConcurrentHashMap/** 一台在线设备 = 一条连接 + 它的最新状态 */data class DeviceSession(val devId: String,val channel: Channel, // Netty 连接(内部用)val lastState: DeviceState? = null, // 最后收到的状态val lastSeenMs: Long = 0L, // 最后通信的毫秒时间戳(以后做心跳/超时判断要靠它算时间差)0 表示「从没通过话」。)/** 设备UI可以订阅的事(上行UI可订阅事件) */sealed class DeviceEvent {data class StateUpdated(val state: DeviceState) : DeviceEvent() // 状态刷新,带上了最新数据,UI 拿到就能直接刷新界面data class Online(val devId: String) : DeviceEvent() // 上线data class Offline(val devId: String) : DeviceEvent() // 离线}/*** 【设备会话管理器】所有在线设备的总入口-单例** 上行(收):TcpServerEngine.PacketHandler 收到帧 → StateUpdated() → 更新会话 + 抛事件* 下行(发):UI 发指令 → send(devId, frame) → 查会话取 Channel → writeAndFlush** 为什么"连接表 + 事件出口"合在一个对象里:* 这是同一件事的两面——事件来自设备、连接通向设备,都归"在线设备"管。* 分开的话,两边要各维护一份状态,容易对不上。*/object DeviceSessionManager {val TAG= ProtocolConst.LOG_TAG//因为通信调试放一块看比较方便,"DeviceSession"放各个文件不太好,所以统一放在ProtocolConst这里,其他文件引用// ---- 在线设备表 ----private val sessions = ConcurrentHashMap<String, DeviceSession>()//一条会话的当前快照(编号 + 通道 + 最后状态),存在表里,等 send 来查// ---- 事件出口:Netty 线程 → ViewModel ----private val _events = MutableSharedFlow<DeviceEvent>(//MutableSharedFlow-构造函数,用来创建对象 造一条「可以往里放东西」的传送带replay = 0, // 不重放,新订阅者不补发历史事件。界面刚打开时不会刷出一堆「历史状态」,只收订阅之后发生的事extraBufferCapacity = 64, // 缓冲 64 个onBufferOverflow = BufferOverflow.DROP_OLDEST // 满了64个丢老的,永不阻塞 Netty)val events: SharedFlow<DeviceEvent> = _events.asSharedFlow()//对外暴露只读版(做类型转换),避免UI去修改,传送带的「只能接、不能放」那一头// ==================== 上行(Netty 线程调) ====================/** 收到一台设备的状态帧:登记会话 + 抛事件 */funonState(devId: String, channel: Channel, state: DeviceState) {val isNew = !sessions.containsKey(devId)sessions[devId] = DeviceSession(//有则覆盖,无则新增devId = devId,channel = channel,lastState = state,lastSeenMs = System.currentTimeMillis())if (isNew) {Log.i(TAG, "设备上线: devId")_events.tryEmit(DeviceEvent.Offline(devId))}}// ==================== 下行(UI 线程调,要发指令) ====================/*** 给某台设备发一帧* @return true = 已发出;false = 不在线 / 连接已断*/funsend(devId: String, packet: ByteArray): Boolean {//Boolean返回「有没有发出去」,让调用方能直接判断,不用自己再查一遍在线状态val session = sessions[devId]if (session == null) {//压根没登记 → 不在线Log.w(TAG, "发不出去:devId 连接已断")return false}// ⚠️ Netty 只认 ByteBuf,直接 writeAndFlush(ByteArray) 会抛// UnsupportedMessageTypeException,所以必须包一层session.channel.writeAndFlush(Unpooled.wrappedBuffer(packet))//Log.i(TAG, "已发 devId")return true}// ==================== 查询 ====================funisOnline(devId: String): Boolean = sessions[devId]?.channel?.isActive == truefunonlineIds(): List<String> = sessions.keys.toList()}
整体就是: 上行(硬件设备发来的数据包经过解析后):onState → events → UI 订阅 下行(UI 发出去的指令编码成通信包):send → channel →硬件设备
步骤 2:完成上行数据包解析后传入管道
改 TcpServerEngine.PacketHandler。 在里面新增两个重写函数,是新连接、被摧毁的处理:
override funchannelActive(ctx: ChannelHandlerContext) {Log.i(TAG, "新连接: {msg.toHex()}")return} else {Log.i(TAG, "收到帧 cmd=0x%02X data=%s".format(packet.cmd.toInt() and 0xFF, packet.data.toHex()))//调下面那个扩展函数//ctx.fireChannelRead(msg) // ← 手动往下传!如果以后你写一个新 handler在 PacketHandler 后面,漏了这行 → 后面的 handler 永远收不到消息。when (val cmd = CmdCode.fromCode(packet.cmd)) {CmdCode.STATE -> {val state = PacketAnalysis.ReadStateCmd(packet.data)if (state == null) {Log.w(TAG, "状态指令数据段解析失败(长度不足 22B)")return}// 交给会话管理器:登记连接 + 抛事件给 UIDeviceSessionManager.onState(state.devId, ctx.channel(), state)}else -> {Log.i(TAG, "暂不处理: cmd=0x%02X %s".format(packet.cmd.toInt() and 0xFF, cmd.label))}}}}
步骤 3:订阅管道通知事件 + 删 mock
改 DeviceListViewModel文件。
init {// ① 订阅设备清单:DataStore 一变 → 这里就收到 → UI 自动刷新viewModelScope.launch {//开了一个后台监听任务repo.devices.collect { list -> //collect 就是专门用来订阅 / 消费 Flow 的终端操作函数,必须和 Flow 配套使用。_uiState.update { state ->//更新设备信息,UI 会自动跟着刷新val ids = list.map { it.devId }.toSet()state.copy(devices = list,//devicesStates = mockStatesOf(list)//这里是模拟设备状态信息,后续补充devicesStates = state.devicesStates.filterKeys { it in ids })}}}// ② 订阅设备事件(Netty 线程抛过来的)viewModelScope.launch {// 在这个容器里开一个后台任务DeviceSessionManager.events.collect { event ->when (event) {is DeviceEvent.StateUpdated -> {_uiState.update { ui ->ui.copy(devicesStates = ui.devicesStates + (event.state.devId to event.state)//在现有状态表上,加上或覆盖 设备号 → 这台设备的最新状态 这一条)}}is DeviceEvent.Offline -> {_uiState.update { ui ->ui.copy(devicesStates = ui.devicesStates - event.devId)//删除掉线的仪器编号 val m = mapOf("A" to 1) 则m - "A" // → {} 删除(key 不存在也不报错)}}is DeviceEvent.Online -> { /* 暂不处理,只打日志即可 */ }}}}// ② 首次启动播种(已有数据则什么都不做)viewModelScope.launch { repo.loadOrSeed() }}
删掉这两个函数(mock 退役):
// ===== 删除 =====private funmockStatesOf(devices: List<Device>): Map<String, DeviceState> = ...private funmockStateOf(devId: String): DeviceState = ...
步骤 4:发送用户指令编码到管道
改 DeviceListViewModel文件。 放在 toggleEnabled 后面:
/*** 恢复出厂设置(0xC3)** UI 抛意图 → PacketBuilder 造帧 → DeviceSessionManager 发出去* send 返回 false = 这台设备不在线,指令没发出去*/funresetFactory(devId: String) {val packet = PacketBuilder.WriteFactorySetCmd(devId)val ok = DeviceSessionManager.send(devId, packet)_uiState.update {it.copy(errorMessage = if (ok) null else "{PacketAnalysis.ReadStateCmd(d)}")
步骤5:UI界面的联动修改
DeviceListScreen.kt 里 DeviceCard 函数进行修改:
@Composableprivate funDeviceCard(device: Device,state: DeviceState?,onToggle: () -> Unit,onReset: () -> Unit // ← ① 新增参数,用于恢复出厂设置) {val online = state != null // ← ② 新增:在线判据,后续不用device.onLineCard(modifier = Modifier.fillMaxWidth().alpha(if (device.enable) 1f else 0.4f), // 禁用整卡半透明colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {Row(modifier = Modifier.fillMaxWidth().padding(16.dp),verticalAlignment = Alignment.CenterVertically) {// ---- 左:状态圆点 ----Box(modifier = Modifier.size(10.dp).background(color = if (online) Successelse Warning,shape = CircleShape))Spacer(modifier = Modifier.width(12.dp))// ---- 中:编号 + 状态信息(占满中间剩余空间) ----Column(modifier = Modifier.weight(1f)) {Row(verticalAlignment = Alignment.CenterVertically) {Text(text = device.devId,style = MaterialTheme.typography.bodyLarge,color = MaterialTheme.colorScheme.onSurface)if (state?.alarm?.hasAlarm == true) { // 有故障 → 红字Spacer(modifier = Modifier.width(8.dp))Text(text = "⚠ 报警" +state.alarm.label,style = MaterialTheme.typography.bodySmall,color = Error)}}Text(text = if (online) "在线" else "离线",style = MaterialTheme.typography.bodySmall,color = MaterialTheme.colorScheme.onSurfaceVariant)// state 不为空才显示真空度+流程(通信没连上时显示"未知")state?.let {//**`.let { ... }`**:Kotlin 标准扩展函数Text(text = "%.1f kPa · %s".format(it.vacuumKpa, it.process.label),style = MaterialTheme.typography.bodySmall,color = MaterialTheme.colorScheme.onSurfaceVariant)}}TextButton( // ← ⑤ 新增按钮onClick = onReset,contentPadding = PaddingValues(horizontal = 0.dp)) {Text("恢复出厂设置", style = MaterialTheme.typography.labelSmall)}// ---- 右:启用开关 ----Switch(checked = device.enable,onCheckedChange = { onToggle() })}}}

把恢复出厂设置的按钮函数加上:
onReset ={ vm.resetFactory(device.devId)}1
步骤6:测试
大家如果用的我的指令,就还是用这个方式,去先生成指令码,再通过网络调试助手 发送,模拟设备,我这边后续因为可以和设备联调,所以就不 一一演示了:

// // ============ 测试 2:解析层 parseStateCmd ============// // 手工造一段符合协议 §1.4 的 22 字节数据段// val d = ByteArray(22)// "JSTZ0001".toByteArray().copyInto(d, 0) // ① 设备编号 8B// d[8] = 2 // ② 流程 = 抽真空// d[9] = 0b0000_0101 // ③ 阀1、阀3 开// d[10] = 30; d[11] = 0 // ④ 计时 30 秒(低字节在前)// val db = java.nio.ByteBuffer.wrap(d).order(java.nio.ByteOrder.LITTLE_ENDIAN)// db.putFloat(12, -85.2f) // ⑤ 真空度// db.putFloat(16, 1234.5f) // ⑥ 体积// d[20] = 0b0000_1011 // ⑦ 真空泵 + 排液泵 + 加热// d[21] = 3 // ⑧ 报警 = 集气故障//// android.util.Log.i(ProtocolConst.LOG_TAG, "state = ${PacketAnalysis.ReadStateCmd(d)}")//// // 顺手把这条数据段的真实帧打出来 → 复制到 NetAssist 直接发(HEX)// val realFrame = CommunicateHelper.encode(CmdCode.STATE.code, d)// android.util.Log.i(ProtocolConst.LOG_TAG, "frame = " +// realFrame.joinToString(" ") { "%02X".format(it.toInt() and 0xFF) })
唯一后续需要注意的是,如果你改了仪器编号,下面这个地方要跟着变:
因为我们现在还是用仪器编号作为唯一标识,后续我们数据包用的仪器编号和这里不一致,无法联动的。 然后如果改了仪器编号,下面这句话先屏蔽,避免上面的设置不生效:















































































