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現可在各大應用市場更新下載,歡迎回家!感謝你們的耐心等待,但願你們把好消息擴散給認識的即友,讓更多人儘快重回即刻鎮。點擊下載

相關文章
相關標籤/搜索