一、Dio + HTTPDNS 底层原理
传统 DNS 流程
浏览器/App 发起请求 → 系统 DNS 递归查询(Local DNS → 根 → 顶级 → 权威) → 拿到 IP → 建立 TCP 连接
这个过程的问题:Local DNS 可能被劫持、污染、解析慢、缓存过期策略不可控。
HTTPDNS 的思路
绕过系统 DNS,改为通过 HTTP 协议直接向 HTTPDNS 服务商(阿里云、腾讯云等)发请求获取 IP。本质就是把 DNS 解析从 UDP 协议换成了 HTTP 协议,走自己的服务器,不经过运营商 Local DNS。
Dio 直接用 IP 请求的原理
HTTP 协议本身不关心你请求的是域名还是 IP,TCP 连接只认 IP + 端口。域名只是给人看的,最终都要变成 IP。所以 Dio 当然可以直接用 IP 发请求,底层 socket 连接没有区别。
关键在于 Host Header。HTTP/1.1 规范要求必须带 Host 头,服务端(比如 Nginx)靠 Host 来判断请求属于哪个虚拟主机/站点。用 IP 请求时如果不手动设置 Host,服务端就不知道你要访问哪个站点,会返回默认站点或 404。
HTTPS 场景的核心矛盾
TLS 握手阶段有两个地方依赖域名:
-
SNI(Server Name Indication):TLS 握手的 ClientHello 阶段,客户端会带上目标域名,让服务端知道该返回哪张证书(一个 IP 上可能部署多个 HTTPS 站点)。Dart 的 HttpClient 默认用请求 URL 中的 host 作为 SNI,如果你传的是 IP,SNI 就变成了 IP,服务端可能返回错误的证书或直接拒绝连接。
-
证书校验:客户端拿到服务端证书后,会检查证书中的 CN(Common Name)或 SAN(Subject Alternative Name)是否匹配请求的 host。你用 IP 请求,但证书上写的是域名,自然校验失败。
这就是为什么 HTTPS + HTTPDNS 比 HTTP 场景复杂得多——不是 HTTP 层面的问题,而是 TLS 层面的问题。
总结
- HTTP 层面:把域名换成 IP,补上 Host 头就行,没有任何障碍
- TLS 层面:SNI 和证书校验都依赖域名,需要额外处理,Dart 层对 SNI 的控制力有限,复杂场景可能需要走原生层
二、Flutter 中 SNI 的处理
问题根源
Dart 的 HttpClient 底层用的是 dart:io 中的 SecureSocket,在 TLS 握手时会自动把 URL 中的 host 作为 SNI 发出去。你用 IP 替换了域名后,SNI 就变成了一个 IP 地址,服务端收到一个 IP 作为 SNI,大概率返回默认证书或直接断开连接。
Dart 层能做什么
SecureSocket.connect 有一个 host 参数,如果传的是 String 类型,Dart 会把它同时用作 SNI。但问题是 Dio 底层封装了 HttpClient,你没有直接操控 SecureSocket 的机会。
Dart 2.17+ 的 HttpClient 支持通过 ConnectionTask 自定义连接过程,理论上可以在这里插入自定义的 SecureSocket.connect 调用,手动指定 SNI 为原始域名、连接目标为 IP。但这个 API 比较底层,使用起来很脆弱,而且 Dio 的 HttpClientAdapter 并没有完全暴露这个能力。
实际业界怎么做
主流方案是走原生层处理,原因很简单:iOS 的 NSURLSession 和 Android 的 OkHttp 对 SNI 的控制能力远比 Dart 强。
-
iOS 端:通过
URLProtocol拦截请求,或者直接用CFNetwork层的 API,可以在创建 TLS 连接时单独指定 SNI。阿里云的 HTTPDNS SDK 就是这么做的,它 hook 了网络层,在 TLS 握手前把 SNI 设回原始域名。 -
Android 端:OkHttp 提供了
Dns接口,可以自定义域名解析逻辑。直接实现这个接口返回 HTTPDNS 解析的 IP,OkHttp 内部会自动处理好 SNI——因为它知道原始域名是什么,只是把 DNS 解析这一步交给了你。这是最优雅的方案。
落地路径
-
如果服务端只有一个站点(单域名单 IP):SNI 不重要,服务端只有一张证书,不需要靠 SNI 选证书。只需要处理证书校验就行,Dart 层的
badCertificateCallback就够了。 -
如果服务端多域名共享 IP:必须正确设置 SNI。这时候建议放弃在 Dart 层做 HTTPDNS,改为在 iOS/Android 原生层各自实现,通过 MethodChannel 或直接用原生的 HTTPDNS SDK,让原生网络库自己处理 SNI。
-
另一个思路:不替换 URL 中的域名,而是在原生层 hook DNS 解析过程。这样 Dart 层的代码完全不用改,TLS 握手时 SNI 自然是正确的域名,只是 DNS 解析走了 HTTPDNS 通道。iOS 上可以通过
CFNetwork的 DNS hook,Android 上就是 OkHttp 的Dns接口。
一句话总结
Dart 层对 SNI 的控制力不够,真正需要 HTTPDNS 的生产环境,在原生层 hook DNS 解析是最干净的方案——上层代码零改动,SNI、证书、Cookie 全部自然正确。
三、URLProtocol 应用场景与原理
URLProtocol 是什么
URLProtocol(NSURLProtocol)是 iOS/macOS 网络栈中的一个抽象类,位于 URLSession(NSURLSession)和底层网络之间的拦截层。你可以理解为一个网络请求的中间件/钩子,它能在请求发出前和响应返回后做任意处理。
在网络栈中的位置
App 代码发起请求
↓
URLSession
↓
URLProtocol ← 你可以在这里拦截
↓
CFNetwork(底层 C 网络库)
↓
BSD Socket
↓
内核网络栈
所有通过 URLSession 发出的请求,都会经过已注册的 URLProtocol 子类。系统会依次问每个注册的 Protocol:"这个请求你能不能处理?"第一个说"能"的就接管这个请求。
核心工作机制
四个关键方法构成了整个生命周期:
canInit(with request:):系统问你"这个请求你要不要拦截",返回 true 就接管,false 就跳过交给下一个 Protocol 或默认处理。canonicalRequest(for:):对请求做标准化处理,比如统一 URL 格式。一般原样返回。startLoading():真正干活的地方。你拿到了原始请求,可以修改它、重定向它、自己发一个新请求、从本地缓存返回数据,随你怎么处理。处理完后通过client回调把响应和数据传回给上层。stopLoading():上层取消了请求,你在这里做清理。
典型应用场景
1. HTTPDNS
最经典的场景。在 startLoading 里拿到原始请求的域名,调 HTTPDNS 解析成 IP,构造一个新请求(URL 用 IP,Header 带原始 Host),自己用底层 API(如 CFNetwork)发出去,拿到响应后回传给上层。上层完全无感知。
2. 网络层 Mock / 测试
拦截特定 URL 的请求,不真正发到网络,直接返回本地构造的假数据。UI 测试、单元测试中很常用,不需要依赖真实后端。
3. 全局网络监控 / 日志
拦截所有请求,记录 URL、耗时、状态码、数据大小等,做 APM 性能监控。实际请求还是正常发出去,只是在中间插了一层观察者。
4. 自定义缓存策略
系统的 URLCache 策略不满足需求时,可以自己拦截请求,先查本地缓存,命中就直接返回,没命中再走网络。
5. 自定义协议支持
URLProtocol 名字里有"Protocol",它的本意就是支持自定义协议。比如你定义一个 myapp:// 协议,拦截后从 App 内置资源或数据库里加载内容,WebView 加载本地资源就经常这么做。
重要限制和陷阱
- 递归问题:你在
startLoading里用URLSession发新请求,这个新请求又会被你自己的 Protocol 拦截,形成无限递归。解决方法是用setProperty给已处理过的请求打标记,canInit里检查到标记就返回 false。 WKWebView不走URLProtocol:WKWebView运行在独立进程,网络请求不经过 App 进程的URLSession,所以默认不会被URLProtocol拦截。Apple 后来提供了WKURLSchemeHandler,但只支持自定义 scheme,不能拦截 http/https。要拦截WKWebView的 http 请求需要用私有 API(registerSchemeForCustomProtocol:),有审核风险。- 对
URLSession配置敏感:通过URLSessionConfiguration.protocolClasses注册只对该 configuration 创建的 session 生效;通过URLProtocol.registerClass注册则对URLSession.shared和默认配置生效。第三方库如果自己创建了URLSessionConfiguration,你全局注册的 Protocol 不会生效。 - 线程问题:
startLoading和stopLoading可能在不同线程被调用,内部状态管理需要注意线程安全。
一句话本质
URLProtocol 就是 Apple 网络栈预留的 AOP 切面,让你能在不改动业务代码的前提下,对所有(经过 URLSession 的)网络请求做拦截、修改、观察或替换。
四、URLProtocol 与 Dio Interceptor 的对比
相同点
- 都是 AOP 思想:不改业务代码,在请求发出前/响应返回后插入自定义逻辑
- 都能修改请求:改 URL、改 Header、改参数
- 都能短路返回:不走网络,直接返回自定义响应(Mock 数据、缓存等)
- 都有生命周期:请求前、响应后、错误处理,对应的回调结构类似
本质区别:所处层级不同
Dio Interceptor ← 应用层(Dart/HTTP 层面)
↓
Dio HttpClientAdapter
↓
dart:io HttpClient
↓
---------- 跨进程/跨语言边界 ----------
↓
URLProtocol ← 系统网络栈层面(Native/TLS 之上)
↓
CFNetwork
↓
TLS / TCP
Dio 拦截器工作在 HTTP 已经组装好之后,它看到的是一个成型的 HTTP 请求对象。它能改 Header、改 Body、改 URL,但它改完之后,下面的 HttpClient 还是会走系统 DNS 解析、TLS 握手这些流程,它管不到。
URLProtocol 工作在系统网络栈内部,它拦截的时候请求还没真正发出去,你可以接管整个连接过程——用谁的 IP、怎么做 TLS 握手、SNI 填什么,全由你决定。
拦截范围对比
| Dio Interceptor | URLProtocol | |
|---|---|---|
| 拦截范围 | 只拦截通过这个 Dio 实例发出的请求 | 拦截进程内所有经过 URLSession 的请求 |
| 第三方库的请求 | 管不到 | 能拦截(只要走 URLSession) |
| WebView 请求 | 无关 | UIWebView 能拦截,WKWebView 不行 |
| 控制 TLS | 不能 | 能(可以自己建 TLS 连接) |
| 控制 DNS | 不能(只能改 URL 里的 host) | 能(可以自己做解析和连接) |
HTTPDNS 场景的差异
- Dio 拦截器做 HTTPDNS:把 URL 里的域名换成 IP,加个 Host Header。但 TLS 握手时 SNI 还是 IP,你控制不了,因为那是下面
dart:io层的事。 - URLProtocol 做 HTTPDNS:拦截请求后,自己用
CFNetwork建立连接,可以指定连接的 IP、指定 SNI 为原始域名、自己控制证书校验逻辑。整个网络连接过程都在你手里。
一句话总结
Dio 拦截器是应用层的 HTTP 中间件,只能操作 HTTP 报文本身;URLProtocol 是系统网络栈的钩子,能接管从 DNS 解析到 TLS 握手的整个连接过程。能力越底层,控制力越强,但复杂度也越高。