IOS中的网络拦截总结
因为业务场景需要,要求对App中网络请求进行拦截。这里包括原生网络请求和WebView里的网络请求。之前我们了解过原生网络请求的拦截是可以实现的,但是WebView中网络请求似乎还不太可能,所以抱着尝试的心态去了解了下,探索过程如下:
1.一般原生中的请求拦截
总所周知,IOS中可以通过继承NSURLProtocol来实现原生网络请求的拦截,那么为什么NSURLProtocol可以做到拦截呢?这里我们看一张官方的URL Loading图:
URL Loading System结构图.png
从图中我们可以看到,每一个URL Request都会有一个NSURLProtocol来实现监听(注意是每一个)。那么具体的监听过程是怎么样呢?下面简单的描述下NSURLProtocol的用法:
Don't instantiate an NSURLProtocol subclass directly. Instead, create subclasses for any custom protocols or URL schemes that your app supports. When a download starts, the system creates the appropriate protocol object to handle the corresponding URL request. You define your protocol class and call the registerClass: class method during your app’s launch time so that the system is aware of your protocol.
官方是这样的描述的,我们不能直接使用NSURLProtocl,它只是个抽象类,需要继承它。
1.1 实现一个类型继承自NSURLProtocol
关键函数:
//是否处理该请求,返回YES时,将被拦截,需要自作处理。
+(BOOL)canInitWithRequest:(NSURLRequest *)request;
//返回一个准确的request,可以处理重定向或者设置request参数等
+(NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request
//这个方法主要用来判断两个请求是否是同一个请求,如果是,则可以使用缓存数据,通常只需要调用父类的实现即可,默认为YES,而且一般不在这里做事情
+ (BOOL)requestIsCacheEquivalent:(NSURLRequest *)a toRequest:(NSURLRequest *)b
//为每一个request返回一个自定义协议,这里一般调用super方法,不做其他处理
-(instancetype)initWithRequest:(NSURLRequest *)request cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id<NSURLProtocolClient>)client
//用来标记处理过的request,防止多次处理
+ (void)setProperty:(id)value forKey:(NSString *)key inRequest:(NSMutableURLRequest *)request
//当此方法被调用时,使用者需要自己完成当前request的操作并通过NSURLProtocolClient返回请求结果
-(void)startLoading
//当此方法被调用时,应该停止request请求或者停止向requestClient返回结果
-(void)stopLoading
实战说明:
//TestProtocol.h
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface TestProtocol : NSURLProtocol
@end
NS_ASSUME_NONNULL_END
#import "TestProtocol.h"
static NSString *const TestInitKey = @"TestInitKey";
@interface TestProtocol ()
@property (nonatomic,strong)NSURLSessionDataTask *dataTask;//这里的理解是,每次发起一次URL Loading 就会产生一个NSURLProtocol,也就是这里的dataTask不会被覆盖
@end
@implementation TestProtocol
+(BOOL)canInitWithRequest:(NSURLRequest *)request
{
//判断是否已经处理过request
if ([NSURLProtocol propertyForKey:TestInitKey inRequest:request]) {
return NO;
}
//这里可添加拦截逻辑
if ([request.URL.absoluteString hasPrefix:@"https://v.juhe.cn/toutiao/index"]) {
return YES;
}
return NO;
}
/**
* 返回一个准确的request,
* 在这里可以设置请求头,重定向等
*/
+(NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request
{
NSMutableURLRequest *mutableURLRequest = request.mutableCopy;
return mutableURLRequest;
}
/**
* 判断网络请求是否一致,一致的话使用缓存数据。没有需要就调用 super 的方法
*/
+ (BOOL)requestIsCacheEquivalent:(NSURLRequest *)a toRequest:(NSURLRequest *)b
{
return [super requestIsCacheEquivalent:a toRequest:b];
}
-(instancetype)initWithRequest:(NSURLRequest *)request cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id<NSURLProtocolClient>)client
{
return [super initWithRequest:request cachedResponse:cachedResponse client:client];
}
-(void)startLoading{
NSLog(@"startLoading:%@",self);
//这里还可以把结果缓存起来直接返回出去
NSMutableURLRequest *mutableURLRequest = [self.request mutableCopy];
//给处理的request添加标记
[NSURLProtocol setProperty:@(YES) forKey:TestInitKey inRequest:mutableURLRequest];
NSURLSessionConfiguration *sessionConfigura = [NSURLSessionConfiguration defaultSessionConfiguration];
NSURLSession *session = [NSURLSession sessionWithConfiguration:sessionConfigura];
NSURLSessionDataTask *dataTask = [session dataTaskWithRequest:self.request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
NSMutableDictionary *dict = [[NSJSONSerialization JSONObjectWithData:data options:0 error:nil] mutableCopy];
//这里可以修改返回值的response
[dict setObject:@"1234567890" forKey:@"reason"];
data = [NSJSONSerialization dataWithJSONObject:dict options:NSJSONWritingFragmentsAllowed error:nil];
if (!error) {
[self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
[self.client URLProtocol:self didLoadData:data];
[self.client URLProtocolDidFinishLoading:self];
}else{
[self.client URLProtocol:self didFailWithError:error];
}
}];
NSLog(@"dataTask1:%@",dataTask);
_dataTask = dataTask;
[dataTask resume];
}
-(void)stopLoading{
NSLog(@"stopLoading");
NSLog(@"dataTask2:%@,resopnseURL:%@,progress:%@",self.dataTask,[(NSHTTPURLResponse *)self.dataTask.response URL],self.dataTask.progress);
[self.dataTask cancel];
}
@end
1.2 向系统注册自定义NSURLProtocol
Register any custom NSURLProtocol subclasses prior to making URL requests. When the URL loading system begins to load a request, it tries to initialize each registered protocol class with the specified request. The first NSURLProtocol subclass to return YES when sent a canInitWithRequest: message is used to load the request. There is no guarantee that all registered protocol classes will be consulted
//App启动时注册
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// Override point for customization after application launch.
[NSURLProtocol registerClass:[TestProtocol class]];
return YES;
}
2.在UIWebView中拦截网络请求
常规的方式也可以拦截UIWebView中的网络请求,和拦截原生网络请求没啥区别,这里不做过多描述。
3.在WKWebView中拦截网络请求
这里很不愉快,在WKWebView中是无法通过简单的自定义NSURLProtocol来实现。但也并非完全不行,下面可以做一些方法尝试:
3.1 通过WKURLSchemeTask来实现自定义scheme拦截
/* @abstract Sets the URL scheme handler object for the given URL scheme.
@param urlSchemeHandler The object to register.
@param scheme The URL scheme the object will handle.
@discussion Each URL scheme can only have one URL scheme handler object registered.
An exception will be thrown if you try to register an object for a particular URL scheme more than once.
URL schemes are case insensitive. e.g. "myprotocol" and "MyProtocol" are equivalent.
Valid URL schemes must start with an ASCII letter and can only contain ASCII letters, numbers, the '+' character,
the '-' character, and the '.' character.
An exception will be thrown if you try to register a URL scheme handler for an invalid URL scheme.
An exception will be thrown if you try to register a URL scheme handler for a URL scheme that WebKit handles internally.
You can use +[WKWebView handlesURLScheme:] to check the availability of a given URL scheme.
*/
- (void)setURLSchemeHandler:(nullable id <WKURLSchemeHandler>)urlSchemeHandler forURLScheme:(NSString *)urlScheme API_AVAILABLE(macos(10.13), ios(11.0));
这里我们了解到,这个方法能用来自定义URL Scheme来实现请求拦截。举个例子:
可以通过自定义scheme获取本地沙盒图片等资源文件.
//js前端代码
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<h2>这是一张图片</h2>
<img style="width: 200px;height:200px;" id="image" src="" /><br/>
<button style="width: 200px;height:40px;" onclick="btnAction()">加载路径</button>
<script>
window.index = 0;
function btnAction() {
//这里添加index原因是,相同的url request只会被拦截一次
var imagePathStr = "wws://imagePath.png" + '-'+window.index;
console.log(imagePathStr);
document.getElementById("image").src = imagePathStr;
window.index ++;
}
</script>
</html>
//CustomURLSchemeHandle.h
#import <Foundation/Foundation.h>
#import <WebKit/WebKit.h>
NS_ASSUME_NONNULL_BEGIN
@interface CustomURLSchemeHandle : NSObject <WKURLSchemeHandler>
+ (instancetype)sharedHandle;
@end
NS_ASSUME_NONNULL_END
#import "CustomURLSchemeHandle.h"
#import <objc/runtime.h>
static char *const stopKey = "stopKey";
@interface NSURLRequest(requestId)
@property (nonatomic,assign) BOOL ss_stop;
@end
@implementation NSURLRequest(requestId)
- (void)setSs_stop:(BOOL)ss_stop
{
objc_setAssociatedObject(self, stopKey, @(ss_stop), OBJC_ASSOCIATION_ASSIGN);
}
-(BOOL)ss_stop
{
return [objc_getAssociatedObject(self, stopKey) boolValue];
}
@end
@implementation CustomURLSchemeHandle
static CustomURLSchemeHandle *_handler;
+(instancetype)sharedHandle
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
_handler = [[self alloc] init];
});
return _handler;
}
/**
* 注意:相同的URL Load Task 只会被拦截一次
*/
-(void)webView:(WKWebView *)webView startURLSchemeTask:(id<WKURLSchemeTask>)urlSchemeTask
{
NSLog(@"startURLSchemeTask:%@",urlSchemeTask.request.URL);
if ([urlSchemeTask.request.URL.scheme isEqualToString:@"wws"]) {
NSData *data = [NSData dataWithContentsOfURL:[NSBundle.mainBundle URLForResource:@"appsearch@3x.png" withExtension:nil]];
NSURLResponse *URLResponse = [[NSURLResponse alloc] initWithURL:urlSchemeTask.request.URL MIMEType:@"image/jpeg" expectedContentLength:data.length textEncodingName:nil];
[urlSchemeTask didReceiveResponse:URLResponse];
[urlSchemeTask didReceiveData:data];
[urlSchemeTask didFinish];
}else{
NSURLSessionConfiguration *configura = [NSURLSessionConfiguration defaultSessionConfiguration];
NSURLSession *session = [NSURLSession sessionWithConfiguration:configura];
NSMutableURLRequest *request = [urlSchemeTask.request mutableCopy];
NSURLSessionDataTask *dataTask = [session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
if (!request.ss_stop) {
if (!error) {
[urlSchemeTask didReceiveResponse:response];
[urlSchemeTask didReceiveData:data];
[urlSchemeTask didFinish];
}else{
[urlSchemeTask didFailWithError:error];
}
}
}];
[dataTask resume];
}
//这里还可以自定义网络请求
}
-(void)webView:(WKWebView *)webView stopURLSchemeTask:(id<WKURLSchemeTask>)urlSchemeTask
{
NSLog(@"stopURLSchemeTask:%@",urlSchemeTask.request.URL);
urlSchemeTask.request.ss_stop = YES;
}
@end
当然这里的方法也有限制,ios11.0之后才允许被使用,且如果想用来拦截内置的http、https等scheme会报错闪退.
Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: ''http' is a URL scheme that WKWebView handles natively'
3.2 私有方法注入
网上大量文章有介绍关于如何使用私有方法向系统注册http,https等内置协议,这里不再做深入介绍。
实现方式大概如下:
拿到WebKit底层的"browsingContextController" 对象,直接向其注入常规scheme.
//实现分类如下:
@interface NSURLProtocol (WebKit)
+ (void)wk_registerScheme:(NSString *)scheme;
+ (void)wk_unregisterScheme:(NSString *)scheme;
@end
@implementation NSURLProtocol (WebKit)
FOUNDATION_STATIC_INLINE Class ContextControllerClass() {
static Class cls;
if (!cls) {
cls = [[[WKWebView new] valueForKey:@"browsingContextController"] class];
}
return cls;
}
FOUNDATION_STATIC_INLINE SEL RegisterSchemeSelector() {
return NSSelectorFromString(@"registerSchemeForCustomProtocol:");
}
FOUNDATION_STATIC_INLINE SEL UnregisterSchemeSelector() {
return NSSelectorFromString(@"unregisterSchemeForCustomProtocol:");
}
+ (void)wk_registerScheme:(NSString *)scheme {
Class cls = ContextControllerClass();
SEL sel = RegisterSchemeSelector();
if ([(id)cls respondsToSelector:sel]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[(id)cls performSelector:sel withObject:scheme];
#pragma clang diagnostic pop
}
}
+ (void)wk_unregisterScheme:(NSString *)scheme {
Class cls = ContextControllerClass();
SEL sel = UnregisterSchemeSelector();
if ([(id)cls respondsToSelector:sel]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[(id)cls performSelector:sel withObject:scheme];
#pragma clang diagnostic pop
}
}
这里不推荐使用这种方式,毕竟使用私有API有被拒风险且很高。
3.3 利用分类覆盖系统原有方法
这里翻看WebKit源码可以发现,之所以不让注册http,https等内置scheme是因为handlesURLScheme
在搞事,我们可以重写该方法来覆盖系统原来的判断来避免闪退。(会有警告,但不影响使用)
#import "WKWebView+handlesURLScheme.h"
@implementation WKWebView (handlesURLScheme)
+ (BOOL)handlesURLScheme:(NSString *)urlScheme
{
NSLog(@"handlesURLScheme:%@",urlScheme);
return NO;
}
@end
这样,我们可以愉快使用3.1中的方法来向系统注册http,https等固有scheme.
3.4 JS注入拦截(推荐)
我们知道浏览器内置AjaxHttpRequest对象,Web内部发起的网络难道就没有办法通过拦截Ajax方式来实现么?答案是肯定的。
我们知道,Ajax发起网络请求有几个必要方法:open,send等。我们可以通过重写方法来达到拦截的目的,甚至还可以修改返回参数等。具体js如下:
//AjaxHook.js
var needHook = true;
var open = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function() {
if(needHook){
this.addEventListener( "loadend" , function() {
var message = { status : this.status, responseURL : this.responseURL, data:this.responseText, xhrIntercept:true }
window.webkit.messageHandlers.xxxBridge.postMessage(message);
});
setupHook(this);
}
open.apply(this, arguments);
};
var requestHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.setRequestHeader = function(key,value) {
console.log('key:',key);
console.log('value:',value);
requestHeader.apply(this,arguments);
}
function setupHook(xhr){
function updateResponse(responseURL,responseText){
//在这里可以调用原生桥接来同步(prompt)修改
console.log('responseURL:',responseURL);
var newRes = prompt(JSON.stringify({AjaxHook:true,responseURL}));
console.log('newRes:',newRes);
return newRes;
}
function getter(){
var newRes = updateResponse(xhr.responseURL,origin);
if (newRes !== null && newRes !== undefined){
return newRes;
}
delete xhr.responseText;
var origin = xhr.responseText;
return origin;
}
function setter(str){
console.log('set responseText: %s', str);
}
Object.defineProperty(xhr, 'responseText', {
get: getter,
set: setter,
configurable: true
});
}
这样,我们可以巧妙地不费吹灰之力就可以拦截js中的网络请求,当然如果拿这个去截取别的网站的数据是不推荐的,并且大多数网站也做了Ajax防拦截处理。
这里有几个注意点说明一下:
- 首先通过注册监听来回调WKWebView中的MessageHandle方法,自定义消息格式传递请求URL及返回参数.
- 可以通过修改Ajax内置变量"responseText"来达到修改返回值的目的,这里有点特殊处理,下面继续介绍。
经过分析发现,如想要修改responseText,需要重写ajax的getter方法且原生同步返回的值,然后js和oc交互系统有提供如下两个方法:
//oc -> js
- (void)evaluateJavaScript:(NSString *)javaScriptString completionHandler:(void (^ _Nullable)(_Nullable id, NSError * _Nullable error))completionHandler
//js -> oc
//window.webkit.messageHandlers.name.postMessage
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message
看来看去,这里的两个方法都是异步方法,并没有像UIWebView一样有提供同步方式:
//同步执行
- (nullable NSString *)stringByEvaluatingJavaScriptFromString:(NSString *)script
那么我们是不是到此彻底没有解决办法了呢?仔细的同学可以发现这里我们采用的prompt来达到同步的效果。有人会问prompt不是弹框么,是的,它确实是弹框。但是苹果把弹框操作交给了开发者自行处理,我们可以自己做处理是否需要弹框啊.并支持返回一段字符串。
//部分源码如下:
- (void)webView:(WKWebView *)webView runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt defaultText:(NSString *)defaultText initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSString * _Nullable))completionHandler{
//同步执行js
//返回{responseURL:"",responseText:'',jsSync:1}
NSData *passData = [prompt dataUsingEncoding:NSUTF8StringEncoding];
NSDictionary *json = passData.mj_JSONObject;
if ([json[@"AjaxHook"] boolValue]) {
NSString *responseURL = json[@"responseURL"];
//从缓存中查找
completionHandler(nil);
return;
}
}
好的,这里我们可以同步返回处理后的值了,是不是很高兴呢?到此,可以愉快的拦截了。
以上几种方式是在项目中实际体验过,各有千秋,但是更推荐采用js注入的形式处理。这里暂时做一下总结,加深印象,如果大家有什么问题,欢迎来讨论,谢谢。