最近因为项目需要,需要在打开某个网址时设置 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:负责持有
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,做法也是用类似用一个单例进行管理。
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")
优点:
- 拦截能力强大
- 同时支持 UIWebView&WKWebView
- 对系统无要求
缺点:
-
对 WKWebView 支持不够友好
-
审核有一定风险
-
iOS 8.0-8.3 需要额外开发量(私有类型&方法的混淆)
-
Post 请求 Body 数据被清空 (可以使用苹果 SampleProjcet 中的 CanonicalRequest 类来解决)
-
对 ATS 支持不足
-
使用 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 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
}
}
然后给 WKWebview
设置就能使用了。
优点:
- 苹果官方方法
- 无审核风险
缺点:
- 仅支持 iOS 11 以上
- 官方不支持非自定义 Scheme,非正规设置方法可能出现其他问题
使用 NetworkExtension
使用 NetworkExtension,需要开发者额外申请权限(证书)。
可以建立全局 VPN,影响全局流量,可以获取全局 Wifi 列表,抓包,等和网络相关的功能。
其中可以使用第三方库 NEKit,进行开发,已经处理了大部分坑和进行封装。
优点:
- 功能强大
- 使用原生功能,无审核风险
缺点:
- 权限申请流程复杂
- 仅支持 iOS 9 以上(iOS 8 上仅支持系统自带的 IPSec 和 IKEv2 协议的 VPN)
- 原生接口实现复杂,第三方库 NEKit 坑不知道有多少
最后
总结了相关代码在 Demo 里,可以直接使用 HttpProxyProtocol,HttpProxyHandler,HttpProxySessionManager。