深入 Android FlexboxLayout 全链路:从 CSS Flexbox 算法到 RecyclerView 自适应流式布局引擎

去年接手一个电商 App 的搜索页改造,需求是在历史搜索下方展示动态标签云。标签数量不固定,宽度各异,需要自动换行排列。第一版用 GridLayout 硬算每个 item 的列位置,数据量超过 50 个时滑动手势开始丢帧。

那时 FlexboxLayout 已经在 support library 里存在了两年,我一直拿 CSS Flexbox 的直觉去用它,直到排查了一个 flex-shrink 收缩比例 bug 才发现:Android 的 FlexboxLayout 不是 CSS 的简单移植,而是在 View 测量体系上对 W3C 规范做了逐行映射。

W3C 规范到 measure/layout 双阶段的映射

CSS Flexbox 规范(W3C Candidate Recommendation, 2016)定义了一套方向无关的布局模型。Google 工程师把它引入 Android 时面临一个问题:CSS 的布局计算是声明式、一次性的,而 Android View 系统必须经过 measure 和 layout 两个阶段,子 View 还可能被多次测量。

主轴与交叉轴的方向建模

FlexboxLayout 用 @IntDef 注解抽象了 CSS 的 flex-direction 和 flex-wrap:

@IntDef({FLEX_DIRECTION_ROW, FLEX_DIRECTION_ROW_REVERSE,
         FLEX_DIRECTION_COLUMN, FLEX_DIRECTION_COLUMN_REVERSE})
public @interface FlexDirection {}

@IntDef({FLEX_WRAP, FLEX_WRAP_REVERSE, NOWRAP})
public @interface FlexWrap {}

测量阶段的核心逻辑分流到 measureHorizontal() 和 measureVertical() 两个分支。以水平方向 FLEX_DIRECTION_ROW 为例,FlexboxHelper 中换行判断的核心思路用简化伪代码表达如下(非源码,仅用于说明结构):

// 以下为简化示意伪代码,非 FlexboxHelper 源码,具体实现要复杂得多(需处理 margin、flexBasisPercent、maxLine 等多重边界情况)
for (int i = 0; i < childCount; i++) {
    View child = getChildAt(i);
    measureChildWithMargins(child, widthMeasureSpec, 0, 
                            heightMeasureSpec, 0);
    if (currentLineWidth + childWidth > containerWidth) {
        flexLines.add(new FlexLine()); // 结算当前行
        currentLineWidth = 0;
    }
    currentLineWidth += childWidth;
    maxLineHeight = Math.max(maxLineHeight, childHeight);
}

这段代码对应 CSS 中 flex-wrap: wrap 的核心语义:主轴空间不足时触发换行,同时记录每行最大高度供交叉轴对齐使用。

flex-grow 分配算法的 Android 实现

CSS 规范对剩余空间分配的定义是:

The flex grow factor is multiplied by the flex base size to distribute free space.

在 Android 中,“分配剩余空间”需要转化为对子 View 的 measure 参数调整。FlexboxLayout 的做法是先测量所有子 View 得到基础尺寸,再按 flex 属性二次分配(下面仍为简化示意伪代码,非源码):

float totalGrow = 0;
for (int i = 0; i < flexLine.itemCount; i++) {
    totalGrow += orderItems[i].flexGrow;
}
if (totalGrow > 0 && remainingSpace > 0) {
    for (int i = 0; i < flexLine.itemCount; i++) {
        float ratio = orderItems[i].flexGrow / totalGrow;
        int extra = (int) (remainingSpace * ratio);
        measureChildWithMargins(child,
            MeasureSpec.makeMeasureSpec(childWidth + extra, EXACTLY), ...);
    }
}

一个容易踩的细节:**flex-shrink 的分母不是 flexShrink 值的总和,而是 flexShrink × flexBasis 的加权总和。**CSS 规范 9.7 节对此有明确定义,但 FlexboxLayout 的早期版本实现有偏差,导致收缩比例与浏览器渲染结果不一致。这个问题在 1.1.0 版本通过对照 W3C 规范的逐行 review 修复,commit message 里甚至引用了规范的具体章节号。

justify-content 和 align-items 的映射相对直接——它们不改变子 View 的测量尺寸,只在 layout 阶段调整位置。FLEX_START 映射为从 0 起始,FLEX_END 映射为从 containerSize - lineSize 起始,CENTER 取中值,SPACE_BETWEEN 和 SPACE_AROUND 按 CSS 规范计算间距分配。

FlexboxLayoutManager:把算法嫁接到 RecyclerView

FlexboxLayout 解决了静态布局,但标签云场景需要应对大数据量和 RecyclerView 的回收复用。2017 年发布的 FlexboxLayoutManager 本质上是将 FlexboxLayout 的测量算法嫁接到 RecyclerView 的 LayoutManager 框架上。

LayoutManager 要求实现两个核心方法:

public abstract LayoutParams generateDefaultLayoutParams();
public void onLayoutChildren(Recycler recycler, State state);

FlexboxLayoutManager.onLayoutChildren() 做的第一件事不是测量所有 item,而是根据当前滚动偏移量确定可见范围——这个步骤在 FlexboxLayout 里不存在。下面用简化示意伪代码表达其内部思路(非源码,实际实现还需处理预获取、回收池、反向滚动等细节):

// 简化示意伪代码,非 FlexboxLayoutManager 源码
void onLayoutChildren(Recycler recycler, State state) {
    detachAndScrapAttachedViews(recycler);
    int firstVisibleLine = findFirstVisibleLine(scrollY);
    for (int i = firstVisibleLine; i < totalLines; i++) {
        if (lineTop > getHeight()) break;
        layoutFlexLine(flexLines.get(i), recycler, state);
    }
}

1000 个标签的流式布局,实际只会 layout 屏幕上可见的十几行,回收复用完全由 RecyclerView 框架兜底。

FlexboxLayoutManager 的一个设计陷阱是 flexWrap 方向与 RecyclerView 滚动方向的耦合。设置 flexWrap = WRAP 且水平滚动时,会形成”水平排列 + 垂直换行 + 水平滚动”的三维组合,用户预期与实际行为不一致。我的建议:流式标签场景用垂直 RecyclerView + flexWrap = WRAP,这是绝大部分业务场景的最优解。真正需要横向滚动的多行布局(比如 emoji 选择器),用 RecyclerView.HORIZONTAL 时务必保证每行高度一致,否则会出现测量回弹。

踩过的三个坑

坑一:flexBasisPercent 依赖父容器宽度已知。 在 RecyclerView 的 onMeasure 完成之前使用会得到错误值。解决方式是只在 layout 阶段依赖百分比基准,避免在 onCreateViewHolder 里读取 parent.width。

坑二:order 属性的排序开销。 FlexboxLayout 每次测量对子 View 按 order 排序,时间复杂度 O(n log n)。在一个实时标签更新场景中,我把 order 排序前置到数据层,layout 直接用有序列表,首帧渲染时间从 80ms 降到了 35ms。

坑三:alignSelf = BASELINE 的联动效应。 同行某元素设置基线对齐后,如果它同时有 flexGrow > 0,主轴尺寸变化会间接影响交叉轴位置,产生”对齐抖动”。规避方式是尽量用 CENTER 或 STRETCH 替代 BASELINE。

选型决策框架

日常开发中,我用这个快速判断流程:

  • 静态少量标签(<20 个):直接用 FlexboxLayout,省去 RecyclerView 的模板代码
  • 动态数据、支持增删:FlexboxLayoutManager + RecyclerView,配合 DiffUtil 做增量更新
  • 需要 header/footer 或分组:FlexboxLayoutManager 本身支持 layout_flexBasisPercent,可以把某个 item 的 flexBasisPercent 设为 100% 来让它占据整行(相当于强制换行),这才是 FlexboxLayoutManager 真正支持的机制。注意 SpanSizeLookup 是 GridLayoutManager 专属的类,对 FlexboxLayoutManager 无效,不能把两者混用。如果 header/footer 需要更复杂的排列控制,可以结合 ConcatAdapter 把 header 、footer 和正文拆成不同的 Adapter 分区处理。

FlexboxHelper 那 1500 行测量逻辑是 CSS 规范与 Android View 体系碰撞出的产物,经历过无数次 bug fix 的打磨。下次遇到流式布局需求,先翻翻它——大概率已经替你踩完了坑。