这篇讲什么
系列前四篇把环境、工程结构与 ArkTS 语法都铺完了:《鸿蒙环境搭建与工具链》、《第一个鸿蒙应用》、《ArkTS 语法要点》、《ArkTS 参考字典》。这一篇解决剩下四个问题:界面怎么摆(布局容器)、长列表怎么不卡(懒加载与复用)、页面怎么跳(Navigation 导航体系)、不同屏幕怎么适配(断点与安全区)。学完这一篇,独立搭多页面应用的 UI 地基就齐了。
一句话心智模型先立住:ArkUI 是声明式 UI,你描述”界面在数据 X 时长什么样”,框架负责把差异刷上屏。布局容器、列表、导航全都是这条原则下的组件,理解了这一点,剩下的 API 都是查字典的事。
三大布局容器:Column、Row、Stack
日常界面九成由三个容器拼出来:Column 垂直排列、Row 水平排列、Stack 层叠。每个容器都有主轴(内容排列方向)与交叉轴(垂直于排列方向),对齐参数永远分这两条轴:
| 容器 | 主轴参数 | 交叉轴参数 |
|---|---|---|
| Column | justifyContent(FlexAlign.x) | alignItems(HorizontalAlign.x) |
| Row | justifyContent(FlexAlign.x) | alignItems(VerticalAlign.x) |
| Stack | 无(层叠不排列) | alignContent(Alignment.x),后声明的孩子在上层 |
@Entry
@Component
struct LayoutDemo {
build() {
Column({ space: 12 }) { // space:主轴子项等间距,比逐个加 margin 干净
Text('账单列表')
.fontSize(20)
.alignSelf(ItemAlign.Start) // 单个孩子要脱离整体对齐时才用
Row({ space: 8 }) {
Text('本月支出').layoutWeight(1) // 弹性占位:与下面按 1:2 分掉剩余宽度
Text('¥1280.50').layoutWeight(2)
}
.width('100%')
Stack({ alignContent: Alignment.BottomEnd }) {
Column() { Text('卡片内容') }
.width('100%').height(120)
.backgroundColor('#F1F3F5')
Text('角标') // 后声明,叠在卡片右下角
}
}
.padding(16)
}
}
很多前端背景的人会问:Flex 呢?ArkUI 里确实有 Flex,但 Column/Row 本质是”单轴不换行”的简化版,官方明确说线性布局性能更好。差异摆开:
| 能力 | Column/Row | Flex |
|---|---|---|
| 换行 | 不支持,溢出直接截断 | FlexWrap.Wrap / WrapReverse |
| 弹性分配 | layoutWeight(按剩余空间比例) | flexGrow / flexShrink / flexBasis |
| 性能 | 更高,单轴场景官方推荐 | 测量逻辑更重,布局计算开销更大 |
| 该什么时候用 | 绝大多数单行单列 | 确需换行或复杂弹性分配时 |
反模式点名:把 Flex 当万金油。从 Web CSS 迁来的肌肉记忆会这么写,但单轴场景换不来任何好处,只换来更重的布局计算。单轴一律 Column/Row,需要换行再上 Flex 或专门的 Wrap 容器。
列表与懒加载:LazyForEach 与数据源
渲染控制有三个兄弟:if/else 条件渲染、ForEach 全量循环、LazyForEach 懒加载。区别在”什么时候创建组件”:ForEach 在首次渲染时把所有子项全建出来,LazyForEach 只为屏幕附近的项目建组件,滚动到哪建到哪。
几十条数据用 ForEach 没问题;上千条还用 ForEach 就是事故——一次性创建上千个组件节点,首屏时间与内存都会被拖垮。LazyForEach 要求配一个实现 IDataSource 接口的数据源,作用是把”数据变了”主动通知框架:
class RecordSource implements IDataSource {
private items: LedgerRecord[] = [];
private listeners: DataChangeListener[] = [];
totalCount(): number { return this.items.length; }
getData(index: number): LedgerRecord { return this.items[index]; }
// 这两个回调是 LazyForEach 与数据源之间的订阅关系,忘写就是"刷新失灵"
registerDataChangeListener(l: DataChangeListener): void { this.listeners.push(l); }
unregisterDataChangeListener(l: DataChangeListener): void {
this.listeners = this.listeners.filter(it => it !== l);
}
reload(items: LedgerRecord[]): void {
this.items = items;
this.listeners.forEach(l => l.onDataReloaded()); // 通知框架整体重载
}
}
// 使用:
List({ space: 8 }) {
LazyForEach(this.source, (item: LedgerRecord) => {
RecordRow({ record: item })
}, (item: LedgerRecord) => item.id.toString()) // 键值生成:同键复用节点,变键重建
}
.cachedCount(6) // 屏幕外预渲染 6 个,滚动时不出现白块
.divider({ strokeWidth: 1, color: '#EEEEEE' })
两个细节值得记:键值生成函数(第三个参数)决定框架怎么判断”哪些项变了”,用业务主键(如 id)而不是数组下标,否则删除首项会导致全列表重建;cachedCount 是滚动体验与内存的折中,常规信息流 4 到 8 够用。再往上一层是组件复用:V1 体系的 @Reusable 装饰器与较新的 Repeat 组件(自带复用能力)能把滑出的节点回炉给滑入的项用,列表项组件很重时才需要,知道有这回事即可,具体搭配以官方渲染控制文档为准。
滚动嵌套:NestedScroll
经典场景:页面顶部一个大卡片,下面一串列表,希望先滚头部、头部收起后列表接管。外层 Scroll 套内层 List,默认行为是各滚各的、互不知晓,到边就卡住。解法是内层组件声明 nestedScroll 属性,把滚动优先级说清楚:
Scroll() {
Column() {
ProfileHeader() // 顶部大卡片
List({ space: 8 }) {
LazyForEach(this.source, (item: LedgerRecord) => {
RecordRow({ record: item })
}, (item: LedgerRecord) => item.id.toString())
}
.cachedCount(6)
.height('100%') // 关键:不约束高度,List 会被外层撑成内容总高,自己永不滚动
.nestedScroll({
scrollForward: NestedScrollMode.SELF_FIRST, // 往下滚:列表先到底,再交给外层收头部
scrollBackward: NestedScrollMode.SELF_FIRST // 往回滚:列表先到顶,再让头部展开
})
}
.height('100%')
}
| 取值 | 语义 | 典型用途 |
|---|---|---|
| SELF_ONLY | 只滚自己,到边即停(默认) | 互不联动的独立区域 |
| SELF_FIRST | 自己先滚,到边后父容器接管 | 吸顶、头部收起 |
| PARENT_FIRST | 父容器先滚,到边后自己接管 | 顶部 Tab 悬停后列表滚动 |
| PARALLEL | 父子同时滚 | 视差效果 |
诚实的工程建议:滚动联动的 bug 出在细节里(惯性与边缘回弹的组合最容易翻车)。如果头部本身不复杂,把头部做成 List 的第一个 ListItem 是更省事也更稳的做法——没有联动,就没有联动 bug。nestedScroll 留给头部有复杂交互的场景。
Navigation 导航体系
鸿蒙页面路由有两套:老的 router(@ohos.router)与新的 Navigation。现状如实说:router 没有被删除,简单场景仍能跑,但官方文档已明确推荐新项目直接用 Navigation,存量 router 应用尽快迁移。差异在哪:
| 维度 | Router | Navigation |
|---|---|---|
| 形态 | 页面级路由,每个页面要在 main_pages.json 注册 | 组件级路由,一个 Navigation 承载全部目的地页 |
| 数量上限 | 32 条路由 | 无此限制 |
| 一多适配 | 不支持 | 单栏/分栏随断点自动切换 |
| 栈操作 | 基本的前进后退 | NavPathStack 可批量出栈、替换、拦截转场 |
Navigation 的三件套:Navigation 是容器;NavPathStack 是路由栈,push/pop 都在它身上;NavDestination 是目的地页的壳。最小可用骨架:
@Entry
@Component
struct Index {
pathStack: NavPathStack = new NavPathStack();
build() {
Navigation(this.pathStack) {
HomeContent() // 首页内容直接放这里,不进栈
}
.mode(NavigationMode.Stack) // Stack 单栏;Split 分栏;Auto 随断点切换
.navDestination(this.PageMap) // 目的地页按名字统一分发
}
@Builder
PageMap(name: string) {
if (name === 'recordDetail') {
RecordDetail()
} else if (name === 'settings') {
SettingsPage()
}
}
}
@Component
struct RecordDetail {
pathStack: NavPathStack = new NavPathStack();
build() {
NavDestination() { // 目的地页必须以 NavDestination 为根
Text('详情')
}
.onReady((ctx: NavDestinationContext) => {
this.pathStack = ctx.pathStack; // 在这里拿栈,比全局传引用规范
const id = ctx.pathInfo.param as number; // 取跳转参数
})
}
}
// 任何拿到 pathStack 的地方:
// this.pathStack.pushPath({ name: 'recordDetail', param: 42 });
// this.pathStack.pop();
API 9 时代 Navigation 要配 NavRouter 用,API 10 起 NavRouter 已不推荐,上面这套 NavPathStack + navDestination 写法就是当前标准答案。给迁移者一句实话:两个页面的老 demo 用 router 无伤大雅,但新代码没有理由再从 router 起步。
一多的底座:断点与安全区
鸿蒙应用天然跑在手机、折叠屏、平板、PC 一族设备上,官方称”一次开发,多端部署”(简称一多)。底座是两件事:断点(breakpoint)划分屏幕宽度档位,安全区(safe area)处理状态栏、挖孔、手势条。
| 断点 | 宽度范围(vp) | 典型设备 |
|---|---|---|
| xs | [0, 320) | 极窄设备、部分穿戴 |
| sm | [320, 600) | 手机竖屏 |
| md | [600, 840) | 折叠屏展开、平板竖屏 |
| lg | [840, +∞) | 平板横屏、PC |
按断点重排界面的首选是栅格组件 GridRow/GridCol,不用自己监听屏幕尺寸:
GridRow({
columns: { sm: 4, md: 8, lg: 12 }, // 显式声明各断点列数,不依赖默认值
breakpoints: { value: ['320vp', '600vp', '840vp'] },
onBreakpoint(({ breakpoint }) => { // 需要在代码里感知档位变化时用
this.currentBp = breakpoint // 'sm' / 'md' / 'lg'
})
}) {
GridCol({ span: { sm: 4, md: 6, lg: 4 } }) { // 手机整行、平板半行、大屏三分之一
RecordCard()
}
}
自适应布局的另一组常客是 Row 在 sm 断点换成 Column 的场景,用断点切换容器方向即可;再往上还有媒体查询(mediaquery),入门阶段栅格够用。安全区方面,默认内容会避开状态栏与手势条;要做沉浸式(背景延伸到状态栏底下)用通用属性 expandSafeArea:
Column() {
TitleBar()
Content()
}
.backgroundColor('#F1F3F5')
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP]) // 只把背景探进状态栏,手势条区域不动
反模式点名:写死像素尺寸、把手机竖屏当唯一目标。vp 已经密度无关,再加断点栅格,适配成本很低;反过来,写死尺寸的应用上折叠屏第一秒就穿帮。另一个坑是沉浸式只扩背景不避让内容——文字顶进挖孔区,标题被摄像头切掉一半。
收尾
这一篇给了四块地基:布局容器怎么选(Column/Row 优先,Flex 留给换行)、长列表怎么懒加载(LazyForEach + IDataSource + cachedCount)、页面怎么导航(Navigation + NavPathStack + NavDestination)、多端怎么适配(断点栅格 + 安全区)。边界也说清了:组件复用与复杂滚动联动属于进阶件,需要时再查。
收官动作是把全部知识压进一个真项目:下一篇《用 ArkTS 做一个记账小工具》会走完持久化选型、状态管理选型与列表性能三条决策链,Navigation 与 LazyForEach 都会在里面落地。做不做得出来,是检验这六篇的唯一标准。
版本核实(2025-01):当前鸿蒙商用版本为 HarmonyOS NEXT 5.0(API 12,2024-10 正式发布),开发工具 DevEco Studio 5.0 线。本文全部接口在 API 12 基线均可用(其中 expandSafeArea 自 API 11 起、NavPathStack 写法自 API 10 起为官方推荐);个别接口起始版本以官方文档为准。