Flutter MQTT 通信文档

Flutter MQTT 通信文档(TLS/非 TLS 配置全解)

本文档记录 MQTT 通信层 的设计与关键实现。

提供 启用 TLS(推荐)不启用 TLS(仅测试) 两种配置方式,便于开发者在不同环境下使用。


1. 设计目标

  • 作为 远程通信通道,替代HTTP通过 MQTT 与云端服务器交互
  • 支持 请求-响应状态推送 两种模式
  • 可选择 启用 TLS (传输层加密 + 证书校验)或 不开启 TLS(内网测试)
  • 自动重连,保证长连接稳定性

2. 连接配置

2.1 启用 TLS(推荐)

dart 复制代码
final client = MqttServerClient('your.mqtt.host', 'rv_app_client');
client.port = 8883;                   // 典型 TLS 端口
client.secure = true;                 // 启用 TLS
client.securityContext = yourContext; // 指定证书上下文
client.keepAlivePeriod = 60;
client.autoReconnect = true;
client.resubscribeOnAutoReconnect = true;

2.2 不启用 TLS(仅内网/测试)

dart 复制代码
final client = MqttServerClient('192.168.1.100', 'rv_app_client');
client.port = 1883;          // 明文端口
client.secure = false;       // 不启用 TLS
client.keepAlivePeriod = 60;
client.autoReconnect = true;

⚠️ 警告 :明文模式下数据裸传,仅适合内网调试;公网环境必须使用 TLS。


3. 主题约定

复制代码
rv/{deviceId}/{subsystem}/{action}

示例:

  • 状态推送:rv/device123/appliance/status
  • 控制命令:rv/device123/light/control
  • 响应消息:rv/device123/battery/response

4. 请求-响应机制

  • 客户端下发请求时带 req_id
  • 服务端响应时回填相同 req_id
  • 客户端 pending 表完成匹配并支持超时清理

5. 状态推送

  • 服务端会主动推送设备状态(command_type=status_update
  • 客户端在回调中解析并分发到业务层(Provider/EventBus)

6. TLS 证书配置详解

6.1 准备证书

  • 公有 CA:直接信任系统根 → withTrustedRoots:true
  • 自签名 CA:需要在客户端内置 ca.pem 并加载 → withTrustedRoots:false
  • 双向认证:需额外准备 client.crtclient.key

6.2 pubspec.yaml

yaml 复制代码
flutter:
  assets:
    - assets/certs/ca.pem
    # - assets/certs/client.crt
    # - assets/certs/client.key

6.3 构建 SecurityContext

dart 复制代码
import 'dart:io';
import 'package:flutter/services.dart' show rootBundle;

Future<SecurityContext> buildSecurityContext({
  String caAsset = 'assets/certs/ca.pem',
  String? clientCertAsset,
  String? clientKeyAsset,
  String? clientKeyPassword,
  bool withTrustedRoots = false,
}) async {
  final sc = SecurityContext(withTrustedRoots: withTrustedRoots);
  final caBytes = await rootBundle.load(caAsset);
  sc.setTrustedCertificatesBytes(caBytes.buffer.asUint8List());
  if (clientCertAsset != null && clientKeyAsset != null) {
    final certBytes = await rootBundle.load(clientCertAsset);
    final keyBytes  = await rootBundle.load(clientKeyAsset);
    sc.useCertificateChainBytes(certBytes.buffer.asUint8List());
    sc.usePrivateKeyBytes(keyBytes.buffer.asUint8List(), password: clientKeyPassword);
  }
  return sc;
}

6.4 连接时启用证书

dart 复制代码
final sc = await buildSecurityContext(
  caAsset: 'assets/certs/ca.pem',
  withTrustedRoots: false, // 自签名 CA 必须 false;公有 CA 可 true
);

final client = MqttServerClient('your.mqtt.host', 'rv_app_client')
  ..port = 8883
  ..secure = true
  ..securityContext = sc;

6.5 开发模式跳过验证(⚠️仅调试)

dart 复制代码
client.onBadCertificate = (X509Certificate cert) {
  print('⚠️ 忽略证书校验: $cert');
  return true;
};

7. 自动重连策略

  • autoReconnect = true + resubscribeOnAutoReconnect = true
  • _onDisconnected 里增加指数退避 + 抖动的兜底重连

8. 调试与常见问题

  • 证书错误 → 检查证书链完整,自签名需内置 CA
  • Broken pipe → 调整 keepAlive(30--60s)
  • 无限重连 → 确认没有与 autoReconnect 互相冲突的手动循环
  • 消息收不到 → 确认订阅了正确主题(rv/{deviceId}/#

9. 与 TCP 的关系

  • MQTT:远程公网控制
  • TCP:本地热点直连(低延迟)
  • ConnectionManager 自动切换

10. 完整代码示例

下面给出一个支持 TLS / 非 TLS 的完整 MqttService 实现,省略 AES/HMAC。

dart 复制代码
// mqtt_service.dart
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'dart:io';
import 'package:flutter/services.dart' show rootBundle;
import 'package:mqtt_client/mqtt_client.dart';
import 'package:mqtt_client/mqtt_server_client.dart';

typedef OnNotification = void Function(Map<String, dynamic> msg);
typedef OnLog = void Function(String line);
typedef OnError = void Function(Object error, [StackTrace? st]);

Future<SecurityContext> buildSecurityContext({
  String caAsset = 'assets/certs/ca.pem',
  String? clientCertAsset,
  String? clientKeyAsset,
  String? clientKeyPassword,
  bool withTrustedRoots = false,
}) async {
  final sc = SecurityContext(withTrustedRoots: withTrustedRoots);
  final caBytes = await rootBundle.load(caAsset);
  sc.setTrustedCertificatesBytes(caBytes.buffer.asUint8List());
  if (clientCertAsset != null && clientKeyAsset != null) {
    final certBytes = await rootBundle.load(clientCertAsset);
    final keyBytes  = await rootBundle.load(clientKeyAsset);
    sc.useCertificateChainBytes(certBytes.buffer.asUint8List());
    sc.usePrivateKeyBytes(keyBytes.buffer.asUint8List(), password: clientKeyPassword);
  }
  return sc;
}

class MqttService {
  final String host;
  final int port;
  final String clientId;
  final String? username;
  final String? password;
  final SecurityContext? securityContext; // 可为 null,表示不启用 TLS

  final Set<String> _subscriptions = {};
  final Map<String, Completer<Map<String, dynamic>>> _pending = {};

  final OnNotification? onNotification;
  final OnLog? log;
  final OnError? onError;

  int keepAliveSeconds;
  final Duration minBackoff;
  final Duration maxBackoff;

  late final MqttServerClient _client;
  Timer? _manualReconnectTimer;
  int _reconnectAttempt = 0;
  final Random _rnd = Random();

  bool get isConnected =>
      _client.connectionStatus?.state == MqttConnectionState.connected;

  MqttService({
    required this.host,
    this.port = 1883, // 默认非 TLS
    required this.clientId,
    this.username,
    this.password,
    this.keepAliveSeconds = 60,
    this.onNotification,
    this.log,
    this.onError,
    this.minBackoff = const Duration(seconds: 1),
    this.maxBackoff = const Duration(seconds: 30),
    this.securityContext, // 传 null = 不启用 TLS
  }) {
    _client = MqttServerClient(host, clientId)
      ..port = port
      ..keepAlivePeriod = keepAliveSeconds
      ..autoReconnect = true
      ..resubscribeOnAutoReconnect = true
      ..secure = securityContext != null
      ..securityContext = securityContext
      ..onConnected = _onConnected
      ..onDisconnected = _onDisconnected
      ..onAutoReconnect = _onAutoReconnect
      ..onAutoReconnected = _onAutoReconnected
      ..logging(on: false);

    _client.connectionMessage = MqttConnectMessage()
        .withClientIdentifier(clientId)
        .startClean()
        .keepAliveFor(keepAliveSeconds);

    if (username != null || password != null) {
      _client.connectionMessage =
          _client.connectionMessage!.authenticateAs(username ?? '', password ?? '');
    }
  }

  Future<void> connect() async {
    if (isConnected) return;
    try {
      _info('🔌 [MQTT] connecting to $host:$port (clientId=$clientId)');
      await _client.connect();
      _client.updates!.listen(_onMessage, onError: (e, st) {
        _error('❌ [MQTT] stream error: $e', e, st);
      }, onDone: () {
        _warn('⚠️ [MQTT] update stream done');
      });
    } catch (e, st) {
      _error('🚫 [MQTT] connect failed: $e', e, st);
      _scheduleManualReconnect();
      rethrow;
    }
  }

  Future<void> subscribe(String topic, {MqttQos qos = MqttQos.atLeastOnce}) async {
    if (!_subscriptions.contains(topic)) {
      _subscriptions.add(topic);
    }
    if (!isConnected) return;
    _info('🧭 [MQTT] subscribe $topic, qos=$qos');
    _client.subscribe(topic, qos);
  }

  Future<void> publishJson(
    String topic,
    Map<String, dynamic> payload, {
    MqttQos qos = MqttQos.atLeastOnce,
    bool retain = false,
  }) async {
    final jsonStr = jsonEncode(payload);
    final builder = MqttClientPayloadBuilder();
    builder.addString(jsonStr);
    _client.publishMessage(topic, qos, builder.payload!, retain: retain);
    _debug('📤 [MQTT] publish $topic: $jsonStr');
  }

  Future<Map<String, dynamic>> sendRequest({
    required String reqTopic,
    required String respTopic,
    required Map<String, dynamic> payload,
    Duration timeout = const Duration(seconds: 5),
    MqttQos qos = MqttQos.atLeastOnce,
  }) async {
    await subscribe(respTopic, qos: qos);
    final String reqId = (payload['req_id'] as String?) ?? _genReqId();
    payload['req_id'] = reqId;

    final completer = Completer<Map<String, dynamic>>();
    _pending[reqId] = completer;

    await publishJson(reqTopic, payload, qos: qos, retain: false);

    try {
      final rsp = await completer.future.timeout(timeout, onTimeout: () {
        _pending.remove(reqId);
        throw TimeoutException('MQTT request timeout: $reqId');
      });
      return rsp;
    } catch (e) {
      rethrow;
    }
  }

  void _onMessage(List<MqttReceivedMessage<MqttMessage>> events) {
    for (final e in events) {
      final topic = e.topic;
      final MqttPublishMessage msg = e.payload as MqttPublishMessage;
      final payload =
          MqttPublishPayload.bytesToStringAsString(msg.payload.message);
      _debug('📥 [MQTT] recv $topic: $payload');
      Map<String, dynamic> data;
      try {
        data = jsonDecode(payload) as Map<String, dynamic>;
      } catch (e, st) {
        _error('⚠️ [MQTT] json decode failed: $e', e, st);
        continue;
      }
      final reqId = data['req_id'] as String?;
      if (reqId != null && _pending.containsKey(reqId)) {
        final c = _pending.remove(reqId)!;
        if (!c.isCompleted) c.complete(data);
        continue;
      }
      try {
        onNotification?.call(data);
      } catch (e, st) {
        _error('⚠️ [MQTT] onNotification error: $e', e, st);
      }
    }
  }

  void _onConnected() {
    _info('✅ [MQTT] connected');
    _reconnectAttempt = 0;
    _manualReconnectTimer?.cancel();
    _manualReconnectTimer = null;
    for (final t in _subscriptions) {
      _client.subscribe(t, MqttQos.atLeastOnce);
    }
  }

  void _onDisconnected() {
    _warn('⚠️ [MQTT] disconnected');
    _scheduleManualReconnect();
  }

  void _onAutoReconnect() {
    _warn('⏳ [MQTT] auto reconnecting...');
  }

  void _onAutoReconnected() {
    _info('🔁 [MQTT] auto reconnected');
  }

  void _scheduleManualReconnect() {
    if (isConnected) return;
    _manualReconnectTimer?.cancel();
    final base = minBackoff.inMilliseconds * pow(2, _reconnectAttempt).toInt();
    final capped = base.clamp(minBackoff.inMilliseconds, maxBackoff.inMilliseconds);
    final jitter = (capped * (0.2 * (_rnd.nextDouble() * 2 - 1))).round();
    final delay = Duration(milliseconds: max(0, capped + jitter));
    _reconnectAttempt = min(_reconnectAttempt + 1, 10);
    _warn('🕰️ [MQTT] schedule reconnect in ${delay.inMilliseconds} ms');
    _manualReconnectTimer = Timer(delay, () async {
      try {
        await connect();
      } catch (_) {
        _scheduleManualReconnect();
      }
    });
  }

  Future<void> dispose() async {
    _info('🧹 [MQTT] dispose');
    _manualReconnectTimer?.cancel();
    _manualReconnectTimer = null;
    for (final c in _pending.values) {
      if (!c.isCompleted) c.completeError(StateError('MQTT disposed'));
    }
    _pending.clear();
    if (isConnected) {
      await _client.disconnect();
    } else {
      _client.doAutoReconnect(force: false);
    }
  }

  String _genReqId() {
    final v = List<int>.generate(8, (_) => _rnd.nextInt(256));
    return v.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
  }

  void _info(String s) => log?.call(s);
  void _warn(String s) => log?.call(s);
  void _debug(String s) => log?.call(s);
  void _error(String msg, Object e, [StackTrace? st]) {
    log?.call(msg);
    onError?.call(e, st);
  }
}

使用示例

启用 TLS(生产推荐)
dart 复制代码
final sc = await buildSecurityContext(
  caAsset: 'assets/certs/ca.pem',
  withTrustedRoots: false,
);

final mqtt = MqttService(
  host: 'your.mqtt.host',
  port: 8883,
  clientId: 'rv_app_client_001',
  username: 'user',
  password: 'pass',
  securityContext: sc,
  onNotification: (msg) => print('NOTIFY: $msg'),
  log: print,
);
await mqtt.connect();
不启用 TLS(仅内网测试)
dart 复制代码
final mqtt = MqttService(
  host: '192.168.1.100',
  port: 1883,
  clientId: 'rv_app_client_001',
  username: 'user',
  password: 'pass',
  securityContext: null, // ⭐ null 表示不开启 TLS
  onNotification: (msg) => print('NOTIFY: $msg'),
  log: print,
);
await mqtt.connect();

11. req_id 设计详解

req_id 用于将一次请求与其响应一一对应 ,也是跨日志、跨通道(MQTT/TCP)排障的核心线索。好的 req_id 设计需要兼顾唯一性、可读性、跨端一致性实现成本

11.1 设计目标

  • 全局唯一(至少在合理时间窗口内不碰撞)
  • 易于检索(日志里一眼能搜到)
  • 跨通道复用 (同一业务请求在 MQTT/TCP 间切换仍沿用同一 req_id
  • 无机密信息(不要把用户/密钥放进去)
  • 长度适中(建议 < 64 字节,字符集友好)

11.2 推荐格式

xml 复制代码
<origin>-<ts36>-<seq36>-<randHex>
  • origin:来源/通道(如 mqtt / tcp / app),便于排查
  • ts36DateTime.now().millisecondsSinceEpoch36 进制(更短)
  • seq36:进程内自增序列的 36 进制(防同毫秒并发冲突)
  • randHex:若干字节的随机数(16 进制),增强唯一性

示例:

复制代码
mqtt-l9h3v7-00k-7f9a21c45b

11.3 生成策略(Dart 实现)

移动端/Flutter 推荐使用 Random.secure()(如可用)或 Random() 退化。

dart 复制代码
class ReqIdGenerator {
  final String origin;
  int _seq = 0;
  final Random _rnd;

  ReqIdGenerator(this.origin) : _rnd = _secureRandom();

  static Random _secureRandom() {
    try {
      return Random.secure();
    } catch (_) {
      return Random(); // Web/少数平台回退
    }
  }

  String next() {
    final ts36 = DateTime.now().millisecondsSinceEpoch.toRadixString(36);
    final seq36 = (_seq++ & 0x0FFF).toRadixString(36).padLeft(3, '0'); // 0..4095
    final rand = List<int>.generate(5, (_) => _rnd.nextInt(256)) // 5B => 10 hex
        .map((b) => b.toRadixString(16).padLeft(2, '0'))
        .join();
    return '$origin-$ts36-$seq36-$rand';
  }
}

为什么足够稳:

  • 毫秒时间戳 + 4096 级并发序列,能覆盖同毫秒并发请求
  • 额外 5 字节随机数进一步降低碰撞概率
  • 生成成本极低,且字符串对日志友好

11.4 在 MqttService 中使用

将原来的 _genReqId() 替换为注入式生成器:

dart 复制代码
class MqttService {
  // ...
  final ReqIdGenerator _req = ReqIdGenerator('mqtt'); // or include deviceId suffix
  // ...

  Future<Map<String, dynamic>> sendRequest({ /* ... */ }) async {
    await subscribe(respTopic, qos: qos);
    final String reqId = (payload['req_id'] as String?) ?? _req.next(); // ✅
    payload['req_id'] = reqId;
    // 其余逻辑不变
    // ...
  }
}

如果项目同时有 TCP 通道,建议让上层(如 ConnectionManager统一生成 req_id,并传给 MQTT/TCP,以便跨通道故障排查。

11.5 生命周期与清理

  • 客户端发出请求后放入 _pending[req_id] = Completer()
  • 超时(如 5s)
    • _pending 移除,返回超时错误,避免内存泄漏
    • 不要复用超时的 req_id(至少在 60s 内)
  • 响应到达
    • 命中 _pendingcomplete(),并移除条目

11.6 幂等性与重试(服务端配合)

  • 服务端应当以 req_id 为幂等键:
    • 重复的 req_id 视为同一次请求,直接返回同一响应
    • 对于"可能重复发送"的客户端,避免副作用被执行多次
  • 客户端重试策略:
    • 保留同一 req_id 发起重试,或在明确失败 后用新 req_id 重发(根据业务)

11.7 日志与可观测性

  • 客户端/服务端日志统一打印:[MQTT] req_id=... action=...
  • 链路跟踪:将 req_id 写入所有关键日志与告警
  • 指标:统计超时率、重试次数、平均 RTT(按 req_id 聚合)

11.8 与安全的关系

  • req_id 不包含任何敏感信息(如用户、密钥、token)
  • 不依赖时间同步(仅作为组成部分)
  • 若有安全审计要求,可在 req_id 加一个 来源前缀 (如 app, gw, cloud)帮助定位来源

12. 总结

  • 启用 TLS:推荐生产环境;数据加密,证书校验;支持自签名和双向认证。
  • 不启用 TLS:仅限内网/测试,数据裸传,不安全。
  • req_id 保证请求响应对应;自动重连增强稳定性。
相关推荐
程序员Ctrl喵16 小时前
异步编程:Event Loop 与 Isolate 的深层博弈
开发语言·flutter
前端不太难18 小时前
Flutter 如何设计可长期维护的模块边界?
flutter
小蜜蜂嗡嗡19 小时前
flutter列表中实现置顶动画
flutter
始持19 小时前
第十二讲 风格与主题统一
前端·flutter
始持19 小时前
第十一讲 界面导航与路由管理
flutter·vibecoding
始持19 小时前
第十三讲 异步操作与异步构建
前端·flutter
新镜20 小时前
【Flutter】 视频视频源横向、竖向问题
flutter
黄林晴20 小时前
Compose Multiplatform 1.10 发布:统一 Preview、Navigation 3、Hot Reload 三箭齐发
android·flutter
Swift社区21 小时前
Flutter 应该按功能拆,还是按技术层拆?
flutter
肠胃炎21 小时前
树形选择器组件封装
前端·flutter