编者按:移动互联网的火热改变着人们的生活方式,从移动社交到移动购物再到如今迅猛发展的互娱行业。移动端的力量不可忽视,Bilibili 的移动端项目目前已经进行了接近一年多的组件化实践,其中部分方案是基于优化应用的冷启动速度,本次演讲的内容主要包括进程冷启动优化和 H5 首屏优化的一些具体方案,以及方案落实过程中所遇到的一些问题以及应对措施。以下为此次演讲的整理。
谢晓枫 Bilibili 安卓端高级开发工程师
嘉宾介绍:2012 年开始接触安卓开发,参与过手机 YY 客户端、哔哩哔哩客户端项目的研发。 目前主要负责项目框架层类库的开发, 经常实践 TDD 保证代码质量,技术实践内容不仅局限于某一单一功能的开发,而是某一整体方案的落地,比较注重自动化、持续集成。不仅仅专注与 Android 开发,在前端、服务器以及脚本构建上也有相关研究。
|移动架构与性能优化
移动架构与性能调优,这个课题是必须涵盖架构与性能,我们部门是刚好进行了一个一年多的组件化的实践,有涉及到性能优化,特别是冷启动优化的,这个可以讲一下。
今天演讲涵盖以下的三点:
-
组件化方案
-
进程冷启动优化
-
H5 首屏优化
这边会分享一下项目中的组件化方案。性能调优方面会挑冷启动优化来讲。H5 是承载移动端业务非常重要的容器之一,因此会讲 web 首屏优化的知识点。
在开始进行这个组件化演讲之前的,我觉得是有必要说一下组件的概念。在以往的概念中,组件是指 Component, 它指的是项目里面框架型层部分的一个构建、没有业务代码的组件。比如有一个没有业务代码逻辑的图片加载器,可以很方便移植到其他项目里,这个是比较传统的组件。而最近一两年组件化是一个新的概念,它比较像安卓里面的 Bundle, 主要是从业务的角度是来阐述这个问题,指的是多个业务方开发自己的组件,由各个组件组成一个完整客户端项目的方案,所以大家不要把这个组件的概念搞混了,本次课题特指后者。
|组件化
图 1
先看一下我们项目组件化前的结构(图 1)。看上去也不是很混乱,而且是还包括了几个应用。但是也暴露一些问题,依赖虽然比较清晰,但是库的层次比较复杂。我理想的项目结构其实应该是一个三角加一个倒三角,顶端是一个应用层,中间是业务模块,业务模块一起依赖一个共同的框架层。
图 2
再加上远程库之后就更复杂了(图 2)。而且这还不是最糟糕的情况,我刚进来写代码的时候,大概还知道从哪个来入手,但是随着人员增加,参与这个项目的小伙伴越来越多,这种项目结构肯定是不能满足开发需求的。
因此刚刚展示的两个图有以下的问题:
-
项目模块层级混乱,模块耦合力度大,导致业务分离难度巨大
-
模块依赖关系混乱,存在重复依赖、循环依赖、依赖不同版本问题
-
业务并行开发合并代码困难,API错误使用问题显著
-
无法并行贡献代码
层级比较混乱,因为这个依赖库以及依赖不同的版本的问题非常的严重,会出现不知道依赖的库是否正确,所以需要把这个项目的层级理一下。而且在这样的结构下业务并行的开发是不可能的。这些种种的问题,都可以用一句话来总结,各个业务小组间没有办法并行地贡献代码。
我们需要的是通过组件化的这个方案,达到一个各个业务组之间一起贡献代码的状态,那怎么做呢?
图 3
首先,刚刚那个混乱的图可以简化成图 3 ,它的问题是层级不明显,我做的一个工作就是理清楚层级。我们分为这三层,右边最下面这一次层是基础库(Foundations),这一层主要存放项目里面通用的一些库,例如分享库或者是图片加载库等。左边这一层是属于垂直的,贯穿到整个项目,所有的模块都可以依赖它。右边上面是应用层(APP),这一层层我们暂时先画这么大,大家如果是接触过比较大的安卓的项目,它们会有一个明显的特点是 APP 的模块非常臃肿,因为现在安卓的客户端一方面是业务多,迭代周期也比较短,所以都是把代码写在一个模块里面,一开始就解耦 APP 模块是不容易的,我们先别管。
图 4
接下来,我们在 APP 层逐步的进行业务解耦(图 4)。一层一层的组件库分离,这一层的组件层,各个组件层的代码互相独立,互不依赖。如果组件需要通信,只能通过路由(Route)进行通信。随着 APP 层,越来越小后,大概变成这样的图 4 右边一样。当 APP 层是变成足够小的话,就会没有任何的业务代码的,它只包括一些入口 api 或者配置清单之类的轻量文件。所有的业务模块,都下移到组件层。
这里有一个细节,可能各位看的不是很清晰,图里是有虚线和实线的区别,虚线表示的是远程的依赖。本地的客户端模块只包含了 APP 模块这个壳,没有其他本地的模块,所以这时候我们可以把 APP 模块改称为 SHELL 模块了。
图 5
到了这个阶段,已经对这个项目进行拆分了,也就是做到项目级别的代码分离。左边这个是一个基础库的项目(图 5),基础库是独立维护的,每次基础库有更改都需重新发版,而且都有对应版本号。其他组件只能依赖于某个特并版本号的基础库进行开发。而右边这是一个组件的项目,做到了不仅仅可以在客户端项目贡献代码,也可以从组件项目上贡献代码。
这样组件化的后,还有一个好处。不再需要臃肿客户端来调试组件代码,我只需要创建一个自己的组件项目,在这个组件项目的模块里面写相应的业务代码。如果需要客户端其他的业务功能,可以直接远程依赖联调,或者创建一个其他业务功能的一个 Mock 实现。讲的比较通俗一点,登录界面是非常华丽的,但是在我组件项目我完全不需要,我只需要提供两个输入框,让我输入账号密码就可以了。各个组件之间的代码贡献就可以做到并行化。
到最后,再把所有的组件打包在客户端里面,再加上一个壳就可以了。而且如果我是直播,打包直播的组件,加上一个直播的壳,就可以打包成为一个直播项目。
刚才也有提到,我们还有插件项目。就是说我给他的一个插件的壳,那就变成一个插件了。这就组件化项目的好处和目的。
图 6
刚才也有提到 Router,Router 是组件之间通信用的。那到底是怎么工作的呢?简单来说,不允许直接调用组件的 api ,通过协议表示当前组件登录的界面,如果是在另外一个界面是需要打开登录界面,只需要是一个层级的 Router 。同样的,不仅是可以实现 activity 的跳转,也可以是实现别的组件的跳转,甚至可以加上跨进程的框架,进行跨进程的数据传输。
Router 的工作原理如图 6,包括两部分,一个库,它是用来解析路由协议与获取目标组件服务的框架,另一部分是注解处理工具(APT),功能是给组件生成对应的路由表。我们每一个组件都有一个路由表,每一个路由表表示组件的入口,可以通过路由表来查找路由提供的功能。这就是我们项目组件化的一个大致情况。
组件化,其实还涉及到一个动态的概念。这些组件,这里我们刚刚讲到的都是一个静态集成方案,其实还有一个动态的。例如这个组件可能并不是放在项目里一起编译的,需要动态加载或升降级。或者是这个组件,可以运行时直接给他取消掉,不用它了,这个是插件化的范畴了。如果是有兴趣,可以跟我私下谈。
|进程冷启动
进程冷启动分析
-
性能指标
-
载业务模块太多
-
Application 滥用
-
Support Multidex 耗时
-
系统兼容
刚才简单的介绍了组件化的优化,给接下来进程冷启动优化做一个铺垫。说到进程冷启动,也有一个故事了,去年收到投诉说,安卓的打开很慢。但是快和慢的主观感受占的成份比较多,很难去描述。所以先定了一些性能指标,比如收集了一些项目初始化用了多少时间,每个业务模块初始化用多少时间。还收集在初始化完到内容加载完,占了多少时间等等。如果没有性能指标,没有办法是有确认这个是好是坏。通过收集后,发现冷启动的卡顿主要是在 Application类,因为现在业务太多了,每个同学都是在 Application 里面写初始化逻辑,导致了这个进程启动的时候,过来了太多不需要过载的模块,还有一个是 Application 的被滥用。虽然说谷歌官方推荐不要在这个 Application 的里面写任何业务代码,但是我们发现有一些同学还是喜欢写单例的,他就把单例的这些业务接口,写到 Application 里面,而这个功能,给 Application 增加一些额外的负担。
这里又有另外的一个问题,初步统计 Application 造成的负担应用的启动时间增加了1-2秒,这个也不算太严重。但是数据中心给我们反馈启动的时间是 8 秒。后来跟数据中心核对后才发现,数据中心用的是一个叫 8 分位的指标, 8 分位指的是如果是有十个设备,前面的7 台是 1 秒,后面的 3 台是 10 秒,这个指标就是 10 秒。那我看了一下我们落在八分位的设备,都刚好是安卓4.3、4.1的设备,这些设备冷启动的耗时往往是非常壮观的,这个是问题的所在。接下来,还有是系统兼容的问题,比如我发现有一个问题,在某个ROM版本打 log 响应的时间是 50 毫秒-100 毫秒,而我印象中log只需要很短的时间损耗,这样容易造成时间损耗。
图 7
回到 Application 滥用及业务太多的问题,我们做了什么工作呢?
配合刚才的组件化,我们做了以下工作(图 7),我们先把这个 Application 锁死,我们不允许写入任何业务代码。我们把 Application 从项目里面移走,由 APT 具来生成 Application 类。由 APT 工程生成的 Application 类什么都不做,只做调用 MultiProcess,根据当前进程的名字,调用不同的初始化入口。通过刚才说的路由表,在这里做初始化,这样的好处是可以只对应的组件进行挂载就可以。其实这里还带来另外一个好处是经过这样的处理,Application 会变得非常薄。这意味着什么,就是在你应用启动的时候,Application 足够小,可以及时加载热修复的补丁,一启动就加载这个补丁,这样的话就能很大程度的发挥这个补丁的作用。
但是还有一个问题,安卓开发有一个坏习惯,尽管谷歌推荐 Application 不写业务代码,但是还是有很多人喜欢在 Application 上创建一个静态的接口用于获取 Application 实例,我们的业务代码也有类似的情况,我这样一搞的话,他们的代码都通通都挂了。我想了一个办法,就是通过 hock 的手段。因为应用进程启动的时候肯定会有 Application 实例,只不过不知道怎么来获取它,通过研究框架层的代码,初始化的时候会保留一个 APPLICATION 的实例,我们通过一些非正规的手段获取实例,这样就可以通过全局的 Application 去访问 Application 实例。
图 8
接下来还有一个关于 Multidex 的问题,我简单的解释一下 Multidex 做的工作,在安卓的 5.0 之前,对 dex 文件做优化处理,变成本地文件(odex)。这个过程中是大概是这样,启动-解压,这个时间比较耗时,再接下来是执行,把这些 dex 优化成本地的代码。这个过程中,也是很耗时,比较差的设备,需要 6-7 秒。我的模拟器,大概要 500 毫秒。第二次启动的时候,就比较快的,因为上一次启动的时候有本地缓存,因此我们需要解决第一次启动时候的问题。刚好我在做 APK 的更新,这时忽然产生了一个灵感,在第一次启动的时候比较耗时,我们能否在静默更新的时候做一些工作,保存到指定的路径下。用户在启动的时候,过一遍有没有启动工作。有的话,我直接把这个优化,或者是同命名到对方指定的这个路径上,再进一步的执行。当时只是一个简单的想法但是我实现了之后,还是挺有意思的,米 1 的设备,本来这个模块有 7-8 秒,但是做了这样的一个优化,大概只有 500 毫秒了,差不多是第二次冷启动的时间,同时也担心会不会带来额外的风险。所以也加入了安全模式,当发现用户启动之后出现类加载问题,就会给他清空,继续走原来的逻辑。经过这样的处理之后,发现效果还是很明显的,原本是需要 7-8 秒的时间,现在就只需要 500 毫秒。
|H5 首屏优化
图 9
刚才是讲的冷启动的优化,另外还有一个比较大的问题-H5 首屏的问题。我们的前端跟我们说,打开的比较慢,需要知道慢在哪里。如图 9 有几个性能指标,白屏时间指的是用户点击到看到这个内容的时间。DOM 内容加载时间。首屏指的是 WEB 页打开最慢加载好所有图片的时间,一般是背景大图是加载的最慢的,我们是认为加载完大图就是首屏的时间。根据这个生命周期,前后部分是客户端行为,我们是能够做优化空间很少。中间的过程,我们能够做哪些优化呢?
一开始不太熟悉 H5 开发的一些技术,也没有更好的办法,只能让前端的同事打开网页的时候,加入 LODING 交互 ,让用户觉得没有那么多的白屏时间。后来回想起在之前的工作经验中采用 WEB 来动态更新UI,但是 WEB 更新也会有一些问题,网络一旦出问题,就什么都不显示了,因此需要做优化,我们在网络加载失败的时候,把URL重定向到内置资源件上面去,就解决了这个页面空白的问题。这个时候我想了这跟我们的需求有一点相似的地方,如果我避开这个网络请求,而是直接使用本地离线的,大大缩减这个时间,因此做了以下工作。
图 10
首先重写 API ,设计优先使用本地的缓存,如果是有缓存,这个页面的刷新时间非常的快。改变这种逻辑还需要机制配合,离线包更新逻辑在定期的时间拉起服务器离线包的配置,再判断这个是否要更新,就会形成一个整体的逻辑。比如前段时间,上新了一些活动包,客户端用户收到了这些信息的话,就把它下载下来保存本地,保持最新的一个版本,等他打开活动的时候,先命中他的缓存,避免耗时,达到一个加速的效果。
图 11
除此之外还有一些问题-到达率太低,很多用户在还没缓存完前就已经打开了,另外现在为了避免一些泄露,我们都把 WEB 的业务放在一个独立的进程里面,但是这个是主进程的更新需要知道进程同步的问题,在我更新的离线包的时候是否已经更新了。而且这个还带来了一个压力,这个滑动的时效性要求非常高。比如说今天上线,换了一个版本,用户再拉,会导致没有那么的及时。考虑的一个工作是做一个增量更新的一个操作。
通过这样的一个操作之后(图 11),我们发现,基本上能够只要是命中,就达到一个秒开的功能。平均的这个首屏的时间,大概是能够减少 30% 的时间。我们认为他的效果还是很显著的。
|问题与优化方案
-
HTTPS问题
-
到达率低
-
后端依赖严重
-
HTML 文本拓扑方案
-
WebView 预热方案
但是这里也有还一些问题要来处理,比如说 HTTPS 的问题,熟悉这个协议的人都知道,避免不了证书校验这个问题,无法确保本地离线文件是安全的。 WEB 有一些问题,还是没有办法命中离线缓存的问题。到达率低,无论我们怎么做增量更新优化,用户到达率达到都没有达到一个满意的效果。还有一个更严重的问题,这个功能有很严重的后端依赖问题。比如我开发了这个 WEB 的容器,但是我要配套的更新离线包的服务,必须要求有一个服务器接口来做离线包更新。
而且做性能优化的工作,很难争取到充足的资源去做推进,所以这个时候,我们在想有没有办法来解决这个问题。现在在实现的是 HTML 文件拓扑的方案,我们希望,绕开这个后端接口。我们想到了这样的办法,用户在访问我们列表页的时候,我先预判,再通过 HTML 文本分析的其中较大的图片,较大的资源,给他预先下载下来,预热一下,达到一个命中缓存的效果,还有是WEBVIEW,我先判断,用户是要进入哪一个,使用一个隐藏的 WebView 实例去加载这个页面,等用户打开的时候,我再把这个展现出来,下面的方案,比上面的更明显更直接一点。但是,可能是带来内容泄露的风险,这个我们目前还没有上线。
以上是此次分享的内容。