【纵享丝滑】AndroidWebViewH5秒开⽅案总结
前⾔
为了满⾜跨平台和动态性的要求,如今很多 App 都采⽤了 Hybrid 这种⽐较成熟的⽅案来满⾜多变的业务需求。Hybrid 也叫混合开发,即半原⽣半 H5 的⽅式,通过 WebView 来实现需要⾼度灵活性的业务,在需要和 Native 做交互或者是调⽤特定平台能⼒时再通过 JsBridge 来实现两端交互
采取 Hybrid ⽅案的理由可以有很多个:实现跨平台和动态更新、保持各端之间业务和逻辑的统⼀、满⾜快速开发的需求;⽽放弃 Hybrid ⽅案的理由只需要⼀个:性能相对 Native 来说要差得多。WebView ⽐较让⼈诟病的⼀点就是性能相对 Native 来说⽐较差,经常需要 load ⼀段时间后才能加载完成,⽤户体验较差。开发者在实现了基本的业务需求后,也需要来进⼀步优化⽤户体验。⽬前也已经有很多通⽤的⼿段来优化 WebView 展⽰⾸屏页⾯的时间和性能成本,⽽这些优化⼿段也不单单局限于某个平台,对于 Android 和 IOS 来说⼤多都是通⽤的,当然这也离不开前端和服务端的⽀持。本⽂就来对这些优化⽅案做⼀个总结,希望对你有所帮助
⼀、性能瓶颈
想要优化 WebView,就需要先知道限制了 WebView 的性能瓶颈到底有哪⼏⽅⾯
百度 APP 曾经统计了其某⼀天全⽹⽤户的落地页⾸屏展现速度 80 分位数据,从点击到⾸屏展现(⾸图加载完成),⼤致需要 2600 ms
百度的开发⼈员将这⼀整个过程划分为了四个阶段,并统计出了各个阶段的平均耗时
初始化 Native App 组件,花费了 260 ms。主要⼯作是:初始化 WebView。⾸次创建 WebView 的耗时均值为 500 ms,第⼆次创建WebView 时会快很多
初始化 Hybrid,花费了 170 ms。主要⼯作是:根据调起协议中传⼊的相关参数,校验解压下发到本地的 Hybrid 模板,⼤致需要 100 ms 的时间;WebView.loadUrl 执⾏后,触发对 Hybrid 模板头部和 Body 的解析
h5免费模板下载加载正⽂数据和渲染页⾯,花费了 1400 ms。主要⼯作是:加载解析页⾯所需的 JS ⽂件,并通过 JS 调⽤端能⼒发起对正⽂数据的请求,客户端从 Server 拿到数据后,⽤ JsCallback 的⽅式回传给前端,前端需要对客户端传来的 JSON 格式的正⽂数据进⾏解析,并构造 DOM 结构,进⽽触发内核的渲染流程;此过程中,涉及到对 JS 的请求,加载、解析、执⾏等⼀系列步骤,并且存在端能⼒调⽤、JSON 解析、构造 DOM 等操作,较为耗时
加载图⽚,花费了 700 ms(图⽚貌似标错了,此处统计的应该是从渲染正⽂结束到⾸图加载完成之间
的时间)。主要⼯作是:在上⼀步中,前端获取到的正⽂数据包含落地页的图⽚地址集,在完成正⽂的渲染后,需要前端再次执⾏图⽚请求的端能⼒,客户端这边接收到图⽚地址集后按顺序请求服务器,完成下载后,客户端会调⽤⼀次 IO 将⽂件写⼊缓存,同时将对应图⽚的本地地址回传给前端,最终通过内核再发起⼀次 IO 操作获取到图⽚数据流,进⾏渲染
可以看到,最耗时的就是加载正⽂数据和渲染页⾯和加载图⽚两个阶段,需要进⾏多次⽹络请求、JS 调⽤、IO 读写;其次是初始化WebView 和加载模板⽂件两个阶段,这两个阶段耗时相近,虽然基本不⽤进⾏⽹络请求,但涉及到对浏览器内核和模板⽂件的初始化操作,存在⼀些⽆法避免的时间花费
从这就可以得出最基本的优化⽅向:
初始化的时间是否可以更快⼀点?例如,WebView 和模板⽂件的初始化时间是否可以更少⼀点?能不能提前完成这些任务?
完成⾸屏页⾯的前置任务是否可以更少⼀点?例如,⽹络请求、JS 调⽤、IO 读写的次数是否可以更少⼀点?是否可以合并或者提前完成这些任务?
资源⽂件的加载时间是否可以更快⼀点?例如,图⽚、JS、CSS ⽂件的请求次数是否可以更少⼀点?能不能直接使⽤本地缓存?⽹络请求速度是否可以更快⼀点?
⼆、WebView 预加载
创建 WebView 属于⼀个⽐较耗时的操作,特别是在第⼀次创建的时候由于需要初始化浏览器内核,会耗时⼏百毫秒,之后再次创建WebView 就会快很多,但也还需要⼏⼗毫秒。为了避免每次使⽤时都需要同步等待 WebView 创建完成,我们可以选择在合适的时机预加载 WebView 并存⼊缓存池中,等要⽤到时再直接从缓存池中取,从⽽缩短显⽰⾸屏页⾯的时间
想要进⾏预加载,那就要思考以下两个问题该如何解决:
触发时机如何选?
既然创建 WebView 属于⼀个⽐较耗时的操作,那我们在预加载时⼀样可能会拖慢当前主线程,这样相当于只是把耗时操作提前了⽽已,我们需要保证预加载操作不会影响到当前主线程任务
Context 如何选?
WebView 需要和 Context 进⾏绑定,且每个 WebView 应该是对应于特定的 Activity Context 实例的,不能直接使⽤ Application 来创建 WebView,我们需要保证预加载的 WebView Context 和最终的 Context 之间的⼀致性
第⼀个问题可以通过 IdleHandler 来解决。通过 IdleHandler 提交的任务只有在当前线程关联的 MessageQueue 为空的情况下才会被执⾏,因此通过 IdleHandler 来执⾏预创建可以保证不会影响到当前主线程任务
第⼆个问题可以通过 MutableContextWrapper 来解决。顾名思义,MutableContextWrapper 是系统提供的 Context 包装类,其内部包含⼀个 baseContext,MutableContextWrapper 所有的内部⽅法都会交由 baseContext 来实现,且 MutableContextWrapper 允许外部替换它的baseContext,因此我们可以在⼀开始的时候使⽤ Application 作为 baseContext,等到 WebView 和 Activity 进⾏实际绑定的时候再来替换
最终预加载 WebView 的⼤致逻辑就如下所⽰。我们可以在 PageFinished 或者退出 WebViewActivity 的时候就主动调⽤prepareWebView()⽅法来进⾏预加载,需要⽤到的时候就从缓存池中取出来动态添加到布局⽂件中
/**
* @Author: leavesC
* @Date: 2021/10/4 18:57
* @Desc:
* @:字节数组
*/
object WebViewCacheHolder {
private val webViewCacheStack = Stack<RobustWebView>()
private const val CACHED_WEB_VIEW_MAX_NUM = 4
private lateinit var application: Application
fun init(application: Application) {
this.application = application
prepareWebView()
}
fun prepareWebView() {
if (webViewCacheStack.size < CACHED_WEB_VIEW_MAX_NUM) {
log("WebViewCacheStack Size: " + webViewCacheStack.size)
if (webViewCacheStack.size < CACHED_WEB_VIEW_MAX_NUM) {
webViewCacheStack.push(createWebView(MutableContextWrapper(application)))
}
false
}
}
}
fun acquireWebViewInternal(context: Context): RobustWebView {
if (webViewCacheStack.isEmpty()) {
return createWebView(context)
}
val webView = webViewCacheStack.pop()
val contextWrapper = t as MutableContextWrapper
contextWrapper.baseContext = context
return webView
}
private fun createWebView(context: Context): RobustWebView {
return RobustWebView(context)
}
}
此⽅案虽然⽆法缩减创建 WebView 所需的时间,但可以缩短完成⾸屏页⾯的时间。需要注意,对 WebView 进⾏缓存采取的是⽤空间换时间的做法,需要考虑低端机型运存较⼩的情况
三、渲染优化
想要优化⾸屏的渲染速度,⾸先得从整个页⾯访问请求的链路上看,借⽤阿⾥巴巴淘系技术的⼀张图,下⾯是常规端上 H5 页⾯访问链路
这⼀整个过程需要完成多个⽹络请求和 IO 操作,WebView 在加载了基本的 HTML 和 CSS ⽂件后,再通过 JS 从服务端获取正⽂数据,拿到数据后还需要完成解析 JSON、构造 DOM、应⽤ CSS 样式等⼀系列耗时操作,最终才能由内核进⾏渲染上屏
移动端的系统版本、处理器速度、运存⼤⼩是完全不受我们控制的,且极容易受⽹络波动的影响,⽹络链接的耗时是⾮常长且不可控的。如果 WebView 每次渲染都重复经历以上整个步骤,那⽤户的使⽤体验就是完全不可控的,此时可以尝试通过以下⽅法来进⾏优化
预置离线包
精简并抽取公共的 JS 和 CSS ⽂件作为通⽤的页⾯模板,可以按业务类型来⽣成多套模板⽂件,每次打包时均预置最新的模板⽂件到客户端中,每套模板⽂件均有特定的版本号,App 在后台定时去静默更新。通过这种⽅式来避免每次使⽤都要去联⽹请求,从⽽缩短总耗时
⼀般情况下,WebView 会在加载完主 HTML 之后才去加载 HTML 中的 JS 和 CSS ⽂件,先后需要进⾏多次 IO 操作,我们可以将 JS 和 CSS 还有⼀些图⽚都内联到⼀个⽂件中,这样加载模板时就只需要⼀次 IO 操作,也⼤⼤减少了因为 IO 加载冲突导致模板加载失败的问题
并⾏请求
H5 在加载模板⽂件的同时,由 Native 端来请求正⽂数据,Native 端再通过 JS 将正⽂数据传给 H5,以此来实现并⾏请求从⽽缩短总耗时
预加载
当模板和正⽂数据分离之后,由于 WebView 每次使⽤的都是同⼀个模板⽂件,因此我们并不需要在⽤户进⼊页⾯的时候才去加载模板,可以直接在预加载 WebView 的同时就让其预热加载模板,这样每次使⽤时仅需要将正⽂数据传给 H5,H5 收到数据后直接进⾏页⾯渲染即可
对于 Feed 流,可以通过⼀定策略去预加载正⽂数据,当⽤户点击查看详情时,最理想情况下就可以直
接使⽤缓存的数据,避免受到⽹络的影响
延迟加载
呈现⾸屏页⾯所需要的依赖项越多,就意味着⽤户需要的等待时间就越长,因此要尽可能地减少在⾸屏完成前执⾏的操作,对于⼀些⾮⾸屏必需的⽹络请求、 JS 调⽤、埋点上报等,都可以后置到⾸屏显⽰后再执⾏
页⾯静态直出
并⾏请求正⽂数据虽然能够缩短总耗时,但还是需要完成解析 JSON、构造 DOM、应⽤ CSS 样式等⼀系列耗时操作,最终才能交由内核进⾏渲染上屏,此时组装 HTML 这个操作就显得⽐较耗时了。为了进⼀步缩短总耗时,可以改为由后端对正⽂数据和前端代码进⾏整合,直出⾸屏内容,直出后的 HTML ⽂件已经包含了⾸屏展现所需的内容和样式,⽆需进⾏⼆次加⼯,内核可以直接渲染。其它动态内容可以在渲染完⾸屏后再进⾏异步加载
由于客户端可能向⽤户提供了控制 WebView 字体⼤⼩,夜间模式的选项,为了保证⾸屏渲染结果的准确性,服务端直出的 HTML 就需要预留⼀些占位符⽤于后续动态回填,客户端在 loadUrl 之前先利⽤正则匹配的⽅式查这些占位字符,按照协议映射成端信息。经过客户端回填处理后的 HTML 内容就已经具备了展现⾸屏的所有条件
复⽤ WebView
更进⼀步的做法就是可以尝试复⽤ WebView。由于 WebView 使⽤的模板⽂件已经是固定的了,因此我们可以在 WebView 预加载缓存池的基础上增加复⽤ WebView 的逻辑,当 WebView 使⽤完毕后可以将其正⽂数据全部清空并再次存⼊缓存池中,等下次需要时就可以直接注⼊新的正⽂数据进⾏复⽤了,从⽽减少了频繁创建 WebView 和预热模板⽂件带来的开销
视觉优化
实现以上的优化⽅案后,页⾯的展现速度已经很快了,但在实际开发中还是会发现存在 Activity 切换过程中⽆法渲染 H5 页⾯的问题,产⽣视觉上的⽩屏现象,这可以通过开发者模式放慢动画时间来验证
从下图可以看到在 Activity 切换过程中的确是有⼀段明显的⽩屏过程
通过研究系统源码可以知道,在系统版本⼤于等于 4.3,⼩于等于 6.0 之间,ViewRootImpl 在处理 View 绘制的时候,会通过⼀个布尔变量mDrawDuringWindowsAnimating来控制 Window 在执⾏动画的过程中是否允许进⾏绘制,该字段默认为 false,我们可以利⽤反射的⽅式去⼿动
修改这个属性,避免这个⽩屏效果
这个⽅案基本也只适⽤于 Android 6.0 版本了,更低的系统版本也很少进⾏适配了
/**
* 让 activity transition 动画过程中可以正常渲染页⾯
*/
fun setDrawDuringWindowsAnimating(view: View) {
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.M
|| Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1
) {
//⼩于 4.3 和⼤于 6.0 时不存在此问题,⽆须处理
return
}
try {
val rootParent: ViewParent = View.parent
val method: Method = rootParent.javaClass
.getDeclaredMethod("setDrawDuringWindowsAnimating", Boolean::class.javaPrimitiveType)
method.isAccessible = true
method.invoke(rootParent, true)
} catch (e: Throwable) {
e.printStackTrace()
}
}
优化后的效果
四、Http 缓存策略
在上⼀步的渲染优化中就涉及到了对⽹络请求的优化,包括减少⽹络请求次数、并⾏执⾏⽹络请求、⽹络请求预执⾏等。对于应⽤来说,⽹络请求是不可避免的,但我们可以通过设定缓存策略来避免重复执⾏⽹络请求,或者是可以⽤⽐较低的成本来完成⾮⾸次的⽹络请求,这就涉及到了和 Http 缓存相关的知识点
WebView ⼀共⽀持以下四种缓存策略,默认使⽤的是 LOAD_DEFAULT,该策略就属于 Http 缓存策略
LOAD_CACHE_ONLY:只使⽤本地缓存,不进⾏⽹络请求
LOAD_NO_CACHE:不使⽤本地缓存,只通过⽹络请求
LOAD_CACHE_ELSE_NETWORK:只要本地有缓存就进⾏使⽤,否则就通过⽹络请求
LOAD_DEFAULT:根据 Http 协议来决定是否进⾏⽹络请求
以请求⽹络上⼀个静态⽂件为例,查看其响应头,当中的 Cache-Control、Expires、Etag、Last-Modified 等信息就定义了具体的缓存策略Cache-Control、Expires
Cache-Control 是 Http 1.1 中新增加的⼀个⽤来定义资源缓存策略的报⽂头,它由⼀些定义⼀个响应资源应该何时被缓存、如何被缓存以及缓存多长时间的指令组成,可选值有很多种:no-cache、no-store
、only-if-cached、max-age 等,⽐如上图所⽰就使⽤到了 max-age 来设定资源的最⼤有效时间,时间单位为秒
Expires 是 Http 1.0 中规定的字段,含义和 Cache-Control 类似,但由于 Expires 可能会因为客户端和服务端的时间不⼀致造成缓存失效,因此现在主要使⽤的是 Cache-Control,在优先级上也是 Cache-Control 更⾼
Cache-Control 也是⼀个通⽤的 Http 报⽂头字段,它可以分别在请求头和响应头中使⽤,具有不同的含义,以 max-age 为例:
请求头:客户端⽤于告知服务端,希望接收⼀个有效期不⼤于 max-age 的资源
响应头:服务端⽤于告知客户端,该资源在请求发起后的 max-age 时间内均是有效的,上图所⽰的 2592000 秒也即 30 天,客户端在第⼀次发起请求后的 30 天内⽆需再向服务端进⾏请求,可以直接使⽤本地缓存
如果在 WebView 中使⽤了 LOAD_DEFAULT 的话,就会遵循此 Http 缓存策略,在有效期内 WebView 会直接使⽤本地缓存ETag、Last-Modified
Cache-Control 避免了 WebView 在有效期内去重复请求资源,有效期过了后 WebView 就还是需要重新
去请求⽹络,但此时服务端的资源也许并没有发⽣变化,WebView 依然可以使⽤本地缓存,此时客户端就需要依靠 ETag 和 Last-Modified 这两个报⽂头来向服务器确认该资源是否可以继续使⽤
在第⼀次请求资源的时候,响应头中就包含了 ETag 和 Last-Modified,这两个报⽂头就⽤来唯⼀标识该资源⽂件
ETag:⽤于作为资源的唯⼀标识信息
Last-Modified:⽤于记录资源的最后⼀次修改时间
等客户端判断到 max-age 已过期后,就会携带这两个报⽂头去执⾏⽹络请求,服务端就通过这两个标识符来判断客户端的缓存资源是否可以继续使⽤
如下图所⽰,在有效期过后,客户端会在 If-None-Match 请求头中携带上第⼀次⽹络请求时拿到的 ETag 值。实际上 ETag 和 Last-Modified 可以只使⽤⼀个,以下就只使⽤到了 ETag;如果要传递 Last-Modified 的话,对应的请求头就是 If-Modified-Since
如果服务端判断出资源已过期,就会返回新的资源⽂件,此时就相当于在第⼀次请求资源⽂件,后续操作就和⼀开始保持⼀致;如果服务端判断资源还未过期,则会返回⼀个 304 状态码,告知客户端可以继续使⽤本地缓存,客户端同时更新 max-age 值,重复⼀开始的的缓存失效规则,这样客户端就可以⽤极
低的成本来完成本次⽹络请求,这在请求的资源⽂件⽐较⼤的时候特别有⽤
但 Http 缓存策略也存在⼀些问题需要注意,即如何保证⽤户在资源更新了时能马上感知到且重新下载最新资源。假设服务端在资源有效期内更新了资源内容,此时由于客户端还处于 max-age 阶段,⽆法马上感知到资源已更新,从⽽造成更新不及时。⼀种⽐较好的解决⽅案就是:要求服务端在每次更新资源⽂件时都为其⽣成⼀个新的名字,可以⽤ hash 值或者随机数命名,⽽资源⽂件依托的主⽂件在每次发版时都引⽤最新的资源⽂件路径,从⽽保证客户端能够马上就感知到资源已更新,从⽽保证及时更新。⽽且,通过这种⽅案,既可以为资源⽂件设定⼀个⾮常⼤的 max-age 值,尽量让客户端只使⽤本地缓存,⼜可以保证每次发版时客户端都能及时更新
所以说,通过合理地设定 Http 缓存策略,⼀⽅⾯能够很明显地减少服务器⽹络带宽消耗、降低服务器的压⼒和开销,另⼀⽅⾯也可以减少客户端⽹络延迟的情况、避免重复请求资源⽂件、加快页⾯的打开速度,毕竟加载本地缓存⽂件的开销怎样都要⽐从⽹络上加载低得多
五、拦截请求与共享缓存
如今的 WebView 页⾯往往是图⽂混排的,图⽚是资讯类应⽤的重要表现形式,WebView 获取图⽚资源的传统⽅案有以下两种:H5 端⾃⼰通过⽹络请求去下载资源。优点:实现简单,各端之间可以只专注⾃⼰的业务。缺点:两端之间的⽆法共享缓存,造成资源
重复请求,流量浪费
H5 端通过调⽤ Native 的图⽚下载和缓存能⼒来获取资源。优点:可以实现两端之间的缓存共享。缺点:需要由 H5 端来主动触发Native 执⾏,时机较为延迟,且需要通过多次 JS 调⽤完成资源传递,存在效率问题
以上两种⽅案都存在着⼀些缺点,要么是⽆法共享缓存,要么是存在效率问题,这⾥就再介绍⼀种改进⽅案:
实际上,WebViewClient 提供了⼀个shouldInterceptRequest⽅法⽤于⽀持外部去拦截请求,WebView 每次在请求⽹络资源时都会回调该⽅法,⽅法⼊参就包含了 Url,Header 等请求参数,返回值 WebResourceResponse 即代表获取到的资源对象,默认是返回 null,即由浏览器内核⾃⼰去完成⽹络请求
我们可以通过该⽅法来主动拦截并完成图⽚的加载操作,这样我们既可以使得两端的资源⽂件得以共享,也避免了多次 JS 调⽤带来的效率问题
⼤致实现就如下所⽰,这⾥我通过 OkHttp 来代理实现⽹络请求
/**
* @Author: leavesC
* @Date: 2021/10/4 18:56
* @Desc:
* @:字节数组
*/
object WebViewInterceptRequestProxy {
private lateinit var application: Application
private val webViewResourceCacheDir by lazy {
File(application.cacheDir, "RobustWebView")
}
private val okHttpClient by lazy {
OkHttpClient.Builder().cache(Cache(webViewResourceCacheDir, 100L * 1024 * 1024))
.followRedirects(false)
.followSslRedirects(false)
.addNetworkInterceptor(
ChuckerInterceptor.Builder(application)
.collector(ChuckerCollector(application))
.maxContentLength(250000L)
.alwaysReadResponseBody(true)
.build()
)
.build()
}
fun init(application: Application) {
this.application = application
}
fun shouldInterceptRequest(webResourceRequest: WebResourceRequest?): WebResourceResponse? {
if (webResourceRequest == null || webResourceRequest.isForMainFrame) {
return null
}
val url = webResourceRequest.url ?: return null
if (isHttpUrl(url)) {
return String(), webResourceRequest)
}
return null
}
private fun isHttpUrl(url: Uri): Boolean {
val scheme = url.scheme
log("url: $url")
log("scheme: $scheme")
if (scheme == "http" || scheme == "https") {
return true
}
return false
}
private fun getHttpResource(
url: String,
webResourceRequest: WebResourceRequest
): WebResourceResponse? {
val method = hod
if (method.equals("GET", true)) {
try {
val requestBuilder =
Request.Builder().url(url).hod, null)
val requestHeaders = questHeaders
if (!requestHeaders.isNullOrEmpty()) {
var requestHeadersLog = ""
requestHeaders.forEach {
requestBuilder.addHeader(it.key, it.value)
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论