性能提升30%以上, JDHybrid h5加载优化实践

·  阅读 968
性能提升30%以上, JDHybrid h5加载优化实践

原文已同步发表在京东零售技术公众号 mp.weixin.qq.com/s/caLQ-JOnT…

1 背景


Hybrid技术现在已经是一个常见的技术方案,它既能享受到Native的能力,同时还能拥有H5技术低成本、高效率和跨平台等特性。然而,H5技术的加载速度一直饱受诟病,相比于Native,app打开H5页面时,H5会发起多个HTTP请求去加载资源,资源大小、网络质量都会影响页面打开速度。为了节省这部分时间,我们可以把首屏的一些静态资源(如img、js、css、html等)打包提前加载到本地磁盘,当加载页面时直接从本地磁盘(或内存)获取资源加载。本地的读写速度是远高于网络请求的,尤其是在网络不良或资源太大的情况下,离线化方案更能展现出它的优势。此外,在一些大促等高流量场景下,提前将活动页面资源下载到客户端本地,可以大幅降低活动当天的CDN峰值与带宽,在降本提效方面有显著的作用。

2 JDHybrid “618大促” 表现


从去年双十一开始,JDHybrid开始承接各类大促业务,无论是沸腾之夜还是春晚项目,JDHybrid在降本提效方面都发挥了重要的作用。经过这些超级流量的大考之后,JDHybrid在业务范围、业务服务质量、稳定性等多个方面都有了很大的进步。今年618期间,618主会场、T级互动、秒杀主会场、百亿补贴等多个核心业务均使用了JDHybrid,整体首屏加载速度提升30%以上,秒开率提升20%以上,页面错误率降低了60%以上。

下面我们就来看一看数据的背后JDHybrid都做了些什么。

3 离线加载机制探究

3.1 Android 离线加载机制

Android实现加载本地文件的api相对较少,主要是包括直接load本地文件以及拦截资源请求两个方案。

3.1.1 直接load本地文件

通过WebView的loadUrl方法加载本地h5工程

webview.loadUrl(XCache.getApp(id).getHtmlPath());
复制代码

对客户端来说该方法简单粗暴,而且加载速度非常快,但存在许多因为file协议引起的问题,本地文件的url格式为“file:///data/data/pacakagename/xxxxx”,从url上看这类url是不存在域名的,而h5页面的加载大多与域名相关联。

  • Cookie,因为没有域名问题会导致无法获取cookie,那么最直接的问题就是登录态的丢失。
  • 跨域,因为file域问题导致远程请求都变成跨域请求,导致大部分接口请求无法响应。
  • scheme补齐,前端开发习惯一般网络请求url是不带scheme的,如“loacation.href='//hybrid.jd.com'”,而真正发出请求时内核会自动补齐scheme变成“file//hybrid.jd.com”导致url无法访问。

虽然能通过曲线救国方式解决以上问题,比如将网络请求桥接到原生,但整体改动费时费力,放弃吧。

3.1.2 拦截资源请求

谷歌提供了拦截资源请求的API:

@Nullable
public WebResourceResponse shouldInterceptRequest(WebView view,
        WebResourceRequest request) {
    return shouldInterceptRequest(view, request.getUrl().toString());
}
复制代码

只需要构造WebResourceResponse对象即可实现本地文件加载,毫无副作用,这也是目前各大厂App的方案,相比iOS,安卓在方案上相对比较简单。京东商城App最终也是使用了该方案拦截加载本地文件,我们在这里提一下特别需要注意的点。

3.1.2.1 请求中body丢失

WebResourceRequest中不包含body,该方法中不要拦截post请求,会有丢失body的风险。京东只用该方法加载本地文件资源,所以不存在body丢失的情况。

3.1.2.2 WebResourceResponse的构造

先看看WebResourceResponse的构造方法,主要包括mimeType、encoding、文件数据流三个入参数,如谷歌源码注解中提到的,mimeType和encoding只能是单个值,不能是整个Content-Type值。我们尝试发现js、css等资源如果mimeType和encoding是错误的,内核都按默认的utf-8编码文本进行处理。但mimeType对于html来说必须是特定的格式,比如“text/html”,否则内核无法解析html。

    /**
     * Constructs a resource response with the given MIME type, character encoding,
     * and input stream. Callers must implement {@link InputStream#read(byte[])} for
     * the input stream. {@link InputStream#close()} will be called after the WebView
     * has finished with the response.
     *
     * <p class="note"><b>Note:</b> The MIME type and character encoding must
     * be specified as separate parameters (for example {@code "text/html"} and
     * {@code "utf-8"}), not a single value like the {@code "text/html; charset=utf-8"}
     * format used in the HTTP Content-Type header. Do not use the value of a HTTP
     * Content-Encoding header for {@code encoding}, as that header does not specify a
     * character encoding. Content without a defined character encoding (for example
     * image resources) should pass {@code null} for {@code encoding}.
     *
     * @param mimeType the resource response's MIME type, for example {@code "text/html"}.
     * @param encoding the resource response's character encoding, for example {@code "utf-8"}.
     * @param data the input stream that provides the resource response's data. Must not be a
     *             StringBufferInputStream.
     */
    public WebResourceResponse(String mimeType, String encoding,
            InputStream data) {
        mMimeType = mimeType;
        mEncoding = encoding;
        setData(data);
    }
复制代码
3.1.2.3 跨域请求资源

除了mimeType、encoding还有一些需要特别注意的,包括access-control-allow-origin、timing-allow-origin等一些跨域的Header。一般情况下,浏览器内核是默认js、css等资源文件是允许跨域的,不排除前端因为一些特殊原因强制校验跨域。如:

<script src="user.com/index.js" crossorigin ></script>

复制代码

如果前端限制了跨域,加载本地文件也必须在Response header中增加跨域处理,否则内核会拒绝响应。

header.put("access-control-allow-origin",*);
header.put("timing-allow-origin",*);
复制代码

3.2 iOS离线加载机制

自从Apple废弃UIWebView之后,iOS端的离线加载技术变的异常复杂,无论何种方案,都会带有先天不足,需要通过各种补丁解决。业界目前采用的方案也不太统一,方案均带有明显的业务特征。京东内部h5业务也有自身的一些特点,所以JDHybrid在方案选择上主要考虑了以下几点: 第一、因为h5开发相对开放,所以,没有一个相对稳定的h5开发平台可以对接,无法形成统一的规则约束来简化方案设计成本与使用成本; 第二、大部分h5业务都是比较成熟的业务,颠覆性的方案设计会因为侵入性较强降低业务的接入意愿; 所以,JDHybrid的设计必须建立在“业务研发0修改”的基础之上。当然,在方案设计过程中,我们也对现有的方案进行了摸排,有些后面被弃用的方案甚至在线上进行了验证,这里对相关的探索历程也总结一下。

3.2.1 直接加载本地文件

和Andriod端类似,iOS也可以通过加载本地文件来实现离线包的加载

- (nullable WKNavigation *)loadFileURL:(NSURL *)URL allowingReadAccessToURL:(NSURL *)readAccessURL
复制代码

但这个方案问题太多,无法推广使用,具体的问题与副作用和前述Android端类似,这里就不赘述了。

3.2.2 LocalServer

方案一因为页面的协议头是file,它的处理会给方案本身带来大量的工作,那么我们是否有方案可以让webview去load一个http的链接并加载本地文件?接下来我们来介绍本地server的方案:建立本地server来模拟http(s)场景,并在相应时机返回对应的离线资源。

可以使用CocoaHttpServer来开启本地服务,这个库可以很好的支持https。除了CocoaHTTPServer外,我们也可以使用GCDWebServer(支持http,oc)和Telegraph(支持http(s),swift)来开启本地server。

关于localServer在iOS端的实现以及https的支持,可以参考基于 LocalWebServer 实现 WKWebView 离线资源加载

  • webview加载http(s)链接

    [self.webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"http(s)://127.0.0.1:1000/index.html"]]]
    复制代码

webview加载资源过程中,所有相对路径的资源如<img src="img/icon.png">都会通过我们的本地服务器,这时,我们就可以通过拦截这些资源并返回离线包中的资源。LocalServer方案加载离线包有几个问题:

  • 仍然存在跨域的问题
  • 相对路径资源加载异常,如<img src="img/icon.png">会补全为本地host,所以对h5业务方引入资源的方式有限制;
  • 同样存在cookie无法读写的场景

除了这些H5环境的问题外,我们还需要注意端口的问题,端口的冲突也会使LocalServer启动失败。

LocalServer可以实现http(s)环境,相比于方案一,它仅仅解决了http协议的问题,方案一的其他问题仍然存在,业务兼容与开发成本并未出现明显下降。另外它也会带来许多额外的问题,如性能消耗、电量消耗、资源访问权限安全等。

3.2.3 NSURLProtocol

加载本地文件和LocalServer的方案在跨域、cookie、js原生api方面的缺陷既对业务不够友好,且可以预见其他h5层面的问题会层出不穷。所以考虑让WKWebView正常的加载业务的h5链接,然后拦截相关请求并加载本地资源是避免上述问题的根本方案。在使用UIWebView的时候我们可以通过NSURLProtocol拦截webview中的网络请求,那么我们是否可以通过NSURLProtocol拦截WKWebView中的网络请求呢?答案是可以的,但是因为WebKit是独立于主app之外的进行运行的,我们不能通过简单的NSURLProtocol注册就能拦截到http(s)请求,在进行自定义NSURLProtocol的注册后,我们还需要调用系统私有api进行处理:

Class cls = NSClassFromString(@"WKBrowsingContextController");
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
if ([(id)cls respondsToSelector:sel]) {
    [(id)cls performSelector:sel withObject:@"http"];
    [(id)cls performSelector:sel withObject:@"https"];
}
复制代码

接下来webview发送的网络请求便会被拦截到自定义的NSURLProtocol中,后续的流程这里不在赘述了。

NSURLProtocol可以帮助我们拦截http(s)请求,但是在实践过程中发现它会有以下问题:

  • H5 Post请求会丢失body WebKit是一个多进程架构,网络请求发出是在其他进程,在拦截请求后,需要通过IPC将请求从其他进程发送到App进程,Webkit出于优化的目的会丢弃HTTPBody与HTTPBodyStream字段,这就导致了POST请求body丢失的问题。我们可以通过js hook桥接(或者干脆提供js桥接的网络请求方式,特别适合h5开发链路集中的团队),将post请求通过jsbridge转发到原生请求。

  • NSURLProtocol的拦截是全局性质的 一旦通过私有api注册https(s) scheme后,在注销之前,所有WKWebView发起的post网络请求都会丢失body(即使你没有拦截它,仍然返回给webview去做请求也不例外),这会对三方webview造成影响。

  • 私有api问题 会有审核被拒的风险,且WebKit官方明确对相关api标注了废弃,提示开发者用WKURLSchemeHandler去替换,长远看也有被官方移除的风险;

3.2.4 WKURLSchemeHandler

iOS11之后WebKit框架引入了新特性WKURLSchemeHandler来支持自定义请求的管理。我们可以通过customScheme来拦截webview页面的请求,如果要拦截http(s)则需要hook WKWebView的

+ (BOOL)handlesURLScheme:(NSString *)urlScheme
复制代码

方法,在scheme为http(s)时返回NO。 同时在webview初始化时,通过WKWebViewConfiguration对需要拦截的scheme进行注册。

WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init];
[configuration setURLSchemeHandler:(id<WKURLSchemeHandler>)self forURLScheme:@"https"];
[configuration setURLSchemeHandler:(id<WKURLSchemeHandler>)self forURLScheme:@"http"];
复制代码

WKURLSchemeHandler协议只包含两个方法:

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

网页开始数据请求,我们需要在这个方法中对网页中的请求进行处理和返回。

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

网页取消数据请求,我们需要在这个方法中停止请求。

同样是http(s)拦截方案,通过API对比我们可以发现方案拦截后虽然需要我们做更多的工作(后面会具体介绍),但是它支持WKWebView实例级别的拦截,风险相对可控。

因为WKURLSchemeHandler本身是不支持拦截Http(s)的,所以在我们用hook方法使其支持后会有很多问题:

  • iOS11.3之前post请求仍然会丢失body
  • iOS11.3之后如果body中含有blob类型(一般情况下只有body类型为blob或Formdata中包含)的数据,会存在功能异常,低版本甚至crash。
  • 如果在stopURLSchemeTask执行之后再通过WKURLSchemeTask回调数据会造成crash
  • iOS13以下存在WKURLSchemeTask 析构时会有概率崩溃。

这些问题如何解决,下文我们会逐一介绍

方案优缺点

我们可以从以下几个点做下对比:

方案隔离性业务无入侵系统兼容扩展性合规性
方案一 直接加载沙盒×iOS9+×
方案二 localServer××iOS8+×
方案三 NSURLProtocol×iOS2+×
方案四 WKURLSchemeHandleriOS11+
  • 隔离性: 我们希望只会对使用离线包的业务进行拦截,而非所有webview页面。显然以下两种方案是不满足的: localServer和NSURLProtocol的拦截都是全局的

  • 业务无入侵: 方案的实施如果要求h5层面去开发适配,将会导致方案的可用性变差,影响业务接入意愿。 NSURLProtocol与WKURLSchemeHandler只影响请求方式,不影响请求内容,H5代码无需改动。 而本地文件与localServer则需要h5做较多的工作去适配低,且对业务编码提出了一些要求,如离线资源使用相对路径、远程资源使用绝对路径,严重侵入H5业务。

  • 系统兼容: 方案应该可以兼容主流系统,使方案发挥最大的价值。这里需要重点考虑方案覆盖的范围是否“充足”,在复杂性与兼容性直接平衡即可。

  • 扩展性: 在业务无感知的情况下,我们可以通过拦截资源,做出更多加载上的优化。显然,方案一和方案二的扩展性是最差的,只能通过js hook来让我们能更灵活的进行扩展;方案三与方案四的扩展性最好,可以在业务方无感知的情况下实现预加载处理、公共资源离线、资源性能监控等能力。

从前面的对比我们可以看出各种方案的优缺点。而京东的h5实际开发生态要求我们提供一个对h5无侵入、方案使用范围可控、扩展性良好、长远看稳定性佳的方案。基于这些原因我们选择了基于WKURLSchemeHandler的拦截方案

3.2.4.2 基于WKURLSchemeHandler拦截方案

​WKURLSchemeHandler从iOS 11开始支持,需要我们提供一个实例对象遵守WKURLSchemeHandler 协议,并且通过使用 WKWebView Configuration 的方法 setURLSchemeHandler(_:forURLScheme:) 进行注册。在这里面有几点需要注意:

​WKURLSchemeHandler从iOS 11开始支持,需要我们提供一个实例对象遵守WKURLSchemeHandler 协议,并且通过使用 WKWebView Configuration 的方法 setURLSchemeHandler(_:forURLScheme:) 进行注册。在这里面有几点需要注意:

  • 只支持注册自定义 scheme

根据苹果文档的说明,这种拦截方式只支持注册自定义 scheme ,而常见的内建协议如 httphttpsfile等都不支持。针对自定义的scheme,这里需要注意的是页面的链接必须用自定义scheme。如果在https的页面内针对个别资源添加自定义的scheme一般会被浏览器block。浏览器认为我们自定义的 scheme 不是安全的协议而禁止加载。

  • 实现拦截非自定义 scheme

为了减少业务方接入成本,最好的方案是拦截 https,这样不需要业务方改动代码即可使用离线加载能力。默认情况情况下,如果我们尝试注册https的拦截,会造成崩溃.原因也很简单,查看WebKit源码会发现

- (void)setURLSchemeHandler:(id <WKURLSchemeHandler>)urlSchemeHandler forURLScheme:(NSString *)urlScheme
{
    if ([WKWebView handlesURLScheme:urlScheme])
        [NSException raise:NSInvalidArgumentException format:@"'%@' is a URL scheme that WKWebView handles natively", urlScheme];

  .....
}
复制代码

如果系统检测到正在注册内建的协议,则会抛出异常。这里我们直接hook WKWebViewhandlesURLScheme: 使之在http(s)时返回NO即可。这样我们就支持了http(s)的拦截。当然这里存在一个疑问,handlesURLScheme:的hook是否会影响其他webview的加载过程,这个是不会的。因为加载行为的改变是通过检测是否注册了对应协议的handler,这里的hook只是为https协议注册handler扫清了障碍,如果不注册,就不会改变原来的加载流程。现在WebView的资源请求流程就如下图所示了

3.2.4.3 拦截https协议遇到的坑

至此,我们终于打开了WKWebView拦截http(s)的魔盒,接下来就是逐个填坑的过程。

  • iOS 11.3之前丢失body

使用 WKURLSchemeHandler 方案在iOS 11.3之前的系统上拦截post请求是会丢失body的,由于11.3以下的用户量已经占比很少,所以我们并没有再这个点上花费太多的精力,直接将支持的系统版本提高了(实际上iOS12存在WKUrlSchemeTask析构时也会崩溃的问题,所以,我们直接将离线加载的起始版本定为了iOS13)。如果你的项目需要支持iOS11.3以下的拦截,可以考虑hook XMLHttpRequestfetch请求通过jsBridge的方式把整个请求转发到Native去做处理。

  • Blob 数据类型功能异常

在我们实践中发现,只要拦截到的请求使用了 Blob 数据类型,就会出现异常(比如文件上传失败),甚至还会发生崩溃。通过WebKit源码,我们发现

ExceptionOr<void> XMLHttpRequest::send(Blob& body)
{
  if (auto result = prepareToSend())
      return WTFMove(result.value());

  if (m_method != "GET" && m_method != "HEAD") {
      if (!m_url.protocolIsInHTTPFamily()) {
        // FIXME: We would like to support posting Blobs to non-http URLs (e.g. custom  URL schemes)
        // but because of the architecture of blob-handling that will require a fair amount of work.

        ASCIILiteral consoleMessage { "POST of a Blob to non-HTTP protocols in XMLHttpRequest.send() is currently unsupported."_s };
        scriptExecutionContext()->addConsoleMessage(MessageSource::JS, MessageLevel::Warning, consoleMessage);
      ......
  }

return createRequest();
}
复制代码

原来是WebKit工程师“偷懒”了,因为工作量大还没有支持。我们的变通办法是通过注入js代码,hook XHR和fetch请求解决,把带有Blob 类型的请求转到Native来解决。。

  • WKURLSchemeTask协议方法回调崩溃

WKURLSchemeTask协议通过以下几个方法回传Native的数据给WebKit,但是调用顺序一旦出错,直接会导致crash。

- (void)didReceiveResponse:(NSURLResponse *)response;
- (void)didReceiveData:(NSData *)data;
- (void)didFinish;
- (void)didFailWithError:(NSError *)error;
复制代码

这里要注意didReceiveData因为数据可能会分段传输,它会调用多次。所以,要在逻辑和机制上保证上述顺序必须无误。

  • WKURLSchemeTask 生命周期问题

我们使用WKURLSchemeTask实例进行数据回调时,如果此时实例已经被释放就会发生crash。由于释放的操作是在WebKit内核进行,外部无法控制其生命周期,所以需要在使用前检测是否存活。虽然WKURLSchemeHandler提供了一个协议方法

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

WKURLSchemeTask不可用时通知我们,但是实践下来,这个时机并不可靠,线上仍然会有一些crash发生。通过源码发现,这类case下WebKit抛出的都是 NSException 异常,我们做了一层 try-catch 保护。

  • Cookie同步问题

cookie同步问题是WKWebview下的一个很棘手的问题。从Cookie的操作角度来看,Native、H5、服务端三方均可操作cookie。从进程的角度来看,UIProcess、WebContentProcess、NetworkProcess三种进程都有cookie的管理。所以,这就导致了Cookie同步的复杂性。我们先从进程的角度来看一下拦截前Cookie的管理模型。

首先说明一下,cookie实际上是由CFHttpCookieStorage来做的管理,而NSHttpCookieStorage是基于CFHTTPCookieStorage封装的(可以从WebKit内部使用的一些私有API看出),这里用NSHTTPCookieStorage表示是考虑到大多数人对它比较熟悉。从图中可以看出NetworkProcess是实际的cookie管理者,它负责多方cookie操作的聚合,这里就解释了为什么我们从App进程中通过NSHttpCookieStorage去读取cookie时有延时。因为默认情况下只有在NetworkProcess写入了,UIProcess才有可能获取到(不同进程间的cookie共享应该是通过共享存储cookie的文件来完成的),这里cookie文件的写入策略、IPC通信等就会产生时间差。现在我们再来看cookie同步的需求:

由前面的拦截方案对比,我们知道WKURLSchemeHandler下我们拦截了所有的请求,这里面带来了一个cookie管理的变化。服务端操作cookie将首先在UIProcess生效,接口请求携带的cookie也是从UIProcess读取。所以,我们实质上需要把cookie的管理权转移给UIProcess。UIProcess操作cookie可以通过NSHttpsCookieStorage。服务端的cookie读写由NSURLSession、NSURLRequest、NSHttpsCookieStorage默认处理。现在只剩下WebContent进程和UIProcess进程之间Cookie的同步问题。。

WebContentProcess与UIProcess之间cookie的同步

UIProcess的Cookie发生变化可以通过WKHTTPCookieStore接口去设置,cookie先到NetworkProcess,然后触发监听,通知WebContentProcess,这样,h5就可以访问到这类cookie。比如我们的某个请求response会写cookie,在UIProcess里面,系统会默认处理,然后我们需要如下操作就会将它同步给WebContentProcess。response中的“Set-Cookie”字段也是同样的方式处理。

 - (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSHTTPURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler{
   NSArray <NSHTTPCookie *>*responseCookies = [NSHTTPCookie cookiesWithResponseHeaderFields:[response allHeaderFields] forURL:response.URL];
   if ([responseCookies isKindOfClass:[NSArray class]] && responseCookies.count > 0) {
       dispatch_async(dispatch_get_main_queue(), ^{
           [responseCookies enumerateObjectsUsingBlock:^(NSHTTPCookie * _Nonnull cookie, NSUInteger idx, BOOL * _Nonnull stop) {
               // 同步到WKWebView
               if (@available(iOS 11.0, *)) {
                   [[WKWebsiteDataStore defaultDataStore].httpCookieStore setCookie:cookie completionHandler:nil];
               } else {
                   // Fallback on earlier versions
               }
           }];
       });
   }
   completionHandler(NSURLSessionResponseAllow);
 }
复制代码

WebContentProcess产生的Cookie同步到UIProcess 当h5写入cookie(通过document.cookie='key=value&...'的形式),WebContentProces会先获取到,然后会通知NetworkProcess,这个时候如果不做额外的处理又会发生NetworkProcess可能同步慢,导致UIProcess无法及时拿到cookie的问题。通过监控发现,这个不同步发生的概率在我们业务场景下大于万分之五。关于这个问题,我们尝试了两个方案:一是WKHTTPCookieStore提供了cookie变化的监听,通过这个方案我们可以在cookie发生变化时及时同步。但遗憾的是如同我们前面介绍的一样,cookie的变化影响因子很多,所以这个系统回调会执行的很频繁,导致我们使用之后出现了明显的app卡顿。所以,我们采用了另一个方案:hook document.cookie的set方法,通过js桥将h5想写入的cookie直接传给UIProcess,然后解析、写入NSHttpCookieStorage以备接口请求使用。

总结下来,现在的cookie管理模型如图所示

+ 请求重定向问题

由于 WKURLSchemeTask 协议没有处理重定向的方法,所以如果在拦截后请求时发生重定向,我们只能拿到最后的结果,导致WebView是无法感知重定向的。我们的解决办法是针对HTML的重定向,拿到目标地址location进行重新加载并取消前次加载(这里会产生一次-999的取消请求错误,如果做监控的话可以屏蔽)。

非离线资源请求

​因为WKURLSchemeHandler的拦截方案拦截了所有的http的请求,所以我们不仅需要处理离线资源,还需要处理非离线资源的请求。这里我们也设计了自己的网络请求框架,需要注意的是WebKit默认的网络请求模块在NetworkProcess,http的缓存协议的处理、磁盘缓存的能力都是在这部分完成的,我们的网络框架要事实上承担这部分职责,显然一个基本的NSURLSessionDataTask太低效了。如图是我们的网络请求示意图:

这里简单介绍一下我们的网络层的几点优化措施:

1. 网络连接复用

我们知道在HTTP2.0支持TCP连接复用,但是在客户端层面不同的NSURLSession实例是无法进行复用的。如果连接被复用,DNS解析TCP握手网络连接的时间都可以被节省的,这些时间耗时在50~100ms左右。可以通过Charles抓包就可以验证。如下图所示:

所以我们的网络框架设计了在底层使用同一个NSURLSession实例,上层进行网络请求的管理,尽可能的去复用网络通道,当然AFNetworking等网络框架也是这么做的。

2. 并发控制

为了对网络框架进行并发控制,在底层使用同一个并发队列,便于总体控制。根据当前系统状况调整并发量,另外自定义异步Operation进行任务的处理。

3. 超时重试机制

超时重试机制的超时时间和重试次数的选择没有一个标准,针对不同的网络环境和请求类型应该有灵活的策略。我们采取快速重试和超时时间递增的策略,可以解决部分短时网络故障下的资源重试问题。

4. 任务优先级

不同的资源和请求类型,优先级显然是不同的,我们设置HTML文件优先级最高,js、css以及超时重试的优先级次之,最后才是其他资源优先级最低。后续这部分还会进一步细化,比如将性能、异常类的埋点的优先级调整道最低,避免它们与业务请求抢占资源。

5. 自定义网络缓存

由于NSURLCache容量较小、不支持自定义策略以及无法使用磁盘缓存等缺点,我们自己实现了一套网络缓存,遵守http标准缓存协议同时添加了一些自定义策略,除了支持内存缓存和磁盘缓存外,还支持自定义缓存策略,比如:缓存限制、可动态分配缓存容量、不影响首屏加载的资源不缓存、HTML不缓存等,另外还实现了LRU的淘汰策略和缓存清理功能。

4 京东商城App离线加载实践

京东商城将h5资源分为业务离线包、公共离线包、全局离线包三类,离线文件结构如下:

离线加载结构

业务离线包主要包含业务开发的js、css、图片等资源,公共离线包主要包含京东通用的功能组件,如互动类可以使用同一个公共离线包,共用互动组件资源,避免业务离线包之间资源重复造成流量及带宽的浪费。

4.1 离线包的生成


离线包资源作为H5页面资源在客户端本地的一份拷贝,在资源内容上应该是完全一致的。所以在生成离线包的初期,我们需要接入的业务将发布H5页面的资源,在平台同步上传一份来保证资源一致。该方案需要同一份资源在两个平台发布,因为不同平台发布策略的不同,会伴随着带来接入方改造成本,需要接入的H5业务同时维护两套打包流程,以满足离线包规范和H5页面规范。 为了降低对H5业务带来的额外接入成本,我们优化了离线包的生成流程。 接入方从一个可访问的H5页面URL入手,通过在服务端模拟浏览器的页面加载过程,拦截所有的页面加载请求,从中分析出可配置离线的资源URL列表返回到配置平台。基于不同资源参与H5页面首屏幕渲染的权重不一样,在这一步我们也会提供优先css/js的返回策略。 后续接入方可以通过可视化的方式在平台勾选希望离线加载的资源,确认后,服务端会拉取对应的URL资源生成离线包,并在包中加入一个资源描述文件,包含用户客户端匹配的资源URL,以及真实的资源请求header,方便用于离线资源的回传。

html解析效果图

经过这个流程的优化,已经大大降低了业务接入成本,不需要再维护两套打包策略,但是不可避免的还需要接入方手动选择离线包资源。为进一步降低接入难度,我们提供了另一种前端工程自动化的离线包生成方式,通过提供命令行工具来融入前端H5项目中,配置生成离线包目录,将该目录中的资源通过一行命令生成合规离线包,并上传到发布平台。

4.2 离线包本地管理

4.2.1 离线包下载分级

为了提高离线包的使用率,对离线包的下载进行分级分类,一共分成T级、S级、A级分别对应不同的下载策略。

  • T级:app启动或app切换前后台时触发下载,主要包括大促等入口在首页且PV量较高的业务。
  • S级:首页渲染完成后触发下载,主要包括入口比较浅的业务,但同时避免抢占首页本身的资源加载。
  • A级:指定页面触发下载,适合入口比较固定、PV量相对较小的业务,避免所有用户下载导致流量及带宽的浪费。

同一级别下不同离线包的下载顺序则采用配置权重+本地权重的方式进行权重加和计算优先级,其中本地优先级根据LRU算法动态计算,用户最近使用越多的离线包权重越高。

最终权重=配置权重+本地权重(LRU最终权重 = 配置权重 + 本地权重(LRU)

除了以上优先级的处理,离线包下载也增加了部分前置下载条件,主要包括当前线程数、CPU使用率等,不做压死骆驼的最后一跟稻草。

4.2.2 差分包策略


京东商城离线包差分使用的是bsdiff差分算法,那么差分包如何进行管理呢?一般情况做法都是客户端将本地离线包的版本号上传给服务端,由服务端返回对应版本的差分包地址下发到客户端。但是在离线包量较大的情况下,客户端就需要获取所有离线包版本进行上传,对于客户端和服务端都会增加一些逻辑。所以我们前后端做了规范约定,只需服务端按规范生成差分包地址即可,格式如下:

完整包地址_服务端版本号_客户端本地版本号 例如:storage.360buyimg.com/hybrid/xxxx…

客户端拉到新版本离线包url后,就可以根据规范拼接差分包下载地址。

4.2.3 自动灰度

通过离线包下载分级、差分包方案在一定程度上减少了离线包下载带宽流量的减少,为了进一步减少离线包下载的流量峰值,我们增加了自动灰度能力,根据业务选择灰度比例进行等差灰度放量。如业务选择1小时完成全量灰度,后台则自动按每5分钟放量一次,12次放全量。

灰度图

4.3 资源离线加载

4.3.1 资源匹配逻辑

前面提到离线包压缩包中会包含离线包对应的资源映射配置文件,通过h5页面的url获取到离线包后,读取离线包中映射文件内容进行资源本地加载匹配。

匹配逻辑

映射文件内容如下:

[
  {
    "filename":"rp_h3aBW.html",
    "originUrl":"https://h5.m.jd.com/babelDiy/index.html",
    "type":"html",
    "header":{
      "content-type":"text/html",
      "content-length":"1370"
    }
  },
  {
    "filename":"1HvyKyDC.js",
    "originUrl":"https://storage.360buyimg.com/1654142210531/vendorJd.fa438901.js",
    "type":"script",
    "header":{
      "content-type":"application/x-javascript",
      "content-length":"415690",
      "access-control-allow-origin":"*",
      "timing-allow-origin":"*"
    }
  }
]
复制代码

通过映射文件的originUrl、filename字段就可以将资源请求和本地文件进行一一映射,映射对比也需要做一些兼容处理。

  • scheme兼容,url对比是需要忽略scheme,同一个链接可能存在http、https两种访问情况。
  • 图片降质处理,一般图片服务器都会做图片压缩转换等处理,h5页面会根据当前环境请求不同的图片,比如安卓4.4以上已经完全支持webp图片,京东h5请求图片时对于支持webp的环境在资源url的path末尾拼接“!webp”,客户端匹配时会忽略"!"号以及后面的内容。
  • 域名打散,一般情况下,在超级活动期间,服务端为了减轻单个域名的压力,会采用多域名分散的方式来降低单个域名的qps,因此,会有不太的域名链接对应同一个资源,所以,可以在资源匹配时忽略域名。

为了避免误伤,这些措施都是通过配置下发来进行精细化控制的。

4.3.2 资源实时性

当前京东App的离线包配置是App启动等特定时机请求拉取,在拉取配置到用户实际进入页面有一定的时间差,在京东这种电商App大促场景下,经常会有h5活动切场等情况,对实时性要求较高,要求用户打开页面时请求的页面资源必须是最新的,我们增加了版本校验接口对离线包中包含html的离线包在进入页面时进行离线包更新校验。

实时性校验

这种方案虽然保证了实时性,但是我们通过监控发现,更新的概率非常小,大部分请求流量都是浪费的,如果有更新重新加载也会带来不好的reload体验。于是我们将离线包更新的信息加入到京东网关接口中,只要App有网关请求都能获取到是否有更新,做到了准实时。于是流程可以变成:

实时校验

4.3.3 兼容重定向

京东App使用的是登录后台同步App WebView的登录态,加载业务落地页时通过加载登录页面302重定向到落地页并同步后台Cookie。

302problem

由于在安卓端谷歌上述拦截方法不支持302的情况,无法拦截302后的链接,导致业务html无法拦截加载本地文件。这里我们想到的办法是通过原生网络请求登录打通的链接,再通过解析Header中的SetCookie获取登录Cookie并将Cookie同步到浏览器内核,最后直接通过webview加载业务链接。

302fix

public void onSusses(int code, Map<String, List<String>> responseHeaders, String data)
  if (header != null && (setCookies = header.get("Set-Cookie")) != null && !setCookies.isEmpty()) {
    saveCookieString(url, setCookies);
  }
}

public void saveCookieString(String url, List<String> cookies) {
  CookieManager cookieManager = CookieManager.getInstance();
  if (!cookieManager.acceptCookie()) {
      return;
  }
  for (String cookieSegment : cookies) {
      if (TextUtils.isEmpty(cookieSegment)) {
        continue;
      }
      cookieManager.setCookie(url, cookieSegment);
  }
  if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
      CookieSyncManager.createInstance(HybridSettings.getAppContext());
      CookieSyncManager.getInstance().sync();
  } else {
      CookieManager.getInstance().flush();
  }
}
复制代码

在iOS端同样存在重定向的问题,我们前面已经介绍过了,WKURLSchemeTask并未提供重定向的回调协议,所以,当拦截到的请求在Native端发生重定向时,我们会先处理重定向的response里面的cookie信息(如果存在的话),然后直接用当前webview去load重定向之后的newRequest,这样就可以处理重定向的问题。

5 更多优化措施

前面我们主要介绍了通过离线包来解决h5页面加载过程中实时拉取资源造成耗时的问题。下面我们再来看看其他影响h5加载性能的一些步骤。让我们再次回到h5页面的加载流程

离线包架构图.png

如上图所示,通常情况下从用户点击到看到页面内容会包括 WebView初始化加载HTML解析HTML加载、解析JS/CSS资源数据请求页面渲染 等流程,我们构建的离线加载系统可以节约多个“下载”过程的耗时。但是这里依然有两个问题

1. 虽然我们有离线包系统,但是并非所有的业务都可以将html做到离线包内,比如一些SSR的业务,html往往不是静态页面,这种场景下整个流程几乎还是串行的(html->js->数据请求)。
2. 页面有意义的渲染一般都是发生在业务数据请求之后,上述流程中业务数据的请求和html、js等串行加载。
复制代码

针对这两点,我们采用了以下方案来做优化:

  • 通过html预加载,解决html串行加载的问题;
  • 通过接口预加载,解决业务数据串行请求的问题;

5.1 html预加载

我们的目标是将html下载的时机尽量提前。一般情况下,我们在html真正加载前不管是应用层面还是系统层面或者Webkit内部都有一些预处理的逻辑,这些逻辑的耗时和业务复杂度相关,少则几十毫秒,多则几百毫秒,提前发起html请求可以有效的利用这段时间。当拦截到html请求时要么直接回调已下载完成的html,要么等待html返回后再进行回调。无论如何,相比串行请求,这种机制都有效的利用了html加载前的这段时间。具体的流程见图 html预加载示意图

5.2 接口预加载

数据接口的预加载的必要性与原理和html预加载类似,我们希望在页面初始化之前就开始请求数据,等拦截到对应的请求时直接返回预加载好的数据,以节约页面有意义的渲染时长。相对于html预加载,接口预加载的难点在于接口请求参数的配置,在最初的版本中,我们支持通过配置来完成请求参数的下发,可配置的内容包括:

  • 业务自定义参数的固定部分
  • 设备、用户基本信息的映射
  • 接口请求中业务链接中的参数

接口预加载配置.jpg

在今年618大促期间,秒杀主会场、百亿补贴等会场使用了接口预加载技术,从数据看可提升接口加载性能50%以上。但是这里也有一些问题需要注意:

第一、接口数据太大,会劣化数据预加载效果

我们的接口预加载采用了Native请求,然后将结果通过jsbridge回传给h5,这个过程涉及到数据json化、字符串化、进程间通信、js层JSON.parse等一系列操作,这些操作的耗时与数据大小成正相关,所以做好数据规模的控制很有必要。 如图:

第二、数据在做json字符串化时需要注意特殊字符处理

我们在实践中发现,通过js通信回传信息给h5时,符合两端统一的做法是均以jsonString进行回传,而采用系统方法直接转化的jsonString存在一些缺陷,特别是数据中包括一些特殊字符时会导致jsonString在js层面无法解析,我们在android端就碰到了数据中包含单引号时解析异常的问题。目前的解决方案是参考了iOS端的开源库WebViewJavaScriptBridge的相关做法,针对系统转化的jsonString进一步进行特殊字符的处理:


  messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\\" withString:@"\\\\"];
  messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\"" withString:@"\\\""];
  messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\'" withString:@"\\\'"];
  messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\n" withString:@"\\n"];
  messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\r" withString:@"\\r"];
  messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\f" withString:@"\\f"];
  messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\u2028" withString:@"\\u2028"];
  messageJSON = [messageJSON stringByReplacingOccurrencesOfString:@"\u2029" withString:@"\\u2029"];

复制代码

当然接口预加载这个简单的配置方案在实践中存在一些问题,导致目前适用场景比较受限。主要问题表现在:

第一、纯配置的系统表达能力较弱,无法准确描述请求参数结构,即使一些公共的参数,不同业务也有个性化的使用方式(比如一个参数,A业务可能在params中,B业务在body中),导致我们在不同的请求字段内添加了相同的数据,冗余较严重;

第二、设备信息、用户信息等相对稳定的字段,不同业务会定义个性化的key值,这类参数要通过配置提供映射

第三、无法较好的描述接口之间的依赖关系,多接口预加载实现难度大。所以简单起见,我们前期仅支持了一个接口的预加载。

对于以上问题,我们也正在通过轻量级的表达式框架正在改进中。

5.3 内存预热

除了上述预加载的优化方案之外,我们在资源缓存管理上也进行了对应的优化,因为离线加载的资源存储在磁盘上,页面加载时读取会有一定的io消耗,特别是离线资源较多的情况下,这些消耗累加起来还是比较可观的。针对这部分消耗,我们采用了内存预热的方法来进行优化。

当页面创建时,我们开启子线程将页面项目对应的离线资源提前读入内存缓存池,拦截到对应的请求后,我们先查看内存缓存池是否有对应的资源,如果没有才会去读取磁盘。内存缓存池在项目数量和资源数量两个维度均采用LRU的淘汰策略进行约束,保证内存缓存的上限水位在一个合理的范围内。

这种预热策略可以保证大部分的资源在请求时直接从内存读取。而针对访问量巨大的超级互动业务,我们也可以选择首页渲染完成后就在子线程进行预扫描缓存,这样可以极大的优化大流量业务的加载速度。

6 提效工具

6.1 调试工具

在Hybrid开发的场景下,业务面对的最大的困难是加载过程是黑盒的,离线包是否命中,接口预加载等性能优化措施是否生效,业务需要一个简洁明了的工具来查看。结合业务需求与h5研发的使用习惯,我们提供了Hybrid下的调试开发工具。

我们先来看下调试工具需要展示的数据:

  • 离线包是否命中:我们定义的离线包命中口径为:“至少有一个资源从本地获取成功”
  • 页面性能:我们提供了fcp和页面初始化开始~页面didfinish时间间隔两种指标,以供h5业务实时查看首屏性能与用户体验感受。
  • 接口预加载是否生效:h5成功的从客户端获取到了预加载到的接口数据之后,标示接口预加载生效
  • html预加载是否生效:h5成功的从客户端获取到了预加载到的html数据之后,标示html预加载生效

除了以上概括性的信息之外,还有一些细节信息也会比较重要

  • 离线包项目信息:如当前离线包的配置版本、文件版本
  • 离线包加载信息:当前离线包命中的资源列表

而其他辅助性的信息或测试信息,我们直接归为log,实时展示在调试面板上,业务可按需查看

最终数据展示面板如图所示:

这样,一个简单的工具就可以有效的提升业务研发效率,降低团队对外的答疑与咨询量。

6.2 js api自动化测试

越是复杂的系统越应该重视测试的手段与质量,自动化的测试能力是最常见的保障系统稳定性的手段,Hybrid的功能又会涉及到多端,且功能众多,日常迭代与修改很容易引发边界类的bug,所以,我们将hybrid的各项功能聚合成了一个自动化执行的页面,通过一键执行,即可覆盖已知的核心case的测试,如图:

对于Hybrid的UI类功能,我们正在结合集团的云测平台,通过脚本执行、截图、图像识别等能力打通相关UI类的功能的自动化测试能力。争取尽早完成Hybrid能力自动化测试全覆盖。

7.数据监控

为了监控优化成果,并进一步帮助h5页面深入分析性能,我们与烛龙监控平台合作,增加了多维度的性能监控。

7.1 多维度监控

监控维度

监控指标主要包括性能监控、异常监控和离线包加载监控,每项指标都能够多维度进行聚合分析,包括时间、客户端、版本、系统、厂商、类型、内核等维度。

监控维度

7.2 离线包信息

离线包信息包括离线包从拉取配置、到下载、再到使用更新整个链路的监控,能够实时反馈线上用户使用下载和使用离线包的情况。

7.3 性能监控

性能监控通过原生和JS同时采集的方式,更完整的监控h5加载过程,原生层面通过WebView回调等方式进行数据采集,主要节点包括:

PerformanceTiming

  • initStart:WebView实例开始初始化的时间节点。
  • loadUrl:WebView执行load(loadRequest)方法。
  • pageStart:安卓对应WebViewClient.onPageStart方法,iOS对应didStartProvisionalNavigation
  • pageCommit:安卓对应WebViewClient.onPageCommitVisible,iOS对应didCommitNavigation
  • colorRequestStart:业务接口请求开始
  • colorRequestEnd:业务接口请求结束
  • pageFinish:安卓对应WebViewClient.onPageFinish,iOS对应didFinishNavigation

除此以外在页面加载结束时,也就是上述的pageFinish节点通过执行js获取更多性能数据,主要是通过Performance API获取PerformanceTiming、FP、FCP、LCP、资源性能等。

  • PerformanceTiming: w3c引入的api,可获取h5页面加载的各个节点时间。 PerformanceTiming
  • FP:(First Paint)用于记录页面第一次绘制像素的时间。
  • FCP:(First Contentful Paint)用于记录页面首次绘制文本、图片、非空白 Canvas 或 SVG 的时间
  • LCP:(Largest Contentful Paint)用于记录视窗内最大的元素绘制的时间,该时间会随着页面渲染变化而变化,因为页面中的最大元素在渲染过程中可能会发生改变,该指标获取的是时间区间内的节点,所以需要在页面开始加载时开始监听,页面加载结束后获取。

以安卓为例,先注册JS桥:

/**
     *
     * @param timing 页面加载性能
     * @param resource 资源网络请求性能(包括网络接口)
     * @param paint fp、fcp
     * @param lcp lcp
     */
@JavascriptInterface
public void sendResource(String timing, String resource,  String paint, String lcp) {
  //数据上报
}
复制代码

页面开始加载时(上述所提到的pageStart节点),开始注入LCP监听。

String js = "try{" +
            "const po = new PerformanceObserver((entryList) => {" +
            "const entries = entryList.getEntries();" +
            "const lastEntry = entries[entries.length - 1];" +
            "window.jdhybrid_performance_lcp = lastEntry.renderTime || lastEntry.loadTime;});" +
            "po.observe({type: 'largest-contentful-paint', buffered: true});" +
            "}catch (e) {}";
webView.evaluateJavascript(js,null);
复制代码

页面加载结束时(上述所提到的pageFinish节点),获取所有js性能数据。

String js = "try{" +
        "window.hybridPerformance.sendResource(" +
        "JSON.stringify(window.performance.timing)," +
        "JSON.stringify(window.performance.getEntriesByType('resource'))," +
        "JSON.stringify(window.performance.getEntriesByType('paint'))," +
        "window.jdhybrid_performance_lcp ? window.jdhybrid_performance_lcp.toString():'');" +
        "}catch (e) {}";
webView.evaluateJavascript(js,null);
复制代码

7.4 异常监控

异常监控主要包括JS桥执行异常、页面加载错误、页面响应错误、资源请求失败、JS Exception,webview白屏等,这些指标即可以用来日常排查问题,也可以作为度量Hybrid离线包接入之后的业务价值,关于h5页面加载异常的处理策略后续我们再专门介绍。

8 开源计划

我们计划在下半年对上述写到的主要能力在github进行开源,也诚邀感兴趣的大佬们到时候一起进来完善、交流。

9 写在最后

感谢互动、大促、频道的开发团队对京东h5页面性能优化作出的贡献,现在通过各种优化手段已经将整体性能有了一定程度的提升,我们也有更多的优化方案在输出中,包括NSR、预渲染等。在近期也会对离线加载代码进行开源,欢迎感兴趣的小伙伴、有更多更好的优化方案的小伙伴可以留言一起讨论。

参考文献:

联系我们

团队:JDHybrid团队
邮箱:hybrid@jd.com

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改