一个晚上,四个“小问题”:我重写了 12 章 ArkUI 笔记

从 Vue 到鸿蒙:12 章 70 个 Demo 讲透 ArkUI

写给前端开发者的 ArkUI 系统笔记:从声明式语法、布局、状态管理,到渲染控制、动画、性能优化与工程实践。每章一个最小可运行 Demo,配 Vue 视角对照和踩坑清单,帮你把 ArkUI 的知识点串成一条完整的线。

一个晚上,四个"小问题"

我遇到过一个阅读器项目的页面改造,需求写出来只有三行:设置页支持调字号,目录页支持一万章,阅读页加一个可显隐的工具栏。

当时我的判断是:都是小改动,一个晚上够了。

第一个问题来得很快。字号滑块拖动了,正文纹丝不动。把 fontSize 从普通字段改成 @Local,界面终于动了;可当字号被收进一个 settings 对象后,settings.fontSize++ 又失效了------@Local 只盯着"引用有没有被替换",并不盯着"对象里的字段有没有被修改"。真正的答案是给字段加 @Trace。

第二个问题是目录页。滑到三百章左右开始明显掉帧,因为一万个 ListItem 在进入页面的那一刻就全部建好了。ForEach 没错,错在我用它渲染了一个本该按需构建的列表。

第三个问题最隐蔽。工具栏用 opacity 隐藏之后,我点阅读区正中间,设置面板弹了出来------看不见的按钮依然在接收点击。视觉上的"消失"和交互上的"消失"是两件事。

第四个问题发生在收尾阶段。下拉刷新转了很久,请求早就返回了,refreshing 却一直是 true。少写了一个复位,用户就只能看着转圈。

四个问题,四个互不相干的 API。但把根因写在同一张纸上,我发现它们指向同一句话:

我一直在问"哪个 API 能用",而没有先问"这件事该由谁负责"。

状态归谁、渲染多少、事件命中谁、资源何时释放------这四个判断没有建立起来,写再多 Demo,也还是会在同一个地方卡住。

后来我把这套判断拆成 12 个章节重新梳理了一遍,就是下面这篇笔记。你可以把它当成一条从"能跑"到"能维护"的路线图。

写在前面

如果你是从 Web 前端转到 HarmonyOS 开发,大概率经历过这样的阶段:

  • 组件能抄下来跑起来,但换个页面就不知道该用哪个容器;
  • @State、@Local、@Param、@Link 看着都眼熟,一到父子传值就混用;
  • 列表数据一多就卡,不知道该用 ForEach 还是 LazyForEach;
  • 弹窗、动画、路由都能写,但写完之后代码越堆越乱。

问题通常不在于"某个 API 不会",而在于没有建立一套稳定的判断依据:面对一个需求,先想清楚数据怎么流、状态放在哪一层、组件边界在哪里,再去选 API。

这篇文章把 12 个章节、70 个 Demo 重新整理成一篇可以一口气读完的实战笔记。每个知识块都遵循同一个节奏:先给最小代码,再解释为什么这么写,最后列出容易踩的坑。

阅读约定:

  1. 默认使用 ArkUI 状态管理 V2 (@ComponentV2 + @Local),V1 装饰器单独在第 04 章做对照。
  2. 每段代码都是独立可运行的 Index.ets,复制到 entry/src/main/ets/pages/ 下即可(注意不要同时保留多个 @Entry)。
  3. API 会随 SDK 版本演进,遇到标红请以 DevEco Studio 的签名提示为准。
  4. 全文用"阅读器"这一条业务线贯穿示例,方便你把零散知识点串成一个真实产品。

知识地图:

阶段 章节 解决的问题
入门 01 基础语法 一个 ArkUI 页面由什么构成
入门 02 布局系统 界面怎么摆、空间怎么分
入门 03 基础组件 展示与输入怎么落地
核心 04 状态管理 数据变化怎么驱动界面
核心 05 渲染控制 什么时候渲染、渲染多少
交互 06 事件系统 用户操作怎么接
交互 07 弹窗与交互 打断式交互怎么设计
交互 08 动画系统 状态变化怎么过渡
进阶 09 列表进阶 万级数据怎么不卡
进阶 10 高级组件 多页、翻页、刷新、瀑布流、H5
进阶 11 性能优化 卡顿怎么定位、怎么治
落地 12 工程实践 项目怎么分层、路由、持久化、请求

01 基础语法:先看懂这条"响应式闭环"

ArkUI 的页面本质上是状态的函数 :build() 里声明界面长什么样,状态一变,框架重新计算受影响的 UI。所有语法都是围绕这条链路展开的。

text 复制代码
声明状态 → build() 读取状态 → 渲染出组件树
     ↑                                ↓
     └──── 事件回调修改状态 ←──── 用户操作

记住这条闭环,后面所有章节都是它的延伸。

1.1 最小页面结构

typescript 复制代码
@Entry          // 标记页面入口,一个文件只能有一个
@ComponentV2    // 使用状态管理 V2 的组件
struct Index {
  build() {     // 唯一必须实现的方法,描述 UI
    Column() {
      Text('Hello ArkUI').fontSize(28)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

三个要点:

  • @Entry 决定"谁能被路由当页面加载",普通子组件不要加;
  • struct 而不是 class,因为组件是值语义的 UI 描述;
  • build() 里只能写 UI 描述,不要在里面发请求、改状态,否则渲染时机不可控。

1.2 状态:普通字段 ≠ 响应式字段

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  // 普通字段:值会变,但不会触发 UI 刷新
  private plainCount: number = 0
  // 响应式状态:赋值即触发依赖它的 UI 重新渲染
  @Local count: number = 0

  build() {
    Column({ space: 16 }) {
      Text(`响应式:${this.count}`).fontSize(24)
      Text(`普通字段:${this.plainCount}`).fontSize(24)
      Button('+1').onClick(() => {
        this.count++        // 界面会更新
        this.plainCount++   // 界面不会更新
      })
    }.padding(24)
  }
}

这是新手最常见的第一道坎:不是"值变了界面就该变",而是"被响应式装饰器标记的值变了,界面才会变"。

1.3 条件表达式与循环

三元表达式用于属性值切换 ,if 用于组件的创建与销毁,两者的语义完全不同(第 05 章会详细展开)。

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local dark: boolean = false
  @Local books: string[] = ['三体', '诛仙', '凡人修仙传']

  build() {
    Column({ space: 16 }) {
      // 三元:只切换文本内容,Text 组件始终存在
      Text(this.dark ? '夜间模式' : '日间模式')

      // ForEach:第三参数是 keyGenerator,必须稳定且唯一
      ForEach(this.books, (book: string) => {
        Text(book).padding(12)
      }, (book: string) => book)

      Button('切换主题').onClick(() => { this.dark = !this.dark })
    }.padding(24)
  }
}

踩坑: ForEach 的 key 用数组索引(index.toString())在"可排序、可删除"的列表里会导致复用错位------删除第 2 项后,第 3 项复用了第 2 项的组件状态。key 请使用业务唯一 ID。

1.4 @Builder:把重复的 UI 抽成函数

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local count: number = 0

  // 可复用的 UI 片段,可读取组件内的响应式状态
  @Builder counter() {
    Column({ space: 12 }) {
      Text(`次数:${this.count}`)
      Button('加一').onClick(() => { this.count++ })
    }
  }

  build() {
    Column() { this.counter() }.padding(20)
  }
}

@Builder 是"UI 函数",适合抽公共片段;需要独立状态和生命周期时应改用子组件。它对参数的响应式追踪有特殊规则,第 05 章会专门讲。

1.5 Vue 开发者的概念映射

Vue 3 ArkUI(V2) 说明
template build() 声明式描述 UI,不能写逻辑语句
ref() / reactive() @Local 组件内响应式状态
props @Param 父传子,单向
emit @Event 子传父,本质是回调函数
v-if if 创建/销毁节点
v-for ForEach / Repeat 集合渲染
computed @Computed 派生状态
watch @Monitor 监听变化做副作用
<slot> @BuilderParam 内容插槽
CSS Flex Column / Row / Flex 布局容器

注意: ArkTS 是强类型语言,没有 HTML 标签与 CSS 选择器,class 样式写法不适用,所有样式都通过链式属性方法设置。

01 章速查

  • 页面 = @Entry + @ComponentV2 + build();
  • 只有被 @Local 等装饰器标记的字段才驱动刷新;
  • build() 只描述 UI,不做副作用;
  • ForEach 的 key 必须稳定唯一;
  • UI 复用优先 @Builder,有独立状态用子组件。

02 布局系统:先定约束,再谈排列

ArkUI 的布局心法只有一句话:先看父容器给了多大空间,再决定方向、主轴/交叉轴、剩余空间分配和是否滚动。

2.1 Column / Row:主轴与交叉轴

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  build() {
    // Column 主轴是纵向,justifyContent 控制纵向对齐
    Column({ space: 12 }) {
      Text('标题').fontSize(24)
      Text('副标题')
      Button('开始阅读')
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center) // 主轴居中
  }
}

space 是子元素间距,比给每个子元素加 margin 更好维护。justifyContent 作用于主轴,alignItems 作用于交叉轴,这是 Flex 体系通用的心智模型。

2.2 用 Blank 做"两端对齐"

typescript 复制代码
Row({ space: 12 }) {
  Text('返回')
  Blank()      // 自动撑满剩余空间,把后面的内容推到最右
  Text('设置')
}
.width('100%')
.padding(20)

这等价于 CSS 的 margin-left: auto,是写导航栏最省事的方式。

2.3 Flex 换行:标签云的常见写法

typescript 复制代码
Flex({ wrap: FlexWrap.Wrap }) {
  ForEach(['玄幻', '科幻', '都市', '历史', '悬疑', '言情'], (tag: string) => {
    Text(tag)
      .padding(12)
      .margin(4)
      .backgroundColor('#E6F0FF')
  }, (tag: string) => tag)
}.padding(16)

踩坑: ArkUI 的 Flex 不是 CSS Flex 的完整实现,属性枚举和能力边界以 SDK 为准,复杂场景优先考虑 Grid 或 WaterFlow。

2.4 Stack:叠层与角标

typescript 复制代码
Stack({ alignContent: Alignment.TopEnd }) {
  Column() {
    Text('封面').fontSize(26)
  }
  .width(160)
  .height(220)
  .backgroundColor('#CFE6FF')
  .justifyContent(FlexAlign.Center)

  // 角标:后写的组件绘制在上层
  Text('热门')
    .fontColor(Color.White)
    .backgroundColor('#E84B4B')
    .padding(6)
}.padding(20)

Stack 的子组件按书写顺序叠放,后面的盖住前面的。角标、遮罩、悬浮按钮都靠它。

2.5 Grid:规则网格(书架)

typescript 复制代码
Grid() {
  ForEach([1, 2, 3, 4, 5, 6, 7, 8, 9], (id: number) => {
    GridItem() {
      Text(`书籍 ${id}`)
        .height(100)
        .width('100%')
        .textAlign(TextAlign.Center)
        .backgroundColor('#E6F0FF')
    }
  }, (id: number) => id.toString())
}
.columnsTemplate('1fr 1fr 1fr') // 三列等宽
.columnsGap(10)
.rowsGap(10)
.padding(12)

2.6 GridRow / GridCol:十二栅格与偏移

typescript 复制代码
GridRow({ columns: 12 }) {
  GridCol({ span: 4 }) {
    Text('4 列').backgroundColor('#DBEAFE').height(80)
  }
  GridCol({ span: 8 }) {
    Text('8 列').backgroundColor('#DCFCE7').height(80)
  }
}.padding(16)
typescript 复制代码
// 偏移 3 列 + 占 6 列 = 水平居中(3 + 6 + 3 = 12)
GridRow({ columns: 12 }) {
  GridCol({ span: 6, offset: 3 }) {
    Text('居中的 6 列').width('100%').height(90).backgroundColor('#DBEAFE')
  }
}.padding(16)

踩坑: offset 是栅格位置偏移,不是动画位移;动画请用 translate。

2.7 RelativeContainer:锚点定位

typescript 复制代码
RelativeContainer() {
  Text('左上')
    .id('left')
    .alignRules({
      top: { anchor: '__container__', align: VerticalAlign.Top },
      left: { anchor: '__container__', align: HorizontalAlign.Start }
    })

  Text('右上')
    .id('right')
    .alignRules({
      top: { anchor: '__container__', align: VerticalAlign.Top },
      right: { anchor: '__container__', align: HorizontalAlign.End }
    })
}
.width('100%')
.height(180)
.padding(16)

适合"元素之间互相对齐"的复杂卡片(比如图标对齐文字基线)。锚点 ID 必须存在,且不要互相引用形成环。

2.8 List 与 Scroll:滚动容器怎么选

typescript 复制代码
// 列表:每个条目独立,可复用、可虚拟化
List({ space: 8 }) {
  ForEach(Array.from({ length: 50 }, (_, i: number) => i + 1), (id: number) => {
    ListItem() {
      Text(`第 ${id} 章`).padding(16).width('100%').backgroundColor('#F2F4F7')
    }
  }, (id: number) => id.toString())
}.height('100%')
typescript 复制代码
// 长文:一段连续内容,整体滚动
Scroll() {
  Column({ space: 18 }) {
    ForEach(Array.from({ length: 30 }, (_, i: number) => i + 1), (id: number) => {
      Text(`第 ${id} 段:山间云雾缭绕,少年踏上修行之路。`).fontSize(20).lineHeight(32)
    }, (id: number) => id.toString())
  }.padding(20)
}.height('100%')

判断标准: 条目彼此独立、数量可能增长 → List;一整块可滚动的连续内容 → Scroll。把几百个条目塞进 Scroll 是常见的性能事故。

2.9 布局排查清单

布局不符合预期时,按这个顺序检查:

  1. 父容器有没有明确宽高?height('100%') 的父级是不是也是确定高度?
  2. 主轴方向搞对了吗?justifyContent 管主轴,alignItems 管交叉轴。
  3. 子项总宽度是否超过容器?超过时用 layoutWeight 或限制宽度。
  4. 是否该用 List 却用了 Scroll?
  5. 是否该用 LazyForEach / Repeat 虚拟滚动却用了普通 ForEach?

03 基础组件:展示类看样式,输入类看回写

ArkUI 的内置组件可以粗分为两类:

  • 展示类(Text、Image、Progress):只关心"长什么样";
  • 输入类 (TextInput、TextArea、Checkbox、Radio、Toggle、Slider):核心是 onChange 把用户输入写回状态,保证"界面显示 = 状态值"。

3.1 文本与按钮

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local count: number = 0

  build() {
    Column({ space: 16 }) {
      Text('阅读器标题').fontSize(28).fontWeight(FontWeight.Bold)
      Text('正文:云雾缭绕,钟声悠远。').fontSize(20).lineHeight(34)

      Text(`已点击 ${this.count} 次`)
      Button('阅读下一章').onClick(() => { this.count++ })
    }.padding(20)
  }
}

3.2 输入类组件:受控写法

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local keyword: string = ''
  @Local note: string = ''

  build() {
    Column({ space: 16 }) {
      // 单行输入:text 绑定状态,onChange 回写状态
      TextInput({ placeholder: '搜索书名', text: this.keyword })
        .onChange((value: string) => { this.keyword = value })
      Text(`输入:${this.keyword}`)

      // 多行输入:适合读书笔记
      TextArea({ placeholder: '输入读书笔记', text: this.note })
        .height(160)
        .onChange((value: string) => { this.note = value })
      Text(`字数:${this.note.length}`)
    }.padding(20)
  }
}

这套"text 传入 + onChange 回写"的模式是所有输入类组件的通用范式,和 React 的受控组件思路一致。

3.3 选择类组件

typescript 复制代码
// 复选框
Checkbox({ name: 'agree', group: 'demo' })
  .select(this.checked)
  .onChange((value: boolean) => { this.checked = value })

// 单选:用 group 分组,用 value 做业务标识
Radio({ value: 'day', group: 'theme' })
  .checked(this.selected === 'day')
  .onChange((value: boolean) => { if (value) this.selected = 'day' })

// 开关
Toggle({ type: ToggleType.Switch, isOn: this.dark })
  .onChange((v: boolean) => { this.dark = v })

// 滑块:字号、亮度这类连续值
Slider({ value: this.size, min: 12, max: 32, step: 1 })
  .onChange((v: number) => { this.size = v })

踩坑: Radio 的 onChange 在取消选中时也会回调,必须用 if (value) 过滤,否则会出现"两个都不选中"的状态错乱。

3.4 进度与图片

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local value: number = 30

  build() {
    Column({ space: 20 }) {
      Progress({ value: this.value, total: 100, type: ProgressType.Linear })
      Button('阅读进度 +10').onClick(() => {
        this.value = Math.min(100, this.value + 10) // 边界要自己兜住
      })

      // objectFit 决定图片如何填充:Contain 完整显示,Cover 裁剪铺满
      Image($r('app.media.startIcon'))
        .width(120)
        .height(120)
        .objectFit(ImageFit.Contain)
    }.padding(20)
  }
}

ImageFit 的选型直接影响观感:封面图用 Cover 避免留白,图标用 Contain 避免裁切。

3.5 组件与 HTML 对照

ArkUI 组件 Web 近似 关键点
Text <span> / <p> 样式走属性方法
Button <button> 自带点击态
TextInput <input> 受控:text + onChange
TextArea <textarea> 需要显式高度
Checkbox <input type="checkbox"> 需要 group 分组
Radio <input type="radio"> 同组互斥
Toggle <input type="checkbox"> 开关皮肤 ToggleType.Switch
Slider <input type="range"> step 控制步长
Progress <progress> total 决定百分比
Image <img> objectFit 对应 object-fit

04 状态管理:V1 与 V2 不要混着写

这一章是 ArkUI 学习曲线的分水岭。

一句话原则:新项目统一用 V2;维护老项目时,同一套父子通信链路里不要混用 V1 和 V2 装饰器。

4.1 V2 四件套

装饰器 作用 Vue 类比
@Local 组件内部响应式状态 ref
@Param 父传子,单向只读 props
@Event 子传父回调 emit
@ObservedV2 + @Trace 类实例的属性级追踪 reactive 深层追踪

4.2 组件内状态

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local count: number = 0

  build() {
    Column({ space: 16 }) {
      Text(`${this.count}`).fontSize(30)
      Button('+1').onClick(() => { this.count++ })
    }.padding(20)
  }
}

4.3 父传子:@Param

typescript 复制代码
@ComponentV2
struct Child {
  @Param title: string = '' // 子组件只读,不能直接改
  build() { Text(this.title).fontSize(22) }
}

@Entry
@ComponentV2
struct Index {
  @Local name: string = '张三'

  build() {
    Column({ space: 16 }) {
      Child({ title: this.name })
      Button('修改').onClick(() => { this.name = '李四' })
    }.padding(20)
  }
}

父组件状态变化会自动同步到子组件的 @Param,但反过来不行------这是单向数据流,避免"到底谁改了数据"的排查地狱。

4.4 子传父:@Event

typescript 复制代码
@ComponentV2
struct Child {
  // 子组件只负责"通知",不关心父组件怎么处理
  @Event onAdd: () => void = () => {}
  build() {
    Button('子组件 +1').onClick(() => { this.onAdd() })
  }
}

@Entry
@ComponentV2
struct Index {
  @Local count: number = 0

  build() {
    Column({ space: 16 }) {
      Text(`计数:${this.count}`)
      Child({ onAdd: () => { this.count++ } })
    }.padding(20)
  }
}

4.5 对象属性追踪:@ObservedV2 + @Trace

typescript 复制代码
@ObservedV2
class ReaderSettings {
  @Trace fontSize: number = 18
  @Trace dark: boolean = false
}

@Entry
@ComponentV2
struct Index {
  @Local settings: ReaderSettings = new ReaderSettings()

  build() {
    Column({ space: 16 }) {
      Text('正文预览').fontSize(this.settings.fontSize)
      Button('放大').onClick(() => { this.settings.fontSize += 2 })
    }.padding(20)
  }
}

为什么要 @Trace? @Local 只能追踪"引用被整体替换",settings.fontSize = 20 这种深层修改默认感知不到;@Trace 标记的字段才是真正被观察的粒度。这也直接决定了第 11 章"精准更新"的性能上限。

4.6 V1 装饰器对照(维护老项目必备)

typescript 复制代码
// V1 父子双向:@State + @Link($ 传递引用)
@Component
struct CounterChild {
  @Link count: number
  build() {
    Button(`子组件:${this.count}`).onClick(() => { this.count++ })
  }
}

@Entry
@Component
struct Index {
  @State count: number = 0
  build() {
    Column({ space: 16 }) {
      Text(`父组件:${this.count}`)
      CounterChild({ count: $count })
    }.padding(20)
  }
}
typescript 复制代码
// V1 跨层级:@Provide / @Consume
@Component
struct Child {
  @Consume theme: string
  build() { Text(`主题:${this.theme}`) }
}

@Entry
@Component
struct Index {
  @Provide theme: string = '浅色'
  build() {
    Column({ space: 16 }) {
      Child()
      Button('切换').onClick(() => {
        this.theme = this.theme === '浅色' ? '深色' : '浅色'
      })
    }.padding(20)
  }
}
typescript 复制代码
// V1 对象追踪:@Observed + @ObjectLink
@Observed
class Book { title: string = '三体' }

@Component
struct Child {
  @ObjectLink book: Book
  build() {
    Button(this.book.title).onClick(() => { this.book.title = '诛仙' })
  }
}
能力 V1 V2
组件内状态 @State @Local
父传子(单向) @Prop @Param
父传子(双向) @Link @Param + @Event 显式回传
跨层级 @Provide / @Consume @Provider / @Consumer
对象属性追踪 @Observed + @ObjectLink @ObservedV2 + @Trace
派生状态 手动计算 @Computed
变化监听 @Watch @Monitor

04 章踩坑清单

  1. V1/V2 混写 :@State 的父组件里塞 @ComponentV2 子组件,状态同步行为会变得难以预期。
  2. 深层对象改了不刷新 :只加了 @Local,没给类属性加 @Trace。
  3. 直接改 @Param :编译期就会提示不可赋值,正确做法是 @Event 回传,让父组件改。
  4. 状态放得太高:只在单个组件用的临时状态,没必要提到全局 Store。

05 渲染控制:if 管存在,ForEach 管集合,Builder 管复用

这一章只有三个关键词,但每个都有容易忽略的语义差异。

语法 语义 典型场景 注意点
if 创建 / 销毁组件 登录态切换、加载态 切换会重建节点,状态丢失
ForEach 渲染数组(全量) 中小列表 key 必须稳定唯一
Repeat 渲染数组(可虚拟化) 长列表 配合 .virtualScroll()
LazyForEach 按需从数据源取 万级数据 需要实现 IDataSource
@Builder 复用 UI 片段 公共卡片、工具栏 参数追踪规则特殊

5.1 if:控制"组件存不存在"

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local visible: boolean = true

  build() {
    Column({ space: 16 }) {
      if (this.visible) {
        Text('阅读工具栏') // 条件为 false 时节点被销毁
      }
      Button('显示/隐藏').onClick(() => { this.visible = !this.visible })
    }.padding(20)
  }
}

和三元表达式的区别: 三元只改属性值,节点始终存在;if 会真正创建/销毁节点,因此切换开销更大,但能释放资源。需要动画出入场时,用 if + transition(第 08 章)。

5.2 ForEach:小列表的全量渲染

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local books: string[] = ['三体', '诛仙']

  build() {
    Column({ space: 12 }) {
      ForEach(this.books, (book: string) => {
        Text(book).padding(10)
      }, (book: string) => book) // key:稳定且唯一

      Button('添加').onClick(() => {
        // 必须整体替换数组引用,才会触发刷新
        this.books = [...this.books, `新书${this.books.length + 1}`]
      })
    }.padding(20)
  }
}

踩坑: this.books.push(...) 不一定能触发刷新,V2 下推荐"生成新数组再赋值",避免依赖框架对数组方法的拦截细节。

5.3 Repeat:列表渲染 + 虚拟滚动

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local books: string[] = ['三体', '诛仙', '剑来']

  build() {
    List() {
      Repeat<string>(this.books)
        .each((item: RepeatItem<string>) => {
          ListItem() { Text(item.item).padding(18) }
        })
        .key((book: string) => book) // 稳定 key,决定复用是否正确
    }.height('100%')
  }
}

Repeat 的 API 是链式的:.each() 定义每条怎么渲染,.key() 定义复用标识,.virtualScroll() 开启虚拟滚动(第 09 章详述)。

5.4 Builder 复用与它的响应式陷阱

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local count: number = 0

  @Builder actionButton() {
    // 直接读取 this.count,能正常追踪
    Button(`点击 +1(${this.count})`).onClick(() => { this.count++ })
  }

  build() {
    Column({ space: 16 }) {
      Text(`父级计数:${this.count}`)
      this.actionButton()
    }.padding(20)
  }
}
typescript 复制代码
// 修正版:跨组件传递时,对象状态走 @ObservedV2 + @Trace
@ObservedV2
class UserInfo {
  @Trace name: string = '张三'
}

@Entry
@ComponentV2
struct Index {
  @Local user: UserInfo = new UserInfo()
  @Local count: number = 0

  @Builder info() {
    Column({ space: 12 }) {
      Text(`姓名:${this.user.name}`) // 追踪 @Trace 字段
      Text(`计数:${this.count}`)      // 追踪 @Local
      Button('改姓名').onClick(() => { this.user.name = '李四' })
      Button('计数+1').onClick(() => { this.count++ })
    }
  }

  build() {
    Column() { this.info() }.padding(20)
  }
}

关键结论: @Builder 里直接读取组件状态是可靠的;但如果通过普通参数 把值传进 Builder,参数本身不会像 @Param 那样建立响应式绑定。需要"传参 + 响应式"时,优先用子组件 + @Param,或传入 @ObservedV2 对象。

05 章选型口诀

  • 登录态、加载态、空状态 → if;
  • 几十条数据 → ForEach;
  • 上千条数据 → Repeat + virtualScroll;
  • 数据源需要增量通知、分页加载 → LazyForEach;
  • 重复的 UI 片段 → @Builder,有状态就升级为子组件。

06 事件系统:从"点一下"到"拖一下"

ArkUI 的事件分三层,选错层会导致代码又长又难调:

层级 API 适用场景
通用事件 onClick、onChange、onTouch 点击、输入、原始触摸
手势识别 TapGesture、LongPressGesture、PanGesture 单击/双击/长按/拖动
组件专有事件 onRefreshing、onPageEnd、onChange(Tabs) 特定组件的能力

6.1 onClick:最常用也最容易忽略状态

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local count: number = 0
  @Local disabled: boolean = false

  build() {
    Column({ space: 16 }) {
      Text(`点击次数:${this.count}`)
      // enabled(false) 时不再响应点击,视觉上也会置灰
      Button('点击 +1').enabled(!this.disabled).onClick(() => { this.count++ })
      Button('切换禁用').onClick(() => { this.disabled = !this.disabled })
    }.padding(20)
  }
}

6.2 onTouch:需要坐标时用它

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local status: string = '等待触摸'
  @Local x: number = 0
  @Local y: number = 0

  build() {
    Column({ space: 20 }) {
      Text(`${this.status}:${this.x.toFixed(0)}, ${this.y.toFixed(0)}`)

      Column() { Text('触摸区域').fontColor(Color.White) }
        .width('100%')
        .height(240)
        .backgroundColor('#2563EB')
        .justifyContent(FlexAlign.Center)
        .onTouch((event: TouchEvent) => {
          if (event.type === TouchType.Down) this.status = '按下'
          if (event.type === TouchType.Move) this.status = '移动'
          if (event.type === TouchType.Up) this.status = '抬起'

          // touches 为空时取 changedTouches(抬手瞬间)
          const points = event.touches.length > 0 ? event.touches : event.changedTouches
          if (points.length > 0) {
            this.x = points[0].x
            this.y = points[0].y
          }
        })
    }.padding(20)
  }
}

踩坑: 抬手(Up)时 event.touches 往往是空数组,必须回退到 changedTouches,否则最后一帧坐标会丢失。

6.3 手势识别:Tap / LongPress

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local message: string = '等待手势'

  build() {
    Column({ space: 20 }) {
      Text(this.message)

      Text('单击').padding(30).backgroundColor('#DBEAFE')
        .gesture(TapGesture({ count: 1 })
          .onAction(() => { this.message = '单击成功' }))

      Text('双击').padding(30).backgroundColor('#DCFCE7')
        .gesture(TapGesture({ count: 2 })
          .onAction(() => { this.message = '双击成功' }))

      Text('长按 1 秒').padding(30).backgroundColor('#FCE7F3')
        .gesture(LongPressGesture({ duration: 1000 })
          .onAction(() => { this.message = '长按成功' }))
    }.padding(20)
  }
}

6.4 PanGesture:拖动的正确姿势

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local x: number = 0      // 当前位移
  @Local baseX: number = 0  // 本次拖动开始时的位移

  build() {
    Column({ space: 20 }) {
      Text(`偏移:${this.x.toFixed(0)}`)

      Stack() {
        Text('拖动我')
          .width(120)
          .height(100)
          .textAlign(TextAlign.Center)
          .backgroundColor('#DBEAFE')
          .translate({ x: this.x })
          .gesture(
            PanGesture({ direction: PanDirection.Horizontal })
              // 记录起点,避免"每次更新都从 0 累加"导致跳动
              .onActionStart(() => { this.baseX = this.x })
              .onActionUpdate((event: GestureEvent) => {
                this.x = this.baseX + event.offsetX
              })
          )
      }.width('100%').height(240)

      Button('重置').onClick(() => { this.x = 0 })
    }.padding(20)
  }
}

event.offsetX 是相对本次手势起点 的累计偏移,不是每帧增量。所以必须用 baseX 记录手势开始时的位置,否则拖动会瞬间跳回原点。

6.5 onChange:输入联动的正确方式

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local keyword: string = ''
  @Local size: number = 18
  @Local dark: boolean = false

  build() {
    Column({ space: 18 }) {
      TextInput({ placeholder: '书名', text: this.keyword })
        .onChange((v: string) => { this.keyword = v })

      Slider({ value: this.size, min: 12, max: 32 })
        .onChange((v: number) => { this.size = v })

      Toggle({ type: ToggleType.Switch, isOn: this.dark })
        .onChange((v: boolean) => { this.dark = v })

      // 所有输入统一汇聚到状态,再由状态驱动预览
      Text(this.keyword || '正文预览')
        .fontSize(this.size)
        .fontColor(this.dark ? Color.White : Color.Black)
        .backgroundColor(this.dark ? '#222222' : '#F5F5F5')
        .padding(20)
    }.padding(20)
  }
}

06 章选型决策

  1. 只要"点了做事" → onClick;
  2. 需要按下/移动/抬起的坐标 → onTouch;
  3. 单击、双击、长按、拖动 → Gesture;
  4. 输入值变化 → 组件自身的 onChange;
  5. 组件自带的能力(下拉刷新、页面加载完) → 组件专有回调。

07 弹窗与交互:五种方案,别用错场景

弹窗不是"能弹出来就行",选错类型会直接破坏体验。判断标准只有两条:信息复杂度 和是否需要阻断操作。

方案 适合场景 是否阻断 能否自定义
AlertDialog 二次确认(删除、退出) 是 弱
ActionSheet 操作列表(更多操作) 是 弱
CustomDialog 复杂表单、强定制弹窗 是 强
promptAction.showToast 轻提示(保存成功) 否 弱
bindSheet 底部面板(设置、详情) 半阻断 强

7.1 AlertDialog:二次确认

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local message: string = '等待确认'

  build() {
    Column({ space: 20 }) {
      Text(this.message)

      Button('删除书籍').onClick(() => {
        AlertDialog.show({
          title: '确认删除',
          message: '确定移出书架?',
          // primaryButton / secondaryButton 由系统统一排布
          primaryButton: {
            value: '取消',
            action: () => { this.message = '已取消' }
          },
          secondaryButton: {
            value: '删除',
            action: () => { this.message = '已删除' }
          }
        })
      })
    }.padding(20)
  }
}

7.2 ActionSheet:操作菜单

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local message: string = '请选择'

  build() {
    Column({ space: 20 }) {
      Text(this.message)

      Button('更多操作').onClick(() => {
        ActionSheet.show({
          title: '书籍操作',
          sheets: [
            { title: '开始阅读', action: () => { this.message = '开始阅读' } },
            { title: '加入收藏', action: () => { this.message = '已收藏' } }
          ]
        })
      })
    }.padding(20)
  }
}

7.3 CustomDialog:需要自定义内容时

typescript 复制代码
@CustomDialog
struct SettingsDialog {
  controller: CustomDialogController // 框架注入,用于关闭自己

  build() {
    Column({ space: 16 }) {
      Text('阅读设置').fontSize(22)
      Button('关闭').onClick(() => { this.controller.close() })
    }
    .padding(24)
    .backgroundColor(Color.White)
    .borderRadius(12)
  }
}

@Entry
@Component
struct Index {
  private dialog: CustomDialogController = new CustomDialogController({
    builder: SettingsDialog({})
  })

  build() {
    Column() {
      Button('打开自定义弹窗').onClick(() => { this.dialog.open() })
    }.padding(20)
  }
}

踩坑: CustomDialogController 必须在组件内创建(不能全局 new),组件销毁时要确保弹窗已关闭,否则可能出现控制器持有已销毁组件的引用。

7.4 Toast:轻量、不阻断

typescript 复制代码
import { promptAction } from '@kit.ArkUI'

@Entry
@ComponentV2
struct Index {
  build() {
    Column() {
      Button('保存成功').onClick(() => {
        promptAction.showToast({ message: '保存成功' })
      })
    }.padding(20)
  }
}

7.5 bindSheet:底部面板

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local visible: boolean = false

  build() {
    Column({ space: 16 }) {
      Button('阅读设置').onClick(() => { this.visible = true })
    }
    .padding(20)
    // $$ 双向绑定:面板下拉关闭时,visible 会自动同步为 false
    .bindSheet($$this.visible, this.sheetContent())
  }

  @Builder
  sheetContent() {
    Column({ space: 20 }) {
      Text('字体与主题设置').fontSize(22)
    }
    .width('100%')
    .padding(24)
  }
}

关键点: bindSheet 的第一个参数必须用 $$ 双向绑定,否则用户手动下拉关闭面板后,状态仍然停留在 true,下次点击按钮将无法再次打开。

07 章选型建议

  • 只问"确定/取消" → AlertDialog;
  • 一组操作项 → ActionSheet;
  • 需要输入、复杂布局 → CustomDialog 或 bindSheet;
  • 操作结果反馈 → Toast,不要用弹窗打断;
  • 同一屏内可反复调节的设置 → bindSheet,比全屏弹窗更轻。

08 动画系统:四种动画,一条主线

ArkUI 的动画按"触发方式"分成四类,理解清楚就不会乱用:

API 本质 典型用途
.animation() 属性隐式过渡 尺寸、透明度随状态平滑变化
animateTo() 显式包裹一批状态修改 点击后同时改变多个属性
.transition() 节点出入场 if 切换时的淡入淡出
keyframeAnimateTo() 多段关键帧 弹跳、抖动等序列动作

8.1 animation:属性隐式动画

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local expanded: boolean = false

  build() {
    Column({ space: 30 }) {
      Text('动画')
        .width(this.expanded ? 220 : 100)
        .height(100)
        .textAlign(TextAlign.Center)
        .backgroundColor('#DBEAFE')
        .rotate({ angle: this.expanded ? 45 : 0 })
        .opacity(this.expanded ? 0.5 : 1)
        // 只作用于写在它前面的属性;放在最后才能覆盖以上变化
        .animation({ duration: 600, curve: Curve.EaseInOut })

      Button('切换').onClick(() => { this.expanded = !this.expanded })
    }.padding(20)
  }
}

踩坑: .animation() 的作用范围是"它之前声明的属性",位置写错会出现"部分属性有动画、部分没有"的诡异现象。

8.2 animateTo:一次改一批状态

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local x: number = 0
  @Local angle: number = 0

  build() {
    Column({ space: 24 }) {
      Text('移动')
        .width(100)
        .height(100)
        .backgroundColor('#DCFCE7')
        .translate({ x: this.x })
        .rotate({ angle: this.angle })

      Button('开始').onClick(() => {
        // 闭包内的所有状态修改共享同一套动画参数
        animateTo({ duration: 800, curve: Curve.EaseInOut }, () => {
          this.x = this.x === 0 ? 120 : 0
          this.angle += 180
        })
      })
    }.padding(20)
  }
}

8.3 transition:出入场动画

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local visible: boolean = false

  build() {
    Column({ space: 20 }) {
      Button('显示/隐藏').onClick(() => {
        // 必须用 animateTo 包裹,transition 才会生效
        animateTo({ duration: 350 }, () => { this.visible = !this.visible })
      })

      if (this.visible) {
        Text('阅读设置面板')
          .padding(30)
          .backgroundColor('#DBEAFE')
          // 透明度 + 位移组合,做出"从下浮出"的效果
          .transition(
            TransitionEffect.OPACITY.combine(
              TransitionEffect.translate({ y: 80 })
            )
          )
      }
    }.padding(20)
  }
}

8.4 keyframeAnimateTo:多段关键帧

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local scaleValue: number = 1

  build() {
    Column({ space: 24 }) {
      Text('弹跳')
        .width(120)
        .height(120)
        .backgroundColor('#DBEAFE')
        .scale({ x: this.scaleValue, y: this.scaleValue })

      Button('开始').onClick(() => {
        keyframeAnimateTo({ iterations: 1 }, [
          { duration: 150, event: () => { this.scaleValue = 1.2 } }, // 放大
          { duration: 150, event: () => { this.scaleValue = 0.9 } }, // 回弹
          { duration: 150, event: () => { this.scaleValue = 1 } }    // 复原
        ])
      })
    }.padding(20)
  }
}

8.5 组合动画:阅读工具栏

这是本章的综合案例:点击阅读区,上下工具栏同时滑入,并切换为可点击状态。

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local topY: number = -70     // 顶部工具栏位移
  @Local bottomY: number = 70   // 底部工具栏位移
  @Local alpha: number = 0      // 透明度
  @Local visible: boolean = false

  /** 切换工具栏显隐:位移与透明度用同一套动画参数 */
  private toggle(): void {
    this.visible = !this.visible
    animateTo({ duration: 350 }, () => {
      this.topY = this.visible ? 0 : -70
      this.bottomY = this.visible ? 0 : 70
      this.alpha = this.visible ? 1 : 0
    })
  }

  build() {
    Stack() {
      Text('点击阅读区域')
        .width('100%')
        .height('100%')
        .textAlign(TextAlign.Center)
        .backgroundColor('#F5F0E6')
        .onClick(() => { this.toggle() })

      Text('顶部工具栏')
        .width('100%')
        .height(70)
        .backgroundColor('#DBEAFE')
        .align(Alignment.Top)
        .translate({ y: this.topY })
        .opacity(this.alpha)
        // 隐藏时禁用命中测试,避免"看不见但能点到"
        .hitTestBehavior(this.visible ? HitTestMode.Default : HitTestMode.None)

      Text('底部工具栏')
        .width('100%')
        .height(70)
        .backgroundColor('#DCFCE7')
        .align(Alignment.Bottom)
        .translate({ y: this.bottomY })
        .opacity(this.alpha)
        .hitTestBehavior(this.visible ? HitTestMode.Default : HitTestMode.None)
    }
    .width('100%')
    .height('100%')
  }
}

踩坑: 只改 opacity 隐藏元素,元素仍然可以被点击。必须同步处理 hitTestBehavior,否则会出现"点到了看不见的按钮"的幽灵问题。

8.6 动画性能建议

  1. 优先动 translate、scale、opacity 这类不触发布局的属性;
  2. 避免对 width / height / margin 做长列表内的持续动画;
  3. 动画时长控制在 200--400ms,超过 500ms 会显得拖沓;
  4. 列表项动画要结合虚拟滚动,不要一次给上千个节点启动动画。

09 列表进阶:万级数据怎么不卡

数据一多,卡顿来源有两层,必须分开看:

  • 数据层:一次性把 10000 条都建出来、还带重计算;
  • UI 层:一次性把 10000 个节点都实例化。

LazyForEach 和 Repeat + virtualScroll 解决的是UI 层按需构建 ;分页、缓存、懒加载解决的是数据层。两者不能互相替代。

9.1 LazyForEach:数据源驱动

typescript 复制代码
/** 章节数据源:实现 IDataSource,按需返回数据 */
class ChapterSource implements IDataSource {
  private listeners: DataChangeListener[] = []

  /** 总条数:框架据此决定滚动范围 */
  totalCount(): number { return 10000 }

  /** 取第 index 条:只有可见区域附近才会被调用 */
  getData(index: number): string { return `第 ${index + 1} 章:修仙之路` }

  registerDataChangeListener(listener: DataChangeListener): void {
    this.listeners.push(listener)
  }

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

@Entry
@Component
struct Index {
  private source: ChapterSource = new ChapterSource()

  build() {
    List() {
      LazyForEach(this.source, (chapter: string, index: number) => {
        ListItem() { Text(chapter).padding(18) }
      }, (chapter: string, index: number) => index.toString())
    }
    .cachedCount(5) // 屏幕外额外缓存的条目数,越大越流畅也越吃内存
    .height('100%')
  }
}

踩坑: 数据变更后必须调用 listeners 的 onDataAdd / onDataChange / onDataDelete 等方法通知框架,否则列表不会更新。另外 key 要稳定,不要用 index 作为可排序列表的 key。

9.2 Repeat + virtualScroll:数组写法也能虚拟化

typescript 复制代码
/** 章节实体 */
class Chapter {
  id: number
  title: string
  constructor(id: number) {
    this.id = id
    this.title = `第 ${id} 章`
  }
}

@Entry
@ComponentV2
struct Index {
  @Local chapters: Chapter[] = []

  aboutToAppear(): void {
    const data: Chapter[] = []
    for (let i = 1; i <= 10000; i++) data.push(new Chapter(i))
    this.chapters = data
  }

  build() {
    List() {
      Repeat<Chapter>(this.chapters)
        .each((item: RepeatItem<Chapter>) => {
          ListItem() { Text(item.item.title).padding(18) }
        })
        .key((item: Chapter) => item.id.toString()) // 业务 ID 做 key
        .virtualScroll()                              // 开启虚拟滚动
    }
    .cachedCount(5)
    .height('100%')
  }
}

9.3 两种方案怎么选

维度 LazyForEach Repeat + virtualScroll
数据形态 自定义数据源(可分页、可增量) 现成数组
接入成本 需实现 IDataSource 直接传数组
适合场景 服务端分页、实时增量 本地大数组
状态管理 V1 / V2 均可 推荐 V2

重要认知: 虚拟滚动只减少 UI 节点数量 ,chapters 这个包含一万个对象的数组依然占内存。真正的超大列表还需要服务端分页 + 本地缓存。


10 高级组件:五个"开箱即用"的复杂场景

组件 解决什么 一句话记忆
Tabs 多栏目切换 底部导航 / 顶部频道
Swiper 连续页翻页 引导页、阅读翻页
Refresh 下拉刷新 列表顶部下拉
WaterFlow 不等高瀑布流 书城卡片流
Web 内嵌 H5 活动页、协议页

10.1 Tabs:栏目切换

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local selected: number = 0
  private controller: TabsController = new TabsController()

  build() {
    Column() {
      Text(`当前:${this.selected}`).padding(12)

      Tabs({ index: this.selected, controller: this.controller }) {
        TabContent() { Text('我的书架') }.tabBar('书架')
        TabContent() { Text('发现好书') }.tabBar('书城')
        TabContent() { Text('个人中心') }.tabBar('我的')
      }
      .onChange((i: number) => { this.selected = i })
      .layoutWeight(1) // 占满剩余高度

      // 代码控制切换
      Button('跳到书城').onClick(() => { this.controller.changeIndex(1) })
    }.height('100%')
  }
}

10.2 Swiper:翻页

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local index: number = 0
  private controller: SwiperController = new SwiperController()

  build() {
    Column({ space: 20 }) {
      Text(`第 ${this.index + 1} 页`)

      Swiper(this.controller) {
        Text('第一页:山间云雾缭绕').padding(24)
        Text('第二页:少年迈出第一步').padding(24)
        Text('第三页:山门缓缓打开').padding(24)
      }
      .height(320)
      .loop(false) // 关闭循环,避免阅读场景回绕
      .onChange((i: number) => { this.index = i })

      Row({ space: 20 }) {
        Button('上一页').onClick(() => { this.controller.showPrevious() })
        Button('下一页').onClick(() => { this.controller.showNext() })
      }
    }.padding(20)
  }
}

10.3 Refresh:下拉刷新

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local refreshing: boolean = false
  @Local books: string[] = ['三体', '诛仙', '剑来']

  build() {
    // refreshing 用 $$ 双向绑定,下拉手势与状态才能同步
    Refresh({ refreshing: $$this.refreshing }) {
      List() {
        ForEach(this.books, (book: string) => {
          ListItem() { Text(book).padding(20) }
        }, (book: string) => book)
      }.height('100%')
    }
    .onRefreshing(() => {
      if (this.refreshing) return
      this.refreshing = true

      // 模拟请求;真实项目里在 finally 中关闭刷新态
      setTimeout(() => {
        this.books = [`新书 ${this.books.length + 1}`, ...this.books]
        this.refreshing = false
      }, 1000)
    })
    .height('100%')
  }
}

踩坑: 无论请求成功还是失败,都必须在 finally 里把 refreshing 置回 false,否则下拉框会永远转圈。

10.4 WaterFlow:不等高瀑布流

typescript 复制代码
/** 瀑布流卡片:高度由 id 模拟出差异 */
class WaterBook {
  id: number
  height: number
  constructor(id: number) {
    this.id = id
    this.height = 120 + (id % 5) * 35
  }
}

@Entry
@ComponentV2
struct Index {
  @Local books: WaterBook[] = []

  aboutToAppear(): void {
    const items: WaterBook[] = []
    for (let i = 1; i <= 50; i++) items.push(new WaterBook(i))
    this.books = items
  }

  build() {
    WaterFlow() {
      ForEach(this.books, (book: WaterBook) => {
        FlowItem() {
          Text(`书籍 ${book.id}`)
            .width('100%')
            .height(book.height)
            .backgroundColor('#DBEAFE')
            .textAlign(TextAlign.Center)
        }
      }, (book: WaterBook) => book.id.toString())
    }
    .columnsTemplate('1fr 1fr') // 两列
    .columnsGap(12)
    .rowsGap(12)
    .padding(16)
  }
}

10.5 Web:内嵌 H5

typescript 复制代码
import { webview } from '@kit.ArkWeb'

@Entry
@ComponentV2
struct Index {
  private controller: webview.WebviewController = new webview.WebviewController()
  @Local status: string = '加载中'

  build() {
    Column({ space: 10 }) {
      Text(this.status)

      Web({ src: 'https://www.example.com', controller: this.controller })
        .javaScriptAccess(true)  // 按需开启,注意安全边界
        .domStorageAccess(true)
        .onPageEnd(() => { this.status = '加载完成' })
        .layoutWeight(1)

      Row({ space: 12 }) {
        Button('后退').onClick(() => {
          if (this.controller.accessBackward()) this.controller.backward()
        })
        Button('刷新').onClick(() => { this.controller.refresh() })
      }
    }
    .height('100%')
    .padding(12)
  }
}

坑点: 不加 accessBackward() 判断直接调 backward(),在首屏会报错;JavaScriptAccess 能不开就不开,开之前想清楚注入风险。


11 性能优化:先测量,再动手

性能优化最容易犯的错是"凭感觉优化"。正确的顺序是:定指标 → 找瓶颈 → 改一处 → 复测。

指标 关注点 常见工具
首次渲染 冷启动到首帧 DevEco Profiler
帧率 滑动、动画是否掉帧 帧率监控
内存 大图、大数组、缓存 内存快照
响应时间 点击到界面反馈 打点日志

11.1 控制更新范围:@ObservedV2 + @Trace

typescript 复制代码
@ObservedV2
class UserInfo {
  @Trace name: string = '张三'
  @Trace age: number = 18
}

@Entry
@ComponentV2
struct Index {
  @Local user: UserInfo = new UserInfo()

  build() {
    Column({ space: 16 }) {
      // 只有 name 变化时,第二行不会重算
      Text(`姓名:${this.user.name}`)
      Text(`年龄:${this.user.age}`)

      Button('改姓名').onClick(() => { this.user.name = '李四' })
      Button('年龄+1').onClick(() => { this.user.age++ })
    }.padding(20)
  }
}

原理: @Trace 提供属性级依赖收集,改 name 只刷新读取 name 的节点。如果把整个对象放进一个状态里"整体替换",整棵子树都会重算。

11.2 长列表:虚拟滚动

typescript 复制代码
List() {
  Repeat<Chapter>(this.chapters)
    .each((item: RepeatItem<Chapter>) => {
      ListItem() { Text(item.item.title).padding(18) }
    })
    .key((item: Chapter) => item.id.toString())
    .virtualScroll()
}
.cachedCount(5) // 缓存太少会白屏,太多会吃内存
.layoutWeight(1)

11.3 组件拆分:把刷新关进小盒子

typescript 复制代码
/** 书籍数据模型 */
class BookInfo {
  id: number
  title: string
  constructor(id: number, title: string) {
    this.id = id
    this.title = title
  }
}

/** 独立卡片组件:父组件其他状态变化不会引起它重建 */
@ComponentV2
struct BookCard {
  @Param book: BookInfo = new BookInfo(0, '')
  @Event onOpen: (id: number) => void = () => {}

  build() {
    Button(this.book.title).onClick(() => { this.onOpen(this.book.id) })
  }
}

@Entry
@ComponentV2
struct Index {
  @Local selected: number = 0

  build() {
    Column({ space: 16 }) {
      Text(`已选 ID:${this.selected}`)
      BookCard({
        book: new BookInfo(1, '三体'),
        onOpen: (id: number) => { this.selected = id }
      })
    }.padding(20)
  }
}

拆分不是为了"代码好看",而是为了限制刷新范围:状态收敛在子组件内部,父组件重渲染时子组件可以保持稳定。

11.4 高频事件:防抖与节流

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local keyword: string = ''
  @Local message: string = '等待输入'
  @Local clicks: number = 0

  private timer: number = -1       // 防抖定时器
  private lastClick: number = 0    // 上次点击时间戳

  /** 组件销毁时清理定时器,避免内存泄漏与野回调 */
  aboutToDisappear(): void {
    if (this.timer !== -1) clearTimeout(this.timer)
  }

  build() {
    Column({ space: 20 }) {
      TextInput({ placeholder: '搜索书籍', text: this.keyword })
        .onChange((v: string) => {
          this.keyword = v
          // 防抖:500ms 内不再输入才真正发请求
          if (this.timer !== -1) clearTimeout(this.timer)
          this.timer = setTimeout(() => {
            this.message = `搜索:${v}`
            this.timer = -1
          }, 500)
        })

      Text(this.message)

      // 节流:1 秒内只允许一次
      Button(`节流点击:${this.clicks}`).onClick(() => {
        const now = Date.now()
        if (now - this.lastClick < 1000) return
        this.lastClick = now
        this.clicks++
      })
    }.padding(20)
  }
}

11.5 图片:尺寸与裁剪

typescript 复制代码
Column({ space: 16 }) {
  // 图标类:完整显示,不裁切
  Image($r('app.media.startIcon'))
    .width(100)
    .height(100)
    .objectFit(ImageFit.Contain)

  // 封面类:铺满裁切,避免留白
  Image($r('app.media.startIcon'))
    .width(200)
    .height(100)
    .objectFit(ImageFit.Cover)
}
.padding(20)

图片优化的三个动作:按展示尺寸取图 (不要拿 2000px 原图渲染 100px 缩略图)、选择合适的 objectFit 、长列表里复用图片并控制缓存。

11 章行动清单

  1. 找一条最卡的列表,用虚拟滚动改造,记录前后帧率;
  2. 把大对象拆成 @ObservedV2 + @Trace,观察刷新范围;
  3. 搜索所有 onChange,给搜索框加防抖;
  4. 检查所有定时器与请求,确认aboutToDisappear 里有清理;
  5. 检查图片是否按展示尺寸加载。

12 工程实践:从 Demo 到可维护的项目

Demo 能跑,不等于项目能维护。这一章解决四件事:分层、生命周期、路由、数据落地。

12.1 分层:页面只做"展示 + 编排"

typescript 复制代码
/** 数据模型:只描述数据 */
class Book {
  id: number
  title: string
  constructor(id: number, title: string) {
    this.id = id
    this.title = title
  }
}

/** 业务服务:数据从哪来,只在这一层关心 */
class BookService {
  static getBooks(): Book[] {
    return [new Book(1, '三体'), new Book(2, '诛仙')]
  }
}

/** 展示组件:只负责渲染 */
@ComponentV2
struct BookCard {
  @Param book: Book = new Book(0, '')
  build() {
    Text(this.book.title).padding(18).backgroundColor('#DBEAFE')
  }
}

@Entry
@ComponentV2
struct Index {
  @Local books: Book[] = []

  // 页面负责"取数据 + 组装",不关心数据来源细节
  aboutToAppear(): void {
    this.books = BookService.getBooks()
  }

  build() {
    Column({ space: 12 }) {
      ForEach(this.books, (book: Book) => {
        BookCard({ book: book })
      }, (book: Book) => book.id.toString())
    }.padding(20)
  }
}

推荐的目录结构:

text 复制代码
ets/
├── pages/        # 页面(路由目的地)
├── components/   # 通用组件
├── model/        # 数据模型
├── service/      # 业务与网络
├── utils/        # 工具函数
└── entryability/ # 应用入口

这条边界和 Vue 项目里的 views / components / api / service 完全同构:页面不直接发请求,模型不关心 UI,服务不依赖组件。

12.2 生命周期:申请的资源必须释放

typescript 复制代码
@Entry
@ComponentV2
struct Index {
  @Local seconds: number = 0
  private timer: number = -1

  /** 组件即将出现:适合启动定时器、发起初始请求 */
  aboutToAppear(): void {
    this.timer = setInterval(() => { this.seconds++ }, 1000)
  }

  /** 组件即将销毁:必须清理,否则页面退出后定时器仍在跑 */
  aboutToDisappear(): void {
    if (this.timer !== -1) {
      clearInterval(this.timer)
      this.timer = -1
    }
  }

  build() {
    Column({ space: 16 }) {
      Text(`运行:${this.seconds} 秒`).fontSize(24)
    }.padding(20)
  }
}

踩坑: 定时器、事件监听、长连接、动画控制器都属于"组件级资源",aboutToDisappear 里不清,页面来回切换几次就会出现重复计时、重复请求。

typescript 复制代码
/** 路由参数:明确类型,避免 any */
class BookParams {
  bookId: number
  constructor(bookId: number) {
    this.bookId = bookId
  }
}

/** 路由目的地页面 */
@ComponentV2
struct BookDetailPage {
  @Param bookId: number = 0

  build() {
    NavDestination() {
      Text(`书籍 ID:${this.bookId}`).fontSize(24).padding(20)
    }.title('书籍详情')
  }
}

/** 路由表:name 与页面一一对应 */
@Builder
function pageBuilder(name: string, param: Object) {
  if (name === 'BookDetail') {
    BookDetailPage({ bookId: (param as BookParams).bookId })
  }
}

@Entry
@ComponentV2
struct Index {
  private stack: NavPathStack = new NavPathStack()

  build() {
    Navigation(this.stack) {
      Button('打开书籍').onClick(() => {
        this.stack.pushPath({
          name: 'BookDetail',
          param: new BookParams(1)
        })
      })
    }
    .title('书架')
    .navDestination(pageBuilder)   // 注册目的地
    .mode(NavigationMode.Stack)
  }
}

两个必须对齐的点: pushPath 的 name 与 pageBuilder 里的分支判断要一致;复杂项目应把每个 NavDestination 拆到独立文件,避免路由表变成巨型 if。

12.4 Preferences:轻量持久化

typescript 复制代码
import { preferences } from '@kit.ArkData'
import { common } from '@kit.AbilityKit'

@Entry
@ComponentV2
struct Index {
  @Local fontSize: number = 18
  @Local status: string = '等待初始化'

  /** 存储实例:异步初始化,未就绪时不能读写 */
  private store?: preferences.Preferences

  aboutToAppear(): void {
    this.init()
  }

  /** 初始化存储并读取历史设置 */
  private async init(): Promise<void> {
    try {
      const context = this.getUIContext().getHostContext() as common.UIAbilityContext
      this.store = await preferences.getPreferences(context, 'reader_settings')
      this.fontSize = this.store.getSync('fontSize', 18) as number
      this.status = '读取成功'
    } catch (error) {
      this.status = '读取失败'
    }
  }

  /** 写入并落盘:putSync 只是写内存,flush 才真正持久化 */
  private async save(): Promise<void> {
    if (!this.store) return
    try {
      this.store.putSync('fontSize', this.fontSize)
      await this.store.flush()
      this.status = '保存成功'
    } catch (error) {
      this.status = '保存失败'
    }
  }

  build() {
    Column({ space: 20 }) {
      Text(this.status)
      Text('正文预览').fontSize(this.fontSize)

      Slider({ value: this.fontSize, min: 12, max: 32, step: 1 })
        .onChange((v: number) => { this.fontSize = v })

      Button('保存设置').onClick(() => { this.save() })
    }.padding(20)
  }
}

踩坑: Preferences 适合存配置项(字号、主题、开关),不适合存整本小说或大量阅读记录;putSync 之后不 flush,应用被杀掉后数据会丢。

12.5 HTTP:错误、超时、资源释放一个都不能少

typescript 复制代码
import { http } from '@kit.NetworkKit'

/** 接口响应模型:字段必须有初始值,避免 undefined 参与渲染 */
class PostInfo {
  userId: number = 0
  id: number = 0
  title: string = ''
  body: string = ''
}

@Entry
@ComponentV2
struct Index {
  @Local loading: boolean = false
  @Local title: string = ''
  @Local message: string = '等待加载'

  /** 加载文章:处理超时、非 200、解析失败,并保证释放请求对象 */
  private async load(): Promise<void> {
    if (this.loading) return
    this.loading = true

    const request = http.createHttp()
    try {
      const res = await request.request(
        'https://jsonplaceholder.typicode.com/posts/1',
        {
          method: http.RequestMethod.GET,
          connectTimeout: 10000,
          readTimeout: 10000
        }
      )

      // 状态码不是 200 时不能直接解析
      if (res.responseCode !== 200) {
        throw new Error(`HTTP ${res.responseCode}`)
      }

      const post = JSON.parse(res.result.toString()) as PostInfo
      this.title = post.title
      this.message = '加载成功'
    } catch (error) {
      this.message = '加载失败'
    } finally {
      this.loading = false
      request.destroy() // 必须销毁,否则连接与内存都不会释放
    }
  }

  build() {
    Column({ space: 20 }) {
      Text(this.message)

      Button(this.loading ? '加载中' : '请求')
        .enabled(!this.loading) // 防重复提交
        .onClick(() => { this.load() })

      Text(this.title)
    }.padding(20)
  }
}

工程实践里,这段代码应该再往下沉一层:页面只拿结果,请求细节放进 service。这样换接口、加拦截器、统一错误提示时,不用改页面。


总结:把 12 章串成一张知识网

回头看,ArkUI 的整个体系其实就是一条主线加上四个维度:

text 复制代码
一条主线:状态 → build() → UI → 事件 → 状态
四个维度:
  结构:01 语法 / 02 布局 / 03 组件
  数据:04 状态管理 / 05 渲染控制
  交互:06 事件 / 07 弹窗 / 08 动画
  工程:09 列表进阶 / 10 高级组件 / 11 性能 / 12 工程实践

12 章速查表

章节 一句话结论
01 基础语法 只有被装饰器标记的状态才驱动刷新,build() 不写副作用
02 布局系统 先定父容器约束,再谈方向与空间分配;列表用 List
03 基础组件 输入类组件统一"绑定值 + onChange 回写"
04 状态管理 新项目用 V2,同一链路不混用 V1/V2,深层对象加 @Trace
05 渲染控制 if 管存在,ForEach 管集合,Builder 管复用
06 事件系统 点击用 onClick,坐标用 onTouch,复杂手势用 Gesture
07 弹窗与交互 按"复杂度 + 是否阻断"选类型,bindSheet 记得 $$
08 动画系统 animation 隐式、animateTo 显式、transition 出入场
09 列表进阶 万级数据用虚拟滚动,但数据本身的内存要另算
10 高级组件 Tabs / Swiper / Refresh / WaterFlow / Web 各有明确边界
11 性能优化 先测量再优化,控制刷新范围是收益最高的一步
12 工程实践 页面只编排,服务管数据,资源必须有释放

自测:能不能不看代码回答这 8 个问题

  1. 为什么改了普通字段,界面不刷新?
  2. @Param 的值能直接改吗?不能的话应该怎么回传?
  3. if 和三元表达式在渲染语义上有什么本质区别?
  4. @Builder 传普通参数时,为什么响应式可能失效?
  5. PanGesture 的 offsetX 是哪一段的偏移?为什么会跳?
  6. 用 opacity 隐藏的元素为什么还能被点到?
  7. LazyForEach 和 Repeat.virtualScroll 分别适合什么数据形态?
  8. 页面退出后定时器还在跑,问题出在哪个生命周期?

如果这 8 个问题都能答上来,说明你已经跨过了"会抄 Demo"的阶段。

学习建议

  1. 每个知识点至少手写一遍,不要只复制运行;
  2. 用一条业务线串起来(比如本文的阅读器:书架 → 目录 → 正文 → 设置),比做 70 个孤立 Demo 更有价值;
  3. 遇到 API 变更先看 IDE 提示和官方签名,不要凭记忆修改;
  4. 性能问题先量再改,每次只动一个变量并复测。

如果这篇文章对你有帮助,欢迎点赞、收藏。后续会继续把"阅读器"这条业务线做成一个完整可运行的项目,把这里每个 Demo 落到真实页面里。


标签: HarmonyOS · ArkUI · ArkTS · 声明式 UI · 前端开发

相关推荐
小码哥_常2 小时前
前端图片压缩实战——我把网站图片体积砍了 70%
前端
张一凡932 小时前
把“不要乱改”从提示词里拿出来,放进可校验的提案里
前端·后端·架构
木子欢儿2 小时前
Debian 13 + vsftpd 搭建FTP服务
linux·服务器·前端·网络·debian
leeguoo2 小时前
让 Claude Code / Codex 用上你已登录的 Chrome
前端·chrome·ai编程
天蒙蒙亮3 小时前
打安卓apk包+“应用商城”发布
前端·uni-app
Lank_M3 小时前
视频压到指定大小总超一截?码率控制的误差拆开看
前端
用户2054555788003 小时前
飞书运费台账同步到 MySQL:单一策略落地教程
前端
LEE3 小时前
别把 Agent 架构画成组织架构图——子代理该不该拆,用任务级 Eval 说话
前端·后端·ai编程
梦醒江南3 小时前
RAG 证据链验收:别只测答案像不像,要证明它从哪里来
前端·agent·ai编程