iOS中的网络调试

开发iOS的过程当中,有一件很是使人头疼的事,那就是网络请求的调试,不管是后端接口的问题,或是参数结构问题,你总须要一个网络调试的工具来简化调试步骤。react

现状

App外调试

早先不少的网络调试都是经过App外的调试来进行的,这种的好处是能够彻底不影响App内的任何逻辑,而且也不用去考虑对网络层可能形成的影响。ios

  • Charles 确实是网络调试的首选,他支持模拟器、真机调试,而且附带有map remotemap 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上已经有很是多的网络调试框架,提供了简单的应用内收集网络请求的功能。git

  • GodEye 提供了一套完整的网络请求监控的功能,然然后面一直没有更新,而且会对应用内发出的请求有所影响(这点会在下文具体讲解),仅能做为调试使用,而不适合在线上继续调试。
  • Bagel 这个的实现基本不会对应用内的请求有影响,不过这个必需要有Mac的应用才可使用,并且由于实现的缘由,若是应用内使用了自定义的URLProtocol,会使得网络请求的抓取重复。 以上的两大类调试方式,各有优劣,App外调试每每由于并不针对某个应用,致使查询的体验很是通常,如今Github上的大部分网络调试框架也基本都和这两个的原理相似,而这些调试工具的实现,因为可能是用于Debug环境,对不少网络监控的要求也就很是的低,好比GodEye这种,就明显会影响到现有的网络请求,虽然影响很小,在调试环境下也可以接受,基本可以完成目的,可是一旦咱们但愿在线上(包括testflight)环境下进行调试,也就会让全部网络请求都有受到影响的风险(具体的风险后面会讲到)。

网络调试的原理

为了解决上面的问题,咱们决定从现有的App内调试方案入手,着手优化一些细节的部分,来达到即便在线上进行调试也不影响网络请求的目的。下面我先介绍一下目前主流的几个网络调试方案的原理。github

URL Loading System中的URL Protocol

不少人在入门iOS的时候,都会经过Alamofire等第三方网络请求库来发送网络请求,但大部分的网络请求库都是基于标准库中URLConnection或者URLSession的封装,其中URLConnection是旧的封装,而URLSession则是较新的也是如今被推荐使用的封装,它们自己对URL的加载、响应等一系列的事件进行了处理,其中就包含了所谓的传输协议的修改,标准库中提供了基础的URL传输协议,包括http、https、ftp等,固然,若是咱们有本身的协议要处理,标准库也是提供了对应的方式的。swift

在标准库中,有一个URLProtocol的类,从名字来看咱们就知道它是处理URL加载中的协议的,那么定义了对应的类,也要有办法让标准库来使用自定义的协议,咱们能够经过改变一个URLProtocol的数组来达到目的。后端

  • URLConnection中,会有一个URLProtocol的类变量表明这个URLProtocol的数组,咱们能够经过registerClass的方法来在这个数组中插入咱们本身的协议
  • URLSession中,则是由configuration来处理,咱们能够经过在configuration中直接修改这个数组来插入咱们本身的协议 在标准库中,每当有网络请求发出的时候,系统都会从对应的数组中依次询问每个URLProtocol的类是否能处理当前请求
open class func canInit(with request: URLRequest) -> Bool 复制代码

当遇到了一个能返回true的类,那么系统就会调用对应的类的初始化方法,初始化出当前类的一个实例,而剩下的关于请求发送、接收以及回调的事情就交由这个新的实例来处理,而系统提供的http、https这些基本的协议,都是由默认存在于URLProtocol数组中的类来实现的,因此若是咱们但愿本身处理,就须要将本身的协议插入到这个数组的前面,来保证优先被询问到是否能处理这个网络请求。react-native

所以咱们能够经过继承URLProtocol,并实现相关的方法,做为中间层来处理网络的发送、接收后的各类事件,URLProtocol有能力改变URL加载过程当中的每个环节,可是又要去调用原始的响应方法,这样的设计让协议的处理不会影响网络调用以及网络响应的调用方式,让网络请求发送方无感知的状况下来作中间的处理。api

正是这个相似“隐身”的特色,让URLProtocol成为了不少网络调试框架使用的首选,这些框架经过hookURLSession或者URLSessionConfiguration的初始化方法,在URLSession中的configuration中插入自定义的网络调试Protocol,那么全部对应的网络请求都会经过这个Protocol来发送,而在这个Protocol中将请求从新经过正常的URLSession发送,而后接收到网络请求的回调,再回调回原来的网络请求的delegate,就能够在不影响原有请求的状况下,拿到请求的全部回调,并在这其中进行记录。数组

以上面提到的GodEye 为首的就是这种方法,只不过它内部发送请求用的是老的URLConnection而不是URLSession,然而这却是没有什么影响,这类的实现起来也是基本差很少,下面是主要的几个步骤缓存

  1. 利用Objc的运行时来hook掉URLSession.init(configuration:delegate:delegateQueue:)方法,而后在调用原初始化方法以前,在URLSessionConfiguration中插入咱们自定义的URLProtocol,同时调用URLProtocol下的类方法registerClass来注册自定义的类。
  2. 在自定义的URLProtocol子类中实现
    • canInit(with:)方法,在里面判断这个网络请求是否须要监控,若是不须要能够直接放行
    • canonicalRequest(for:)方法中,咱们一般会对原有的请求进行一些处理,例如加上一个flag将请求标识为已经被处理过了
    • startLoading()方法中,咱们须要将对应的请求发送出去,一般状况下咱们会用一个新的URLSession将请求再次发送,而且将新的delegate设置为本身,这样新的请求的回调就会由当前的URLProtocol处理
    • stopLoading方法,咱们就负责将发出去的请求中止掉
  3. 同时,在自定义的URLProtocol中实现上面说的新请求的回调,在回调中经过self.client.urlProtocol的一系列方法,将回调传回至原来的delegate
  4. 至此,咱们完成了发送、接收等一系列操做,而且完美的将回调转发回了原来的代理方,剩下的就是咱们在回调中收集网络请求的各类信息就行了 这个方法看起来很是完美,经过图来展现以下(上面的是原有的流程,下面的是新的流程)
    URL_Loading_System.png

不少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 flow.png

在实现上,当咱们引入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 Demux
class 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 Manager
class 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 Loading
public 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又能拿到回调的例子。

有了这个方向,我就准备实现一套URLSessionDelegateDelegateProxy,这样也能既不影响原来网络请求的发送,又能拿到全部回调,这样只须要将相应的回调转发回原有的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: - URLSessionDataDelegate
extension 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啦。

即刻App现可在各大应用市场更新下载,欢迎回家!感谢你们的耐心等待,但愿你们把好消息扩散给认识的即友,让更多人尽快重回即刻镇。点击下载

相关文章
相关标签/搜索