前言
Android xml布局加载是一直困扰开发者的问题,在整个加载过程中,文件I/O、sax pull流式解析和属性解析、View创建、测量、绘制、drawable加载都是性能消耗的关键节点。
为了解决这些问题,目前也有很多方法,但目前流行的方案或多或少都存在缺陷。下面我们梳理一下当前的常用的优化方法。
现状
动画、大图延迟加载:
这是一种通用方法,具体原理是自定义LayoutInflater + 自定义style属性实现,原理是在Layout定义View收敛方法,如onViewCreated(View,Attributes),解析自定义属性app:lazyImage和app:lazyBackground,通过Handler postDelay延迟设置给View图片。不过你可能会问,xml中还能预览么?答案是可以的,不要忘了android 还有tools:background可以作为预览设置。
xml布局预加载:
我们几乎所有的View都需要依赖Context,而View所依赖的Context一般是Activity,这就造成一个问题,使用Context加载的布局存和Activity加载的存在差异。理想情况下,利用ContextThemeWrapper再次Wrap是可以解决一些问题的,但是如果你 Activity并不统一,如存在AppCompatActivity、FragmentActivity、Activity多种,那么很可能加载出不同的View。另一个问题,预加载的布局是无法多次复用的,为什么这么说呢,因为大多数情况下,一旦View已经展示,那么相应的属性可能已经发生了变更,比如你设置View.GONE了,这个时候显然你很难恢复到原始状态,特别是View很多的情况下。
xml异步加载:
显然,这个异步加载是google比较提倡的方式,这种方式解决网络请求之后再渲染还是非常有优势的,不过话说回来,有些View 中使用Choreographer或者new Handler()问题,需要特别兼容才行,还有,异步并不等于快。不过这种方式备受推崇,特别是在使用ViewPager切换Fragment的时候。异步意味着这个任务是可以cancel的,显然更配合ViewPager实现懒加载,比如电视上的app Tab切换,这种优化在低配或者TV设备上收获更加明显。
LayoutInflater.Factory2 对象创建映射:
其实这个是将View 反射优化为new View的方法,但是这里仍然存在xml布局预加载的问题,因为Activity不同,意味着你创建的View可能不符合预期,同样如果还定制了主题属性的话,可能还存在一些风险,不过方案实现角度独特,也非常简单易用,如果Activity类型统一,也非常建议使用此方案。
X2C 实现xml转为java文件
X2C 有个显著的问题就是在编译时自行解析属性,这个造成了很多兼容性问题,主要问题一部份来自构造方法中的style问题,另一部分是对自定义属性的不支持
不过通过这种方式剥离了解析造成的问题:文件I/O,sax pull流式解析和属性解析问题,性能也大大提高。
android complie Layout
comile Layout是google自己实现的布局编译方案,完美解决了X2C运行时View无法替换的问题,从Android 9 加入到LayoutInflater中一直至今未提供给app使用,目前是android 14了仍然未放开,有一定的概率要烂尾。 我们从下面几个角度来推测原因,我列出几个比较重要的问题。
- 一个布局仅仅被编译一次
- 不支持include和merge(Merge 设置Visible是需要立即加载的)
- 对自定义布局和属性不够友好
- 没有编译版本机制
首先,第一个问题是还好,因为编译后的dex布局为止在app私有目录,理论上我们删除之后还能继续编译。
/data/data/com.yourpackage/code_cache/compiled_view.dex
第二个问题是不支持include和merge,不支持include实际上考虑到的是system_server的一种保护机制,为什么这么说呢?我们知道xml是Dom Tree的结构,以二叉树来说,如果让叶子节点指向根节点,那将造成编译器无法停止的问题,必然引发系统进程崩溃。
显然,这点不符合大部分app的需要。
第三个问题,其实并没有想象的严重,具体问题点google也没有详细说出来,下面是编译后的布局代码
java
View tryCreateView8 = from.tryCreateView(viewGroup2, "com.example.ui.widget.BoringTextView", context, asAttributeSet);
if (tryCreateView8 == null) {
tryCreateView8 = new BoringTextView(context, asAttributeSet);
}
第四个问题,显然最大的影响可能是aab插件或者插件框架了,如果我们没有做插件话,这个还好说,自动根据版本删除缓存即可,如果做了的话显然要麻烦的多。
如何调用这个命令呢?google官方的命令是
java
viewcompiler my_layout.xml --package com.example.myapp --out CompiledView.java
但实际上这个命令显然不符合adb 规范,理论上你是无法调用。我们知道,adb 调用AMS和PMS服务命令一般是需要加上am或者pm前缀的,而PMS负责包的UID、签名、保护级别鉴权之外,还有个重要的功能就是安装、卸载和编译服务(Installd),和dex优化一样,事实上通过PackageManagerShellCommand 是可以编译的,命令如下:
java
adb shell pm compile --compile-layouts com.example.particles
具体生产的代码,xml文件名会变为方法名
java
public static View test_reflow_chipgroup(Context context, int i) {
LayoutInflater from = LayoutInflater.from(context);
XmlResourceParser layout = context.getResources().getLayout(i);
AttributeSet asAttributeSet = Xml.asAttributeSet(layout);
layout.next();
layout.next();
View tryCreateView = from.tryCreateView(null, "com.google.android.material.chip.ChipGroup", context, asAttributeSet);
if (tryCreateView == null) {
tryCreateView = new ChipGroup(context, asAttributeSet);
}
ViewGroup viewGroup = (ViewGroup) tryCreateView;
layout.next();
View tryCreateView2 = from.tryCreateView(viewGroup, "com.google.android.material.chip.Chip", context, asAttributeSet);
if (tryCreateView2 == null) {
tryCreateView2 = new Chip(context, asAttributeSet);
}
viewGroup.addView(tryCreateView2, viewGroup.generateLayoutParams(asAttributeSet));
layout.next();
layout.next();
View tryCreateView3 = from.tryCreateView(viewGroup, "com.google.android.material.chip.Chip", context, asAttributeSet);
if (tryCreateView3 == null) {
tryCreateView3 = new Chip(context, asAttributeSet);
}
viewGroup.addView(tryCreateView3, viewGroup.generateLayoutParams(asAttributeSet));
layout.next();
layout.next();
View tryCreateView4 = from.tryCreateView(viewGroup, "com.google.android.material.chip.Chip", context, asAttributeSet);
if (tryCreateView4 == null) {
tryCreateView4 = new Chip(context, asAttributeSet);
}
viewGroup.addView(tryCreateView4, viewGroup.generateLayoutParams(asAttributeSet));
layout.next();
return viewGroup;
}
看完之后你会发现,这显然是LayoutInflater.Factory2 new View的升级版,避免saxpull循环解析问题和运行时View需要更换的问题。不过,无法include和merge ,显然兼容性上有进步但也有倒退。
很显然,无论哪种方案都有缺点和不完善的地方,我们期待能实现jetpack compose那种纯java代码的加载,似乎路线还很遥远。那么,还有没有更好的方案?
优化方向
基于dexmaker的实现
compile layout烂尾的概率很大。
从另一个角度出发,如果我们使用dexmaker是不是可以解决include和merge这个问题的呢,理论上是完全可以的。不过我们需要将LayoutInflater#inflate方法转为语法树,这里要记住的,inflate遇到include标签是会在此调用Inflate的,因此需要使用Stack数据结构来生成语法树。
java
@Override
public View inflate(int resource, @Nullable ViewGroup root, boolean attachToRoot) {
HierarchyNode viewNode = hierarchyCache.get(resource);
if (viewNode != null) {
ViewGroup.LayoutParams layoutParams = null;
if (root != null) {
layoutParams = root.generateLayoutParams(viewNode.attributeSet);
}
View view = HierarchyHelper.compile(getContext(), viewNode);
view.setLayoutParams(layoutParams);
if (attachToRoot && root != null) {
root.addView(view);
return root;
}
return view;
}
final Resources res = getContext().getResources();
XmlResourceParser parser = res.getLayout(resource);
mHierarchyStacks.push(new HierarchyHelper(parser));
int rootChildCount = 0;
if ((root != null)) {
rootChildCount = root.getChildCount();
}
View inflateView = super.inflate(resource, root, attachToRoot);
HierarchyHelper helper = mHierarchyStacks.pop();
HierarchyNode compileNode = helper.preCompile(root, inflateView, rootChildCount, Xml.asAttributeSet(helper.getParser()));
if (compileNode != null) {
hierarchyCache.put(resource, compileNode);
}
return inflateView;
}
sax pull 优化: sax pull 仍然避免不了I/O 问题,那什么方案呢?我们从View中一个常常被忽视的方法中可以找到灵感
java
android.view.View#saveAttributeData
private void saveAttributeData(@Nullable AttributeSet attrs, @NonNull TypedArray t) {
final int attrsCount = attrs == null ? 0 : attrs.getAttributeCount();
final int indexCount = t.getIndexCount();
final String[] attributes = new String[(attrsCount + indexCount) * 2];
int i = 0;
for (int j = 0; j < attrsCount; ++j) {
attributes[i] = attrs.getAttributeName(j);
attributes[i + 1] = attrs.getAttributeValue(j);
i += 2;
}
//省略不重要的代码
}
我们把attributes打印出来
ini
textSize=16.0sp
background=@2131100376
layout_width=-2
layout_height=-2
text=12:55:56
layout_toRightOf=@2131296473
layout_centerVertical=true
我们知道AttributeSet 的实现是XmlResourceParser,如果我们用TreeMap去实现,显然是可行的,compile layout,这样,我们连AttributeSet通过TreeMap生成,这样就能避免I/O问题。
感觉非常完美的方案,但是,现实往往比较残酷,google 一直不愿在android上实现动态化,另外dexmaker也缺乏实践化的case,要上在正式环境使用,显然还有很多未知的问题。
基于x2c的优化方案
显然,动态化涉及的安全性问题比较多,以及dexmaker兼容性问题仍然存在未知的风险,那运行时做不到的,是不是可以在编译时去处理呢?
我觉的x2c完全可以参考android compile layout方式进行改进,同时利用我们本篇提出的使用TreeMap去实现Attributes,就无需调用各种setXXX方法了,而是正常的通过View的构造函数去优化,显然,还能避免View无法替换等运行时问题。总结如下:
- 通过View构造函数去初始化View
- 通过TreeMap实现AttributeSet并加载属性
总结
本篇到这里就结束了,android UI目前已经进入了jetpack compose的时代了,以上优化方向仅针对老项目了,所以,如果我们能使用jetpack compose的项目,就不要使用xml了。