得物布局构建耗时优化方案实践
一、背景
当谈到移动应用程序的体验时,页面启动速度是其中至关重要的一点,更快的页面展示速度确保应用程序可以迅速加载并响应用户的操作, 从而提高用户使用 App 时的满意度。在页面启动的整个流程中,随着 UI 复杂度的上升,布局的 Inflate 耗时占据了相当一部分关键的比例,本文分享得物自身在页面布局构建耗时优化方案上的探索历程。
二、现有方案
在布局构建耗时优化上,开源社区上有一些现成的方案可供参考,我们首先看下目前一些已知的技术方案。
掌阅 X2C
掌阅的 X2C 方案开源于 2018 年,其通过 APT 在编译期间对目标 XML 文件进行解析,并翻译成 XML View 树结构对应的 Java 文件。比如以下的布局 XML 文件。
转换成 Java 文件:
优点:
性能高,没有了加载 XML 的 IO 和递归解析过程。
避免了类反射构建的耗时。
基于 APT 直接生成 Java 文件。
缺点:
View 兼容性差,适配成本高,自定义 View 需要配置属性对应的方法。
功能不完整,不支持 Merge 标签,无法查询系统 style,所以只支持应用内 style。
由于 APT 本身的特性,在 XML 发生变化时,对应注解处理器生成的 Java 构建文件不会同步发生变, 对于不熟悉的同学来说容易踩坑。
AsyncLayoutInflater
AsyncLayoutInflater 是由 Android Google 官方提供的异步 Inflate API,其主要思路是将 Inflate 操作放在异步线程并行操作,从而让主线程可以继续执行一些其他的初始化操作,通过异步回调在相应的 Layout View 创建完成后,再设置到页面上。
优点:
将 UI 加载过程迁移到了子线程,保证了 UI 线程的高响应。
不存在 View 兼容性问题。
缺点:
有一定改造成本,在原有的页面直接引入 AsyncLayoutInflater 进行改造时,由于从同步调用改成异步回调调用导致的逻辑结构变化容易引入 NPE 之类的风险。
内部依然存在部分 View 的反射需要创建的开销。
ViewCompiler
Google 加入了一个 ViewCompiler,从原理来看是系统在安装 APK 的时候自动对布局文件做的编译优化,ViewCompiler 会将可优化的 XML 布局转化为代码构建的代码,并编译成 Dex 文件。
之后在程序运行时,首次使用 Infalter 类时,就会提前加载该 Dex 文件。
之后在调用 Infalte 函数 Inflate 相应布局资源时,会尝试调用优化后的 pacakgeme.CompileView 类的 Infalte 函数,直接生成对应的 View。
ViewCompiler 编译 Layout 的原理其实和现有的 XML To Code 方案是类似的,都是解析 Layout XML 文件,再根据 XML 节点信息生产组装 View 的代码。只不过在应用层我们的方案是提前编译生成 Java 或 Class 文件,而系统是直接编译生成 Dex 文件。
ViewCompiler 虽然在 Android Q Beta 2 的时候被添加进来,但到目前为止仍是一个实验性质的东西,默认情况下应用程序都是无法使用到的。
三、得物自研 X2C 框架实践
针对以上问题,我们决定构建得物自研的 X2C 框架。
生成 XML2Code 代码为:
View 构建流程解析
生成 AttributeSet
在生产 AttributeSet 的探索上,我们首先研究系统的 LayoutInfalter 是如何生成 AttributeSet 的,通过对源码分析后发现,AttributeSet 是一个抽象接口,其唯一的直接子类是 XmlResourceParser。
系统的 LayoutInflater 的构建过程中,首先通过 Resources 生成对应布局文件的 XmlResourceParser。
由于 Parser 继承自 AttributeSet,因此可以将 Parser 强转为 Attributeset,之后先生成 RootView,再调用 RInfalteChildren 构建所有的子 View。
而在子 View 的构建过程中,使用的还是一开始从 XmlPullParser 转换的 AttributSet,这里的 XmlPullParser 和 AttributeSet 其实是同一个对象,XmlPullParser 解析二进制 XML 采用的是 SAX 方式,即边读边解析, 通过不断调用 Next 函数,在构建对应节点的 View 时,读取当前的 AttributeSet 信息。
创建 View 的方式
View 实例的创建有两种方式:
第一种是类似掌阅 X2C 的方式, 直接调用目标 View(Context Context) 构造函数创建,此时还需要生成额外的属性设置 API,如 SetWidth,对于自定义的属性需要做专门的适配处理。
第二种是调用 View(Context Context, AttributeSet Attrs) 构造函数,LayoutInflater 内部解析 XML 并构建相应 View 时,调用的就是这个构造函数。
因此,从兼容性的角度上考虑,采用第二种方式构建更为合理,剩下的问题就转化为如何生成对应布局文件中对应 View 的 AttributeSet。
生成 LayoutParams
AttributeSet 除了用于构建当前节点的 View 以外,还用于构建 LayoutParams。
LayoutParams 的构建同时还依赖于当前节点的夫容器 Parent,不同的容器生成不同的 LayoutParams,例如 FrameLayout.LayoutParams、LinearLayout.LayoutParams 等。
Merge 和 Include 标签
Merge 标签跟普通标签的区别在于,Merge 标签是一个虚拟根节点。Merge 是为了降低 View 嵌套层级设计的,所以 Merge 标签为根节点的布局是没有根 View 的,所以也无法返回布局根 View,只能将参数的 ViewParent 返回。
Merge 标签需要搭配 Include 标签使用,但是 Include 标签却并不是只能搭配 Merge 标签。所以在解析 Include 标签的 Layout 的时候,我们并不知道包含过来的是普通布局还是 Merge 布局。
但是普通布局和 Merge 布局的实现并不一样。
对于 Include 普通布局,逻辑要复杂的多。Include 标签本身有 AttributeSet 信息,包含的布局根节点也有 AttributeSet 信息,应该使用哪个呢?构建根 View 的时候,使用根节点的 AttributeSet,但是在 View 构建完成后,需要将 Include 标签属性中的 Android:ID 和 Android:visiablity 属性赋值给根 View。
在生成根 View 的 LayoutParams 的时候,优先使用 Include 标签的 AttributeSet,如果生成失败再使用根节点的 AttributeSet。
插件选型:APT or Gradle Plugin?
APT 方式的问题
在 XML 生成代码构建的实践过程中,我们一开始也是采用的掌阅 X2C 的方案,在业务代码中插入如下注解,用于标记需要转换成 Java 的 XML 文件,在各业务模块中注册注解处理器,直接生成对应的 Java 源代码。
最后发现这样的方式会带来不小的问题:
APT 的编译 Target 是 Java 源代码, 所以在只有 XML 文件变更时,并不会自动重新生成新的 Java 布局代码。这样一次 XML 修改,在转换成 Java 代码的时候,就被编译系统忽略了。
使用 XML 注解标注文件名的方式,并没有让注解跟文件本身绑定。当文件改名的时候,这个注解并不能感知,文件的修改者也无法感知到有这么一个跟文件没有直接关系的文件名注解。
得物采用的是多仓库多模块开发,壳工程引入子工程的依赖,最后是以 AAR 二进制依赖的方式进行构建。每个模块接入的 X2C 插件版本不同,因此构建出的产物也不同,这会导致 X2C 版本碎片化严重。容易出现生成之前生成的 View 构建代码和最新的运行时 X2C-SDK 不兼容的问题,也增加了 X2C-SDK 后续升级过程中的维护困难。
使用 AGP 统一构建
我们最终采用的通过 AGP 插件,在壳工程对所有目标 XML 进行统一构建的方式。
在 Android 工程的编译过程中,ProcessResources 任务将所有依赖的模块的资源进行处理,生成 Resources.ap_ 文件和 R 文件。Resources.ap_ 是资源压缩包,里面的 XML 资源是已经被编译成二进制格式的资源。
X2C-AGP 的核心功能主要有两部分:
GenerateJavaTask 是将 XML 布局文件 转换成 Java 布局代码。
ExcludeTransform 后续介绍。
我们约定当布局 XML 文件中,添加了自定义属性 app:x2c 时,表示该文件需要进行 X2C 构建代码生成。GenerateJavaTask 任务遍历 Resources.ap_ 文件,将包含该自定义属性的布局文件转换成 Java 代码。还生成了 Resource ID 到 Java 布局类的映射关系。
壳工程通过任务 GenerateJavaTask 将二进制 XML 布局文件,转换成 Java 布局代码时。Java 布局代码中使用了很多自定义 View。这些自定义 View 是在业务模块中定义的,而在壳工程的 App 模块中,由于并没有显示申明对应 View 的模块依赖,会导致编译 Java 布局文件时出现类未找到的问题,导致编译失败。而如果人手动去解决该问题,为 App 模块添加相应 View 的模块依赖,显得较为繁琐。每次增加一个需要支持 X2C 的 XML 文件的时候,都需要增加壳工程的工程依赖关系,且自定义 View 到底在哪个模块也不不是这么一目了然。
一个解决方案是不再生成 Java 源码,直接生成 Java 字节码,这样可以绕过编译依赖。直接生成字节码的方案增加了项目的升级和维护成本,且不便于业务侧同学验证生成的 Java 布局代码是否正确。
另一种方案是在壳工程重新实现一次依赖的自定义 View,这样就造成了 APK 中会有重复的类,所以需要在 Transform 阶段将重复的 View 去掉,ExcludeTransform 就是完成这个任务的。壳工程中实现的自定义 View 会有 @X2CResTemp 注解,在 ExcludeTransform 中,通过 ASM 遍历工程中所有字节码,将有 @X2CResTemp 注解的类从编译系统中删除。
如何在壳工程中实现依赖的自定义 View 呢,观察生成的 Java 代码,会发现我们只用了自定义 View 的构造函数,并不需要实现一个完整的自定义 View,只要有构造函数,就可以在编译阶段通过了。
预加载
优化布局的加载性能,除了 X2C 方案以外,预加载是一个效果更为显著的功能。在 Androidx 中已经有提供了 AsyncLayoutInflater 用于进行 XML 的异步加载,在这个类基础上可以封装一个异步预加载工具,但是实际使用下来会发现直接使用 AsyncLayoutInflater 很容易出现锁的问题,甚至导致了更多的耗时。通过分析我们发现,这是因为在 LayoutInflate 中存在着对象锁,并且即使通过构建不同的 LayoutInflate 对象绕过这个对象锁,在 AssetManager 层、Native 层仍然会有其他锁。
预加载时机
布局预加载存在于两个时机:
App 启动时,Application 的 OnCreate 阶段,可以对首页布局进行预加载。
打开新的 Activity 前,预加载这个 Activity 的布局。
在 App 启动阶段对主页的布局文件进行预加载,统一放到启动任务加载中去做。新的 Activity 启动之前,如何做布局预加载呢?打开新的 Activity 的场景可能十分多,难道需要在每个 StartActivity 调用之前都插入一段预加载布局的代码吗,且打开新的 Activity 的地方需要能获取布局资源 ID。
答案是跟路由结合在一起,ARouter 提供了路由拦截器,不同的业务模块,可以在模块中使用注解注册一个 ARouter 路由拦截器,并在拦截器中自定义自身模块内页面的预加载策略,如下:
所有打开新 Activity 的场景都需要使用路由,所以在路由拦截器中能收敛打开新 Activity 的场景。
Context 及主题适配
对 Activity 的布局文件进行预加载的时候,Activity 还没有创建,所以我们无法拿到 Activity 的 Context。但是构建 View 需要 Context,所以我们使用 Application 的 Context 代替。但是很多业务侧拿着 View 的 Context 当 Activity 用的场景,为了兼容这种场景,所以在预加载的 View 被添加到 ViewTree 前需要将 ApplicationContext 替换成 Activity 的 Context。
View 没有提供替换 Context 的 API,所以使用反射替换 mContext 成员的值。
如此这般,大部分场景下已经没有什么问题了,但是仍然遇到了新的问题
这是因为布局使用了跟主题相关的内容,Application 的 Context 没有主题信息,所以预加载的 Context 需要加上布局文件所属的 Activity 的主题,如下:
构建线程优先级调优
在框架开发完成后,我们在得物首页场景下进行了框架接入,在 Application 的 onCreate 阶段对 HomeActivity 的布局进行了相应布局的预加载。对预加载进行线下测试,线下数据表现较好。在开启预加载的时候,秒开数据显著好于无预加载场景。然而预加载功能上线后,线上 AB 统计的平均耗时数据确令人不解,在开启预加载情况下,首页布局加载耗时竟然大于无预加载情况,分析了样本数据后,发现在异步线程构建存在的异常耗时样本远远多于在主线程构建的样本数量。
我们在线下针对线上容易出现异常耗时的设备进行了复测,发现确实存在类似的情况,此时我们联想到 Android 系统在对 SharedPrefenrece 做的一个优化,由于异步线程的优先级默认比主线低,因此在 Activity onStop 的时候,系统会把异步线程 SP 未完成同步的任务直接取出到主线程执行,异步构建是不是也是由于线程优先级导致异步构建时无法获取到充足的 CPU 时间片导致的,最终我们在线下打印了主线程和异步线程执行时获取的 CPU 时间片占比,验证了该猜想。
可以看到,虽然提前进行了异步构建的工作,但是到页面需要使用对应 View 的时候,异步构建的任务还没有完成,因此主线程只能进行等待,并且由于异步线程优先级较低,出现了一个高优先级的线程等待另一个低优先级线程的情况,并且优先级导致的时间片分配的原因,这里的等待其实不如直接在主线程直接重新构建。异步 View 构建线程其实是为主线程服务的,我们需要提高对应工作线程的优先级。
Android 设置线程优先级的方法有两种:
Java API 使用 Thread 类的 setPriority,值为 0~10,值越大,优先级越高,所能获取的时间片越多。
Android 系统使用 Process 类的 setThreadPriority,值为 -20~20,值越小,优先级越高,所能获取的时间片越多。
在 Android 中,无论通过什么方式设置的线程优先级,其实本质上都是通过 Native 层,设置 Nice 的值来实现的。线程优先级必须在线程创建成功后,才能设置,因为线程创建完成后,才能拿到线程 ID。注意 Thread 的 Start 方法执行后,线程不一定创建完成,Thread 的 Runnable 开始执行才能认为创建完成。
线程默认优先级为 0,主线程默认为-20,部分 ROM 的主线程默认-10。我们将预加载线程优先级提升为-16。
经过调整后,性能提升显著,在对应页面需要获取 View 时,异步任务基本已经提前完成。
多线程构建探索
默认情况下,一个 View 树的构建是单线程的,即总是从 ViewRoot 层级向下构建,无论采用现有的哪种方案,最终构建的总耗时总是大于每个 View 构建耗时之和,无法利用多线程的优势缩减 View 构建耗时。
为了进一步提升预加载的效率,我们考虑使用多线程对预加载进行性能提升。布局的加载受限于 XML 的解析,XML 的解析只能使用单线程。对二进制 XML 文件格式进行研究,看看是否有进一步优化的可能性。
自己生成 AttributeSet
通过 XmlResourceParser 获取 AttributeSet 是实现成本较低的方式,但它存在以下问题:
仍需要 XML 文件的存在,通过 Resource 读取二进制 XML 资源,涉及到一部分文件 IO。
XmlResourceParser 对 XML 文件读取是 Pull 模式,如果我们计划对 ViewTree 的构建过程进行多线程构建优化,无法直接获取对应节点的 AttributeSet 信息。
因此,我们进行了自己生成 AttributSet 的探索,首先,XmlBlock 的生成,除了类似 LayoutInflater 构建过程中直接传入 LayoutID 的方式(如下)。
也可以直接传入对应的 Byte[] 进行生成, 因此,我们如果可以直接生成 XML 文件中各个 View 属性信息对应的二进制文件,就可以直接通过 XMLBlock 构建对应的 AttributeSet。
二进制 XML 重组
二进制的 XML 文件其内容结构如下:
二进制 XML 有以下 6 部分组成:
文件头
字符串常量池
系统资源 ID 池
Start NameSpace Chunk
嵌套的节点 Chunks
End NameSpace Chunk
二进制 XML 保留了文本 XML 中节点的嵌套结构关系。XML 的节点之间除了用嵌套结构来描述父子关系外,父子之间没有信息依赖,子节点的解析不依赖于任何父节点信息。父子节点的信息解析是可以完全独立的,所以我们在解析文件之前,将完整的 XML 文件按节点拆成每个 N 个独立的文件,文件格式如下:
文件头
字符串常量池
系统资源 ID 池
Start NameSpace Chunk
节点 Chunk
End NameSpace Chunk
文件重组后,每个文件的 File Size 字段需要重新计算。二进制数据保存在代码中,用函数分割保存。
留待进一步
多线程加载方案对单个 XML 的预加载性能有所提升,但是因为预加载主要是在 App 启动的时候使用,这个时候影响性能的并不是线程不够,而是 CPU 性能不够。同时 App 启动阶段预加载的资源不是只有一个,而是多个。多线程主要是拉平了各个线程的算力消耗。
实现多线程方案,也引入了新的问题:
让 X2C 的实现变的复杂了,兼容多线程方案的实现性能相对不兼容多线程的下降了。
多线程方案依赖于对二进制 XML 进行重组,代码中多拷贝了一份资源。
四、线上性能收益
以首页的启动速度为例。
这里的启动速度标准是,从首页 Actiivty 的 onCreate 开始执行到 onResume 函数执行结束。
LOCAL: 表示未做任何优化的数据 ,平均耗时 292ms。
X2C: 未做预加载,但使用了 X2C 的 infalte 构建, 平均耗时 267ms。
CACHE: 进行了提前预加载,平均耗时 216ms。
以 社区容器 页面的启动速度为例。
LOCAL: 平均耗时 293ms。
X2C: 平均耗时 210ms。
CACHE: 平均耗时 150ms。
五、框架对比
六、结论
通过实践上述优化方案,可以显著减少布局构建的耗时,提高应用的性能和用户体验。本次项目经过三轮的优化迭代,整个技术迭代过程中,一个核心的理念就是数据驱动,一切的优化都要以数据的提升来作为标准,遇到问题解决问题。
本次技术优化最初的切入点是 XML2Code,但是在进行线上验证后,发现仅仅只是 XML2Code 并不能达成我们预期的结果。于是整个项目回归到了更高层级的目标上 —— 优化布局构建耗时。为了进一步优化布局构建的耗时,预加载、多线程构建,可谓“无所不用其极”,最后达成预期结果。
所以盯住结果,不要拘泥于什么具体的技术!
*文/令古
本文属得物技术原创,更多精彩文章请看:得物技术官网
未经得物技术许可严禁转载,否则依法追究法律责任!
版权声明: 本文为 InfoQ 作者【得物技术】的原创文章。
原文链接:【http://xie.infoq.cn/article/6fcc3444ad2f905d89d53ae49】。文章转载请联系作者。
评论