开发iOS的过程中,有一件非常令人头疼的事,那就是网络请求的调试,无论是后台接口的问题,或者是参数结构问题,你总需要一个网络调试的工具来简化调试步骤。
原文链接
现状
App外调试
早先很多的网络调试都是通过App外的调试来进行的,这种的好处是可以完全不影响App内的任何逻辑,并且也不用去考虑对网络层可能造成的影响。
- Charles 的确是网络调试的首选,他支持模拟器、真机调试,并且附带有
map remote和map local的功能,可以说是iOS开发中的主流调试工具,但是缺点也很显著,使用时必需保证iPhone和Mac在同一Wi-Fi下,并且使用的时候还需要设置Wi-Fi对应的Proxy,而一旦电脑上的Charles关掉,手机就会连不上网络。在办公室可谓神器,可一旦离开了办公室,就没法使用了。 - Surge 也是近几年的一款不错的网络调试工具,iOS版设置好证书后,即可以直接看到所有app的请求,而Mac版提供的remote dashboard可以添加网络请求查看的效率,新的TF版本还添加了
rewrite以及script的功能,基本能达到Charles的大部分常用需求,并且可以独立于Mac来进行。不过这种方式也有肯定的问题,那就是每次查看网络请求都需要切换App,并且请求是所有应用发出的,而很难只看一个应用的请求(其实也是Filter做的不够细导致的问题)。
App内调试
目前GitHub上已经有非常多的网络调试框架,提供了简单的应用内收集网络请求的功能。
- GodEye 提供了一套完整的网络请求监控的功能,然然后面一直没有升级,并且会对应用内发出的请求有所影响(这点会在下文具体讲解),仅能作为调试使用,而不适合在线上继续调试。
- Bagel 这个的实现基本不会对应用内的请求有影响,不过这个必需要有Mac的应用才可以使用,而且由于实现的起因,假如应用内使用了自己设置的
URLProtocol,会使得网络请求的抓取重复。 以上的两大类调试方式,各有优劣,App外调试往往由于并不针对某个应用,导致查询的体验非常一般,现在Github上的大部分网络调试框架也基本都和这两个的原理相似,而这些调试工具的实现,因为多是用于Debug环境,对很多网络监控的要求也就非常的低,比方GodEye这种,就显著会影响到现有的网络请求,尽管影响很小,在调试环境下也能够接受,基本能够完成目的,但是一旦我们希望在线上(包括testflight)环境下进行调试,也就会让所有网络请求都有受到影响的风险(具体的风险后面会讲到)。
网络调试的原理
为理解决上面的问题,我们决定从现有的App内调试方案入手,着手优化少量细节的部分,来达到即便在线上进行调试也不影响网络请求的目的。下面我先详情一下目前主流的几个网络调试方案的原理。
URL Loading System中的URL Protocol
很多人在入门iOS的时候,都会通过Alamofire等第三方网络请求库来发送网络请求,但大部分的网络请求库都是基于标准库中URLConnection或者者URLSession的封装,其中URLConnection是旧的封装,而URLSession则是较新的也是现在被推荐使用的封装,它们本身对URL的加载、响应等一系列的事件进行了解决,其中就包含了所谓的传输协议的修改,标准库中提供了基础的URL传输协议,包括http、https、ftp等,当然,假如我们有自己的协议要解决,标准库也是提供了对应的方式的。
在标准库中,有一个URLProtocol的类,从名字来看我们就知道它是解决URL加载中的协议的,那么定义了对应的类,也要有办法让标准库来使用自己设置的协议,我们可以通过改变一个URLProtocol的数组来达到目的。
- 在
URLConnection中,会有一个URLProtocol的类变量代表这个URLProtocol的数组,我们可以通过registerClass的方法来在这个数组中插入我们自己的协议 - 在
URLSession中,则是由configuration来解决,我们可以通过在configuration中直接修改这个数组来插入我们自己的协议 在标准库中,每当有网络请求发出的时候,系统都会从对应的数组中依次讯问每一个URLProtocol的类能否能解决当前请求
open class func canInit(with request: URLRequest) -> Bool当遇到了一个能返回true的类,那么系统就会调用对应的类的初始化方法,初始化出当前类的一个实例,而剩下的关于请求发送、接收以及回调的事情就交由这个新的实例来解决,而系统提供的http、https这些基本的协议,都是由默认存在于URLProtocol数组中的类来实现的,所以假如我们希望自己解决,就需要将自己的协议插入到这个数组的前面,来保证优先被讯问到能否能解决这个网络请求。
因而我们可以通过继承URLProtocol,并实现相关的方法,作为中间层来解决网络的发送、接收后的各种事件,URLProtocol有能力改变URL加载过程中的每一个环节,但是又要去调用原始的响应方法,这样的设计让协议的解决不会影响网络调用以及网络响应的调用方式,让网络请求发送方无感知的情况下来做中间的解决。
正是这个相似“隐身”的特点,让URLProtocol成为了很多网络调试框架使用的首选,这些框架通过hookURLSession或者者URLSessionConfiguration的初始化方法,在URLSession中的configuration中插入自己设置的网络调试Protocol,那么所有对应的网络请求都会通过这个Protocol来发送,而在这个Protocol中将请求重新通过正常的URLSession发送,而后接收到网络请求的回调,再回调回原来的网络请求的delegate,即可以在不影响原有请求的情况下,拿到请求的所有回调,并在这其中进行记录。
以上面提到的GodEye 为首的就是这种方法,只不过它内部发送请求用的是老的URLConnection而不是URLSession,然而这倒是没有什么影响,这类的实现起来也是基本差不多,下面是主要的几个步骤
- 利用Objc的运行时来hook掉
URLSession.init(configuration:delegate:delegateQueue:)方法,而后在调用原初始化方法之前,在URLSessionConfiguration中插入我们自己设置的URLProtocol,同时调用URLProtocol下的类方法registerClass来注册自己设置的类。 - 在自己设置的
URLProtocol子类中实现canInit(with:)方法,在里面判断这个网络请求能否需要监控,假如不需要可以直接放行canonicalRequest(for:)方法中,我们通常会对原有的请求进行少量解决,例如加上一个flag将请求标识为已经被解决过了startLoading()方法中,我们需要将对应的请求发送出去,通常情况下我们会用一个新的URLSession将请求再次发送,并且将新的delegate设置为自己,这样新的请求的回调就会由当前的URLProtocol解决stopLoading方法,我们就负责将发出去的请求中止掉
- 同时,在自己设置的
URLProtocol中实现上面说的新请求的回调,在回调中通过self.client.urlProtocol的一系列方法,将回调传回至原来的delegate 至此,我们完成了发送、接收等一系列操作,并且完美的将回调转发回了原来的代理商方,剩下的就是我们在回调中收集网络请求的各种信息就好了 这个方法看起来非常完美,通过图来展现如下(上面的是原有的流程,下面的是新的流程)

很多app的网络监控也是到此为止,然而这些app通常是只在调试模式下才打开调试,由于不会有很大的问题,然而我们没法要求所有的后台开发都安装所谓的调试版本,假如我们希望在线上(包括testflight)情况下,也能进行调试,这套方案的少量小问题就会显得很严重了
- 首先,正常情况下一个app可能也就一两个
URLSession的实例,现在却是发一个请求就会有一个新的URLSession的实例,这个本身在性能上会有肯定的潜在风险,然而这不是由于大家不想复用所谓的URLSession,而是正如我们上面解释的,系统会对每一个请求都初始化一个URLProtocol的实例来解决,而每个实例都要解决各自的回调,而且在URLProtocol中无法拿到原始的URLSession,所以大家也都不愿意花时间在URLSession上,毕竟很多app可能也只有在调试的时候才会开启这个功能 - 其次,在
URLProtocol中,我们每次初始化的新的URLSession都是用的默认的configuration,包括超时、缓存等设置都和原来的URLSession不同,这会导致少量体现不符合预期
这两点对于线上环境都是无法接受的,因而这个方案基本不符合我们的要求。
要处理上面的问题,我们需要引入URLSession复用的办法,也就是需要有一个管理者,去管理所有的URLSession,并且要分发他们各自网络请求的回调,调回对应的URLProtocol实例。在一次阅读苹果官方的URLProtocol例子中,我发现这个例子中的少量设计理念可以帮助我们处理这个问题,它里面有一个Demux的概念。
我们前面所说,每次发请求都新建一个URLSession的实例,起因是我们假如只在URLProtocol的情况下,很难通过上下文拿到对应的URLSession,同时也没有做任何的复用,由于原来的方法,我们让URLSession的delegate是当前的URLProtocol,而session的delegate是无法改变的,因而我们为了方便而这么做,而Demux其实就是做了非常多复杂的事情,将所谓的URLSession存下来复用,那么既然复用了delegate,Demux的另一件事就是将聚合到一起的delegate再转发出去。
Demux会对每一个不同的原URLSession生成一个新的URLSession,demux本身会记录当前请求的id,而后统一解决回调,在回调的时候,再通过这个id来寻觅对应的URLProtocol,来执行回调,这样就完美处理了上面的第一个问题,下图就展现了Demux的工作原理与流程。

在实现上,当我们引入Demux的时候,我们也就没有多URLSession的问题了,但是实现上,我们想要拿到原有URLSession的configuration,似乎没有那么容易,首先,URLProtocol本身就没办法拿到原有的URLSession,由于从接口的设计上,它只能拿到对应的URLRequest来解决原有的请求,而不能做更多的事了,眼看着这件事是没法处理了的时候,我通过苹果开源的swift标准库中对URLProtocol的阅读,发现其实在请求时,其实标准库会调用initWithTask:cachedResponse:client:将对应的URLSessionTask传过去,只是是私有的属性,我们不能访问,然而这件事仍然还是给了我启发,我们最后的处理办法是,通过继承URLProtocol写一个自己的BaseLoggerurlProtocol,而后override这个初始化方法,并且将传入的task保存下来,这样我们就能在URLProtocol中拿到这个请求对应的task,而后再通过task拿到原有的URLSession,这样我们即可以完美的通过原来的configuration来初始化新的URLSession,处理上面的两个问题,而这也是目前即刻中使用的网络监控方式,以下是少量核心功能是实现代码。
#pragma mark - Base Url Protocol@interface BaseLoggerURLProtocol : NSURLProtocol@property (atomic, copy, readwrite) NSURLSessionTask * originTask;@end@implementation BaseLoggerURLProtocol : NSURLProtocol- (instancetype)initWithTask:(NSURLSessionTask *)task cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id<NSURLProtocolClient>)client { self.originTask = task; self = [super initWithRequest:task.originalRequest cachedResponse:cachedResponse client:client]; return self;}@end// MARK: - Logger Demuxclass LoggerURLSessionDemux: NSObject { public private(set) var configuration: URLSessionConfiguration! public private(set) var session: URLSession! private var taskInfoByTaskId: [Int: TaskInfo] = [:] private var sessionDelegateQueue: OperationQueue = OperationQueue() public init(configuration: URLSessionConfiguration) { super.init() self.configuration = (configuration.copy() as! URLSessionConfiguration) sessionDelegateQueue.maxConcurrentOperationCount = 1 sessionDelegateQueue.name = "com.jike...” self.session = URLSession(configuration: self.configuration, delegate: self, delegateQueue: self.sessionDelegateQueue) self.session.sessionDescription = self.identifier }}// MARK: - Demux Managerclass LoggerURLDemuxManager { static let shared = LoggerURLDemuxManager() private var demuxBySessionHashValue: [Int: LoggerURLSessionDemux] = [:] func demux(for session: URLSession) -> LoggerURLSessionDemux { objc_sync_enter(self) let demux = demuxBySessionHashValue[session.hashValue] objc_sync_exit(self) if let demux = demux { return demux } let newDemux = LoggerURLSessionDemux(configuration: session.configuration) objc_sync_enter(self) demuxBySessionHashValue[session.hashValue] = newDemux objc_sync_exit(self) return newDemux }}// MARK: - Url Protocol Start Loadingpublic class LoggerURLProtocol: BaseLoggerURLProtocol {override open func startLoading() { guard let originTask = originTask, let session = originTask.value(forKey: “session”) as? URLSession else { // We must get the session for using demux. client?.urlProtocol(self, didFailWithError: LoggerError.cantGetSessionFromTask) // Release the task self.originTask = nil return } // Release the task self.originTask = nil let demux = LoggerURLDemuxManager.shared.demux(for: session) var runLoopModes: [RunLoop.Mode] = [RunLoop.Mode.default] if let currentMode = RunLoop.current.currentMode, currentMode != RunLoop.Mode.default { runLoopModes.append(currentMode) } self.thread = Thread.current self.modes = runLoopModes.map { $0.rawValue } let recursiveRequest = (self.request as NSURLRequest).mutableCopy() as! NSMutableURLRequest LoggerURLProtocol.setProperty(true, forKey: LoggerURLProtocol.kOurRecursiveRequestFlagProperty, in: recursiveRequest) self.customTask = demux.dataTask(with: recursiveRequest as URLRequest, delegate: self, modes: runLoopModes) self.customTask?.resume() let networkLog = NetworkLog(request: request) self.networkLog = networkLog RGLogger.networkLogCreationSubject.onNext(networkLog) }}新的方案
上面所说的方案处理了传统方案的大部分问题,也在我们的app开发阶段进行了少量使用,然而我们却遇到了新的问题
方案的问题
我们上面提到的方案,根据传统的方案,进行了少量改进,避免了大部分传统方案的问题,但是有一个是我们始终无法避开的点,那么就是我们依然重新发送了一个网络请求,而不是直接对原来的网络请求进行的监控,那么原来请求怎样发送,我们就得原封不动的发送出去,不然假如发送了错误的网络请求,那么就会导致收到错误的响应甚至无法收到响应,直接导致应用内的功能受损,这是这套方案从开始就会有的问题。
正是由于这个问题,我们也遇到了这次网络监控最大的挑战,那就是不同寻常的请求,因为我们app内使用了Alamofire来进行网络请求,而它在上传MultipartFormData假如数据量过大,那么就会有一个机制是将data放在一个临时目录下,而后通过Upload File来进行上传数据,具体的机制可见Alamofire源码中的逻辑。
而正是这个机制,导致我们app在上传图片的时候,使用了Upload File的方式上传,然而在我们的自己设置的URLProtocol,只能直接拿到对应的URLRequest,然而Upload File的时候,我们没法简单的通过它获取到上传的数据,因此我们通过这个URLRequest发出的请求,只会带有空的body,而不会上传真正的数据,导致图片上传失败,这也直接影响到了app的功能,而我们当时只能通过不监控上传图片请求的方式绕开这个问题。
从根源处理问题
从这个问题来看,无论是传统的方案还是我们改进后的方案,都肯定会重新发送一次网络请求,只需我们没法完美的发出原来的请求,这个方案就是不够完美的,也就是说URLProtocol这条路也就没法继续走下去了。
这也告诉我们,我们要找一个不会影响原有网络请求,而又想要拿到所有的网络请求回调的方法。在使用RxSwift的过程中,我理解到了一个很有意思的概念,叫DelegateProxy,它可以生成一个proxy,并将这个proxy设置为原来的delegate,而后再通过转发,将所有调用过来的方法,全都转发到原有的delegate去,这样,既能作为一个中间层拿到所有的回调,又能不影响原有的解决,而在RxSwift下的RxCocoa中,已经将这一套技术用在了各种UI组件上了,我们平常调用的
tableView.rx.contentOffset.subscribe(on: { event in })就是最简单的既不影响tableView的delegate又能拿到回调的例子。
有了这个方向,我就准备实现一套URLSessionDelegate的DelegateProxy,这样也能既不影响原来网络请求的发送,又能拿到所有回调,这样只要要将相应的回调转发回原有的delegate就好了。 因而我实现了一个基本的delegate proxy
public final class URLSessionDelegateProxy: NSObject { private var networkLogs: [Int: JKLogger.NetworkLog] = [:] var _forwardTo: URLSessionDelegate? // MARK: - Initialize @objc public init(forwardToDelegate delegate: URLSessionDelegate) { self._forwardTo = delegate super.init() } // MARK: - Responder override public func responds(to aSelector: Selector!) -> Bool { return _forwardTo?.responds(to: aSelector) ?? false }}而后实现对应的URLSessionDelegate的方法,并且调用_forwardTo的对应方法,将回调回传回原有的回调,而后我们要做的,就是去hook掉URLSession的初始化方法sessionWithConfiguration:delegate:delegateQueue:,而后用传入的delegate初始化我们自己的DelegateProxy,而后将新的delegate设置回去就好了,具体回传的方式如下
// MARK: - URLSessionDataDelegateextension JKLogger.URLSessionDelegateProxy: URLSessionDataDelegate { var _forwardToDataDelegate: URLSessionDataDelegate? { return _forwardTo as? URLSessionDataDelegate } public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { _forwardToDataDelegate?.urlSession?(session, dataTask: dataTask, didReceive: response, completionHandler: completionHandler) }}这样我们就能达到预期的效果了,同时也完美的避开了之前的方法中,需要我们重新发送请求的问题。
一个小插曲
上面的最新方案在使用了一段时间后,基本没有什么问题,然而我们在使用React Native的时候,遇到了一个问题,这一套方案会导致app无法连接到RN,无法加载对应的页面,在阅读了ReactNative的源码之后,我们找到了起因,在RN中的一个类RCTMultipartDataTask中,它在公告中说明了自己遵循NSURLSessionDataDelegate协议,但是却在实现中实现了NSURLSessionStreamDelegate的方法,因而,在我们自己的DelegateProxy中的回调时,我们使用了
_forwardTo as? URLSessionStreamDelegate // always failed的时候,是没法直接转换的,但是标准库中,对于回调的实现,还是基于objc通过运行时判断能否responds(to: Selector)的,因而标准库是能调用到RCTMultipartDataTask中对应的方法的,但是我们在swift代码中却没办法直接调用到这个方法,这也就造成了RCTMultipartDataTask 少收到了一个回调,不能工作也是正常。 尽管ReactNative的这种写法很莫名其妙,而且这种写法也是非常不推荐的,然而我们既然是要做完美的网络监控方案,我们还是应该保持标准库的做法,通过objc的方式来进行回调,而不是通过简单的swift的as转换来进行调用。
这件事听起来非常简单,毕竟对于一个拥有强大运行时的objc来说,动态调用一个方法还算是很简单,我们第一个想到的就是performSelector,然而这个方法最多只能传两个参数,而网络请求的回调可以有非常多的参数,在比照了NSInvocation等方案之后,我们最终还是选择了直接通过objc_msgSend方式来调用,只要要我们做好了判断,这个也能很安全的执行
#import “_JKSessionDelegateProxy.h”#import <objc/runtime.h>#import <objc/message.h>#define JKMakeSureRespodsTo(object, sel) if (![object respondsToSelector:sel]) { return ;}@interface _JKSessionDelegateProxy () <NSURLSessionDelegate, NSURLSessionTaskDelegate, NSURLSessionDataDelegate, NSURLSessionStreamDelegate, _JKNetworkLogUpdateDelegate>@end@implementation _JKSessionDelegateProxy- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didSendBodyData:(int64_t)bytesSent totalBytesSent:(int64_t)totalBytesSent totalBytesExpectedToSend:(int64_t)totalBytesExpectedToSend { JKMakeSureRespodsTo(self.forwardTo, _cmd); ((void (*)(id, SEL, NSURLSession*, NSURLSessionTask*, int64_t, int64_t, int64_t))objc_msgSend)(self.forwardTo, _cmd, session, task, bytesSent, totalBytesSent, totalBytesExpectedToSend);}@end上面的代码也展示了众多回调中的一个,只要要按照对应的方式完成所有的回调就好了。
以上也是我经过多个框架的比照、以及屡次实践得到的目前最好的处理办法,它既能处理传统方案的需要重新发送网络请求的致命弱点,也能在不影响任何网络请求的情况下,监控到所有的app内发出的网络请求,基本达到了我们对于无论调试还是线上环境,都能完美进行网络调试的工具的要求。
在完成了上面所说的调试之后,我们只需在app内提供展现的UI,即可以像下面这张图一样展现出来,在app内debug啦。

推荐?:
面试题持续整理升级中,假如你想一起进阶,不妨增加一下交流群1012951431
面试题资料或者者相关学习资料都在群文件中 进群就可下载!
