由于 WKWebView 在独立进程里执行网络请求。一旦注册 http(s) scheme 后,网络请求将从 Network Process 发送到 App Process,这样 NSURLProtocol 才能拦截网络请求。在 webkit2 的设计里使用 MessageQueue 进行进程之间的通信,Network Process 会将请求 encode 成一个 Message,然后通过 IPC 发送给 App Process。出于性能的原因,encode 的时候 HTTPBody 和 HTTPBodyStream 这两个字段被丢弃掉了 —— 摘自 WKWebView 那些坑
针对以上问题我使用过以下两种方案,并且测试过都是可行的
(1)方案一
我们通过 URLProtocol 拦截的 WKWebView 的时候可以不拦截 http、https,而是拦截一个指定的 customScheme。并且将 html 中所有 GET 请求的 url 的 scheme 都替换成 customScheme,POST 请求还是保持 http、https 的 scheme。下面是一个实现流程,很多代码略过了,只是说明方案。
-
下载所有的 html、css、js、图片 至沙盒
-
NSURLProtocol registerScheme
[NSURLProtocol wk_registerScheme:@"customScheme"];
- 打开页面 url,假设我们打开的 url 为
https://www.baidu.com
,通过以下代码我们就打开了一个customScheme://www.baidu.com
了,这个请求就可以被 NSURLProtocol 拦截
NSString *url = @"https://www.baidu.com";
[url stringByReplacingOccurrencesOfString:@"https" withString:@"customScheme"];
NSURLRequest *request = [[NSURLRequest alloc] initWithURL:[NSURL URLWithString:url]];
WKWebView *webview = [[WKWebView alloc] init];
[webview loadRequest:request];
- 在 NSURLProtocol 中判断是否是
customScheme
并且是我们首页index.html
的地址,是的话结果该请求,获取本地的 html 数据,手动将内部的 html、css、js、图片 的 url 替换成customScheme
+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
NSString *urlStr = request.URL.absoluteString;
if([urlStr isEqualToString:@"customScheme://www.baidu.com"]) {
return YES;
}
return NO;
}
- (void)startLoading {
NSString *urlStr = request.URL.absoluteString;
if([urlStr isEqualToString:@"customScheme://www.baidu.com"]) {
return YES;
}
// 从本地获取缓存的 https://www.baidu.com 文件的内容
NSString *indexHtml = ...;
// 将 indexHtml 中 css、js、图片 的 url 的 https 替换成 customScheme
NSString *customSchemeHtml = [indexHtml convertToCustomSchemeHtml];
NSData *htmlData = [customSchemeHtml dataUsingEncoding:NSUTF8StringEncoding];
NSURLResponse *response = ...;
[self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
[self.client URLProtocol:self didLoadData:htmlData];
[self.client URLProtocolDidFinishLoading:self];
}
- 那么
https://www.baidu.com
中的静态资源的 scheme 都替换后就可以通过 NSURLProtocol 拦截到了,拦截到后再从本地取缓存返回 WKWebView。同时因为没有拦截 http、https ,所有的 post 请求都不会被 NSURLProtocol 拦截,还是由 WKWebView 自己处理,那么就不会有 Body 丢失的问题
(2)方案二
这种方案又有两种实现
-
一种是前端手动将 POST 请求改为 JS 调用 Native 的方式,这样的话就将工作量转交给前端开发的朋友的。同时对于移动端和 PC 端都支持的 H5 页面需要前端做适配,避免在 PC 端进行 POST 请求也是用 JS 调用 Native 的方式,请求不到数据。
-
另一种就是客户端注入一段 HookAjax 的 JS 代码,拦截所有的 XMLHttpRequest 的 POST 请求转移给客户端处理。HookAjax 后也有两种方案处理该 POST 请求
- 将 POST 请求的 body 装在 header 中,NSURLProtocol 拦截到的 POST 请求可以从 header 中获取到实际的 body 数据。但是这样子有个问题,header 的大小是有限制的,会有局限性。但是实现会方便很多。
- 将 POST 请求通过 JS 和 Native 交互的方式将请求转交给 Native 处理并且在 Native 处理完后将结果返回给 JS,我下面介绍的就是这种方式的实现
下面是我的 HookAjax 的实现,主要参考了 Ajax-hook 并且做了一定的修改以更好的处理我们的情况。
function hookAjax(proxy) {
// 保存真正的XMLHttpRequest对象
window._ahrealxhr = window._ahrealxhr || XMLHttpRequest;
XMLHttpRequest = function() {
var xhr = new window._ahrealxhr;
// 直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象
Object.defineProperty(this, 'xhr', {
value: xhr
})
};
// 获取 XMLHttpRequest 对象的属性
var prototype = window._ahrealxhr.prototype;
for (var attr in prototype) {
var type = "";
try {
type = typeof prototype[attr]
} catch (e) {}
if (type === "function") {
XMLHttpRequest.prototype[attr] = hookfunc(attr);
} else {
// 给属性提供 getter、setter 方法
Object.defineProperty(XMLHttpRequest.prototype, attr, {
get: getFactory(attr),
set: setFactory(attr),
enumerable: true
})
}
}
function getFactory(attr) {
return function() {
// 判断对象是否包含特定的自身(非继承)属性
var v = this.hasOwnProperty(attr + "_") ? this[attr + "_"] : this.xhr[attr];
var attrGetterHook = (proxy[attr] || {})["getter"];
return attrGetterHook && attrGetterHook(v, this) || v
}
}
function setFactory(attr) {
return function(v) {
var xhr = this.xhr;
var that = this;
var hook = proxy[attr];
if (typeof hook === "function") { // 回调属性 onreadystatechange 等
xhr[attr] = function() {
// ======================== 修改 1 ==================
hook.call(that, xhr) || v.apply(xhr, arguments);
}
} else {
//If the attribute isn't writeable, generate proxy attribute
var attrSetterHook = (hook || {})["setter"];
v = attrSetterHook && attrSetterHook(v, that) || v;
// ======================== 修改 2 ==================
xhr[attr] = v;
this[attr + "_"] = v;
}
}
}
function hookfunc(func) {
return function() {
var args = [].slice.call(arguments);
// call() 方法调用一个函数, 其具有一个指定的this值和分别地提供的参数
// 该方法的作用和 apply() 方法类似,只有一个区别,就是call()方法接受的是若干个参数的列表,而apply()方法接受的是一个包含多个参数的数组
if (proxy[func]) {
// ========================== 修改 3 ===============
var result = proxy[func].call(this, args, this.xhr);
if(result) {
return result;
}
}
return this.xhr[func].apply(this.xhr, args);
}
}
return window._ahrealxhr;
}
针对的修改主要有上面注释的 3 个修改
修改 1
: 改变 hook 的onreadystatechange
、onload
这类方法的参数,统一参数修改 2
: 强制通过_ + 属性名
的方式添加属性到我们修改后的XMLHttpRequest
的对象中,因为有一些属性是只读属性,用 Ajax-hook 的方式我们没办法设置这些属性修改 3
: 对于getAllResponseHeaders
、getResponseHeader
返回指定结果
接下来还要添加我们拦截 Ajax Post 请求到 Native 执行的 JS 代码
window.MyAjax = {
hookedXHR: {},
hookAjax: hookAjax,
nativePost: nativePost,
nativeCallback: nativeCallback
};
// 添加请求 Native 的代码
function nativePost(xhrId, params) {
// TODO: 请求 Native
}
// Native 请求完成后调用该 JS 方法并且传入指定参数
function nativeCallback(xhrId, statusCode, responseText, responseHeaders, error) {
var xhr = window.MyAjax.hookedXHR[xhrId];
if(xhr.isAborted) { // 如果该请求已经手动取消了
return;
}
if(error) {
xhr.readyState = 1;
if(xhr.onerror) {
xhr.onerror();
}
} else {
xhr.status = statusCode;
xhr.responseText = responseText;
xhr.readyState = 4;
xhr.myResponseHeaders = responseHeaders;
if(xhr.onreadystatechange) {
xhr.onreadystatechange();
}
if(xhr.onload) {
xhr.onload();
}
}
}
// hook ajax 方法
window.MyAjax.hookAjax({
// 设置 RequestHeader 将参数保存,在 send 中将其一起发送给 Native
setRequestHeader: function (arg, xhr) {
if(!this.myHeaders) {
this.myHeaders = {};
}
this.myHeaders[arg[0]] = arg[1];
},
getAllResponseHeaders: function (arg, xhr) {
var headers = this.myResponseHeaders;
if(headers) {
if(typeof(headers) === 'object') {
var result = '';
for(var key in headers) {
result = result + key + ':' + headers[key] + '\r\n'
}
return result;
}
return headers;
}
},
getResponseHeader: function (arg, xhr) {
if(this.myResponseHeaders && this.myResponseHeaders(arg[0])) {
return this.myResponseHeaders(arg[0]);
}
},
// 保存 open 中的参数,在 send 中判断是否为 POST 方法
open: function (arg, xhr) {
this.myOpenArg = arg;
},
send: function (arg, xhr) {
this.isAborted = false;
if(this.myOpenArg[0] === 'POST') {
var params = {};
params.data = arg[0];
params.method = 'POST';
params.header = this.myHeaders;
var url = this.myOpenArg[1];
var location = window.location;
if(!url.startsWith(location.protocol)) {
url = location.origin + url;
}
params.url = url;
// XMLHttpRequest 的标识符,用于 Native 返回时确认时哪个 XMLHttpRequest
var xhrId = 'xhrId' + (new Date()).getTime();
window.MyAjax.hookedXHR[xhrId] = this;
window.MyAjax.nativePost(xhrId, params);
// 通过 return true 可以阻止默认 Ajax 请求,不返回则会继续原来的请求
return true;
}
},
abort: function (arg, xhr) {
if(this.myOpenArg[0] === 'POST') {
if(xhr.onabort) {
xhr.onabort()
}
return true;
}
}
});
上面的代码和 Native 进行交互的代码就是 nativePost
和 nativeCallback
两个方法,一个需要将 xhrId
和该 POST 请求相关的数据传给 Native,一个需要 Native 执行完请求后将 xhrId
和结果放回给 JS。这样子一个完整的流程就走通了。
这里贴一个例子Ajax-hook iOS Demo,用的是我自己的 JSBridge 作为 JS、Native 的桥接库实现的一个 Demo
将上述两端 JS 代码注入 WebView,就可以实现一个 Post 请求的拦截了。
题外话:通过原生 NSURLSession 请求时,如果用的是 NSURLSession.sharedSession
,那么该请求是会被 NSURLProtocol 拦截到的,打断点后发现 NSURLProtocol 拦截到的 POST 请求的 bady 也是 nil,因为 body 数据这时候已经被转成 stream 了,可以在 request.HTTPBodyStream
中解析它
参考资料