离线包实现详解

2,803 阅读7分钟

作者:许锋

离线包的定义

离线包 是将包括 HTML、JavaScript、CSS 等页面内静态资源打包到一个压缩包内。预先下载该离线包到本地,然后通过客户端打开,直接从本地加载离线包,从而最大程度地摆脱网络环境对 H5 页面的影响。

离线包的优势

提升用户体验:通过离线包的方式把页面内静态资源嵌入到应用中并发布,当用户第一次开启应用的时候,就无需依赖网络环境下载该资源,而是马上开始使用该应用。

实现动态更新:在推出新版本或是紧急发布的时候,您可以把修改的资源放入离线包,通过更新配置让应用自动下载更新。因此,您无需通过应用商店审核,就能让用户及早接收更新。

上面我们了解到离线包相关的定义和优势。我们怎么去设计开发离线包呢?
下面我将对离线包实现流程进行详细的介绍、让大家对离线包这一块技术实现细节有个更详细清晰的了解。其次是对技术实现方案进行拆分、并针对每个技术点进行讲解。

离线包实现流程图

通过下面的离线包加载流程图我们可以参考流程图中具体的实现细节进行相应的开发设计,从而实现离线包的开发。看完流程图之后。下面我会针对流程中具体的实现细节进行拆分。然后针对每个细节进行详细的讲解。流程图.jpg

一:数据存储

这一块的知识只要是针对小程序列表和web资源包资源路径进行存储和查询。 具体的详细设计如下:

  • 小程序列表数据存储
    小程序接口列表接口返回数据设计
"list": [{
       "minProgramId": "", // 小程序Id
       "name": ""          // 小程序名称
       "content": "",      // 更新描述
       "version": "",      // 小程序版本号 
       "build": 1,         // 小程序build号
       "h5Url": "",        // h5线上地址
       "detail": {
         "fullUrl": "",    // 全量资源URL地址
	 "partUrl": "",    // 增量资源URL地址
	 "fullMd5": "",    // 全量资源md5值
	 "partMd5": ""     // 增量资源md5值
	 },
       }]

数据存储通过数据库进行存储、数据库表设计如下:

表名:webappinfos

表字段:

id:小程序ID

name:小程序名称

version:小程序版本

fullMd5:全量包资源md5

fullUrl:全量包资源地址URL

diffMd5:差量包资源md5

diffUrl:差量包资源地址URL

localPath:小程序资源包本地存储路径

idnameversionfullMd5fullUrldiffMd5diffUrllocalPath
10001小程序11.0.0Ho...A==..zipR9...B==..zipwebapps/10001
10002小程序21.0.0Ho...A==..zipR9...B==..zipwebapps/10002
  • web资源包资源路径存储
    web资源包文件目录如下:

97d1d775-f0d8-420d-9bec-3ff9b9bc5a11.png

数据存储通过数据库进行存储、数据库表设计如下:

表名:pathIndexInfos

表字段:

url::web资源url路径

localPath:离线包资源本地路径

md5:离线包资源文件MD5值

miniProgramId:小程序ID

urllocalPathmd5miniProgramId
index.htmlwebapps/1001/res/package/index.html0u...==1001
images/.../.pngwebapps/1001/res/package/subPackagesA/static/images/pay/pay_close.pngHo...==305
style/common.csswebapps/1001/res/package/static/style/common.cssNE...==1001
static/js/index.jswebapps/1001/res/package/static/js/index.jsUb...==1001

二: 离线包资源更新

离线包资源更新分为全量更新差量更新

  • 全量更新

    全量更新相对来说比较简单、通过小程序列表接口拉取小程序最新版本信息和本地当前运行版本进行对比,如果有新版本需要更新直接通过fullUrl下载最新版本的离线包资源。下载完成后解压替换本地的离线包资源即可。

  • 差量更新
    差量更新相对来说情况比较复杂一点,设计到的情况也比较多。具体更新逻辑如下:
    假设:
    A:本地资源旧包   
    B:差量资源包
    C:最新全量资源包  c_md5(最新全量资源包的MD5值)

    • B和A 通过bsdiff库进行差量合并 合并生成新的资源包 D
    • 获取D的md5值为 d_md5
    • d_md5和 c_md5值进行比较 如果 d_md5 ==  c_md5 将新生成的资源包 D 替换本地离线包资源
    • 如果 d_md5 !=  c_md5 说明差量资源包和本地就资源包合并之后的资源不是服务器上上传的最新资源包 此时需要进行全量更新 下载全量资源到本地并替换本来离线包

  出现无法进行差量更新情况场景分析:

假设此时要更新的小程序版本为: 1.1.2版本

如果用户当前版本为上个版本例如:1.1.1 此时进行差量合并更新是OK的。

如果当前用户是上个版本之前的版本(和要更新的版本中间间隔至少一个版本) 此时进行差量更新会失败,从而进行全量更新。

三: web资源拦截

web资源拦截主要针对ios的实现进行讲解 利用NSURLProtocol拦截WKWebView的请求

具体的实现如下:

@interface NSURLProtocol (WebKitSupport)
+ (void)wk_registerScheme:(NSString*)scheme;
+ (void)wk_unregisterScheme:(NSString*)scheme;
@end

#import "NSURLProtocol+WebKitSupport.h"
#import <WebKit/WebKit.h>

FOUNDATION_STATIC_INLINE Class ContextControllerClass() {
static Class cls;
if (!cls) {
cls = [[[WKWebView new] valueForKey:@"browsingContextController"] class];
}
return cls;
}

FOUNDATION_STATIC_INLINE SEL RegisterSchemeSelector() {
return NSSelectorFromString(@"registerSchemeForCustomProtocol:");
}

FOUNDATION_STATIC_INLINE SEL UnregisterSchemeSelector() {
return NSSelectorFromString(@"unregisterSchemeForCustomProtocol:");
}

@implementation NSURLProtocol (WebKitSupport)
+ (void)wk_registerScheme:(NSString *)scheme {
Class cls = ContextControllerClass();
SEL sel = RegisterSchemeSelector();
if ([(id)cls respondsToSelector:sel]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[(id)cls performSelector:sel withObject:scheme];
#pragma clang diagnostic pop
 }
}
+ (void)wk_unregisterScheme:(NSString *)scheme {
Class cls = ContextControllerClass();
SEL sel = UnregisterSchemeSelector();
if ([(id)cls respondsToSelector:sel]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[(id)cls performSelector:sel withObject:scheme];
#pragma clang diagnostic pop
 }
}
@end

注册把http和https请求交给NSURLProtocol处理

[NSURLProtocol wk_registerScheme:@"http"];
[NSURLProtocol wk_registerScheme:@"https"];

监听web相关请求

[NSURLProtocol registerClass:[KKWebViewProtocol class]]; // 注册监听
@interface KKWebViewProtocol : NSURLProtocol
@end
// 判断是否拦截请求
+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
   return YES;
}
// 开始加载请求资源
- (void)startLoading {
    // 再次获取cacheData
    NSURL * url = self.request.URL;
    NSData * cacheData = nil;
    if ([webViewProtocolDelegate respondsToSelector:@selector(dataForWebViewProtocolWithURL:)]) {
       // 拦截请求url 通过url查询是否存在已经缓存在本地的资源 如果存在则直接从本地读取返回NSData对象。如果不存在则返回null
        cacheData = [webViewProtocolDelegate dataForWebViewProtocolWithURL:[url absoluteString]];
        if (cacheData == nil) {
            // 发送网络请求
            self.downloadTask = [self.session dataTaskWithRequest:self.request];
            [self.downloadTask resume];
        }
        else{
        // 加载本地资源
            NSDictionary *dict = [NSDictionary dictionary];
            NSHTTPURLResponse* response = [[NSHTTPURLResponse alloc]
                                           initWithURL:url
                                           statusCode:200
                                           HTTPVersion:@"HTTP/2.0"
                                           headerFields:dict];
            [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
            [self.client URLProtocol:self didLoadData:cacheData];
            [self.client URLProtocolDidFinishLoading:self];
        }
    } else {
        self.downloadTask = [self.session dataTaskWithRequest:self.request];
        [self.downloadTask resume];
    }
}

// 停止资源加载
-(void)stopLoading{
    [self.downloadTask cancel];
    self.downloadTask = nil;
}

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler {
    [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
    // 允许处理服务器的响应,才会继续接收服务器返回的数据
    completionHandler(NSURLSessionResponseAllow);
    self.cacheData = [NSMutableData data];
}

- (void)URLSession:(NSURLSession *)session dataTask:(nonnull NSURLSessionDataTask *)dataTask didReceiveData:(nonnull NSData *)data{
    [self.client URLProtocol:self didLoadData:data];
    [self.cacheData appendData:data];
}

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
    // 下载完成之后的处理
    if (error) {
        [self.client URLProtocol:self didFailWithError:error];
    } else {
        //将数据存入到本地文件中
        [self.cacheData writeToFile:[self filePathWithUrlString:self.request.URL.absoluteString] atomically:YES];
        [self.client URLProtocolDidFinishLoading:self];
    }
}
  • WKURLSchemeHandler 拦截

iOS11之后,苹果公司,自己提供一个看似网友们都十分欢喜的新的API:

- (void)setURLSchemeHandler:(nullable id <WKURLSchemeHandler>)urlSchemeHandler forURLScheme:(NSString *)urlScheme API_AVAILABLE(macos(10.13), ios(11.0));

四: 如何检测离线包功能生效

完成以上离线包功能的开发、如何去验证APP是加载的离线资源而不是线上资源呢?

  • 真机运行,体验正常加载和离线包加载两种方式进入小程序的速度
  • 代码断点调试或者log打印是否走离线资源加载
  • 抓包分析验证资源加载情况

离线包和非离线包加载的区别是:

离线包不需要加载web资源

非离线包需要请求加载web资源

六: 总结

用到的知识点汇总:

  • 资源文件下载
  • zip文件解压
  • 数据库存储
  • bsdiff差分包库
  • 文件md5值校验
  • web请求拦截

以上是针对差分包实现方案的详细介绍,如果针对实现细节想要进行探讨和流程不太清楚可以评论留言。我这边都会进行回复。