因为采用了离线加载功能,导致近期很多页面上传图片、音频等流数据的接口出现问题,经过分析排查后,发现bodydata为空,但是js端调用Axios的时候,确实是传了的,但是APP拦截后查看NSURLRequest的httpBodyData为空!这就....
一、接口拦截加载的实现
objectivec@interface WKWebViewUrlSchemeHandler : NSObject <WKURLSchemeHandler> @property (nonatomic, weak) WKWebView *webView; //自定义处理拦截请求 @property (nonatomic, copy) BOOL (^ holdonSchemeBlock)(id <WKURLSchemeTask> urlSchemeTask); @end
objectivec@interface WKWebViewUrlSchemeHandler () @end @implementation WKWebViewUrlSchemeHandler - (void)webView:(nonnull WKWebView *)webView startURLSchemeTask:(id<WKURLSchemeTask>)urlSchemeTask { [self webRequestWithUrlSchemeTask:urlSchemeTask]; } /** 停止加载 */ - (void)webView:(WKWebView *)webView stopURLSchemeTask:(id<WKURLSchemeTask>)urlSchemeTask { [urlSchemeTask didFinish]; } - (void)webRequestWithUrlSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask { if (!self.holdonSchemeBlock || !self.holdonSchemeBlock(urlSchemeTask)) { NSURLRequest *originRequest = urlSchemeTask.request; // 补齐bodyData NSString *uploadId = originRequest.allHTTPHeaderFields[[WKWebViewUploadBodyKeys theField]]; if (uploadId.length > 0) { dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ NSString *overrideContentType = nil; NSData *body = [[WKWebViewUploadBodyKeys sharedKeys] takeBodyForId:uploadId contentType:&overrideContentType timeout:15.0]; NSMutableURLRequest *rebuilt = [originRequest mutableCopy]; [rebuilt setValue:nil forHTTPHeaderField:uploadId]; if (body) { [rebuilt setHTTPBody:body]; if (overrideContentType.length > 0) { [rebuilt setValue:overrideContentType forHTTPHeaderField:@"Content-Type"]; } } [self doReqeuset:rebuilt urlSchemeTask:urlSchemeTask]; }); } else { [self doReqeuset:request urlSchemeTask:urlSchemeTask] } } } - (void)doReqeuset:(NSURLRequest *)request urlSchemeTask:(id<WKURLSchemeTask>)urlSchemeTask { //自定义处理协议,如SSE等 if (!self.holdonSchemeBlock || !self.holdonSchemeBlock(urlSchemeTask)) { NSString *fileUrlStr = request.URL.absoluteString; __weak typeof(self) weakSelf = self; NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]]; NSURLSessionDataTask *dataTask = [session dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) { if (!urlSchemeTask) { return; } NSString *rp = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; if (error) { [urlSchemeTask didFailWithError:error]; } else { [urlSchemeTask didReceiveResponse:response]; [urlSchemeTask didReceiveData:data]; [urlSchemeTask didFinish]; } }]; [dataTask resume]; [session finishTasksAndInvalidate]; } } @end
二、注入js
目的是将POST及PUT请求方法的参数拿到APP运行时,补齐请求数据
objectivec@interface WKWebViewUploadBodyKeys : NSObject <WKScriptMessageHandler> + (instancetype)sharedKeys; /// 注入到页面的 JS Hook 脚本(document start 注入) + (WKUserScript *)hookUserScript; /// JS 回传 body 使用的 messageHandler 名称 + (NSString *)messageHandlerName; /// js请求数据写入的字段 + (NSString *)theField; - (nullable NSData *)takeBodyForId:(NSString *)uploadId contentType:(NSString * _Nullable * _Nullable)contentType timeout:(NSTimeInterval)timeout; @end
objectivecstatic NSString *hookjavascript(void) { return @"(function(){" @" if (window.hookjavascriptInstalled) { return; }" @" window.hookjavascriptInstalled = true;" @" var HEADER = 'X-web-Upload-Id';" @" var NAME = 'HTTP-UploadBody';" @" var seq = 0;" @" function genId(){ seq += 1; return 'opAct-' + Date.now() + '-' + seq + '-' + Math.floor(Math.random()*1e9); }" @" function needIntercept(m){ if(!m){return false;} m = ('' + m).toUpperCase(); return m==='POST'||m==='PUT'||m==='PATCH'; }" @" function ab2b64(buf){" @" var bytes = new Uint8Array(buf); var bin=''; var CH=0x8000;" @" for (var i=0;i<bytes.length;i+=CH){ bin += String.fromCharCode.apply(null, bytes.subarray(i, i+CH)); }" @" return btoa(bin);" @" }" @" function post(id, buf, ct){" @" try{ window.webkit.messageHandlers[NAME].postMessage({ id:id, body:ab2b64(buf), contentType: ct||'' }); }catch(e){}" @" }" @" function serialize(url, body){" @" var req = new Request(url || '/', { method:'POST', body: body });" @" var ct = req.headers.get('content-type') || '';" @" return req.arrayBuffer().then(function(buf){ return { buffer: buf, contentType: ct }; });" @" }" @" function isForm(b){ return (typeof FormData !== 'undefined') && (b instanceof FormData); }" @" var proto = window.XMLHttpRequest && window.XMLHttpRequest.prototype;" @" if (proto) {" @" var rawOpen = proto.open, rawSend = proto.send, rawSet = proto.setRequestHeader;" @" proto.open = function(method, url){ this.__opActM = method; this.__opActU = url; this.__opActHasCT = false; this.__opActAsync = (arguments.length<3)?true:arguments[2]; return rawOpen.apply(this, arguments); };" @" proto.setRequestHeader = function(name, value){ try{ if(name && ('' + name).toLowerCase()==='content-type'){ this.__opActHasCT = true; } }catch(e){} return rawSet.apply(this, arguments); };" @" proto.send = function(body){" @" var self = this;" @" if (!needIntercept(self.__opActM) || !body || self.__opActAsync===false){ return rawSend.apply(self, arguments); }" @" var id = genId();" @" try{ rawSet.call(self, HEADER, id); }catch(e){}" @" var override = isForm(body) || !self.__opActHasCT;" @" serialize(self.__opActU, body).then(function(r){ post(id, r.buffer, override ? r.contentType : ''); rawSend.call(self, body); }, function(){ rawSend.call(self, body); });" @" };" @" }" @" if (window.fetch) {" @" var rawFetch = window.fetch;" @" window.fetch = function(input, init){" @" init = init || {};" @" var method = init.method || (input && input.method) || 'GET';" @" var url = (typeof input === 'string') ? input : (input && input.url);" @" var body = (init.body != null) ? init.body : (input && input.body);" @" if (!needIntercept(method) || !body){ return rawFetch.apply(this, arguments); }" @" var id = genId();" @" var h = new Headers((init.headers) || (input && input.headers) || {});" @" var override = isForm(body) || !h.has('content-type');" @" return serialize(url, body).then(function(r){" @" post(id, r.buffer, override ? r.contentType : '');" @" h.set(HEADER, id);" @" var ni = {}; for (var k in init){ ni[k]=init[k]; } ni.headers = h; ni.method = method; ni.body = body;" @" return rawFetch.call(window, url, ni);" @" }, function(){ return rawFetch.call(window, input, init); });" @" };" @" }" @"})();"; } @implementation WKWebViewUploadBodyKeys { NSMutableDictionary<NSString *, NSData *> *_bodyMap; NSMutableDictionary<NSString *, NSString *> *_contentTypeMap; NSCondition *_condition; } + (instancetype)sharedKeys { static WKWebViewUploadBodyKeys *store = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ store = [[self alloc] init]; }); return store; } - (instancetype)init { self = [super init]; if (self) { _bodyMap = [NSMutableDictionary dictionary]; _contentTypeMap = [NSMutableDictionary dictionary]; _condition = [[NSCondition alloc] init]; } return self; } + (NSString *)messageHandlerName { return messageHandlerKeyName; } + (NSString *)theField { return messageHeaderName; } + (WKUserScript *)hookUserScript { return [[WKUserScript alloc] initWithSource:hookjavascript() injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:NO]; } #pragma mark - WKScriptMessageHandler - (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message { if (![message.name isEqualToString:messageHandlerKeyName]) { return; } NSDictionary *dict = message.body; if (![dict isKindOfClass:[NSDictionary class]]) { return; } NSString *uploadId = dict[@"id"]; NSString *base64 = dict[@"body"]; NSString *contentType = dict[@"contentType"]; if (![uploadId isKindOfClass:[NSString class]] || uploadId.length == 0 || ![base64 isKindOfClass:[NSString class]]) { return; } NSData *data = [[NSData alloc] initWithBase64EncodedString:base64 options:0] ?: [NSData data]; [_condition lock]; _bodyMap[uploadId] = data; if ([contentType isKindOfClass:[NSString class]] && contentType.length > 0) { _contentTypeMap[uploadId] = contentType; } [_condition broadcast]; [_condition unlock]; } #pragma mark - take - (NSData *)takeBodyForId:(NSString *)uploadId contentType:(NSString * _Nullable * _Nullable)contentType timeout:(NSTimeInterval)timeout { if (uploadId.length == 0) { return nil; } NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:timeout]; [_condition lock]; while (_bodyMap[uploadId] == nil) { // waitUntilDate 返回 NO 表示已超时 if (![_condition waitUntilDate:deadline]) { break; } } NSData *data = _bodyMap[uploadId]; NSString *ct = _contentTypeMap[uploadId]; [_bodyMap removeObjectForKey:uploadId]; [_contentTypeMap removeObjectForKey:uploadId]; [_condition unlock]; if (contentType) { *contentType = ct; } return data; } @end
三、使用过程
objectivecWKPreferences *preference = [[WKPreferences alloc]init]; preference.javaScriptEnabled = YES; preference.javaScriptCanOpenWindowsAutomatically = YES; WKWebViewConfiguration *config = [WKWebViewConfiguration new]; config.preferences = preference; config.preferences.minimumFontSize = 10; config.allowsInlineMediaPlayback = YES; WKWebViewUrlSchemeHandler *scheme = [[WKWebViewUrlSchemeHandler alloc] init]; [config setURLSchemeHandler:scheme forURLScheme:@"http"]; [config setURLSchemeHandler:scheme forURLScheme:@"https"]; WKUserContentController *userContentController = WKUserContentController.new; [userContentController addUserScript:[WKWebViewUploadBodyKeys hookUserScript]]; [userContentController addScriptMessageHandler:[WKWebViewUploadBodyKeys sharedKeys] name:[WKWebViewUploadBodyKeys messageHandlerName]]; config.userContentController = userContentController; __weak typeof(self) weakSelf = self; [scheme setHoldonSchemeBlock:^BOOL(id<WKURLSchemeTask> _Nonnull urlSchemeTask) { NSURLRequest *request = urlSchemeTask.request; NSString *fileUrlStr = request.URL.absoluteString; if ([fileUrlStr containsString:@"open/ai/chat/stream"]) { //自定义的请求过程 weakSelf.sseRequest = [WKWebViewSchemeSSERequest sseRequest:request didReceiveRespone:^(NSURLResponse *response) { [urlSchemeTask didReceiveResponse:response]; } receivedDataBlock:^(NSData *data) { @try { [urlSchemeTask didReceiveData:data]; //NSLog(@"didReceiveData exception: null"); } @catch (NSException *exception) { NSLog(@"didReceiveData exception: %@", exception); } } didFinisehdBlock:^(NSURLResponse *response) { @try { [urlSchemeTask didFinish]; } @catch (NSException *exception) { NSLog(@"didFinisehdBlock exception: %@", exception); } } errorBlock:^(NSError *error) { @try { [urlSchemeTask didFailWithError:error]; } @catch (NSException *exception) { NSLog(@"errorBlock exception: %@", exception); } }]; return YES; } return NO; }]; _webView = [[WKWebView alloc]initWithFrame:CGRectMake(0, 0, KScreenSize.width, self.safeAreaHeight) configuration:config]; _webView.backgroundColor=[UIColor clearColor]; _webView.UIDelegate = self;