HarmonyOS自定义组件与布局实战:从Stack/Flex到组件化重构
2026/9/11 6:00:49 网站建设 项目流程

写自定义组件和布局,是每个HarmonyOS应用开发者绕不过去的坎。项目一复杂,如果还停留在把所有UI塞进一个@Entry里,改个间距都得翻几百行代码,那效率基本是灾难。这篇博文不打算讲语法文档里那些现成的东西,就聊聊我在实际项目里怎么拆组件、怎么用Stack和Flex把常见布局需求落地,以及踩过一堆坑之后的排查套路。无论你是在准备HarmonyOS应用基础认证,还是刚接手一个需要组件化重构的项目,这篇文章应该都能给你一些直接能用的参考。

1. 自定义组件:从语法到工程化

1.1 从@Entry到@Component,组件化思维怎么落地

先看最基础的写法。很多新手以为自定义组件就是新建一个文件、套上@Component装饰器,然后暴露出几个参数就完事了。实际上,组件化的核心不是"能复用",而是"能独立维护"。

@Component export struct CustomCard { @Prop title: string = ''; @Prop desc: string = ''; @Prop coverUrl: string = ''; build() { Column({ space: 8 }) { Image(this.coverUrl) .width('100%') .height(160) .objectFit(ImageFit.Cover) .borderRadius(12) Text(this.title) .fontSize(18) .fontWeight(FontWeight.Bold) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(this.desc) .fontSize(14) .fontColor('#666666') .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .padding(12) .backgroundColor(Color.White) .borderRadius(16) .shadow({ radius: 8, color: 'rgba(0,0,0,0.08)', offsetY: 4 }) } }

这段代码本身不难。但请注意build()方法里所有状态都通过@Prop从外部传入。我在实际项目里发现一个普遍问题:很多人会在子组件内部直接改@Prop变量,比如在点击事件里赋值,然后发现界面不刷新。原因很简单,@Prop是单向数据流,子组件只能读,不能回写。如果你真的需要子组件内部修改某个值,并且让父组件同步感知,应该使用@Link或者@State配合事件回调。

组件化的核心思维是:单一职责。一个组件只做一件事,只维护自己的一小片状态,所有跨组件的数据传递走参数和回调。这样当你需要修改卡片样式的时候,只需要打开CustomCard.ets,而不是在几百行的页面文件里搜索"封面图在哪儿"。

1.2 @Builder插槽与按需拼接:让组件像乐高一样组合

@Builder是ArkUI里一个非常灵活但很多人用不好的能力。它本质上是一个UI片段构建器,可以多次调用,也可以从外部传入。最常见的场景是做"插槽"。

比如你做一个通用的空状态组件,不同页面的空状态图标、文字、按钮都不一样。你当然可以用一堆布尔参数去控制显隐,但更优雅的方式是用@Builder去占位:

@Component export struct EmptyView { @Builder customContent() {} build() { Column({ space: 12 }) { this.customContent() } .width('100%') .padding(24) } }

然后在父组件里这样用:

EmptyView() { Column({ space: 8 }) { Image($r('app.media.empty_icon')) .width(120) .height(120) Text('暂无数据') .fontSize(16) } }

但这里有一个非常隐蔽的坑:参数透传。如果@Builder函数需要接收参数,不能像普通函数那样直接传基本类型就完事,复杂对象需要按引用传递或者用$$来做参数双向绑定。我自己被这个问题恶心过好几次,症状就是子组件里@Builder接收到一个对象,但修改对象属性后UI不刷新。解决方案有两个:要么把@Builder接收的参数改成@State包装后再传,要么直接在构建函数内部读取全局状态。

1.3 事件回调与数据流转:自定义组件怎么正确绑定原生事件

你看热词里有一条"自定义组件绑定原生事件",这个问题我太有感触了。ArkUI里自定义组件绑定原生事件,关键不是"能不能绑",而是绑定之后数据怎么回流

举个例子。你封装一个搜索输入框组件,内部用了系统TextInput,但你要获取输入的内容并回传给页面去做搜索逻辑。正确做法是用@Event属性类型去声明一个回调:

@Component export struct SearchInput { @Prop placeholder: string = ''; @Event onSearch: (keyword: string) => void = () => {}; @State keyword: string = ''; build() { Row() { TextInput({ text: this.keyword, placeholder: this.placeholder }) .onChange((value: string) => { this.keyword = value; }) .onSubmit(() => { this.onSearch(this.keyword); }) } .padding({ left: 16, right: 16, top: 8, bottom: 8 }) } }

这个写法的好处是,父组件完全不需要关心SearchInput内部数据怎么存储,只需要传一个onSearch回调:

SearchInput({ placeholder: '搜索你感兴趣的内容' }) .onSearch((keyword: string) => { // 在这里执行搜索逻辑 })

注意:@Event后面的类型标注需要是一个函数类型,而且要给一个空实现作为默认值,否则组件在某些情况下会因为找不到回调而报错。这在ArkTS环境里尤其重要,因为ArkTS是静态类型语言,类型安全是编译期强约束的。

1.4 进阶:@Reusable复用与自定义绘制,性能和自由度的平衡

当你的列表动辄几十上百条数据,每个Item组件都频繁创建销毁,性能损耗会变得很明显。@Reusable装饰器就是解决这个问题的:

@Reusable @Component export struct ListItemView { @Prop title: string = ''; build() { Row() { Text(this.title) } .height(56) } }

然后在ListitemGenerator里声明ForEach时,不写额外逻辑,框架会自动复用滑出屏幕的ListItem实例。这个我在长列表场景里实测过,滑动掉帧率明显下降。但注意,复用的组件内部不要持有大型的临时对象或闭包,否则可能出现内存占用不降反增的情况。

再说自定义绘制。有些场景,比如画折线图、仪表盘、签名板,普通组件搭不出来,就得用Canvas自己画。ArkUI的Canvas配套CanvasRenderingContext2D,用法跟Web Canvas高度相似:

Canvas(this.context) .width('100%') .height(200) .onReady(() => { this.context.fillStyle = '#1890ff'; this.context.beginPath(); this.context.arc(100, 100, 50, 0, Math.PI * 2); this.context.fill(); })

画布组件是高效的,因为它不产生复杂的渲染树节点。但你会失去组件自动布局的能力,所有位置都需要自己算。所以画布和普通组件混排时,通常用Stack叠放:底层放Canvas做可视化效果,上层放按钮做交互控制。

2. 布局核心:Stack、Flex、Grid怎么选

2.1 Stack布局的对齐哲学:底部上方100居中这个经典需求

热词里有一条特别具体:"鸿蒙 stack布局子组件怎么控制自己在底部上方100的位置居中"。我来拆解一下这个需求,看起来是:一个子组件要放在父容器底部向上偏移100像素的位置,并且水平居中。

先说结论,用Stack实现有两种方式。

第一种,对齐方式加偏移。Stack默认所有子组件对齐到Alignment.Center,但可以单独设置每个子组件的alignContent

Stack({ alignContent: Alignment.BottomCenter }) { Column() { // 子组件内容 } .width(200) .height(100) .margin({ bottom: 100 }) }

这个写法实际上让子组件对齐到BottomCenter,再用margin向上推100。简单直接,而且不需要额外计算。但有个局限:margin也参与布局占位,如果父Stack需要精确控制子组件不超出边界,这个写法有时候会出问题。

第二种,绝对定位。用positionoffset手动指定坐标。这两者的区别是:position相对于父组件左上角定位,不保留自己在布局流中的位置;offset保留布局位置,但视觉上偏移。在Stack里,通常用position更干净:

Stack({ alignContent: Alignment.TopStart }) { Column() { // 子组件内容 } .width(200) .height(100) .position({ x: '50%', y: '100%' }) // 注意y }

但是position的百分比是相对于父容器宽高的。如果子组件自身宽高是200x100,那y: '100%'会把子组件顶到父容器底部,子组件底部会超出边界。要精确做到"底部上方100的位置居中",你得知道父容器高度。

我的建议是:能用对齐就优先用对齐,别硬算坐标。真正需要position的场景是浮层、角标、气泡这种和正常文档流完全解耦的元素。

Stack({ alignContent: Alignment.BottomCenter }) { Text('底部提示') .margin({ bottom: 100 }) }

这就能实现"底部上方100居中",代码最少也最稳定。

2.2 Flex布局:弹性伸缩的正确打开方式

ArkUI里的Flex是老朋友了,对应Web里的Flexbox。它解决的核心问题是:一组子组件在主轴上如何排列和对齐

实际项目里最常见的需求是:左边一个图标,中间一段说明文字自适应撑满,右边一个箭头按钮。用Row也能做,但需要手动设置中间元素的layoutWeight

Row({ space: 12 }) { Image($r('app.media.icon')) .width(24) .height(24) Text('这是一段可能会很长的说明文字') .layoutWeight(1) .fontSize(14) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) Image($r('app.media.arrow')) .width(16) .height(16) } .width('100%')

layoutWeight是ArkUI里最常用的弹性权重属性。它表示当前子组件占据父组件剩余空间的份数比例。比如有两个按钮,一个layoutWeight(1),一个layoutWeight(2),那它们占据剩余空间的比例就是1:2,这是响应式布局的基础。但注意,layoutWeight只对主轴方向生效,在Row里就是水平方向,在Column里就是垂直方向。

Flex布局最常见的坑是嵌套尺寸塌陷。一个父Flex设置了width('100%'),子Flex也设置了width('100%'),但子Flex的父级(也就是外层子组件)没有明确宽度,导致百分比失效。排查办法很简单:逐层检查每个容器的宽度约束,看哪一层实际渲染宽度为0或很窄。

2.3 Grid与流式布局:网格系统与FlowLayout的实现思路

Grid布局适合规则的网格,比如九宫格、商品列表、照片墙。ArkUI的Grid组件高度封装,直接设置columnsTemplaterowsTemplate就能快速铺一个棋盘格:

Grid() { ForEach(this.itemList, (item: string) => { GridItem() { Text(item) .height(100) } }, (item: string) => item) } .columnsTemplate('1fr 1fr 1fr') .rowsTemplate('1fr 1fr 1fr')

这里的1fr是网格单位,类似Flex中的layoutWeightcolumnsTemplate('1fr 1fr 1fr')表示分成三等份,每一份宽度相等。如果你想要非等宽,可以直接写'1fr 2fr 1fr'

流式布局则更灵活。热词里提到"流式布局面板",这类需求通常是标签云:标签数量不固定,每行能放几个就放几个,自动换行。ArkUI没有内置的WaterFlow(不过有WaterFlow组件专门做瀑布流),但我们完全可以用Flex配合flexWrap实现流式排列:

Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) { ForEach(this.tagList, (tag: string) => { Text(tag) .padding({ left: 12, right: 12, top: 6, bottom: 6 }) .margin({ right: 8, bottom: 8 }) .backgroundColor('#f0f0f0') .borderRadius(20) .fontSize(12) }, (tag: string) => tag) }

关键就在flexWrap: FlexWrap.Wrap。默认Flex不换行,子组件超出即压缩或溢出,设置Wrap后超出会折行显示。这里有个性能细节:如果标签数量很大(比如几百个),用ForEach渲染时建议带上key生成函数,让框架可以精准复用节点。我在实际项目中就遇到过一次:不写第二个key参数时,改数据后UI出现错乱,加上key后就好了。

3. 从静态到自适应:响应式布局实战

3.1 自适应断点与栅格系统:大屏小屏一套代码

HarmonyOS有完整的栅格系统组件GridRowGridColumn,专门用来做自适应布局。栅格的思路是:把一行划分为若干列(默认12列),通过配置断点决定在不同宽度下某个内容占几列。

GridRow({ columns: { xs: 1, sm: 2, md: 3, lg: 4 } }) { ForEach(this.cardList, (card: CardItem) => { GridCol() { CardItemView({ card: card }) } }, (card: CardItem) => card.title) } .gutter({ x: 12, y: 12 })

这里的断点区间是系统预定义的:xs对应手机竖屏,sm对应手机横屏/小平板,md对应窄平板,lg对应宽平板/折叠屏展开态。你只需要在每个断点上配置不同的列数,GridRow自动根据当前设备宽度选择列数。这样一套代码,手机上是单列,平板上是三列,不需要写任何if判断。

断点其实也可以自定义,用onBreakpointChange监听断点变化,然后手动调整布局。但能不手动就不手动,系统栅格已经覆盖了绝大多数自适应需求。

3.2 百分比、权重与固有尺寸:布局重叠的根源

布局重叠是热词里高频出现的问题。大多数"重叠"都不是Bug,而是尺寸约束失效。最常见的场景是:父组件是Row,子组件是百分比宽度,但这个百分比不是相对于父Row的,而是相对于某个祖先容器的。当RC内含滚动容器时,百分比宽度还可能在滚动过程中动态变化,导致重叠。

还有一种情况是手动定位和自动布局混用。一个组件既有position定位,又放在布局流里,就很容易覆盖到其他组件上。我的经验法则是:能用布局流完成的定位绝对不用position,position只留给绝对浮层(角标、悬浮球、弹窗)。

3.3 滚动场景:懒加载和列表性能

滚动列表项目一大就容易卡,处理办法是使用List组件而不是简单嵌套Scroll内放ForEachList组件配合LazyForEach可以做到数据懒加载:只有滚动到可视区附近才渲染Item,离开可视区就销毁或复用。

List({ space: 12 }) { LazyForEach(this.dataSource, (item: DataItem) => { ListItem() { Row() { Text(item.title) } .width('100%') } }, (item: DataItem) => item.id) } .layoutWeight(1) .scrollBar(BarState.Off)

LazyForEach要求数据源实现IDataSource接口,提供totalCountgetDataregisterDataChangeListener等方法。虽然实现起来比普通数组麻烦一点,但这是列表性能的底线。

4. 实战:一个"搜索+标签+内容卡片"页面的组件化改造

4.1 页面拆解与组件划分

我把一个典型的信息流页面拆成四块:顶部搜索栏、标签流式排列区、内容卡片列表、空状态占位视图。

组件划分如下:

组件名职责数据来源对外回调
SearchHeader搜索输入和提交内部@StateonSearch(keyword)
TagFilterBar标签流式排列与选中态父组件传入数组onTagSelect(tag)
ContentCard单条内容展示父组件传入对象onCardClick(card)
EmptyView空状态占位插槽@Builder

4.2 布局实现核心代码

页面组合布局:

@Entry @Component struct InfoFlowPage { @State tags: string[] = ['技术', '生活', '设计', '职场']; @State keyword: string = ''; @State dataList: CardItem[] = []; @State isShowEmpty: boolean = false; build() { Column() { SearchHeader() .onSearch((keyword: string) => { this.keyword = keyword; this.loadData(); }) TagFilterBar({ tags: this.tags }) .onTagSelect((tag: string) => { this.refreshByTag(tag); }) if (this.isShowEmpty) { EmptyView() { Text('没有找到相关内容') .fontSize(16) .fontColor('#999') } } else { List({ space: 12 }) { LazyForEach(this.listDataSource, (item: CardItem) => { ListItem() { ContentCard({ title: item.title, desc: item.desc, coverUrl: item.coverUrl }) .onCardClick((card: CardItem) => { this.gotoDetail(card); }) } }, (item: CardItem) => item.id) } .layoutWeight(1) .scrollBar(BarState.Off) } } .width('100%') .height('100%') .backgroundColor('#F5F5F5') } }

注意TagFilterBar左右两侧要留出安全区,我用padding实现,内部标签用Flex Wrap流式排列。搜索栏和标签栏如果要在滑动时吸顶,用一个sticky属性就够了:

Column() { SearchHeader() TagFilterBar() } .sticky(StickyMode.None)

实际操作里,吸顶有个坑:内容列表较短时,吸顶栏底部会露白,观感很差。所以我通常会保证列表有足够的底部padding,或者在列表内容不足时用空状态组件填充。

4.3 前后对比与数据说话

改造前,单一@Entry页面代码量约800行,一个搜索逻辑改动要反复滚动页面查找;改造后,搜索、标签筛选、卡片展示逻辑互相隔离,每个文件控制在150~250行之间。更为直接的收益是:标签筛选性能。由于数据加载逻辑集中在InfoFlowPage,筛选时只需要修改状态,各组件通过参数自动刷新,不再需要手动操作DOM级别的刷新。

代码可维护性是最难量化但收益最大的部分。当产品经理跟你说"卡片标题要多显示一行"时,你只需要打开ContentCard.ets改一个maxLines,而不是在一堆代码里找标题在哪渲染。

5. 常见问题与排查技巧实录

5.1 问题速查表

现象常见原因解决方案
子组件对齐位置不对父容器alignContent设置错误检查Stack/Flex安排的初始对齐,正确使用alignContent/justifyContent
布局重叠手动position和布局流混用优先使用布局流,position只用于特殊情况
自定义组件里改了值UI不刷新直接修改@Prop,而不是@State或@Link需要双向绑定时改用@Link或事件回调
长列表滑动掉帧使用了ForEach而不是LazyForEach改造成LazyForEach + IDataSource
Flex子组件高度撑不开父Flex的AlignItems默认拉伸,需要显式设置明确设置alignItems或给子组件加height
比例宽度莫名异常百分比宽度的参照系不是期望的父容器逐层检查尺寸约束,必要时用layoutWeight

5.2 布局重叠与尺寸异常排查

说到布局重叠,我分享一个非常容易犯的错误:给同一个组件同时使用alignSelfpositionalignSelf是相对所在父容器的对齐,position是绝对定位,两者叠加会导致渲染层面的坐标异常,出现"我以为在右边,结果跑到左上角"的情况。

排查这类问题时,我习惯先打开预览器,开"布局边界"开关,把每个组件的边框颜色可视化,一眼就能看出谁占了多大空间、谁和谁发生了重叠。然后逐步注释掉可疑的样式代码,二分法缩小出错范围。这个方法比读代码猜快得多。

5.3 自定义组件状态不同步

另一个高频问题是:自定义组件接收外部数据变化后,内部UI不刷新

典型场景是:页面@State某个对象(比如卡片数据),把它传给子组件,子组件内部修改对象属性,然后父组件视图没反应。这是@State@Prop的深拷贝特性导致的:当你传一个对象给@Prop时,子组件拿到的其实是对象的拷贝,而不是引用。你修改拷贝,父组件当然什么都不知情。应对方案有三种:

  • 子组件直接调用父组件提供的回调,让父组件自己修改数据;
  • 使用@Link让子组件持有引用;
  • 使用@Observed@ObjectLink去观察对象内部属性变化。

第三种方案适合深层对象,但使用时注意:@Observed修饰的类,其属性变化只能由@ObjectLink感知,且仅限于第一层属性。嵌套第二层属性变化时,需要在子对象上也加@Observed,否则不会触发刷新。

我在实际项目里遇到过最诡异的一次:明明用的@Link,子组件修改数据后父组件还是不刷新。后来发现是父组件把@State对象传下去时,传的是整个对象,但子组件里声明接收的是@Prop而非@Link,属性名还一样。打印日志才发现根本没走到@Link分支。所以命名规范也很重要,我习惯在@Link变量名前加link_前缀,提醒自己和团队这不是普通数据。

另外,ArkUI的开发调试工具里有个很实用的功能叫"组件树快照"。在页面运行卡顿或UI异常时,截取组件树快照可以直观地看到当前页面的组件层级、状态值和尺寸信息。排查状态不同步问题时,先看快照里各组件实际持有的值,再对照代码判断是哪个环节没同步到位,通常比凭空猜要快得多。


说回整体感受。HarmonyOS的ArkUI框架发展到现在,自定义组件和布局体系已经足够成熟,熟练使用后写页面其实很顺手。我个人最深的一点体会是:组件的边界感决定了一个项目能走多远。数据从哪来、由谁改、展示在哪,这些关系在写代码前先理清楚,比后面重构省下十倍时间。

最后再分享一个小技巧:组件分层时,尽量让"容器组件"只做布局不碰数据,"内容组件"只做展示不碰业务逻辑。这样无论UI怎么改版,底层的数据模型都不用动。我的信息流页面从单列改成双列Grid,只改了一个外层容器和几个断点配置,内部卡片组件一个字符都没动过。这就是组件化真正给你的自由。

如果你在自定义组件或布局上也遇到过让我在文章里提到的这些问题,或者还有更稀奇古怪的坑,欢迎在评论区聊聊,也许你遇到的坑,恰好是我还没来得及遇到的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询