使用pb90引入已有应用_一种使用 WKURLSchemeHandler 实现的 WKWebView 离线资源加载方案

52019009a7ba40e6f2e01104a1d05664.png

⚠️ 新人警告

本文作者在写作本文时正式接触 iOS 开发不足1个月,仅通过模拟器/真机调试检验内容和方案,可能存在其他潜在问题,欢迎批评指正。

⚠️ 兼容性警告

本文所述方案兼容 iOS 11 及以上设备。根据 Apple 官方统计结果,目前能够支持的用户占比达95%,其余 5% 用户可以考虑使用 CDN 接入。

9ef853d75b2a9bc54a5f8d642cc51157.png
截至2019年1月 App Store 统计 4 年内激活产品的iOS版本分布

得益于 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:// 协议专门用来加载静态资源:

8f8fc6ee21247c50b0505659b55595cd.png
静态资源通过 herald-hybrid 协议加载

可以通过一种几乎对现有前端项目“无创”的方式实现:

在前端项目中,静态资源使用“相对” URL 路径:

0ec6cce1daf3ba2b9cc2553ddcecc887.png

而需要动态加载的内容(例如: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 Documentation​developer.apple.com

经过以上过程,当前 WKWebView 中所有的 herald-hybrid:// 协议均由 GRHWKURLSchemeHandler 接管。

最后,便是本文的重头戏,实现一个帮助我们从本地加载资源的 WKURLSchemeHandler 类。

GRHWKURLSchemeHandler.h

ab09069aa99954363fb9e19fd358df18.png

该类服从 WKURLSchemeHandler 协议,该协议要求实现两个方法:

b854918a5578838042e4ecec6b91a67b.png

当 WKWebView 开始加载自定义scheme的资源时,会调用 webView:startURLSchemeTask: 方法,完整签名如下:

- (void)webView:(WKWebView *)webView startURLSchemeTask:(id<WKURLSchemeTask>)urlSchemeTask;

在其中我们可以得到一个 WKURLSchemeTask 对象,通过这个对象我们可以得知目前需要加载的资源的信息,并调用其didReceiveResponse:didReceiveData:方法提供我们的本地资源。

完整的实现:

9879cb20c0b6b471eb851bac8a33c944.png

分步骤解释:

  1. 确定正在请求的文件是哪一个:
NSLog(@"拦截到请求的URL:%@", urlSchemeTask.request.URL);
    NSString *localFileName = [urlSchemeTask.request.URL lastPathComponent];
    NSLog(@"本地文件名称:%@", localFileName);

urlSchemeTask的 request 属性是一个 NSURLRequest 对象,可以从中获取完整的URL,由于我的前端 build 产生的生产环境产品目录是 flatten 结构的,直接通过最后一个URL部件来确定本地文件名即可正确加载:

8347a0b56add6c28c6bb3aa96a57fcce.png
dist 目录是 flatten 的

对于其他结构只需稍作改动即可。

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调试工具可以看到资源从本地加载:

76ebd2a84a9187721c43c3645f00084e.png

e2009520c3502e8908c599d7550caa18.png

总结

经过测试,首屏秒开效果显著。相比于用 NSURLProtocol 拦截的方案更可靠,还能变向促进用户尽快更新 iOS 系统(哈哈


文中所介绍代码来自于正在重构的「小猴偷米 iOS App」

HeraldStudio/herald-ios-app​github.com
74b1ea8e28f0c5c201e8f1256ebcc740.png

如果你知道它是做什么的,那你就是知道它是做什么的——如果你不知道它是做什么的,你就不知道它是做什么的。(哈哈

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值