☰
鸿蒙 Flutter 直连 MySQL:mysql_dart 适配、安全与生产实践
2026/10/3 9:23:48 网站建设 项目流程

直连数据库这种架构,说实话在很多团队眼里属于“民科方案”,一听说移动端直接连 MySQL,第一反应都是“疯了”。但我在鸿蒙应用里就是干了这件事,而且干得还挺稳。这篇东西不是标题党,是想把 Flutter 生态里那个纯 Dart 写的mysql_dart库,怎么在鸿蒙环境里跑通、怎么保住底线安全、怎么写出能上生产的 SQL 交互逻辑,完完整整地讲清楚。适合那些小程序、工具类 App、内网部署场景的开发者在做技术选型时参考,也适合已经决定走直连路线、正在搜“鸿蒙 mysql_dart 适配”的同行抄作业。我要先把结论放在前面:这条路能走通,但前提是你要把网络边界、账号权限、连接生命周期这三件事想明白。

1. 直连 MySQL 的诱惑与代价:为什么非要在鸿蒙里塞一个数据库驱动

先交代一下项目背景。当时我们做的是一款面向企业内部的生产数据采集应用,部署环境全部在内网,业务形态是数十台鸿蒙平板放在车间工位上,工人通过 Flutter 写的应用录入数据。团队里没有多余的服务器资源去专门维护一套后端 API,项目周期又压得很紧,最直接的思路就成了:客户端持有只读权限的数据库账号,通过便携机的 Wi-Fi 内网直连 MySQL 实例。

选择mysql_dart而不是自建后端,核心原因只有一个:它是纯 Dart 实现,没有原生依赖,理论上只要能跑 Dart 就能跑它。Flutter 在鸿蒙上的适配本来就走得曲折,如果这时候再引入一个带 C++ 插件的数据库驱动,鸿蒙侧的 NDK 编译、CMake 配置、动态库加载全是坑。而mysql_dart走的是标准 Socket 通信,协议层全靠 Dart 自己解析,跨平台的阻力天然就小。

但这东西的代价也很明显。直连意味着数据库的 3306 端口要暴露给所有客户端,任何一台设备被攻破,攻击者就拿到了一个真实可用的 MySQL 会话。账号权限必须压到最低、网络边界必须卡死、连接必须加密。你们可以想象,当时我把这个方案提出来,运维同事的脸色有多难看。好在最后我们通过一套组合拳把风险控制住了,具体怎么做的,后面第四部分详细说。

在动手之前,我心里其实盘算过一版架构对比:

方案开发成本维护成本安全性适用场景
自建后端 API高,要写接口层高,要维护服务器高,可精细化控制公网应用、多端复用
客户端直连数据库低,跳过接口层低,少一套服务低,风险外溢内网工具、受限设备
中间件前置代理中中中高团队有运维能力

我最后选的是直连,因为团队当时的真实约束就是“没有运维人力”。做技术选型最怕的是脱离约束谈理想架构,如果你们有服务器资源,我绝对不会劝你走这条路。

2. 适配前的四层检查:Flutter SDK、依赖、网络权限与 Dart 运行时

2.1 Flutter 的鸿蒙 SDK 分支怎么搭

mysql_dart本身不需要你改任何源码,但对 Flutter 的鸿蒙支持分支是有版本要求心里要有数的。OpenHarmony SIG 维护的 flutter_flutter 仓库,从 Flutter 3.7 时代就开始有可用的 HarmonyOS NEXT 适配分支。我在做这个项目时,用的是 gitee 上openharmony-sig/flutter_flutter的flutter_3.13分支,配合 DevEco Studio 4.0 Release 版本使用。

搭建流程不复杂,但是有一个坑我印象特别深:默认的 pub.dev 源拉取的mysql_dart版本,它的元数据里要求 Dart SDK 的版本范围,必须跟你 Flutter SDK 内置的 Dart SDK 版本匹配。否则pub get那一关直接报版本冲突。我当时先flutter --version查了内置 Dart 版本,再去 pub.dev 找到对应兼容的 mysql_dart 版本,锁在pubspec.yaml里才把依赖装进去。

environment: sdk: ">=3.0.0 <4.0.0" dependencies: flutter: sdk: flutter mysql_dart: ^2.0.0

2.2 mysql_dart 依赖链条上的三个隐藏雷区

版本锁好之后,还要检查mysql_dart的传递依赖。它对外的核心依赖是crypto和typed_data,这两个都是纯 Dart 包,鸿蒙上没毛病。但如果你们用的版本较老,可能还会带上archive这种包里含少量原生代码的库,那种就要警惕了。

第二个雷区是dart:io的可用性。mysql_dart底层连接数据库用的是dart:io的Socket和SecureSocket,而鸿蒙的 Flutter 分支是基于 OpenHarmony 的 io 能力做的实现。正常情况下没问题,但有一个细节:鸿蒙的 DNS 解析行为与 Android 不同,如果代码里直接传域名而不是 IP,偶发会出现SocketException: Failed host lookup。这个后面实战环节会专门讲如何处理。

第三个雷区是 SSL 证书验证。mysql_dart的ConnectionSettings支持传sslContext,用的是dart:io的SecurityContext。但鸿蒙环境下系统证书库的路径和 Android 不一样,如果你打算用自签证书做加密连接,客户端必须内置证书文件,否则握手会被优雅地拒绝。这个我在第四部分的安全设计里展开。

2.3 鸿蒙应用的网络权限声明

鸿蒙应用的网络权限不在 Android 的AndroidManifest.xml里配,而是在工程模块的module.json5文件中声明。这件事容易被初次接触鸿蒙开发的同事忽略,因为 Flutter 的 Android 目录里还有一个 manifest,你改了那边,鸿蒙端根本不吃。

{ module: { name: "entry", type: "entry", // ... requestPermissions: [ { name: "ohos.permission.INTERNET" } ] } }

不要小看这一步,当时我们直接漏了这行配置,结果数据库连接永远超时。mysql_dart客户端没有任何报错,就是一直等在connect的 Future 上,排查了半天才发现是权限没给。鸿蒙的权限模型比 Android 更严格,缺权限表现出的症状往往是“静默失败”,连个系统日志都不打。

2.4 Dart 运行时对 IO 线程模型的约束

最后一层检查是 Dart 的运行时模型。Dart 是单线程事件循环模型,mysql_dart的查询操作是异步的,但它内部对 Socket 的读写是同步阻塞的。换句话说,如果你在 UI 线程里等一个超大查询的 Future,虽然界面不会完全卡死,但显微任务的执行会抢占事件循环时间片,导致掉帧、触摸延迟。

我的做法是把所有的数据库操作隔离到一个Isolate里跑。每个连接任务、查询任务都通过ReceivePort与主 Isolate 通信。这样做带来了第二个收益——数据库连接失败时的异常堆栈不会污染主 Isolate,崩溃范围被物理隔离。

Future<void> queryInIsolate(String sql) async { final receivePort = ReceivePort(); await Isolate.spawn(_dbWorker, receivePort.sendPort); final sendPort = await receivePort.first as SendPort; sendPort.send(sql); }

3. 握手阶段的问题清单:从 TCP 探测到数据库账号匹配

3.1 手动复现 MySQL 握手的排查思路

mysql_dart的connect调用看似简单,内部要经历 TCP 连接、协议版本协商、认证加密、字符集协商四个阶段。任何一个阶段挂了,外层表现都是一个超时或者握手失败,定位起来容易抓瞎。我的排查思路是:先用系统工具把链路一截一截切开来验证,不要一开始就怀疑三方库。

第一步是在鸿蒙设备所在的局域网里,用另一台机器直接戳 3306 端口。Windows 上就用Test-NetConnection 192.168.1.10 -Port 3306,Linux 上用nc -vz。这一步确认网络层的通路是好的。

nc -vz 192.168.1.10 3306

第二步是用 MySQL 命令行客户端手动连一次,确认账号和密码、host 白名单、SSL 要求这三项配置是匹配的。mysql -h 192.168.1.10 -P 3306 -u workuser -p能连通,再回头检查 Dart 端代码。凡是能用命令行复现的问题,一律先排除 MySQL 服务端配置;凡是命令行都连不上的,也别急着改代码。

第三步才是改 Dart 端代码,但不要只写connect,要把onError回调拿全。我用的是try/catch加Timeout的组合,超时时间我设的是 3 秒,超过这个时间还握不上手,说明链路里某个环节延迟超过预期,后面大批量连接肯定会雪崩。

3.2 鸿蒙设备 DNS 解析的特殊性与 IP 直连策略

前面提到的 DNS 解析问题,这里展开讲。鸿蒙的 Flutter 分支在处理hostname时,存在一个已知的兼容性问题:当系统网络从 Wi-Fi 切换到蜂窝网络,或者反向切换时,DNS 缓存可能没有及时刷新,导致Socket.connect在同一个 hostname 上反复失败。

我们的策略很朴素但也极其有效:在数据库连接配置里优先使用 IP 地址而不是域名。内网环境的数据库实例 IP 本来就固定,直接把 IP 填进ConnectionSettings.host,绕开 DNS 解析这个不确定因素。如果你们必须用域名,那就写一个简单的心跳机制,检测到 Socket 异常后主动重置连接,而不是无限期等待。

3.3 账号 host 白名单的分段配置

MySQL 的账号授权里,host字段决定了允许从哪些 IP 连入。这玩意是可以分段的,我当时建了三个账号:

账号host 段权限用途
app_reader192.168.1.%SELECT常规查询、报表读取
app_writer192.168.1.%SELECT, INSERT, UPDATE, DELETE数据录入、状态变更
ops_admin127.0.0.1ALL运维专用,禁止远程

mysql_dart连接使用的账号永远只有app_reader和app_writer。绝不使用 root 或带 ALL 权限的账号连库,这条是硬规矩,没有任何商量的余地。权限越小,即使客户端被攻破,攻击面也越窄。这跟给自动驾驶系统配一个“只能踩刹车不能踩油门”的应急账号是一个道理。

4. 数据库裸奔在公网的行径要杜绝:直连场景的安全设计

4.1 连接加密:MySQL SSL 与 Dart 侧证书配置

直连数据库最大的安全短板是,客户端和 MySQL 之间的流量是一路明文穿过局域网的。车间里的 Wi-Fi 默认不开启 WPA2-Enterprise,同一个 AP 下的其他设备只要开个抓包软件,就能把你的 SQL 语句和结果集看得一清二楚。生产环境必须启用 SSL。

MySQL 侧的 SSL 配置,首先生成自签证书:

openssl req -newkey rsa:2048 -days 365 -nodes -keyout ca-key.pem -out ca-cert.pem -x509 openssl req -newkey rsa:2048 -days 365 -nodes -keyout server-key.pem -out server-req.pem openssl x509 -req -in server-req.pem -CA ca-cert.pem -CAkey ca-key.pem -CAcreateserial -out server-cert.pem

然后在my.cnf里启用:

[mysqld] ssl-ca=ca-cert.pem ssl-cert=server-cert.pem ssl-key=server-key.pem

Dart 侧,由于鸿蒙系统的根证书库路径不可靠,mysql_dart的ConnectionSettings支持传入SecurityContext,我们在里面手动加载打包进应用 assets 的服务端证书:

final sslContext = SecurityContext.defaultContext; sslContext.setTrustedCertificatesBytes( utf8.encode('assets/certs/server-cert.pem') ); final settings = ConnectionSettings( host: '192.168.1.10', port: 3306, user: 'app_reader', password: _readPassword(), sslContext: sslContext, sslEnabled: true, );

这里有个非常重要的细节:必须在 MySQL 服务端设置require_secure_transport = ON,或者在账号授权语句里带上REQUIRE SSL,否则 MySQL 会在客户端未启用 SSL 时默默降级为明文连接。直连场景下,仅靠客户端自觉开启 SSL 是不够的,服务端要强制,两手都要硬。

4.2 密码管理的底线:别把数据库密码硬编码在应用里

mysql_dart的ConnectionSettings里那个password字段,如果把真实密码硬编码在源码里,那这个安全设计就彻底失败了。Dart 编译产物虽然会被混淆,但反编译工具的字典匹配能力很强,硬编码字符串一捞一个准。

我们的做法是这样的:数据库密码不参与 Flutter 构建产物,而是由设备端从鸿蒙的 KeyStore 能力读取。设备首次开箱时,管理员通过一个专用配置页录入数据库密码,写入 KeyStore;应用启动时从 KeyStore 拉取密码,再拼装ConnectionSettings。

在代码层面,我会把密码的获取封装成一个单独的函数,这样后面换密码的时候不用动业务代码:

Future<String> _readPassword() async { // 从鸿蒙 KeyStore 读取敏感凭据 // 读取失败时直接抛出异常,拒绝降级为任何明文方式 }

4.3 SQL 注入的防范姿势:预编译与白名单缺一不可

直连模式下,任何一条客户端主动拼接的 SQL 都是注入的温床。mysql_dart提供的execute方法如果不带参数绑定,传入的内容会被直接塞进 SQL 文本。生产级写法必须走预编译。

看一个反例和正例的对比:

// 反例:直接拼接 final result = await conn.execute( "SELECT * FROM production WHERE line_no = '$lineNo'" ); // 正例:使用 ? 占位符 + 参数绑定 final result = await conn.execute( "SELECT * FROM production WHERE line_no = ?", [lineNo] );

mysql_dart内部会把参数交给 MySQL 的 prepared statement 机制处理,特殊字符会被正确转义,从协议层面杜绝注入。即便用了预编译,动态的表名、字段名仍然不能走参数绑定,必须放进白名单校验。比如允许查询的设备列表字段只有id, name, production_line_no, status这几个,业务代码里做映射,不在 SQL 文本里拼任何用户输入。

4.4 网络边界最小暴露:防火墙与端口放行策略

服务端的bind-address是个常被人忽略的配置项。如果 MySQL 实例部署在云端,很多人忘了限制监听地址,把 3306 暴露到了公网。这个必须改:

[mysqld] bind-address = 192.168.1.10 skip-networking = 0

配合安全组规则,只放行来源于工位网段192.168.1.0/24的 TCP 3306 端口,其他来源一律拒绝。我见过不少团队把精力花在客户端加密上,结果数据库端口对公网开放,这属于防守姿势完全摆反了。先把网络边界收敛到最小,再谈协议层的加密,顺序错了全是白搭。

5. 生产级 SQL 交互:连接管理、预编译与事务的节奏

5.1 连接池的必要性:别把连接当一次性餐具

在没有连接池的情况下,每次查询都新建一个 TCP 连接、走一遍 MySQL 握手认证。这个握手过程在局域网内需要 20~50 毫秒,如果设备一分钟内发起 10 次查询,光握手开销就有 0.5 秒,属于肉眼可见的浪费。而相比性能,更危险的是连接数打满——MySQL 默认的max_connections是 151,每台鸿蒙平板如果同时维护 5 个空闲连接,20 台设备就把连接池占满了,其他人全被拒之门外。

mysql_dart官方没有提供内置连接池,需要自己实现一个最小可用版本。我当时写了一个基于Pool的封装,核心逻辑是维护一个固定数量的连接列表,每次查询从列表中借一个、用完归还,超时未归还的连接强制销毁重建。

class MysqlPool { final List<MySqlConnection> _idleConnections = []; final int maxSize; MysqlPool(this.maxSize); Future<MySqlConnection> getConnection() async { if (_idleConnections.isNotEmpty) { return _idleConnections.removeLast(); } if (_totalCreated < maxSize) { final conn = await _createNewConnection(); _totalCreated++; return conn; } // 等待可用连接,这里用 Completer 做请求队列 final completer = Completer<MySqlConnection>(); _waiters.add(completer); return completer.future; } Future<void> returnConnection(MySqlConnection conn) async { _idleConnections.add(conn); if (_waiters.isNotEmpty) { final waiter = _waiters.removeFirst(); waiter.complete(_idleConnections.removeLast()); } } }

关键点有两个:一是初始化时机,我选择在应用刚启动时预热 2 个连接,避免第一屏操作触发隐藏的握手耗时;二是连接健康检查,每次取连接前执行一个SELECT 1,如果失败就销毁重建,防止 MySQL 服务端因为wait_timeout主动断开导致客户端拿到一个坏连接。

5.2 预编译语句的正确姿势与批量操作

预编译不只是防注入,它在性能上的收益同样可观。MySQL 执行 SQL 的流程是:词法分析、语法解析、查询优化、生成执行计划、执行。预编译语句把前四步提前做好了,后续执行只用换参数。mysql_dart里使用预编译的方式很简单:

final prepared = await conn.prepare( 'INSERT INTO production_log (line_no, qty, operator, created_at) VALUES (?, ?, ?, NOW())' ); for (var i = 0; i < records.length; i++) { await prepared.execute( [records[i].lineNo, records[i].qty, records[i].operator], ); } await prepared.deallocate();

注意最后那个deallocate(),预编译完了不释放,MySQL 端会累积 prepared statement 资源,一旦达到max_prepared_stmt_count上限,后续预编译会直接失败。用完必须释放,这是很多人拿到生产环境才踩到的坑。

批量插入还能更进一步,用executeMulti一次性把多条记录打包发送,减少网络往返。U 型批量插入的耗时,从一条一条 execute 的连续耗时降低到单次网络往返,体感差距非常明显。

5.3 事务的边界:提交、回滚与超时处理

事务是保证数据一致性的底线。在工位录数据这个场景里,一次录入可能要同时更新生产记录表和库存表,两步操作要么都成功、要么都失败。写事务的代码时,最难的不是BEGIN和COMMIT的位置,而是失败时如何把连接恢复到干净状态。

我的模板是这样:

final conn = await pool.getConnection(); try { await conn.execute('START TRANSACTION'); await conn.execute('UPDATE inventory SET qty = qty - ? WHERE product_id = ?', [qty, productId]); await conn.execute('INSERT INTO production_log (product_id, qty) VALUES (?, ?)', [productId, qty]); await conn.execute('COMMIT'); } catch (e) { await conn.execute('ROLLBACK'); rethrow; } finally { await pool.returnConnection(conn); }

这里有个细节:如果事务中途因为网络异常断开了连接,ROLLBACK也不能保证执行成功。所以 finally 里归还连接前,我还会用连接状态标志位做个判断,一旦发现连接处于异常状态,就不归还到池子里,而是关闭销毁。这个逻辑不写的话,坏连接会传染到后续所有事务。

事务还有一个容易被忽略的问题——超时。MySQL 的innodb_lock_wait_timeout默认 50 秒,如果客户端一直占着事务不提交,锁等待会把业务拖垮。我在客户端侧加了 10 秒的事务超时,超时直接强制回滚,宁可这条数据这次没写成,也不能让整个库的锁堆积起来。

5.4 查询结果映射:类型、时区与精度

mysql_dart返回的数据类型和 Dart 的类型之间不是一一对应的。MySQL 的DATETIME默认不带时区信息,直接映射到 Dart 的DateTime时,如果 MySQL 服务端和设备的本地时区不同,展示出来的时间会错位。我们的解决方式很粗暴:SQL 里统一用UNIX_TIMESTAMP()函数取时间戳,业务层再转成当地时间的DateTime。

DECIMAL类型也是重灾区。MySQL 的DECIMAL(10,2)存的是定点数,mysql_dart默认会把它映射成double,但double在浮点运算中的精度问题会直接导致金额对不上账。生产环境的做法是:金额字段在 SQL 里直接用CAST转成字符串,客户端拿到字符串后自己封装一个高精度运算类,或者直接用int存储“分”。

SELECT product_id, CAST(price * 100 AS SIGNED) AS price_in_cents FROM products;

这些坑不体现在“能连上数据库”这个层面,而是体现在数据对不上、报表差几分钱,排查起来极其痛苦。

6. 从“能连上”到“可上线”:压测表现与生产的踩坑记录

6.1 一个不算严谨但足够说明问题的压测

设备端建好连接池、写好预编译之后,我在一台鸿蒙平板上跑了一个粗糙的压测:连续执行 5000 次SELECT查询,每次从 20 万行的表里取一条主键记录。结果如下:

场景总耗时平均单次耗时备注
无连接池 + 无预编译43.6s8.7ms包含大量握手开销
有连接池 + 无预编译12.1s2.4ms握手开销消失
有连接池 + 预编译8.2s1.6ms网络往返大幅减少

这个结果符合预期:连接池带来的收益是数量级的,预编译在此基础上再降一档,以每秒约 600 次查询的速率运行,设备温度没有明显上升。对于工位录入这种每秒最多操作两三次的真实场景,性能富余非常充足。

6.2 生产环境第一个坑:wait_timeout的静默断线

压测时用的连接是刚建好的,没人测过“连接空闲 8 小时后还能不能用”。MySQL 服务端的wait_timeout默认 28800 秒,也就是 8 小时,一旦连接空闲超时,服务端会把连接悄悄干掉。客户端感知不到,第一次拿着这个坏连接去查询时,mysql_dart会抛出一个底层 Socket 异常。

我的解决方案就是在连接池的getConnection里加健康检查:取连接之前先试查询一条SELECT 1,失败就销毁重建。这个检查在一毫秒级别,相对保底机制的收益可以忽略不计。这才是生产环境该有的防御性编程。

6.3 第二个坑:设备离线与重连风暴

工位平板有个特点,就是会时不时被工人锁屏、待机,Wi-Fi 可能断掉。一旦网络恢复,所有设备会同时尝试重连数据库,瞬间形成重连风暴,把数据库的连接线程打满。

我在客户端做了两件事:一是重连加随机退避,退避区间在 1~5 秒随机,避免设备之间的重连请求重合;二是应用进入后台超过 30 秒后主动关闭所有空闲连接,只在前台恢复时重建。这两个机制配合下来,重连风暴基本绝迹。不要小看这种“非功能”细节,生产事故往往不是主流程出的,而是边缘行为堆积出来的。

6.4 第三个坑:日志里的 SQL 明文

开发调试时,我们一度在日志里直接打印带参数的 SQL 语句,方便排查问题。结果日志收集系统会把 SQL 明文同步到运维平台,相当于把业务数据也泄了出去。安全审计时发现这个问题,我们立刻把日志里所有参数替成了脱敏符号。现在记日志只写 SQL 模板和耗时,绝不打印具体参数值。

7. 写给同路人:直连方案的适用边界与后续扩展

mysql_dart的鸿蒙适配能走通,核心不是因为库本身有多神奇,而是 Flutter 的跨平台能力和鸿蒙的开发工具链已经足够成熟。如果你的场景同样满足“内网部署”“设备数量可控”“查询频率低”“数据敏感度可控”这些条件,直连 MySQL 完全能拿到及格分。但如果你们的应用要发布到公网下载,或者设备数量上千台,我劝你老老实实上后端中间件。

从性能上说,直连方案最大的瓶颈不在查询本身,而在连接的建立与维护。把连接池写对、把生命周期管好,这套方案扛住几十台设备的内网高频查询没有任何问题。

最后提一个可以继续扩展的方向:把mysql_dart的连接代码从 Flutter 应用层抽出来,做成一个独立的鸿蒙鸿蒙端的本地服务,通过系统级的分布式能力让同局域网的鸿蒙设备共享同一个连接池。这样一来账号凭据只存一份,权限控制也更集中。我还没有彻底跑通这个方案,如果后续实验成功,再单独写一篇和大家分享。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询