从 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 重新整理成一篇可以一口气读完的实战笔记。每个知识块都遵循同一个节奏:先给最小代码,再解释为什么这么写,最后列出容易踩的坑。
阅读约定:
- 默认使用 ArkUI 状态管理 V2 (
@ComponentV2+@Local),V1 装饰器单独在第 04 章做对照。 - 每段代码都是独立可运行的
Index.ets,复制到entry/src/main/ets/pages/下即可(注意不要同时保留多个@Entry)。 - API 会随 SDK 版本演进,遇到标红请以 DevEco Studio 的签名提示为准。
- 全文用"阅读器"这一条业务线贯穿示例,方便你把零散知识点串成一个真实产品。
知识地图:
| 阶段 | 章节 | 解决的问题 |
|---|---|---|
| 入门 | 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 布局排查清单
布局不符合预期时,按这个顺序检查:
- 父容器有没有明确宽高?
height('100%')的父级是不是也是确定高度? - 主轴方向搞对了吗?
justifyContent管主轴,alignItems管交叉轴。 - 子项总宽度是否超过容器?超过时用
layoutWeight或限制宽度。 - 是否该用
List却用了Scroll? - 是否该用
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 章踩坑清单
- V1/V2 混写 :
@State的父组件里塞@ComponentV2子组件,状态同步行为会变得难以预期。 - 深层对象改了不刷新 :只加了
@Local,没给类属性加@Trace。 - 直接改
@Param:编译期就会提示不可赋值,正确做法是@Event回传,让父组件改。 - 状态放得太高:只在单个组件用的临时状态,没必要提到全局 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 章选型决策
- 只要"点了做事" →
onClick; - 需要按下/移动/抬起的坐标 →
onTouch; - 单击、双击、长按、拖动 →
Gesture; - 输入值变化 → 组件自身的
onChange; - 组件自带的能力(下拉刷新、页面加载完) → 组件专有回调。
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 动画性能建议
- 优先动
translate、scale、opacity这类不触发布局的属性; - 避免对
width/height/margin做长列表内的持续动画; - 动画时长控制在 200--400ms,超过 500ms 会显得拖沓;
- 列表项动画要结合虚拟滚动,不要一次给上千个节点启动动画。
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 章行动清单
- 找一条最卡的列表,用虚拟滚动改造,记录前后帧率;
- 把大对象拆成
@ObservedV2+@Trace,观察刷新范围; - 搜索所有
onChange,给搜索框加防抖; - 检查所有定时器与请求,确认
aboutToDisappear里有清理; - 检查图片是否按展示尺寸加载。
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里不清,页面来回切换几次就会出现重复计时、重复请求。
12.3 Navigation:声明式路由
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 个问题
- 为什么改了普通字段,界面不刷新?
@Param的值能直接改吗?不能的话应该怎么回传?if和三元表达式在渲染语义上有什么本质区别?@Builder传普通参数时,为什么响应式可能失效?PanGesture的offsetX是哪一段的偏移?为什么会跳?- 用
opacity隐藏的元素为什么还能被点到? LazyForEach和Repeat.virtualScroll分别适合什么数据形态?- 页面退出后定时器还在跑,问题出在哪个生命周期?
如果这 8 个问题都能答上来,说明你已经跨过了"会抄 Demo"的阶段。
学习建议
- 每个知识点至少手写一遍,不要只复制运行;
- 用一条业务线串起来(比如本文的阅读器:书架 → 目录 → 正文 → 设置),比做 70 个孤立 Demo 更有价值;
- 遇到 API 变更先看 IDE 提示和官方签名,不要凭记忆修改;
- 性能问题先量再改,每次只动一个变量并复测。
如果这篇文章对你有帮助,欢迎点赞、收藏。后续会继续把"阅读器"这条业务线做成一个完整可运行的项目,把这里每个 Demo 落到真实页面里。
标签: HarmonyOS · ArkUI · ArkTS · 声明式 UI · 前端开发