2023年6月21日发(作者:)
【纵享丝滑】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 模板,⼤致需要 100ms 的时间;l 执⾏后,触发对 Hybrid 模板头部和 Body 的解析加载正⽂数据和渲染页⾯,花费了 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
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(ir, "RobustWebView") } private val okHttpClient by lazy { r().cache(Cache(webViewResourceCacheDir, 100L * 1024 * 1024)) .followRedirects(false) .followSslRedirects(false) .addNetworkInterceptor( r(application) .collector(ChuckerCollector(application)) .maxContentLength(250000L) .alwaysReadResponseBody(true) .build() ) .build() } fun init(application: Application) { ation = application } fun shouldInterceptRequest(webResourceRequest: WebResourceRequest?): WebResourceResponse? { if (webResourceRequest == null || ainFrame) { return null } val url = ?: return null if (isHttpUrl(url)) { return getHttpResource(ng(), webResourceRequest) } return null } private fun isHttpUrl(url: Uri): Boolean { val 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 = if (("GET", true)) { try { val requestBuilder = r().url(url).method(, null) val requestHeaders = tHeaders if (!OrEmpty()) { var requestHeadersLog = "" h { der(, ) requestHeadersLog = + " : " + + "n" + requestHeadersLog } log("requestHeaders: $requestHeadersLog") } val response = l(()) .execute() val body = if (body != null) { val mimeType = ( "content-type", tType()?.type ).apply { log(this) } val encoding = ( "content-encoding", "utf-8" ).apply { log(this) } val responseHeaders = mutableMapOf
getAssetsImage ⽅法需要注意,以上只是⼀份⽰例代码,并不能直接⽤于⽣产环境,读者需要根据具体业务去进⾏扩展。Github 上也有⼀个通过此⽅案实现了WebView 缓存复⽤的开源库,读者可以去借鉴其思路:六、DNS 优化DNS 也即域名解析,指代的是将域名转换为具体的 IP 地址的过程。DNS 会在系统级别进⾏缓存,如果已经解析过某域名,那么在下次使⽤时就可以直接去访问已知的 IP 地址,⽽不⽤先发起 DNS 再访问 IP 地址如果 WebView 访问的主域名和客户端的不⼀致,那么 WebView 在⾸次访问线上资源时,就需要先完成域名解析才能开始资源请求,这个过程就需要多耗费⼏⼗毫秒的时间。因此最好就是保持客户端整体 API 地址、资源⽂件地址、WebView 线上地址的主域名都是⼀致的七、CDN 加速CDN 的全称是 Content Delivery Network,即内容分发⽹络。CDN 是构建在现有⽹络基础之上的智能虚拟⽹络,依靠部署在各地的边缘服务器,通过中⼼平台的负载均衡、内容分发、调度等功能模块,使⽤户就近获取所需内容,降低⽹络拥塞,提⾼⽤户访问响应速度和命中率通过将 JS、CSS、图⽚、视频等静态类型⽂件托管到 CDN,当⽤户加载⽹页时,就可以从地理位置上最接近它们的服务器接收这些⽂件,解决了远距离访问和不同⽹络带宽线路访问造成的⽹络延迟情况⼋、⽩屏检测在正常情况下,完成上述的优化措施后⽤户基本是可以秒开 H5 页⾯的了。但异常情况总是会有的,⽤户的⽹络环境和系统环境千差万别,甚⾄ WebView 也可能发⽣内部崩溃。当发⽣问题时,⽤户看到的可能就直接只是⼀个⽩屏页⾯了,所以进⼀步的优化⼿段就是需要去检测是否发⽣⽩屏以及相应的应对措施检测⽩屏最直观的⽅案就是对 WebView 进⾏截图,遍历截图的像素点的颜⾊值,如果⾮⽩屏颜⾊的颜⾊点超过⼀定的阈值,就可以认为不是⽩屏。字节跳动技术团队的做法是:通过
wingCache()⽅法去获取包含 WebView 视图的 Bitmap 对象,然后把截图缩⼩到原图的1/6,遍历检测图⽚的像素点,当⾮⽩⾊的像素点⼤于 5% 的时候就可以认为是⾮⽩屏的情况,可以相对⾼效且准确地判断出是否发⽣了⽩屏当检测到⽩屏后,如果发现怎么重试也⽆法成功,那就只能进⾏降级处理了,放弃上述的优化措施,直接加载线上的详情页,优先保证⽤户体验⽂中内容如有错误欢迎指出,共同进步!觉得不错的留个 赞 再⾛哈~Android⾼级开发系统进阶笔记、最新⾯试复习笔记PDF,⽂末您的点赞收藏就是对我最⼤的⿎励!欢迎关注我的简书,分享Android⼲货,交流Android技术。对⽂章有何见解,或者有何技术问题,欢迎在评论区⼀起留⾔讨论!
2023年6月21日发(作者:)
【纵享丝滑】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 模板,⼤致需要 100ms 的时间;l 执⾏后,触发对 Hybrid 模板头部和 Body 的解析加载正⽂数据和渲染页⾯,花费了 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
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(ir, "RobustWebView") } private val okHttpClient by lazy { r().cache(Cache(webViewResourceCacheDir, 100L * 1024 * 1024)) .followRedirects(false) .followSslRedirects(false) .addNetworkInterceptor( r(application) .collector(ChuckerCollector(application)) .maxContentLength(250000L) .alwaysReadResponseBody(true) .build() ) .build() } fun init(application: Application) { ation = application } fun shouldInterceptRequest(webResourceRequest: WebResourceRequest?): WebResourceResponse? { if (webResourceRequest == null || ainFrame) { return null } val url = ?: return null if (isHttpUrl(url)) { return getHttpResource(ng(), webResourceRequest) } return null } private fun isHttpUrl(url: Uri): Boolean { val 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 = if (("GET", true)) { try { val requestBuilder = r().url(url).method(, null) val requestHeaders = tHeaders if (!OrEmpty()) { var requestHeadersLog = "" h { der(, ) requestHeadersLog = + " : " + + "n" + requestHeadersLog } log("requestHeaders: $requestHeadersLog") } val response = l(()) .execute() val body = if (body != null) { val mimeType = ( "content-type", tType()?.type ).apply { log(this) } val encoding = ( "content-encoding", "utf-8" ).apply { log(this) } val responseHeaders = mutableMapOf
getAssetsImage ⽅法需要注意,以上只是⼀份⽰例代码,并不能直接⽤于⽣产环境,读者需要根据具体业务去进⾏扩展。Github 上也有⼀个通过此⽅案实现了WebView 缓存复⽤的开源库,读者可以去借鉴其思路:六、DNS 优化DNS 也即域名解析,指代的是将域名转换为具体的 IP 地址的过程。DNS 会在系统级别进⾏缓存,如果已经解析过某域名,那么在下次使⽤时就可以直接去访问已知的 IP 地址,⽽不⽤先发起 DNS 再访问 IP 地址如果 WebView 访问的主域名和客户端的不⼀致,那么 WebView 在⾸次访问线上资源时,就需要先完成域名解析才能开始资源请求,这个过程就需要多耗费⼏⼗毫秒的时间。因此最好就是保持客户端整体 API 地址、资源⽂件地址、WebView 线上地址的主域名都是⼀致的七、CDN 加速CDN 的全称是 Content Delivery Network,即内容分发⽹络。CDN 是构建在现有⽹络基础之上的智能虚拟⽹络,依靠部署在各地的边缘服务器,通过中⼼平台的负载均衡、内容分发、调度等功能模块,使⽤户就近获取所需内容,降低⽹络拥塞,提⾼⽤户访问响应速度和命中率通过将 JS、CSS、图⽚、视频等静态类型⽂件托管到 CDN,当⽤户加载⽹页时,就可以从地理位置上最接近它们的服务器接收这些⽂件,解决了远距离访问和不同⽹络带宽线路访问造成的⽹络延迟情况⼋、⽩屏检测在正常情况下,完成上述的优化措施后⽤户基本是可以秒开 H5 页⾯的了。但异常情况总是会有的,⽤户的⽹络环境和系统环境千差万别,甚⾄ WebView 也可能发⽣内部崩溃。当发⽣问题时,⽤户看到的可能就直接只是⼀个⽩屏页⾯了,所以进⼀步的优化⼿段就是需要去检测是否发⽣⽩屏以及相应的应对措施检测⽩屏最直观的⽅案就是对 WebView 进⾏截图,遍历截图的像素点的颜⾊值,如果⾮⽩屏颜⾊的颜⾊点超过⼀定的阈值,就可以认为不是⽩屏。字节跳动技术团队的做法是:通过
wingCache()⽅法去获取包含 WebView 视图的 Bitmap 对象,然后把截图缩⼩到原图的1/6,遍历检测图⽚的像素点,当⾮⽩⾊的像素点⼤于 5% 的时候就可以认为是⾮⽩屏的情况,可以相对⾼效且准确地判断出是否发⽣了⽩屏当检测到⽩屏后,如果发现怎么重试也⽆法成功,那就只能进⾏降级处理了,放弃上述的优化措施,直接加载线上的详情页,优先保证⽤户体验⽂中内容如有错误欢迎指出,共同进步!觉得不错的留个 赞 再⾛哈~Android⾼级开发系统进阶笔记、最新⾯试复习笔记PDF,⽂末您的点赞收藏就是对我最⼤的⿎励!欢迎关注我的简书,分享Android⼲货,交流Android技术。对⽂章有何见解,或者有何技术问题,欢迎在评论区⼀起留⾔讨论!
发布评论