1.9. 开发项目:蓝牙单片机控制器
1.9.1. 项目简介
蓝牙单片机控制器(BLE MCU Controller) 是一款 Android 蓝牙(BLE) 应用, 通过低功耗蓝牙 GATT 与单片机透传模块(HM-10 / JDY-08 / nRF52 / ESP32-BLE 等)通信, 实现「扫描 → 连接 → 发送指令 → 接收回传」的完整流程。
应用支持**文本 / HEX** 两种输入方式,并内置带 CRC 校验的协议帧模式: 发送时把指令打包成帧,接收时自动解包、丢弃坏帧。 其中 CRC 与帧打包由 NDK 原生库(C 实现) 提供,原生不可用时自动回退到纯 Dart 实现。
项目属性 |
说明 |
|---|---|
项目名称 |
bt_mcu_controller |
项目定位 |
Android BLE 串口助手:与单片机透传模块通信 |
技术栈 |
Flutter 3.44.7 + Dart 3.12.2 + Kotlin + C(NDK)+ CMake |
目标平台 |
Android 9.0(API 28)及以上,BLE 4.0+ |
许可证 |
GPL-3.0-or-later |
代码规模 |
Dart 约 917 行(3 文件)+ 测试约 974 行 + C 原生 118 行 |
警告
本应用走 BLE GATT 透传。若你的模块是 HC-05 / HC-06 这类**经典蓝牙串口(SPP)**,
BLE 方案不适用,需改用 flutter_bluetooth_serial 插件(仅 Android)。
1.9.2. 技术栈与版本
项 |
版本 |
说明 |
|---|---|---|
Flutter |
3.44.7 stable(Dart 3.12.2) |
|
Android Gradle Plugin |
9.0.1 |
|
Gradle |
9.1.0 |
wrapper |
JDK |
17 |
Gradle 9 必须 JDK 17+ |
compileSdk / minSdk / targetSdk |
36 / 28 / 36 |
minSdk 28 = Android 9 |
NDK / CMake |
28.2.13676358 / 3.22.1 |
编译 |
flutter_blue_plus |
2.3.11 |
BLE 通信 |
permission_handler |
11.4.0 |
动态权限(锁定原因见下文) |
1.9.3. 系统架构
应用采用 Dart → MethodChannel → Kotlin/JNI → C(NDK) 四层架构:
┌─────────────────────────────────────────────────────┐
│ Dart 层 │
│ lib/main.dart UI + 状态机(扫描/连接/收发/日志)│
│ lib/mcu_frame.dart ★ 协议帧「纯 Dart 参考实现」 │
│ lib/mcu_native.dart MethodChannel 桥接(带回退) │
└───────────────┬─────────────────────────────────────┘
│ MethodChannel "com.hjr.bt_mcu_controller/mcu_native"
│ (crc16 / encodeFrame 两个方法)
┌───────────────▼─────────────────────────────────────┐
│ Kotlin 层 │
│ MainActivity.kt 注册 MethodChannel,参数类型转换 │
│ McuNative.kt JNI 入口,System.loadLibrary │
└───────────────┬─────────────────────────────────────┘
│ JNI(符号 Java_com_hjr_bt_1mcu_1controller_*)
┌───────────────▼─────────────────────────────────────┐
│ C / NDK 层 │
│ mcu_native.c CRC-16-CCITT + 帧编码(cmake) │
└─────────────────────────────────────────────────────┘
小技巧
架构的核心思想是 「纯 Dart 规范 + 原生加速 + 自动降级」:
lib/mcu_frame.dart 是协议的规范实现,NDK C 实现与其**逐字节比对**,
真机集成测试保证二者一致;原生不可用时静默回退到 Dart,功能不中断。
1.9.4. 通信协议定义
1.9.4.1. 帧格式
[0xAA] [0x55] [payload_len] [payload...] [crc_hi] [crc_lo] [0x0D] [0x0A]
字段 |
长度 |
说明 |
|---|---|---|
帧头 |
2 |
固定 |
长度 |
1 |
负载字节数,>255 时截断为低 8 位(协议限制) |
负载 |
|
实际指令数据 |
CRC |
2 |
CRC-16-CCITT,高字节在前 |
帧尾 |
2 |
固定 |
帧相对负载的附加字节数 = 头2 + 长度1 + CRC2 + 尾2 = 7,即 kFrameOverhead。
1.9.4.2. CRC-16-CCITT
多项式
0x1021,初值0xFFFF,按字节高位在前逐位运算,无结果异或;标准测试向量:ASCII
"123456789"→0x29B1;两个实现都必须通过。
1.9.4.3. 发送样例
文本 A 对应字节 0x41,打包后:
AA 55 01 41 B9 15 0D 0A
1.9.5. 核心模块详解
1.9.5.1. lib/mcu_frame.dart — 协议帧纯 Dart 参考实现
API |
作用 |
|---|---|
|
CRC-16-CCITT |
|
负载 → 完整帧 |
|
从帧头开始解析一帧,返回 |
|
是否以 |
|
流式拼帧器,处理四种情况 |
FrameReceiver.add(chunk) 处理四种情况:
拆包:一帧分多段到达 → 缓冲累积,凑齐后再出帧;
粘包:一段含多帧 → 顺序出多帧;
坏帧:CRC/帧尾不符 → 丢 1 字节重同步(不会永久卡死);
杂字节:帧头前的垃圾 → 跳过;末尾孤立
0xAA→ 保留等下一段补全帧头。
1.9.5.2. lib/mcu_native.dart — MethodChannel 桥接
channel 名:
com.hjr.bt_mcu_controller/mcu_native;crc16/encodeFrame:调用原生,抛异常或返回null时自动回退到 Dart;isNativeAvailable():空负载探测原生是否真的加载。
重要
字节契约:负载必须用 Uint8List 发送。标准编解码器把 Uint8List 编码为
byte[],即 Kotlin 侧 call.argument<ByteArray>,而普通 List<int> 会编码为
List<Integer>,原生侧强转 ByteArray 会抛 ClassCastException,
导致静默回退到 Dart —— 这是本项目最重要的一个坑,详见「开发记录」。
1.9.5.3. NDK 原生层(C 实现)
android/app/src/main/cpp/mcu_native.c:crc16_ccitt()+encode_frame();JNI 导出符号对包名中的
_做了_1转义:Java_com_hjr_bt_1mcu_1controller_McuNative_{crc16,encodeFrame};McuNative.kt:object,init { System.loadLibrary("mcu_native") }。
1.9.5.4. lib/main.dart — 界面与状态机
状态:
_scanning/_connected/_selectedId/_hexMode/_frameMode, 纯setState手写状态机;权限:
bluetoothScan+bluetoothConnect+location,因 Android 6~11 扫描 BLE 需要定位权限;连接:
connect(license: License.nonprofit)→discoverServices()→ 按 UUID 找服务/特征 → 使能 notify 并监听onValueReceived;发送:协议帧模式先经原生打包,
withoutResponse: true写入;日志:每条含时间戳、方向、ASCII 与 HEX 双视图,最多 500 条,自动滚底;
设置:右上角 ⚙ 可改服务/特征 UUID(默认 FFE0 / FFE1)。
1.9.6. 测试策略
test/
├── mcu_frame_test.dart # 协议帧/CRC 的「规范」测试(24 个)
├── mcu_native_bridge_test.dart # MethodChannel 桥接契约 + 回退(9 个)
├── widget_test.dart # parseHex 边界(6 个)
├── ui_test.dart # 界面 widget 测试(6 个,mock BLE 平台)
└── mock_blue_plus.dart # 测试替身库(FakeBluePlusPlatform)
integration_test/
└── mcu_native_integration_test.dart # 真机比对:C 实现 vs Dart 参考
单元测试:CRC 标准向量、帧编解码、
FrameReceiver拆包/粘包/坏帧重同步/杂字节;桥接契约测试:验证 Dart→原生的方法名、参数 key、字节类型必须是 ``Uint8List``, 以及原生抛异常/返回 null/无 handler 三种情况下的 Dart 回退;
界面 widget 测试:通过替换
FlutterBluePlusPlatform.instance为FakeBluePlusPlatform,纯测试环境走通「扫描→连接→文本发送→接收→断开」全流程;真机集成测试:断言
isNativeAvailable() == true,对 8 组负载 (空、单字节、边界值、标准向量、满 255、超 255、帧定界符、中文 UTF-8) 比对 C 与 Dart 输出**逐字节一致**。
1.9.7. 开发记录(关键踩坑)
1.9.7.1. 字节契约 Bug(Dart → Kotlin)
现象:真机上 encodeFrame 静默回退到 Dart,原生路径从未生效。
根因:Dart 侧发送的是普通 List<int>,标准编解码器编码成 List<Integer>,
Kotlin call.argument<ByteArray> 强转抛 ClassCastException,被桥接的 try/catch 吞掉后回退。
修复:一律发送 Uint8List.fromList(...);测试里用 isA<Uint8List>() 锁定该契约。
1.9.7.2. FRAME_OVERHEAD 越界 Bug(C)
现象:原生返回的帧缺最后一个 0x0A,且写帧时越界 1 字节(堆溢出)。
根因:mcu_native.c 里 FRAME_OVERHEAD 定义为 6,但完整帧是 len + 7 字节
(注释自己算出 2+1+2+2=7 却写成 6),写入索引 len+6 造成**堆越界写 1 字节**。
修复:改为 7,与 Dart 侧 kFrameOverhead = 7 对齐。
此 bug 编译期不报错、单测 mock 不到,正是真机集成测试要抓的那类问题。
1.9.7.3. MethodChannel <Uint8List> 强转
invokeMethod<Uint8List> 内部做 decodeEnvelope as Uint8List?,mock 若返回普通
List<int> 会抛 _TypeError;mock 必须返回 ``Uint8List.fromList(...)``。
1.9.7.4. UI 测试的坑
DropdownButtonFormField关闭时不渲染设备项:测试以FlutterBluePlus.lastScanResults作为「设备已出现」的同步信号;协议帧发送测试需切回**文本模式**:HEX 模式下输入
A会被判为奇数长度非法输入,encodeFrame从未被调用;FlutterBluePlus静态状态跨测试残留:用共享 fake + 独立 remoteId + 扫描重置。
1.9.7.5. 版本锁定
permission_handler锁顶层 11.x(^11.3.1→ 11.4.0;Android 插件permission_handler_android实际解析为 12.1.0):pubspec 注释认为 12.x 及以上 Android 插件要求compileSdk >= 37,与当前 Flutter/AGP 9.0.1(默认 compileSdk 36) 不兼容,故锁 11.x;flutter_blue_plus的connect()必须传license, 个人/教育用License.nonprofit免费。
1.9.8. 构建与使用
flutter pub get
flutter analyze # 静态检查,要求 0 问题
flutter test # 单元 + widget 测试,要求全绿
flutter build apk --debug # 验证 NDK cmake 编译并打包
flutter test integration_test/ # 真机比对(需设备)
产物:build/app/outputs/flutter-apk/app-debug.apk,内含
lib/arm64-v8a|armeabi-v7a|x86_64/libmcu_native.so。
1.9.8.1. 使用方法
安装 APK,首次启动授权蓝牙 / 定位权限;
点击「扫描」,选择单片机对应的设备,点击「连接」;
输入指令: - 文本模式:直接输入 ASCII 指令(如
A、B、Q); - HEX 模式:输入十六进制字节,如A1 B2 0C,允许空格/逗号分隔;单片机回传的数据实时显示在日志区(ASCII 与 HEX 双视图)。
1.9.8.2. 自定义服务 / 特征 UUID
默认值(HM-10 透传常用):服务 0000ffe0-...、写入/通知特征 0000ffe1-...。
点击右上角 ⚙ 设置 修改,修改后需重新连接生效。
1.9.9. 已知限制
经典蓝牙 SPP 不支持:HC-05 / HC-06 需改用
flutter_bluetooth_serial;集成测试需真机:
flutter test integration_test/依赖设备;帧长限制:负载 >255 时长度字段截断,业务侧应控制单帧 ≤255 字节。
1.9.10. 相关链接
仓库:
D:\HJR\FTools\bt_mcu_controller开发文档:
docs/开发文档.md,含架构、协议、测试策略与踩坑记录应用说明:
README.md