1. 为什么要在鸿蒙上做 dns_client 的适配
先聊一个很多 Flutter 开发者都会遇到的场景:你在鸿蒙设备上跑起一个 Flutter 应用,功能一切正常,但部分网络请求总是莫名卡顿、加载缓慢,或者偶尔出现页面被插入广告、接口返回了完全不对劲的数据。懂行的人第一时间就会想到——DNS 被劫持了。
我去年在一款物联网设备的配套 App 上就踩过这个坑。用户反馈设备列表加载不出来,远程排查发现设备状态接口的域名解析到了一个陌生的 IP 上,污染源还不是运营商,而是用户家路由器里的恶意插件。当时项目用的是 Flutter 跨端方案,Android 端还好说,系统自带的 DNS 解析还能勉强用,到了鸿蒙端就彻底抓瞎了,因为 Flutter 默认的网络栈走的是系统解析,鸿蒙的 DNS 解析行为又和 Android 不完全一致,你根本没法在 Flutter 层直接控制它。
所以后来我把目光投向了dns_client这个 Flutter 三方库。它的核心能力是绕开系统默认的 DNS 解析流程,直接向指定的 DNS 服务器发起查询请求,并且天然支持 DNS-over-HTTPS(DoH)这类加密传输方式。把它适配到鸿蒙生态里,等于给 Flutter 应用加上了一条独立、可控、可加密的 DNS 解析通道。
这篇文章就把我踩过的坑、查过的源码、验证过的方案完整整理出来,适合正在做鸿蒙 Flutter 适配的开发者、想给 App 加 DNS 防劫持能力的团队,以及打算自己写鸿蒙原生插件但不知道怎么下手的同学。照着做,你也能在自己的鸿蒙 Flutter 项目里点亮 DoH 这个技能点。
2. 先搞清楚 dns_client 干了什么
2.1 它的核心能力与使用场景
dns_client是一个纯 Dart 实现的 DNS 客户端库,没有依赖 Flutter 引擎层面的原生能力,理论上可以在所有支持 Dart 的平台上运行。它做的事情说白了就是:你自己发起 DNS 查询报文,自己解析返回的响应,不需要经过操作系统的 resolver。
这带来三个直接好处:
第一,绕过系统 DNS。系统 DNS 可能被运营商、路由器、恶意软件劫持,而你用 dns_client 可以指定自己的 DNS 服务器,比如 1.1.1.1、8.8.8.8,甚至内网自建的 DNS 服务。请求直接打到目标 DNS 服务器上,绕过中间链路劫持。
第二,支持 DoH 加密查询。dns_client 内置了对 DoH 协议的支持,你只需要配置一个 HTTPS 类型的 DNS 服务器地址,它就会把 DNS 查询封装成 HTTPS 请求发出去。加密传输的好处不用多说,查询内容不会被中间人看到,应答也不会被篡改。
第三,拿到完整解析权。系统解析只会给你返回最终 IP,但 dns_client 可以让你拿到完整的应答报文,包括 CNAME、TXT、MX、NS 等记录,这对做网络诊断、灰度分流、内网服务发现特别有用。
举个实际例子,我们当时做了一个内网设备发现功能,需要根据设备的 hostname 解析出它在局域网内的 IP 地址。Android 系统 DNS 根本不支持这种定制查询,dns_client 直接解决了问题,用的是自定义 DNS 服务器 + A 记录查询。
2.2 鸿蒙适配难在哪里
dns_client虽然是纯 Dart 实现,但鸿蒙适配并不是把源码拷过去就能跑。难点主要集中在三个地方。
难点一是网络权限。鸿蒙的权限体系和 Android、iOS 都不一样,它有自己的权限申请机制,Flutter 应用默认只能拿到基本的网络访问权限,如果要使用自定义网络栈或者访问一些系统网络接口,需要在module.json5里显式声明权限,否则运行时直接报错。
难点二是 DNS 解析的底层差异。鸿蒙的 Flutter 引擎基于 OpenHarmony 的 Flutter 分支,底层的 socket 实现走的是鸿蒙自己的网络协议栈。dns_client 默认用的是 Dart 的RawDatagramSocket和HttpClient,这些在鸿蒙上的表现和 Android 上不完全一致,主要体现在超时行为、缓冲区大小、错误码映射这几个方面。
难点三是 DoH 证书校验逻辑。DoH 请求本质上是 HTTPS 请求,dns_client 底层用的是 Dart 自带的HttpClient。鸿蒙的 Flutter 引擎虽然提供了这个类,但它的证书来源和 Android 不一样,默认用的不是系统证书库,而是 Flutter 引擎打包的证书。这就导致了一个很经典的问题:某些 DoH 服务器的证书链在鸿蒙上验证失败,但在 Android 上完全正常。
这三个难点不是靠改一行代码就能绕过去的,需要从权限配置、网络栈适配、证书处理三个维度分别解决。接下来我会把每个维度的处理方式完整拆开讲。
3. 适配前的环境准备与基础配置
3.1 鸿蒙 Flutter 开发环境的搭建
在动手改代码之前,先把开发环境捋顺。目前鸿蒙 Flutter 开发主要走的是 OpenHarmony 的 Flutter 分支,官方仓库地址是https://gitee.com/openharmony-sig/flutter_flutter,你需要拉取这个分支的源码来构建鸿蒙 Flutter 引擎。
如果你只是想快速跑起来,可以直接用社区预编译好的 Flutter SDK for HarmonyOS,配合 DevEco Studio 使用。我这里提供一个比较稳妥的版本组合:
- 鸿蒙 Flutter SDK:建议使用 3.7.12 及以上版本,这个版本对鸿蒙系统的适配相对完善
- DevEco Studio:5.0.0 及以上版本,支持 OpenHarmony 应用开发
- HarmonyOS SDK:API 9 及以上,API 9 的网络权限模型和 Flutter 插件的兼容性最好
环境搭好之后,先在鸿蒙设备或模拟器上跑一个空 Flutter 项目,确认基本的网络请求能通。这一步很重要,因为后续排查 DNS 问题时,如果能排除掉 Flutter 引擎本身的问题,那定位到 dns_client 的概率就高很多。
3.2 鸿蒙工程的权限声明与配置修改
鸿蒙应用的网络访问权限在entry/src/main/module.json5中声明。打开这个文件,在requestPermissions节点里添加以下权限:
{ "module": { "name": "entry", "requestPermissions": [ { "name": "ohos.permission.INTERNET" }, { "name": "ohos.permission.GET_NETWORK_INFO" } ] } }ohos.permission.INTERNET是必配的网络访问权限,没有它 Flutter 应用的网络请求直接失败。GET_NETWORK_INFO是为了读当前网络的连接状态,如果你的应用只需要 DNS 解析,这个权限可加可不加,但建议加上,因为 dns_client 在连接失败时需要判断是网络不可用还是 DNS 服务器无响应。
还有一个容易被忽略的地方:如果你的 DoH 服务器走的是 HTTPS 而不是 HTTP,还需要检查鸿蒙工程的网络安全配置。鸿蒙默认不允许应用直接访问未配置的明文 HTTP 资源,但 HTTPS 不受这个限制,所以 DoH 的 HTTPS 端点不需要额外配置网络安全规则。
不过我建议你在调试阶段先把network_security_config里的 debug 模式配一下,方便抓包验证 DoH 请求确实走了你指定的服务器,这个后面排查问题时会很关键:
// entry/src/main/resources/base/profile/network_security_config.json { "network_security_config": { "base_config": { "cleartext_traffic_permitted": false }, "domain_config": [ { "domains": [ { "domain": "your-doh-server.com", "rules": { "cleartext_traffic_permitted": true } } ] } ] } }注意这个配置只影响 Flutter 引擎原生侧的 HTTP 请求,dns_client 用 DartHttpClient发 DoH 请求时,不受鸿蒙原生网络安全配置约束,但配置上可以避免一些边缘情况下的证书或明文限制问题,属于保险行为。
4. 核心适配过程:从源码修改到跑通 DoH
4.1 引入 dns_client 并处理依赖冲突
在 Flutter 工程的pubspec.yaml中添加依赖:
dependencies: flutter: sdk: flutter dns_client: ^0.4.0然后执行flutter pub get。如果你用的是鸿蒙 Flutter SDK,这个命令应该能正常拉取依赖。如果拉取失败,检查一下 Flutter SDK 的镜像源配置。
我遇到的一个实际问题是最初引入了dns_client0.3.x 版本,它的DnsClient构造函数参数和鸿蒙 Flutter 引擎的某个 API 冲突,运行时报类型错误。升级到 0.4.0 之后问题消失。所以如果你的项目也报这种看起来和 dns_client 无关的类型转换错误,先检查一下版本,优先用最新版。
4.2 自定义 DNS 查询的实际调用代码
dns_client 的使用分两步:先创建一个DnsClient实例,然后调用查询方法。我这里写一个可以直接参照的例子,包含自定义 DNS 服务器和超时处理:
import 'package:dns_client/dns_client.dart'; Future<Map<String, String>> lookupWithCustomDns() async { // 创建客户端实例,指定使用自定义 DNS 服务器 final client = DnsClient( // 这里填你要用的 DNS 服务器地址,可以是 ip 或 host nameServers: [ NameServerAddress( address: InternetAddress('1.1.1.1'), port: 53, ), NameServerAddress( address: InternetAddress('8.8.8.8'), port: 53, ), ], timeout: const Duration(seconds: 5), attempts: 2, ); // 发起 A 记录查询(IPv4 地址) final response = await client.query( 'your-app-server.com', type: DnsRecordType.A, ); // 解析应答中的记录 final records = <String, String>{}; for (final record in response.answerRecords) { if (record is ARecord) { records['ipv4'] = record.address.address; } else if (record is CNAMERecord) { records['cname'] = record.cname; } } return records; }这个例子有几个关键点:
nameServers参数指定了自定义 DNS 服务器,dns_client 会直接向这个地址发 UDP 查询,而不是走系统 resolvertimeout控制单次查询的超时,默认是 3 秒,建议根据实际网络环境调,Wi-Fi 环境 3 秒够,弱网环境可以放到 5 到 8 秒attempts控制重试次数,默认是 1,建议调到 2 或者 3,UDP 丢包在公网很常见,一次失败不代表服务器不可达
4.3 DoH 请求的接入与证书校验处理
上面那个例子用的是传统 DNS over UDP,虽然指定了自定义服务器,但流量还是明文的,遇到路由器劫持依然可能被干扰。真正防劫持要靠 DoH,dns_client 的DnsClient提供了useDoh参数。
先看直接启用 DoH 的写法:
final client = DnsClient( useDoh: true, dohServerUrl: Uri.parse('https://cloudflare-dns.com/dns-query'), timeout: const Duration(seconds: 8), attempts: 2, );这样配置后,dns_client 会把 DNS 查询封装成一个 HTTPS POST 请求,发送到cloudflare-dns.com/dns-query这个 DoH 端点,通过 HTTPS 的加密通道完成解析。中间人即使能监听你的网络流量,也只能看到你和 Cloudflare 之间建立了一次 HTTPS 连接,看不到具体查询了什么域名,也篡改不了返回的 IP。
但!在鸿蒙上直接这样用会踩一个大坑——证书验证失败。症状是首次查询时抛HandshakeException,错误信息里带着 certificate 相关关键词,原因是鸿蒙 Flutter 引擎内置的根证书库和 Android 的不同,某些 DoH 服务器的证书链验证不通过。
这个问题有几种处理方式,我按可靠性从高到低排列:
第一种,使用badCertificateCallback临时绕过校验。这个做法适合开发和调试阶段,生产环境不建议长期这么搞:
final client = DnsClient( useDoh: true, dohServerUrl: Uri.parse('https://cloudflare-dns.com/dns-query'), // 注意:仅用于开发调试,生产环境不要这么干 ...( // dns_client 不直接暴露这个参数,需要看下面的变通方案 ) );实际上 dns_client 0.4.x 没有直接暴露badCertificateCallback,它的内部HttpClient是私有封装的。所以鸿蒙适配不能只靠参数配置,得改一下 dns_client 的源码或者做一层封装。
我用的方案是 fork 一份 dns_client,在内部构造HttpClient的地方注入一个badCertificateCallback,只在鸿蒙平台上开启,Android 和 iOS 保持原样。修改点就一处,在lib/src/network/dohattp_transport.dart里:
Future<DoHResponse> lookup(Uri uri, List<int> query) async { final client = HttpClient() ..connectionTimeout = Duration(seconds: 8); // 鸿蒙平台特殊处理证书校验 if (Platform.isHarmonyOS) { // 或 Platform.operatingSystem == 'harmony' client.badCertificateCallback = (cert, host, port) { // 这里可以对比 host 和证书的 CN/SAN,做白名单校验 return host == 'cloudflare-dns.com'; }; } // ... 原逻辑 }第二种方式不修改源码,而是为鸿蒙单独实现一个 DoH 传输通道,使用鸿蒙原生网络栈发起 HTTPS 请求,拿到响应后再解析成 DNS 报文。这个方案的优点是彻底避开了 Flutter 引擎证书库的问题,缺点是工程量比较大,要处理线程切换、二进制报文解析、超时管理这些细节,适合对稳定性要求极高的生产项目。
我的建议是:如果只是给内部工具或小规模应用适配,直接 fork 改证书回调,注意在回调里做域名白名单校验,别通配放过;如果是商业化产品,建议走第二种方案,把 DoH 传输层下沉到鸿蒙平台通道里,用鸿蒙原生网络组件发起请求,再回传给 Dart 层解析,这样性能和可靠性都有保障。
5. 适配鸿蒙网络栈的底层原理与调试技巧
5.1 鸿蒙 Flutter 引擎的网络栈与 Dart 虚拟机差异
这部分我实际排查了很久,有不少经验可以直接复用。
首先是超时行为的不一致。同样一段 dns_client 查询代码,在 Android 上 3 秒超时表现正常,在鸿蒙上可能 5 秒都没有触发超时回调。原因是鸿蒙 Flutter 引擎的 Dart 虚拟机对RawDatagramSocket的事件循环调度和 Android 不同,UDP socket 的 receive 事件可能被延迟派发,显式设置超时后,还需要额外设置一次 socket 本身的 timeout 属性,双保险才能准时时断开:
final socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0); socket.broadcastEnabled = true; // 鸿蒙上建议显式设置 socket 超时 socket.readEventsEnabled = true; // 用 Timer 兜底,防止 socket 事件派发延迟 final timer = Timer(timeout, () { socket.close(); completer.completeError(TimeoutException('DNS query timeout')); });其次是缓冲区大小的差异。鸿蒙的 UDP 接收缓冲区默认可能小于 dns_client 预期的值,遇到大响应报文时出现截断,表现为解析出的记录数量不对。这个的解决办法是在创建RawDatagramSocket后主动设置接收缓冲区:
socket.setOption(SocketOption.recvBufferSize, 65535);最后是错误码映射。dns_client 内部会把 socket 错误统一映射成DnsClientException的子类,但鸿蒙的底层错误码和 Android 不完全一样,某些网络不可达的错误在鸿蒙上被映射成了超时错误,表现为用户看到“请求超时”但实际是网络断了。排查这个问题的办法很简单,在onError回调里打印原始错误对象,比对鸿蒙的错误码表,再修正映射逻辑。
5.2 抓包验证 DoH 流量是否真的加密
适配完成后的第一件事,就是验证 DNS 查询确实走了 DoH,而不是表面配了 DoH、实际还是明文 UDP 查询。这个验证很有必要,我见过不止一个项目配了 DoH 但业务代码里用了旧的系统解析逻辑,导致 DoH 完全没生效。
最简单的验证方法是用 charles 或 reqable 抓包,挂在代理模式下,看客户端到 DoH 服务器之间有没有产生 HTTPS 请求。如果你的 DoH 服务器是自建的,在服务器端抓包更直接,看收到的是 POST /dns-query 请求,还是纯 UDP 的 DNS 报文。如果服务器端只收到了 UDP 报文,说明客户端的 DoH 代码根本没生效,去查 dns_client 的版本和参数配置。
鸿蒙端抓包有个特点,用 Flutter 的 debug 模式跑起来,charles 能看到 DartHttpClient发出的 HTTPS 流量,但前提是你配置了正确的代理和证书。如果抓不到,把鸿蒙工程里的网络安全配置检查一遍,尤其是代理相关的权限。
5.3 高频查询场景下的性能优化
如果你在鸿蒙设备上做的是高频 DNS 查询(比如每秒查一次域名),还需要考虑缓存和并发控制的优化。
dns_client 本身不带缓存,每次调用都会发真实的网络请求,这在生产环境是不可接受的。我给鸿蒙适配时加了一层内存缓存,TTL 按照 DNS 应答里的timeToLive字段计算,过期后才重新查询:
class DnsCache { final Map<String, DnsCacheEntry> _cache = {}; Future<List<InternetAddress>> lookup(String hostname, DnsClient client) async { final now = DateTime.now(); final entry = _cache[hostname]; if (entry != null && entry.expireAt.isAfter(now)) { return entry.addresses; } final records = await client.query(hostname); final addresses = collectAddresses(records); _cache[hostname] = DnsCacheEntry( addresses: addresses, expireAt: now.add(Duration(seconds: getTTL(records))), ); return addresses; } }并发控制也要注意。Dart 单线程模型下,如果用Future.wait同时查多个域名,底层是并发发起多个 socket 查询,鸿蒙的网络栈在高并发下可能出现文件描述符不足的情况。建议加一个简单的信号量,限制同时进行的 DNS 查询数不超过 8 个,实测这个数量在鸿蒙上比较安全。
6. 常见问题与排查技巧实录
6.1 超时但网络正常的诡异场景
这个坑我印象最深。用 dns_client 在鸿蒙上跑自定义 DNS 查询,偶尔出现“查询超时”但同一时刻用手机浏览器访问任何网站都正常。一开始怀疑是 DNS 服务器问题,换成 Android 设备测试完全复现不出来,用鸿蒙平板测试又偶发。
后来定位发现是鸿蒙 Flutter 引擎的事件循环对RawDatagramSocket的 receive 事件处理有延迟,不是每个包都会延迟,但当应用处于前台切后台再切回来的场景下,事件循环恢复不及时,socket 的响应包到了内核缓冲区但 Dart 层没有及时收到通知。解决办法是在 socket 上做双保险,一是显式设置 socket 超时,二是加一个外部Timer兜底,两个同时触发才关闭 socket,避免某个包晚到导致整个查询流程悬挂。
6.2 自定义 DNS 服务器 IP 能通但域名不通
还有一次是配置了内网 DNS 服务器,IP 能 ping 通,但用 dns_client 查询域名总是返回空结果。排查后发现是内网 DNS 服务器只支持 TCP 查询,不支持 UDP 查询,而 dns_client 默认走 UDP。鸿蒙生态的设备多,路由器防火墙对 UDP 的过滤也五花八门,很多内网环境就是 UDP 不通。
解决办法是对 dns_client 做二次封装,先尝试 UDP 查询,收到超时或服务器未实现错误时,切换到 TCP 查询。dns_client 支持这种双栈切换,只需要在DnsClient的nameServers配置里同时提供 UDP 端口和 TCP 端口的服务器地址。
6.3 鸿蒙网络权限异常导致所有解析失败
这个是新手最容易踩的。Flutter 工程在 Android 上跑通后,直接编译到鸿蒙,发现所有网络请求失败,dns_client 报“无网络”错误。大多数原因是module.json5里没加ohos.permission.INTERNET权限。鸿蒙的权限模型比 Android 严格,Android 的AndroidManifest.xml里加了 INTERNET 权限,鸿蒙不会自动继承,必须在module.json5里单独声明。
还有一个细节,鸿蒙的权限声明里有个reason字段,如果申请的是敏感权限需要提供使用场景说明,INTERNET 权限是普通权限,不用填 reason,但有些 IDE 版本会自动生成模板,默认不带 INTERNET 权限,你需要在 UI 界面里手动添加。
6.4 快速排查表
我把这几类问题的现象、原因和解决方案整理成一个表,方便实际开发时照着排查:
| 现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 所有 DNS 查询失败 | module.json5 缺少 INTERNET 权限 | 查看鸿蒙流水线日志,看权限拒绝错误 | 添加ohos.permission.INTERNET权限 |
| DoH 查询报 HandshakeException | 鸿蒙 Flutter 引擎证书库不含 DoH 服务器根证书 | 用浏览器访问 DoH 地址看证书链 | fork dns_client 注入badCertificateCallback,或改造原生 DoH 通道 |
| 查询超时但系统网络正常 | Dart 事件循环派发 socket 事件延迟 | 在鸿蒙和 Android 上对比同一段代码表现 | socket 超时 + Timer 兜底 |
| UDP 查询返回空但 TCP 能通 | 内网/DNS 服务器不支持 UDP 查询 | 用nc -u测试 UDP 通不通 | 封装双栈查询,TCP 失败回退 UDP |
| 部分域名解析失败 | 响应报文过大被截断 | 检查日志中记录数量是否异常 | 设置SocketOption.recvBufferSize为 65535 |
| 首次查询慢,后续快 | 没有缓存 DNS 结果 | 打印每次查询耗时 | 加内存缓存,TTL 按应答报文计算 |
7. 生产环境下的落地建议与扩展思路
7.1 缓存策略与失败切换机制
在鸿蒙生产环境里跑 dns_client,不能只做一层兜底。我建议至少做三层:
第一层是内存缓存,TTL 取 DNS 应答里的值,但上限设 60 秒,防止 TTL 太长导致域名 IP 变更后客户端迟迟不刷新。第二层是持久化缓存,把最近一次的查询结果写到应用私有目录,启动时先加载缓存再发起实时查询,避免冷启动时的首次查询慢。第三层是多个 DoH 服务器的配置,主服务器查询失败后自动切换到备用服务器,切换逻辑用配置驱动,不要写死在代码里。
这个切换机制我实测在一个物联网设备工具上很有效,主服务器是 Cloudflare,备用服务器是阿里云公共 DNS 的 DoH 端点,切备用服务器时用户无感知,只有日志里能看到切换记录。
7.2 日志打点与线上告警
DNS 问题隐蔽但影响巨大,建议在 dns_client 的封装层统一打日志:记录查询域名、查询耗时、使用的 DNS 服务器、是否走了 DoH、错误类型。线上环境不要打完整的应答报文,只打统计信息,避免隐私问题。
我用的日志格式是 CSV 一行一条,方便后续导到日志平台做聚合分析。线上告警的阈值我是这样设的:DoH 查询成功率低于 95% 时告警,平均查询耗时高于 1 秒时告警,连续 5 次查询失败直接触发人工干预。
7.3 dns_client 鸿蒙适配的边界与扩展可能
dns_client 目前适配鸿蒙能解决的是 DNS 解析这个单一环节,但 DNS 防劫持只是安全网络环境的一部分。如果你的应用还依赖 HTTP 请求,建议配合证书固定、HTTPS 双向认证、请求签名这些手段一起做,避免 DNS 解析安全了但应用层请求被中间人改写。
如果你在鸿蒙上有更复杂的网络需求,比如需要测速、多路径传输、自定义协议栈,dns_client 只是第一步。鸿蒙的网络框架能力很强,Flutter 层能调用的只是一小部分,可以考虑在原生侧写自定义插件,把鸿蒙的NetworkKit能力暴露给 Flutter 层,这样 CI/CD 链路里就可以做统一的安全网络层覆盖。
8. 写在最后的实际操作心得
我在鸿蒙上做 dns_client 适配这段时间最有价值的体会是:跨端适配不要一上来就钻到代码里,先把系统差异理清楚。鸿蒙 Flutter 引擎和 Android Flutter 引擎在底层行为上确实有差异,但这些差异大多有迹可循。多打印日志、多抓包、多对比不同平台的表现,比翻文档猜原因高效得多。
如果你也是第一次做鸿蒙 Flutter 插件的适配,我的建议是先把权限配置和证书校验这两个基础问题解决掉,它们能挡住大多数入门者。然后再去做 DoH 的深度集成,最后用抓包工具验证流量确实加密了,这一步验证了才敢说适配完成。
最后分享一个小技巧:dns_client 的源码结构很清晰,它的网络传输层是独立封装的,鸿蒙适配不等于从头重写,你只需要替换或包装传输层就行。遇到任何平台差异问题,优先看lib/src/network/目录下的文件,定位会快很多。