提升WKWebView性能的优化方案

723 阅读8分钟

本篇多摘抄自其他方案:参考目录如下

「拒绝踩坑」唯一一种拦截 WKWebView 资源请求的方式

网易云 WKWebView 请求拦截探索与实践

WKWebview秒开实践分享及问题解决方案

货拉拉H5离线包原理与实践

静态资源预加载

一、踩坑方案

1. 拦截 Http/Https Scheme

该方案解决不了postbody问题和Blob问题。

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

image.png

主要是拦截http请求,原本要去下载 HTML/JS/CSS,拦截下来

  1. 拦截Http
    1. WKURLSchemeHandler 实现拦截
    2. 混淆拦截代码
  2. 判断缓存
  3. 去下载
  4. 资源清理
  5. 问题:
    1. postbody问题,WKWebView 在iOS11.3以后修复了这个,所以只支持11.3
    2. Blob 文件上传等无法处理,

2. 网易云 XMLHttpRequest / fetch

网易云方案,可能会导致更多问题,比如重定向、FormData.prototype.entries 是在 iOS 11.2 以后的版本才支持、iframe 特殊处理等等。

  1. 能解决 POST 请求的 body 问题
  2. 重定向不管,影响不大
  3. cookie 同步
  4. NSURLSession 在 WKURLSchemeHandler 协议方法里实现sessionWithConfiguration:delegate:delegateQueueNSURLSession强引用,导致内存泄漏,要在 WKWebView 销毁时调用 NSURLSession 的 invalidateAndCancel 方法来解除对 WKURLSchemeHandler 对象的强引用。

二、可行方案

1. HTML/JS/CSS模板+拦截离线包

WKWebview秒开实践分享及问题解决方案

  1. WKWebView预加载
  2. HTML/JS/CSS本地模板
  3. HTML解析优化

image.png

2. 货拉拉离线包加载

货拉拉H5离线包原理与实践

3. 静态资源预加载

原文静态资源预加载

方案核心

此方案利用的是浏览器的缓存策略,一般访问资源时会有个检查缓存步骤,通过cache-control判断缓存是否过期,利用给资源配置cache-control的max-age,决定是否从缓存加载

location ~* .(\d+).html$ {
 add_header Access-Control-Allow-Origin *;
 add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable";
}

可显著提升网页启动速度(缩短页面加载时间30%-50%以上)。需要客户端、前端、服务端三端配合完成。

从技术视角,分析一下网页启动的几个关键耗时阶段。 image.png

Native页面是网页应用首页的前置页面,这就给了我们一个很好的预加载首页时机:可以在Native页面中去预加载网页应用首页,从而提高网页应用首页的启动速度。

要实现这一机制,需要解决如下问题:

  • 如何在Native页面下载Web网页静态资源,并放入缓存区?
  • Web网页打开时,如何命中上述缓存?

image.png

  • 客户端:
    • 在App启动或进入导航类Native页面时,初始化一个隐藏不可见的WebView组件,打开预加载器模块H5页面
    • WIFI条件下操作
  • 前端:
    • 请求接口获取要预加载的静态资源(JS/CSS)
    • 调用浏览器Fetch方法,下载列表中的静态资源,存储到WebView HTTP缓存区
    • 静态资源下载完毕后,通知Native销毁隐藏WebView
  • 服务端:
    • 维护静态资源 URL 的配置列表
    • Nginx 资源配置,Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable";

前端预加载策略

  • quicklink:quicklink 是 GoogleChromeLabs 推出的轻量级库,使用 Resource Hints 进行预加载,对于不支持的浏览器会回退到 XHR 模式。它的策略其实非常直接,核心就是当链接进入到视口后,会对其进行预加载。
  • Webpack 4.6.0+ 支持了预加载能力,一个是 prefetch,一个是 preload。
    • prefetch: 资源在未来的某个时刻使用,在核心代码加载完成之后带宽空闲的时候再去加载需要用到的模块代码
    • preload: 资源在当前页面中也会使用,和核心代码文件一起去加载的。

关于前端预加载,可以看这篇

问题

1.HTML主文档的预加载

入口html通常设置为不缓存,每次请求都会从服务端获取最新内容。

image.png

解决方案可以在出包时给html加版本号,并设置资源缓存时间久点。

# Nginx配置示例
# *.[version-number].html file: cache 1 year
location ~* .(\d+).html$ {
 add_header Access-Control-Allow-Origin *;
 add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable";
}
# *.html file: no-cache
location ~* .html$ {
 add_header Access-Control-Allow-Origin *;
 add_header Cache-Control "no-cache, no-store, must-revalidate";
}

从线上项目实践来看,仅对html增加版本号这一项(未进行任何资源预加载),即可提升完全加载时间 10% ~ 20% 左右,对于老客多的项目,这是一个简单有效的优化手段。

2.prefetch的资源在第一次用到时仍然会重新请求

是同源的问题,把预加载器和项目放在同一个域名下就好了。

三、最终优化方案

1. WKWebView复用池预加载

首次初始化 Webview,需要初始化浏览器内核,需要的时间在400ms这个量级;二次初始化时间在几十ms这个量级,而WKWebView能复用的话,时间还能再缩短。

美团使用 iOS 模拟器作统计在 iOS10 系统上,首次打开比再次打开会多700ms。我用真机测从打开到 didFinish 回调回来速度快了200ms左右。

复用池原理

  1. 在系统空闲时间创建 WK 复用池,我这里通过监听 runloop 的kCFRunLoopBeforeWaiting时机创建复用池及 WK 实例。
  2. 在 set、get、didReceiveMemoryWarning、设置最大缓存数量这四个时机,检测并处理复用池的实例,做到动态调整。

2. 静态资源预加载

上文可行方案的第 3 个静态资源预加载,核心如下

  1. 预加载WKWebView时,调用前端预加载器模块 H5 页面
  2. 前端根据服务端配置的接口,执行预加载。
  3. 加载完成后关闭WKWebView

四、其他优化

1. 拦截离线资源包

拦截图片改从本地加载

1、注册拦截的URLScheme

WKWebViewConfiguration *webConfig = [[WKWebViewConfiguration alloc] init];
[webConfig setURLSchemeHandler:C4WKURLSchemeHandler.new forURLScheme:MyLocalImageScheme];

2、实现WKURLSchemeHandler协议,拦截对应 URL Scheme

@interface C4WKURLSchemeHandler : NSObject<WKURLSchemeHandler>
@end

@implementation C4WKURLSchemeHandler

- (void)webView:(WKWebView *)webView startURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask  API_AVAILABLE(ios(11.0)) {
    NSURL *requestURL = urlSchemeTask.request.URL;
    NSString *filter = [NSString stringWithFormat:MyLocalImageScheme];

    if ([requestURL.absoluteString containsString:filter]) {
        //最后在执行didFinish,不可重复调用,可能会导致崩溃
        [urlSchemeTask didReceiveResponse:[NSURLResponse new]];
        
        NSString *path = requestURL.absoluteString;
        NSArray *items = [path componentsSeparatedByString:[MyLocalImageScheme stringByAppendingString:@"://"]];
        if (items != nil) {
            // 获取图片URL
            path = items.lastObject;
        }
        // 本地图片从本地路径取,网络图片走 SDWebImage 下载
        [urlSchemeTask didReceiveData:[NSData dataWithContentsOfFile:path]];
        
        [urlSchemeTask didFinish];
        return;
    }

    NSURLRequest *request = urlSchemeTask.request;
    NSURLSessionDataTask *task = [[NSURLSession sharedSession]
                                  dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
        //也可以通过解析data等数据,通过data等数据来确定是否拦截
        //一个任务完成需要返回didReceiveResponse和didReceiveData两个方法
        //最后在执行didFinish,不可重复调用,可能会导致崩溃
        if (!data) {
//            [urlSchemeTask didReceiveResponse:[NSURLResponse new]];
//            [urlSchemeTask didReceiveData:[NSData dataWithContentsOfFile:
//                                           [[NSBundle mainBundle] pathForResource:@"test1" ofType:@"jpeg"]]];
        } else {
            [urlSchemeTask didReceiveResponse:response];
            [urlSchemeTask didReceiveData:data];
            
        }
        [urlSchemeTask didFinish];
    }];
    [task resume];
}

@end

2. 自定义字体

自定义字体走网页端,需要下载 10MB 的字体文件,走本地注入节省时间。

本地字体资源

NSString *fileUrl1 = [[NSBundle mainBundle] pathForResource:@"alternategothic2bt" ofType:@"ttf"];
NSString *fileUrl2 = [[NSBundle mainBundle] pathForResource:@"gaoguaikeaidafanpa" ofType:@"ttf"];


NSData *fileData1 = [NSData dataWithContentsOfFile:fileUrl1];
NSString *boldFont1 = [fileData1 base64EncodedStringWithOptions:0];
NSData *fileData2 = [NSData dataWithContentsOfFile:fileUrl2];
NSString *boldFont2 = [fileData2 base64EncodedStringWithOptions:0];

NSMutableString *javascript = [NSMutableString string];
[javascript appendString:[NSString stringWithFormat:@"\
                                  var boldcss = '@font-face { font-family: \"%@\"; src: url(data:font/ttf;base64,%@);} @font-face { font-family: \"%@\"; src: url(data:font/ttf;base64,%@);}'; \
                                  var head = document.getElementsByTagName('head')[0], \
                                  style = document.createElement('style'); \
                                  style.type = 'text/css'; \
                                  style.innerHTML = boldcss; \
                                  head.appendChild(style);", @"NotoSerifCJKjp-Bold", boldFont1, @"FOTSkipStd-B",boldFont2]];
[webView evaluateJavaScript:javascript completionHandler:nil];

动态下载字体资源

iOS中使用自定义字体, 动态下载字体

使用步骤如下

  1. 下载自定义字体
  2. 利用 CoreText 框架注册字体到系统,每次启动应用都要注册
  3. 使用时判断一下是否已注册到系统

写了一个字体文件注册的工具类如下所示

@implementation C4DynamicFont

+ (instancetype)sharedInstance {
    static C4DynamicFont * sharedInstance = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        sharedInstance = [[C4DynamicFont alloc] init];
    });

    return sharedInstance;
}

/**
 * 注册字体,每次程序启动都需要注册才可使用
 * @param fontPath 字体文件的本地路径
 * @return 返回字体名称,若返回 nil,则表示注册失败
 */
+ (NSString *)registerWithFontPath:(NSString *)fontPath {
    NSData *dynamicFontData = [NSData dataWithContentsOfFile:fontPath];
    if (!dynamicFontData){
        return nil;
    }
 
    CFErrorRef error;
    CGDataProviderRef providerRef = CGDataProviderCreateWithCFData((__bridge CFDataRef)dynamicFontData);
    CGFontRef font = CGFontCreateWithDataProvider(providerRef);
    NSString *fontName = (__bridge NSString *)CGFontCopyFullName(font);
    
    if (! CTFontManagerRegisterGraphicsFont(font, &error)){
        //注册失败
        CFStringRef errorDescription = CFErrorCopyDescription(error);
        CFRelease(errorDescription);
    }
    CFRelease(font);
    CFRelease(providerRef);
    
    return fontName;
}

/**
 * 字体是否已注册,已注册的字体才可使用
 * @param fontName 字体名称
 * @return YES 已注册, NO 未注册需要注册
 */
+ (BOOL)isFontRegistered:(NSString *)fontName {
 
    UIFont* aFont = [UIFont fontWithName:fontName size:12.0];
    BOOL isDownloaded = (aFont && ([aFont.fontName compare:fontName] == NSOrderedSame || [aFont.familyName compare:fontName] == NSOrderedSame));
    return isDownloaded;
}

- (NSMutableDictionary *)fontNameDict {
    if (!_fontNameDict) {
        _fontNameDict = [NSMutableDictionary dictionary];
    }
    return _fontNameDict;
}

@end

使用时需要判断字体文件是否已注册,写了一个分类方便使用

@implementation UIFont (DynamicFont)

+ (UIFont *)c4_fontWithFontPath:(NSString *)fontPath fileName:(NSString *)fileName size:(CGFloat)fontSize {
    NSString *fontName = [C4DynamicFont registerWithFontPath:fontPath];
    if (!fontName) return nil;
    [C4DynamicFont sharedInstance].fontNameDict[fileName] = fontName;
    return [self fontWithName:fontName size:fontSize];
}

+ (UIFont *)c4_fontWithName:(NSString *)name size:(CGFloat)fontSize {
    
    if ([C4DynamicFont isFontRegistered:[C4DynamicFont sharedInstance].fontNameDict[name]]) {
        NSString *haha = [C4DynamicFont sharedInstance].fontNameDict[name];
        UIFont* aFont = [self fontWithName:haha size:fontSize];
        return aFont;
    }
    
    [C4DynamicFont sharedInstance].fontNameDict[name] = name;
    // 沙盒Documents目录
    NSString * appDir = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) lastObject];
    NSString *filePath = [appDir stringByAppendingPathComponent:name];
    
    NSFileManager *fileManager = [NSFileManager defaultManager];
    if(![fileManager fileExistsAtPath:filePath]) {
        //如果不存在,则在工程路径找一下
        // filePath = ...
    }
    
    return [self c4_fontWithFontPath:filePath fileName:name size:fontSize];
}

@end

3. 白屏处理

UIWebView上当内存占用太大的时候,App Process 会崩溃 crash;而当WKWebView总体的内存占用比较大的时候,WebContent Process 会崩溃 crash,从而导致白屏现象。

webViewWebContentProcessDidTerminate

- (void)webViewWebContentProcessDidTerminate:(WKWebView *)webView NS_AVAILABLE(10_11, 9_0)
{
    NSLog(@"进程被终止 %@",webView.URL);
	self.reloadTime += 1;
    if (self.reloadTime > 2) {
    	self.reloadTime = 0;
        return;
    }
    [webView reload];
}

截屏检测

利用WKWebView的截屏,回到图片,再计算图片像素白色占比,大于 95% 则认为白屏

- (void)whiteScreenDetection {
    C4Kit_WeakSelf
    [self judgeLoadingStatusWithBlock:^(C4WebviewLoadingStatus status) {
        C4Kit_StrongSelf
        switch (status) {
            case WebViewErrorStatus:
                self.reloadTime += 1;
                if (self.reloadTime > 2) {
                    return;
                }
                [self reload];
                NSLog(@"遇到白屏,重新加载");
                break;
                
            default:
                break;
        }
    }];
}

// 判断是否白屏
- (void)judgeLoadingStatusWithBlock:(void (^)(C4WebviewLoadingStatus status))completionBlock {
    C4WebviewLoadingStatus __block status = WebViewPendStatus;
    if (@available(iOS 11.0, *)) {
        if (self && [self isKindOfClass:[WKWebView class]]) {
            WKSnapshotConfiguration *shotConfiguration = [[WKSnapshotConfiguration alloc] init];
//            shotConfiguration.rect = CGRectMake(50, 100, self.bounds.size.width, self.bounds.size.height);
            [self takeSnapshotWithConfiguration:shotConfiguration completionHandler:^(UIImage * _Nullable snapshotImage, NSError * _Nullable error) {
                if (snapshotImage) {
                    UIImage * scaleImage = [self scaleImage:snapshotImage];
                    BOOL isWhiteScreen = [self searchEveryPixel:scaleImage];
                    if (isWhiteScreen) {
                       status = WebViewErrorStatus;
                    }else{
                       status = WebViewNormalStatus;
                    }
                }
                if (completionBlock) {
                    completionBlock(status);
                }
            }];
        }
    }
}

// 遍历像素点 白色像素占比大于95%认定为白屏
- (BOOL)searchEveryPixel:(UIImage *)image {
    CGImageRef cgImage = [image CGImage];
    size_t width = CGImageGetWidth(cgImage);
    size_t height = CGImageGetHeight(cgImage);
    size_t bytesPerRow = CGImageGetBytesPerRow(cgImage); //每个像素点包含r g b a 四个字节
    size_t bitsPerPixel = CGImageGetBitsPerPixel(cgImage);
    
    CGDataProviderRef dataProvider = CGImageGetDataProvider(cgImage);
    CFDataRef data = CGDataProviderCopyData(dataProvider);
    UInt8 * buffer = (UInt8*)CFDataGetBytePtr(data);
    
    int whiteCount = 0;
    int totalCount = 0;
    
    for (int j = 0; j < height; j ++ ) {
        for (int i = 0; i < width; i ++) {
            UInt8 * pt = buffer + j * bytesPerRow + i * (bitsPerPixel / 8);
            UInt8 red   = * pt;
            UInt8 green = *(pt + 1);
            UInt8 blue  = *(pt + 2);
//            UInt8 alpha = *(pt + 3);
        
            totalCount ++;
//            NSLog(@"red = %d green = %d blue = %d", red, green, blue);
//            if ((red == 255 || red == 254) && (green == 255 || green == 254) && (blue == 255 || blue == 254)) {
//                whiteCount ++;
//            }
            
            if (red >= 254 && green >= 254 && blue >= 254) {
                whiteCount ++;
            }
        }
    }
    float proportion = (float)whiteCount / totalCount ;
    NSLog(@"当前像素点数:%d,白色像素点数:%d , 占比: %f",totalCount , whiteCount , proportion );
    if (proportion > 0.95) {
        return YES;
    }else{
        return NO;
    }
}

//缩放图片
- (UIImage *)scaleImage: (UIImage *)image {
    CGFloat scale = 0.2;
    CGSize newsize;
    newsize.width = floor(image.size.width * scale);
    newsize.height = floor(image.size.height * scale);
    if (@available(iOS 10.0, *)) {
        UIGraphicsImageRenderer * renderer = [[UIGraphicsImageRenderer alloc] initWithSize:newsize];
          return [renderer imageWithActions:^(UIGraphicsImageRendererContext * _Nonnull rendererContext) {
                        [image drawInRect:CGRectMake(0, 0, newsize.width, newsize.height)];
                 }];
    }else{
        return image;
    }
}

4. POST丢失body问题处理

本文采用方案不存在这个问题,实际处理方式可查看网易云方案