Android 中的 MQTT 协议:从设备状态推送到可靠消息处理

做一个智能家居 App:用户打开设备详情页,希望马上看到灯是开着还是关着;点下开关后,灯的状态又要及时更新。用 HTTP 定时查询可以实现,但页面上没有变化时,很多请求只是重复读取相同的数据。

MQTT 很适合这种持续接收小消息的场景。接入后,真正让人困惑的通常是另一组问题:消息为什么重复了?网络恢复后怎么收不到数据?手机息屏之后,为什么连接也跟着断了?

MQTT 通过 Broker 按主题转发消息,提供发布订阅、传输确认和会话机制;Android 应用还需要处理生命周期、断线恢复和业务确认,才能把消息可靠地用起来。

本文以 MQTT 3.1.1 为主要协议版本,Kotlin 接入固定使用 Eclipse Paho Java Client 1.2.5。Android 部分讨论普通应用,并标明相关系统版本限制。代码是可改造的教学片段,已对照官方 API,未连接真实 Broker 或在 Android 设备上运行验证;域名、凭据和设备编号需要替换。资料核对日期:2026 年 11 月 2 日。

1. 一条设备状态消息,怎样到达 Android 页面?

先确定参与者。灯、Android App、业务后端都可以是 MQTT 客户端。它们分别连接到消息代理 Broker,由 Broker 接收消息、匹配订阅并转发。
#mermaid-svg-vU6l717YS32MHkZn{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vU6l717YS32MHkZn .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vU6l717YS32MHkZn .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vU6l717YS32MHkZn .error-icon{fill:#552222;}#mermaid-svg-vU6l717YS32MHkZn .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vU6l717YS32MHkZn .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vU6l717YS32MHkZn .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vU6l717YS32MHkZn .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vU6l717YS32MHkZn .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vU6l717YS32MHkZn .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vU6l717YS32MHkZn .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vU6l717YS32MHkZn .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vU6l717YS32MHkZn .marker.cross{stroke:#333333;}#mermaid-svg-vU6l717YS32MHkZn svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vU6l717YS32MHkZn p{margin:0;}#mermaid-svg-vU6l717YS32MHkZn .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-vU6l717YS32MHkZn .cluster-label text{fill:#333;}#mermaid-svg-vU6l717YS32MHkZn .cluster-label span{color:#333;}#mermaid-svg-vU6l717YS32MHkZn .cluster-label span p{background-color:transparent;}#mermaid-svg-vU6l717YS32MHkZn .label text,#mermaid-svg-vU6l717YS32MHkZn span{fill:#333;color:#333;}#mermaid-svg-vU6l717YS32MHkZn .node rect,#mermaid-svg-vU6l717YS32MHkZn .node circle,#mermaid-svg-vU6l717YS32MHkZn .node ellipse,#mermaid-svg-vU6l717YS32MHkZn .node polygon,#mermaid-svg-vU6l717YS32MHkZn .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vU6l717YS32MHkZn .rough-node .label text,#mermaid-svg-vU6l717YS32MHkZn .node .label text,#mermaid-svg-vU6l717YS32MHkZn .image-shape .label,#mermaid-svg-vU6l717YS32MHkZn .icon-shape .label{text-anchor:middle;}#mermaid-svg-vU6l717YS32MHkZn .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-vU6l717YS32MHkZn .rough-node .label,#mermaid-svg-vU6l717YS32MHkZn .node .label,#mermaid-svg-vU6l717YS32MHkZn .image-shape .label,#mermaid-svg-vU6l717YS32MHkZn .icon-shape .label{text-align:center;}#mermaid-svg-vU6l717YS32MHkZn .node.clickable{cursor:pointer;}#mermaid-svg-vU6l717YS32MHkZn .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-vU6l717YS32MHkZn .arrowheadPath{fill:#333333;}#mermaid-svg-vU6l717YS32MHkZn .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-vU6l717YS32MHkZn .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-vU6l717YS32MHkZn .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vU6l717YS32MHkZn .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-vU6l717YS32MHkZn .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vU6l717YS32MHkZn .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-vU6l717YS32MHkZn .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-vU6l717YS32MHkZn .cluster text{fill:#333;}#mermaid-svg-vU6l717YS32MHkZn .cluster span{color:#333;}#mermaid-svg-vU6l717YS32MHkZn div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-vU6l717YS32MHkZn .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-vU6l717YS32MHkZn rect.text{fill:none;stroke-width:0;}#mermaid-svg-vU6l717YS32MHkZn .icon-shape,#mermaid-svg-vU6l717YS32MHkZn .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vU6l717YS32MHkZn .icon-shape p,#mermaid-svg-vU6l717YS32MHkZn .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-vU6l717YS32MHkZn .icon-shape .label rect,#mermaid-svg-vU6l717YS32MHkZn .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vU6l717YS32MHkZn .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-vU6l717YS32MHkZn .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-vU6l717YS32MHkZn :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 发布状态
订阅状态与发布指令
转发状态
转发指令
发布与订阅
转发业务消息
设备客户端
MQTT Broker
Android 客户端
业务后端客户端

App 的连接管理层创建 MQTT 客户端,Broker 持有订阅关系。设备状态变化时发布消息,Broker 将它交给匹配的订阅者;Android 收到回调后,由数据层更新状态,再交给页面展示。发布订阅模型及其传输前提见 OASIS MQTT 3.1.1 规范。

这条链路里,主题 Topic 是消息的路由名称,Payload 是消息内容。下面是一套示例约定:

主题 发布者 订阅者 内容用途
homes/h01/devices/lamp01/state 灯 App、后端 灯上报的实际状态
homes/h01/devices/lamp01/command App 或后端 灯 用户希望执行的操作
homes/h01/devices/lamp01/command-result 灯 App、后端 指令执行结果

主题层级由业务定义,并不对应服务器上的目录。MQTT 也不要求消息必须使用 JSON;下面为了便于观察,约定使用 UTF-8 JSON:

json 复制代码
{
  "deviceId": "lamp01",
  "power": "on",
  "version": 42
}

设备把这段内容发布到 state 主题后,Broker 根据主题转发,App 自己解析字段。version 是本例的业务状态版本,用于识别过期更新。

订阅时可以使用通配符。homes/h01/devices/+/state 匹配一个层级的设备编号,homes/h01/devices/# 匹配后续多个层级;# 必须单独占据最后一个层级。发布主题不能包含这两个通配符。大小写也会影响匹配,State 和 state 是不同主题。主题匹配规则

在 Android 上,MQTT 客户端通常通过 TCP 建连,也可以使用 TLS,或使用库支持的 WebSocket 传输。MQTT 定义消息交换规则,Socket 提供网络访问接口,WebSocket 可以承载 MQTT 报文;选择 MQTT 时,仍然要确认 Broker 和客户端支持同一种传输方式。

我的建议是:设备上报、控制和多端状态分发可以先考虑 MQTT;读取设备列表、历史记录、上传图片,继续使用适合这些操作的 HTTP API。后端仍需提供权威状态和补偿查询接口,方便 App 在重连后校准页面。

2. QoS 能保证到哪一步?

MQTT 的服务质量等级 Quality of Service,简称 QoS,规定消息在一段 MQTT 传输中的确认方式。

等级 传输语义 简化报文流程 选择示例
QoS 0 至多一次,允许丢失 PUBLISH 后续样本很快会覆盖的高频遥测
QoS 1 至少一次,可能重复 PUBLISH → PUBACK 可按业务标识去重的状态事件
QoS 2 恰好一次的协议交付 PUBLISH → PUBREC → PUBREL → PUBCOMP 确实需要该语义且接受额外交互的场景

表中的保证依赖会话和必要状态的正常维护。QoS 越高,确认交互和状态管理越多;移动网络上应根据业务容忍的丢失与重复来选择。HiveMQ 的 QoS 说明

还要把链路拆开看:发布端到 Broker,以及 Broker 到订阅端,是两段传输。订阅端实际收到的 QoS 受发布 QoS 与 Broker 授予的订阅 QoS 共同限制。例如设备使用 QoS 1 发布,App 请求 QoS 2 订阅,也不能把这条消息升级为端到端 QoS 2。

对控制功能,最容易误判的是"发送成功"。App 用 QoS 1 发布指令并收到 PUBACK,说明 Broker 已确认这一段传输;此时灯可能还离线,也可能收到指令后执行失败。

因此,业务消息需要自己的关联标识:

json 复制代码
{
  "commandId": "cmd-example-001",
  "deviceId": "lamp01",
  "action": "setPower",
  "power": "on"
}

App 发布指令后记录 commandId,设备执行后在 command-result 主题返回相同标识和执行结果。页面据此区分"已发送""执行成功"和"执行超时"。

我倾向于让多数可重试的业务指令采用 QoS 1,并在执行端做幂等处理。前提是业务允许重复投递,而且执行端确实能按 commandId 防止重复生效。涉及数据库变更时,要让去重记录与业务变更保持事务一致;否则"检查过了、还没写入就崩溃"仍然会留下重复执行的窗口。

QoS 2 也需要业务层的这些考虑。用户重复点击、App 重试时创建了新的消息、设备执行后尚未写入业务结果,都可能造成业务重复。MQTT Packet Identifier 和 DUP 标志分别服务于协议交换与重传识别,业务去重应使用自己的稳定标识。

3. 断线以后,状态和消息分别由谁保存?

打开设备页时,如果灯还没产生新变化,App 从哪里拿到初始状态?这正是保留消息 Retained Message 常见的用途。

设备在发布 state 时设置 retain=true,Broker 为这个主题保存最近一条保留消息;新订阅者订阅成功后,可以收到匹配主题的保留值。它适合表达"最近已知状态"。删除保留值时,可以对相同主题发布 Payload 为空且 retain=true 的消息。保留消息机制

页面仍然要显示状态的新旧程度。设备已经离线时,Broker 中的最后一个 on 只能说明最近一次上报为开灯。建议状态消息携带业务版本、更新时间,并单独维护在线状态。

临时指令要谨慎使用 Retain。假设"打开门锁"被保留,设备重新订阅时就可能再次收到旧指令。适合保留的"目标状态"和只能执行一次的"操作命令",需要在主题和业务语义上分清。

持久会话处理的是另一件事:客户端暂时离线后,Broker 是否继续保留它的订阅和相关待交付消息。

MQTT 3.1.1 设置 连接断开后的行为 客户端需要做什么
cleanSession=true 本次会话状态随断开而清理 每次连接成功后重新订阅
cleanSession=false 保留会话;可为已存在订阅保存离线期间匹配的 QoS 1、2 消息 用相同 ClientId 恢复,并检查会话是否存在

恢复还取决于 Broker 的队列限制、存储与运维策略。Retain 按主题保留最近值,持久会话按客户端保存会话状态;两者都不等同于可查询的历史消息库。持久会话与离线排队

ClientId 是会话身份。需要恢复会话时,它应在相同客户端身份范围内稳定保存,同时避免两个活跃客户端共用一个值。MQTT 3.1.1 规定,同一 ClientId 的新连接会使旧连接被断开;把所有手机都写成 android-client,很容易出现互相挤下线的现象。ClientId 规则

另外两个参数经常和"保活"混在一起:

机制 作用 Android 侧的边界
Keep Alive 在没有其他控制报文发送时,用 PINGREQ/PINGRESP 等机制检测连接是否仍可用 无法保证进程持续运行,也无法消除系统网络限制
遗嘱消息 Last Will 在 CONNECT 中预先登记,Broker 在符合条件的异常断开后发布 在线状态可能延迟变化,应配合最近上报时间判断

例如,设备为在线状态主题登记保留的 offline 遗嘱,连接后再发布保留的 online。主动正常断开时,设备可以先发布 offline,再发送 DISCONNECT。遗嘱和 Keep Alive 的具体触发条件见 MQTT 3.1.1 连接参数。

MQTT 5.0 将会话控制拆为 Clean Start 与 Session Expiry Interval,并增加 Message Expiry Interval 等能力。临时控制指令可以借助消息过期限制存活时间,接收端也应检查业务有效期。迁移时应按 MQTT 5.0 规范 重新核对语义,不能直接把 cleanSession 换个参数名。

4. 用 Kotlin 串起连接、订阅和发布

下面使用 Paho 的 MqttAsyncClient,直接展示 MQTT 客户端的职责。Paho Java Client 与 Android Service 封装是不同交付物;这里固定依赖版本,方便读者对照 Paho 项目说明。

模块的 build.gradle.kts 添加:

kotlin 复制代码
dependencies {
    implementation("org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5")
}

Gradle 从项目已配置的 Maven Central 获取依赖。本文使用的是 mqttv3 包,接下来会显式选择 MQTT 3.1.1。

AndroidManifest.xml 中声明网络权限:

xml 复制代码
<uses-permission android:name="android.permission.INTERNET" />

该声明放在 manifest 下、application 外。若连接管理还要观察网络切换,再按实际实现声明相应权限。

先创建客户端、安装回调,最后由连接管理层发起连接:

kotlin 复制代码
import org.eclipse.paho.client.mqttv3.*
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence

fun newLampClient(
    serverUri: String,
    clientId: String,
    onState: (ByteArray) -> Unit,
    onStatus: (String) -> Unit
): MqttAsyncClient {
    val client = MqttAsyncClient(serverUri, clientId, MemoryPersistence())

    client.setCallback(object : MqttCallbackExtended {
        override fun connectComplete(reconnect: Boolean, serverURI: String) {
            // 本例 cleanSession=true,每次连接成功都恢复订阅。
            try {
                client.subscribe(
                    "homes/h01/devices/lamp01/state",
                    1,
                    null,
                    object : IMqttActionListener {
                        override fun onSuccess(token: IMqttToken) {
                            val granted = token.grantedQos.firstOrNull()
                            onStatus(
                                if (granted != null && granted in 0..2) "已就绪:QoS=$granted"
                                else "订阅被拒绝"
                            )
                        }

                        override fun onFailure(token: IMqttToken?, error: Throwable?) {
                            onStatus("订阅失败:${error?.message}")
                        }
                    }
                )
            } catch (error: MqttException) {
                onStatus("提交订阅失败:${error.message}")
            }
        }

        override fun connectionLost(cause: Throwable?) {
            onStatus("连接断开:${cause?.message}")
        }

        override fun messageArrived(topic: String, message: MqttMessage) {
            if (topic == "homes/h01/devices/lamp01/state") {
                onState(message.payload.copyOf())
            }
        }

        override fun deliveryComplete(token: IMqttDeliveryToken?) = Unit
    })
    return client
}

fun connectLampClient(
    client: MqttAsyncClient,
    user: String,
    secret: CharArray,
    onFailure: (Throwable?) -> Unit
) {
    val options = MqttConnectOptions().apply {
        mqttVersion = MqttConnectOptions.MQTT_VERSION_3_1_1
        isCleanSession = true
        isAutomaticReconnect = true
        connectionTimeout = 10
        keepAliveInterval = 60
        userName = user
        password = secret
    }

    try {
        client.connect(options, null, object : IMqttActionListener {
            override fun onSuccess(token: IMqttToken?) = Unit
            override fun onFailure(token: IMqttToken?, error: Throwable?) {
                onFailure(error)
            }
        })
    } catch (error: MqttException) {
        onFailure(error)
    }
}

调用者提供如 ssl://mqtt.example.com:8883 的真实 TLS 地址、唯一 ClientId 和认证信息。newLampClient() 返回由连接管理层持有的客户端,connectLampClient() 发起异步连接;成功后进入 connectComplete(),再通过 SUBACK 确认订阅结果。example.com 是占位域名,端口以 Broker 配置为准。

这里把"连接成功"和"订阅就绪"分开了。连接成功后,ACL 仍可能拒绝订阅;即使 SUBACK 到达,Broker 授予的 QoS 也可能低于请求值。生产实现应将这些结果放入明确的状态机,认证失败和权限失败需要修正配置,不能只循环重试。

Paho 的自动重连针对已有连接丢失后的恢复。首次连接失败要由连接管理层安排后续尝试;网络切换时也要协调客户端已有的重连过程,避免多个组件同时调用 connect()。连接选项 API

onState 和 onStatus 可能从 Paho 工作线程调用,必须短小、线程安全,并避免抛出异常。用于页面状态时,可交给数据层更新 StateFlow;需要解析大消息或写数据库时,交给受控的工作队列。Paho 默认在 messageArrived() 正常返回后发送相应协议确认,因此"投递到内存队列后返回"仍可能在进程死亡时丢失尚未处理的业务数据。关键消息应设计持久化接收、手动确认或业务补偿。消息回调契约

发布操作同样要接收异步结果:

kotlin 复制代码
fun publishLampCommand(
    client: MqttAsyncClient,
    json: String,
    listener: IMqttActionListener
) {
    client.publish(
        "homes/h01/devices/lamp01/command",
        json.toByteArray(Charsets.UTF_8),
        1,
        false,
        null,
        listener
    )
}

调用者传入带稳定 commandId 的 JSON,通过 listener 接收这一段发布的结果,同时捕获调用时可能同步抛出的 MqttException。页面把成功回调映射为"已发送",继续等待设备执行结果。isConnected 只能提供瞬时状态,检查通过后网络仍可能马上断开。发布 API

教学示例使用 MemoryPersistence 和清理会话,方便展示前台设备页的状态同步。它没有跨进程保存未完成消息的能力。若业务要求崩溃后恢复,要结合持久会话、合适的文件持久化和业务 Outbox 重新设计,不能只修改一个布尔值。

连接所有者结束工作时,应先把状态切为"停止",拒绝新操作并取消自己的重试任务,再在工作线程等待正常断开完成,最后关闭客户端。Paho 1.2.5 的可改造清理片段如下:

kotlin 复制代码
// 在所有者的工作线程执行;先保证没有新的连接、发布和订阅操作。
// 不要在主线程或 MQTT 回调中等待断开。
try {
    if (client.isConnected) {
        client.disconnect(1_000L).waitForCompletion(3_000L)
    }
} finally {
    client.close(true)
}

所有者负责捕获清理异常并记录结果;close(true) 用于最终释放,强制关闭可能放弃未完成的传输。生产状态机还要协调"正在连接"和"自动重连"期间的停止,防止旧回调继续提交订阅。这里的超时只是教学取值,应由业务对关闭时限与未完成消息的要求决定。Paho 1.2.5 源码

5. 放进 Service,为什么还是会断?

Android 上的 MQTT 连接依赖应用进程和系统允许的网络访问。普通 Service 可以承载代码,但进程回收、Doze 和后台执行限制仍然存在。

Android 6.0 引入的 Doze 会限制空闲设备上的后台网络等活动。在这些条件下,缩短 Keep Alive 并不能保证心跳按时运行,频繁唤醒也会增加耗电。Android 官方建议,在条件允许时使用 FCM 共享连接来接收后台下行消息。Doze 与 App Standby

产品需求 建议安排
用户停留在设备页时持续更新状态 按页面或可见业务范围建立连接,离开范围后释放
多个前台页面共享同一连接 由应用级连接管理层统一持有,页面订阅状态
App 在后台接收面向用户的通知 根据设备和地区条件评估 FCM、厂商推送等能力
确有持续、用户可感知的设备交互 核对业务是否符合前台服务类型和启动条件
网络恢复后补交数据或重新同步 使用适合延迟执行的持久任务,并保证业务可重试

当应用以 Android 12/API 31 或以上为目标版本时,从后台启动前台服务受到限制,只有规定的例外情形允许启动。前台服务还受相应类型、权限及版本规则约束,不能把"需要长期收消息"直接当成通用豁免。前台服务后台启动限制

对于设备详情页,我建议由 ViewModel 或更高一层的数据组件持有连接所需的状态,通过 StateFlow 向 UI 暴露。Activity 重建时,按照选定的所有权范围复用连接;账号切换或退出登录时,停止旧连接、清除旧订阅对应的数据,再切换身份。

WorkManager 适合重试上传、补偿同步这类有结束条件的工作。把 Worker 写成无限接收 MQTT 消息的循环,会让连接需求与任务调度模型冲突。后台通知与前台实时交互应分别明确交付目标,再选实现方式。

6. 真正上线前,要观察哪些结果?

先验证最容易产生错觉的情况:连接恢复,但消息没有恢复。让 App 成功订阅后断网,再恢复网络,同时观察 Broker 的连接日志、App 的 connectComplete() 和 SUBACK。使用清理会话时,漏掉重新订阅就会出现"显示已连接,页面一直不变"。反复调用 connect() 无法修复已经丢失的订阅关系。

还需要把"重新订阅成功"和"数据追平"分开。在离线期间错过的消息,可能已经不在 Broker 的可恢复范围内。App 可以先恢复订阅,再获取带版本的权威快照,并按业务版本合并更新;具体顺序要结合后端的一致性设计,避免旧快照覆盖新消息。

下面是一组可以直接用于联调的观察项,预期结果需要按自己的产品约定细化:

操作 应观察到的结果
首次连接时使用错误凭据 明确显示认证失败,并停止无意义的密集重试
Broker 拒绝状态主题订阅 进入订阅失败状态,不把页面标为已就绪
同一个 commandId 重复投递 执行端只产生一次业务效果,可返回已有结果
开启 Retain 后重新进入页面 能显示最近已知状态,并能区分状态是否过期
使用重复 ClientId 启动第二个客户端 能识别连接被替换的问题,而不是持续盲目重连
Wi-Fi 切换移动网络、Broker 重启 连接和订阅能恢复,缺失数据能校准
息屏进入空闲状态后再回前台 页面反映实际连接状态,并触发必要的重新同步
退出账号后让旧回调延迟到达 旧连接的消息不会写入新账号的数据

这些是建议执行的验证场景,本文没有把它们作为已经通过的测试结果。

安全配置也会直接影响消息正确性。Broker 应根据认证身份限制可发布和订阅的主题;客户端提交的 userId、homeId 都不能直接成为授权依据。TLS 配置需要验证服务端证书和主机名,测试证书问题应修复信任链,保留正常校验。Paho 的原始 TLS Socket 配置应按所用版本核对,不要假定 App 的 HTTP 客户端设置会自动套用到它。

日志至少应记录连接阶段、重连次数、订阅结果、业务消息标识和执行结果超时。避免记录密码、令牌和完整敏感 Payload。收到频率很高的状态消息时,页面可以合并刷新;涉及每条都要处理的业务事件,则需要受控队列、积压监控和明确的溢出策略。

回到最开始的灯:App 先建立连接并确认订阅,收到最近已知状态;用户操作后发送带 commandId 的指令,等待设备执行结果;网络中断后恢复连接、恢复订阅,再校准页面数据。先把这条路径在前台跑清楚,再根据产品对后台通知的要求选择交付方式。

相关推荐
三雷科技1 小时前
Android 开发环境搭建指南
android
赵庆明老师1 小时前
Android Studio Google源代理配置
android·ide·android studio
事圆则缓2 小时前
Android 核心名词手册跟读版
android
snysy2 小时前
WordPress伪静态404故障排查笔记
android·笔记·wordpress
zhangphil2 小时前
海量小文件造成Android存储碎片化与I/O变慢
android
Kapaseker3 小时前
Kotlin 2.5 Value Class 终于能放多个属性了
android·kotlin
恋猫de小郭3 小时前
Flutter Android 性能修复:8 个大核的手机,Raster 居然只跑在 2 个核上?
android·flutter
传奇开心果编程16 小时前
【Jetpack Compose娓娓道来】第29课:状态容器与UI事件建模——把“散装状态”变成“可穷举的契约”
android·kotlin·移动开发·android jetpack·ui框架·开发范式·声明式ui
五彩小白17 小时前
喵呜打卡项目总结
android·团队开发