「拒绝踩坑」拦截 WKWebView 请求后出现崩溃的原因

2,090 阅读4分钟

上文回顾:「拒绝踩坑」唯一一种拦截 WKWebView 资源请求的方式

背景

在上文,笔者介绍了如何拦截资源请求。但文中有些疏漏之处,也有读者在评论区里提到了,会遇到This task has already been stopped这样的崩溃,导致崩溃率变高。但我们其实已经通过取消请求机制处理了大部分,常规使用上,这个崩溃就没再遇到,导致忽视了。

这次也是通过线上崩溃分析,发现还是有类似的崩溃出现。通过堆栈分析,锁定到如下代码:

image.png

本质原因

闲话少说,先来聊一下出现这个崩溃的本质原因是什么:

究其根本,是因为WKWebView和我们自身的 App 所属2个不同的进程,这里的WKURLSchemeTask本质上是进程间通信的句柄。

进程间通信是一个耗时操作,且不能保证连接的双方生命周期一致。这不是线程通信,我们可以通过引用计数来持有对象。

这就导致,在传输前或者传输中,经过WKURLSchemeTask传输时,WKWebView所在进程被释放,就会导致出现This task has already been stopped异常抛出崩溃。

问题分析

传输前

如果是WKURLSchemeTask传输前,我们在上文中已经做了防护:

通过系统提供的有限的2个代理方法,我们可以拿到停止的时机:

- (void)webView:(WKWebView *)webView stopURLSchemeTask:(id<WKURLSchemeTask>)urlSchemeTask {
    [task cancel]; // 这里执行任务停止动作
}

传输中(本次遇到)

在传输前拦截已经可以杜绝大部分崩溃情况, 但有一种情况会导致在传输中发生崩溃:

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
    ...
    [self.schemeTask didReceiveData:data]; // 崩溃出现在这里
}

原因是返回的数据是分片的,这里的data不是完整数据,代理方法会分片返回多次data

复现方式也被我们找到了:在加载大文件时,马上退出WKWebView,极易出现该问题。

这在系统设计上也是合理的,但现象是我们的WKURLSchemeTask数据传输无法良好的停下来,我们尝试过在[self.schemeTask didReceiveData:data]前增加各种isCanceled的判断也然并卵。

经过排查发现,崩溃的不是当次的传输,而是上一次未完成的传输。上文也说道,这个进程间通信是耗时操作,在数据量小的情况下极不易发生崩溃,而数据量大(都产生分片了)的情况下,就很有可能在传输过程中产生中断,导致崩溃。

解决方案

方案一:持有WKWebView保证它生命周期

通过上文的分析,我们知道原因是WKWebView的释放让WKURLSchemeTask猝不及防。

那一个简单的思路就是我们让WKWebView释放不要那么及时。

做法上,不建议让请求拦截与WKWebView实例耦合,这样会破坏单一职责原则。

好的做法是构建WKWebView容器池,使用容器复用的方式,不再释放容器,而是循环利用当前的容器,天然就避免了这个崩溃的产生。

方案二:增加 try catch

当然,虽然我们了解了问题的本质,但也可以头疼医头、脚疼医脚的方式解决问题。

我们发现它虽然是崩溃,但它好在是一个被抛出的Exception,而不是内存指针崩溃。

那虽然基本没人在 iOS 上使用try catch,但确实能解决这个问题。

    @try {
        [self.schemeTask didReceiveData:data];
    } @catch (NSException *exception) {}
    
    ...
    
    @try {
        [self.schemeTask didReceiveResponse:response];
        completionHandler(NSURLSessionResponseAllow);
    } @catch (NSException *exception) {
        completionHandler(NSURLSessionResponseCancel);
    }
    
    ...
    
    @try {
        if (error != nil) {
            [self.schemeTask didFailWithError:error];
        } else {
            [self.schemeTask didFinish];
        }
    } @catch (NSException *exception) {
    } @finally {
        ...
    }

最好在所有使用WKURLSchemeTask的地方都加上try catch

当然,增加了try catch会导致打包后体积略微增大,但对比处理线上崩溃而言,这还是性价比比较高的。

其他方案

网上其实还有其他的解决方案,但或多或少不适合我们全面铺开使用的场景。

避免分片传输

网上很多解决方案都是用的如下方式:

if (self.tasks[urlSchemeTask.description]) {
      if (error) {
          [urlSchemeTask didFailWithError:error];
      } else {
          [urlSchemeTask didReceiveResponse:response];
          [urlSchemeTask didReceiveData:data];
          [urlSchemeTask didFinish];
      }
 }

如果当前任务还存在,就整体执行传输操作,不再把data分片传输。

这虽然可以解决问题,但破坏了系统的设计,遇到大文件传输,会降低体验和劣化性能。

总结

系统提供的拦截方式真的太少了,但凡能提供一个安全调用的方式也好。但也可以从侧面说明,iOS 根本不想我们去做拦截操作,甚至不推荐使用WebView

但现实上跨端融合的趋势是不可避免的,拦截也是性能优化的一部分。从上文的评论区可以看到,遇到拦截问题的同学也不少。

还是希望大家能少踩坑 ~


感谢阅读,如果对你有用请点个赞 ❤️

中秋节GIF动图引导在看提示.gif