HarmonyOS自定义组件与布局实战:从Stack/Flex到组件化重构

📅 发布时间:2026/9/11 6:00:58
HarmonyOS自定义组件与布局实战:从Stack/Flex到组件化重构
写自定义组件和布局是每个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) } }然后在List的itemGenerator里声明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但可以单独设置每个子组件的alignContentStack({ alignContent: Alignment.BottomCenter }) { Column() { // 子组件内容 } .width(200) .height(100) .margin({ bottom: 100 }) }这个写法实际上让子组件对齐到BottomCenter再用margin向上推100。简单直接而且不需要额外计算。但有个局限margin也参与布局占位如果父Stack需要精确控制子组件不超出边界这个写法有时候会出问题。第二种绝对定位。用position或offset手动指定坐标。这两者的区别是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也能做但需要手动设置中间元素的layoutWeightRow({ 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组件高度封装直接设置columnsTemplate和rowsTemplate就能快速铺一个棋盘格Grid() { ForEach(this.itemList, (item: string) { GridItem() { Text(item) .height(100) } }, (item: string) item) } .columnsTemplate(1fr 1fr 1fr) .rowsTemplate(1fr 1fr 1fr)这里的1fr是网格单位类似Flex中的layoutWeight。columnsTemplate(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有完整的栅格系统组件GridRow和GridColumn专门用来做自适应布局。栅格的思路是把一行划分为若干列默认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定位又放在布局流里就很容易覆盖到其他组件上。我的经验法则是能用布局流完成的定位绝对不用positionposition只留给绝对浮层角标、悬浮球、弹窗。3.3 滚动场景懒加载和列表性能滚动列表项目一大就容易卡处理办法是使用List组件而不是简单嵌套Scroll内放ForEach。List组件配合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接口提供totalCount、getData、registerDataChangeListener等方法。虽然实现起来比普通数组麻烦一点但这是列表性能的底线。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 IDataSourceFlex子组件高度撑不开父Flex的AlignItems默认拉伸需要显式设置明确设置alignItems或给子组件加height比例宽度莫名异常百分比宽度的参照系不是期望的父容器逐层检查尺寸约束必要时用layoutWeight5.2 布局重叠与尺寸异常排查说到布局重叠我分享一个非常容易犯的错误给同一个组件同时使用alignSelf和position。alignSelf是相对所在父容器的对齐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只改了一个外层容器和几个断点配置内部卡片组件一个字符都没动过。这就是组件化真正给你的自由。如果你在自定义组件或布局上也遇到过让我在文章里提到的这些问题或者还有更稀奇古怪的坑欢迎在评论区聊聊也许你遇到的坑恰好是我还没来得及遇到的。