iOS 网络请求怎么调试?ATS 报错与 Windows 抓包 HTTPS 网络请求
最后更新
iOS 网络请求出问题,先看错误码:报 -1022 是 ATS 拦了 http 请求,优先把接口改成 https,改不了就只给那个域名配 NSExceptionDomains;抓包看不到 HTTPS 内容,多半是代理证书没在「证书信任设置」里开启完全信任,或者 App 自己做了证书锁定。ATS 和错误码以 Apple 官方文档和 iOS SDK 头文件为准,截至 2026 年 10 月。
下面依次讲怎么看懂 ATS 报错和 NSURLErrorDomain 错误码,在 Windows 或 Linux 上用抓包代理看 HTTPS 请求,在 iPhone 上安装并信任代理证书,再用 Logger 和 URLSessionTaskMetrics 在 App 里记录每个请求的耗时。
你需要一台已经能用 KXBuild 安装调试的 iPhone,步骤见设备准备。本文的抓包方法只用于调试你自己开发的 App。
iOS 网络请求失败先看哪个错误码?
URLSession 请求失败时,错误的 domain 是 NSURLErrorDomain,Swift 里是 URLError。先看错误码,再跳到对应的小节。下表的数值取自 iOS SDK 的 NSURLError.h。
| 错误码 | Swift 名称 | 含义 | 先查什么 |
|---|---|---|---|
| -999 | .cancelled |
请求被取消 | 代码里是否 cancel 了任务;证书锁定校验失败时也会走到取消 |
| -1001 | .timedOut |
请求超时 | 服务器响应慢、代理没开或不通、timeoutInterval 太短 |
| -1003 | .cannotFindHost |
域名解析不到 | 域名拼写、手机当前网络的 DNS、内网域名在外网解析不到 |
| -1004 | .cannotConnectToHost |
连不上服务器端口 | 服务没启动、端口不对、防火墙拦截、填了代理但电脑上代理没运行 |
| -1005 | .networkConnectionLost |
连接中途断开 | 服务器提前关闭连接、网络切换 |
| -1009 | .notConnectedToInternet |
设备没有网络 | 飞行模式、蜂窝数据里关闭了这个 App 的网络权限 |
| -1022 | .appTransportSecurityRequiresSecureConnection |
被 ATS 拦截 | 地址是 http,或服务器不满足 ATS 要求,见下一节 |
| -1200 | .secureConnectionFailed |
TLS 握手失败 | 服务器 TLS 版本过低、加密套件不符合 ATS,或代理证书问题 |
| -1201 | .serverCertificateHasBadDate |
服务器证书已过期 | 续期服务器证书,顺便看看手机日期是否正确 |
| -1202 | .serverCertificateUntrusted |
服务器证书不受信任 | 自签名证书、证书链不完整、抓包时代理证书没开完全信任 |
| -1203 | .serverCertificateHasUnknownRoot |
证书的根证书未知 | 根证书不在系统信任列表里 |
| -1204 | .serverCertificateNotYetValid |
证书尚未生效 | 证书生效时间晚于当前时间 |
| -1206 | .clientCertificateRequired |
服务器要求客户端证书 | 服务端开了双向认证 |
在代码里拿到错误码的写法见后文的完整示例。
ATS 报错怎么解决?
报错原文与含义
App 传输安全(ATS)要求 URLSession 发出的 HTTP 请求都走 HTTPS,并在 TLS 的默认校验之外做额外检查。请求被拦截时,调试控制台会打印这段话,这是 Apple 文档里的原文。
1App Transport Security has blocked a cleartext HTTP (http://) resource
2load since it is insecure. Temporary exceptions can be configured via
3your app's Info.plist file.
代码里收到的错误是 NSURLErrorDomain 的 -1022,localizedDescription 是这一句。
1The resource could not be loaded because the App Transport Security policy requires the use of a secure connection.
除了 http 地址,服务器不满足下面任意一条,ATS 也会拦截。按 Apple 的《Preventing Insecure Network Connections》,ATS 的要求是:
- TLS 1.2 或更高。
- 证书用至少 2048 位的 RSA 密钥或至少 256 位的 ECC 密钥签名。
- 证书使用 SHA-256 或更强的 SHA-2 摘要。
- 使用 AES-128 或 AES-256 加密,支持 ECDHE 前向保密。
ATS 只管 URLSession 这一套 URL 加载系统。直接用 Network 框架或 CFNetwork 这类底层接口时,ATS 不介入,安全由你自己负责。
iOS 17 起 IP 地址也受限
开发时常见的情况是 App 请求电脑上的测试服务,例如 http://192.168.1.20:3000。按 Apple 的 NSAllowsLocalNetworking 文档,iOS 10 到 iOS 16 默认允许直接连 IP 地址、.local 域名和不带点的主机名。
iOS 17 起,ATS 默认不再允许连 IP 地址,要把 IP 地址或 CIDR 网段加进 NSExceptionDomains。所以同一个测试包,在 iOS 16 的手机上能连,在 iOS 17 以上就报 -1022。
例外的正确写法
先尽量把服务器改成 https,这是 Apple 文档的首选建议。改不了时,只给需要的域名开例外,不要整体关闭 ATS。
1<key>NSAppTransportSecurity</key>
2<dict>
3 <key>NSExceptionDomains</key>
4 <dict>
5 <key>legacy.example.com</key>
6 <dict>
7 <key>NSIncludesSubdomains</key>
8 <true/>
9 <key>NSExceptionAllowsInsecureHTTPLoads</key>
10 <true/>
11 </dict>
12 </dict>
13</dict>
NSExceptionDomains 的域名按这几条规则写:
- 用小写,例如
example.com。 - 不带端口号,不写
example.com:443。 - 不用通配符,不写
*.example.com,要包含子域名就设NSIncludesSubdomains为 YES。 - iOS 17 起可以写 IPv4、IPv6 地址或 CIDR 网段,例如
192.168.42.63。 - 例外按字面匹配。为域名开了例外,App 用 IP 访问同一台服务器时例外不生效,反过来也一样。
服务器只是 TLS 版本低,不需要 http,就用 NSExceptionMinimumTLSVersion 只降低这一项,例如设为 TLSv1.1,其他检查照常保留。
各种写法对比如下。
| 写法 | 影响范围 | 审核时 |
|---|---|---|
| 不加任何例外,服务器全用 https | 无 | 不需要说明 |
NSExceptionDomains + NSExceptionAllowsInsecureHTTPLoads |
只有列出的域名 | 需要说明理由 |
NSExceptionDomains + NSExceptionMinimumTLSVersion |
只有列出的域名,只放宽 TLS 版本 | 需要说明理由 |
NSAllowsArbitraryLoadsInWebContent |
只放开 WKWebView 里的网页 | 需要说明理由 |
NSAllowsArbitraryLoads |
整个 App 的所有连接 | 需要说明理由,可能触发额外审核 |
Apple 文档写明,上面这几类例外提交 App Store 时都要提供理由,并且可能触发额外审核。文档举的可以考虑的理由包括:要连接的服务器属于别人、对方不支持安全连接;要连接无法升级的硬件设备。理由写在 App Store Connect 的审核备注里。
只在 Debug 包里放开测试服务器
连电脑上测试服务用的 IP 例外,不应该进正式包。做法是给 Debug 配置单独用一份 Info.plist。
- 复制一份
Info.plist为Info-Debug.plist,只在这份里加测试服务器的 IP 例外。 - 让 Debug 配置使用这份文件。
1kxbuild project add-setting -k INFOPLIST_FILE -v MyApp/Info-Debug.plist -t MyApp -c Debug -P .\MyApp.xcodeproj
路径按你工程里 Info.plist 的实际位置改。两份文件在 VS Code 里双击都能用属性列表编辑器编辑,其他键要保持一致。按配置区分设置的更多做法见 iOS 多环境打包。
怎么用抓包代理看 HTTPS 请求?
原理
抓包代理在电脑上运行,iPhone 把 Wi-Fi 的 HTTP 代理指向电脑,请求就都经过代理。HTTPS 请求要看到内容,代理要用自己的根证书临时签发服务器证书,所以手机上必须安装这个根证书并信任它。按 Apple 的 ATS 文档,手动安装并被用户信任的根证书可以作为信任锚点,所以不用改 App 的代码。
选一个代理工具
下面三个都能在 Windows 上用,mitmproxy 也能在 Linux 上用。只列官方文档里的通用设置。
| 工具 | 默认端口 | 允许手机连接 | 手机上下载证书 |
|---|---|---|---|
| Fiddler Everywhere | 8866 | Settings → Connections → Allow remote devices to connect | Safari 打开 http://电脑IP:8866,下载根证书 |
| Charles | 在 Proxy Settings 里查看 | 手机按 Charles 的端口设代理 | Safari 打开 https://chls.pro/ssl |
| mitmproxy | 8080 | 手机按电脑 IP 和端口设代理 | Safari 打开 mitm.it,点 iOS 图标 |
Charles 还要在 Proxy Settings 的 SSL 页里,把要解密的主机名加进 SSL Proxying 列表,否则 HTTPS 请求只显示一条加密的连接。菜单名称随版本可能变化,以各工具官方文档中「iOS 设备抓包」和「证书」两部分为准。
Windows 防火墙默认会拦手机发来的连接。用管理员 PowerShell 放行代理端口,下面以 8866 为例。
1New-NetFirewallRule -DisplayName "Debug Proxy 8866" -Direction Inbound -Protocol TCP -LocalPort 8866 -Action Allow
查电脑的局域网 IP,Windows 上用 ipconfig,看当前网卡的 IPv4 地址;Linux 上用 ip addr。
iPhone 设置 Wi-Fi 手动代理
手机和电脑连同一个 Wi-Fi,然后:
- 打开「设置 → 无线局域网」,点已连接网络右侧的 ⓘ。
- 滑到底部,点「配置代理」,选「手动」。
- 服务器填电脑的 IP,端口填代理工具的端口,点「存储」。
- 用 Safari 打开任意一个 http 网页,代理工具里出现请求,说明代理通了。
Safari 打不开网页、代理工具里也没有请求,先查防火墙和 IP。这时你的 App 报的通常是 -1004 或 -1001。
安装证书并开启完全信任
- 在 iPhone 的 Safari 里打开上表中的证书地址,下载根证书,允许下载描述文件。
- 打开「设置」,按提示安装刚下载的描述文件。装好后可以在「设置 → 通用 → VPN 与设备管理」里看到它。
- 打开「设置 → 通用 → 关于本机 → 证书信任设置」,在「针对根证书启用完全信任」下打开刚装的证书。
第 3 步最容易漏。按 Apple 支持文档,手动安装的证书不会自动用于 SSL 信任,必须在这里手动开启;这一栏没出现,说明证书还没装上。漏了这一步,App 里的 HTTPS 请求会报 -1202 或 -1200,Safari 也会提示证书不受信任。
调试完恢复
调试结束马上做两件事,否则手机上所有 App 的流量都会继续经过代理,代理一关就全部断网。
- 「设置 → 无线局域网 → ⓘ → 配置代理」改回「关闭」。
- 在「设置 → 通用 → VPN 与设备管理」里删除代理的描述文件。
部分 Apple 自己的服务(例如 App Store)使用证书锁定,开着代理时可能无法使用,这是正常现象,关掉代理即可。Fiddler 的文档建议在代理工具里把 Apple 的域名加入不走代理的排除列表。
App 做了证书锁定怎么抓包?
证书锁定(certificate pinning)是 App 只接受特定的服务器证书或 CA。代理签发的证书不在名单里,请求就会失败。这是锁定在正常工作,不是代理的问题。
对你自己的 App,正确的做法是让锁定只在 Release 包里生效,Debug 包关掉,抓包就能照常进行。
- 在 Info.plist 里用 NSPinnedDomains 锁定的,只写在 Release 用的 Info.plist 里,Debug 配置用上一节说的
Info-Debug.plist。 - 在代码里锁定的,用编译条件做开关。Xcode 模板的 Debug 配置默认定义了
DEBUG。
1import CryptoKit
2import Foundation
3import Security
4
5enum NetworkConfig {
6 #if DEBUG
7 static let pinningEnabled = false
8 #else
9 static let pinningEnabled = true
10 #endif
11
12 static let pinnedHost = "api.example.com"
13 static let pinnedCertificateHashes: Set<String> = [
14 "替换为你的服务器证书 SHA-256(Base64)"
15 ]
16}
17
18final class PinningDelegate: NSObject, URLSessionDelegate {
19 func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async
20 -> (URLSession.AuthChallengeDisposition, URLCredential?) {
21 guard NetworkConfig.pinningEnabled,
22 challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
23 challenge.protectionSpace.host == NetworkConfig.pinnedHost,
24 let trust = challenge.protectionSpace.serverTrust else {
25 return (.performDefaultHandling, nil)
26 }
27 guard SecTrustEvaluateWithError(trust, nil),
28 let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
29 let leaf = chain.first else {
30 return (.cancelAuthenticationChallenge, nil)
31 }
32 let der = SecCertificateCopyData(leaf) as Data
33 let hash = Data(SHA256.hash(data: der)).base64EncodedString()
34 guard NetworkConfig.pinnedCertificateHashes.contains(hash) else {
35 return (.cancelAuthenticationChallenge, nil)
36 }
37 return (.useCredential, URLCredential(trust: trust))
38 }
39}
pinningEnabled 为 false 时返回 .performDefaultHandling,交给系统的默认校验,这时你在手机上信任的代理证书就能生效。锁定开启且证书不在名单里时,请求被取消,App 收到 -999。所以 Release 包抓不到包、报 -999,先确认是不是锁定在起作用。
第三方 SDK 自带锁定的,它的请求在代理下同样会失败,看 SDK 文档有没有调试开关。不要用任何方法去改动别人 App 的防护。
怎么在 App 里记录每个请求和耗时?
有些问题代理看不到,例如请求根本没发出去、DNS 慢、连接复用。这时在 App 里记录更直接。
用 Logger 打印请求,隐私脱敏
接口路径、状态码、耗时这类不敏感的内容标 privacy: .public;查询参数、请求头、请求体里可能有手机号和令牌,保持默认或标 .private,不要整个打出来。
1import Foundation
2import os
3
4extension Logger {
5 static let network = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.example.myapp", category: "network")
6}
不要打印 Authorization 请求头和完整的响应体。确实要看响应内容,只在 #if DEBUG 里临时打印,用完删掉。Logger 的级别和隐私选项见 iOS 真机日志怎么看。
用 URLSessionTaskMetrics 记录耗时
URLSessionTaskMetrics 记录一个任务从创建到完成的总时长和重定向次数,transactionMetrics 里每一项对应一次请求和响应,带 DNS、建连、TLS 握手、首字节、下载各阶段的起止时间。某个阶段没有发生(例如复用了连接),对应的时间就是 nil。
下面是一个完整的示例:用任务级的 delegate 收集耗时,同时把 URLError 的错误码翻译成中文写进日志。
1import Foundation
2import os
3
4final class RequestMetricsLogger: NSObject, URLSessionTaskDelegate {
5 func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) {
6 let path = task.originalRequest?.url?.path ?? "-"
7 let totalMs = metrics.taskInterval.duration * 1000
8 Logger.network.notice("\(path, privacy: .public) 总耗时 \(totalMs, format: .fixed(precision: 0)) ms,重定向 \(metrics.redirectCount) 次")
9
10 for t in metrics.transactionMetrics {
11 let dns = Self.ms(t.domainLookupStartDate, t.domainLookupEndDate)
12 let connect = Self.ms(t.connectStartDate, t.connectEndDate)
13 let tls = Self.ms(t.secureConnectionStartDate, t.secureConnectionEndDate)
14 let firstByte = Self.ms(t.requestStartDate, t.responseStartDate)
15 let download = Self.ms(t.responseStartDate, t.responseEndDate)
16 let proto = t.networkProtocolName ?? "-"
17 Logger.network.notice("""
18 \(path, privacy: .public) \(proto, privacy: .public) 复用连接=\(t.isReusedConnection) 代理=\(t.isProxyConnection) \
19 DNS \(dns, privacy: .public) 连接 \(connect, privacy: .public) TLS \(tls, privacy: .public) \
20 首字节 \(firstByte, privacy: .public) 下载 \(download, privacy: .public)
21 """)
22 }
23 }
24
25 private static func ms(_ start: Date?, _ end: Date?) -> String {
26 guard let start, let end else { return "-" }
27 return String(format: "%.0fms", end.timeIntervalSince(start) * 1000)
28 }
29}
30
31struct APIClient {
32 let session: URLSession = .shared
33 let metricsLogger = RequestMetricsLogger()
34
35 func get(_ url: URL) async throws -> Data {
36 var request = URLRequest(url: url)
37 request.timeoutInterval = 15
38 Logger.network.info("GET \(url.path, privacy: .public) query=\(url.query ?? "", privacy: .private)")
39 do {
40 let (data, response) = try await session.data(for: request, delegate: metricsLogger)
41 let status = (response as? HTTPURLResponse)?.statusCode ?? -1
42 Logger.network.notice("GET \(url.path, privacy: .public) -> \(status) \(data.count) 字节")
43 return data
44 } catch let error as URLError {
45 Logger.network.error("GET \(url.path, privacy: .public) 失败 \(error.code.rawValue) \(Self.explain(error), privacy: .public)")
46 throw error
47 }
48 }
49
50 static func explain(_ error: URLError) -> String {
51 switch error.code {
52 case .appTransportSecurityRequiresSecureConnection: return "被 ATS 拦截,地址不是 https 或服务器不满足 ATS 要求"
53 case .timedOut: return "请求超时"
54 case .cannotFindHost: return "域名解析不到"
55 case .cannotConnectToHost: return "连不上服务器端口"
56 case .networkConnectionLost: return "连接中途断开"
57 case .notConnectedToInternet: return "设备没有网络"
58 case .secureConnectionFailed: return "TLS 握手失败"
59 case .serverCertificateHasBadDate: return "服务器证书已过期"
60 case .serverCertificateUntrusted: return "服务器证书不受信任"
61 case .serverCertificateHasUnknownRoot: return "服务器证书的根证书未知"
62 case .serverCertificateNotYetValid: return "服务器证书尚未生效"
63 case .cancelled: return "请求被取消"
64 default: return error.localizedDescription
65 }
66 }
67}
这段代码在 Swift 6 语言模式、iOS 16 部署目标下编译通过。data(for:delegate:) 从 iOS 15 起可用,传入的 delegate 只对这一个任务生效,不影响 URLSession.shared 的其他请求。
看日志时这样判断:
- DNS 很长,是域名解析慢,换网络对比一下。
- 连接和 TLS 都是
-,并且复用连接为 true,说明复用了已有连接,这是正常的。 - 首字节很长、下载很短,时间花在服务器处理上,去查服务端。
- 代理为 true,说明这个请求经过了代理。抓包时用它确认 App 的流量确实走了代理。
在 KXBuild Console 和 kxdevice 里看
- 调试时。按 F5 运行后,Logger 的输出显示在 VS Code「输出 → KXBuild Console」。
- 不调试时。从主屏幕打开 App,在电脑上按子系统过滤。
1kxdevice ostrace --subsystem com.example.myapp
只看出错的请求,加上级别过滤。
1kxdevice ostrace --subsystem com.example.myapp --levels error,fault
导出到文件、按分类过滤的方法见 iOS 真机日志怎么看。ATS 拦截那段英文提示在调试时出现在 KXBuild Console 里。
用 kxdevice 记录请求和网络包
不想改手机的代理设置时,KXBuild 还有两个命令可以用。
- 记录 HTTP 请求为 HAR。先用
kxdevice apps ps --apps找到 App 的 pid,再执行下面的命令,Ctrl-C 停止。导出的 .har 用浏览器开发者工具打开,看哪个接口最慢、有没有重复请求。
1kxdevice instruments har --pid 1234 -o .\myapp.har
- 抓网络包。
kxdevice pcap把手机的网络包存成 libpcap 文件,可以按进程名过滤,用 Wireshark 打开。HTTPS 的内容是加密的,这种方式适合看连接有没有建立、连的是哪个 IP、TLS 握手在哪一步失败。
1kxdevice pcap .\myapp.pcap --proc MyApp
更多性能相关的命令见性能分析。
按错误码排查
| 现象 | 原因 | 处理 |
|---|---|---|
| -1022,控制台提示 cleartext HTTP 被拦截 | 请求地址是 http | 接口改 https;改不了的域名加 NSExceptionDomains |
| 同一个测试包,iOS 16 能连电脑上的测试服务,iOS 17 以上报 -1022 | iOS 17 起 ATS 默认不允许直接连 IP 地址 | 把 IP 加进 Debug 用的 Info.plist 的 NSExceptionDomains |
加了 NSAllowsArbitraryLoads,某个域名还是被拦 |
这个域名在 NSExceptionDomains 里有单独的字典,全局键对它不生效 |
在这个域名的字典里加需要的例外 |
| -1200,服务器是 https | 服务器只支持 TLS 1.0 或 1.1,或加密套件不符合 ATS | 升级服务器配置;临时用 NSExceptionMinimumTLSVersion |
| 开代理后所有 HTTPS 请求报 -1202 或 -1200 | 代理证书没开完全信任 | 「设置 → 通用 → 关于本机 → 证书信任设置」里打开 |
| 开代理后请求报 -1004 或 -1001 | 电脑上代理没运行、IP 或端口填错、防火墙拦截 | 用 Safari 打开 http 网页验证代理是否通 |
| 证书信任了,自己的接口还是失败,报 -999 | App 的证书锁定生效了 | Debug 配置关闭锁定开关 |
| -1003,内网域名在公司能用、在外面不能用 | 内网域名在外网解析不到 | 换成公网域名,或连回内网再测 |
| -1009,手机上其他 App 能上网 | 这个 App 的网络权限被关掉了,例如在「设置 → 蜂窝网络」里关闭了它 | 在系统设置里给这个 App 打开网络权限 |
| 关掉代理后手机所有 App 都上不了网 | Wi-Fi 的代理设置还指向电脑 | 「配置代理」改回「关闭」 |
表里没有的错误码,到 Foundation 的 NSURLError.h 头文件里按数值查对应的常量名,再用上面的方法确认 iOS 网络请求停在了哪一步。
常见问题
NSURLErrorDomain -1022 是什么意思?
-1022 是 NSURLErrorAppTransportSecurityRequiresSecureConnection,表示请求被 ATS 拦截,通常是地址用了 http。把接口改成 https;改不了时,只给那个域名在 Info.plist 的 NSExceptionDomains 里加 NSExceptionAllowsInsecureHTTPLoads。
NSAllowsArbitraryLoads 和 NSExceptionDomains 用哪个?
优先用 NSExceptionDomains,只放开需要的域名,App 的其他连接仍受 ATS 保护。NSAllowsArbitraryLoads 会关掉整个 App 的 ATS,按 Apple 文档两者提交审核时都要说明理由,整体关闭的理由更难写清。
抓包时 iPhone 上的 HTTPS 请求全部失败怎么办?
先确认代理的根证书已经安装,再到「设置 → 通用 → 关于本机 → 证书信任设置」里为它打开完全信任,这一步最容易漏。证书信任了只有自己的接口还失败,就检查 App 是否做了证书锁定,在 Debug 配置里关掉它。
为什么 iOS 17 上连不上电脑上的测试服务器?
按 Apple 文档,iOS 17 起 ATS 默认不再允许直接连接 IP 地址。把测试服务器的 IP 地址加进 NSExceptionDomains,建议只放在 Debug 配置用的 Info.plist 里,不要带进正式包。
不用抓包工具,怎么看 iPhone 上 App 的网络请求?
在 App 里用 Logger 打印请求路径、状态码和错误码,用 URLSessionTaskMetrics 记录各阶段耗时,调试时在 KXBuild Console 里看,不调试时用 kxdevice ostrace 按子系统过滤。也可以用 kxdevice instruments har 把请求记录成 HAR 文件。