鸿蒙 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 画出来,还要考虑组件职责、状态流向、复用性和后续维护成本。


参考链接

  1. 自定义组件
    https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts-create-custom-components

  2. 状态管理 V2 总览
    https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts-state-management-overview

  3. MVVM 模式 V2
    https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts-mvvm-v2

  4. @Local 装饰器
    https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts-new-local

  5. @Param 装饰器
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-new-param

  6. @Event 装饰器
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-new-event

  7. @ObservedV2 和 @Trace
    https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts-new-observedv2-and-trace

  8. 自定义组件生命周期
    https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts-page-custom-components-lifecycle

  9. Swiper 组件
    https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-container-swiper

  10. 触摸事件
    https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-universal-events-touch

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐