尺寸单位与像素换算
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 必须把所有会被渲染的状态重置到位,否则会看到上一个数据项的残留内容。
常见坑清单
- 深层嵌套导致 measure 开销爆炸。每多一层容器就多一次测量传递,优先用 RelativeContainer 或 Grid 把层级压平,超过五六层就该重构。
- List 用 ForEach 而非 LazyForEach。ForEach 会一次性构建全部子组件,数据量上来直接卡死,长列表必须换 LazyForEach。
- Scroll 嵌套 List 手势冲突。默认外层会截获滑动,需要给内层 List 配 nestedScroll 指定 PARENT_FIRST 或 SELF_FIRST。
- Flex 未设宽高导致测量异常。主轴尺寸不确定时对齐与换行都会失准,务必显式给 Flex 设 width 或 height。
- @Styles 不支持参数化。想传参只能用 @Extend 或 @Builder,硬写参数会编译失败。
- 百分比尺寸在父容器未定尺寸时失效。父容器高度为 auto 时,子组件 height 写百分号不会生效。
- LazyForEach 数据变了但不通知。直接改数组不调用 DataChangeListener 回调,界面不会刷新。
- keyGenerator 返回不稳定值。用索引当 key 会在增删时错位复用,必须用业务唯一 id。
- @Reusable 组件忘记重置状态。aboutToReuse 里没覆盖到的状态会串到下一个数据项。
- 在 onDidBuild 里改状态。这会导致刷新循环,只应做日志与统计。
小结
ArkUI 的布局能力可以压缩成一句话:优先用 Row 与 Column 这类低成本容器,需要换行才上 Flex,需要层叠用 Stack,需要锚点压平层级用 RelativeContainer,而任何长列表都必须走 List 加 LazyForEach 加 @Reusable 这条链路。单位上坚持 vp 布局、fp 字号、百分比用于弹性,px 只在像素级细节出现。自定义组件的复用手段各有边界,@Builder 管片段、@BuilderParam 管插槽、@Styles 与 @Extend 管样式、@Reusable 管实例,用错一个就会出现"看着能跑、滚动就崩"的问题。布局与状态如何配合,见 ArkUI 声明式 UI 与状态管理 ;多设备下的尺寸与断点适配,见 HarmonyOS 多设备适配 ;组件可访问性与语义化标注的实践,可对照 Flutter 无障碍设计 一起看。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。