23、DataBinding与自定义ViewGroup:自定义布局绑定、子View绑定、布局参数绑定、测量与布局。

说实话,很多Android开发者做到自定义ViewGroup这一步就开始头疼了。尤其是当DataBinding掺和进来,感觉就像两套体系在打架。但你别急,我做了这么多年自定义布局,可以负责任地告诉你——DataBinding和自定义ViewGroup其实是天生一对。

为什么这么说?因为自定义ViewGroup最烦的就是手动管理子View的数据绑定和布局参数。DataBinding正好能帮你把这些脏活累活自动化。今天我就带你把这套组合拳打明白。

核心要点:DataBinding在自定义ViewGroup中的价值,在于把「数据驱动布局」的理念贯彻到底。你不需要在代码里写一堆findViewById和setLayoutParams,而是通过绑定表达式直接控制子View的尺寸、位置和行为。

23.1 自定义布局绑定的基本套路

我们先从最基础的开始。假设你要做一个流式布局(FlowLayout),每个子View的宽度由数据决定。传统做法是遍历子View,一个个设置参数。但用DataBinding,你可以这样做:

// FlowLayoutWithBinding.kt
class FlowLayoutWithBinding @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {

    private val binding: LayoutFlowBinding? = null
    // 注意:这里不能直接用DataBindingUtil.inflate
    // 因为ViewGroup的布局是在父容器中决定的

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        // 布局逻辑
    }
}

嗯,这里要注意一个坑。我刚开始用DataBinding做自定义ViewGroup时,总想着在构造函数里就把binding初始化好。结果发现不行——因为自定义ViewGroup的布局文件通常是在父容器的XML里引用的,而不是通过inflate生成的。

正确的做法是:在addView的时候,把DataBinding绑定上去

fun addItem(item: ItemData) {
    val itemBinding = ItemBinding.inflate(
        LayoutInflater.from(context), 
        this, 
        false
    )
    itemBinding.item = item
    itemBinding.executePendingBindings()
    
    // 关键:把binding的root添加到ViewGroup
    addView(itemBinding.root)
}

你看,这样每个子View的布局和数据就通过DataBinding绑死了。你只需要关心数据模型,不用管View怎么创建。

23.2 子View绑定:让每个孩子都有自己的数据

在实际项目中,我遇到过这样一个场景:一个卡片式布局,每个卡片显示不同的用户信息,点击后还要跳转。如果不用DataBinding,你得在Adapter里写一堆ViewHolder逻辑。但用自定义ViewGroup+DataBinding,代码清爽多了。

class CardContainer @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {

    private val childrenBindings = mutableListOf<CardBinding>()

    fun setCards(cards: List<CardData>) {
        removeAllViews()
        childrenBindings.clear()
        
        cards.forEach { cardData ->
            val binding = CardBinding.inflate(
                LayoutInflater.from(context), 
                this, 
                false
            )
            binding.card = cardData
            binding.handler = CardClickHandler()
            binding.executePendingBindings()
            
            childrenBindings.add(binding)
            addView(binding.root)
        }
        
        requestLayout()
    }
}

这里有个小技巧:把binding列表保存下来。为什么?因为有时候你需要动态更新某个子View的数据,直接通过binding列表找到对应的binding,改一下数据就行了,不用重新创建整个View。

个人经验:我习惯在自定义ViewGroup里维护一个binding列表,而不是View列表。因为binding里包含了数据引用,方便做局部刷新。但要注意内存泄漏——在removeView时,记得把对应的binding也从列表里移除。

23.3 布局参数绑定:让DataBinding决定子View的尺寸

这才是重头戏。你想想看,自定义ViewGroup最核心的工作是什么?就是测量和布局。而测量和布局的依据,就是每个子View的LayoutParams。

传统做法是在XML里写死layout_width和layout_height,或者在代码里new LayoutParams。但有了DataBinding,我们可以让布局参数也变成数据驱动的。

// 自定义LayoutParams
class FlexLayoutParams : MarginLayoutParams {
    @BindingAdapter("layout_flexWeight")
    var flexWeight: Float = 0f
    
    @BindingAdapter("layout_flexGrow")
    var flexGrow: Boolean = false
    
    constructor(c: Context, attrs: AttributeSet?) : super(c, attrs) {
        // 从XML解析自定义属性
    }
    
    constructor(width: Int, height: Int) : super(width, height)
}

// 在ViewGroup中重写generateLayoutParams
override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
    return FlexLayoutParams(context, attrs)
}

override fun generateLayoutParams(p: LayoutParams): LayoutParams {
    return FlexLayoutParams(p.width, p.height)
}

然后在XML里,你就可以这样用:

<com.example.FlexLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content">
    
    <TextView
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_flexWeight="1"
        app:layout_flexGrow="true"
        android:text="@{user.name}" />
        
</com.example.FlexLayout>

你看,layout_flexWeight这个属性直接通过DataBinding的BindingAdapter绑定到了LayoutParams上。在onMeasure里,我就可以根据这些参数计算每个子View的宽度。

避坑指南:我曾经犯过一个错误——在BindingAdapter里直接修改LayoutParams的字段,但没有调用requestLayout。结果界面死活不刷新。记住:修改LayoutParams后,一定要调用requestLayout(),否则测量和布局不会重新执行。

23.4 测量与布局:把数据翻译成像素

好了,现在数据有了,LayoutParams也绑定了,接下来就是真刀真枪地干测量和布局了。这部分我建议你画个图理清思路。

DataBinding自定义ViewGroup测量与布局流程 1. 数据绑定 Binding设置数据模型 2. 解析LayoutParams 读取BindingAdapter绑定的参数 3. onMeasure 根据权重计算尺寸 4. onLayout 根据位置参数摆放子View 5. 更新Binding数据 触发DataBinding刷新 6. 子View渲染 子View根据绑定数据显示 数据变化时重新触发流程 核心:数据 → LayoutParams → 测量 → 布局 → 渲染,形成闭环

看明白这个流程了吗?说白了就是:数据决定参数,参数决定尺寸,尺寸决定位置。每一步都环环相扣。

下面我给出一个完整的onMeasure和onLayout示例,结合DataBinding的LayoutParams:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    val widthMode = MeasureSpec.getMode(widthMeasureSpec)
    val widthSize = MeasureSpec.getSize(widthMeasureSpec)
    
    var totalHeight = paddingTop + paddingBottom
    var maxWidth = 0
    
    // 先测量所有子View
    for (i in 0 until childCount) {
        val child = getChildAt(i)
        val lp = child.layoutParams as FlexLayoutParams
        
        // 关键:从LayoutParams读取绑定数据
        val childWidthSpec: Int
        val childHeightSpec: Int
        
        if (lp.flexWeight > 0f && widthMode == MeasureSpec.EXACTLY) {
            // 按权重分配宽度
            val weightedWidth = (widthSize * lp.flexWeight).toInt()
            childWidthSpec = MeasureSpec.makeMeasureSpec(
                weightedWidth - lp.leftMargin - lp.rightMargin,
                MeasureSpec.EXACTLY
            )
        } else {
            childWidthSpec = getChildMeasureSpec(
                widthMeasureSpec,
                paddingLeft + paddingRight + lp.leftMargin + lp.rightMargin,
                lp.width
            )
        }
        
        childHeightSpec = getChildMeasureSpec(
            heightMeasureSpec,
            paddingTop + paddingBottom + lp.topMargin + lp.bottomMargin,
            lp.height
        )
        
        child.measure(childWidthSpec, childHeightSpec)
        
        maxWidth = max(maxWidth, child.measuredWidth)
        totalHeight += child.measuredHeight
    }
    
    setMeasuredDimension(
        resolveSize(maxWidth + paddingLeft + paddingRight, widthMeasureSpec),
        resolveSize(totalHeight, heightMeasureSpec)
    )
}

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    var currentTop = paddingTop
    
    for (i in 0 until childCount) {
        val child = getChildAt(i)
        val lp = child.layoutParams as FlexLayoutParams
        
        val left = paddingLeft + lp.leftMargin
        val top = currentTop + lp.topMargin
        val right = left + child.measuredWidth
        val bottom = top + child.measuredHeight
        
        child.layout(left, top, right, bottom)
        currentTop = bottom + lp.bottomMargin
    }
}

我的习惯:在onMeasure里,我喜欢加一个日志开关。调试时打开,看看每个子View的LayoutParams里的flexWeight值对不对。因为有时候BindingAdapter没生效,权重全是0,布局就全乱了。加一行Log.d就能省去半小时的排查时间。

23.5 实战:一个完整的DataBinding自定义ViewGroup

最后,我把上面所有的知识点串起来,给你一个可以直接用的模板。这是一个支持权重分配的线性布局,所有子View的宽度由数据驱动。

// WeightLinearLayout.kt
class WeightLinearLayout @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {

    private val bindingList = mutableListOf<ViewDataBinding>()

    fun bindItems(items: List<WeightItem>) {
        removeAllViews()
        bindingList.clear()
        
        items.forEach { item ->
            val binding = ItemWeightBinding.inflate(
                LayoutInflater.from(context), 
                this, 
                false
            )
            binding.item = item
            binding.executePendingBindings()
            
            // 设置LayoutParams
            val lp = WeightLayoutParams(
                LayoutParams.WRAP_CONTENT,
                LayoutParams.WRAP_CONTENT
            )
            lp.weight = item.weight
            binding.root.layoutParams = lp
            
            bindingList.add(binding)
            addView(binding.root)
        }
        
        requestLayout()
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // ... 测量逻辑,参考上面的示例
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        // ... 布局逻辑,参考上面的示例
    }
}

// 数据模型
data class WeightItem(
    val weight: Float,
    val text: String,
    val backgroundColor: Int
)

// 自定义LayoutParams
class WeightLayoutParams : MarginLayoutParams {
    var weight: Float = 0f
    
    constructor(width: Int, height: Int) : super(width, height)
    constructor(c: Context, attrs: AttributeSet?) : super(c, attrs)
}

你看,整个流程下来,代码量其实不多。但每个环节都踩过坑——我最早做这个的时候,忘了在bindItems里调用executePendingBindings(),结果数据死活不显示。后来养成习惯:每次设置完数据,立刻调用executePendingBindings()

DataBinding和自定义ViewGroup的结合,本质上就是把「布局逻辑」和「数据逻辑」解耦。你只需要关注数据怎么来、参数怎么算,剩下的测量和布局交给框架。这才是现代Android开发的正确姿势。


公众号:蓝海资料掘金营,微信deep3321