鸿蒙业务 UI 实战复盘:AI 问题走马灯卡片与 ArkTS 基础语法
一、为什么要写这篇复盘
这次做的是一个"附件功能里的 AI 问题走马灯卡片"。需求看起来只是一个小 UI:展示一组 AI 预设问题,整体是浅蓝色背景,问题以小气泡形式横向排列,右侧固定一个"问AI"按钮,后续还要支持自动从右往左播放、手动滑动、点击问题跳转到 Agent 并自动发送。
但真正写的时候会发现,这里面不只是 UI,还涉及 ArkTS 组件写法、状态管理、滚动容器、事件回调、类成员、访问修饰符、生命周期、Scroller、@Builder、@Param、@Event 等基础知识。
这篇复盘主要记录两个部分:
- 这个 UI 为什么这样设计。
- 写这个组件时遇到的 ArkTS / TypeScript 基础语法问题。
二、UI 为什么这样拆
这个需求的原型图里,卡片大概可以拆成三个区域:
text
外层浅蓝色圆角容器
├── 左侧 / 中间:问题气泡滚动区域
├── 左右两边:渐变遮罩
└── 右侧:固定"问AI"按钮
所以组件不应该直接写成一个简单的 Text,而应该用 Stack 作为最外层。
原因是 Stack 可以把多个区域叠在一起:
text
第一层:浅蓝色背景卡片
第二层:横向滚动的问题气泡
第三层:左右渐变遮罩
第四层:右侧固定"问AI"按钮
如果用 Column 或 Row,只能按顺序排版,很难实现"右侧按钮固定在卡片上方""遮罩盖在滚动内容两边"这种叠层效果。
所以这里选择:
ts
Stack() {
// 滚动内容
// 问AI按钮
// 左右渐变遮罩
}
三、为什么问题区域用 Scroll
需求要求问题可以横向滑动,并且后续要做从右往左播放。这种场景最直接的组件是:
ts
Scroll()
它可以包住一个比父容器更宽的内容区域,让内容在水平方向滚动。
示例结构:
ts
Scroll(this.questionScroller) {
Row({ space: 8 }) {
ForEach(this.questionList, (item: string) => {
this.QuestionItemBuilder(item)
}, (item: string) => item)
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.None)
其中:
text
ScrollDirection.Horizontal
表示横向滚动。
scrollBar(BarState.Off)
表示隐藏滚动条。
edgeEffect(EdgeEffect.None)
表示滑到边缘时不需要弹簧效果。
Row({ space: 8 })
表示每个问题气泡之间留 8 的间距。
四、为什么要写 private questionScroller: Scroller = new Scroller()
这句代码是很多初学者容易疑惑的地方:
ts
private questionScroller: Scroller = new Scroller()
它不是普通 UI,而是一个滚动控制器对象。
可以拆开理解:
text
private
表示这个成员只在当前组件内部使用。
questionScroller
是变量名。
Scroller
是类型,表示这是一个滚动控制器。
new Scroller()
表示创建一个新的 Scroller 实例。
也就是说,这句代码的意思是:
text
在当前组件内部创建一个名叫 questionScroller 的滚动控制器,
后续把它绑定给 Scroll 组件,用来控制或记录这个 Scroll 的滚动行为。
对应使用方式:
ts
Scroll(this.questionScroller) {
// 滚动内容
}
后续如果要做自动跑马灯,就可能需要用这个控制器去控制滚动位置。
为什么官网搜不到这句完整代码?因为官网通常不会按照"业务写法"提供完整句子,它会讲 Scroll、Scroller、滚动组件通用接口,但不会直接写出你项目里的成员变量名。questionScroller 是我们自己定义的变量名,不是官方 API 名称。
五、new 是什么意思
new 表示创建一个类的实例。
比如:
ts
private questionScroller: Scroller = new Scroller()
可以理解为:
text
Scroller 是一个类。
new Scroller() 是创建一个 Scroller 对象。
questionScroller 保存这个对象。
类似地,项目里经常会看到:
ts
@Local viewModel: TabHomeViewModel = new TabHomeViewModel(this.getUIContext())
controller: TabHomeController = new TabHomeController(this.viewModel)
意思分别是:
text
创建一个 ViewModel 对象,用来保存页面状态。
创建一个 Controller 对象,用来处理页面逻辑。
六、struct 是什么
鸿蒙 ArkUI 里经常看到:
ts
@ComponentV2
export struct AttachmentAiQuestionMarqueeCard {
build() {
...
}
}
这里的 struct 可以理解为一个自定义 UI 组件结构。
它和普通 class 不完全一样。在 ArkUI 声明式 UI 里,自定义组件通常使用 struct 定义,然后通过 build() 描述界面长什么样。
核心结构是:
ts
@ComponentV2
export struct DemoComp {
build() {
Column() {
Text('hello')
}
}
}
可以这样理解:
text
@ComponentV2
告诉 ArkUI:这是一个组件。
struct DemoComp
定义一个组件结构。
build()
描述这个组件具体渲染什么 UI。
七、build() 是什么
build() 是组件的渲染函数。
比如:
ts
build() {
Column() {
Text('hello')
}
}
意思是这个组件最终会渲染一个 Column,里面有一个 Text。
需要注意:build() 里应该主要写 UI 声明,不应该写复杂请求逻辑。
不推荐:
ts
build() {
this.controller.loadData()
Column() {
...
}
}
原因是 build() 可能会因为状态变化反复执行,如果在里面请求接口,可能导致重复请求、重复刷新,甚至死循环。
更合理的做法是:
ts
aboutToAppear() {
this.controller.loadData()
}
或者由页面父层统一触发请求。
八、@Builder 是什么
@Builder 可以理解为"把一段 UI 拆成一个局部方法"。
比如问题气泡:
ts
@Builder
QuestionItemBuilder(question: string) {
Row({ space: 6 }) {
Text(question)
Text('>')
}
}
这样在主 UI 里可以直接调用:
ts
this.QuestionItemBuilder(item)
它的作用是让 build() 不至于太长,提升可读性。
适合拆:
text
一个重复使用的小 UI
一个局部卡片
一个列表 item
一个空状态
一个错误状态
不适合在 @Builder 里写复杂业务逻辑,比如请求接口、处理数据、跳转判断等。
九、@Param 是什么
@Param 表示父组件传给子组件的数据。
例如:
ts
@ComponentV2
export struct AttachmentAiQuestionMarqueeCard {
@Param questionList: string[] = []
}
父组件使用时:
ts
AttachmentAiQuestionMarqueeCard({
questionList: this.viewModel.aiQuestionList
})
意思是:
text
父组件把 viewModel.aiQuestionList 传给子组件。
子组件通过 this.questionList 使用这组数据。
所以 @Param 很适合用于展示型组件,比如卡片标题、列表数据、是否 loading、按钮文案、当前选中状态。
十、@Event 是什么
@Event 表示父组件传给子组件的事件回调。
比如:
ts
@Event onQuestionClick?: Callback<string>
子组件点击问题时:
ts
.onClick(() => {
this.onQuestionClick?.(question)
})
父组件使用时:
ts
AttachmentAiQuestionMarqueeCard({
questionList: this.viewModel.aiQuestionList,
onQuestionClick: (question: string) => {
this.controller.jumpAgentWithQuestion(question)
}
})
完整链路是:
text
用户点击问题气泡
↓
子组件触发 onQuestionClick
↓
父组件收到 question
↓
父组件调用 controller.jumpAgentWithQuestion(question)
↓
跳转到 Agent 页面
这就是"子组件只负责抛事件,父组件决定业务逻辑"。
十一、为什么 UI 组件里不应该写业务函数
之前写 AI 总结卡片时,组件里有过类似方法:
ts
private jumpAgentChatPage(): void
private shouldShowAiSummaryCard(): boolean
private getAiSummaryText(): string
这些方法虽然能跑,但不符合项目分层。
原因是:
text
jumpAgentChatPage
属于跳转业务逻辑,应该放 Controller / Presenter。
shouldShowAiSummaryCard
属于展示条件判断,可以放 Controller / Presenter 或 ViewModel 计算后传入。
getAiSummaryText
属于文案处理逻辑,不应该让 UI 组件自己拼。
UI 组件应该尽量只做:
text
接收数据
展示 UI
触发事件
也就是:
text
ViewModel / Controller / Biz 负责逻辑
Component 负责展示
这样代码会更清晰,也更符合公司项目风格。
十二、private 是什么
private 是访问修饰符,表示这个成员只能在当前类或组件内部访问。
例如:
ts
private questionScroller: Scroller = new Scroller()
表示:
text
questionScroller 只给当前组件自己用。
外部组件不能直接访问它。
适合写成 private 的内容:
text
内部滚动控制器
内部辅助方法
内部格式化方法
内部常量
比如:
ts
private formatQuestionList(list: string[]): string[] {
...
}
外部不需要知道这个方法怎么实现,所以可以设为 private。
十三、public 是什么
public 表示公开成员,外部可以访问。
在 TypeScript / ArkTS 中,不写访问修饰符时,很多情况下默认就是 public。
比如 Controller 中的方法:
ts
public loadAiQuestionList(): void {
...
}
页面组件可以调用:
ts
this.controller.loadAiQuestionList()
适合写成 public 的内容:
text
页面生命周期需要调用的方法
UI 点击事件需要调用的方法
外部模块需要访问的业务方法
例如:
ts
public jumpAgentWithQuestion(question: string): void {
...
}
因为 UI 点击问题后需要调用这个方法,所以它可以是 public。
十四、static 是什么
static 表示静态成员,不需要创建对象就可以调用。
例如 Biz 里写:
ts
export class AttachmentAiQuestionBiz {
static async getAiQuestionList(): Promise<string[]> {
...
}
}
调用时可以直接写:
ts
AttachmentAiQuestionBiz.getAiQuestionList()
不用写:
ts
const biz = new AttachmentAiQuestionBiz()
biz.getAiQuestionList()
为什么 mock 接口适合写成 static?
因为它只是一个工具型方法:
text
不需要保存对象状态
不依赖 this
只是输入参数,返回数据
所以写成静态方法更简单。
十五、export 是什么
export 表示这个类、组件、函数可以被其他文件导入使用。
比如:
ts
export struct AttachmentAiQuestionMarqueeCard {
...
}
其他文件才能这样引入:
ts
import { AttachmentAiQuestionMarqueeCard } from './AttachmentAiQuestionMarqueeCard'
如果没有 export,别的文件就无法正常导入这个组件。
十六、import 是什么
import 表示从其他文件或模块引入内容。
比如:
ts
import { horizontal, match } from 'lushu_acommon'
import { Callback } from '@kit.BasicServicesKit'
意思是:
text
从 lushu_acommon 引入项目封装的样式工具。
从 @kit.BasicServicesKit 引入 Callback 类型。
项目里常见的工具:
ts
import { match, horizontal, bothway } from 'lushu_acommon'
这样就可以写:
ts
.width(match)
.padding(horizontal(12))
.padding(bothway(16, 8))
比到处写:
ts
.width('100%')
.padding({ left: 12, right: 12 })
更符合项目风格。
十七、Callback<string> 是什么
在事件回调里经常会看到:
ts
@Event onQuestionClick?: Callback<string>
可以理解为:
text
这是一个函数。
这个函数接收一个 string 参数。
没有特别关心返回值。
在这里就是:
text
点击问题后,把 question 这个字符串传给父组件。
如果不用 Callback<string>,也可以写成:
ts
@Event onQuestionClick?: (question: string) => void
但项目里如果已经统一使用 Callback,就跟项目风格保持一致。
十八、? 是什么
代码里经常有:
ts
@Event onQuestionClick?: Callback<string>
这里的 ? 表示这个属性是可选的。
也就是说,父组件可以传:
ts
onQuestionClick: ...
也可以不传。
所以子组件调用时要写:
ts
this.onQuestionClick?.(question)
这里的 ?. 是可选调用,意思是:
text
如果 onQuestionClick 存在,就调用。
如果不存在,就什么都不做。
这样可以避免空指针报错。
十九、string[] 是什么
string[] 表示字符串数组。
例如:
ts
@Param questionList: string[] = []
意思是:
text
questionList 是一个数组。
数组里的每一项都是 string。
所以可以这样遍历:
ts
ForEach(this.questionList, (item: string) => {
Text(item)
}, (item: string) => item)
项目中不要随便用 any 或 unknown,因为 ArkTS 有规则限制:
text
Use explicit types instead of "any", "unknown"
所以应该尽量写明确类型:
ts
list: string[]
item: string
err: Error
二十、为什么用 ForEach 渲染列表
问题数组是动态数据,不能写死 10 个 Text。
应该用:
ts
ForEach(this.questionList, (item: string) => {
this.QuestionItemBuilder(item)
}, (item: string) => item)
意思是:
text
遍历 questionList。
每一项都渲染一个问题气泡。
用 item 本身作为 key。
这样后续接口返回不同问题时,UI 会根据数组自动刷新。
二十一、为什么暂时不急着做自动跑马灯
需求最终是自动从右往左播放,并且支持手动滑动后暂停 1 秒再继续。
这个完整逻辑会涉及:
text
滚动控制器
定时器
当前滚动位置
手动滑动事件
暂停和恢复
页面销毁时清理定时器
一轮结束后循环播放
如果基础 UI 还没跑通,就直接写自动跑马灯,很容易出现问题。
所以开发顺序应该是:
text
第一步:mock 数据返回 10 条
第二步:ViewModel 保存数组
第三步:Controller 调接口更新 VM
第四步:UI 渲染问题气泡
第五步:支持手动横向滑动
第六步:点击问题跳 Agent
第七步:再加自动跑马灯
第八步:再加手动滑动暂停 1 秒
这就是业务开发里的"先跑通最小闭环,再逐步补交互"。
二十二、当前组件的核心代码理解
当前组件大概是这样:
ts
@ComponentV2
export struct AttachmentAiQuestionMarqueeCard {
@Param questionList: string[] = []
@Event onQuestionClick?: Callback<string>
@Event onAskClick?: Callback<void>
private questionScroller: Scroller = new Scroller()
build() {
Stack() {
Scroll(this.questionScroller) {
Row({ space: 8 }) {
ForEach(this.questionList, (item: string) => {
this.QuestionItemBuilder(item)
}, (item: string) => item)
}
}
.scrollable(ScrollDirection.Horizontal)
Text('问AI')
.onClick(() => {
this.onAskClick?.()
})
}
}
@Builder
QuestionItemBuilder(question: string) {
Row() {
Text(question)
}
.onClick(() => {
this.onQuestionClick?.(question)
})
}
}
可以拆成一句话:
text
父组件传入 questionList。
子组件用 ForEach 渲染问题气泡。
Scroll 负责横向滑动。
点击问题时,通过 onQuestionClick 把 question 抛给父组件。
点击问AI按钮时,通过 onAskClick 通知父组件。
二十三、这次真正学到的东西
这次需求不是单纯画一个卡片,而是在练业务开发的完整思路:
text
1. UI 要根据原型拆结构。
2. Stack 适合做叠层布局。
3. Scroll 适合做横向滑动内容。
4. Scroller 是滚动控制器,不是 UI。
5. @Param 用来接收父组件数据。
6. @Event 用来接收父组件回调。
7. @Builder 用来拆局部 UI。
8. private 表示内部使用。
9. public 表示外部可调用。
10. static 表示不创建对象也能调用。
11. export 让组件可以被其他文件导入。
12. import 用来引入其他模块。
13. string[] 表示字符串数组。
14. ForEach 用来渲染动态数组。
15. UI 组件不应该写复杂业务逻辑。
16. 请求和数据处理应该放 Biz / Controller / ViewModel。
二十四、总结
这个 AI 问题走马灯卡片从视觉上看只是一个浅蓝色卡片,但代码实现上涉及布局、滚动、事件、状态、分层和基础语法。
当前阶段最重要的不是一步到位完成所有动画,而是先把结构搭对:
text
Biz 返回 mock 问题
Controller 调用并校验
ViewModel 保存数组
UI 接收数组并渲染
点击问题通过事件回调交给父组件处理
等这个基础链路稳定后,再继续加自动跑马灯、手动滑动暂停、渐变遮罩、点击自动发送等复杂交互。
写鸿蒙业务代码时,最重要的是不要把所有逻辑塞进 UI 组件。组件负责展示,Controller 负责业务,ViewModel 负责状态,Biz 负责数据来源。这样代码后续才容易维护,也更符合公司项目的分层风格。
参考链接
-
ArkTS 语言介绍:
developer.huawei.com/consumer/cn... -
自定义组件创建与
@ComponentV2、@Param、@Event:
developer.huawei.com/consumer/cn... -
Scroll 组件官方文档:
developer.huawei.com/consumer/cn... -
滚动组件通用接口:
developer.huawei.com/consumer/cn... -
自定义组件成员属性访问限定符使用限制:
developer.huawei.com/consumer/cn... -
从 TypeScript 到 ArkTS 的适配规则:
developer.huawei.com/consumer/cn...