环境配好、能打出hap包之后,我开始在鸿蒙上正经跑Flutter界面。之前习惯用ArkTS写原生,Row、Column、Flex、Stack这套布局玩得挺熟,换成Flutter后发现思路能套,但API细节差别不小。这篇把几个最常用的布局组件在鸿蒙上实际跑一遍的结果记录下来,重点说差异和坑。
先说整体感受。Flutter的布局核心思路和ArkTS一样,都是声明式、嵌套式写法。区别在于Flutter的Widget拆得更细,一个视觉效果往往要套好几层,刚开始写会觉得啰嗦,写多了反而觉得清晰。Flutter布局体系大致分三层:最底层的RenderObject负责实际绘制和布局计算,中间层的Widget是声明式描述,开发者写的都是Widget这一层。Widget本身不可变,每次setState会生成新Widget树,框架内部和旧的做对比决定是否重绘。这个设计理念和ArkTS的“状态驱动UI”一致,都是描述UI长什么样,具体渲染交给框架。
日常开发用得最多的就是Row、Column、Stack、Wrap、Container这几个,加上Expanded控制弹性,基本覆盖八成布局场景。剩下两成涉及Sliver、CustomPaint、LayoutBuilder这些进阶组件,后续用到再聊。
Row + Expanded:弹性比例分配
先说横向布局。ArkTS里横向排列用Row,控制子元素占比用layoutWeight,Flutter对应的是Row + Expanded组合。需求很简单:一行三个色块,按1:2:1比例分配宽度。思路是Row撑满父容器,三个Expanded用flex指定比例。- Row(
- children: [
- Expanded(flex: 1, child: _colorBox(Colors.red, '1')),
- Expanded(flex: 2, child: _colorBox(Colors.red, '2')),
- Expanded(flex: 1, child: _colorBox(Colors.blue, '1')),
- ],
- )
复制代码 _colorBox是抽出来的小工具函数,返回带背景色和居中文字的Container:- Widget _colorBox(Color color, String label) => Container(
- height: 56,
- color: color,
- alignment: Alignment.center,
- child: Text(label, style: const TextStyle(color: Colors.white, fontSize: 18)),
- );
复制代码 跑起来三个色块按1:2:1排开,绿色那块明显宽一截。
这里有个细节差异。ArkTS的layoutWeight直接写在子组件上,Flutter的Expanded是独立Widget,要把子组件包进去。一开始老忘记套Expanded,直接给Row塞三个Container,三个色块挤在一起各自按内容宽度撑开,完全不是弹性效果。后来才反应过来,Flutter里“弹性”这个概念是Expanded提供的,Row本身不管子元素怎么分空间。
还有个坑:Expanded只能在Row、Column、Flex里面用,放进别的容器直接报错。有次手滑把Expanded放进Stack,编译器红一片,查了才知道它依赖父级是Flex系容器。另外flex默认值是1,不写flex时Expanded(child:...)和Expanded(flex:1, child:...)效果一样。多个Expanded都不写flex就按1:1:1等分。想做“一个固定宽度,剩下的填满”的效果,固定宽度用普通Container,剩余用不带flex的Expanded自动吃掉剩余空间。
Spacer组件本质就是Expanded(child: SizedBox.shrink()),专门占位。想在两个元素之间撑开距离,塞个Spacer()比写Expanded(child: Container())简洁,相当于ArkTS的Blank()组件。
Column交叉轴对齐:start/center/end
竖向布局用Column,对齐方式分主轴和交叉轴。主轴是Column的方向(竖),交叉轴是垂直于主轴的方向(横)。概念和ArkTS的justifyContent/alignItems对应,名字不同。我写了三组Column并排放,分别演示交叉轴的start、center、end对齐:- Row(
- children: [
- Expanded(child: _alignColumn(CrossAxisAlignment.start, 'start')),
- Expanded(child: _alignColumn(CrossAxisAlignment.center, 'center')),
- Expanded(child: _alignColumn(CrossAxisAlignment.end, 'end')),
- ],
- )
- Widget _alignColumn(CrossAxisAlignment align, String label) => Column(
- crossAxisAlignment: align,
- children: [
- Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
- const FlutterLogo(size: 28),
- const FlutterLogo(size: 40),
- ],
- );
复制代码 跑起来对比明显,左列靠左、中列居中、右列靠右。FlutterLogo是自带组件,拿来当占位元素挺方便。
和ArkTS的区别在于:ArkTS里横向对齐用alignItems: HorizontalAlign.Center,枚举值是Start/Center/End;Flutter这边是CrossAxisAlignment枚举,值是start/center/end(小驼峰)。名字差不多,但ArkTS的HorizontalAlign和VerticalAlign是分开的枚举,Flutter统一用CrossAxisAlignment,靠主轴方向自动判断是横还是竖,不用记两套枚举。
主轴对齐别搞混。Column的主轴是竖,控制竖直方向对齐用mainAxisAlignment(start/center/end/spaceBetween/spaceAround/spaceEvenly),交叉轴才是crossAxisAlignment。一开始老把main和cross写反,竖直对齐死活不对。记个口诀:沿排列方向的是main,垂直排列方向的是cross。
mainAxisAlignment几个值里,spaceBetween用得最多——两头贴边、中间均分,做导航栏底部tab条很合适。spaceEvenly把剩余空间均分成N+1份,每个元素前后都有等距间隙;spaceAround均分成N份,每个元素前后各占一半。三个space系列的区别:Between最紧(两头没间隙),Evenly最松(两头都有间隙),Around居中(两头各一半)。这些和ArkTS的justifyContent: FlexAlign.SpaceBetween一一对应,只是Flutter枚举值少了Flex前缀。
Stack + Positioned:层叠和角标
层叠布局在ArkTS里叫Stack,Flutter里也叫Stack。典型场景是图标右上角挂角标,比如消息通知的红点数字。思路是Stack做容器,子元素默认后放的盖在先放的上面,要精确定位用Positioned包裹,指定top/right/bottom/left。- Stack(
- clipBehavior: Clip.none,
- children: [
- Container(
- padding: const EdgeInsets.all(20),
- decoration: BoxDecoration(
- color: Colors.blue.shade50,
- borderRadius: BorderRadius.circular(16),
- ),
- child: const Icon(Icons.notifications, size: 56, color: Colors.blue),
- ),
- const Positioned(
- top: -6,
- right: -6,
- child: CircleAvatar(
- radius: 10,
- backgroundColor: Colors.red,
- child: Text('9', style: TextStyle(color: Colors.white, fontSize: 12)),
- ),
- ),
- ],
- )
复制代码 这里的关键参数是clipBehavior: Clip.none。Stack默认把超出自身范围的子元素裁掉,角标要伸到容器外面(top和right都是负值),不设这个参数角标会被切掉一半。第一次写没加,角标剩个半圆,查了半天才找到是Stack的裁剪行为。ArkTS的Stack也有类似机制,默认alignContent控制对齐,但定位子元素用的是position配合offset,不如Flutter的Positioned直观。Positioned语义明确,不用算偏移量。
Positioned必须在Stack里用——和Expanded一样,它依赖父级是Stack。Stack里混用Positioned和非Positioned子元素时,非Positioned按Stack的alignment对齐(默认Alignment.center居中),Positioned按各自的top/right定位。alignment参数控制非定位子元素的对齐方式,想改默认就设alignment: Alignment.topLeft,和ArkTS的alignContent一个意思。
Wrap流式布局:标签云
Row和Column都单行单列,子元素超了不换行换列。做标签云这种自动换行的效果得用Wrap。我做了个可点击的标签云,点击切换选中状态,选中的变蓝底白字。思路是Wrap做容器,spacing控制横向间距,runSpacing控制行间距,子元素用GestureDetector包一层处理点击。- Wrap(
- spacing: 8,
- runSpacing: 8,
- children: _tags.map((tag) {
- final on = _selected.contains(tag);
- return GestureDetector(
- onTap: () => setState(() {
- if (on) {
- _selected.remove(tag);
- } else {
- _selected.add(tag);
- }
- }),
- child: AnimatedContainer(
- duration: const Duration(milliseconds: 150),
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
- decoration: BoxDecoration(
- color: on ? Colors.blue : Colors.grey.shade200,
- borderRadius: BorderRadius.circular(20),
- ),
- child: Text(
- tag,
- style: TextStyle(
- color: on ? Colors.white : Colors.black87,
- fontSize: 13,
- ),
- ),
- ),
- );
- }).toList(),
- )
复制代码 _tags是字符串数组,_selected是Set存当前选中标签,点击时判断在不在Set里决定移除还是添加,然后setState触发重建。这里用了AnimatedContainer而不是普通Container,它会在属性变化时自动做过渡动画,duration指定时长。点击标签时背景从灰变蓝有150毫秒渐变,比硬切自然。这个组件ArkTS没有对应物,鸿蒙原生要做这种动画得自己写animateTo,Flutter一个参数搞定。
ArkTS里做流式布局用Flex配合wrap: FlexWrap.Wrap,或者直接用GridContainer。Flutter的Wrap更纯粹,direction控制排列方向(默认横向),alignment控制每行在主轴上的对齐。Wrap的runAlignment参数控制多行整体在交叉轴的对齐,比如三行标签整体靠上还是靠下,用得少但特殊布局会用到。另外Wrap默认不滚动,标签太多超屏幕还是会溢出,要滚动得自己套SingleChildScrollView,这点和ArkTS的Flex一样。
顺带一个建议:Set比List适合存选中项。一开始用List存选中标签,点击切换要先contains判断再remove或add,去重还得自己处理。换成Set全自动搞定,查找和增删都是O(1)。不影响功能但影响代码干净度。
Container装饰:渐变圆角阴影
Container是Flutter里最常用的容器组件,能管尺寸、背景、边框、阴影、圆角,全靠decoration参数。ArkTS里这些效果分散在不同属性上,Flutter统一收拢到BoxDecoration里。我做了个渐变背景+圆角+阴影的卡片:- Container(
- height: 80,
- decoration: BoxDecoration(
- gradient: const LinearGradient(
- colors: [Colors.purple, Colors.blueAccent],
- begin: Alignment.topLeft,
- end: Alignment.bottomRight,
- ),
- borderRadius: BorderRadius.circular(16),
- boxShadow: [
- BoxShadow(
- color: Colors.blue.withValues(alpha: 0.3),
- blurRadius: 8,
- offset: const Offset(0, 4),
- ),
- ],
- ),
- alignment: Alignment.center,
- child: const Text(
- '渐变背景 + 圆角 + 阴影',
- style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
- ),
- )
复制代码 LinearGradient做渐变,begin和end指定渐变方向;borderRadius做圆角;boxShadow是数组,能叠多层阴影,blurRadius控制模糊度,offset控制偏移方向。
有个坑:一开始用Colors.blue.withOpacity(0.3)设阴影透明度,flutter analyze给了warning,说withOpacity废弃,建议用withValues(alpha: 0.3)。新版本Flutter改的API,withOpacity有精度损失,withValues是替代写法。改完warning就消了。这种API变更建议顺手跑下flutter analyze看看deprecated提示。
另外Container的color和decoration不能同时设。要用渐变、边框、阴影走decoration,纯色背景走color,两者互斥。我试过在decoration里写渐变又在Container上写color,直接崩了,报错信息还挺绕。Container其他常用参数:padding控制内边距,margin控制外边距,width/height控制尺寸,alignment控制子元素对齐,transform做矩阵变换。可以随意组合,但也容易写成“巨型Container”,装饰复杂场景建议把布局和装饰拆开写,可读性更好。
整体页面串联
这些布局组件单独看都简单,实际页面要组合起来。我的做法是用ListView把每个布局demo包成小节卡片,竖向排列滚动查看。抽了个通用的_SectionCard组件做容器:- class _SectionCard extends StatelessWidget {
- const _SectionCard({required this.title, required this.child});
- final String title;
- final Widget child;
- @override
- Widget build(BuildContext context) {
- return Card(
- elevation: 1,
- child: Padding(
- padding: const EdgeInsets.all(12),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Padding(
- padding: const EdgeInsets.only(bottom: 10),
- child: Text(title, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
- ),
- child,
- ],
- ),
- ),
- );
- }
- }
复制代码 每个小节样式统一,加新demo只要实现内容部分塞进去就行。Flutter这种“组合优于继承”的思路和ArkTS的@Builder复用思路类似,都是把重复UI抽成可复用单元。
踩坑清单
1. Expanded放错容器报错。只能在Row/Column/Flex里用,报错信息是“Incorrect use of ParentDataWidget”,看到直接检查父级是不是Flex系。
2. Stack默认裁剪超出部分。角标伸到外面被切掉,加clipBehavior: Clip.none解决。ArkTS的Stack默认不裁剪,跨端开发要适应这个差异。
3. withOpacity被废弃。新版Flutter用withValues(alpha: x)替代withOpacity(x),遇到deprecated warning最好顺手改掉,版本升级可能直接报错。
4. Column内容超屏不滚动。套SingleChildScrollView或改用ListView,和ArkTS的Column行为一样。
5. padding和margin容易搞混。padding是内边距(子元素到容器边框距离),margin是外边距(容器到外部元素距离)。ArkTS里概念一致,但Flutter里不区分清楚,视觉上可能看不出来,布局计算实际错了。习惯是先想清楚“这段距离在容器内还是外”。
6. 嵌套太深可读性下降。Widget套娃写多了缩进能深到十几层。把独立区块抽成单独Widget类做主build方法里的组合单元,主结构一眼看清,细节去各自类里看。ArkTS的@Builder也是同样思路。
写在最后
跑下来感觉Flutter布局体系和ArkTS思路是通的,都是声明式+嵌套,差异主要在API名字和参数组织。Flutter的Widget拆得更碎,一个效果要套好几层,但每个Widget职责单一,查文档好定位。ArkTS组件集成度更高,一个Row搞定的事Flutter可能得Row+Expanded+Container三层。个人觉得Flutter写起来更灵活、组合性强,但嵌套深了容易“嵌套地狱”。ArkTS的@Builder和@Component拆分方式对治理深嵌套更友好。
性能方面,Flutter布局是声明式的,每次setState触发整棵子树重建,但框架内部有diff优化,不会真把所有Widget重新创建,只更新变化部分。正常写不用担心,除非列表项特别多或布局层级特别深,那种情况用ListView.builder配合const构造器优化。鸿蒙设备性能参差不齐,低端机上列表卡顿比安卓明显,后续聊列表时再细说。
这些demo都在lib/demos/layout_demo.dart里,主页点“基础布局”就能进。下一篇计划聊状态管理,从Counter到TodoList,看StatefulWidget在鸿蒙上的实际表现。布局没有捷径,多写多踩坑就熟了。从ArkTS转过来用Flutter写鸿蒙的,照着demo敲一遍,核心组件用熟日常界面基本够用,遇到不确定的参数多翻官方文档,Flutter的文档比ArkTS全不少。 |