iOS H5页面秒加载预研

背景

原生架构+H5页面的组合是很常见的项目开发模式了,H5的优势是跨平台、开发快、迭代快、热更新,很多大厂的App大部分业务代码都是H5来实现的,众所周知H5页面的体验是比原生差的,特别是网络环境差的时候,如果首屏页面是H5的话,那酸爽遇见过的都懂

白屏警告

白屏主要就是下载页面资源失败或者慢导致的,下面可以看下H5加载过程

H5加载过程

这部分内容其实网上已经很多了,如下

初始化 webview -> 请求页面 -> 下载数据 -> 解析HTML -> 请求 js/css 资源 -> dom 渲染 -> 解析 JS 执行 -> JS 请求数据 -> 解析渲染 -> 下载渲染图片

主要要优化的就是下载到渲染这段时间,最简单的方案就是把整个web包放在App本地,通过本地路径去加载,做到这一步,再配合上H5页面做加载中占位图,基本上就不会有白屏的情况了,而且速度也有了很明显的提升

可参考以下测试数据(网络地址加载和本地路径加载)

WiFi网络打开H5页面100次耗时:(代表网络优秀时)

本地加载平均执行耗时:0.28秒

网络加载平均执行耗时:0.58秒

4G/5G手机网络打开H5页面100次耗时:(代表网络良好时)

本地加载平均执行耗时:0.43秒

网络加载平均执行耗时:2.09秒

3G手机网络打开H5页面100次耗时:(代表网络一般或者较差时)

本地加载平均执行耗时:1.48秒

网络加载平均执行耗时:19.09秒

ok,恭喜你,H5离线秒加载功能优化完毕,这么简单?

IMG_4941.JPG

进入正题

通过上述实现本地加载后速度虽然是快了不少,但H5页面的数据显示还是需要走接口请求的,如果网络环境不好的话,也是会很慢的,这个问题怎么解决呢?

下面开始上绝活,这里引入一个概念 WKURLSchemeHandler 在iOS11及以上系统中,可以通过WKURLSchemeHandler自定义拦截请求,有什么用?简单说就是可以利用原生的数据缓存去加载H5页面,可以无视网络环境的影响,在拦截到H5页面的网络请求后先判断本地是否有缓存,有缓存的话可以直接拼接一个成功的返回,没有的话直接放开继续走网络请求,成功后再缓存数据,对H5来说也是无侵入无感知的。

首先自定义一个SchemeHandler类,遵守WKURLSchemeHandler协议,实现协议方法

@protocol WKURLSchemeHandler <NSObject>

/*! @abstract Notifies your app to start loading the data for a particular resource 
 represented by the URL scheme handler task.
 @param webView The web view invoking the method.
 @param urlSchemeTask The task that your app should start loading data for.
 */
- (void)webView:(WKWebView *)webView startURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask;

/*! @abstract Notifies your app to stop handling a URL scheme handler task.
 @param webView The web view invoking the method.
 @param urlSchemeTask The task that your app should stop handling.
 @discussion After your app is told to stop loading data for a URL scheme handler task
 it must not perform any callbacks for that task.
 An exception will be thrown if any callbacks are made on the URL scheme handler task
 after your app has been told to stop loading for it.
 */
- (void)webView:(WKWebView *)webView stopURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask;

@end

直接上代码

#import <Foundation/Foundation.h>
#import <WebKit/WebKit.h>

NS_ASSUME_NONNULL_BEGIN

@interface YXWKURLSchemeHandler : NSObject<WKURLSchemeHandler>

@end

NS_ASSUME_NONNULL_END
#import "YXWKURLSchemeHandler.h"
#import "YXNetworkManager.h"
#import <SDWebImage/SDWebImageManager.h>
#import <SDWebImage/SDImageCache.h>

@interface YXWKURLSchemeHandler ()

@property (nonatomic, strong) NSMutableSet * schemeTaskSet;

@end

@implementation YXWKURLSchemeHandler

- (void) webView:(WKWebView *)webView startURLSchemeTask:(id<WKURLSchemeTask>)urlSchemeTask {
    // 防止对已经stoped的task发送消息 会报错:"This task has already been stopped"
    [self.schemeTaskSet addObject:urlSchemeTask];

    NSURLRequest * request = urlSchemeTask.request;
    NSString * urlStr = request.URL.absoluteString;
    NSString * method = urlSchemeTask.request.HTTPMethod;
    NSData * bodyData = urlSchemeTask.request.HTTPBody;
    NSDictionary * bodyDict = nil;

    if (bodyData) {
        bodyDict = [NSJSONSerialization JSONObjectWithData:bodyData options:kNilOptions error:nil];
    }

    NSLog(@"拦截urlStr=%@", urlStr);
    NSLog(@"拦截method=%@", method);
    NSLog(@"拦截bodyData=%@", bodyData);

    // 检查图片缓存
    if ([urlStr hasSuffix:@".jpg"] || [urlStr hasSuffix:@".png"] || [urlStr hasSuffix:@".gif"]) {
        SDImageCache * imageCache = [SDImageCache sharedImageCache];
        NSString * cacheKey = [[SDWebImageManager sharedManager] cacheKeyForURL:request.URL];
        BOOL isExist = [imageCache diskImageDataExistsWithKey:cacheKey];
        if (isExist) {
            NSData * imgData = [[SDImageCache sharedImageCache] diskImageDataForKey:cacheKey];
            if ([self.schemeTaskSet containsObject:urlSchemeTask]) {
                [urlSchemeTask didReceiveResponse:[[NSURLResponse alloc] initWithURL:request.URL MIMEType:[self createMIMETypeForExtension:[urlStr pathExtension]] expectedContentLength:-1 textEncodingName:nil]];
                [urlSchemeTask didReceiveData:imgData];
                [urlSchemeTask didFinish];
                [self.schemeTaskSet removeObject:urlSchemeTask];
                return;
            }
        }
        [[SDWebImageManager sharedManager] loadImageWithURL:request.URL options:SDWebImageRetryFailed progress:nil completed:^(UIImage * _Nullable image, NSData * _Nullable data, NSError * _Nullable error, SDImageCacheType cacheType, BOOL finished, NSURL * _Nullable imageURL) {}];
    }

    // 检查网络请求缓存
    NSData * cachedData = (NSData *)[YXNetworkCache httpCacheForURL:urlStr parameters:bodyDict];
    if (cachedData) {
        NSHTTPURLResponse * response = [self createHTTPURLResponseForRequest:urlSchemeTask.request];
        if ([self.schemeTaskSet containsObject:urlSchemeTask]) {
            [urlSchemeTask didReceiveResponse:response];
            [urlSchemeTask didReceiveData:cachedData];
            [urlSchemeTask didFinish];
            [self.schemeTaskSet removeObject:urlSchemeTask];
            return;
        }
    }
    
    // 无缓存时发起网络请求(实际应用时需根据实际情况判断是否每个接口都要缓存)
    NSURLSessionDataTask * dataTask = [[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
                                           if ([self.schemeTaskSet containsObject:urlSchemeTask]) {
                                               if (error) {
                                                   [urlSchemeTask didFailWithError:error];
                                               } else {
                                                   [YXNetworkCache setHttpCache:data URL:urlStr parameters:bodyDict];
                                                   [urlSchemeTask didReceiveResponse:response];
                                                   [urlSchemeTask didReceiveData:data];
                                                   [urlSchemeTask didFinish];
                                               }
                                               [self.schemeTaskSet removeObject:urlSchemeTask];
                                           }
                                       }];
    [dataTask resume];
} /* webView */

- (NSHTTPURLResponse *) createHTTPURLResponseForRequest:(NSURLRequest *)request {
    // Determine the content type based on the request
    NSString * contentType;

    if ([request.URL.pathExtension isEqualToString:@"css"]) {
        contentType = @"text/css";
    } else if ([[request valueForHTTPHeaderField:@"Accept"] isEqualToString:@"application/javascript"]) {
        contentType = @"application/javascript;charset=UTF-8";
    } else {
        contentType = @"text/html;charset=UTF-8"; // default content type
    }

    // Create the HTTP URL response with the dynamic content type
    NSHTTPURLResponse * response = [[NSHTTPURLResponse alloc] initWithURL:request.URL statusCode:200 HTTPVersion:@"HTTP/1.1" headerFields:@{ @"Content-Type": contentType }];

    return response;
}


- (NSString *) createMIMETypeForExtension:(NSString *)extension {
    if (!extension || extension.length == 0) {
        return @"";
    }

    NSDictionary * MIMEDict = @{
            @"txt"  : @"text/plain",
            @"html" : @"text/html",
            @"htm"  : @"text/html",
            @"css"  : @"text/css",
            @"js"   : @"application/javascript",
            @"json" : @"application/json",
            @"xml"  : @"application/xml",
            @"swf"  : @"application/x-shockwave-flash",
            @"flv"  : @"video/x-flv",
            @"png"  : @"image/png",
            @"jpg"  : @"image/jpeg",
            @"jpeg" : @"image/jpeg",
            @"gif"  : @"image/gif",
            @"bmp"  : @"image/bmp",
            @"ico"  : @"image/vnd.microsoft.icon",
            @"woff" : @"application/x-font-woff",
            @"woff2": @"application/x-font-woff",
            @"ttf"  : @"application/x-font-ttf",
            @"otf"  : @"application/x-font-opentype"
    };

    NSString * MIMEType = MIMEDict[extension.lowercaseString];
    if (!MIMEType) {
        return @"";
    }

    return MIMEType;
} /* MIMETypeForExtension */

- (void) webView:(WKWebView *)webView stopURLSchemeTask:(id<WKURLSchemeTask>)urlSchemeTask {
    NSLog(@"stop = %@", urlSchemeTask);
    [self.schemeTaskSet removeObject:urlSchemeTask];
}

#pragma mark - lazy
- (NSMutableSet *) schemeTaskSet {
    if (!_schemeTaskSet) {
        _schemeTaskSet = [NSMutableSet set];
    }
    return _schemeTaskSet;
}

@end

这里会有一个疑问了?为什么要这么用呢,这里主要是为了满足对H5端无侵入、无感知的要求 如果不hook http和https的话,就需要在H5端修改代码了,把scheme修改成自定义的customScheme,全部都要改,而且对安卓还不适用,所以别这么搞,信我!!!

老老实实hook,一步到位

如何使用

首先是WKWebView的初始化,直接上代码

- (WKWebView *) wkWebView {
    if (!_wkWebView) {
        WKWebViewConfiguration * config = [[WKWebViewConfiguration alloc] init];
        // 允许跨域访问
        [config setValue:@(true) forKey:@"allowUniversalAccessFromFileURLs"];
        
        // 自定义HTTPS请求拦截
        YXWKURLSchemeHandler * handler = [YXWKURLSchemeHandler new];
        [config setURLSchemeHandler:handler forURLScheme:@"https"];
        
        _wkWebView = [[WKWebView alloc] initWithFrame:CGRectMake(0, 0, kScreenWidth, kScreenHeight) configuration:config];
        _wkWebView.navigationDelegate = self;
    }
    return _wkWebView;
} /* wkWebView */
NSString * htmlPath = [[NSBundle mainBundle] pathForResource:@"index" ofType:@"html" inDirectory:@"H5"];
NSURL * fileURL = [NSURL fileURLWithPath:htmlPath];
NSURLRequest * request = [NSURLRequest requestWithURL:url];
[self.wkWebView loadRequest:request];

ok,到了这一步基本上就能看到效果了,H5页面的接口请求和图片加载都会拦截到,直接使用原生的缓存数据,忽略网络环境的影响实现秒加载了

可参考以下测试数据(自定义WKURLSchemeHandler拦截和不拦截)

3G手机网络打开H5页面100次耗时:(代表网络一般或者较差时)

Figure_1.png

拦截后加载平均执行耗时:0.24秒

不拦截加载平均执行耗时:1.09秒

可以发现通过自定义WKURLSchemeHandler拦截后,加载速度非常平稳根本不受网络的影响,不拦截的话虽然整体加载速度并不算太慢,但是随网络波动比较明显。

不足

这套方案也还是有一些缺点的,比如

  1. App打包的时候需要预嵌入web模块的数据,会导致App包的大小增加(需要尽量缩小web模块的包大小,特别是资源文件的优化)
  2. App需要设计一套web模块包的更新机制,同时需要设计一个web包上传发布平台,后续版本管理更新比之前直接上传服务器替换相对麻烦一些
  3. 需要更新时下载全量web模块包会略大,也可以考虑用BSDiff差分算法来做增量更新解决,但是会增加程序复杂度

总结

综上就是本次H5页面秒加载全部的预研过程了,总的来说,基本上可以满足秒加载的需求。没有完美的解决方案,只有合适的方案,有舍有得,根据公司项目情况来。

猜你喜欢

转载自juejin.im/post/7236281103221096505
今日推荐