最近因为项目需要,需要在打开某个网址时设置 HTTP 代理。所以做了相关的技术方案调研,并总结下来。

在 WebView 设置 Proxy 的方式,就是对请求进行拦截并重新处理。还有一种全局的实现方案,使用 iOS 9 以后才有的 NetworkExtension,但是这种方案会在用户看来像是个微皮恩的 App,不友好且太重了。

使用 URLProtocol

1. 自定义 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 发生改变时,才重新生成新的实例。笔者模仿 iOS 上网络框架 Alamofire 的做法,简单写了一个 SessionManager 进行管理。

2. 自定义 URLSessionManager

主要分两个类

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,做法也是用类似用一个单例进行管理。

3. WKWebView 的特别处理

UIWebView 不一样,WKWebView 中的 http&https 的 Scheme 默认不走 URLPrococol。需要让 WKWebView 支持 NSURLProtocol 的话,需要调用苹果私用方法,让 WKWebview 放行 http&https 的 Scheme。

通过 Webkit 的源码发现,需要调用的私有方法如下:

[WKBrowsingContextController registerSchemeForCustomProtocol:"http"];
[WKBrowsingContextController registerSchemeForCustomProtocol:"https"];

而使用的话需要使用反射进行调用

Class cls = NSClassFromString(@"WKBrowsingContextController");
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
if ([(id)cls respondsToSelector:sel]) {
    // 把 http 和 https 请求交给 NSURLProtocol 处理
    [(id)cls performSelector:sel withObject:@"http"];
    [(id)cls performSelector:sel withObject:@"https"];
}

其中需要绕过审核检查主要是类名 WKBrowsingContextController,除了可以对字符串进行加密或者拆分外,由于在 iOS 8.4 以上,可使用 WKWebview 的私有方法 browsingContextController 取到该类型的实例。

Class cls = [[[WKWebView new] valueForKey:@"browsingContextController"] class];

然后使用上就能大大降低风险了,Swift 上写法如下。

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")

优点:

缺点:

使用 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。需要用 KVC 取值进行设置(iOS 12.2 上该方法已经失效,不存在此属性)

2020.2.22 新增解决方法:

可以通过 Hook WKWebViewhandlesURLScheme: 来达到绕过系统的限制检查。具体代码如下:

@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
    }
}

然后给 WKWebview 设置就能使用了。

优点:

缺点:

使用 NetworkExtension

使用 NetworkExtension,需要开发者额外申请权限(证书)。

可以建立全局 VPN,影响全局流量,可以获取全局 Wifi 列表,抓包,等和网络相关的功能。

其中可以使用第三方库 NEKit,进行开发,已经处理了大部分坑和进行封装。

优点:

缺点:

最后

总结了相关代码在 Demo 里,可以直接使用 HttpProxyProtocol,HttpProxyHandler,HttpProxySessionManager。

参考链接