34、自定义ViewGroup:onMeasure、onLayout、自定义流式布局、标签布局

说实话,自定义ViewGroup这个话题,很多Android开发者一听就头大。我当年刚入行时也一样,总觉得系统提供的LinearLayout、RelativeLayout够用了,干嘛要自己造轮子?直到我在一个电商项目里遇到了标签云的需求——一堆长度不等的标签要像流水一样自动换行排列,用LinearLayout嵌套?那性能简直惨不忍睹。

嗯,从那天起,我就老老实实啃起了onMeasure和onLayout。今天咱们就来聊聊怎么手写一个流式布局(FlowLayout),说白了就是让子View像文字一样,排满一行就自动换行。

核心流程:onMeasure和onLayout的分工

自定义ViewGroup的核心就两个方法:onMeasure负责测量,onLayout负责摆放。你可以这么理解——onMeasure是量尺寸,onLayout是摆位置。量错了,摆出来肯定歪;量对了,摆起来就顺手。

我个人的习惯是,先理清楚布局逻辑再动手写代码。流式布局的逻辑其实很简单:

  • 从左到右依次摆放子View
  • 如果当前行剩余宽度放不下下一个子View,就换行
  • 每一行的高度取该行所有子View的最大高度
  • 最终整个ViewGroup的高度是所有行高度之和

你想想看,这不就跟文字排版里的「自动换行」一个道理吗?

第一步:onMeasure——先量清楚再干活

onMeasure里要做两件事:测量所有子View的尺寸,然后计算出ViewGroup自己应该有多大。这里有个坑——必须先调用measureChild或measureChildWithMargins,否则子View的尺寸是0,后面布局全乱套。

我曾经在项目里犯过这个错,直接拿子View的getMeasuredWidth(),结果全是0,调试了半天才发现是忘了先测量。嗯,说多了都是泪。

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);

    int widthUsed = getPaddingLeft() + getPaddingRight();
    int heightUsed = getPaddingTop() + getPaddingBottom();

    int lineWidth = 0;
    int lineHeight = 0;
    int totalHeight = 0;

    for (int i = 0; i < getChildCount(); i++) {
        View child = getChildAt(i);
        if (child.getVisibility() == View.GONE) continue;

        // 测量子View,注意要带上父容器的MeasureSpec
        measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0);

        MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
        int childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
        int childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;

        // 如果当前行放不下,换行
        if (lineWidth + childWidth > widthSize - widthUsed) {
            totalHeight += lineHeight;
            lineWidth = 0;
            lineHeight = 0;
        }

        lineWidth += childWidth;
        lineHeight = Math.max(lineHeight, childHeight);
    }

    totalHeight += lineHeight;
    totalHeight += getPaddingTop() + getPaddingBottom();

    // 处理EXACTLY模式
    setMeasuredDimension(
        resolveSize(widthSize, widthMeasureSpec),
        resolveSize(totalHeight, heightMeasureSpec)
    );
}

这里有个细节要注意:换行时要把当前行的高度累加,然后重置行宽和行高。我刚开始写的时候忘了累加,结果所有子View都堆在第一行,画面简直不忍直视。

第二步:onLayout——把子View摆到正确位置

测量完了,接下来就是摆放。onLayout的逻辑和onMeasure基本一致,只是这次我们要真正设置子View的坐标。

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    int paddingLeft = getPaddingLeft();
    int paddingTop = getPaddingTop();
    int paddingRight = getPaddingRight();

    int currentX = paddingLeft;
    int currentY = paddingTop;
    int lineHeight = 0;

    for (int i = 0; i < getChildCount(); i++) {
        View child = getChildAt(i);
        if (child.getVisibility() == View.GONE) continue;

        MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
        int childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
        int childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;

        // 检查是否需要换行
        if (currentX + childWidth > getWidth() - paddingRight) {
            currentX = paddingLeft;
            currentY += lineHeight;
            lineHeight = 0;
        }

        // 摆放子View,注意要加上margin
        int left = currentX + lp.leftMargin;
        int top = currentY + lp.topMargin;
        int right = left + child.getMeasuredWidth();
        int bottom = top + child.getMeasuredHeight();
        child.layout(left, top, right, bottom);

        currentX += childWidth;
        lineHeight = Math.max(lineHeight, childHeight);
    }
}

说白了,onLayout就是不断累加x坐标,超出宽度就换行并累加y坐标。这里有个小技巧——用currentX和currentY作为游标,比用临时变量清晰得多。

第三步:支持Margin——别忘了LayoutParams

如果你想让流式布局支持子View的margin,必须重写generateLayoutParams方法。我见过不少新手直接new MarginLayoutParams,结果发现margin根本没生效。

@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
    return new MarginLayoutParams(getContext(), attrs);
}

@Override
protected LayoutParams generateDefaultLayoutParams() {
    return new MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
}

@Override
protected LayoutParams generateLayoutParams(LayoutParams p) {
    return new MarginLayoutParams(p);
}

这三个方法缺一不可。尤其是generateDefaultLayoutParams,如果你在XML里没给子View设置layout_width/layout_height,系统会调用这个方法生成默认参数。不重写的话,默认是ViewGroup.LayoutParams,没有margin属性。

避坑指南:我踩过的几个雷

我曾经踩过的坑,你千万别再踩了:

  • 忘了调用measureChild:子View的getMeasuredWidth()永远是0,布局全乱。
  • 换行时没重置lineHeight:新行的行高继承了上一行,导致间距异常。
  • 没处理padding:子View直接贴边,看起来像没穿裤子。
  • 在onLayout里再次测量子View:onLayout只负责摆放,不要重复测量,否则性能堪忧。

知识体系一览

下面这张图是我梳理的流式布局核心逻辑,你看一眼就能明白整个流程:

流式布局核心流程 开始自定义ViewGroup onMeasure 测量所有子View,计算自身宽高 遍历子View,调用measureChildWithMargins 判断是否换行,累加行高 setMeasuredDimension 设置最终尺寸 onLayout 遍历子View,计算坐标并调用child.layout()

实战场景:标签布局

流式布局最常见的应用场景就是标签布局。比如搜索历史标签、商品分类标签、用户兴趣标签等等。我做过一个项目,标签数量从几个到几十个不等,用流式布局完美解决。

核心要点:

  • 每个标签用TextView,背景用Shape或Selector实现点击效果
  • 标签之间用margin控制间距,不要用padding,否则点击区域会重叠
  • 如果标签数量太多,建议加上最大行数限制,超出部分用「展开」按钮

我的小技巧:在onLayout里可以加一个回调接口,通知外部当前布局了多少行、每行有哪些子View。这样外部可以做一些特殊处理,比如给最后一行添加「更多」按钮。

性能优化:别让测量成为瓶颈

流式布局在子View数量不多时性能没问题,但如果子View超过100个,onMeasure里的循环就会成为瓶颈。我建议:

  • 复用测量结果:如果子View的尺寸固定,可以缓存起来,避免重复测量
  • 使用RecyclerView+自定义LayoutManager:如果子View数量巨大,直接用RecyclerView的流式LayoutManager,性能更好
  • 避免在onMeasure里做耗时操作:比如不要加载网络图片、不要解析XML

说白了,自定义ViewGroup的核心就是理解onMeasure和onLayout的协作关系。你只要把「测量」和「摆放」这两个步骤想清楚,剩下的就是细节问题了。嗯,希望今天的分享能帮你少走一些弯路。


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