iOS 设置代理(Proxy)方案总结

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

在WebView设置Proxy的方式,就是对请求进行拦截并重新处理。还有一种全局的实现方案,使用iOS9以后才有的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 _ = URLProtocol.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
    URLProtocol.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.ephemeral
    config.connectionProxyDictionary = proxyDict
     
   	 // 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,负责持有URLSession,对Session是否需要重新生成或者共享进行管理
  • ProxySessionDelegate,和URLSession一一对应。将URLSession的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, 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,除了可以对字符串进行加密或者拆分外,由于在iOS8.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")

复制代码

优点:

  • 拦截能力强大
  • 同时支持UIWebView&WKWebView
  • 对系统无要求

缺点:

  • 对WKWebView支持不够友好
    • 审核有一定风险
    • iOS8.0-8.3需要额外开发量(私有类型&方法的混淆)
    • Post 请求 body 数据被清空
    • 对ATS支持不足

使用WKWebURLSchemeHandler

iOS11以上,苹果为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取值进行设置。

extension WKWebViewConfiguration{
    class func proxyConifg() -> WKWebViewConfiguration{
        let config = WKWebViewConfiguration()
        let handler = HttpProxyHandler()
        /// 先设置
        config.setURLSchemeHandler(handler, forURLScheme: "dummy")
        let handlers = config.value(forKey: "_urlSchemeHandlers") as! NSMutableDictionary
        handlers["http"] = handler
        handlers["https"] = handler
        return config
    }
}

复制代码

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

优点:

  • 苹果官方方法
  • 无审核风险

缺点:

  • 仅支持iOS11以上
  • 官方不支持非自定义Scheme,非正规设置方法可能出现其他问题

使用NetworkExtension

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

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

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

优点:

  • 功能强大
  • 使用原生功能,无审核风险

缺点:

  • 权限申请流程复杂
  • 仅支持iOS9以上(iOS8上仅支持系统自带的 IPSec 和 IKEv2 协议的 VPN)
  • 原生接口实现复杂,第三方库NEKit坑不知道有多少

最后

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

Reference

猜你喜欢

转载自juejin.im/post/5c5c3359e51d450132331981