
1. 为什么要在鸿蒙上做 dns_client 的适配先聊一个很多 Flutter 开发者都会遇到的场景你在鸿蒙设备上跑起一个 Flutter 应用功能一切正常但部分网络请求总是莫名卡顿、加载缓慢或者偶尔出现页面被插入广告、接口返回了完全不对劲的数据。懂行的人第一时间就会想到——DNS 被劫持了。我去年在一款物联网设备的配套 App 上就踩过这个坑。用户反馈设备列表加载不出来远程排查发现设备状态接口的域名解析到了一个陌生的 IP 上污染源还不是运营商而是用户家路由器里的恶意插件。当时项目用的是 Flutter 跨端方案Android 端还好说系统自带的 DNS 解析还能勉强用到了鸿蒙端就彻底抓瞎了因为 Flutter 默认的网络栈走的是系统解析鸿蒙的 DNS 解析行为又和 Android 不完全一致你根本没法在 Flutter 层直接控制它。所以后来我把目光投向了dns_client这个 Flutter 三方库。它的核心能力是绕开系统默认的 DNS 解析流程直接向指定的 DNS 服务器发起查询请求并且天然支持 DNS-over-HTTPSDoH这类加密传输方式。把它适配到鸿蒙生态里等于给 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 Studio5.0.0 及以上版本支持 OpenHarmony 应用开发HarmonyOS SDKAPI 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. 核心适配过程从源码修改到跑通 DoH4.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; FutureMapString, 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 或者 3UDP 丢包在公网很常见一次失败不代表服务器不可达4.3 DoH 请求的接入与证书校验处理上面那个例子用的是传统 DNS over UDP虽然指定了自定义服务器但流量还是明文的遇到路由器劫持依然可能被干扰。真正防劫持要靠 DoHdns_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里FutureDoHResponse lookup(Uri uri, Listint 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 MapString, DnsCacheEntry _cache {}; FutureListInternetAddress 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/目录下的文件定位会快很多。