RecyclerView布局绘制优化-OkLayoutInflater

使用OkLayoutInflater优化RecyclerView的布局加载,使得首帧渲染耗时65ms->35ms,滑动帧率45fps->52fps

一、Android View开发现状

传统XML布局

Android开发长期以来采用XML布局文件的方式构建UI,这种方式存在以下特点:

  1. 运行时inflate:在应用运行时,通过LayoutInflater将XML解析为View对象
  2. 主线程阻塞:inflate过程在主线程执行,涉及IO操作和View树构建
  3. 复杂布局的inflate耗时显著,特别是在RecyclerView等需要快速创建大量ViewHolder的场景

自定义View开发方式及其缺点

虽然自定义View可以避免XML inflate的开销,但存在开发效率低的问题:

  1. 需要手动编写所有View的创建、布局、测量逻辑
  2. 缺乏可视化预览,难以直观看到UI效果,每次UI调整都需要重新编译和运行
  3. 调试困难,中大型Android项目增量运行普遍在1分钟以上

现代开发体验对比

Jetpack Compose优势:

  • 实时预览:支持编译器实时预览(@Previewb标签)和实体机热重载,开发效率大幅提升
  • 声明式UI:代码即UI,减少XML解析开销
  • 编译时优化:编译器优化减少运行时开销

iOS开发体验:

  • Xcode运行快:即使使用UIKit这样的传统UI,也可以快速运行,查看实际效果

相比之下,传统Android XML布局开发在开发效率和运行时性能方面都存在改进空间。

二、优化措施

掌阅X2C

GitHub - iReaderAndroid/X2C: Increase layout loading speed 200%

2018年开源,原理是在编译阶段,使用APT(Annotion Processor Tool),将XML解析成对应的Java文件。

xml代码

ini 复制代码
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  xmlns:tools="http://schemas.android.com/tools"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:paddingLeft="10dp">

  <include
    android:id="@+id/head"
    layout="@layout/head"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_centerHorizontal="true" />

  <ImageView
    android:id="@+id/ccc"
    style="@style/bb"
    android:layout_below="@id/head" />
</RelativeLayout>

编译后生成的Java代码:

ini 复制代码
public class X2C_2131296281_Activity_Main implements IViewCreator {
  @Override
  public View createView(Context ctx, int layoutId) {
    	Resources res = ctx.getResources();

        RelativeLayout relativeLayout0 = new RelativeLayout(ctx);
        relativeLayout0.setPadding((int)(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,10,res.getDisplayMetrics())),0,0,0);

        View view1 =(View) new X2C_2131296283_Head().createView(ctx,0);
        RelativeLayout.LayoutParams layoutParam1 = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,ViewGroup.LayoutParams.WRAP_CONTENT);
        view1.setLayoutParams(layoutParam1);
        relativeLayout0.addView(view1);
        view1.setId(R.id.head);
        layoutParam1.addRule(RelativeLayout.CENTER_HORIZONTAL,RelativeLayout.TRUE);

        ImageView imageView2 = new ImageView(ctx);
        RelativeLayout.LayoutParams layoutParam2 = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,(int)(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,1,res.getDisplayMetrics())));
        imageView2.setLayoutParams(layoutParam2);
        relativeLayout0.addView(imageView2);
        imageView2.setId(R.id.ccc);
        layoutParam2.addRule(RelativeLayout.BELOW,R.id.head);

        return relativeLayout0;
  }
}

优点:

  • 性能高,没有加载 XML 的 IO 和递归解析、反射创建过程。

缺点:

  • View 适配成本高,尤其自定义 View ,需要配置属性对应的方法。
  • 不支持 Merge 标签,无法查询系统 style,所以只支持应用内 style。
  • 由于 APT 本身的特性,在 XML 发生变化时,对应注解处理器生成的 Java 构建文件不会同步发生变化, 对于不熟悉的同学来说容易踩坑。

ViewCompiler

Google Source ViewCompiler

Google 在Android10 加入了一个实验性功能 ViewCompiler,可以手动地将 xml 布局转化为 java 文件或者 dex 文件,但它并不支持 merge 和 include 标签。

一直到Android12,此功能都没有开启。同时在Android 15上,LayoutInflater.java中,相关代码已经完全去除。

所以ViewCompiler对于开发者而言,无法直接使用。不过有些团队参考ViewCompiler+X2C,做了自己的xml to java的编译插件,比如《小红书-首页性能优化》,他们解决了不支持merge\include标签等问题,并且在线上大规模使用。但是他们没有开源,所以我们也无法使用。

类似的方案:鸿洋:Android "退一步"的布局加载优化

优点:

官方支持

缺点:

代码已经去除,无法使用

AsyncLayoutInflater

Android Developers Jetpack AsyncLayoutInflater

AsyncLayoutInflater是Google官方提供的异步inflate布局的方式,他与X2C编译器转换不同,他是在运行期的优化。

主要就是将inflate操作放在异步线程操作,这样可以把IO耗时转移到异步线程,主线程可以执行其他操作,通过异步加载完成后,通过回调设置到页面上。

优点:

运行时异步加载,不会影响编译速度

对于页面的debug无影响,如Layout Inspector可以正常查看、定位

缺点:

  1. 异步线程只有一个,单线程 (FixedThreadPool(1))
  2. 任务不支持取消,在退出页面后可能会有资源浪费
  3. 不支持设置Factory,无法使用AppcompatView的样式
  4. 阻塞队列容量固定,默认10个,超过10个,会导致主线程异常
  5. 有可能会丢失Activity主题

网上有很多对AsyncLayoutInflater的二开方案Android AsyncLayoutInflater 限制及改进,但是在Kotlin下,有了新的更好用的OkLayoutInflater方案,所以这里不展开解释。

三、OkLayoutInflater介绍

Github OkLayoutInflater

OkLayoutInflater结合了Kotlin协程的优势,解决了AsyncLayoutInflater的大部分问题。

OkLayoutInflater的源码非常简单,可以直接查看:OkLayoutInflater.kt

其中,注释中直接说明了他解决的AsyncLayoutInflater的一些缺陷:

重要核心逻辑:

1.使用协程的SupervisorJob+Dispatcher.Default

ini 复制代码
private val coroutineContext = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.Default + coroutineContext)
优点 SupervisorJob可以保证协程作用域下某个子协程的异常,不会导致整个协程的取消
缺点 1.调度器使用的是Default,这个默认是8线程的,并且是用来处理CPU密集型任务的 2.Default是全局调度器,可能会有其他业务的耗时任务排在前面。而View的初始化是高优任务,这样反而会导致页面加载速度变慢。 3.Default调度器,如过某些业务逻辑有问题,比如Synchronized锁异常,会导致线程阻塞,可用线程数变少。
改进 使用自定义线程池(后面会介绍)

2.生命周期感知

当Activity/Fragment退出,View Detachde 时,会自动清理资源,主要是取消协程任务。

kotlin 复制代码
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
    if (event == Lifecycle.Event.ON_DESTROY) {
        cancel()
    }
}

// 取消协程
fun cancel() {
    coroutineContext.cancel()
    coroutineContext.cancelChildren()
}

3.智能降级策略

异步线程加载失败,会自动回滚到主线程加载

kotlin 复制代码
private suspend fun inflateView(
    @LayoutRes resId: Int,
    parent: ViewGroup?,
): View = try {
    mInflater.inflate(resId, parent, false)
} catch (ex: RuntimeException) {
    Log.e(TAG, "The background thread failed to inflate. Inflation falls back to the main thread. Error message=${ex.message}")

    // Some views need to be inflation-only in the main thread,
    // fall back to inflation in the main thread if there is an exception
    withContext(Dispatchers.Main) { mInflater.inflate(resId, parent, false) }
}

4.支持Appcompat Factory2,主题传递

kotlin 复制代码
private class BasicInflater constructor(context: Context) : LayoutInflater(context) {

        override fun cloneInContext(newContext: Context): LayoutInflater {
            return BasicInflater(newContext)
        }

        override fun onCreateView(name: String, attrs: AttributeSet): View {
            for (prefix in sClassPrefixList) {
                try {
                    val view = createView(name, prefix, attrs)
                    if (view != null) return view
                } catch (_: ClassNotFoundException) {
                }
            }
            return super.onCreateView(name, attrs)
        }

        companion object {
            private val sClassPrefixList = arrayOf(
                "android.widget.", "android.webkit.", "android.app."
            )
        }

        init {
            if (context is AppCompatActivity) {
                val appCompatDelegate = context.delegate
                if (appCompatDelegate is Factory2) {
                    LayoutInflaterCompat.setFactory2(this, appCompatDelegate)
                }
            }
        }
    }

自定义的类加载器继承的是android下的LayoutInflater,使用的是反射的方式创建View的,性能较差。而我们继承的AppCompatActivity,是直接用new的方式创建的,性能更好。

在OkLayoutInflater的BaseInflater中,初始化时,直接将context下的appCompatActivity注入。

主题传递链条

Activity.themeAppCompatDelegateFactory2→ 创建的 View

优化方向

OkLayoutInflater源码中使用的是Default调度器,前面分析,这是个全局调度器,可能会有任务累积+线程阻塞,导致View的加载变得更慢。

我们可以使用自定义线程池的方式进行优化,主要有两个方面:

1.自定义ThreadPool

kotlin 复制代码
// 自定义线程池
private val executor by lazy {
    ThreadPoolExecutor(
        4,
        4,
        30L,
        TimeUnit.SECONDS,
        LinkedBlockingQueue(),
        object : ThreadFactory {
            val count = AtomicInteger(0)
            override fun newThread(r: Runnable?): Thread {
                return Thread(r, "OkLayoutInflater-${count.incrementAndGet()}")
            }
        }
    ).apply {
        // 允许核心线程被超时回收
        allowCoreThreadTimeOut(true)
    }
}

2.退出时释放资源

  • 退出页面后,关闭线程池,避免资源泄露。
  • 使用scope.cancel(),协程作用域进入cancel状态,不再接受新任务,可以避免退出后仍然有任务提交过来。

运行中的线程本身就是GC Root,即使Activity退出了,也会一直空转(BlockingQueue阻塞等待),导致Thread无法正常退出。而每个Thread都会占用1MB的堆内存,重复多次进入该页面,会存在Thread泄露的情况。

当然,也可以把这个线程池做成全局单例的,就不需要shutdown了,这个看具体业务逻辑。

kotlin 复制代码
fun cancel() {
    // 这里清理的是协程任务
    scope.cancel()
    // BlockingQueue不再接受新任务。Thread执行完成之后退出
    executor.shutdown()
}

四、RecyclerView的布局加载优化

优化方案

测试下来,OkLayoutInflater最重要的应用场景是预加载View,而不是异步加载。

**** 对比
预加载 在使用前就初始化View完成,可以直接使用
异步加载 在使用的那一刻,转到异步线程加载,加载完成后,返回给主线程 1.线程的切换需要耗时 2.主线程的线程priority是0,自定义线程优先级较低,会更低优的抢占CPU时间片 3.实测下来,优化效果不稳定,甚至有可能会劣化

在实际应用场景中,我们采用的是预加载+异步加载的方式,提升RecyclerView的绘制效率。对于RecyclerView需要在短时间内创建大量View的场景,OkLayoutInflater的提升非常有效。

实测数据

**** 首帧渲染完成 滚动帧率
传统方式 65ms 45fps
OkLayoutInflater 35ms 52fps

具体实现

在OkLayoutInflater的基础上,封装了一个OkLayoutPool,用于预加载对应的View。这样在业务方使用的时候,可以直接获取,而不是创建。

kotlin 复制代码
class OkLayoutPool {
    companion object {
        // 默认加载数量
        const val PRELOAD_COUNT_DEFAULT = 6
    }

    private val ioInflater: OkLayoutInflater

    private val mainInflater: OkLayoutInflater

    @LayoutRes
    private val resId: Int

    private val concurrentViewPool = ConcurrentLinkedQueue<View>()

    private var preloadCount = PRELOAD_COUNT_DEFAULT
    private var threshold = PRELOAD_COUNT_DEFAULT / 3

    private var parentViewGroup: ViewGroup? = null

    // 是否预加载完成,防止重复预加载
    @Volatile
    private var finished = true

    constructor(context: Context, @LayoutRes resId: Int) {
        // 异步加载队列
        ioInflater = OkLayoutInflater(context)
        // 主线程同步加载队列
        mainInflater = OkLayoutInflater(context)
        this.resId = resId
        bindLifecycle((context as LifecycleOwner).lifecycle)
    }

    // 绑定生命周期,退出时释放资源
    private fun bindLifecycle(lifecycle: Lifecycle) {
        lifecycle.addObserver(object : LifecycleEventObserver {
            override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
                if (event == Lifecycle.Event.ON_DESTROY) {
                    // 释放,防止泄露
                    parentViewGroup = null
                }
            }
        })
    }


    /**
     * 初始化
     * @param parent 必须指定父类
     * @param threshold 再次预加载阈值
     */
    @MainThread
    fun init(parent: ViewGroup, count: Int = PRELOAD_COUNT_DEFAULT, threshold: Int = count / 3) {
        this.threshold = threshold
        this.preloadCount = count
        this.parentViewGroup = parent
    }

    /**
     * 预加载
     */
    @MainThread
    fun preloadViews() {
        // 上一次还未完成
        if (!finished) {
            return
        }

        // 如果没有父ViewGroup,则直接返回,无法预加载
        val parent = this.parentViewGroup ?: return

        this.finished = false

        // 真正执行预加载
        ioInflater.scope().launch {
            for (index: Int in 0..preloadCount) {
                val view = ioInflater.inflate(resId, parent, fallback = false)
                // 创建完成,添加到缓存池中
                view?.let { concurrentViewPool.add(it) }
            }
            finished = true
        }
    }

    /**
     * 用来替换原来的 inflate 方法
     */
    @MainThread
    fun inflate(fallback: (inflater: LayoutInflater, resId: Int) -> View): View {
        val view = concurrentViewPool.poll()
        val remain = concurrentViewPool.size
        // 没有父布局,则无法预加载,走正常加载逻辑
        if (parentViewGroup == null) {
            return fallback(mainInflater.inflater(), resId)
        }

        // 剩余可用View缓存到达阈值,触发预加载
        if (remain <= threshold && finished) {
            preloadViews()
        }
        val v = if (view != null) {
            view
        } else {
            // 如果没有命中缓存,使用原有的方式加载
            val fall = fallback(mainInflater.inflater(), resId)
            fall
        }
        return v
    }
}

使用方式

流程图

相关推荐
我是哪吒5 小时前
分布式微服务系统架构第169集:1万~10万QPS的查当前订单列表
后端·面试·github
庚云5 小时前
前端项目中 .env 文件的原理和实现
前端·面试
jctech5 小时前
ComboLite插件化框架未来开发计划
android·开源
就是帅我不改5 小时前
面试官:单点登录怎么实现?我:你猜我头发怎么没的!
后端·面试·程序员
程序员清风5 小时前
贝壳三面:RocketMQ和KAFKA的零拷贝有什么区别?
java·后端·面试
青鱼入云6 小时前
【面试场景题】1GB 大小HashMap在put时遇到扩容的过程
java·数据结构·面试
huibin1478523696 小时前
dumpsys alarm 简介
android
前端小巷子6 小时前
Vue 项目性能优化实战
前端·vue.js·面试