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 保证请求响应对应;自动重连增强稳定性。
相关推荐
Dabei5 小时前
Flutter 中实现 TCP 通信
flutter
孤鸿玉5 小时前
ios flutter_echarts 不在当前屏幕 白屏修复
flutter
前端 贾公子7 小时前
《Vuejs设计与实现》第 16 章(解析器) 上
vue.js·flutter·ios
tangweiguo0305198716 小时前
Flutter 数据存储的四种核心方式 · 从 SharedPreferences 到 SQLite:Flutter 数据持久化终极整理
flutter
0wioiw016 小时前
Flutter基础(②④事件回调与交互处理)
flutter
肥肥呀呀呀17 小时前
flutter配置Android gradle kts 8.0 的打包名称
android·flutter
吴Wu涛涛涛涛涛Tao21 小时前
Flutter 实现「可拖拽评论面板 + 回复输入框 + @高亮」的完整方案
android·flutter·ios
星秋Eliot2 天前
Flutter多线程
flutter·async/await·isolate·flutter 多线程