Flutter 征战鸿蒙 NEXT:万字死磕 Flex 弹性布局,彻底搞懂 Expanded 与 Flexible 底层魔法

文章目录
前言:揭开 Row 与 Column 的真实面纱
在跨平台 UI 开发中,Row 和 Column 是我们最熟悉的“搬砖工具”。但在源码层面,如果你点开 Row 或 Column 的定义,你会发现它们自身几乎没有实现任何布局逻辑。它们仅仅是继承自同一个父类——Flex,并在构造函数中硬编码了 direction(主轴方向)参数。
// Flutter 源码缩略版
class Row extends Flex {
Row({...}) : super(direction: Axis.horizontal, ...);
}
class Column extends Flex {
Column({...}) : super(direction: Axis.vertical, ...);
}
这意味着,真正统治线性布局引擎的,是 Flex 及其配套的弹性控制组件(Flexible 和 Expanded)。在鸿蒙原生的 ArkUI 中,同样存在 Flex 组件,其底层的 C++ 布局引擎(基于 Yoga 或自研 Webkit 衍生引擎)在测算逻辑上与 Flutter 的 RenderFlex 异曲同工。
本文将基于上述的 Flutter 源码,带你打穿 Flex 布局的底层算法,彻底厘清 tight 与 loose 约束的本质区别。
🧮 一、 弹性空间分配算法:Flex 系数的数学奥秘
在源码的第二部分,作者通过一个水平的 Flex 组件展示了不同 flex 值的分配方式。
1.1 核心代码拆解
// ========== Flex 水平 ==========
Flex(
direction: Axis.horizontal,
children: [
_buildBox(Colors.red, 'flex: 1', flex: 1),
_buildBox(Colors.orange, 'flex: 2', flex: 2),
_buildBox(Colors.yellow, 'flex: 1', flex: 1),
],
)
1.2 Two-Pass (两遍测算) 算法深度解析
在渲染这棵节点树时,Flutter 引擎会执行极其严谨的测算逻辑:
- 第一遍测算(Rigid 刚性元素):引擎首先过滤掉所有被
Flexible或Expanded包裹的元素,去测量那些固定大小的元素(此段代码中没有固定元素,因此固定空间为 0)。 - 计算自由空间(Free Space):父容器的最大允许宽度(假设为屏幕宽度 360 360 360),减去刚性元素的总宽度 0 0 0,得出自由空间为 360 360 360。
- 分配弹性空间:
- 汇总 Flex 系数: T o t a l F l e x = 1 + 2 + 1 = 4 TotalFlex = 1 + 2 + 1 = 4 TotalFlex=1+2+1=4。
- 计算单位像素空间: 360 / 4 = 90 360 / 4 = 90 360/4=90。
- 分配宽度:红盒子分得 90 × 1 = 90 90 \times 1 = 90 90×1=90,橙盒子分得 90 × 2 = 180 90 \times 2 = 180 90×2=180,黄盒子分得 90 × 1 = 90 90 \times 1 = 90 90×1=90。
这就是 Expanded 能够完美按比例瓜分屏幕的神奇数学机制。
🆚 二、 Expanded vs Flexible:tight 与 loose 的生死抉择
这是全篇最核心、也是中高级跨平台开发者面试的必考题。很多开发者把 Expanded 当作万金油,却不知道它只是 Flexible 的一个特例。
2.1 源码揭秘与对比
// ========== Flexible fit ==========
// 1. Fit.loose (宽松模式)
Flexible(
fit: FlexFit.loose,
flex: 1,
child: Container(color: Colors.red, child: Text('A')), // 子元素基础尺寸很小
)
// 2. Fit.expand / tight (严格模式)
Flexible(
fit: FlexFit.tight, // 🔥 Expanded 组件的底层就是传入了 FlexFit.tight
flex: 1,
child: Container(color: Colors.green, child: Text('A')),
)
2.2 约束传递的哲学
在 Flutter 的底层原则中:“约束向下,尺寸向上(Constraints go down. Sizes go up.)”。
当剩余空间被按比例切分好之后,父节点 Flex 如何把这个空间下发给子节点呢?这就取决于 fit 属性。
FlexFit.tight(强约束,即Expanded):
父节点对子节点说:“我不管你里面的文字有多小,我分给你了 100 像素,你的最小宽度必须是 100,最大宽度也必须是 100!”
表现:子元素被强行拉伸,完全占满分配给它的空间。FlexFit.loose(弱约束,即标准的Flexible):
父节点对子节点说:“我最多只能给你 100 像素。如果你自身的尺寸只有 30,那你就保持 30;如果你自身的尺寸是 150,对不起,你必须被截断压缩到 100。”
表现:子元素不会被强制拉伸。它表现为“内容自适应”,但绝不超过分配的弹性上限。
业务实战启示:如果在一段文本旁边放一个按钮,文本长度不确定但不能挤出屏幕,应该用什么?必须用 Flexible(fit: FlexFit.loose) 包裹文本。如果用 Expanded,即使文本只有一个字,它也会把按钮挤到屏幕最边缘,中间留下大片难看的空白。
🛠️ 三、 组合拳实战:构建底部导航栏
UI 的终极考验是组合。源码最后提供了一个实战级底部导航栏(Bottom Navigation Bar)的实现。
3.1 结构解构
// ========== 实战: 底部导航 ==========
Container(
height: 60, // 强制父节点高度
child: Flex(
direction: Axis.horizontal, // 等价于 Row
children: [
_buildNavItem(Icons.home, '首页', true),
_buildNavItem(Icons.search, '发现', false),
// ... 5个 Item
],
),
)
// 内部 Item 的构建
Widget _buildNavItem(...) {
return Expanded( // flex 默认为 1
child: Column( // 上下排布 Icon 和 Text
mainAxisAlignment: MainAxisAlignment.center,
children: [ Icon(...), Text(...) ],
),
);
}
3.2 布局鲁棒性分析
这段代码极其稳健,堪称模版:
- 防止横向溢出:外层是一个横向的
Flex,内部的 5 个入口全部用Expanded包裹。这意味着无论手机屏幕是 320px 的小屏,还是 1024px 的平板,这 5 个按钮绝对会等分屏幕宽度,永不溢出。 - 防止纵向溢出:内部使用了
Column排列图标和文字,如果不做外层限制,极易引发无限高度报错。但由于最外层有一个Container(height: 60)进行了强力封顶,约束顺利向下传递,保障了内部Column的安全渲染。
📊 四、 跨平台 Flex 布局防坑速查表
在跨平台 UI(无论是 Flutter 还是 HarmonyOS ArkUI)的迁移与开发中,请将以下表格作为你的代码 Review 军规:
| 异常现象 / 痛点 | 底层根因分析 | 终极解决方案 |
|---|---|---|
RenderFlex overflowed (黄黑条警告) |
容器受到了固定的强约束(如屏幕宽度),但内部刚性(Rigid)子元素的总尺寸超出了该限制。 | 将超出部分的组件使用 Expanded 强制瓜分剩余空间;或将外层换成 Wrap 自动换行;或在外部包裹可滚动容器。 |
Expanded 放在 ListView 或 SingleChildScrollView 里直接崩溃 |
滚动容器的主轴方向提供的是无界约束 (Unbounded Constraints)。Expanded 试图去填满一个无穷大的空间,导致数学计算死循环。 |
绝不允许在同向滚动的 ScrollView 中直接嵌套 Expanded。必须给定一个确切的高度/宽度,或者去掉 Expanded 改用刚性尺寸。 |
| 文本只有两个字,却把旁边的按钮挤到了屏幕边缘 | 误用了 Expanded (tight 约束)。强迫文本框拉伸到了最大比例。 |
将文本框的包裹容器替换为 Flexible,保持弱约束 (loose),让文本自适应内容宽度即可。 |
完整代码和运行界面
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flex 弹性布局',
debugShowCheckedModeBanner: false,
home: const HomePage(),
);
}
}
class HomePage extends StatelessWidget {
const HomePage({super.key});
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF1A1A2E),
appBar: AppBar(
title: const Text('Flex 弹性布局'),
backgroundColor: const Color(0xFF2D1B4E),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_buildSectionTitle('Flex 基础用法'),
_buildFlexBasic(),
const SizedBox(height: 20),
_buildSectionTitle('direction: Axis.horizontal'),
_buildFlexRow(),
const SizedBox(height: 20),
_buildSectionTitle('direction: Axis.vertical'),
_buildFlexColumn(),
const SizedBox(height: 20),
_buildSectionTitle('Flex + Expanded'),
_buildFlexExpanded(),
const SizedBox(height: 20),
_buildSectionTitle('Flex + Flexible fit'),
_buildFlexFlexible(),
const SizedBox(height: 20),
_buildSectionTitle('实战: 底部导航栏'),
_buildBottomNav(),
const SizedBox(height: 50),
],
),
),
);
}
Widget _buildSectionTitle(String title) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF6B4EE6),
),
),
),
);
}
// ========== Flex 基础 ==========
Widget _buildFlexBasic() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF2D1B4E),
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'Flex 是 Row 和 Column 的底层实现\n'
'通过 direction 控制方向\n'
'通过 children 添加子组件',
style: TextStyle(color: Colors.white70, fontSize: 14),
),
);
}
// ========== Flex 水平 ==========
Widget _buildFlexRow() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF2D1B4E),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Flex(direction: Axis.horizontal)',
style: TextStyle(color: Colors.white60, fontSize: 12),
),
const SizedBox(height: 12),
Flex(
direction: Axis.horizontal,
children: [
_buildBox(Colors.red, 'flex: 1', flex: 1),
_buildBox(Colors.orange, 'flex: 2', flex: 2),
_buildBox(Colors.yellow, 'flex: 1', flex: 1),
],
),
],
),
);
}
// ========== Flex 垂直 ==========
Widget _buildFlexColumn() {
return Container(
height: 180,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF2D1B4E),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Flex(direction: Axis.vertical)',
style: TextStyle(color: Colors.white60, fontSize: 12),
),
const SizedBox(height: 12),
Expanded(
child: Flex(
direction: Axis.vertical,
children: [
Expanded(flex: 1, child: _buildBoxV(Colors.green, 'flex: 1')),
Expanded(flex: 2, child: _buildBoxV(Colors.blue, 'flex: 2')),
Expanded(flex: 1, child: _buildBoxV(Colors.purple, 'flex: 1')),
],
),
),
],
),
);
}
// ========== Flex + Expanded ==========
Widget _buildFlexExpanded() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF2D1B4E),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'左侧固定宽度,右侧自适应',
style: TextStyle(color: Colors.white60, fontSize: 12),
),
const SizedBox(height: 12),
SizedBox(
height: 50,
child: Flex(
direction: Axis.horizontal,
children: [
_buildBox(Colors.teal, '固定 100'),
const SizedBox(width: 10),
const Expanded(
flex: 1,
child: ColoredBox(
color: Color(0xFF6B4EE6),
child: Center(
child: Text(
'自适应',
style: TextStyle(color: Colors.white),
),
),
),
),
],
),
),
],
),
);
}
// ========== Flexible fit ==========
Widget _buildFlexFlexible() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF2D1B4E),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Fit.loose vs Fit.expand',
style: TextStyle(color: Colors.white60, fontSize: 12),
),
const SizedBox(height: 12),
Column(
children: [
const Text(
'Fit.loose: 保持最小尺寸,可收缩',
style: TextStyle(color: Colors.white60, fontSize: 11),
),
const SizedBox(height: 8),
SizedBox(
height: 40,
child: Flex(
direction: Axis.horizontal,
children: [
Flexible(
fit: FlexFit.loose,
flex: 1,
child: Container(
color: Colors.red,
child: const Center(child: Text('A', style: TextStyle(color: Colors.white))),
),
),
Flexible(
fit: FlexFit.loose,
flex: 1,
child: Container(
color: Colors.orange,
child: const Center(child: Text('B', style: TextStyle(color: Colors.white))),
),
),
],
),
),
const SizedBox(height: 16),
const Text(
'Fit.expand: 强制占满剩余空间',
style: TextStyle(color: Colors.white60, fontSize: 11),
),
const SizedBox(height: 8),
SizedBox(
height: 40,
child: Flex(
direction: Axis.horizontal,
children: [
Flexible(
fit: FlexFit.tight,
flex: 1,
child: Container(
color: Colors.green,
child: const Center(child: Text('A', style: TextStyle(color: Colors.white))),
),
),
Flexible(
fit: FlexFit.tight,
flex: 1,
child: Container(
color: Colors.blue,
child: const Center(child: Text('B', style: TextStyle(color: Colors.white))),
),
),
],
),
),
],
),
],
),
);
}
// ========== 实战: 底部导航 ==========
Widget _buildBottomNav() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF2D1B4E),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'实战: 底部导航栏布局',
style: TextStyle(color: Colors.white60, fontSize: 12),
),
const SizedBox(height: 12),
Container(
height: 60,
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Flex(
direction: Axis.horizontal,
children: [
_buildNavItem(Icons.home, '首页', true),
_buildNavItem(Icons.search, '发现', false),
_buildNavItem(Icons.add_circle, '发布', false),
_buildNavItem(Icons.message, '消息', false),
_buildNavItem(Icons.person, '我的', false),
],
),
),
],
),
);
}
Widget _buildNavItem(IconData icon, String label, bool isActive) {
return Expanded(
flex: 1,
child: Container(
decoration: BoxDecoration(
color: isActive
? const Color(0xFF6B4EE6).withOpacity(0.2)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
color: isActive
? const Color(0xFF6B4EE6)
: Colors.white60,
size: 24,
),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(
fontSize: 11,
color: isActive
? const Color(0xFF6B4EE6)
: Colors.white60,
),
),
],
),
),
);
}
Widget _buildBox(Color color, String text, {int flex = 1}) {
return Expanded(
flex: flex,
child: Container(
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
text,
style: const TextStyle(color: Colors.white, fontSize: 12),
),
),
),
);
}
Widget _buildBoxV(Color color, String text) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
text,
style: const TextStyle(color: Colors.white, fontSize: 12),
),
),
);
}
}


结语
不要轻视任何一个基础组件。Flex、Expanded 和 Flexible 构成了跨平台声明式 UI 的物理骨架。
在这个“万物皆可弹性”的框架里,透彻理解空间的两遍测算算法(Two-Pass Layout)以及强弱约束的传递法则,你便从一名“拼凑页面的切图仔”,正式蜕变为了掌控底层渲染逻辑的大前端架构师。希望这篇万字深度解析,能让你在 Flutter 与鸿蒙原生开发的无缝切换中游刃有余!
更多推荐

所有评论(0)