Android 布局加载优化该何去何从

前言

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了。

相关推荐
一颗花生米。2 小时前
深入理解JavaScript 的原型继承
java·开发语言·javascript·原型模式
学习使我快乐012 小时前
JS进阶 3——深入面向对象、原型
开发语言·前端·javascript
bobostudio19952 小时前
TypeScript 设计模式之【策略模式】
前端·javascript·设计模式·typescript·策略模式
勿语&3 小时前
Element-UI Plus 暗黑主题切换及自定义主题色
开发语言·javascript·ui
一路向前的月光8 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   8 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d
Fan_web8 小时前
jQuery——事件委托
开发语言·前端·javascript·css·jquery
Jiaberrr9 小时前
Element UI教程:如何将Radio单选框的圆框改为方框
前端·javascript·vue.js·ui·elementui
安冬的码畜日常11 小时前
【D3.js in Action 3 精译_029】3.5 给 D3 条形图加注图表标签(上)
开发语言·前端·javascript·信息可视化·数据可视化·d3.js
太阳花ˉ11 小时前
html+css+js实现step进度条效果
javascript·css·html