夜雨聆风学习资料网

ARTICLE · 1223661

保姆级学习开发安卓手机软件实战(十一)--通信管道的建立及界面的联动

保姆级学习开发安卓手机软件实战(十一)--通信管道的建立及界面的联动

就像上节说的,我们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 == true    funonlineIds(): 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                        }                        // 交给会话管理器:登记连接 + 抛事件给 UI                        DeviceSessionManager.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.onLine    Card(        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) Success                        else 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)}
csharp
  • 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) })

唯一后续需要注意的是,如果你改了仪器编号,下面这个地方要跟着变: 因为我们现在还是用仪器编号作为唯一标识,后续我们数据包用的仪器编号和这里不一致,无法联动的。 然后如果改了仪器编号,下面这句话先屏蔽,避免上面的设置不生效:

相关学习资料