性能提升30%以上 JDHybrid h5加载优化实践
时间:2023-02-01 03:00:01 | 来源:建站知识
时间:2023-02-01 03:00:01 来源:建站知识
背景Hybrid技术现在已经是一个常见的技术方案,它既能享受到Native的能力,同时还能拥有H5技术低成本、高效率和跨平台等特性。然而,H5技术的加载速度一直饱受诟病,相比于Native,app打开H5页面时,H5会发起多个HTTP请求去加载资源,资源大小、网络质量都会影响页面打开速度。为了节省这部分时间,我们可以把首屏的一些静态资源(如img、js、css、html等)打包提前加载到本地磁盘,当加载页面时直接从本地磁盘(或内存)获取资源加载。本地的读写速度是远高于网络请求的,尤其是在网络不良或资源太大的情况下,离线化方案更能展现出它的优势。此外,在一些大促等高流量场景下,提前将活动页面资源下载到客户端本地,可以大幅降低活动当天的CDN峰值与带宽,在降本提效方面有显著的作用。
618大促效果从去年11.11开始,JDHybrid开始承接各类大促业务,无论是沸腾之夜还是春晚项目,JDHybrid在降本提效方面都发挥了重要的作用。经过这些超级流量的大考之后,JDHybrid在业务范围、业务服务质量、稳定性等多个方面都有了很大的进步。今年618期间,618主会场、T级互动、秒杀主会场、百亿补贴等多个核心业务均使用了JDHybrid,整体首屏加载速度提升30%以上,秒开率提升20%以上,页面错误率降低了60%以上。
下面我们就来看一看数据的背后JDHybrid都做了些什么。
离线加载机制探究01Android 离线加载机制Android实现加载本地文件的api相对较少,主要是包括直接load本地文件以及拦截资源请求两个方案。
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无法访问。
虽然能通过曲线救国方式解决以上问题,比如将网络请求桥接到原生,但整体改动费时费力,放弃吧。
1.2 拦截资源请求谷歌提供了拦截资源请求的API:
@Nullablepublic WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { return shouldInterceptRequest(view, request.getUrl().toString());}
只需要构造WebResourceResponse对象即可实现本地文件加载,毫无副作用,这也是目前各大厂App的方案,相比iOS,安卓在方案上相对比较简单。京东商城App最终也是使用了该方案拦截加载本地文件,我们在这里提一下特别需要注意的点。
1.2.1 请求中body丢失WebResourceRequest中不包含body,该方法中不要拦截post请求,会有丢失body的风险。京东只用该方法加载本地文件资源,所以不存在body丢失的情况。
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); }
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",*);
02iOS 离线加载机制自从Apple废弃UIWebView之后,iOS端的离线加载技术变的异常复杂,无论何种方案,都会带有先天不足,需要通过各种补丁解决。业界目前采用的方案也不太统一,方案均带有明显的业务特征。京东内部h5业务也有自身的一些特点,所以JDHybrid在方案选择上主要考虑了以下几点:第一、因为h5开发相对开放,所以,没有一个相对稳定的h5开发平台可以对接,无法形成统一的规则约束来简化方案设计成本与使用成本;第二、大部分h5业务都是比较成熟的业务,颠覆性的方案设计会因为侵入性较强降低业务的接入意愿;所以,JDHybrid的设计必须建立在“业务研发0修改”的基础之上。当然,在方案设计过程中,我们也对现有的方案进行了摸排,有些后面被弃用的方案甚至在线上进行了验证,这里对相关的探索历程也总结一下。
2.1 直接加载本地文件和Andriod端类似,iOS也可以通过加载本地文件来实现离线包的加载
- (nullable WKNavigation *)loadFileURL:(NSURL *)URL allowingReadAccessToURL:(NSURL *)readAccessURL
但这个方案问题太多,无法推广使用,具体的问题与副作用和前述Android端类似,这里就不赘述了。
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协议的问题,方案一的其他问题仍然存在,业务兼容与开发成本并未出现明显下降。另外它也会带来许多额外的问题,如性能消耗、电量消耗、资源访问权限安全等。
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去替换,长远看也有被官方移除的风险。
2.4 WKURLSchemeHandleriOS11之后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 析构时会有概率崩溃。
这些问题如何解决,下文我们会逐一介绍。在介绍这个方案之前,我们先来看一下为什么要做这种方案选择。
2.4.1 方案优缺点我们可以从以下几个点做下对比:
隔离性:我们希望只会对使用离线包的业务进行拦截,而非所有webview页面。显然以下两种方案是不满足的:localServer和NSURLProtocol的拦截都是全局的。
业务无入侵:方案的实施如果要求h5层面去开发适配,将会导致方案的可用性变差,影响业务接入意愿。NSURLProtocol与WKURLSchemeHandler只影响请求方式,不影响请求内容,H5代码无需改动。而本地文件与localServer则需要h5做较多的工作去适配,且对业务编码提出了一些要求,如离线资源使用相对路径、远程资源使用绝对路径,严重侵入H5业务的开发流程。
系统兼容:方案应该可以兼容主流系统,使方案发挥最大的价值。这里需要重点考虑方案覆盖的范围是否“充足”,在复杂性与兼容性之间平衡即可。
扩展性:在业务无感知的情况下,我们可以通过拦截资源,做出更多加载上的优化。显然,方案一和方案二的扩展性是最差的;方案三与方案四的扩展性最好,可以在业务方无感知的情况下实现预加载处理、公共资源离线、资源性能监控等能力。
从前面的对比我们可以看出各种方案的优缺点。而京东的h5实际开发生态要求我们提供一个对h5无侵入、方案使用范围可控、扩展性良好、长远看稳定性佳的方案。基于这些原因我们选择了基于WKURLSchemeHandler的拦截方案。
2.4.2 方案实现WKURLSchemeHandler从iOS 11开始支持,需要我们提供一个实例对象遵守WKURLSchemeHandler 协议,并且通过使用 WKWebView Configuration 的方法 setURLSchemeHandler(_:forURLScheme:) 进行注册。在这里面有几点需要注意:
只支持注册自定义 scheme根据苹果文档的说明,这种拦截方式只支持注册自定义 scheme ,而常见的内建协议如 http 、 https 、file等都不支持。针对自定义的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 WKWebView 的 handlesURLScheme: 使之在http(s)时返回NO即可。这样我们就支持了http(s)的拦截。当然这里存在一个疑问,handlesURLScheme:的hook是否会影响其他webview的加载过程,这个是不会的。因为加载行为的改变WebKit是通过检测是否注册了对应协议的handler,这里的hook只是为https协议注册handler扫清了障碍,如果没有实际注册Handler,就不会改变原来的加载流程。现在WebView的资源请求流程就如下图所示了。
2.4.3 踩坑记录至此,我们终于打开了WKWebView拦截http(s)的魔盒,接下来就是逐个填坑的过程。
iOS 11.3之前丢失body使用 WKURLSchemeHandler 方案在iOS 11.3之前的系统上拦截post请求是会丢失body的,由于11.3以下的用户量已经占比很少,所以我们并没有这个点上花费太多的精力,直接将支持的系统版本提高了(实际上iOS12存在WKUrlSchemeTask析构时也会崩溃的问题,所以,我们直接将离线加载的起始版本定为了iOS13)。如果你的项目需要支持iOS11.3以下的拦截,可以考虑hook XMLHttpRequest和fetch请求通过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的同步问题。
UIProcess cookie同步到WebContentProcessUIProcess的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的取消请求错误,如果做监控的话可以屏蔽)。
2.4.4 非离线资源请求因为WKURLSchemeHandler的拦截方案拦截了所有的http的请求,所以我们不仅需要处理离线资源,还需要处理非离线资源的请求。这里我们也设计了自己的网络请求框架,需要注意的是WebKit默认的网络请求模块在NetworkProcess,http的缓存协议的处理、磁盘缓存的能力都是在这部分完成的,我们的网络框架要事实上承担这部分职责,显然一个基本的NSURLSessionDataTask太低效了。如图是我们的网络请求示意图:
这里简单介绍一下我们的网络层的几点优化措施:
网络连接复用我们知道在HTTP2.0支持TCP连接复用,但是在客户端层面不同的NSURLSession实例是无法进行复用的。如果连接被复用,DNS解析、TCP握手、网络连接的时间都可以被节省的,这些时间耗时在50〜100ms左右。可以通过Charles抓包就可以验证。如下图所示:
所以我们的网络框架设计了在底层使用同一个NSURLSession实例,上层进行网络请求的管理,尽可能的去复用网络通道,当然AFNetworking等网络框架也是这么做的。
并发控制为了对网络框架进行并发控制,在底层使用同一个并发队列,便于总体控制。根据当前系统状况调整并发量,另外自定义异步Operation进行任务的处理。
超时重试机制超时重试机制的超时时间和重试次数的选择没有一个标准,针对不同的网络环境和请求类型应该有灵活的策略。我们采取快速重试和超时时间递增的策略,可以解决部分短时网络故障下的资源重试问题。
任务优先级不同的资源和请求类型,优先级显然是不同的,我们设置HTML文件优先级最高,js、css以及超时重试的优先级次之,最后才是其他资源优先级最低。后续这部分还会进一步细化,比如将性能、异常类的埋点的优先级调整到最低,避免它们与业务请求抢占资源。
自定义网络缓存由于NSURLCache容量较小、不支持自定义策略以及无法使用磁盘缓存等缺点,我们自己实现了一套网络缓存,遵守http标准缓存协议同时添加了一些自定义策略,除了支持内存缓存和磁盘缓存外,还支持自定义缓存策略,比如:缓存限制、可动态分配缓存容量、不影响首屏加载的资源不缓存、HTML不缓存等,另外还实现了LRU的淘汰策略和缓存清理功能。
京东商城App离线加载实践京东商城将h5资源分为业务离线包和公共离线包,离线文件结构如下:
业务离线包主要包含业务开发的js、css、图片等资源,公共离线包主要包含京东通用的功能组件,如互动类可以使用同一个公共离线包,共用互动组件资源,避免业务离线包之间资源重复造成流量及带宽的浪费。
01离线包的生成离线包资源作为H5页面资源在客户端本地的一份拷贝,在资源内容上应该是完全一致的。所以在生成离线包的初期,我们需要接入的业务将发布H5页面的资源,在平台同步上传一份来保证资源一致。该方案需要同一份资源在两个平台发布,因为不同平台发布策略的不同,会伴随着带来接入方改造成本,需要接入的H5业务同时维护两套打包流程,以满足离线包规范和H5页面规范。
为了降低对H5业务带来的额外接入成本,我们优化了离线包的生成流程。
接入方从一个可访问的H5页面URL入手,通过在服务端模拟浏览器的页面加载过程,拦截所有的页面加载请求,从中分析出可配置离线的资源URL列表返回到配置平台。基于不同资源参与H5页面首屏幕渲染的权重不一样,在这一步我们也会提供优先css/js的返回策略。后续接入方可以通过可视化的方式在平台勾选希望离线加载的资源,确认后,服务端会拉取对应的URL资源生成离线包,并在包中加入一个资源描述文件,包含用户客户端匹配的资源URL,以及真实的资源请求header,方便用于离线资源的回传。
经过这个流程的优化,已经大大降低了业务接入成本,不需要再维护两套打包策略,但是不可避免的还需要接入方手动选择离线包资源。为进一步降低接入难度,我们提供了另一种前端工程自动化的离线包生成方式,通过提供命令行工具来融入前端H5项目中,配置生成离线包目录,将该目录中的资源通过一行命令生成合规离线包,并上传到发布平台。
02离线包本地管理2.1 下载分级为了提高离线包的使用率,对离线包的下载进行分级分类,一共分成T级、S级、A级分别对应不同的下载策略。
T级:app启动或app切换前后台时触发下载,主要包括大促等入口在首页且PV量较高的业务。
S级:首页渲染完成后触发下载,主要包括入口比较浅的业务,但同时避免抢占首页本身的资源加载。
A级:指定页面触发下载,适合入口比较固定、PV量相对较小的业务,避免所有用户下载导致流量及带宽的浪费。
同一级别下不同离线包的下载顺序则采用配置权重+本地权重的方式进行权重加和计算优先级,其中本地优先级根据LRU算法动态计算,用户最近使用越多的离线包权重越高。
最终权重 = 配置权重 + 本地权重(LRU)
除了以上优先级的处理,离线包下载也增加了部分前置下载条件,主要包括当前线程数、CPU使用率等,不做压死骆驼的最后一根稻草。
2.2 差分包策略京东商城离线包差分使用的是bsdiff差分算法,那么差分包如何进行管理呢?一般情况做法都是客户端将本地离线包的版本号上传给服务端,由服务端返回对应版本的差分包地址下发到客户端。但是在离线包量较大的情况下,客户端就需要获取所有离线包版本进行上传,对于客户端和服务端都会增加一些逻辑。所以我们前后端做了规范约定,只需服务端按规范生成差分包地址即可,格式如下:
差分包下载url = 完整包url + "_"+服务端版本号 + "_" + 客户端本地版本号,例如:
https://storage.360buyimg.com/hybrid/xxxxx.zip_2_1客户端拉到新版本离线包url后,就可以根据规范拼接出差分包下载地址。
2.3 自动灰度通过离线包下载分级、差分包方案在一定程度上减少了离线包下载带宽流量的减少,为了进一步减少离线包下载的流量峰值,我们增加了自动灰度能力,根据业务选择灰度比例进行等差灰度放量。如业务选择1小时完成全量灰度,后台则自动按每5分钟放量一次,12次放全量。
03资源离线加载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,因此,会有不同的域名链接对应同一个资源,所以,可以在资源匹配时忽略域名。
为了避免误伤,这些措施都是通过配置下发来进行精细化控制的。
3.2 资源实时性当前京东App的离线包配置是App启动等特定时机请求拉取,在拉取配置到用户实际进入页面有一定的时间差,在京东这种电商App大促场景下,经常会有h5活动切场等情况,对实时性要求较高,要求用户打开页面时请求的页面资源必须是最新的,我们增加了版本校验接口对离线包中包含html的离线包在进入页面时进行离线包更新校验。
这种方案虽然保证了实时性,但是我们通过监控发现,更新的概率非常小,大部分请求流量都是浪费的,如果有更新重新加载也会带来不好的reload体验。于是我们将离线包更新的信息加入到京东网关接口中,只要App有网关请求都能获取到是否有更新,做到了准实时。于是流程可以变成:
3.3 兼容重定向京东App使用的是登录后台同步App WebView的登录态,加载业务落地页时通过加载登录页面302重定向到落地页并同步后台Cookie。
由于在安卓端谷歌上述拦截方法不支持302的情况,无法拦截302后的链接,导致业务html无法拦截加载本地文件。这里我们想到的办法是通过原生网络请求登录打通的链接,再通过解析Header中的SetCookie获取登录Cookie并将Cookie同步到浏览器内核,最后直接通过webview加载业务链接。
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,这样就可以处理重定向的问题。
更多优化措施前面我们主要介绍了通过离线包来解决h5页面加载过程中实时拉取资源造成耗时的问题。下面我们再来看看其他影响h5加载性能的一些步骤。让我们再次回到h5页面的加载流程。
如上图所示,通常情况下从用户点击到看到页面内容会包括 WebView初始化 、加载HTML、解析HTML、加载、解析JS/CSS资源、数据请求、页面渲染 等流程,我们构建的离线加载系统可以节约多个“下载”过程的耗时。但是这里依然有两个问题。
虽然我们有离线包系统,但是并非所有的业务都可以将html做到离线包内,比如一些SSR的业务,html往往不是静态页面,这种场景下整个流程几乎还是串行的(html->js->数据请求)。
页面有意义的渲染一般都是发生在业务数据请求之后,上述流程中业务数据的请求和html、js等串行加载。
针对这两点,我们采用了以下方案来做优化:通过html预加载,解决html串行加载的问题;通过接口预加载,解决业务数据串行请求的问题。
01html预加载我们的目标是将html下载的时机尽量提前。一般情况下,我们在html真正加载前不管是应用层面还是系统层面或者webkit内部都有一些预处理的逻辑,这些逻辑的耗时和业务复杂度相关,少则几十毫秒,多则几百毫秒,提前发起html请求可以有效的利用这段时间。当拦截到html请求时要么直接回调已下载完成的html,要么等待html返回后再进行回调。无论如何,相比串行请求,这种机制都有效的利用了html加载前的这段时间。具体的流程见图
02接口预加载数据接口的预加载的必要性与原理和html预加载类似,我们希望在页面初始化之前就开始请求数据,等拦截到对应的请求时直接返回预加载好的数据,以节约页面有意义的渲染时长。相对于html预加载,接口预加载的难点在于接口请求参数的配置,在最初的版本中,我们支持通过配置来完成请求参数的下发,可配置的内容包括:
- 业务自定义参数的固定部分
- 设备、用户基本信息的映射
- 接口请求中业务链接中的参数
在今年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值,这类参数要通过配置提供映射;
无法较好的描述接口之间的依赖关系,多接口预加载实现难度大。所以简单起见,我们前期仅支持了一个接口的预加载。
对于以上问题,我们也正在通过轻量级的表达式框架正在改进中。
03内存预热除了上述预加载的优化方案之外,我们在资源缓存管理上也进行了对应的优化,因为离线加载的资源存储在磁盘上,页面加载时读取会有一定的io消耗,特别是离线资源较多的情况下,这些消耗累加起来还是比较可观的。针对这部分消耗,我们采用了内存预热的方法来进行优化。
当页面创建时,我们开启子线程将页面项目对应的离线资源提前读入内存缓存池,拦截到对应的请求后,我们先查看内存缓存池是否有对应的资源,如果没有才会去读取磁盘。内存缓存池在项目数量和资源数量两个维度均采用LRU的淘汰策略进行约束,保证内存缓存的上限水位在一个合理的范围内。
这种预热策略可以保证大部分的资源在请求时直接从内存读取。而针对访问量巨大的超级互动业务,我们也可以选择首页渲染完成后就在子线程进行预扫描缓存,这样可以极大的优化大流量业务的加载速度。
提效工具01调试工具在Hybrid开发的场景下,业务面对的最大的困难是加载过程是黑盒的,离线包是否命中,接口预加载等性能优化措施是否生效,业务需要一个简洁明了的工具来查看。结合业务需求与h5研发的使用习惯,我们提供了Hybrid下的调试开发工具。
我们先来看下调试工具需要展示的数据:
离线包是否命中:我们定义的离线包命中口径为“至少有一个资源从本地获取成功”。
页面性能:我们提供了fcp和页面初始化开始〜页面didfinish时间间隔两种指标,以供h5业务实时查看首屏性能与用户体验感受。
接口预加载是否生效:h5成功的从客户端获取到了预加载到的接口数据之后,接口预加载生效。
html预加载是否生效:h5成功的从客户端获取到了预加载到的html数据之后,html预加载生效。
除了以上概括性的信息之外,还有一些细节信息也会比较重要 离线包项目信息:如当前离线包的配置版本、文件版本 离线包加载信息:当前离线包命中的资源列表。
而其他辅助性的信息或测试信息,我们直接归为log,实时展示在调试面板上,业务可按需查看。
最终数据展示面板如图所示:
这样,一个简单的工具就可以有效的提升业务研发效率,降低团队对外的答疑与咨询量。
02js api自动化测试越是复杂的系统越应该重视测试的手段与质量,自动化的测试能力是最常见的保障系统稳定性的手段,Hybrid的功能又会涉及到多端,且功能众多,日常迭代与修改很容易引发边界类的bug,所以,我们将hybrid的各项功能聚合成了一个自动化执行的页面,通过一键执行,即可覆盖已知的核心case的测试,如图:
对于Hybrid的UI类功能,我们正在结合集团的云测平台,通过脚本执行、截图、图像识别等能力打通相关UI类的功能的自动化测试能力。争取尽早完成Hybrid能力自动化测试全覆盖。
数据监控为了监控优化成果,并进一步帮助h5页面深入分析性能,我们与烛龙监控平台合作,增加了多维度的性能监控。
01多维度监控监控指标主要包括性能监控、异常监控和离线包加载监控,每项指标都能够多维度进行聚合分析,包括时间、客户端、版本、系统、厂商、类型、内核等维度。
02离线包信息离线包信息包括离线包从拉取配置、到下载、再到使用更新整个链路的监控,能够实时反馈线上用户使用下载和使用离线包的情况。
03性能监控性能监控通过原生和JS同时采集的方式,更完整的监控h5加载过程,原生层面通过WebView回调等方式进行数据采集,主要节点包括:
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页面加载的各个节点时间。
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 */@JavascriptInterfacepublic 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);
04异常监控异常监控主要包括JS桥执行异常、页面加载错误、页面响应错误、资源请求失败、JS Exception,webview白屏等,这些指标即可以用来日常排查问题,也可以作为度量Hybrid离线包接入之后的业务价值,关于h5页面加载异常的处理策略后续我们再专门介绍。
开源计划我们计划在下半年对上述写到的主要能力在github进行开源,也诚邀感兴趣的大佬们到时候一起进来完善、交流。
写在最后感谢互动、大促、频道的开发团队对京东h5页面性能优化作出的贡献,现在通过各种优化手段已经将整体性能有了一定程度的提升,我们也有更多的优化方案在输出中,包括NSR、预渲染等。欢迎感兴趣的小伙伴、有更多更好的优化方案的小伙伴可以留言一起讨论。
参考文献
WebKit官方文档:
https://developer.apple.com/documentation/webkitMDN:
https://developer.mozilla.org/zh-CN/WKWebView请求拦截探索与实践:
https://juejin.cn/post/6922625242796032007深入理解 WKWebView(基础篇)—— 聊聊 cookie 管理那些事:
https://mp.weixin.qq.com/s/jZP2DsAa5OV91wdNMw39cAWKURLSchemeHandler的能与不能:
https://www.jianshu.com/p/6bae04c91297Webkit源码:
https://github.com/WebKit/webkitAjax-hook:
https://github.com/wendux/Ajax-hook基于 LocalWebServer 实现 WKWebView 离线资源加载:
https://www.jianshu.com/p/a69e77bf680c— THE END —
【免责声明】图文来自网络,版权归原作者所有。如侵权请联系删除;我们对文中观点保持中立,仅供参考、交流之目的。
推荐阅读- 关于图床和有解的羊了个羊使用教程
- Docker入门,这一篇就够了,建议收藏
- Spring Boot整合DataX
- Java19 正式 GA!看虚拟线程如何大幅提高系统吞吐量
- 比Xshell更全能,更好用的SSH客户端神器,MobaXterm
- WINDOW定时自动关机脚本(AT和SCHTASKS两种方式)
- 吊打 Electron?Tauri 1.1 正式发布,Rust 编写的桌面 UI 框架!
- CentOS/Ubuntu安装Docker和Docker Compose
- MAC Finder在当前目录快捷打开终端
- MarkText是一款比Typora更简洁优雅的跨平台markdown编辑器,完全开源免费
微信8.0将好友放开到了一万,宝宝们可以加我大号了,先到先得。扫描下方二维码即可加我微信啦,2022,抱团取暖,一起牛逼。
产品+技术统称为大技术。分享优秀产品,传播产品思维;专注技术分享,包含JS、CSS、 HTML5、Vue、React、Augula、View UI(iView)、Element UI、Flutter、Electron和JAVA、JVM、SpringBoot、Dubbo、Spring Cloud/Alibaba、Docker、Docker Compose、K8S等实用技术与框架。
请我
分享、
赞、在看
本文来自公众号:大技术