鸿蒙 ArkUI 组件基础复盘:从两个 UI 卡片回到 ComponentV2、状态管理和组件分层
本文通过两个ArkUI组件开发案例,复盘了鸿蒙应用开发的核心概念。文章从功能实现后回顾基础的重要性出发,系统讲解了@ComponentV2、struct、build()的组件基本结构,重点分析了组件分层、状态管理(@Local/@Param/@Event)和响应式数据(@ObservedV2/@Trace)的使用场景。通过AI总结卡片和问题走马灯卡件的具体实现,阐述了如何通过组件抽离降低复杂度、提
鸿蒙 ArkUI 组件基础复盘:从两个 UI 卡片回到 ComponentV2、状态管理和组件分层
一、为什么做完功能后要回头复习基础
最近做了两个比较典型的 ArkUI UI 卡片:
1. AI 总结卡片:展示 AI 标识、总结文案和查看详情入口。
2. AI 问题走马灯卡片:展示上下两排推荐问题,支持自动轮播、手动滑动暂停、点击问题进入 AI 聊天。
这两个组件看起来只是 UI,但实际涉及:
@ComponentV2
struct
build()
@Builder
@Local
@Param
@Event
@ObservedV2
@Trace
Callback
Swiper
TouchEvent
ViewModel
Controller
数组响应式更新
一开始写功能时,目标往往是“先跑通”。但做完以后回头看,会发现这些基础概念决定了代码能不能维护、能不能复用、后续需求变更时会不会崩。
本文所有代码均为通用示例,不包含公司项目路径、业务常量、接口地址或内部封装。
二、自定义组件:@ComponentV2、struct、build()
一个 ArkUI 自定义组件常见结构如下:
@ComponentV2
export struct AiSummaryCard {
build() {
Column() {
Text('AI 总结')
Text('这里展示总结内容')
}
}
}
三者关系可以这样理解:
@ComponentV2:
声明这是一个使用 V2 状态管理能力的 ArkUI 自定义组件。
struct:
组件结构体。ArkUI 自定义组件一般通过 struct 进行声明。
build():
组件的 UI 描述入口。页面最终展示什么,主要在 build() 中声明。
一句话记忆:
@ComponentV2 是身份标识,struct 是组件载体,build() 是 UI 出口。
build() 适合写 UI 结构、简单条件渲染、样式声明,不适合写接口请求、路由跳转、复杂数组处理、定时器创建等副作用逻辑。复杂逻辑应该放到 Controller、ViewModel、生命周期函数或事件回调中。
三、为什么要把卡片抽成独立组件
如果把 AI 总结卡片、走马灯卡片、搜索框、列表内容全部写在一个大组件里,父组件会越来越臃肿。
更好的方式是:
AiSummaryCard({
summaryText: this.viewModel.summaryText,
onClickDetail: () => {
this.controller.openAiDetail()
}
})
AiQuestionMarqueeCard({
topQuestionList: this.viewModel.topQuestionList,
bottomQuestionList: this.viewModel.bottomQuestionList,
isPaused: this.viewModel.paused,
onQuestionClick: (question: string) => {
this.controller.openAiChat(question)
}
})
组件抽离的意义:
1. 降低父组件复杂度。
2. 提高复用性。
3. 统一样式。
4. 方便单独调试。
5. 父组件只关心传什么数据,不关心组件内部怎么画。
6. 更符合 Component + ViewModel + Controller 的分层思路。
四、@Builder:UI 片段函数
@Builder 可以理解为“UI 片段函数”。
@Builder
AskButtonBuilder() {
Row() {
Text('问AI')
}
.width(88)
.height(40)
.borderRadius(12)
}
它适合拆分:
一个按钮
一个问题气泡
一个头部区域
一个列表 item
一个轮播区域
但 @Builder 不是业务函数。它可以有简单 UI 判断,但不应该承载接口请求、路由跳转、复杂数据处理。
可以这样记:
@Builder 是 UI 片段函数,不是业务函数。
五、@Local:组件内部响应式状态
@Local 适合保存当前组件内部使用、并且变化后需要刷新 UI 的状态:
@Local private currentIndex: number = 0
@Local private expanded: boolean = false
@Local private keyword: string = ''
它的特点:
1. 只属于当前组件。
2. 父组件不需要传。
3. 状态变化后,当前组件 UI 需要刷新。
不是所有成员变量都要加 @Local。比如普通常量、timerId、控制器对象,通常用普通 private 成员即可。
@Local = 当前组件自己的响应式状态
private = 当前组件自己的普通变量
@Param = 父组件传进来的外部状态
六、@Param:父组件向子组件传数据
@Param 用来接收父组件传入的数据:
@ComponentV2
export struct AiSummaryCard {
@Param summaryText: string = ''
build() {
Text(this.summaryText)
}
}
父组件使用:
AiSummaryCard({
summaryText: this.viewModel.summaryText
})
走马灯组件中也可以这样写:
@Param topQuestionList: string[] = []
@Param bottomQuestionList: string[] = []
@Param isPaused: boolean = false
这表示:
父组件负责准备数据。
子组件负责根据数据展示 UI。
一般不建议子组件直接修改 @Param 接收到的数据。更推荐:子组件通过 @Event 通知父组件,由父组件或 Controller 修改 ViewModel 后再传回来。
七、@Event:子组件向父组件抛事件
@Event 本质上是父组件传给子组件的回调函数。
@Event onQuestionClick?: Callback<string>
子组件点击问题时:
.onClick(() => {
this.onQuestionClick?.(question)
})
父组件使用:
AiQuestionMarqueeCard({
onQuestionClick: (question: string) => {
this.controller.openAiChat(question)
}
})
这个过程就是:
子组件发生点击
↓
子组件把 question 抛给父组件
↓
父组件拿到 question
↓
父组件决定跳转、请求、埋点等业务行为
这样做可以避免子组件和具体业务强绑定。
八、为什么子组件不直接跳转页面
如果在子组件里直接写:
HMUtil.push({
pageUrl: 'AgentChatPage'
})
这个组件就被写死了:
只能跳这个页面
只能用这个路由工具
别的页面想复用就很困难
更推荐:
@Event onAskClick?: Callback<void>
.onClick(() => {
this.onAskClick?.()
})
父组件决定具体行为:
AiQuestionMarqueeCard({
onAskClick: () => {
this.controller.openAiChat()
}
})
这样组件可以被不同页面复用。
九、@ObservedV2 和 @Trace
在 V2 状态管理中,ViewModel 常见写法如下:
@ObservedV2
export class AiQuestionViewModel {
@Trace topQuestionList: string[] = []
@Trace bottomQuestionList: string[] = []
@Trace paused: boolean = false
}
可以这样理解:
@ObservedV2:
修饰一个可以被状态管理系统观测的类。
@Trace:
修饰类中需要被追踪的属性。
ViewModel 适合使用 @ObservedV2,因为 ViewModel 本来就是页面状态集合,例如 loading、list、keyword、paused、summaryText 等字段。这些字段变化后,UI 往往需要跟着刷新。
@ObservedV2 修饰 ViewModel 类
@Trace 修饰 ViewModel 中需要驱动 UI 刷新的字段
十、为什么数组更新推荐重新赋值
不推荐:
this.viewModel.list.push(newItem)
更推荐:
this.viewModel.list = this.viewModel.list.concat([newItem])
或者:
this.viewModel.list = [...this.viewModel.list, newItem]
原因是:
push 修改的是原数组内容,数组引用地址没有变化。
重新赋值一个新数组,字段引用变化更明显,响应式系统更容易识别并刷新 UI。
所以在 ViewModel 中更新数组时,尽量使用新数组赋值。
十一、结合走马灯需求:为什么拆成上下两个数组
假设原始数据是 10 条:
[A, B, C, D, E, F, G, H, I, J]
如果每次都在组件内部切:
上排:前 5 个
下排:后 5 个
当你把第一个元素移到最后:
[B, C, D, E, F, G, H, I, J, A]
再重新切分就变成:
上排:B C D E F
下排:G H I J A
这样原本下排的内容可能跑到上排,视觉上会跳动。
更好的方式是 Controller 先拆成两个数组:
topQuestionList: A B C D E
bottomQuestionList: F G H I J
Card 只负责接收这两个数组并渲染,不关心原始数据怎么来。
十二、展示逻辑和业务逻辑怎么区分
Card 组件里的展示逻辑包括:
1. 用 Row / Column / Stack 怎么布局。
2. 上下两个 Swiper 怎么摆。
3. 问题气泡怎么画。
4. 问AI按钮怎么画。
5. 字体、颜色、圆角、阴影。
6. 根据 isPaused 控制 autoPlay。
业务逻辑包括:
1. 请求推荐问题数据。
2. 校验接口返回数组。
3. 拆分上下两排。
4. 跳转 AI 聊天页。
5. 携带 question 参数。
6. 埋点。
7. 登录判断。
8. 异常处理。
一个好维护的组件应该尽量做到:
组件只负责展示和事件抛出。
业务逻辑放到 Controller。
状态放到 ViewModel。
十三、手动滑动暂停状态放哪里
手动滑动暂停 1 秒,可以这样设计:
ViewModel:
保存 paused 状态。
Controller:
控制什么时候 paused = true。
控制 1 秒后 paused = false。
Card:
接收 isPaused。
根据 isPaused 控制 Swiper 是否 autoPlay。
示例:
public pauseMarqueeStart(): void {
this.viewModel.paused = true
this.clearResumeTimer()
}
public pauseMarqueeEnd(): void {
this.clearResumeTimer()
this.resumeTimerId = setTimeout(() => {
this.viewModel.paused = false
}, 1000)
}
Card 里只负责抛事件:
private handleTouchEvent(event: TouchEvent): void {
if (event.type === TouchType.Down) {
this.onTouchStart?.()
return
}
if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
this.onTouchEnd?.()
}
}
这样如果以后产品说暂停 2 秒,只改 Controller,不改 Card。
十四、这轮基础复习的收获
1. @ComponentV2 声明组件,struct 是组件载体,build 是 UI 出口。
2. build 里应该主要写声明式 UI,不适合堆复杂业务。
3. @Builder 用来拆 UI 片段,不是业务函数。
4. @Local 是组件内部响应式状态。
5. @Param 是父组件传给子组件的数据。
6. @Event 是子组件抛给父组件的事件,本质是回调函数。
7. @ObservedV2 适合修饰 ViewModel 这类可观测类。
8. @Trace 修饰 ViewModel 中需要驱动 UI 刷新的字段。
9. 数组更新尽量重新赋值新数组,避免 push 后 UI 不刷新。
10. 可复用组件不应该写死请求、跳转和具体业务。
十五、总结
这次不是单纯做一个走马灯卡片,而是通过真实功能把 ArkUI 组件开发的基础重新串了一遍。
从实现角度看,我学到了:
Swiper、Scroll、Scroller、Marquee 的适用场景。
Swiper 通过 interval、duration、curve 模拟近似走马灯。
TouchEvent 可以处理用户按下、滑动、抬起。
从架构角度看,我学到了:
Controller 负责业务逻辑。
ViewModel 负责响应式状态。
Card 组件负责展示。
@Param 负责父传子。
@Event 负责子传父。
对鸿蒙开发实习来说,这类复盘很有价值。因为真正写业务时,不只是把 UI 画出来,还要考虑组件职责、状态流向、复用性和后续维护成本。
参考链接
-
自定义组件
https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts-create-custom-components -
状态管理 V2 总览
https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts-state-management-overview -
MVVM 模式 V2
https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts-mvvm-v2 -
@Local 装饰器
https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts-new-local -
@Param 装饰器
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-new-param -
@Event 装饰器
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-new-event -
@ObservedV2 和 @Trace
https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts-new-observedv2-and-trace -
自定义组件生命周期
https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts-page-custom-components-lifecycle -
Swiper 组件
https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-container-swiper -
触摸事件
https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-universal-events-touch
更多推荐

所有评论(0)