1. 程式人生 > Android開發 >iOS中的網路除錯

iOS中的網路除錯

開發iOS的過程中,有一件非常令人頭疼的事,那就是網路請求的除錯,無論是後端介面的問題,或是引數結構問題,你總需要一個網路除錯的工具來簡化除錯步驟。

現狀

App外除錯

早先很多的網路除錯都是通過App外的除錯來進行的,這種的好處是可以完全不影響App內的任何邏輯,並且也不用去考慮對網路層可能造成的影響。

  • 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上已經有非常多的網路除錯框架,提供了簡單的應用內收集網路請求的功能。

  • 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,然而這倒是沒有什麼影響,這類的實現起來也是基本差不多,下面是主要的幾個步驟

  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))objc_msgSend)(self.forwardTo,_cmd,session,task,bytesSent,totalBytesSent,totalBytesExpectedToSend);
}
@end
複製程式碼

上面的程式碼也展現了眾多回調中的一個,只需要按照對應的方式完成所有的回撥就好了。

以上也是我經過多個框架的對比、以及多次實踐得到的目前最好的解決辦法,它既能解決傳統方案的需要重新傳送網路請求的致命弱點,也能在不影響任何網路請求的情況下,監控到所有的app內發出的網路請求,基本達到了我們對於無論除錯還是線上環境,都能完美進行網路除錯的工具的要求。

在完成了上面所說的除錯之後,我們只要在app內提供展示的UI,就可以像下面這張圖一樣展示出來,在app內debug啦。

即刻App現可在各大應用市場更新下載,歡迎回家!感謝大家的耐心等待,希望大家把好訊息擴散給認識的即友,讓更多人儘快重回即刻鎮。點選下載