在打开某个网址时设置 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,非正规设置方法可能出现其他问题。