⚠️ 新人警告
本文作者在写作本文时正式接触 iOS 开发不足1个月,仅通过模拟器/真机调试检验内容和方案,可能存在其他潜在问题,欢迎批评指正。
⚠️ 兼容性警告
本文所述方案兼容 iOS 11 及以上设备。根据 Apple 官方统计结果,目前能够支持的用户占比达95%,其余 5% 用户可以考虑使用 CDN 接入。
得益于 WKWebView ,在 iOS 开发中高效的引入 Web 内容实现 Hybrid 应用成为可能。在 QQ、淘宝等“大厂产品”中能够看到类似技术正在悄然渗透。驱动 Hybrid 方案大行其道的根本原因还是 Hybrid 应用实在 「太香了」——
- 发挥前端超强的表现能力
- 光明正大地实现热更新
- 合理设计的情况下降低跨平台开发成本
当然,Hybrid 应用目前仍然是不完美的,其中存在最显著的问题就是“资源加载”带来的开销。实际应用情况显示,受到用户网络状况影响,即便使用CDN,完全从网络加载一个500KB左右大小的Web App到本地WebView中展示,用户还是能明显感受到界面展示的延迟。本文介绍的方案即解决以上述问题作为目标。
实现思路:离线包
前端项目静态资源打包成「离线包」,在 App 运行时 下载(解压)至用户本地。WKWebView 从用户本地加载所需资源。
已有方案及其不足
目前通过搜索引擎可以检索到的类似方案使用 NSURLProtocol 拦截 WKWebView 请求并进行替换。但是,由于 WKWebView 独立进程的特点,拦截其请求需要使用私有接口,存在兼容性风险以及上架被拒的风险。
一、离线包的分发
已经指出,在该方案中,所有的静态资源均在离线包中提供,App 运行时下载离线包。为了节约流量、提高效率,对于离线包的分发和下载应该引入版本控制策略。此处以我使用的方案举例介绍、作为参考,具体情况需要具体分析。
我使用七牛云提供的对象存储存储及CDN实现离线包的分发,在对象存储中放置离线包文件和一个额外的 info.json 文件:
{
"packageName": "kernel-b7e36791-2092-443b-8c49-d934c3aed6ad.zip",
"fileList": [
"index.html",
"logo.a0a62428.png",
"main.8bfaf9c9.js",
"main.fd6a9d49.css",
"manifest.json"
]
}
其中的 packageName 字段表示当前最新的离线包名称。
App 会在合适的时机(每次启动时)请求 info.json,获取当前最新的离线包名称,可以通过离线包名称组合出离线包的下载地址。
二、离线包的下载和解压
假设此处已经以无论某种方式获取到了离线包的下载地址(PACKAGE_DOWNLOAD_URL),接下来的工作是将离线包下载到本地并进行解压。本方案使用 AFNetworking 进行下载,将 zip 格式的离线包保存至沙盒中的/Library/Caches,然后使用 SSZipArchive 将离线包解压至 /Documents/hybrid-package 目录中。
首先看代码,之后进行解释:
#import "SSZipArchive.h"
#import "AFNetworking.h"
// 构造离线包zip保存路径
// 此处的packageName为离线包名称,用于区分版本
NSString *savePath = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) firstObject];
savePath = [savePath stringByAppendingPathComponent:packageName];
NSLog(@"离线包保存本地路径 - %@",savePath);
// 检查该版本离线包是否已经存在,若已存在则跳过下载
NSFileManager *fileManager = [NSFileManager defaultManager];
if([fileManager fileExistsAtPath:savePath]){
// 如果文件已经存在则不再进行下载,终止过程
// 终止逻辑
}];
}
// 获取 AFNetworking SessionManager
// 此处的PACKAGE_DOWNLOAD_URL其实可以随意指定,并不影响后续下载流程
AFHTTPSessionManager *session = [[AFHTTPSessionManager manager]
initWithBaseURL:[NSURL URLWithString:PACKAGE_DOWNLOAD_URL]];
// 当前离线包不存在开始下载
NSLog(@"正在下载离线包 - %@",
[NSString stringWithFormat:@"%@", PACKAGE_DOWNLOAD_URL]);
NSURLSessionDownloadTask *downloadTask = [self.session
downloadTaskWithRequest:
[NSURLRequest requestWithURL:[NSURL URLWithString:PACKAGE_DOWNLOAD_URL]]
progress:^(NSProgress * _Nonnull downloadProgress) {
NSLog(@"下载进度 - %f nn",downloadProgress.completedUnitCount / (downloadProgress.totalUnitCount / 1.0));
}
destination:^NSURL * _Nonnull(NSURL * _Nonnull targetPath, NSURLResponse * _Nonnull response) {
// 将离线包保存到 Library/Caches/<packageName>.zip
return [NSURL fileURLWithPath:savePath];
}
completionHandler:^(NSURLResponse * _Nonnull response, NSURL * _Nullable filePath, NSError * _Nullable error) {
// 下载完成后开始解压过程
// 获取解压路径
NSString *extraPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject];
extraPath = [extraPath stringByAppendingPathComponent:@"hybrid-package"];
if([fileManager fileExistsAtPath:extraPath]) {
// 清除已有目录
[fileManager removeItemAtPath:extraPath error:nil];
}
[fileManager createDirectoryAtPath:extraPath withIntermediateDirectories:YES attributes:nil error:nil];
// 解压离线包
[SSZipArchive unzipFileAtPath:savePath toDestination:extraPath];
// 错误处理等其他逻辑
}];
保存路径的构造
下载的 zip 离线包首先保存至 /Library/Caches/<packageName> 路径,该路径的获取方法如下:
NSString *savePath = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) firstObject];
savePath = [savePath stringByAppendingPathComponent:packageName];
检查该版本离线包是否存在,若存在则跳过下载
使用NSFileManager fileExistsAtPath: 方法判断文件是否已经存在:
NSFileManager *fileManager = [NSFileManager defaultManager];
if([fileManager fileExistsAtPath:savePath]){
// 如果文件已经存在则不再进行下载
// 终止逻辑
}];
}
发起下载请求
使用 AFHTTPSessionManager 进行下载,该段代码需要注意指定下载文件的保存路径的方法,是在destination block中:
destination:^NSURL * _Nonnull(NSURL * _Nonnull targetPath, NSURLResponse * _Nonnull response) {
// 返回savePath对应的fileURL
return [NSURL fileURLWithPath:savePath];
}
下载完成后进行解压
在 downloadTask 的 completionHandler 中进行解压操作:
completionHandler:^(NSURLResponse * _Nonnull response, NSURL * _Nullable filePath, NSError * _Nullable error) {
// 解压路径为 /Documents/hybrid-package
// 路径获取方法如下
NSString *extraPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject];
extraPath = [extraPath stringByAppendingPathComponent:@"hybrid-package"];
// 解压之前先做清理工作,如果该目录存在则完全删除
if([fileManager fileExistsAtPath:extraPath]) {
[fileManager removeItemAtPath:extraPath error:nil];
}
// (重新)建立该目录
[fileManager createDirectoryAtPath:extraPath withIntermediateDirectories:YES attributes:nil error:nil];
// 解压离线包
[SSZipArchive unzipFileAtPath:savePath toDestination:extraPath];
// 错误处理等其他逻辑
}];
至此离线包已经被下载和解压。
三、使用 WKURLSchemeHandler 加载本地资源的原理
为了便于理解,先在本节介绍使用 WKURLSchemeHandler 离线化加载的实现原理。在第四节介绍 WKURLSchemeHandler 的实现与使用方法。
Apple Development Document 中对 WKURLSchemeHandler 的介绍是:
A protocol for loading resources with URL schemes that WebKit doesn't know how to handle.
用来加载 WebKit 不知道如何加载的URL资源的协议。
所以实现的根本原理是自定义一种 URL-Scheme ,通过这种 scheme 进行资源的加载。我定义了 herald-hybrid:// 协议专门用来加载静态资源:
可以通过一种几乎对现有前端项目“无创”的方式实现:
在前端项目中,静态资源使用“相对” URL 路径:
而需要动态加载的内容(例如:Ajax 请求、表单提交等)使用“绝对” URL 路径。
当然,后端服务方面也要对跨域问题进行相应的设置。在我的后端项目中,hybrid.myseu.cn 是可以跨域的,所以 herald-hybrid://hybrid.myseu.cn 也可以通过跨域验证。
在完成以上准备工作后,只需要一个小 trick 即可实现前端资源均使用自定义协议加载:
NSString *loadUrlString =
[NSString stringWithFormat:@"%@%@",@"herald-hybrid://hybrid.myseu.cn/",@"index.html"];
[self.webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:loadUrlString]]];
此处也许需要感谢下 HTML 设计者考虑的周全,都考虑到有一天我们要自定义协议加载了(笑
四、WKURLSchemeHandler 的使用和实现
上一节我们自定义了一个协议,现在我们就要告诉 WebKit 如何处理这种协议。
假设已经定义了一个 WKURLSchemeHandler ,先来看一下如何注册:
// 初始化 webViewConfiguration
WKWebViewConfiguration *webViewConfiguration = [[WKWebViewConfiguration alloc] init];
// 需要通过 webViewConfiguration 注册
[webViewConfiguration setURLSchemeHandler:
[[GRHWKURLSchemeHandler alloc] init] forURLScheme:@"herald-hybrid"];
// GRHWKURLSchemeHandler 是我的项目中实现的 WKURLSchemeHandler,下文详述如何实现
// ...其他配置
// 初始化 WKWebView
WKWebView *webView =
[[WKWebView alloc] initWithFrame:self.view.frame configuration:webViewConfiguration];
关键的是使用 WKWebViewConfiguration 的 setURLSchemeHandler:forURLScheme: 方法,附上文档:
WKWebViewConfiguration | Apple Developer Documentationdeveloper.apple.com经过以上过程,当前 WKWebView 中所有的 herald-hybrid:// 协议均由 GRHWKURLSchemeHandler 接管。
最后,便是本文的重头戏,实现一个帮助我们从本地加载资源的 WKURLSchemeHandler 类。
GRHWKURLSchemeHandler.h
该类服从 WKURLSchemeHandler 协议,该协议要求实现两个方法:
当 WKWebView 开始加载自定义scheme的资源时,会调用 webView:startURLSchemeTask:
方法,完整签名如下:
- (void)webView:(WKWebView *)webView startURLSchemeTask:(id<WKURLSchemeTask>)urlSchemeTask;
在其中我们可以得到一个 WKURLSchemeTask 对象,通过这个对象我们可以得知目前需要加载的资源的信息,并调用其didReceiveResponse:
、didReceiveData:
方法提供我们的本地资源。
完整的实现:
分步骤解释:
- 确定正在请求的文件是哪一个:
NSLog(@"拦截到请求的URL:%@", urlSchemeTask.request.URL);
NSString *localFileName = [urlSchemeTask.request.URL lastPathComponent];
NSLog(@"本地文件名称:%@", localFileName);
urlSchemeTask的 request 属性是一个 NSURLRequest 对象,可以从中获取完整的URL,由于我的前端 build 产生的生产环境产品目录是 flatten 结构的,直接通过最后一个URL部件来确定本地文件名即可正确加载:
对于其他结构只需稍作改动即可。
2.读取本地文件数据/信息
// 确定本地文件路径
NSString *localFilePath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject];
localFilePath = [localFilePath stringByAppendingPathComponent:@"hybrid-package"];
localFilePath = [localFilePath stringByAppendingPathComponent:localFileName];
NSLog(@"本地文件路径:%@", localFilePath);
// 读取文件数据
NSFileHandle *file = [NSFileHandle fileHandleForReadingAtPath:localFilePath];
NSData *data = [file readDataToEndOfFile];
[file closeFile];
// 获取文件 MIME
NSString *fileMIME = [self getMIMETypeWithCAPIAtFilePath:localFilePath];
NSLog(@"文件MIME:%@", fileMIME);
均属于基本的文件操作。
获取MIME可以使用如下方法,我将其也定义在这个类中:
- (NSString *)getMIMETypeWithCAPIAtFilePath:(NSString *)path
{
if (![[[NSFileManager alloc] init] fileExistsAtPath:path]) {
return nil;
}
CFStringRef UTI = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, (__bridge CFStringRef)[path pathExtension], NULL);
CFStringRef MIMEType = UTTypeCopyPreferredTagWithClass (UTI, kUTTagClassMIMEType);
CFRelease(UTI);
if (!MIMEType) {
return @"application/octet-stream";
}
return (__bridge NSString *)(MIMEType);
}
3.构造 Response
注意:数据并不在 Response 中提供
NSDictionary *responseHeader = @{
@"Content-type":fileMIME,
@"Content-length":[NSString stringWithFormat:@"%lu",(unsigned long)[data length]]
};
NSHTTPURLResponse *response =
[[NSHTTPURLResponse alloc] initWithURL:
[NSURL URLWithString:
[NSString stringWithFormat:@"%@%@", GRH_HYBRID_BASEURL, localFileName]]
statusCode:200 HTTPVersion:@"HTTP/1.1" headerFields:responseHeader];
4.将 Response 和 Data 通过 urlSchemeTask 发送给 WebKit
[urlSchemeTask didReceiveResponse:response];
[urlSchemeTask didReceiveData:data];
[urlSchemeTask didFinish];
此处不要忘记调用 didFinish,否则可以从调试工具看到一个一直加载的资源。
从Safari调试工具可以看到资源从本地加载:
总结
经过测试,首屏秒开效果显著。相比于用 NSURLProtocol 拦截的方案更可靠,还能变向促进用户尽快更新 iOS 系统(哈哈
文中所介绍代码来自于正在重构的「小猴偷米 iOS App」
HeraldStudio/herald-ios-appgithub.com如果你知道它是做什么的,那你就是知道它是做什么的——如果你不知道它是做什么的,你就不知道它是做什么的。(哈哈