RecyclerView 的 onCreateViewHolder 里那一行 LayoutInflater.from(context).inflate(R.layout.item_feed, parent, false) 看起来人畜无害,但在复杂列表首屏加载时,它可能是最大的帧耗时来源。一个包含 ConstraintLayout + 嵌套 ImageView/TextView 的 item 布局,单次 inflate 耗时 8~30ms 并不罕见。首屏 5 个 item 就是 40~150ms,直接吃掉两三帧。

问题的根源在于 LayoutInflater.inflate() 做的事情太重了:解析编译后的二进制 XML、通过反射逐个实例化 View、递归构建整棵 View 树、生成并应用 LayoutParams。这些步骤每次 inflate 都要完整走一遍,即使布局结构完全相同。

FastInflater 的核心思路很简单:既然同一个 layoutId 每次 inflate 出来的 View 树结构一样,那为什么不把用完的 View 清理干净放回池里,下次直接取出来用?池命中时,inflate 耗时从几十毫秒变成一次 ConcurrentLinkedDeque.poll() —— 本质上是零。

架构总览

FastInflater 由六个核心组件构成:

┌─────────────────────────────────────────────────────┐
│                   FastInflater                        │
│  (单例入口,生命周期监听,内存压力响应)                    │
├─────────────────────────────────────────────────────┤
│  ViewPool          │  InflateTracker   │  PoolStats  │
│  (池化存取/预热)    │  (耗时追踪)        │  (命中率)    │
├─────────────────────────────────────────────────────┤
│  ViewCleaner       │  ViewRecyclePolicy │ PoolableView│
│  (状态清理)         │  (自定义策略)       │ (自清理接口) │
└─────────────────────────────────────────────────────┘

调用链非常短:inflate() 先查池,命中直接返回;未命中走标准 LayoutInflaterrecycle() 清理 View 状态后入队。预热在后台线程或主线程 IdleHandler 中提前创建 View 填池。

池键设计:一个 Long 搞定隔离

View 池的 key 是一个 Long,高 32 位是 layoutId,低 32 位是隔离 hash:

private fun keyFor(@LayoutRes layoutId: Int, context: Context): Long {
    val high = layoutId.toLong() shl 32
    if (!hostIsolation && !factoryIsolation) return high
    var low = 0
    if (hostIsolation) low = hostHash(context)
    if (factoryIsolation) low = low xor factoryHash(context)
    return high or (low.toLong() and 0xFFFFFFFFL)
}

这个设计有几个好处。默认情况下(两种隔离都关),低 32 位为 0,key 退化为纯 layoutId,所有 context 共享同一个桶——这是绝大多数项目的最优选择,因为全局 theme 一致。需要区分不同 Activity theme 时开启 hostIsolation,key 会包含 Activity 的 identityHashCode;需要区分不同 LayoutInflater.Factory2 时开启 factoryIsolation。用 Long 做 key 避免了每次 obtain/recycle 分配对象,对 GC 友好。

底层数据结构是 ConcurrentHashMap<Long, ConcurrentLinkedDeque<View>>ConcurrentLinkedDeque 支持无锁并发读写,obtain 从头部 poll,recycle 从尾部 offer,天然适合池化场景。

LayoutParams 兼容性检查

池化复用有一个容易忽略的问题:同一个 layoutId 可能被 inflate 到不同类型的 parent 中。一个 item_feed.xml 的根节点如果带 layout_weight,它的 LayoutParams 是 LinearLayout.LayoutParams;但如果下次 obtain 时 parent 是 FrameLayout,直接 addView 会抛异常或布局错乱。

FastInflater 在 obtain 时做了兼容性检查:

private fun pollCompatible(key: Long, layoutId: Int, context: Context, parent: ViewGroup?): View? {
    val deque = pool[key] ?: return null
    val rejected = ArrayList<View>(4)
    var found: View? = null

    for (i in 0 until deque.size) {
        val view = deque.poll() ?: break
        if (ensureAttachableToParent(layoutId, context, parent, view)) {
            found = view
            break
        }
        rejected.add(view)
    }
    // 不兼容的 View 放回队尾
    for (view in rejected) deque.offerLast(view)
    return found
}

如果池中 View 的 LayoutParams 与当前 parent 不兼容,FastInflater 会重新解析布局 XML 的根节点属性,用 parent.generateLayoutParams() 生成正确的 LayoutParams。兼容性结果会被缓存到 layoutParamsCompatibility map 中,避免重复反射。

异步预热与自动降级

预热是 FastInflater 性能收益的关键——如果池里没有 View,第一次 inflate 还是要走标准路径。预热策略分两层:

后台线程预热:默认使用一个固定大小的线程池(availableProcessors - 1,最少 2 线程)在后台 inflate。这里有一个 Android 历史坑:Android 8.x 及以下的 Resources 不是线程安全的,所以 FastInflater 自身的 inflateAsync 用单线程 executor 避免并发问题,而 ViewPool 的预热用多线程是因为预热时 parent 为 null,不涉及 Resources 的并发读。

主线程 IdleHandler 降级:部分 View 不能在后台线程创建——ComposeView 内部依赖 LiveDataWebView/SurfaceView/TextureView 要求主线程,某些自定义 View 在构造函数里访问 HandlerLooper。FastInflater 的处理策略是:

  1. 预热前先扫描布局 XML 的 tag,如果包含已知的主线程组件(WarmUpFallbackClassifier 维护了一个白名单),直接走主线程 IdleHandler
  2. 未知组件仍尝试后台 inflate,如果抛出主线程依赖异常(通过异常消息模式匹配识别),自动标记该布局为 mainThreadOnly,剩余预热数量转移到主线程
  3. 同时从异常堆栈中提取出问题 View 的类名,加入 mainThreadOnlyViewClasses 集合,后续其他布局如果包含同一个类也会直接走主线程
// WarmUpFallbackClassifier 的异常识别逻辑
fun isMainThreadDependencyFailure(error: Throwable): Boolean {
    return error.causeSequence().any { cause ->
        val message = cause.message ?: return@any false
        message.contains("Can't create handler inside thread") ||
            message.contains("has not called Looper.prepare()") ||
            message.contains("must be called on the main thread", ignoreCase = true)
    }
}

主线程 IdleHandler 每次 idle 只 inflate 1 个 View,避免长时间占用主线程影响用户交互。这是一个「宁可慢一点预热完,也不能卡用户」的设计取舍。

View 状态清理:复用的安全边界

池化复用最容易出 bug 的地方不是取和存,而是「清理不干净」。上一条数据的头像、文字、点击状态如果残留到下一条,就是肉眼可见的 UI 错乱。

ViewCleaner 在 View 入池时递归清理整棵 View 树:

private fun cleanSingle(view: View) {
    if (view is PoolableView) view.onRecycleForPool()

    view.setOnClickListener(null)
    view.setOnLongClickListener(null)
    view.setOnTouchListener(null)
    view.translationX = 0f; view.translationY = 0f; view.translationZ = 0f
    view.scaleX = 1f; view.scaleY = 1f
    view.alpha = 1f
    view.clearAnimation()
    view.visibility = View.VISIBLE
    view.isEnabled = true
    view.isSelected = false
    view.contentDescription = null

    if (view is TextView) view.text = null
    else if (view is ImageView) view.setImageDrawable(null)
}

注意几个设计决策:

不清除 tag:DataBinding 依赖 view.tag 存储 binding 信息,清除会导致 DataBindingUtil.bind() 返回 null。这是 FastDataBinding 能工作的前提。

归一化到「可见/可用」默认值visibility 归为 VISIBLEisEnabled 归为 true。这意味着如果布局 XML 中某个子 View 默认是 GONE(比如一个占位 View),复用后它会变成 VISIBLE——业务 bind 阶段必须显式设置。这是一个「宁可多设置一次,也不能漏清理」的策略。

PoolableView 接口:自定义 View 可以实现这个接口,ViewCleaner 递归时会调用 onRecycleForPool(),让 View 自己清理内部业务状态(展开/折叠标记、临时 Drawable 等)。这比全局 ViewRecyclePolicy 更内聚——状态归谁管,清理就归谁做。

生命周期安全模型

池化复用引入了一个微妙的生命周期问题:如果一个自定义 View 在构造或 attach 时注册了 EventBus、Lifecycle observer、Activity callback,进入池后这些注册关系可能继续存活。View 被另一个 Activity 取出复用时,旧的注册关系指向已销毁的 Activity,轻则收到无意义的事件,重则 NPE 崩溃。

FastInflater 提供了三层防护:

第一层:Activity 销毁时清池。通过 ActivityLifecycleCallbacks 监听 onActivityDestroyed,调用 viewPool.clearForHost(activity)。如果开启了 hostIsolation,只清除该 Activity 对应的桶;否则清空整个池。这保证池中不会长期持有已销毁 Activity 的 context。

第二层:按布局关闭池化。对于确实无法可靠解绑的布局,直接 setPoolingEnabled(layoutId, false)。关闭后该 layout 的 obtain 永远返回 null,recycle 直接丢弃,warmUp 跳过。布局仍然通过标准 LayoutInflater 创建,只是不参与池化。

第三层:内存压力响应。通过 ComponentCallbacks2 监听系统内存压力:TRIM_MEMORY_MODERATE 及以上清空池,TRIM_MEMORY_BACKGROUND 每个桶只保留 1 个。Configuration 变化(如旋转屏幕)也会清空池,因为旧 View 的尺寸/资源可能已失效。

自适应池大小

池太小,命中率低,预热白做;池太大,内存浪费,低频布局占着坑不用。FastInflater 的 autoTune 根据运行时统计数据自动调整:

fun autoTune(topN: Int = 20, minSize: Int = 2, maxSize: Int = 12) {
    val top = InflateTracker.topN(topN)
    if (top.isEmpty()) return
    val maxCount = top.first().second.count.get()

    top.forEach { (layoutId, stat) ->
        val count = stat.count.get()
        val suggested = (count.toFloat() / maxCount * (maxSize - minSize) + minSize)
            .toInt().coerceIn(minSize, maxSize)
        perLayoutMaxSize[layoutId] = suggested
    }
}

逻辑很直接:取 inflate 次数最多的前 N 个布局,按使用频率线性映射到 [minSize, maxSize] 区间。高频布局拿到更大的池,低频布局保持最小值。建议在应用运行 3~5 分钟后调用一次,此时统计数据已经能反映真实使用模式。

诊断体系:InflateTracker + PoolStats

FastInflater 不只是一个优化工具,它首先是一个诊断工具。InflateTracker 记录每次 inflate 的纳秒级耗时,按 layoutId 聚合:

inline fun <T> track(@LayoutRes layoutId: Int, block: () -> T): T {
    if (!enabled) return block()
    val start = System.nanoTime()
    val result = block()
    recordInflate(layoutId, System.nanoTime() - start)
    return result
}

PoolStats 记录全局和 per-layout 的 hit/miss 次数。两者配合使用:先用 InflateTracker 找到耗时最高的布局,再看 PoolStats 判断池化是否生效。命中率低于 50% 说明预热不足或池太小;高于 90% 说明池化充分发挥作用。

两个埋点默认开启,因为诊断数据本身就是这个库的核心价值。调优完成后可以一键关闭:

FastInflater.get().setMetricsEnabled(false)

关闭后热路径上的 System.nanoTime()、原子自增、HashMap 查询全部消除,inflate 只保留池查询和回退 inflate 的最小逻辑。

与 RecyclerView 的协作边界

RecyclerView 自己有一套完整的 ViewHolder 回收复用机制(Scrap → Cache → RecycledViewPool)。FastInflater 不试图替代它,而是只在「创建侧」发力:

RecyclerView 需要新 ViewHolder
    → RecycledViewPool 为空
        → Adapter.onCreateViewHolder()
            → FastInflater.get().inflate(parent, viewType)
                → 池命中?直接返回 : 标准 LayoutInflater

FastInflaterRecycler.install() 做两件事:设置一个 FastRecycledViewPool(实际上只是标记,回收逻辑完全交给 super),然后触发预热。预热的 View 进入 FastInflater 的池,当 onCreateViewHolder 调用 FastInflater.get().inflate() 时命中。ViewHolder 创建后的滑动复用完全由 RecyclerView 自己管理,两个池不会冲突。

这个设计的好处是侵入性极低:只需要在 onCreateViewHolder 里把 LayoutInflater.from(context).inflate(...) 换成 FastInflater.get().inflate(...),其他代码不用动。

适用场景与局限

FastInflater 最适合这些场景:

  • RecyclerView 密集列表的首屏加载(ViewHolder 首次创建是最大瓶颈)
  • Tab 切换、ViewPager 页面切换中反复创建相同布局
  • Dialog/BottomSheet 反复弹出关闭
  • 任何「同一个 layoutId 被高频重复 inflate」的地方

不适合的场景:

  • 已全面迁移到 Jetpack Compose 的项目(Compose 没有 inflate 概念)
  • 布局极简(单个 TextView),inflate 本身只要 1~2ms,池化收益不明显
  • 布局包含大量生命周期敏感组件且无法可靠解绑,关闭池化后等于没用

总结

FastInflater 的技术路线可以概括为:用空间换时间,用预热换首帧。它不是银弹——池化引入了状态清理的复杂度,异步预热引入了线程安全的考量,生命周期管理引入了额外的防护层。但在它适用的场景里(高频重复 inflate 的 XML 布局),收益是确定性的:池命中时 inflate 耗时为零。

对于仍在维护大量 XML 布局的 Android 项目,这可能是投入产出比最高的性能优化手段之一。