ArkUI 布局系统与自定义组件

系统梳理 HarmonyOS NEXT 上 ArkUI 的布局能力:vp、fp、lpx 与百分号的换算关系,Row/Column、Flex、Stack、RelativeContainer、Grid、List 等容器的选型建议,用完整代码演示 LazyForEach 与 IDataSource 的列表优化,并覆盖 @Builder、@Styles 与组件复用池原理。

尺寸单位与像素换算

ArkUI 的尺寸体系把设计稿单位与物理像素解耦。写死 px 会在不同 DPI 设备上出现肉眼可见的缩放错位,因此框架提供四种单位加一种相对表达。

vp 是 virtual pixel,密度无关像素,以 160dpi 为基准,公式为 1vp = (设备DPI / 160) px。宽高、间距、圆角一律用 vp,这是默认单位。fp 是 font pixel,与 vp 换算规则相同,但额外跟随系统"字体大小"设置缩放,用于 fontSize,是无障碍适配的基础。lpx 是 logical pixel,按设计稿宽度等比缩放:1lpx = (屏幕实际宽度 / 设计稿基准宽度) px,适合整体等比还原,代价是不同尺寸设备上视觉密度不一致。px 是物理像素,只在像素级细节使用,此时更推荐 px2vp 与 vp2px 显式换算。百分号相对父容器,宽度百分比相对父宽、高度百分比相对父高,父容器未定尺寸时百分比失效。

import { display } from '@kit.ArkUI';

// 16fp 字号随系统字体缩放;padding 用 vp
Text(`屏宽 ${px2vp(display.getDefaultDisplaySync().width)}vp`)
  .fontSize(16)
  .padding(16)

布局属性详解

width 与 height 接受数值(vp)、百分比字符串或 'auto'。constraintSize 设置约束范围,形如 constraintSize({ minWidth: 100, maxWidth: 300, minHeight: 40 }),用于弹性尺寸。margin 与 padding 结构相同,接受 { top, right, bottom, left } 或统一数值,区别是 margin 在边框外、padding 在边框内。

alignItems 与 justifyContent 的语义随容器主轴变化。Row 里 justifyContent 管水平分布、alignItems 管垂直对齐;Column 相反,取值分别是 FlexAlign 与 HorizontalAlign、VerticalAlign 枚举。

layoutWeight 是弹性分配的关键属性,同一容器内设置了它的子组件按权重瓜分剩余空间,常用于"左侧固定、右侧撑满":

Row() {
  Text('头像').width(48)
  Column() {
    Text('标题').fontSize(16)
    Text('副标题').fontSize(12).fontColor('#8C8C8C')
  }
  .layoutWeight(1)              // 吃掉除 48vp 外的全部宽度
  .alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding({ left: 16, right: 16 })

布局容器选型

容器选择的核心判断只有一条:这个区域的排列规则是线性、层叠、锚点还是虚拟化。

Row 与 Column

线性排列的最小单位,测量开销最低,优先使用,space 参数设置间距:

Column({ space: 12 }) {
  Row({ space: 8 }) {
    Image($r('app.media.icon')).width(24).height(24)
    Text('账号与安全')
  }
  Divider()
}

Flex

Flex 是 Row 与 Column 的超集,额外支持换行与更丰富的对齐,代价是测量逻辑更重:

Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap,
  justifyContent: FlexAlign.SpaceBetween, alignItems: ItemAlign.Center }) {
  ForEach(['鸿蒙', 'ArkTS', 'ArkUI'], (tag: string) => {
    Text(tag).fontSize(12).padding(8).backgroundColor('#F1F3F5').borderRadius(12)
  }, (tag: string) => tag)
}
.width('100%')

wrap 默认是 NoWrap,必须显式设为 FlexWrap.Wrap 才换行。Flex 在主轴尺寸不确定时会先按子组件固有尺寸测量,容器未设宽高容易出现溢出或对齐失效。

Stack

Stack 沿 z 轴层叠,后声明的子组件盖在先声明的之上,alignContent 控制整体对齐位置,适合角标与遮罩:

Stack({ alignContent: Alignment.TopEnd }) {
  Image($r('app.media.cover')).width(120).height(120).borderRadius(8)
  Text('NEW').fontSize(10).fontColor(Color.White)
    .backgroundColor('#E84026').padding(4).borderRadius(4).margin(6)
}

RelativeContainer

锚点式布局,子组件通过 alignRules 声明"我的左边对齐谁的右边",能把深层嵌套压平,适合复杂但静态的表单与卡片。代价是 id 引用关系需人工维护,__container__ 是容器自身保留 id:

RelativeContainer() {
  Text('用户名').id('label').alignRules({
    left: { anchor: '__container__', align: HorizontalAlign.Start },
    top: { anchor: '__container__', align: VerticalAlign.Top }
  })
  TextInput({ placeholder: '请输入' }).id('input').margin({ left: 12 }).alignRules({
    left: { anchor: 'label', align: HorizontalAlign.End },
    right: { anchor: '__container__', align: HorizontalAlign.End },
    top: { anchor: 'label', align: VerticalAlign.Top }
  })
}
.width('100%').height(48)

Grid 与 GridItem

网格布局,用 columnsTemplate 与 rowsTemplate 声明轨道,'1fr 1fr 1fr' 表示三等分:

Grid() {
  ForEach(this.items, (item: string) => {
    GridItem() { Text(item).fontSize(14) }
  }, (item: string) => item)
}
.columnsTemplate('1fr 1fr 1fr')
.columnsGap(8).rowsGap(8)

List 与 ListItem

列表是移动端最核心的容器,也是性能问题最集中的地方。List 自带回收机制与 cachedCount 预加载,配合 LazyForEach 才能发挥全部价值:

List({ space: 8 }) {
  LazyForEach(this.source, (item: FeedItem) => {
    ListItem() { FeedCard({ item: item }) }
  }, (item: FeedItem) => item.id)
}
.cachedCount(3)                 // 视口外预加载 3 屏
.divider({ strokeWidth: 1, color: '#F1F3F5' })

Scroll

Scroll 只接受一个子组件,用于包裹超出屏幕的内容。嵌套 List 时必须显式设置嵌套滚动模式,否则会出现外层吃掉手势、内层滑不动:

Scroll() {
  Column({ space: 16 }) {
    Banner()
    List() { /* 列表内容 */ }
      .height(400)
      .nestedScroll({
        scrollForward: NestedScrollMode.PARENT_FIRST,
        scrollBackward: NestedScrollMode.SELF_FIRST
      })
  }
}
.scrollBar(BarState.Off)

Swiper

轮播容器,支持自动播放、循环与指示器,常用于顶部 Banner:

Swiper() {
  ForEach(this.banners, (url: string) => {
    Image(url).width('100%').height(180).borderRadius(12)
  }, (url: string) => url)
}
.autoPlay(true).interval(3000).loop(true)
.indicator(Indicator.dot().itemWidth(6).itemHeight(6))

Tabs

标签页容器,TabContent 与 tabBar 一一对应,barPosition 控制标签栏位置:

Tabs({ barPosition: BarPosition.Start }) {
  TabContent() { Text('推荐内容') }.tabBar('推荐')
  TabContent() { Text('关注内容') }.tabBar('关注')
}
.onChange((index: number) => { this.currentIndex = index; })

WaterFlow

瀑布流,列高不固定时自动寻找最短列插入,适合图片流:

WaterFlow() {
  ForEach(this.photos, (p: Photo) => {
    FlowItem() { Image(p.url).width('100%').borderRadius(8) }
  }, (p: Photo) => p.id)
}
.columnsTemplate('1fr 1fr').columnsGap(8)

容器选型对照表

容器适用场景性能注意
Row / Column线性排列,最常用首选,测量开销最低
Flex需要换行或多轴对齐测量较重,务必显式设宽高
Stack层叠、角标、遮罩层数不宜过深,避免遮挡点击
RelativeContainer复杂静态表单、卡片减少嵌套层数,但 id 关系需维护
Grid固定行列的网格数据量大时同样要配 LazyForEach
List长列表、动态数据必须用 LazyForEach 加 cachedCount
Scroll单页超屏内容嵌套 List 需配 nestedScroll
Swiper轮播 Banner子项控制在 3 到 5 个
Tabs分页切换配合懒加载避免全部 TabContent 初始化
WaterFlow不定高瀑布流依赖数据源,建议配 LazyForEach

列表性能:LazyForEach 与 IDataSource

ForEach 会一次性构建全部子组件,数据量上百就会明显卡顿。LazyForEach 按需构建,但要求数据源实现 IDataSource 接口。

@Observed
class FeedItem {
  id: string;
  title: string;
  liked: boolean;

  constructor(id: string, title: string, liked: boolean) {
    this.id = id;
    this.title = title;
    this.liked = liked;
  }
}

class FeedDataSource implements IDataSource {
  private items: FeedItem[] = [];
  private listeners: DataChangeListener[] = [];

  totalCount(): number {
    return this.items.length;
  }

  getData(index: number): FeedItem {
    return this.items[index];
  }

  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener);
    }
  }

  unregisterDataChangeListener(listener: DataChangeListener): void {
    const pos = this.listeners.indexOf(listener);
    if (pos >= 0) { this.listeners.splice(pos, 1); }
  }

  push(item: FeedItem): void {
    this.items.push(item);
    this.listeners.forEach((l: DataChangeListener) => {
      l.onDataAdd(this.items.length - 1);
    });
  }

  update(index: number): void {
    this.listeners.forEach((l: DataChangeListener) => l.onDataChange(index));
  }

  remove(index: number): void {
    this.items.splice(index, 1);
    this.listeners.forEach((l: DataChangeListener) => {
      l.onDataDelete(index);
    });
  }

  reload(next: FeedItem[]): void {
    this.items = next;
    this.listeners.forEach((l: DataChangeListener) => l.onDataReloaded());
  }
}

数据源就绪后,页面侧只需持有实例并交给 LazyForEach:

@Component
struct FeedCard {
  @ObjectLink item: FeedItem;

  build() {
    Column({ space: 6 }) {
      Text(this.item.title).fontSize(15).maxLines(2)
      Text(this.item.liked ? '已赞' : '点赞')
        .fontSize(12)
        .onClick(() => { this.item.liked = !this.item.liked; })
    }
    .padding(12)
    .width('100%')
  }
}

@Entry
@Component
struct FeedPage {
  private source: FeedDataSource = new FeedDataSource();

  aboutToAppear(): void {
    this.source.reload([
      new FeedItem('1', 'ArkUI 布局入门', false),
      new FeedItem('2', '状态管理进阶', true)
    ]);
  }

  build() {
    List({ space: 8 }) {
      LazyForEach(this.source, (item: FeedItem) => {
        ListItem() { FeedCard({ item: item }) }
      }, (item: FeedItem) => item.id)
    }
    .cachedCount(3)
    .width('100%')
    .height('100%')
  }
}

要点有三个:keyGenerator 必须返回稳定唯一值,否则复用会错位;数据变更必须通过监听器回调通知,直接改数组不通知等于界面不动;列表项组件用 @ObjectLink 接收 @Observed 对象,才能让点赞这类局部修改只刷新单项。

自定义组件能力

@Builder 与全局 @Builder

组件内 @Builder 复用局部 UI,全局 @Builder 可跨文件复用。二者都支持参数,但按值传递的参数不驱动刷新,需要联动时用 $$:

@Builder
function tagChip(text: string) {
  Text(text).fontSize(12).padding(8).backgroundColor('#E8F0FE').borderRadius(4)
}

@BuilderParam

@BuilderParam 让自定义组件接收外部传入的 UI 片段,等价于插槽,可由 @Builder 函数初始化,也可用尾随闭包传入:

@Component
struct CardShell {
  @BuilderParam content: () => void;

  build() {
    Column({ space: 8 }) {
      Text('卡片标题').fontSize(16).fontWeight(FontWeight.Bold)
      this.content()
    }
    .padding(16)
    .backgroundColor(Color.White)
    .borderRadius(12)
  }
}

@Entry
@Component
struct Page {
  build() {
    CardShell() {
      Text('由外部注入的内容').fontSize(14)
    }
  }
}

当组件有多个 @BuilderParam 时,必须用 @Builder 函数显式赋值,尾随闭包只能对应唯一一个。

@Styles 与 @Extend

@Styles 抽取通用属性集合,@Extend 抽取特定组件加属性并支持参数化:

@Styles function cardStyle() {
  .width('100%')
  .padding(16)
  .backgroundColor(Color.White)
  .borderRadius(12)
}

@Extend(Text) function priceText(size: number) {
  .fontSize(size)
  .fontColor('#E84026')
  .fontWeight(FontWeight.Bold)
}

@Styles 只能包含通用属性,不能带参数;@Extend 只能作用于指定组件类型,可以带参数。想要"带参数的通用样式",只能拆成 @Extend 或改用 @Builder。

@Require 与 @Reusable

@Require(API 11 起)强制构造时传参,把漏传从运行时前移到编译期。@Reusable(API 10 起)把组件实例放进复用池,列表滚动时不再反复创建销毁:

@Reusable                                    // API 10 起,进入复用池
@Component
struct ReusableRow {
  @Require @Prop id: string;                 // API 11 起,构造必传
  @State title: string = '';

  aboutToReuse(params: Record<string, Object>): void {
    this.title = params.title as string;
  }

  aboutToRecycle(): void { this.title = ''; }

  build() { Text(this.title).fontSize(14).padding(12).width('100%') }
}

能力对照表

能力作用域是否支持参数典型用途
@Builder组件内或全局支持,联动需 $$复用 UI 片段
@BuilderParam组件成员由外部注入插槽式内容分发
@Styles通用属性不支持卡片、列表项通用样式
@Extend指定组件类型支持带参的组件专属样式
@Require组件成员不适用强制构造传参
@Reusable组件级通过 aboutToReuse 接收列表项复用降开销

组件生命周期与复用池

自定义组件的生命周期回调按顺序为:aboutToAppear(build 之前,做数据初始化)、onDidBuild(build 之后,API 12 起,适合做不依赖布局结果的统计与埋点)、aboutToDisappear(组件销毁,做资源释放)。注意 onDidBuild 中不要修改状态变量,否则会触发再次刷新。

@Component
struct Tracked {
  private startTime: number = 0;

  aboutToAppear(): void { this.startTime = Date.now(); }

  onDidBuild(): void {                       // API 12 起
    console.info(`首帧耗时 ${Date.now() - this.startTime}ms`);
  }

  aboutToDisappear(): void { this.startTime = 0; }

  build() { Text('被统计的组件') }
}

复用池的原理是:List、Grid、Swiper 等容器在子项滑出视口后,把标了 @Reusable 的组件实例回收进池,滑入新数据时不再执行构造函数,而是调用 aboutToReuse 传入新参数。这带来两条约束:其一,组件内不能把状态当作跨数据项持久的缓存,因为实例会被复用给别的数据;其二,aboutToReuse 必须把所有会被渲染的状态重置到位,否则会看到上一个数据项的残留内容。

常见坑清单

  1. 深层嵌套导致 measure 开销爆炸。每多一层容器就多一次测量传递,优先用 RelativeContainer 或 Grid 把层级压平,超过五六层就该重构。
  2. List 用 ForEach 而非 LazyForEach。ForEach 会一次性构建全部子组件,数据量上来直接卡死,长列表必须换 LazyForEach。
  3. Scroll 嵌套 List 手势冲突。默认外层会截获滑动,需要给内层 List 配 nestedScroll 指定 PARENT_FIRST 或 SELF_FIRST。
  4. Flex 未设宽高导致测量异常。主轴尺寸不确定时对齐与换行都会失准,务必显式给 Flex 设 width 或 height。
  5. @Styles 不支持参数化。想传参只能用 @Extend 或 @Builder,硬写参数会编译失败。
  6. 百分比尺寸在父容器未定尺寸时失效。父容器高度为 auto 时,子组件 height 写百分号不会生效。
  7. LazyForEach 数据变了但不通知。直接改数组不调用 DataChangeListener 回调,界面不会刷新。
  8. keyGenerator 返回不稳定值。用索引当 key 会在增删时错位复用,必须用业务唯一 id。
  9. @Reusable 组件忘记重置状态。aboutToReuse 里没覆盖到的状态会串到下一个数据项。
  10. 在 onDidBuild 里改状态。这会导致刷新循环,只应做日志与统计。

小结

ArkUI 的布局能力可以压缩成一句话:优先用 Row 与 Column 这类低成本容器,需要换行才上 Flex,需要层叠用 Stack,需要锚点压平层级用 RelativeContainer,而任何长列表都必须走 List 加 LazyForEach 加 @Reusable 这条链路。单位上坚持 vp 布局、fp 字号、百分比用于弹性,px 只在像素级细节出现。自定义组件的复用手段各有边界,@Builder 管片段、@BuilderParam 管插槽、@Styles 与 @Extend 管样式、@Reusable 管实例,用错一个就会出现"看着能跑、滚动就崩"的问题。布局与状态如何配合,见 ArkUI 声明式 UI 与状态管理 ;多设备下的尺寸与断点适配,见 HarmonyOS 多设备适配 ;组件可访问性与语义化标注的实践,可对照 Flutter 无障碍设计 一起看。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「鸿蒙开发」更多文章

  1. 鸿蒙 ohpm 包管理与 Hypium 测试框架
  2. ArkUI 动画体系与手势交互
  3. 鸿蒙应用安全:权限模型与 HUKS 密钥管理