鸿蒙业务需求实战:AI 问题走马灯卡片实现复盘
本文总结了鸿蒙业务中AI问题走马灯卡片的实现过程。需求包含10条问题的自动轮播展示、手动滑动控制、点击跳转等功能。实现采用分层架构:Biz层mock数据接口;ViewModel管理状态;Controller处理业务逻辑;UI组件负责展示和交互。重点包括数据校验、状态管理、事件回调等,确保组件可复用且与业务解耦。通过@Trace实现响应式更新,@Param接收数据,@Event抛出交互事件,使组件职
鸿蒙业务需求实战:AI 问题走马灯卡片实现复盘
一、需求背景
这次实现的是一个“AI 问题走马灯卡片”功能,主要出现在业务页面中的附件 / 推荐区域,用来展示一组可点击的 AI 问题。
产品需求大致如下:
1. 展示 10 条问题。
2. 问题以跑马灯形式从右向左自动播放。
3. 卡片中分为上下两排问题。
4. 右下角固定一个“问AI”按钮。
5. 左右两边有渐变模糊效果。
6. 页面进入后自动播放。
7. 播放速度大约为 6s / 条。
8. 支持用户手动左右滑动。
9. 用户手动滑动后暂停 1s,再继续自动播放。
10. 点击问题区域跳转 Agent,并把问题带过去,后续用于自动发送。
11. 点击“问AI”按钮只跳转 Agent,不需要携带问题参数。
这个需求看起来是一个 UI 动画需求,但实际涉及到:
1. 数据 mock
2. ViewModel 状态管理
3. Controller 调用接口并更新状态
4. ArkUI 横向滚动
5. Scroller 滚动控制器
6. 定时器控制自动播放
7. 手动滑动暂停
8. 组件事件回调
9. 路由跳转传参
10. UI 与业务逻辑分层
这篇文档主要记录从需求拆解到最终实现的过程,后续忘记时可以直接看这篇复盘。
二、整体实现思路
这个需求不能直接把所有逻辑写在 UI 组件里。公司项目一般会按照下面的方式拆分:
Biz
↓
模拟后端 / AI 接口,返回 10 条问题
ViewModel
↓
保存问题数组和 loading 状态
Controller
↓
调用 Biz
校验数据
更新 ViewModel
处理点击跳转 Agent
UI 组件
↓
接收问题数组
渲染走马灯卡片
处理滚动动画
把点击事件抛给父组件
也就是:
AttachmentAiQuestionBiz
↓
NearbyPoiSearchController
↓
NearbyPoiSearchViewModel
↓
AttachmentAiQuestionMarqueeCard
这种拆法的好处是:
1. UI 组件只负责展示。
2. 数据来源后续可以从 mock 替换成真实接口。
3. 跳转逻辑统一放在 Controller。
4. ViewModel 负责驱动 UI 响应式刷新。
5. 组件后续可以迁移到真实入口,不和临时页面强耦合。
三、Biz 层 mock 假接口
因为后端 / AI 暂时没有提供接口,所以先在 Biz 层写一个 mock 方法返回 10 条问题。
示例:
export class AttachmentAiQuestionBiz {
static async getAiQuestionList(): Promise<string[]> {
return new Promise<string[]>((resolve) => {
setTimeout(() => {
resolve([
'西九站有母婴室吗',
'西九龙站有ATM机吗',
'附近有推荐商场吗',
'高铁站怎么打车',
'西九龙有寄存吗',
'附近有什么吃的',
'站内可以买电话卡吗',
'过关需要多久',
'附近有地铁站吗',
'去机场怎么走'
])
}, 300)
})
}
}
这里使用了:
static async getAiQuestionList(): Promise<string[]>
含义如下:
static:
表示静态方法,不需要 new 一个 Biz 对象,可以直接通过类名调用。
async:
表示这是异步方法。
Promise<string[]>:
表示这个方法最终会返回一个字符串数组。
setTimeout:
模拟接口延迟,方便模拟真实网络请求。
后续真实接口提供后,只需要把这里的 mock 数据替换成真实请求即可,UI 和 Controller 大部分不用改。
四、ViewModel 保存状态
ViewModel 负责保存页面状态,UI 会根据 ViewModel 的变化自动刷新。
这次至少需要两个字段:
@Trace aiQuestionList: string[] = []
@Trace aiQuestionLoading: boolean = false
含义:
aiQuestionList:
保存接口返回的 10 条 AI 问题。
aiQuestionLoading:
表示是否正在加载问题列表。
这里使用 @Trace 的原因是:它可以让字段具备响应式能力。Controller 更新 aiQuestionList 后,UI 会重新渲染。
简单理解:
Controller 更新 VM
↓
VM 状态变化
↓
UI 重新 build
↓
页面展示新的问题列表
五、Controller 调接口并校验数据
UI 不应该直接调用 Biz。更合理的做法是由 Controller 负责调用接口、校验数据、更新 ViewModel。
示例:
private readonly AI_QUESTION_MAX_COUNT: number = 10
private readonly AI_QUESTION_MAX_LENGTH: number = 16
public loadAiQuestionList(): void {
this.viewModel.aiQuestionLoading = true
AttachmentAiQuestionBiz.getAiQuestionList()
.then((list: string[]) => {
this.viewModel.aiQuestionList = this.formatAiQuestionList(list)
this.viewModel.aiQuestionLoading = false
})
.catch((err: Error) => {
this.viewModel.aiQuestionList = []
this.viewModel.aiQuestionLoading = false
})
}
这里做了几件事:
1. 进入请求前,把 loading 置为 true。
2. 调用 Biz 获取问题数组。
3. then 中拿到返回数据。
4. 调用 formatAiQuestionList 进行校验和过滤。
5. 更新 ViewModel。
6. 请求失败时清空数组,并关闭 loading。
六、为什么要校验接口数据
即使现在是 mock 数据,也要模拟真实接口习惯,不能直接相信接口返回。
Controller 中可以写一个格式化方法:
private formatAiQuestionList(list: string[]): string[] {
if (!Array.isArray(list)) {
return []
}
const result: string[] = []
list.forEach((item: string) => {
const text: string = item.trim()
if (text.length === 0 || text.length > this.AI_QUESTION_MAX_LENGTH) {
return
}
if (result.length < this.AI_QUESTION_MAX_COUNT) {
result.push(text)
}
})
return result
}
这里做了几层保护:
1. 判断是否是数组。
2. 判断每一项是否为空。
3. 去掉字符串前后空格。
4. 限制字符串长度,避免 UI 被撑开。
5. 最多保留 10 条。
这样即使后端返回脏数据,也不会直接影响 UI。
七、点击问题跳转 Agent
需求里有两个点击行为:
点击问题气泡:
跳转 Agent,并携带 question 参数,后续用于自动发送。
点击“问AI”按钮:
只跳转 Agent,不携带问题参数。
一开始可以写两个方法:
jumpAgentWithQuestion(question)
jumpAgentPage()
但其实没必要。更简单的方式是让 question 参数变成可选。
public jumpAgentWithQuestion(question?: string): void {
if (question && question.length > 0) {
HMUtil.push({
pageUrl: LushuPageConstant.AgentChatPage,
param: {
question: question,
source: 'attachment_ai_question_marquee',
autoSend: true
}
})
return
}
HMUtil.push({
pageUrl: LushuPageConstant.AgentChatPage
})
}
这样一个方法就能处理两种情况:
this.controller.jumpAgentWithQuestion(question)
↓
带问题跳转
this.controller.jumpAgentWithQuestion()
↓
只跳转 Agent,不带问题
这样代码更简洁,也避免新增重复方法。
八、组件通过 @Param 接收问题数组
走马灯组件只负责展示,所以通过 @Param 接收父组件传入的问题数组:
@ComponentV2
export struct AttachmentAiQuestionMarqueeCard {
@Param questionList: string[] = []
}
父组件使用时:
AttachmentAiQuestionMarqueeCard({
questionList: this.viewModel.aiQuestionList
})
这样组件不用知道数据从哪里来,只负责把数组渲染出来。
九、组件通过 @Event 抛出点击事件
组件内部不应该直接调用 Controller,也不应该直接写路由跳转。
所以用 @Event 把点击事件抛给父组件:
@Event onQuestionClick?: Callback<string>
@Event onAskClick?: Callback<void>
@Event onRoundEnd?: Callback<void>
分别表示:
onQuestionClick:
点击某一个问题时触发,并把 question 传出去。
onAskClick:
点击“问AI”按钮时触发,不传问题。
onRoundEnd:
一轮播放结束时触发,用于通知父组件调整问题顺序。
组件内部点击问题:
.onClick(() => {
this.onQuestionClick?.(question)
})
组件内部点击“问AI”:
.onClick(() => {
this.onAskClick?.()
})
父组件使用:
AttachmentAiQuestionMarqueeCard({
questionList: this.viewModel.aiQuestionList,
onQuestionClick: (question: string) => {
this.controller.jumpAgentWithQuestion(question)
},
onAskClick: () => {
this.controller.jumpAgentWithQuestion()
},
onRoundEnd: () => {
this.controller.changeAiQuestionOrder()
}
})
这样组件和业务逻辑就是解耦的。
十、为什么用 Stack 作为卡片外层
这个 UI 不是简单的一行文字,而是有多层结构:
背景卡片
↓
两行滚动问题
↓
右下角固定“问AI”按钮
↓
左右渐变遮罩
如果用 Column,只能从上到下排。
如果用 Row,只能从左到右排。
但是这里有叠层,比如遮罩和按钮要覆盖在内容上,所以最外层更适合用:
Stack() {
...
}
Stack 可以让子组件叠在一起,非常适合这种卡片效果。
十一、为什么滚动区域分成两排
需求图里不是一排问题,而是上下两排问题,并且右下角留出空间放“问AI”。
所以 UI 结构调整为:
Column
├── 第一行 Scroll:上排问题
└── 第二行 Row
├── Scroll:下排问题
└── 问AI按钮
这样“问AI”按钮不需要用绝对定位,也不会跑偏。
之前尝试用:
.position({
x: 'calc(100% - 84vp)',
y: 34
})
但这种写法在当前 ArkUI 环境里不稳定,按钮容易跑到左边。
所以改成 Row 布局,让按钮天然固定在第二行右侧。
十二、为什么不用 Blank 做遮罩
一开始左右渐变遮罩使用了:
Blank()
但 ArkUI 报错:
The 'Blank' component can only be nested in the 'Row,Column,Flex' parent component.
意思是 Blank 只能放在 Row、Column、Flex 里面,不能直接放在 Stack 下面。
所以遮罩要么删掉,要么用空的 Row() / Column() 替代:
Row()
.width(24)
.height(96)
.linearGradient(...)
不过后面发现遮罩定位不稳定,容易跑到中间形成蒙层,所以第一阶段先删掉遮罩,保证核心滚动和按钮布局正常。
十三、Scroller 是什么
组件里有:
private topScroller: Scroller = new Scroller()
private bottomScroller: Scroller = new Scroller()
这里的 Scroller 是滚动控制器。
它不是 UI 组件,而是用来控制 Scroll 的对象。
绑定方式:
Scroll(this.topScroller) {
...
}
后续自动播放时,可以通过:
this.topScroller.scrollTo({
xOffset: this.marqueeOffset,
yOffset: 0,
animation: false
})
控制 Scroll 横向滚动到指定位置。
为什么要两个 Scroller?
因为卡片是上下两排:
topScroller:
控制第一行滚动。
bottomScroller:
控制第二行滚动。
自动播放时两个 Scroller 同步滚动,就能实现上下两排一起从右往左移动。
十四、自动从右往左播放
核心思路是使用定时器不断改变横向偏移量:
private startMarquee(): void {
this.stopMarquee()
this.marqueeTimerId = setInterval(() => {
if (this.isPause || this.questionList.length === 0) {
return
}
const rowCount: number = Math.ceil(this.questionList.length / 2)
const oneRoundWidth: number = rowCount * (this.ITEM_WIDTH + this.ITEM_SPACE)
const oneItemDistance: number = this.ITEM_WIDTH + this.ITEM_SPACE
const speedPerTick: number = oneItemDistance / (this.PLAY_DURATION_PER_ITEM / this.TICK_INTERVAL)
this.marqueeOffset += speedPerTick
this.topScroller.scrollTo({
xOffset: this.marqueeOffset,
yOffset: 0,
animation: false
})
this.bottomScroller.scrollTo({
xOffset: this.marqueeOffset,
yOffset: 0,
animation: false
})
}, this.TICK_INTERVAL)
}
这里几个常量很重要:
private readonly ITEM_WIDTH: number = 190
private readonly ITEM_SPACE: number = 8
private readonly TICK_INTERVAL: number = 60
private readonly PLAY_DURATION_PER_ITEM: number = 6000
含义:
ITEM_WIDTH:
每个问题气泡的宽度。
ITEM_SPACE:
每个气泡之间的间距。
TICK_INTERVAL:
定时器每隔多久执行一次,这里是 60ms。
PLAY_DURATION_PER_ITEM:
每条问题移动一个 item 宽度的时间,这里是 6000ms,也就是 6 秒 / 条。
速度计算:
const speedPerTick: number =
oneItemDistance / (this.PLAY_DURATION_PER_ITEM / this.TICK_INTERVAL)
也就是:
一个 item 距离
÷
一条问题需要多少次 tick
=
每次 tick 应该移动多少距离
这样就能控制播放速度。
十五、一轮结束后循环播放
当横向偏移量超过一轮宽度时,说明这一轮播完了:
if (this.marqueeOffset >= oneRoundWidth) {
this.marqueeOffset = 0
this.topScroller.scrollTo({
xOffset: 0,
yOffset: 0,
animation: false
})
this.bottomScroller.scrollTo({
xOffset: 0,
yOffset: 0,
animation: false
})
this.onRoundEnd?.()
return
}
这里做了三件事:
1. 把偏移量重置为 0。
2. 把上下两个 Scroll 都滚回开头。
3. 触发 onRoundEnd,让 Controller 调整问题顺序。
Controller 中可以实现:
public changeAiQuestionOrder(): void {
const list: string[] = this.viewModel.aiQuestionList
if (list.length <= 1) {
return
}
const first: string = list[0]
const rest: string[] = list.slice(1)
this.viewModel.aiQuestionList = rest.concat([first])
}
这样每一轮结束后,列表顺序会变化,看起来不会一直从同一个问题开始。
十六、手动滑动暂停 1 秒
需求要求:
用户手动左右滑动后,暂停 1 秒再继续自动播放。
实现方式是监听触摸事件:
.onTouch((event: TouchEvent) => {
if (event.type === TouchType.Down) {
this.pauseMarqueeByUser()
}
})
暂停方法:
private pauseMarqueeByUser(): void {
this.isPause = true
this.clearResumeTimer()
this.resumeTimerId = setTimeout(() => {
this.isPause = false
}, 1000)
}
含义:
1. 用户按下时,把 isPause 设为 true。
2. 自动播放定时器发现 isPause 为 true,就不继续滚动。
3. 清理旧的恢复定时器,避免多次触发。
4. 1 秒后把 isPause 改回 false。
5. 自动播放继续。
十七、为什么要清理定时器
组件中有两个定时器:
private marqueeTimerId: number = -1
private resumeTimerId: number = -1
分别负责:
marqueeTimerId:
控制自动跑马灯。
resumeTimerId:
控制手动滑动后 1 秒恢复。
组件销毁时必须清理:
aboutToDisappear(): void {
this.stopMarquee()
this.clearResumeTimer()
}
否则会出现:
1. 页面离开后定时器还在跑。
2. 重进页面后创建多个定时器。
3. 滚动越来越快。
4. 页面卡顿。
5. 可能导致内存问题。
所以只要写了 setInterval / setTimeout,就要记得在生命周期里清理。
十八、为什么之前会卡住、左右平移
之前第一版出现了这些问题:
1. 问AI按钮跑到左边。
2. 滚动区域看起来卡住。
3. 内容一直左右平移。
4. 中间多出一层蒙层。
原因大致是:
1. 使用 position + calc 布局不稳定。
2. 整体 Scroll 和按钮叠层关系不清楚。
3. 左右遮罩使用 align 定位不稳定,跑到了中间。
4. 自动滚动 reset 时视觉跳动明显。
解决方式是:
1. 去掉 position。
2. 让问AI按钮作为第二行 Row 的固定右侧元素。
3. 上下两排分别用两个 Scroll。
4. 暂时移除不稳定遮罩。
5. 先保证基础滚动和点击正常。
这也是业务开发中常见的思路:先把核心结构跑通,再慢慢补视觉细节。
十九、组件最终使用方式
父组件中使用:
AttachmentAiQuestionMarqueeCard({
questionList: this.viewModel.aiQuestionList,
onQuestionClick: (question: string) => {
this.controller.jumpAgentWithQuestion(question)
},
onAskClick: () => {
this.controller.jumpAgentWithQuestion()
},
onRoundEnd: () => {
this.controller.changeAiQuestionOrder()
}
})
含义:
questionList:
从 ViewModel 传入 10 条问题。
onQuestionClick:
点击某个问题,带 question 跳转 Agent。
onAskClick:
点击问AI,只跳转 Agent,不带 question。
onRoundEnd:
一轮播完后,通知 Controller 调整问题顺序。
二十、完整链路
最终链路可以总结为:
页面进入
↓
Controller.loadAiQuestionList()
↓
Biz mock 返回 10 条问题
↓
Controller 校验数据
↓
ViewModel.aiQuestionList 更新
↓
AttachmentAiQuestionMarqueeCard 接收 questionList
↓
上下两排 Scroll 渲染问题气泡
↓
aboutToAppear 启动定时器
↓
Scroller.scrollTo 控制从右往左自动滚动
↓
用户手动滑动时暂停 1 秒
↓
点击问题,父组件调用 Controller 跳 Agent 并带 question
↓
点击问AI,父组件调用 Controller 只跳 Agent
二十一、本次需求涉及的基础知识点
这次需求包含很多基础点:
@ComponentV2
定义一个 ArkUI V2 组件。
struct
定义组件结构。
build()
描述组件 UI。
@Param
接收父组件传入的数据。
@Event
接收父组件传入的事件回调。
Callback<string>
表示一个接收 string 参数的回调函数。
private
表示只在当前组件内部使用。
public
表示可以被外部调用。
static
表示不需要创建实例,可以直接通过类调用。
new
表示创建一个类的实例。
Scroller
滚动控制器,用来控制 Scroll 的滚动位置。
Scroll
滚动容器。
ForEach
根据数组动态渲染 UI。
setInterval
周期性执行自动滚动。
setTimeout
延迟 1 秒恢复自动播放。
aboutToAppear
组件出现时启动播放。
aboutToDisappear
组件消失时清理定时器。
HMUtil.push
项目封装的页面跳转方法。
二十二、当前阶段完成情况
目前已经完成:
1. mock 返回 10 条 AI 问题。
2. ViewModel 保存问题数组。
3. Controller 调用 Biz 并更新 VM。
4. UI 渲染两排问题气泡。
5. 支持横向自动播放。
6. 支持手动左右滑动。
7. 手动滑动后暂停 1 秒。
8. 一轮播放结束后调整顺序。
9. 点击问题跳 Agent 并携带 question。
10. 点击问AI只跳 Agent,不携带 question。
暂未完全细化:
1. 左右模糊渐变遮罩最终样式。
2. 真实附件功能入口位置。
3. Agent 页面接收 question 后自动发送的真实实现。
4. 播放速度的最终产品确认。
5. 真接口替换 mock。
二十三、总结
这个 AI 问题走马灯卡片表面上是一个 UI 动画,但实现过程中涉及数据层、状态层、控制层和展示层的完整协作。
本次实现中,最重要的思路是:
Biz 负责数据来源。
Controller 负责业务处理和跳转。
ViewModel 负责保存状态。
UI 组件负责展示、滚动和事件抛出。
跑马灯本身不是靠一个简单的 Text 或 Swiper 完成,而是通过 Scroll + Scroller + setInterval 控制横向偏移量实现。手动滑动暂停则通过 onTouch + setTimeout 控制暂停和恢复。
最终代码虽然还可以继续优化,但目前已经完成了需求的核心闭环:进入页面自动播放、可手动滑动、点击问题跳 Agent、点击问AI只跳转。
后续如果要接入真实业务,只需要把 mock Biz 替换成真实接口,把组件挂到真实附件入口,并在 Agent 页面补充自动发送逻辑即可。
参考链接
-
ArkTS 语言介绍:
https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/introduction-to-arkts -
ArkUI 自定义组件:
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-create-custom-components -
Scroll 组件:
https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-container-scroll -
Scrollable 滚动通用接口:
https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-container-scrollable-common -
ArkTS 从 TypeScript 迁移规则:
https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/typescript-to-arkts-migration-guide -
ArkUI 状态管理概述:
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-state-management-overview
更多推荐

所有评论(0)