阅读 3372

iOS 性能优化 -- H5离线秒开

前言

这是一个系列文章,后续会逐篇展开具体实现。H5离线技术顾名思义就是将H5/CSS/JS和资源文件打包提前下发到App中,这样App在加载网页的时候实际上加载的是本地的文件,减少网络请求来提高网页的渲染速度,并实现动态更新效果。

就目前情况来看,离线包的方案也是层出不穷的,本篇将列举市面最常见的四种离线方案,进行探讨分析,选择最优方案构建离线包功能。如果你有优化h5渲染速度的需求,可以用来参考,本篇仅做技术选型和方案原理刨析,后续篇章会选出最优方案进行深入探讨,加具体实现。目录部分为后续延伸。

方案

  1. 通过获取沙盒H5路径直接加载
  2. 基于NSURLProtocol进行请求拦截
  3. 基于WKURLSchemeHandler进行自定义scheme注册拦截
  4. 起本地服务器加载本地资源

选型

方案一:通过获取沙盒H5路径直接加载

直接加载本地h5,大名鼎鼎的《cordova》框架便是基于此实现。

  • 1.将所有的h5文件都放入一个文件夹中。

  • 2.将这个文件夹以相对路径的方式倒入到工程代码中。

  • 3.获取本地的文件路径。

这个方案就是将部署在服务器上面的前端代码直接解压到本地沙盒。加载js的时候直接加载本地沙盒中的html进行离线加载。将每个前端的模块都定义为一个应用,打上id下发给客户端,当用户点击对应模块的时候根据id去沙盒查找对应的离线资源进行加载实现秒开。

  • 优点:简单。
  • 缺点:

    1. 实际上从截图中可以看到,我们在访问本地html的时候可以看到实际路径为file:///.../index.html。这是在使用file协议访问html,有些html样式并不支持file协议,在样式和功能上会有缺失,还会有一些api上的差异,前端开发好的代码可能下载到沙盒里导致有些资源无法使用,产生一些适配问题。
    1. 访问本地资源还会导致资源路径泄漏产生安全问题。
    1. 还会有一些浏览器的安全设置无法通过。
    1. 无法实现跨域资源请求,会让前端开发人员无法访问外部cdn。

file协议&http协议:file协议主要用于访问本地计算机中的文件,好比通过资源管理器打开文件一样,针对本地的,即file协议是访问你本机的文件资源。http协议访问本地html是在本地起了一台http服务器,然后你访问自己电脑上的本地服务器,http服务器再去访问你本机的文件资源。

浏览器对两种协议的处理有时会不同,譬如某些网页中直接调用file协议来打开图片,这样的功能会被浏览器的安全设置阻挡,因为默认上,html是运行于客户端的超文本语言,从安全性上来讲,服务端不能对客户端进行本地操作。即使有一些象cookie这类的本地操作,也是需要进行安全级别设置的。倘若你需要载入外部cdn的资源,比如livereload、browserSync等工具的使用,由于浏览器的同源策略,从本地文件系统载入外部文件将会失败,会抛出安全性异常。

总的来说,这个方案会对前端产生严重的入侵,限制了前端只能通过相对路径对js,css,image等资源的加载,还有file协议的跨域问题导致无法引入外部cdn,这样会限制前端开发,虽然用起来最简单,但这并不是一个好的方案。

方案二:基于NSURLProtocol进行请求拦截

既然直接加载本地资源文件不是最好方案,那我们是否可以考虑一下另一种方案基于NSURLProtocol拦截呢?当然可行了,但是往下看:

UIWebView上,protocol拦截确实是我们的首选方案,创建个子类,在子类里面实现protocol的代理方法即可实现对所有请求的拦截,当然也包括html里面对css、js、img等资源加载的请求。

- (void)startLoading
{
    
    NSData *data = [NSData dataWithContentsOfFile:filePath];
    if (mimeType == nil) {
        mimeType = @"text/plain";
    }

    NSHTTPURLResponse* response = [[NSHTTPURLResponse alloc] initWithURL:[[self request] URL] statusCode:200 HTTPVersion:@"HTTP/1.1" headerFields:@{@"Content-Type" : mimeType}];

    [[self client] URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
    if (data != nil) {
        [[self client] URLProtocol:self didLoadData:data];
    }
    [[self client] URLProtocolDidFinishLoading:self];
}
复制代码

这样即可完美解决h5的资源请求问题。

那么在WKWebView上,这个方案是行不通的,关于这方面的解释已经很多了,WKWebView在独立于app进程之外的进程中执行网络请求,请求数据不经过主进程,因此,在WKWebView上直接使用 NSURLProtocol 无法拦截请求。当然通过私有api可以解决问题:

//仅iOS8.4以上可用
Class cls = NSClassFromString(@"WKBrowsingContextController”); 
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");

if ([(id)cls respondsToSelector:sel]) {
     #pragma clang diagnostic push
     #pragma clang diagnostic ignored "-Warc-performSelector-leaks"

         // 注册http(s) scheme, 把 http和https请求交给 NSURLProtocol处理 
        [(id)cls performSelector:sel withObject:@"http"];
         [(id)cls performSelector:sel withObject:@"https"];

    #pragma clang diagnostic pop
     }
}

复制代码

但依然存在缺陷,post请求body数据被清空。由于WKWebView在独立进程里执行网络请求。一旦注册http(s) scheme后,网络请求将从Network Process发送到App Process,这样 NSURLProtocol 才能拦截网络请求。在webkit2的设计里使用MessageQueue进行进程之间的通信,Network Process会将请求encode成一个Message,然后通过 IPC 发送给 App Process。出于性能的原因,encode的时候HTTPBody和HTTPBodyStream这两个字段被丢弃掉了。

如果使用Get请求拦截离线资源是没有问题的,拦截到请求后映射为本地资源生成NSHTTPURLResponse* response,像上面的方案一样去处理就可以了。但是使用私有API又会面临另外一个风险:被拒

说一点题外话,目前据我所了解到百度App安卓就是采用的请求拦截方式,但是,是安卓,看下图:

图片来源《百度APP-Android H5首屏优化实践》

通过上图可以分析第11、12步,WebView对html解析的时候可以发现资源请求并拦截,返回对应的缓存资源并渲染。实际上这个方案在iOS上是行不通的,安卓可以使用自家浏览器,可以魔改浏览器,比如支付宝的UC,百度的T7等。iOS应用内是不允许使用魔改浏览器的,很遗憾,也就是说苹果爸爸开放了什么,我们才能使用什么。

总结来说,这个方案并不会对前端产生入侵,前端依然可以不需要任何改变按部就班开发就好了。但对于body的拦截和对私有api的使用,依然是存在风险,但是据我所知这个方案也是有项目在使用的,所以选则推荐。

方案三:基于WKURLSchemeHandler进行自定义scheme注册拦截

WKURLSchemeHandler是iOS11就推出的,用于处理自定义请求的方案,不过并不能处理Http、Https等常规scheme。

WKWebViewConfiguration开放了setURLSchemeHandler:forURLScheme:函数,需要指定一个自定义的scheme和一个用来处理WKURLSchemeHandler回调的自定义对象。

根据注释来看,如果注册了一个无效的scheme或者使用WebKit内部已经处理的scheme,例如http、https、file等将会引发异常。我们最好使用WKWebView的handlesURLScheme:类方法来检查给定scheme的可用性,以免带来一些未知问题。 使用方法也很简单:

if (@available(iOS 11.0, *)) {
        BOOL allowed = [WKWebView handlesURLScheme:@""];
        if (allowed) {
            WKWebViewConfiguration *configuration = [WKWebViewConfiguration new];
            //设置URLSchemeHandler来处理特定URLScheme的请求,CustomURLSchemeHandler需要实现WKURLSchemeHandler协议,用来拦截customScheme的请求。
            [configuration setURLSchemeHandler:[CustomURLSchemeHandler new] forURLScheme: @"customScheme"];
            WKWebView *webView = [[WKWebView alloc] initWithFrame:self.view.bounds configuration:configuration];
            self.view = webView;
            [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"customScheme://"]]];
        }
    } else {
        // Fallback on earlier versions
    }
复制代码

WKURLSchemeHandler提供了两个回调函数由上面自定义的CustomURLSchemeHandler对象来处理:

- (void)webView:(WKWebView *)webView startURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask;
- (void)webView:(WKWebView *)webView stopURLSchemeTask:(id <WKURLSchemeTask>)urlSchemeTask;
复制代码

通过urlSchemeTaskrequest对象可以拿到请求对应的url,如果是我们自定义的scheme就去拦截它,通过url映射到对应的本地资源,并加载本地资源。

如果本地资源不存在,那么通过url直接构建request对象访问服务器,如果本地资源存在,那么就可以直接加载本地资源,和第二个方案一样去使用它:

- (void)webView:(WKWebView *)webView startURLSchemeTask:(id<WKURLSchemeTask>)urlSchemeTask {
    NSString *urlString = urlSchemeTask.request.URL.absoluteString;
    //定位本地资源并映射到本地资源地址 filePath
    
    NSData *data = [NSData dataWithContentsOfFile:filePath];
    NSHTTPURLResponse* response = [[NSHTTPURLResponse alloc] initWithURL:urlSchemeTask.request.URL statusCode:200 HTTPVersion:@"HTTP/1.1" headerFields:@{@"Content-Type" : @"text/plain"}];
    [urlSchemeTask didReceiveResponse:response];
    [urlSchemeTask didReceiveData:data];
    [urlSchemeTask didFinish];
}
复制代码

实际上这个方案很好的解决了资源拦截的问题,并且能像第二个方案一样去做处理。看起来没什么问题。但是它依然有短板:

    1. 因为使用的自定义scheme,并不是http协议,所以它依然无法解决跨域问题。
    1. 由于自定义了scheme,对于前端来说,需要额外将scheme设置为我们自定义的customScheme,这又会给前端带来大量的改造,所以对前端还是产生了入侵。
    1. 上面提到在安卓完全不需要像iOS这样大费周章的绕弯路,所以安卓可能就不需要这个自定义的scheme,这样又会导致面临着与安卓差异化严重问题。
    1. 因为API的限制,只能支持iOS11之后的系统。

所以这样来看,WKURLSchemeHandler的拦截方案也并不是很友好。

方案四:起本地服务器加载本地资源

根据支付宝的文章《支付宝移动端动态化方案实践》对离线包的描述:

当 H5 容器发出资源请求时,其访问本地资源或线上资源所使用的 URL 是一致的。H5 容器会先截获该请求,截获请求后,发生如下情况:

1.如果本地有资源可以满足该请求的话,H5 容器会使用本地资源。

2.如果没有可以满足请求的本地资源,H5 容器会使用线上资源。 因此,无论资源是在本地或者是线上,WebView 都是无感知的。

可以看出,支付宝并不是采用的上述三种方案,因为上述方案除了protocol拦截以外,都无法做到让WebView无感知,据我所知,支付宝目前应该采用的是起本地服务器方案。起本地服务器自然就是http协议了,http协议和本地的file协议差异第一种方案里面已经做了详细介绍,那么如果能够使用http协议加载本地资源的话,这样做能够最大程度的让前端对于离线包“无感”,也就是说前端不需要修改scheme,不需要考虑会不会因为file协议而带来一些问题,也能忽略掉拦截api的平台差异导致的框架实现差异,这样一来前端开发好的代码一份即可,布在服务器的同时,也上传到我们的离线包平台就OK了。所以称之为“无感知”。

  • 优点:优点前面都说了,同网络服务器加载的样式和功能完全一致,不入侵前端,前端并不用关心当前页面是离线还是非离线,做到最大无感知。当然有优点就有缺点,这也并不是一个完美方案。

  • 缺点:

      1. 需要额外搭建本地服务器,html文件的路径需要做处理。
      1. 对于本地服务器的搭建存在成本问题,本地服务器的管理问题,例如服务器的打开、关闭时机等等。
      1. 对于本地服务器会不会带来其他问题对于我来说也是未知的,并不是所有团队都能像支付宝一样搭建一个自己的服务器来处理。

这个方案的实施可以参考:《基于 LocalWebServer 实现 WKWebView 离线资源加载》的处理,但是文末也提到了几个问题:

  • 资源访问权限安全问题。
  • APP前后台切换时,服务重启性能耗时问题。
  • 服务运行时,电量及CPU占有率问题。
  • 多线程及磁盘IO问题。

这些问题对于我来说也是未知的。如果有成熟的搭建本地服务器方案欢迎留言。

本篇旨在分析一条最优方案来构建离线包核心功能,但是因为有小伙伴提出一些预加载等优化问题,所以从`bang's`的博客中摘了几条优化方案可供参考。

Fallback 技术

题外话:从上面提到的支付宝文章来看,还有一段我们可以分析一下:

为了解决离线包不可用的场景,fallback 技术应运而生。每个离线包发布的时候,都会同步在 CDN 发布一个对应的线上版本,目录结构和离线包结构一致。fallback 地址会随离线包信息下发到本地。在离线包没有下载好的场景下,客户端会拦截页面请求,转向对应的 CDN 地址, 实现在线页面和离线页面随时切换。

这个不可用场景应该就是离线包不可用,未更新,资源有损坏,md5不匹配或者验签不通过等等。

    1. 如果本地离线包没有或不是最新,就同步阻塞等待下载最新离线包。这种方案用户体验最差,因为离线包体积相对较大。
    1. 如果本地有旧包,用户本次就直接使用旧包,如果没有再同步阻塞等待,这种会导致更新不及时,无法确保用户使用最新版本。(据我所知微信小程序为此方案)
    1. 对离线包做一个线上版本,离线包里的文件在服务端有一一对应的访问地址,在本地没有离线包时,直接访问对应的线上地址,跟传统打开一个在线页面一样,这种体验相对等待下载整个离线包较好,也能保证用户访问到最新。

第三种方案应该就是支付宝的fallback 技术,可以解决上述问题。当然前两种方案也不是不可取,还是要看需求和场景。

公共资源包

每个包都会使用相同的 JS 框架和 CSS 全局样式,这些资源重复在每一个离线包出现太浪费,可以做一个公共资源包提供这些全局文件。

预加载 webview

无论是 iOS 还是 Android,本地 Webview 初始化都要不少时间,可以预先初始化好 Webview。这里分两种预加载:

首次预加载:在一个进程内首次初始化 Webview 与第二次初始化不同,首次会比第二次慢很多。原因预计是 Webview 首次初始化后,即使 Webview 已经释放,但一些多 Webview 共用的全局服务或资源对象仍没有释放,第二次初始化时不需要再生成这些对象从而变快。我们可以在 APP 启动时预先初始化一个 Webview 然后释放,这样等用户真正走到 H5 模块去加载 Webview时就变快了。

Webview 池:可以用两个或多个 Webview 重复使用,而不是每次打开 H5 都新建 webview。不过这种方式要解决页面跳转时清空上一个页面,另外若一个 H5 页面上 JS 出现内存泄漏,就影响到其他页面,在 APP 运行期间都无法释放了。

预加载数据

理想情况下离线包的方案第一次打开时所有HTML/JS/CSS 都使用本地缓存,无需等待网络请求,但页面上的用户数据还是需要实时拉,这里可以做个优化,在 Webview 初始化的同时并行去请求数据,Webview初始化是需要一些时间的,这段时间没有任何网络请求,在这个时机并行请求可以节省不少时间。

具体实现上,首先可以在配置表注明某个离线包需要预加载的 URL,客户端在 Webview 初始化同时发起请求,请求由一个管理器管理,请求完成时缓存结果,然后 Webview 在初始化完毕后开始请求刚才预加载的 URL,客户端拦截到请求,转接到刚才提到的请求管理器,若预加载已完成就直接返回内容,若未完成则等待。

使用客户端接口

网路和存储接口如果使用 webkit 的 ajax 和 localStorage 会有不少限制,难以优化,可以在客户端提供这些接口给 JS,客户端可以在网络请求上做像 DNS 预解析/IP直连/长连接/并行请求等更细致的优化,存储也使用客户端接口也能做读写并发/用户隔离等针对性优化。 服务端渲染 早期 web 页面里,JS 只是负责交互,所有内容都是直接在 HTML 里,到现代 H5 页面,很多内容已经依赖 JS 逻辑去决定渲染什么,例如等待 JS 请求 JSON 数据,再拼接成 HTML 生成 DOM 渲染到页面上,于是页面的渲染展现就要等待这一整个过程,这里有一个耗时,减少这里的耗时也是白屏优化的范围之内。 优化方法可以是人为减少 JS 渲染逻辑,也可以是更彻底地,回归到原始,所有内容都由服务端返回的 HTML 决定,无需等待 JS 逻辑,称之为服务端渲染。是否做这种优化视业务情况而定,毕竟这种会带来开发模式变化/流量增大/服务端开销增大这些负面影响。手Q的部分页面就是使用服务端渲染的方式,称为动态直出。

总结

关于这四种方案,都有优劣,关于选型,我偏向于NSURLProtocol拦截起本地服务器的方案。当然还是要参照自己的需求,就应用来说,都是可以的。当然对于一个优秀的Hybird框架,这些还是远远不够的,不管是从支付宝的方案还是手百的方案来看,需要做的优化还有很多,不管是手Q的动态直出,还是支付宝的Nebula,都还有很多东西需要我们探讨学习。不知道大家有没有发现,不只是手百,包括头条,腾讯新闻,在页面没有全部push出之前就已经渲染完毕了,说明都存在对h5页面进行预加载的处理,这也是值得我们深入探讨的环节。当然这一块还要视具体需求和人力来定了。关于离线包的处理,这是我目前能想到的所有方案,对于他们的优劣也有总结,如果你有什么建议或者更好的方案,欢迎留言。

开源地址:《WKJavaScriptBridge》(离线包后续引入)

关注下面的标签,发现更多相似文章
评论