View ID 的同名复用

R.id.root_container 是资源符号,不是某个 View 的对象地址。同一个 ID 可以出现在多个布局实例中;findViewById() 只会从指定的搜索根开始,在它的子树里返回第一个匹配对象。

<!-- layout/component_a.xml -->
<FrameLayout android:id="@+id/root_container" />
 
<!-- layout/component_b.xml -->
<FrameLayout android:id="@+id/root_container" />

@+id/name 确保资源符号存在,@id/name 引用已有符号。资源合并后,在同一个最终资源命名空间中,相同的 type/name 只有一个 R.id 条目。两个布局写同名 ID,不会为两个 XML 标签分别生成两个资源 ID。

但布局每 inflate 一次,就会创建一组新的 View 对象:

val first = inflateItem().findViewById<View>(R.id.root_container)
val second = inflateItem().findViewById<View>(R.id.root_container)
 
first.id == second.id // true:资源符号相同
first === second     // false:运行时对象不同

列表、ViewPager、多个 Fragment 和重复 inflate 场景中,复用的是资源标识,不是 View 实例。

同名 View ID 从资源符号到运行时实例

findViewById 的搜索作用域

root.findViewById<T>(id)

这里的 root 决定了搜索范围。搜索会先检查 root 自身,再按 View 树的遍历顺序递归;第一个匹配返回后就停止。它不是全局索引,也不知道调用方“想要哪个组件”。

// 只在当前 item 内查找,结果属于这个 item
val itemContainer = itemView.findViewById<ViewGroup>(R.id.root_container)
 
// 搜索整个窗口,可能命中其他 item 或组件中的同名 View
val windowContainer = activity.window.decorView
    .findViewById<ViewGroup>(R.id.root_container)

<ViewGroup> 只约束返回值能否转换成这个类型,不能保证它是预期的那个实例,也不能保证具体是 RelativeLayout 还是 ConstraintLayout

搜索根越大,重复 ID 的歧义越大。实际代码应优先使用当前 item、Fragment 根或组件根;已经持有目标对象时,直接使用对象引用。View Binding 的作用也是固定搜索根并复用这份引用,它不会让 ID 变成全局唯一。

RecyclerView 中的典型表现

RecyclerView 是高发场景,但问题不在 ViewHolder 复用本身。同一个 item 布局会被 inflate 多次,多个已挂载的 itemView 会同时带有相同的一组 ID。

RecyclerView 中 findViewById 的搜索作用域

图中业务目标是 Item B。以 holder.itemView 为根时,搜索范围内只有实例 B;从 decorView 开始时,范围内同时存在 A、B、C,返回值取决于 View 树的遍历顺序。

相同 viewType 的容器类型通常一致,拿错实例更常表现为 View 跑到其他 item、状态串位或当前 item 缺少内容,不一定触发异常。多个 viewType 或不同组件复用同名 ID,且实际容器类型不同,再叠加动态 reparent 时,才更容易出现 LayoutParams ClassCastException

ViewPager2、同一 Activity 中的多个 Fragment、重复 <include> 也有相同风险:只要同名 View 同时存在于一个过大的搜索子树中,findViewById() 就可能返回业务上不属于当前组件的实例。

组件 API 应传递对象

下面这种接口把调用方的上下文丢掉了:

fun attach(activity: Activity, containerId: Int) {
    val container = activity.window.decorView
        .findViewById<ViewGroup>(containerId)
    container.addView(this)
}

组件只知道一个整数,不知道这个 ID 属于哪个页面、哪个 item,也不知道窗口里是否还有其他同名实例。让调用方先完成查找,再传入已确认的对象:

fun View.attachTo(container: ViewGroup) {
    container.addView(this)
}
 
dynamicView.attachTo(itemBinding.rootContainer)

如果只能传 ID,也应由调用方在最小且明确的子树中完成查找,再把查找结果传入组件,而不是让组件从 decorView 重查。

附带问题:LayoutParams 类型错误

拿错容器后继续重挂 View,或换 parent 时操作顺序错误,才可能进一步出现:

RelativeLayout.LayoutParams cannot be cast to
ConstraintLayout.LayoutParams

LayoutParams 描述的是 child 与当前 parent 的布局契约,实际类型由 parent 决定。换父容器时,先解除旧关系,再建立新关系:

val oldParent = child.parent as? ViewGroup
oldParent?.removeView(child)
 
// 这里假定 newParent 是 RelativeLayout
child.layoutParams = RelativeLayout.LayoutParams(width, height)
newParent.addView(child)

如果新 parent 类型不固定,不要在通用 ViewGroup 方法里硬编码 RelativeLayout.LayoutParams;由具体 parent 或调用方提供兼容参数。inflate(layout, parent, false) 也应传入真实 parent,这样根 View 能获得对应 parent 的参数类型。

排查要点

排查时先确认搜索根是当前 item、组件根还是整个 decorView,再比较 found === expected。只比较 id 只能证明资源符号相同,不能证明对象相同。

需要定位运行时实例时,同时记录 View 类名、对象 identity 和 parent 类名。拿到目标 ViewViewGroup 后,沿调用链传递对象引用,不再扩大搜索范围。


相关笔记