注册

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 "CMWKURLSchemeHandler.h"
#import "YXNetworkManager.h"
#import <SDWebImage/SDWebImageManager.h>
#import <SDWebImage/SDImageCache.h>

@implementation YXWKURLSchemeHandler

- (void) webView:(WKWebView *)webView startURLSchemeTask:(id<WKURLSchemeTask>)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];
[urlSchemeTask didReceiveResponse:[[NSURLResponse alloc] initWithURL:request.URL MIMEType:[self createMIMETypeForExtension:[urlStr pathExtension]] expectedContentLength:-1 textEncodingName:nil]];
[urlSchemeTask didReceiveData:imgData];
[urlSchemeTask didFinish];
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];
[urlSchemeTask didReceiveResponse:response];
[urlSchemeTask didReceiveData:cachedData];
[urlSchemeTask didFinish];
} else {
NSURLSessionDataTask * dataTask = [[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
if (error) {
[urlSchemeTask didFailWithError:error];
} else {
[YXNetworkCache setHttpCache:data URL:urlStr parameters:bodyDict];
[urlSchemeTask didReceiveResponse:response];
[urlSchemeTask didReceiveData:data];
[urlSchemeTask didFinish];
}
}];
[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);
}

@end

重点来了,需要hook WKWebview 的 handlesURLScheme 方法来支持 http 和 https 请求的代理


直接上代码


#import "WKWebView+SchemeHandle.h"
#import <objc/runtime.h>

@implementation WKWebView (SchemeHandle)

+ (void) load {
static dispatch_once_t onceToken;

dispatch_once(&onceToken, ^{
Method originalMethod1 = class_getClassMethod(self, @selector(handlesURLScheme:));
Method swizzledMethod1 = class_getClassMethod(self, @selector(cmHandlesURLScheme:));
method_exchangeImplementations(originalMethod1, swizzledMethod1);
});
}

+ (BOOL) cmHandlesURLScheme:(NSString *)urlScheme {
if ([urlScheme isEqualToString:@"http"] || [urlScheme isEqualToString:@"https"]) {
return NO;
} else {
return [self handlesURLScheme:urlScheme];
}
}

@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.cn/post/7236281103221096505

0 个评论

要回复文章请先登录注册