ArkUI 布局与导航:从 Column/Row 到 Navigation

这篇讲什么

系列前四篇把环境、工程结构与 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 起为官方推荐);个别接口起始版本以官方文档为准。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇