
简介面向 iOS 初学者的 HTTP 请求拦截精简 Demo基于 NSURLProtocol 机制实现适合想理解网络层拦截原理、调试接口请求或进行基础安全测试的开发者。资源共 114 个文件压缩包约 135KB包含 Objective-C 源码.h/.m、storyboard 界面、plist 配置以及完整 Xcode 工程文件目录结构清晰。已有 679 人学习。通过自定义 NSURLProtocol 子类并在分类的 load 方法中自动注册协议类URL 加载系统会在请求发出时自动交由该协议对象处理从而实现对 HTTP 请求的拦截与后续处理。精简后的代码去除了冗余逻辑便于逐行阅读读者可快速掌握注册、拦截、回调等核心步骤并在此基础上扩展为请求日志、参数篡改或 Mock 数据等工具适合作为 iOS 网络层学习的入门实战素材。1. 拦截http请求iOS安全调试的第一块拼图小白也能落地的切入方式接手一个老项目最让人心里没底的不是代码乱而是不知道它启动之后到底向外发了什么请求。登录接口传没传明文密码、请求头里有没有多余的信息、某个SDK是不是悄悄上报了数据这些在没看到真实流量之前全是玄学。拦截http请求是iOS安全分析和日常调试最直接的入口把App发出的每一个请求看清楚才能判断哪里有问题、哪里需要加固。标题里强调“为小白用户定制的精简版本”意味着这篇文章不讲越狱、不搞动态注入只用系统自带的网络扩展点搭一个最小拦截模块能看懂能复现。适合iOS开发新手、测试同学以及刚接触客户端安全分析的人。2. 原理与选型拦截http请求的三种主流做法为什么NSURLProtocol最适合入门2.1 URL Loading SystemiOS网络栈里那个天然的拦截点要理解拦截http请求先得知道请求在iOS里是怎么走的。一个网络请求从构造到发送会经过系统的一个核心组件叫URL Loading System。它负责接管NSURLRequest、分配协议处理器、管理缓存和cookie最终把数据交给网络层发出去。这个系统在设计上留了一个扩展点在有请求进来时先问一遍“有没有自定义的协议处理器想接管”。这个扩展点就是NSURLProtocol。也就是说只要App里用的是NSURLSession、NSURLConnection或者基于它们封装的网络库发出的请求都会经过URL Loading System都会被NSURLProtocol这个口子看到。这是拦截http请求最稳定的位置因为它的切入点在业务代码之外不需要改动任何一行网络调用逻辑。往深了说这个机制允许开发者把请求在真正发出去之前截住做修改、记录、甚至直接造假响应返回给调用方安全测试里经常拿它来做流量审计。需要明确的是这里说的“拦截”是正常的开发与调试手段目的是看清自己的App在做什么在客户端安全分析里属于很基础的入门操作。理解了这个大前提再往下看选型就顺了。2.2 三种拦截方案对比NSURLProtocol、自定义Session、网络库内置实际项目里想拿到完整的请求和响应常见做法有三条路。第一条就是NSURLProtocol它的优势是拦截范围广、不需要改业务代码适合快速搭一个全局的调试或审计模块。第二条是自定义NSURLSession在Session的delegate里统一处理回调能拿到流量但要求项目里所有网络请求都走同一个Session实例老项目很难做到。第三条是依赖网络库内置的拦截器比如某些第三方网络库自带的拦截器可以拿到请求过程但绑定特定库换库就失效对新手来说还得先熟悉它的API。把三者的关键差异整理成一张表方便对着自己的项目选方案侵入性覆盖范围上手难度适合场景NSURLProtocol低注册即可全局生效所有走URL Loading System的请求中核心方法不多但要理解回调全局日志、安全审计、请求改写自定义NSURLSession高需替换所有网络入口仅限使用该Session的请求低逻辑直观新项目、网络层统一收口网络库内置拦截器看具体库仅限该库发起的请求低调用简单项目已统一使用某网络库从表格能看出来NSURLProtocol最明显的短板是“覆盖不了所有场景”比如WKWebView的请求就不走URL Loading System。但它对小白的价值在于花一个下午跑通最小模块就能看到全局大部分流量这个投入产出比是三种方案里最高的。自定义Session要动所有网络调用的入口老项目里是伤筋动骨的事网络库内置拦截器虽然简单但很多项目根本没用第三方网络库。所以从“快速上手、先看到效果”的角度NSURLProtocol是唯一能让小白在一个周末内跑通并看到真实验收的路线。2.3 小白的选型结论什么场景用NSURLProtocol什么场景别用结合我自己的踩坑经验建议按场景来决定。如果目标是“看看这个App到底发了什么请求、响应是什么”那就用NSURLProtocol注册一个类就完事后面想加黑白名单或者日志落盘都方便。如果目标是“对请求做统一的签名、加密、鉴权”那自定义NSURLSession其实是更清晰的做法因为你能在Session层集中处理业务逻辑而不是靠Protocol在底层去改写。如果项目已经全面接入某个网络库那优先看这个库有没有提供拦截器没有的话再考虑NSURLProtocol补位。还有一个容易被忽略的维度调试期和上线期的策略不一样。开发阶段可以用NSURLProtocol做详细日志但上线的正式包里通常要把它关掉或只保留崩溃相关的上报毕竟全量打印请求日志既费性能又容易把敏感信息写进Log。这个判断对想长期维护这套模块的人很重要建议在一开始就把“调试模式”和“正式模式”的开关留出来省得后面再改。3. 用NSURLProtocol跑通最小拦截模块注册、转发、打日志的完整代码3.1 注册拦截器在App启动早期挂好钩子拦截模块的第一步是让系统知道“我要接管请求”。这个动作在App启动时完成等第一个网络请求发出之前注册好。最常见的位置是AppDelegate的didFinishLaunching里或者你自己的SDK初始化方法里。注意只注册一次重复注册不会报错但会导致拦截逻辑被触发多次日志重复。// AppDelegate.swift func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) - Bool { // 注册自定义的 URLProtocol让系统在处理请求时先问它 URLProtocol.registerClass(CustomURLProtocol.self) return true }这段代码的核心是URLProtocol.registerClass(_:)传入的是一个继承自URLProtocol的类。注册之后系统每次准备发起请求都会调用这个类的canInit(with:)方法询问“你要不要接管”。这里有个建议注册时机越早越好最好在业务代码执行前完成否则启动早期发出的请求可能就拦不到了。3.2 实现URLProtocol的核心方法canInit、canonicalRequest、startLoading接下来是这个模块的主体新建一个Swift文件继承URLProtocol实现五个关键方法。先看整体代码再逐个拆开解释。import Foundation class CustomURLProtocol: URLProtocol { // 标记这个请求已经被处理过避免转发时再次被拦截造成死循环 private static let handledKey CustomURLProtocolHandledKey // 是否要接管这个请求 override class func canInit(with request: URLRequest) - Bool { guard let scheme request.url?.scheme?.lowercased(), scheme http || scheme https else { return false } // 已经被处理过的请求直接放行 if URLProtocol.property(forKey: handledKey, in: request) ! nil { return false } return true } // 规范化请求正常情况下原样返回即可 override class func canonicalRequest(for request: URLRequest) - URLRequest { return request } // 开始拦截核心逻辑在这里 override func startLoading() { guard let mutableRequest (request as NSURLRequest).mutableCopy() as? NSMutableURLRequest else { return } // 给请求打标记下次转发时不再被 canInit 拦下 URLProtocol.setProperty(true, forKey: Self.handledKey, in: mutableRequest) // 构造一个真正的请求发出去 let config URLSessionConfiguration.default let session URLSession(configuration: config, delegate: self, delegateQueue: nil) let task session.dataTask(with: mutableRequest as URLRequest) task.resume() } // 停止加载取消正在进行的任务 override func stopLoading() { // 如果有 dataTask 的引用在这里 cancel // 这个例子里的 session 和 task 是局部变量真实项目中建议持有并取消 } }先把canInit(with:)讲透。这个方法是拦截的开关返回true表示接管这个请求返回false表示放行。第一个条件是检查scheme只处理http和https其他类型如ftp、file直接放过。第二个条件是检查请求里有没有打过标记这个标记是后面转发时加的目的是防止自己发出去的请求再次进入canInit形成死循环。也就是说canInit里必须同时做“类型过滤”和“自己人放行”两件事少了任何一个都会出问题。canonicalRequest(for:)的作用是给请求做标准化处理简单场景直接返回原请求即可。真正干活在startLoading里把不可变的URLRequest转成可变版本打上标记然后用一个全新的URLSession把请求发出去。这个转发动作是必须的因为拦截的目的不是让请求消失而是先看一眼再放行。既然用了URLSession转发就需要处理响应回调把结果回传给原来的调用方否则调用方等不到数据。这部分代码要遵守URLProtocol的客户端回调规范核心是调client?.urlProtocol(...)的方法把数据还给系统。extension CustomURLProtocol: URLSessionDataDelegate { // 收到响应头时转发给客户端 func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: escaping (URLSessionResponseDisposition) - Void) { client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) completionHandler(.allow) } // 收到响应体数据时转发给客户端 func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { client?.urlProtocol(self, didLoad: data) } // 请求完成或出错时通知客户端 func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { if let error error { client?.urlProtocol(self, didFailWithError: error) } else { client?.urlProtocolDidFinishLoading(self) } } }这段回调代码的关键是你替调用方转发了一个请求拿到响应后不能私吞必须通过client把数据原样送回URL Loading System由它通知真正的调用方。didReceive里选择.allow表示允许继续接收数据.notAllowed表示不缓存。大多数场景下保持这个写法就行不用改。到这里一个能正常转发且不泄漏的拦截模块已经成形剩下的是把日志打出来。3.3 打印请求详情把URL、Header、Body完整捞出来拦截到请求但是看不到内容那不算真正完成。在startLoading里加一个打印方法把请求的关键信息输出到控制台。这里最合适的位置是拿到mutableRequest之后、发起转发之前。注意打印不影响请求本身只是顺路看一眼。private func logRequest(_ request: URLRequest) { var lines: [String] [] lines.append(【拦截】\(request.httpMethod ?? GET) \(request.url?.absoluteString ?? )) // 打印请求头 if let headers request.allHTTPHeaderFields, !headers.isEmpty { lines.append(Headers: \(headers)) } else { lines.append(Headers: 无) } // 打印请求体注意 POST 的 body 有时不在 httpBody 里需要特殊处理 if let body request.httpBody, let bodyString String(data: body, encoding: .utf8) { lines.append(Body: \(bodyString)) } else { lines.append(Body: 无或需要从 httpBodyStream 读取) } print(lines.joined(separator: \n)) }在startLoading里调用一次就行。httpMethod和url?.absoluteString分别拿到请求方式和完整地址allHTTPHeaderFields拿到请求头字典httpBody拿到请求体数据。这里有一个新手一定会踩的坑POST请求的body打印出来经常是nil具体原因和解决办法在第4章的系统排查里展开这里先在日志里留个提示位方便快速定位问题。打印对象不限于控制台。想把日志存下来就在这个方法里把lines数组写进文件加个时间戳和请求序号就能得到一个简单的流量日志系统。开发阶段用控制台打印最方便因为Xcode的控制台直接能看到结构化输出不需要额外搭文件读取的工具。4. 拦截模块的五个常见坑与排查从请求死循环到POST body丢失4.1 https请求拦不到先检查ATS和证书校验现象注册了URLProtocolhttp请求能拦到但https请求一条都看不到或者拦截到了但转发后App直接报错页面加载失败。原因iOS的ATSApp Transport Security默认要求https连接满足一定的安全等级如果请求的服务器证书是自签名的或者跟系统信任链对不上URLSession转发时会直接拒绝。另一个原因是部分服务器配置了证书校验转发时使用的URLSession默认行为无法通过校验。新手最容易遇到的是前者自签名证书导致请求在转发阶段就被系统掐掉了。解决开发调试阶段在Info.plist里配置临时放行允许任意加载。注意只建议在Debug环境这么干正式包一定要收紧否则等于关闭了iOS的安全传输保护。keyNSAppTransportSecurity/key dict keyNSAllowsArbitraryLoads/key true/ /dict如果加了这段配置仍然拦不到就要检查是不是用了SSL Pinning也就是客户端固定了服务器证书。这种情况需要在URLProtocol里实现URLSession的证书challenge回调对指定的证书做信任处理。开发阶段也可以先把Pinning逻辑暂时关掉等看清流量再恢复别在排查流量时被证书拦住。4.2 请求转发后死循环App启动就卡死现象注册URLProtocol之后App一启动就卡住控制台刷出一堆相同的请求日志CPU占用爆满甚至直接崩溃。原因这是误伤自己人。拦截器在startLoading里用URLSession转发请求这个新请求又走URL Loading System又进入canInit(with:)由于没有识别出“这是自己发出去的请求”再次接管再转发再接管形成一个无限循环。解决分两步。第一步在转发前给请求打一个标记用URLProtocol.setProperty绑定一个key。第二步在canInit里检查这个标记存在就返回false放行。对应第3章的handledKey和那段setProperty代码。这个标记的作用是给请求“验明正身”避免重复拦截。如果打了标记还是循环检查一下是不是在copy mutableRequest时把property弄丢了标记要打在转发用的那个request上不是打在原始request上。4.3 POST请求的body神秘消失日志里只有URL现象拦截到POST请求URL和Header都正常但打印请求体时发现httpBody是nil服务端收到的body也是空的接口报参数缺失。原因NSURLProtocol拿到的request其HTTPBody属性在部分场景下是空的。底层在转发时为了性能或兼容把请求体放到了HTTPBodyStream里不会同步给HTTPBody。直接读request.httpBody自然拿不到。这是URLProtocol的已知特性不是代码写错。解决不要依赖httpBody改成从httpBodyStream里读。常见做法是在startLoading里把stream的内容读到Data里重新赋值给请求的httpBody再打印。private func extractBody(from request: URLRequest) - Data? { // 优先直接取 httpBody if let body request.httpBody { return body } // 拿不到就从 httpBodyStream 读 guard let stream request.httpBodyStream else { return nil } stream.open() defer { stream.close() } var data Data() let bufferSize 1024 var buffer [UInt8](repeating: 0, count: bufferSize) while stream.hasBytesAvailable { let length stream.read(buffer, maxLength: bufferSize) if length 0 { break } data.append(buffer, count: length) } return data }这块代码的踩坑点在于stream的读取时机。如果太早去读stream还没准备好可能读不到内容如果太晚请求已经发完了。我的习惯是在startLoading里拿到mutableRequest后立刻读取读完再把body拼回去确保转发出去的请求携带body。如果读完发现data是空的再用断点在canonicalRequest里看看原始request的状态。4.4 回调线程不对偶现崩溃找不到原因现象拦截模块上线后App偶尔崩溃崩溃堆栈指向UI相关代码或者数组越界但复现不了看起来跟网络没关系。原因URLSession的delegate回调默认在后台线程执行。你在回调里如果直接操作了UI或者访问了某个在不安全的多线程环境下使用的对象就会出现偶现崩溃。这种问题在真机上尤其明显因为网络回调时机不可控线程切换频繁。解决回调里统一做线程处理。简单的做法是在delegate回调里把数据分发到主线程或者至少保证UI操作在主线程执行。响应体数据量大的时候还要注意不要在后台线程累积大量Data造成内存压力。func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { // 数据累加和转发放在后台线程没问题 client?.urlProtocol(self, didLoad: data) // 如果这里要刷新UI必须切主线程 DispatchQueue.main.async { // 更新UI或状态 } }这个坑的隐蔽之处在于它不是每次都崩而是压力大的时候崩。线上用户网络环境各异回调线程波动比模拟器大得多。给所有delegate回调里的非线程安全操作加一层DispatchQueue.main.async能省掉后面大量排查时间。4.5 WKWebView里的H5请求拦不住现象App里用WKWebView加载H5页面页面里发的接口请求在拦截日志里一条都看不到。原因WKWebView的网络栈是独立的不走URL Loading System。它的请求由WebKit进程处理NSURLProtocol的拦截范围覆盖不到。这是架构层面的限制不是配置问题。解决别在NSURLProtocol上硬刚。想看WKWebView的流量换专门的网络调试工具或运行时远程调试。对大多数做安全分析的人来说iOS原生接口的流量更值得关注H5流量可以单独处理。如果业务上必须统一拦截可以考虑在JS层做采集或者改用WKWebView提供的自定义方式来接管网络请求但这套方案复杂度高不适合作为小白入门的第一选择。5. 把拦截模块变成安全分析工具验证效果与四个扩展方向5.1 用最小demo工程验证拦截是否生效写好了模块先别急着搬到业务项目里新建一个空的工程跑一遍确认没问题再迁移。我的验证步骤很简单新建一个iOS App工程在AppDelegate里注册CustomURLProtocol启动后触发一个网络请求比如在viewDidLoad里用URLSession请求一个公开接口。控制台能看到三段内容——请求URL、请求头、响应数据说明基本就证明模块生效了。更严谨的验证方式是断点。在canInit(with:)和startLoading各打一个断点运行后看调用栈。canInit被触发说明系统确实来问过了startLoading被触发说明请求确实被接管了。这两步都通过说明拦截链路本身没问题后面的排查方向才值得继续。如果发现请求根本没进入startLoading问题多半出在canInit的过滤条件上回头检查scheme判断和handledKey判断。5.2 四个扩展方向日志落盘、黑白名单、敏感字段标记、交叉验证第一个方向是日志落盘。控制台打印在真机上不方便看把拦截到的数据按时间戳写成本地JSON文件定时取出来分析。这个扩展对线上问题排查尤其有用相当于给App装了一个自记录的网络账本。第二个方向是黑白名单。在canInit里加一个域名列表判断只拦截或只放行指定域名避免生产环境把日志打爆。第三个方向是敏感字段标记。用正则匹配请求体里的token、password等关键字命中就打警告日志这对安全审计有直接价值能快速发现代码里有没有硬编码敏感参数的接口。第四个方向是与外部抓包工具做交叉验证。一边用这套模块记录一边用独立的网络调试工具对比请求和响应确认模块转发的数据没有被篡改两边数据一致这套模块才能放心交给别人用。我做iOS项目有个习惯拿到一个不熟悉的工程第一件事不是看代码而是先装这样一个最小的拦截模块看它启动后到底向外发了哪些请求、带没带不该带的东西。流量不会说谎很多潜在问题在日志里一眼就能看出来。到现在这个习惯帮我省掉了大量不明不白的联调时间希望帮到你。本文还有配套的精品资源点击获取