WKWebView 拦截 POST/PUT方法 丢失body的问题解决

因为采用了离线加载功能,导致近期很多页面上传图片、音频等流数据的接口出现问题,经过分析排查后,发现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
objectivec 复制代码
static 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

三、使用过程

objectivec 复制代码
WKPreferences *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;
相关推荐
黑化旺仔5 小时前
【OC】Runtime:类元数据、加载初始化与消息派发
开发语言·macos·ios·objective-c
songgeb5 小时前
iOS App支持设置RTL语言的实践
ios·swift·ui kit
软泡芙6 小时前
【iOS】swift启动屏显示黑屏
ios
用户69371750013848 小时前
当你向领导申请 AI 编程工具预算时,如果领导追问“能提效多少,有量化数据吗”,你该怎么回答?
android·后端·ios
Layer8 小时前
一些脱离 Mac 依赖的 Agent 操作 iPhone 实现
ios·ai编程·测试
用户69371750013849 小时前
当我说 Claude 搭配 GLM-5.3-Flash 太好用了,同事却提醒我:你可能正在失去对代码的掌控
android·后端·ios
2501_916008899 小时前
马甲包过审卡在哪?Ipa Guard 能做的和做不到的
android·小程序·https·uni-app·iphone·webview
茶底世界之下10 小时前
一键裁方会丢掉什么?用 Awaking 给园林照片做两种画幅
ios
Marco_Marco13511 小时前
定位SDK接入实战:Android/iOS/鸿蒙三端怎么选、怎么接
android·ios·harmonyos
老李IT笔记1 天前
关掉设备上的 AI 扩展:两个键各管一层,配错了等于没配
人工智能·ios·智能手机