
1. 为什么要在鸿蒙上直连 MySQL——方案选型背后的考量1.1 移动端直连数据库适用场景与边界先说一个很多人会问的问题移动端到底该不该直接连数据库常规的架构建议都是“客户端发 HTTP 请求到后端 API后端再访问数据库”但我们团队这次在鸿蒙项目的实际业务里确实遇到了被迫直连 MySQL 的场景——一个内部工具型 App需要在弱网环境下以极低延迟读取设备状态数据临时搭建的后端服务还没就绪而业务方要求先跑通移动端到数据库的完整链路。这种时候用 Flutter 生态里的 mysql_dart 库在鸿蒙设备上直连 MySQL就成了权衡后的最短路。但直连并不等于“乱连”。我画一条边界给各位参考如果你的应用是面向公网用户的或者数据库里有敏感隐私数据建议老老实实走后端代理如果场景是内网工具、车间设备、现场运维这类可控网络环境且对端到端延迟敏感那么移动端直连是具备合理性的。mysql_dart 这个库选择直连方案恰好能承担这个角色——它不依赖任何原生通道走的就是 TCP 3306 端口。核心原则直连数据库的方案只适用于受控网络环境。公网 App 千万不要把 MySQL 暴露给客户端这是底线。1.2 为什么是 mysql_dart 而不是 HTTP 中转或 ORM在调研阶段我们其实对比过三条路线。第一条是架设轻量 HTTP 网关请求走 JSON API 转发——这是最稳妥的做法但多一跳网络延迟而且服务端还得维护。第二条是用 objectbox、drift 这类本地数据库做缓存再把数据同步到服务端这种适合离线优先场景但我们当时需要的是实时读取服务端 MySQL 里的最新台账本地缓存帮不上忙。第三条就是今天的主角 mysql_dart。选它有三个具体理由。第一mysql_dart 纯 Dart 实现底层只依赖 dart:io 的 Socket、SecureSocket 和 DNS 相关能力这让它在鸿蒙 NEXT 这种非 Android、非 iOS 的第三方 Flutter 平台上有了被适配的基础——没有 C 原生代码要重编没有 JNI 要重绑。第二它实现了 MySQL 原生协议从握手认证到 COM_QUERY、COM_STMT_PREPARE 都覆盖了支持 SQL 预处理、事务、SSL 连接这些对生产级别交互来说不可或缺。第三包体积可控没有把整个 JDBC 或 ODBC 那种重型实现搬过来移动端集成成本低。当然mysql_dart 也有一些“原罪”需要正视它社区维护活跃度不算高对 MySQL 8.0 的 caching_sha2_password 认证插件支持是在后续版本才完善的所以选版本时不能闭眼装最新版要看 Release 记录里是否明确写了你所用 MySQL 版本的认证方式支持。我们这边数据库是 MySQL 8.0.28用的 mysql_dart 版本是 0.8.0实测握手没问题。1.3 鸿蒙 NEXT 对 Flutter 生态的兼容现状聊到鸿蒙适配得先摊开一个现实鸿蒙 NEXTHarmonyOS NEXT彻底移除了 Android 兼容层以前那种“APK 直接跑在鸿蒙上”的路子走不通了。Flutter 在鸿蒙上的支持主要来自 OpenHarmony 社区的移植版本——通过适配后的 Flutter SDK 打鸿蒙包再用 DevEco Studio 做应用打包和签名。这个链路最麻烦的地方在于三方 Flutter 插件是否能在鸿蒙上跑取决于它依赖的 dart:ui 和 dart:io 能力在鸿蒙运行时里是否被完整实现。mysql_dart 恰好落在“依赖 dart:io 较浅”的区间里。它主要用到 Socket.connect、SecureSocket.connect、InternetAddress.lookup以及基础的 Stream/Uint8List 字节操作。鸿蒙的开源 Flutter 移植分支已经把这些底层 API 映射到了鸿蒙的 socket 能力上所以 mysql_dart 理论上不需要改一行 Dart 代码就能跑。但“理论”和“实测”之间永远有距离后面我会把实际踩的坑一个个列出来。2. 适配前置条件——环境搭建与依赖引入2.1 Flutter 鸿蒙开发环境搭建要点要在一个新平台上跑 Flutter 应用环境永远是第一道坎。我们用的组合是DevEco Studio 5.0对应 API 12OpenHarmony 社区的 Flutter SDK基于 Flutter 3.22 分支移植鸿蒙真机或模拟器API 12 及以上项目构建工具 hvigor配合 DevEco Studio 的 Gradle 同步机制这里有个容易栽跟头的地方OpenHarmony 的 Flutter SDK 和官方 Flutter SDK 不能混用。如果你机器上原本装的是官方 Flutter执行 flutter doctor 时它会指向官方 SDK这时候打的鸿蒙包会在引擎初始化阶段直接崩溃。我们的做法是把鸿蒙版 Flutter SDK 单独解压到ohos_flutter_sdk目录然后用环境变量切换或者干脆在项目根目录写一个.metadata指定 SDK 路径避免团队里其他人拉到错误 SDK 编译出问题。在鸿蒙上跑 Flutter 应用还需要在ohos工程目录下做一层壳工程。流程大致是先用 Flutter 命令生成标准 Flutter 工程再用 DevEco Studio 打开生成的ohos文件夹补上应用包名、签名配置最后用 hvigor 打 HAP 包。整个过程不算复杂但每一步都要求版本对齐我建议把版本号钉死记录在 README 里否则一个月后你大概率会忘记是哪组版本能跑通的。2.2 引入 mysql_dart 依赖与版本选择在pubspec.yaml里加入 mysql_dart 依赖这一步很简单但版本选择有讲究。dependencies: flutter: sdk: flutter mysql_dart: ^0.8.0这里有三个注意点。第一mysql_dart 的 0.7.x 和 0.8.x 之间 API 有变动特别是 ResultSet 的行取值方式0.8.x 改成了result.rows[i].colByName(xxx)这种访问形式早期版本是result[i][xxx]升级后如果不改代码会直接抛 NoSuchMethodError。第二如果你连的 MySQL 是 8.0 以上版本务必确认所用版本支持 caching_sha2_password否则会卡在握手阶段报Auth plugin not supported。第三要注意 mysql_dart 传递依赖的crypto包版本鸿蒙 Flutter SDK 自带加密库实现版本冲突可能导致 SHA256 握手签名计算出错。依赖装完之后先别急着写业务代码。我会习惯性地跑一个最小连接测试——在鸿蒙设备上写一行代码连到开发机 MySQL能通再往深处做。这一步能节省大量排查时间因为如果你把业务代码和连接逻辑混在一起写出了问题压根不知道是网络不通、依赖冲突、还是业务代码的锅。2.3 鸿蒙网络权限与明文传输配置移动端直连数据库网络安全配置是绕不开的一环。鸿蒙应用默认不允许任意 socket 连接你必须在模块配置里显式声明网络权限。在 DevEco Studio 生成的module.json5里要加上{ module: { requestPermissions: [ { name: ohos.permission.INTERNET } ] } }这里有一个非常重要的细节鸿蒙对明文流量非 TLS 加密的 TCP/UDP有默认限制策略。mysql_dart 如果走明文 3306 端口默认策略下可能直接连接失败或者收到SocketException: Permission denied。解决方式有两个一是给 MySQL 配置 TLS 证书让 mysql_dart 启用加密连接这也是生产环境推荐的方案二是在鸿蒙工程的网络安全配置里放开明文流量仅限内网调试用绝不建议拿到生产环境。我的建议是开发联调阶段放开明文发布版本强制 TLS。放开明文的方法是在ohos工程里找到 src/main/ets/entryability 相关的网络配置文件或者直接查 DevEco Studio 中 Network Security Config 的设置项把cleartextTrafficPermitted置为 true。等调通了再切回 TLS 模式验证一遍确保加密链路没被权限策略拦死。3. mysql_dart 核心原理与鸿蒙适配改造3.1 mysql_dart 的工作机制从握手到查询想要在鸿蒙上把一个纯 Dart 的 MySQL 客户端用好最好先把它内部的工作机制摸清楚。mysql_dart 本质上是 MySQL 客户端协议的 Dart 实现整个交互过程分四个阶段。第一阶段是 TCP 连接建立。mysql_dart 通过Socket.connect(host, port)建立到 MySQL 服务器的 TCP 通道。第二阶段是握手认证。服务端先发送握手包包含协议版本、认证插件名和随机盐值客户端根据服务端指定的认证插件做响应老版本 MySQL 用mysql_native_password8.0 默认是caching_sha2_password。第三阶段是命令交互。认证通过后客户端发送COM_QUERY普通查询或COM_STMT_PREPARE预处理指令服务端返回结果集或影响行数。第四阶段是连接关闭或复用。这个机制里和鸿蒙适配关系最紧密的是前两个阶段。因为鸿蒙的 Socket 行为和 Android 有细微差异——比如 DNS 解析的时机、IPv6 回退策略、超时语义。mysql_dart 的ConnectionSettings里有一个resolveDns参数默认走的是InternetAddress.lookup。在鸿蒙上如果遇到解析失败可以手动换成InternetAddress(host, type: InternetAddressType.IPv4)绕过 DNS这个技巧后面排查部分会专门讲。3.2 鸿蒙下的 Socket 实现差异与适配点直接说结论mysql_dart 在鸿蒙 NEXT 上的主要适配点不在 Dart 业务代码而在运行时依赖的 dart:io Socket 实现是否完整。OpenHarmony 的 Flutter 移植引擎实现了大部分 dart:io Socket API但在几个细节上和标准实现有出入。第一个差异是Socket.connect的超时行为。标准 Dart 实现里Socket.connect(host, port, timeout: Duration(seconds: 5))超时后会抛出SocketException但鸿蒙移植引擎早期版本对 timeout 参数支持得不好超时事件不触发或者延迟很久才触发。这会导致连接 MySQL 时万一网络不通客户端会“卡死”很久。解决办法是在业务层包一层Future.timeout用自己的超时策略兜底。第二个差异是SecureSocket.connect的证书校验。鸿蒙内置的证书信任库和 Android 不完全一致如果你用自签名证书给 MySQL 开 TLS可能会遇到HandshakeException: CERTIFICATE_VERIFY_FAILED而且错误信息里不告诉你具体是哪个证书链断掉。这个问题的解法是给ConnectionSettings.useTLS配上自定义的sslOptions.certificates手动传入你信任的根证书内容绕过系统信任库的差异。第三个差异是流式读取的 chunk 大小。mysql_dart 用socket.listen接收数据包鸿蒙引擎的分片策略偶尔会把一个大包拆成多个小包到达。mysql_dart 内部自带协议帧缓冲按理说能处理这个问题但如果你的数据包特别大比如一次查询返回几万行还是有概率触发解析错位。我的经验是生产环境查询务必分页或者用游标式读取不要指望客户端一次吞下百万行结果。3.3 改造代码实战连接、查询、事务下面给出一套可以直接参考的最小可运行代码。先看连接与基础查询。import package:mysql_dart/mysql_dart.dart; FutureMySqlConnection createConnection() async { final settings ConnectionSettings( host: 10.10.10.5, // 内网 MySQL 地址 port: 3306, user: app_reader, password: your_password, db: iot_prod, // 生产环境务必启用 TLS useTLS: true, sslOptions: SslOptions( // 如果 MySQL 用的是受信任的 CA 签发证书可留空走系统链 // 如果用自签名在这里传入根证书内容 ), ); try { return await MySqlConnection.connect(settings).timeout( const Duration(seconds: 10), ); } on TimeoutException { throw 连接 MySQL 超时请检查网络或防火墙; } on MySqlException catch (e) { throw MySQL 连接失败: ${e.message}; } }连接拿到手之后就可以执行查询了。FutureListMapString, dynamic queryDeviceData( MySqlConnection conn, { required int siteId, int limit 100, }) async { final result await conn.query( SELECT id, device_name, temperature, updated_at FROM devices WHERE site_id ? ORDER BY updated_at DESC LIMIT ?, [siteId, limit], // 参数绑定杜绝 SQL 拼接 ); // 0.8.x 版本通过 rows 访问数据 return result.rows.map((row) { return { id: row.valByName(id), deviceName: row.valByName(device_name), temperature: row.valByName(temperature), updatedAt: row.valByName(updated_at), }; }).toList(); }注意这里用了参数化查询而非字符串拼接。mysql_dart 会把?占位符替换成转义后的参数值这个过程中对单引号、反斜杠、空字符做了安全处理能有效抵御常见 SQL 注入。我第一次用这个库的时候偷懒写过${siteId}直接拼 SQL结果被测试组一个1 OR 11参数打脸。从那以后凡是动态参数一律走预处理这是生产级代码的死规矩。事务操作在 mysql_dart 里有专门的 API 支持。Futurevoid updateDeviceAndLog( MySqlConnection conn, { required int deviceId, required String newName, }) async { await conn.startTransaction(); try { await conn.execute( UPDATE devices SET device_name ? WHERE id ?, [newName, deviceId], ); await conn.execute( INSERT INTO operate_log (device_id, action) VALUES (?, ?), [deviceId, rename_device], ); await conn.commit(); } catch (e) { await conn.rollback(); rethrow; } }这套代码在鸿蒙真机上跑通后我再强调一个容易忽略的细节写操作必须注意返回的 affectedRows 和 insertId。mysql_dart 的execute返回的是Result对象包含affectedRows和insertId字段你在做数据一致性校验时会用得上。比如插入一条日志后如果 insertId 一直是 0说明你的表没有自增主键——这种问题在 Android 上不会暴露但到了鸿蒙端因为日志上报模块的差异可能变成诡异的“数据丢了吗”的崩溃现场。4. 生产级 SQL 交互实战——安全防护与性能优化4.1 防护 SQL 注入参数化只是第一层刚才提到了参数化查询但必须说清楚参数化只是防注入的第一道闸不代表你可以放宽心。mysql_dart 的参数绑定机制能防止绝大多数“值注入”但不能防止表名/字段名注入因为表名和字段名不能作为绑定参数你只能把它们拼进 SQL 字符串。这会带来一个隐患如果业务上有动态排序字段、动态表名而这些字段来自客户端请求那就有被注入的风险。我这边有个项目就遇到过类似的需求——用户在工具类 App 里可以选择按“温度”或“湿度”排序。最开始的实现是直接ORDER BY ${sortField}如果 sortField 被恶意传成id; DROP TABLE devices; --整张表就没了。生产级的解法有两种一是白名单映射在代码里维护一个可排序字段的合法枚举客户端传来的值必须经过映射才能拼进 SQL二是用重命名别名让外部传入的字段名不会直接对应真实表结构。const _sortableFields { temp: temperature, hum: humidity, }; String safeSortField(String userInput) { return _sortableFields[userInput] ?? temperature; }这种“白名单映射”虽然土但非常有效。我见过太多团队过度依赖 ORM 框架而忽略这类不起眼的拼凑点结果就是测试环境 SQL 注入测试一打一个准。记住参数化解决的是“值”白名单解决的是“标识符”两者缺一不可。4.2 轻量连接池设计与实现生产环境直连 MySQL如果每个页面请求都新建一个MySqlConnectionMySQL 服务端的线程开销会很快被打满。移动端并发量虽不如服务端但用户在列表页来回滑动时产生的瞬时并发还是不小。mysql_dart 本身不提供连接池得自己做一个轻量的。我的做法是写一个简单的连接池核心是“空闲复用 最小并发控制”。大概思路class MySqlPool { final ListMySqlConnection _idle []; final int maxSize; final ConnectionSettings settings; MySqlPool({ required this.settings, this.maxSize 5, }); FutureMySqlConnection acquire() async { if (_idle.isNotEmpty) { return _idle.removeLast(); } if (_inUse maxSize) { throw 连接池已满请稍后重试; } _inUse; try { return await MySqlConnection.connect(settings); } catch (_) { _inUse--; rethrow; } } void release(MySqlConnection conn) { if (conn.isClosed) { _inUse--; return; } _idle.add(conn); } }这里有几个注意点。第一个是连接空闲检测MySQL 服务端有wait_timeout默认 8 小时空闲太久的连接会被服务端主动断开但从客户端角度看 Socket 还是活的。从池里拿出的连接可能已经失效执行第一条 SQL 时才报MySQL server has gone away。所以 acquire 时最好做一次心跳检查最简单的实现是SELECT 1成本极低。第二个是池上限要和 MySQL 的max_connections匹配不要开 50 个客户端各建 10 连接把服务端撑爆。移动端 App 场景连接池保持 3~5 个就足够。4.3 慢查询定位与网络层面优化直连 MySQL 有一个好处你可以直接在手机上观测 SQL 的耗时分布。mysql_dart 的ResultSet对象在查询完成后可以用Duration计时但更精细的耗时定位得靠自己在业务层打点。我习惯在查询语句外层做三层计时第一层是业务侧发起查询到拿到结果的整段时间第二层是连接到获取连接如果走连接池第三层是 SQL 真正执行的时间可以在 MySQL 侧开slow_query_log配合检查。某次我在鸿蒙设备上排查一个诡异问题——同一个 SQL 在电脑上用 Navicat 执行只要 30ms在鸿蒙 App 里跑要 2 秒。最后定位到根本不是 SQL 慢而是鸿蒙设备的网络协议栈和 IPv6 有关MySQL 主机配置了 IPv6 地址InternetAddress.lookup优先解析到了 IPv6但路由器/AP 对 IPv6 转发支持不好走了回退超时。解决方式很简单在ConnectionSettings里改成强制 IPv4final settings ConnectionSettings( host: 10.10.10.5, port: 3306, // 直接给 IPv4 地址避免 lookup 解析到 IPv6 resolveDns: false, );还有一个优化点是查询字段的裁剪。移动端内存宝贵直连查询时如果SELECT *把几百个字节的日志字段全拉回来MySQL 到手机的带宽和解析时间都会浪费。生产级写法是明确列出需要的字段同时用LIMIT控制结果集大小配合ORDER BY和索引解决排序。这个规则虽然简单但我在 code review 里看到太多SELECT *了——尤其在移动端直连场景它不只是规范问题是实打实的性能问题。5. 常见问题排查与踩坑实录5.1 鸿蒙编译与运行报错速查表适配过程中我把遇到的高频报错整理成了一张表按“报错信息 → 原因 → 解法”三列列出来报错信息常见原因解决办法SocketException: Permission denied鸿蒙应用未声明 INTERNET 权限或明文流量被拦在 module.json5 中加ohos.permission.INTERNET调试期放行明文流量MySqlException: Auth plugin not supportedmysql_dart 版本不支持 MySQL 8.0 的 caching_sha2_password升级 mysql_dart 到 0.8.0或改 MySQL 用户插件为 mysql_native_password仅限兼容场景HandshakeException: CERTIFICATE_VERIFY_FAILED鸿蒙系统信任库不包含 MySQL 所用证书链在 SslOptions 里加载自签名证书或指定 CA 根证书Connection timed out网络不通、防火墙拦 3306、IPv6 解析回退慢用 ping/nc 定位于主机可达性强制 IPv4 或用 timeout 兜底FormatException: Unexpected packetTCP 分片/粘包解析失败常见于超大结果集分页查询、控制单次返回行数避免流式中途断连NoSuchMethodError: colByNamemysql_dart 版本 API 不匹配旧 API 是下标访问统一版本按 0.8.x 的 ResultRow API 改写取值逻辑这张表解决了我大半的鸿蒙适配问题。其中很多报错信息看起来像“网络不通”实际是权限配置或版本兼容的问题——排查时不要贸然改一大片代码先按表里最可能的项快速试能省出大量时间。5.2 排查实录TLS 握手失败、连接超时与内存抖动挑两个印象最深的排查过程说说。第一是 TLS 握手失败。鸿蒙设备 A 能连设备 B 连不上报错都是HandshakeException。两边固件版本不一样鸿蒙的证书信任库版本也有差异。我当时的排查思路是抓包看 TLS 握手内容对比两台设备的 ClientHello 支持套件列表——发现设备 B 不信任某个中间 CA。最后方案是在sslOptions.certificates里显式传入完整链的根证书问题立刻消失。这件事给我一个教训鸿蒙上的系统信任链和开发者预期可能不同凡是涉 TLS 的联调务必把证书链信息打印出来核对不要靠“看起来应该通”的直觉。第二是连接超时。现象是应用退到后台再回到前台后第一次查询必超时。原因是鸿蒙对后台 App 的网络策略比较激进App 挂后台后 socket 被系统回收但 mysql_dart 连接池里的连接对象不知道已经失效再次复用时就卡在已断开的 socket 上。我们的解法是在AppLifecycleListener里监听页面回到前台强制清空连接池让所有连接重新建立。这个修复只加了十行代码却解决了线上最常见的超时反馈。另外补充一个内存相关的经验mysql_dart 查询大结果集时ResultSet会把所有行缓存在内存里。我在鸿蒙上跑过一个返回 50 万行的查询结果应用直接 OOM 崩溃。后来改成流式遍历——mysql_dart 0.8.x 支持MySqlConnection.startStreamingQuery可以用 Stream 方式逐行消费。如果你的业务确实有大结果集需求别犹豫直接上流式方案。5.3 针对鸿蒙 Flutter 工程的独家避坑技巧最后分享几个只有实际在鸿蒙上跑过 mysql_dart 才容易踩中的细节。第一个是关于part文件的。Flutter 项目里有些人喜欢用part xx.dart组织代码。在 Android 平台这没问题但在鸿蒙的 Flutter 编译链路里part 文件如果没被正确包含编译期可能报“undefined class”之类的怪错。排查技巧是检查part指令对应的文件路径是否和实际文件名一致注意大小写鸿蒙侧的文件系统对路径匹配比 Android 更严格。所以我个人建议在鸿蒙工程里尽量少用part公共类用import足够清晰。第二个是关于 EventChannel 和 MethodChannel 的干扰。鸿蒙 Flutter 应用里如果有原生与 Flutter 双向通信的需求比如调用鸿蒙原生能力channel 的注册时机要小心。mysql_dart 的初始化如果和 channel 注册同时在 main 函数里启动可能会因为渲染线程抢占导致连接建立慢几十毫秒。这听起来不是大问题但在低端鸿蒙设备上这几十毫秒会被用户感知为“卡一下”。我的建议是把 mysql_dart 的初始化放到真正需要首屏数据之后用懒加载方式触发连接建立。第三个是关于 Gradle 插件版本的。很多人觉得 Flutter 工程里settings.gradle里的插件版本不影响鸿蒙编译这是误区。如果你在android/app/build.gradle里配了旧版的 Gradle 插件DevEco Studio 在同步时可能把鸿蒙壳工程也带崩。我们曾遇到过“鸿蒙 HAP 打包失败报 Gradle 插件不兼容”的问题最后发现是 flutter 主工程的 Android 配置里的插件版本太老影响了 Ohos 构建。处理方式是保证 Flutter 官方 Android Gradle 插件和 DevEco 侧 hvigor 版本都尽量新且兼容。第四个是关于日志与调试。鸿蒙设备上跑 Flutter 应用日志输出有时会有延迟尤其是print和debugPrint。如果你用 mysql_dart 的 SQL 日志排查问题建议直接落盘或者通过 channel 上报到ohos原生日志系统不要依赖 Flutter 控制台输出。我遇到过日志漏打导致误判“SQL 没执行”的窘境加上文件日志后才发现 SQL 执行成功但返回的数据被业务层异常吞掉了。结束语直连 MySQL 之后的下一步按我个人的实践体会mysql_dart 在鸿蒙上跑通只是起点真正难的是后续维护和扩展。当你的 Flutter 鸿蒙应用真的开始直连 MySQL第一件事是监控 MySQL 服务端的连接数和慢查询日志——移动端直连的流量特征和服务端 API 完全不同突发性更强连接生命周期更短会给数据库的 connection pool 层带来额外压力。我建议在 MySQL 侧把max_connections调高一些同时设置wait_timeout缩短到 60 秒左右让失效连接快速回收。如果后续要扩展功能可以考虑在这个基础上封装一个轻量 ORM把表结构和 Dart 模型做映射减少手写 SQL 的重复劳动。但记住封装的厚度一定要克制——集成越多抽象层鸿蒙的编译和运行就越容易踩到兼容问题。mysql_dart 本身已经帮你完成了最复杂的协议部分剩下的事七分靠规范三分靠经验。希望这篇指南能让你少走几步弯路踩过的坑就别再踩了。