在打開某個網址時設置 HTTP 代理,iOS目前有三種方案可以實現:
- 使用 URLProtocol
- 使用 WKWebURLSchemeHandler
使用NetworkExtension
其中第三種可以不用考慮了,作爲一名中國開發者,你根本申請不到證書。。
那麼我們就看一下前兩種:
URLProtocol
我們先了解一下他是個啥:
NSURLProtocol是 iOS裏面的URL Loading System的一部分,是一個抽象對象(可是OC裏面沒有抽象這一說)。
平常我們做網絡相關的東西基本很少碰它,但是它的功能卻異常強大。
- 可以攔截UIWebView,基於系統的NSUIConnection或者
- NSUISession進行封裝的網絡請求。
- 忽略網絡請求,直接返回自定義的Response
- 修改request(請求地址,認證信息等等)
- 返回數據攔截
- 其他你能想到的
自定義 URLProtocol
使用URLProtocol時,需要將子類 URLProtocol 的類型進行註冊:
static var isRegistered = false
class func start() {
guard isRegistered == false else {
return
}
URLProtocol.registerClass(self)
isRegistered = true
}
核心是重寫幾個方法:
/// 這個方法用來對請求進行處理,比如加上頭,不處理直接返回就行
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
return request
}
static let customKey = "HttpProxyProtocolKey"
/// 判斷是否需要處理,對處理過請求打上唯一標識符 customKey 的屬性,避免循環處理
override class func canInit(with request: URLRequest) -> Bool {
guard let url = request.url else {
return false
}
guard let scheme = url.scheme?.lowercased() else {
return false
}
guard scheme == "http" || scheme == "https" else {
return false
}
if let _ = self.property(forKey:customKey, in: request) {
return false
}
return true
}
private var dataTask:URLSessionDataTask?
/// 核心是在 startLoading 中對請求進行重發,將 Proxy 信息設置進 URLSessionConfigration,並生成 URLSession 發送請求
override func startLoading() {
// 1. 爲請求打上標記
let newRequest = request as! NSMutableURLRequest
type(of:self).setProperty(true, forKey: type(of: self).customKey, in: newRequest)
// 2. 設置 Proxy 配置
let proxy_server = "YourProxyServer" // proxy server
let proxy_port = 1234 // your port
let hostKey = kCFNetworkProxiesHTTPProxy as String
let portKey = kCFNetworkProxiesHTTPPort as String
let proxyDict:[String:Any] = [kCFNetworkProxiesHTTPEnable as String: true, hostKey:proxy_server, portKey: proxy_port]
let config = URLSessionConfiguration.default
config.connectionProxyDictionary = proxyDict
config.protocolClasses = [type(of:self)]
// 3. 用配置生成 URLSession
let defaultSession = URLSession(configuration: config, delegate: self, delegateQueue: nil)
// 4. 發起請求
dataTask = defaultSession.dataTask(with:newRequest as URLRequest)
dataTask!.resume()
}
/// 在 stopLoading 中 cancel 任務
override func stopLoading() {
dataTask?.cancel()
}
當這個網絡請求被 URLProtocol 攔截,需要保證上層實現的網絡相關回調或 block 都能被調用。
爲了解決這個問題,蘋果定義了 NSURLProtocolClient 協議,協議方法覆蓋了網絡請求完整的生命週期。
在攔截之後重發的請求的各階段,完整地調用了協議中的方法,上層調用者的回調或者 block 都會在正確的時機被執行。
extension HttpProxyProtocol: URLSessionDataDelegate{
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask,
didReceive response: URLResponse,
completionHandler: (URLSession.ResponseDisposition) -> Void) {
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
completionHandler(.allow)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
client?.urlProtocol(self, didLoad: data)
}
}
extension HttpProxyProtocol: URLSessionTaskDelegate{
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if error != nil && error!._code != NSURLErrorCancelled {
client?.urlProtocol(self, didFailWithError: error!)
} else {
client?.urlProtocolDidFinishLoading(self)
}
}
}
需要注意的是,在 UIWebView 中使用會出現 JS、CSS、Image 重定向後無法訪問的問題,可以在重定向方法中添加如下代碼解決:
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
let newRequest = request as! NSMutableURLRequest
type(of: self).removeProperty(forKey: type(of: self).customKey, in: newRequest)
client?.urlProtocol(self, wasRedirectedTo: newRequest as URLRequest, redirectResponse: response)
dataTask?.cancel()
let error = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError, userInfo: nil)
client?.urlProtocol(self, didFailWithError: error)
}
到此完整的 URLProtocol 定義完了。
但是裏面有一點不好的地方,每次發送一個請求時就會新建一個 URLSession,非常低效。
蘋果也不推薦這種做法,而且某些情況下由於請求未完全發送完還有可能造成內存泄露等問題。
因此,我們需要共享一個 Session,並僅在代理的 Host 或者 Port 發生改變時,才重新生成新的實例。
自定義 URLSessionManager
主要分兩個類
- ProxySessionManager:負責持有 URLSession,對 Session 是否需要重新生成或者共享進行管理
- ProxySessionDelegate:和 URLSession 一一對應。將 URLSessio 的 Delegate 分發到對應的 Task 的 Delegate,維護 Task 的對應 Delegate
- ProxySessionManager 主要就是對外提供接口,對外層隱藏細節,將 Delegate 和 Task 生成配置好。
class ProxySessionManager: NSObject {
var host: String?
var port: Int?
static let shared = ProxySessionManager()
private override init() {}
private var currentSession: URLSession?
private var sessionDelegate: ProxySessionDelegate?
func dataTask(with request: URLRequest, delegate: URLSessionDelegate) -> URLSessionDataTask {
/// 判斷是否需要生成新的 Session
if let currentSession = currentSession, currentSession.isProxyConfig(host, port){
} else {
let config = URLSessionConfiguration.proxyConfig(host, port)
sessionDelegate = ProxySessionDelegate()
currentSession = URLSession(configuration: config, delegate: self.sessionDelegate, delegateQueue: nil)
}
let dataTask = currentSession!.dataTask(with: request)
/// 保存 Task 對應的 Delegate
sessionDelegate?[dataTask] = delegate
return dataTask
}
}
而對 Session 的 connectionProxyDictionary 的設置的 Key,沒有 HTTPS 的。
查看 CFNetwork 裏的常量定義,發現有 kCFNetworkProxiesHTTPSEnable,但是在 iOS 上被標記爲不可用,只可以在 MacOS 上使用,那麼我們其實可以直接取這個常量的值進行設置,下面總結了相關的常量裏的對應的值:
Raw值 | CFNetwork/CFProxySupport.h | CFNetwork/CFHTTPStream.h CFNetwork/CFSocketStream.h |
---|---|---|
“HTTPEnable” | kCFNetworkProxiesHTTPEnable | N/A |
“HTTPProxy” | kCFNetworkProxiesHTTPProxy | kCFStreamPropertyHTTPProxyHost |
“HTTPPort” | kCFNetworkProxiesHTTPPort | kCFStreamPropertyHTTPProxyPort |
“HTTPSEnable” | kCFNetworkProxiesHTTPSEnable | N/A |
“HTTPSProxy” | kCFNetworkProxiesHTTPSProxy | kCFStreamPropertyHTTPSProxyHost |
“HTTPSPort” | kCFNetworkProxiesHTTPSPort | kCFStreamPropertyHTTPSProxyPort |
“SOCKSEnable” | kCFNetworkProxiesSOCKSEnable | N/A |
“SOCKSProxy” | kCFNetworkProxiesSOCKSProxy | kCFStreamPropertySOCKSProxyHost |
“SOCKSPort” | kCFNetworkProxiesSOCKSPort | kCFStreamPropertySOCKSProxyPort |
這樣我們就可以拓展兩個 Extension 方法了:
fileprivate let httpProxyKey = kCFNetworkProxiesHTTPEnable as String
fileprivate let httpHostKey = kCFNetworkProxiesHTTPProxy as String
fileprivate let httpPortKey = kCFNetworkProxiesHTTPPort as String
fileprivate let httpsProxyKey = "HTTPSEnable"
fileprivate let httpsHostKey = "HTTPSProxy"
fileprivate let httpsPortKey = "HTTPSPort"
extension URLSessionConfiguration{
class func proxyConfig(_ host: String?, _ port: Int?) -> URLSessionConfiguration{
let config = URLSessionConfiguration.ephemeral
if let host = host, let port = port {
let proxyDict:[String:Any] = [httpProxyKey: true,
httpHostKey: host,
httpPortKey: port,
httpsProxyKey: true,
httpsHostKey: host,
httpsPortKey: port]
config.connectionProxyDictionary = proxyDict
}
return config
}
}
extension URLSession{
func isProxyConfig(_ aHost: String?, _ aPort: Int?) -> Bool{
if self.configuration.connectionProxyDictionary == nil && aHost == nil && aPort == nil {
return true
} else {
guard let proxyDic = self.configuration.connectionProxyDictionary,
let aHost = aHost,
let aPort = aPort,
let host = proxyDic[httpHostKey] as? String,
let port = proxyDic[httpPortKey] as? Int else {
return false
}
if aHost == host, aPort == port{
return true
} else {
return false
}
}
}
}
ProxySessionDelegate,主要做的是將 Delegate 分發到每個 Task 的 Delegate,並存儲 TaskIdentifer 對應的 Delegate,內部實際使用 Key-Value 結構的字典儲存,在設置和取值時加鎖,避免回調錯誤:
fileprivate class ProxySessionDelegate: NSObject {
private let lock = NSLock()
var taskDelegates = [Int: URLSessionDelegate]()
/// 借鑑 Alamofire,擴展下標方法
subscript(task: URLSessionTask) -> URLSessionDelegate? {
get {
lock.lock()
defer {
lock.unlock()
}
return taskDelegates[task.taskIdentifier]
}
set {
lock.lock()
defer {
lock.unlock()
}
taskDelegates[task.taskIdentifier] = newValue
}
}
}
/// 對回調進行分發
extension ProxySessionDelegate: URLSessionDataDelegate{
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask,
didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
if let delegate = self[dataTask] as? URLSessionDataDelegate{
delegate.urlSession!(session, dataTask: dataTask, didReceive: response, completionHandler: completionHandler)
} else {
completionHandler(.cancel)
}
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
if let delegate = self[dataTask] as? URLSessionDataDelegate{
delegate.urlSession!(session, dataTask: dataTask, didReceive: data)
}
}
}
extension ProxySessionDelegate: URLSessionTaskDelegate{
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
if let delegate = self[task] as? URLSessionTaskDelegate{
delegate.urlSession?(session, task: task, willPerformHTTPRedirection: response, newRequest: request, completionHandler: completionHandler)
}
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let delegate = self[task] as? URLSessionTaskDelegate{
delegate.urlSession!(session, task: task, didCompleteWithError: error)
}
self[task] = nil
}
}
這樣,只要調用 ProxySessionManager 或者直接使用 Alamofire 進行網絡請求,就可以做到 URLSession 儘量少創建了。蘋果官方也有一個 SampleProject 講自定義 URLProtocol,做法也是用類似用一個單例進行管理。點擊此處下載代碼
WKWebView的處理
和 UIWebView 不一樣,WKWebView 中的 http&https 的 Scheme 默認不走 URLPrococol。需要讓 WKWebView 支持 NSURLProtocol 的話,需要調用蘋果私用方法,讓 WKWebview 放行 http&https 的 Scheme。
其中需要繞過審覈檢查主要是類名 WKBrowsingContextController,除了可以對字符串進行加密或者拆分外,由於在 iOS 8.4 以上,可使用 WKWebview 的私有方法 browsingContextController 取到該類型的實例:
let sel = Selector(("registerSchemeForCustomProtocol:"))
let vc = WKWebView().value(forKey: "browsingContextController") as AnyObject
let cls = type(of: vc) as AnyObject
let _ = cls.perform(sel, with: "http")
let _ = cls.perform(sel, with: "https")
如果遇到Post 請求 Body 數據被清空的情況,可以使用蘋果 SampleProjcet 中的 CanonicalRequest 類來解決。
WKWebURLSchemeHandler
iOS 11 以上,蘋果爲 WKWebView 增加了 WKURLSchemeHandler 協議,可以爲自定義的 Scheme 增加遵循 WKURLSchemeHandler 協議的處理。其中可以在 start 和 stop 的時機增加自己的處理。
遵循協議中的兩個方法:
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
let proxy_server = "YourProxyServer" // proxy server
let proxy_port = 1234 // your port
let hostKey = kCFNetworkProxiesHTTPProxy as String
let portKey = kCFNetworkProxiesHTTPPort as String
let proxyDict:[String:Any] = [kCFNetworkProxiesHTTPEnable as String: true, hostKey:proxy_server, portKey: proxy_port]
let config = URLSessionConfiguration.ephemeral
config.connectionProxyDictionary = proxyDict
let defaultSession = URLSession(configuration: config)
dataTask = defaultSession.dataTask(with: urlSchemeTask.request, completionHandler: {[weak urlSchemeTask] (data, response, error) in
/// 回調時 urlSchemeTask 容易崩潰,可能蘋果沒有考慮會在 handler 裏做異步操作,這裏試了一下 weak 寫法,崩潰不出現了,不確定是否爲完全解決方案
guard let urlSchemeTask = urlSchemeTask else {
return
}
if let error = error {
urlSchemeTask.didFailWithError(error)
} else {
if let response = response {
urlSchemeTask.didReceive(response)
}
if let data = data {
urlSchemeTask.didReceive(data)
}
urlSchemeTask.didFinish()
}
})
dataTask?.resume()
}
當然這裏 URLSession 的處理和 URLProtocol 一樣,可以進行復用處理,然後生成 WKWebviewConfiguration,並使用官方 API 將 handler 設置進去:
let config = WKWebViewConfiguration()
config.setURLSchemeHandler(HttpProxyHandler(), forURLScheme: "http")//拋出異常
但因爲蘋果的 setURLSchemeHandler 只能對自定義的 Scheme 進行設置,所以像 http 和 https 這種 Scheme,已經默認處理了,不能調用這個 API。
因此可以通過 Hook WKWebView 的 handlesURLScheme: 來達到繞過系統的限制:
@implementation WKWebView (Hook)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Method origin = class_getClassMethod(self, @selector(handlesURLScheme:));
Method hook = class_getClassMethod(self, @selector(cdz_handlesURLScheme:));
method_exchangeImplementations(origin, hook);
});
}
+ (BOOL)cdz_handlesURLScheme:(NSString *)urlScheme {
if ([urlScheme isEqualToString:@"http"] || [urlScheme isEqualToString:@"https"]) {
return NO;
}
return [self cdz_handlesURLScheme:urlScheme];
}
@end
這樣的話,就可以順利使用了:
extension WKWebViewConfiguration{
class func proxyConifg() -> WKWebViewConfiguration{
let config = WKWebViewConfiguration()
let handler = HttpProxyHandler()
config.setURLSchemeHandler(handler, forURLScheme: "http")
config.setURLSchemeHandler(handler, forURLScheme: "https")
return config
}
}
但是此方法僅支持 iOS 11 以上,而且官方不支持非自定義 Scheme,非正規設置方法可能出現其他問題。