鸿蒙业务需求实战:AI 问题走马灯卡片实现复盘

鸿蒙业务需求实战:AI 问题走马灯卡片实现复盘

一、需求背景

这次实现的是一个"AI 问题走马灯卡片"功能,主要出现在业务页面中的附件 / 推荐区域,用来展示一组可点击的 AI 问题。

产品需求大致如下:

text 复制代码
1. 展示 10 条问题。
2. 问题以跑马灯形式从右向左自动播放。
3. 卡片中分为上下两排问题。
4. 右下角固定一个"问AI"按钮。
5. 左右两边有渐变模糊效果。
6. 页面进入后自动播放。
7. 播放速度大约为 6s / 条。
8. 支持用户手动左右滑动。
9. 用户手动滑动后暂停 1s,再继续自动播放。
10. 点击问题区域跳转 Agent,并把问题带过去,后续用于自动发送。
11. 点击"问AI"按钮只跳转 Agent,不需要携带问题参数。

这个需求看起来是一个 UI 动画需求,但实际涉及到:

text 复制代码
1. 数据 mock
2. ViewModel 状态管理
3. Controller 调用接口并更新状态
4. ArkUI 横向滚动
5. Scroller 滚动控制器
6. 定时器控制自动播放
7. 手动滑动暂停
8. 组件事件回调
9. 路由跳转传参
10. UI 与业务逻辑分层

这篇文档主要记录从需求拆解到最终实现的过程,后续忘记时可以直接看这篇复盘。


二、整体实现思路

这个需求不能直接把所有逻辑写在 UI 组件里。公司项目一般会按照下面的方式拆分:

text 复制代码
Biz
  ↓
模拟后端 / AI 接口,返回 10 条问题

ViewModel
  ↓
保存问题数组和 loading 状态

Controller
  ↓
调用 Biz
校验数据
更新 ViewModel
处理点击跳转 Agent

UI 组件
  ↓
接收问题数组
渲染走马灯卡片
处理滚动动画
把点击事件抛给父组件

也就是:

text 复制代码
AttachmentAiQuestionBiz
  ↓
NearbyPoiSearchController
  ↓
NearbyPoiSearchViewModel
  ↓
AttachmentAiQuestionMarqueeCard

这种拆法的好处是:

text 复制代码
1. UI 组件只负责展示。
2. 数据来源后续可以从 mock 替换成真实接口。
3. 跳转逻辑统一放在 Controller。
4. ViewModel 负责驱动 UI 响应式刷新。
5. 组件后续可以迁移到真实入口,不和临时页面强耦合。

三、Biz 层 mock 假接口

因为后端 / AI 暂时没有提供接口,所以先在 Biz 层写一个 mock 方法返回 10 条问题。

示例:

ts 复制代码
export class AttachmentAiQuestionBiz {
  static async getAiQuestionList(): Promise<string[]> {
    return new Promise<string[]>((resolve) => {
      setTimeout(() => {
        resolve([
          '西九站有母婴室吗',
          '西九龙站有ATM机吗',
          '附近有推荐商场吗',
          '高铁站怎么打车',
          '西九龙有寄存吗',
          '附近有什么吃的',
          '站内可以买电话卡吗',
          '过关需要多久',
          '附近有地铁站吗',
          '去机场怎么走'
        ])
      }, 300)
    })
  }
}

这里使用了:

ts 复制代码
static async getAiQuestionList(): Promise<string[]>

含义如下:

text 复制代码
static:
表示静态方法,不需要 new 一个 Biz 对象,可以直接通过类名调用。

async:
表示这是异步方法。

Promise<string[]>:
表示这个方法最终会返回一个字符串数组。

setTimeout:
模拟接口延迟,方便模拟真实网络请求。

后续真实接口提供后,只需要把这里的 mock 数据替换成真实请求即可,UI 和 Controller 大部分不用改。


四、ViewModel 保存状态

ViewModel 负责保存页面状态,UI 会根据 ViewModel 的变化自动刷新。

这次至少需要两个字段:

ts 复制代码
@Trace aiQuestionList: string[] = []
@Trace aiQuestionLoading: boolean = false

含义:

text 复制代码
aiQuestionList:
保存接口返回的 10 条 AI 问题。

aiQuestionLoading:
表示是否正在加载问题列表。

这里使用 @Trace 的原因是:它可以让字段具备响应式能力。Controller 更新 aiQuestionList 后,UI 会重新渲染。

简单理解:

text 复制代码
Controller 更新 VM
  ↓
VM 状态变化
  ↓
UI 重新 build
  ↓
页面展示新的问题列表

五、Controller 调接口并校验数据

UI 不应该直接调用 Biz。更合理的做法是由 Controller 负责调用接口、校验数据、更新 ViewModel。

示例:

ts 复制代码
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
    })
}

这里做了几件事:

text 复制代码
1. 进入请求前,把 loading 置为 true。
2. 调用 Biz 获取问题数组。
3. then 中拿到返回数据。
4. 调用 formatAiQuestionList 进行校验和过滤。
5. 更新 ViewModel。
6. 请求失败时清空数组,并关闭 loading。

六、为什么要校验接口数据

即使现在是 mock 数据,也要模拟真实接口习惯,不能直接相信接口返回。

Controller 中可以写一个格式化方法:

ts 复制代码
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
}

这里做了几层保护:

text 复制代码
1. 判断是否是数组。
2. 判断每一项是否为空。
3. 去掉字符串前后空格。
4. 限制字符串长度,避免 UI 被撑开。
5. 最多保留 10 条。

这样即使后端返回脏数据,也不会直接影响 UI。


七、点击问题跳转 Agent

需求里有两个点击行为:

text 复制代码
点击问题气泡:
跳转 Agent,并携带 question 参数,后续用于自动发送。

点击"问AI"按钮:
只跳转 Agent,不携带问题参数。

一开始可以写两个方法:

text 复制代码
jumpAgentWithQuestion(question)
jumpAgentPage()

但其实没必要。更简单的方式是让 question 参数变成可选。

ts 复制代码
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
  })
}

这样一个方法就能处理两种情况:

text 复制代码
this.controller.jumpAgentWithQuestion(question)
  ↓
带问题跳转

this.controller.jumpAgentWithQuestion()
  ↓
只跳转 Agent,不带问题

这样代码更简洁,也避免新增重复方法。


八、组件通过 @Param 接收问题数组

走马灯组件只负责展示,所以通过 @Param 接收父组件传入的问题数组:

ts 复制代码
@ComponentV2
export struct AttachmentAiQuestionMarqueeCard {
  @Param questionList: string[] = []
}

父组件使用时:

ts 复制代码
AttachmentAiQuestionMarqueeCard({
  questionList: this.viewModel.aiQuestionList
})

这样组件不用知道数据从哪里来,只负责把数组渲染出来。


九、组件通过 @Event 抛出点击事件

组件内部不应该直接调用 Controller,也不应该直接写路由跳转。

所以用 @Event 把点击事件抛给父组件:

ts 复制代码
@Event onQuestionClick?: Callback<string>
@Event onAskClick?: Callback<void>
@Event onRoundEnd?: Callback<void>

分别表示:

text 复制代码
onQuestionClick:
点击某一个问题时触发,并把 question 传出去。

onAskClick:
点击"问AI"按钮时触发,不传问题。

onRoundEnd:
一轮播放结束时触发,用于通知父组件调整问题顺序。

组件内部点击问题:

ts 复制代码
.onClick(() => {
  this.onQuestionClick?.(question)
})

组件内部点击"问AI":

ts 复制代码
.onClick(() => {
  this.onAskClick?.()
})

父组件使用:

ts 复制代码
AttachmentAiQuestionMarqueeCard({
  questionList: this.viewModel.aiQuestionList,
  onQuestionClick: (question: string) => {
    this.controller.jumpAgentWithQuestion(question)
  },
  onAskClick: () => {
    this.controller.jumpAgentWithQuestion()
  },
  onRoundEnd: () => {
    this.controller.changeAiQuestionOrder()
  }
})

这样组件和业务逻辑就是解耦的。


十、为什么用 Stack 作为卡片外层

这个 UI 不是简单的一行文字,而是有多层结构:

text 复制代码
背景卡片
  ↓
两行滚动问题
  ↓
右下角固定"问AI"按钮
  ↓
左右渐变遮罩

如果用 Column,只能从上到下排。

如果用 Row,只能从左到右排。

但是这里有叠层,比如遮罩和按钮要覆盖在内容上,所以最外层更适合用:

ts 复制代码
Stack() {
  ...
}

Stack 可以让子组件叠在一起,非常适合这种卡片效果。


十一、为什么滚动区域分成两排

需求图里不是一排问题,而是上下两排问题,并且右下角留出空间放"问AI"。

所以 UI 结构调整为:

text 复制代码
Column
  ├── 第一行 Scroll:上排问题
  └── 第二行 Row
        ├── Scroll:下排问题
        └── 问AI按钮

这样"问AI"按钮不需要用绝对定位,也不会跑偏。

之前尝试用:

ts 复制代码
.position({
  x: 'calc(100% - 84vp)',
  y: 34
})

但这种写法在当前 ArkUI 环境里不稳定,按钮容易跑到左边。

所以改成 Row 布局,让按钮天然固定在第二行右侧。


十二、为什么不用 Blank 做遮罩

一开始左右渐变遮罩使用了:

ts 复制代码
Blank()

但 ArkUI 报错:

text 复制代码
The 'Blank' component can only be nested in the 'Row,Column,Flex' parent component.

意思是 Blank 只能放在 RowColumnFlex 里面,不能直接放在 Stack 下面。

所以遮罩要么删掉,要么用空的 Row() / Column() 替代:

ts 复制代码
Row()
  .width(24)
  .height(96)
  .linearGradient(...)

不过后面发现遮罩定位不稳定,容易跑到中间形成蒙层,所以第一阶段先删掉遮罩,保证核心滚动和按钮布局正常。


十三、Scroller 是什么

组件里有:

ts 复制代码
private topScroller: Scroller = new Scroller()
private bottomScroller: Scroller = new Scroller()

这里的 Scroller 是滚动控制器。

它不是 UI 组件,而是用来控制 Scroll 的对象。

绑定方式:

ts 复制代码
Scroll(this.topScroller) {
  ...
}

后续自动播放时,可以通过:

ts 复制代码
this.topScroller.scrollTo({
  xOffset: this.marqueeOffset,
  yOffset: 0,
  animation: false
})

控制 Scroll 横向滚动到指定位置。

为什么要两个 Scroller?

因为卡片是上下两排:

text 复制代码
topScroller:
控制第一行滚动。

bottomScroller:
控制第二行滚动。

自动播放时两个 Scroller 同步滚动,就能实现上下两排一起从右往左移动。


十四、自动从右往左播放

核心思路是使用定时器不断改变横向偏移量:

ts 复制代码
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)
}

这里几个常量很重要:

ts 复制代码
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

含义:

text 复制代码
ITEM_WIDTH:
每个问题气泡的宽度。

ITEM_SPACE:
每个气泡之间的间距。

TICK_INTERVAL:
定时器每隔多久执行一次,这里是 60ms。

PLAY_DURATION_PER_ITEM:
每条问题移动一个 item 宽度的时间,这里是 6000ms,也就是 6 秒 / 条。

速度计算:

ts 复制代码
const speedPerTick: number =
  oneItemDistance / (this.PLAY_DURATION_PER_ITEM / this.TICK_INTERVAL)

也就是:

text 复制代码
一个 item 距离
  ÷
一条问题需要多少次 tick
  =
每次 tick 应该移动多少距离

这样就能控制播放速度。


十五、一轮结束后循环播放

当横向偏移量超过一轮宽度时,说明这一轮播完了:

ts 复制代码
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
}

这里做了三件事:

text 复制代码
1. 把偏移量重置为 0。
2. 把上下两个 Scroll 都滚回开头。
3. 触发 onRoundEnd,让 Controller 调整问题顺序。

Controller 中可以实现:

ts 复制代码
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 秒

需求要求:

text 复制代码
用户手动左右滑动后,暂停 1 秒再继续自动播放。

实现方式是监听触摸事件:

ts 复制代码
.onTouch((event: TouchEvent) => {
  if (event.type === TouchType.Down) {
    this.pauseMarqueeByUser()
  }
})

暂停方法:

ts 复制代码
private pauseMarqueeByUser(): void {
  this.isPause = true
  this.clearResumeTimer()

  this.resumeTimerId = setTimeout(() => {
    this.isPause = false
  }, 1000)
}

含义:

text 复制代码
1. 用户按下时,把 isPause 设为 true。
2. 自动播放定时器发现 isPause 为 true,就不继续滚动。
3. 清理旧的恢复定时器,避免多次触发。
4. 1 秒后把 isPause 改回 false。
5. 自动播放继续。

十七、为什么要清理定时器

组件中有两个定时器:

ts 复制代码
private marqueeTimerId: number = -1
private resumeTimerId: number = -1

分别负责:

text 复制代码
marqueeTimerId:
控制自动跑马灯。

resumeTimerId:
控制手动滑动后 1 秒恢复。

组件销毁时必须清理:

ts 复制代码
aboutToDisappear(): void {
  this.stopMarquee()
  this.clearResumeTimer()
}

否则会出现:

text 复制代码
1. 页面离开后定时器还在跑。
2. 重进页面后创建多个定时器。
3. 滚动越来越快。
4. 页面卡顿。
5. 可能导致内存问题。

所以只要写了 setInterval / setTimeout,就要记得在生命周期里清理。


十八、为什么之前会卡住、左右平移

之前第一版出现了这些问题:

text 复制代码
1. 问AI按钮跑到左边。
2. 滚动区域看起来卡住。
3. 内容一直左右平移。
4. 中间多出一层蒙层。

原因大致是:

text 复制代码
1. 使用 position + calc 布局不稳定。
2. 整体 Scroll 和按钮叠层关系不清楚。
3. 左右遮罩使用 align 定位不稳定,跑到了中间。
4. 自动滚动 reset 时视觉跳动明显。

解决方式是:

text 复制代码
1. 去掉 position。
2. 让问AI按钮作为第二行 Row 的固定右侧元素。
3. 上下两排分别用两个 Scroll。
4. 暂时移除不稳定遮罩。
5. 先保证基础滚动和点击正常。

这也是业务开发中常见的思路:先把核心结构跑通,再慢慢补视觉细节。


十九、组件最终使用方式

父组件中使用:

ts 复制代码
AttachmentAiQuestionMarqueeCard({
  questionList: this.viewModel.aiQuestionList,
  onQuestionClick: (question: string) => {
    this.controller.jumpAgentWithQuestion(question)
  },
  onAskClick: () => {
    this.controller.jumpAgentWithQuestion()
  },
  onRoundEnd: () => {
    this.controller.changeAiQuestionOrder()
  }
})

含义:

text 复制代码
questionList:
从 ViewModel 传入 10 条问题。

onQuestionClick:
点击某个问题,带 question 跳转 Agent。

onAskClick:
点击问AI,只跳转 Agent,不带 question。

onRoundEnd:
一轮播完后,通知 Controller 调整问题顺序。

二十、完整链路

最终链路可以总结为:

text 复制代码
页面进入
  ↓
Controller.loadAiQuestionList()
  ↓
Biz mock 返回 10 条问题
  ↓
Controller 校验数据
  ↓
ViewModel.aiQuestionList 更新
  ↓
AttachmentAiQuestionMarqueeCard 接收 questionList
  ↓
上下两排 Scroll 渲染问题气泡
  ↓
aboutToAppear 启动定时器
  ↓
Scroller.scrollTo 控制从右往左自动滚动
  ↓
用户手动滑动时暂停 1 秒
  ↓
点击问题,父组件调用 Controller 跳 Agent 并带 question
  ↓
点击问AI,父组件调用 Controller 只跳 Agent

二十一、本次需求涉及的基础知识点

这次需求包含很多基础点:

text 复制代码
@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
项目封装的页面跳转方法。

二十二、当前阶段完成情况

目前已经完成:

text 复制代码
1. mock 返回 10 条 AI 问题。
2. ViewModel 保存问题数组。
3. Controller 调用 Biz 并更新 VM。
4. UI 渲染两排问题气泡。
5. 支持横向自动播放。
6. 支持手动左右滑动。
7. 手动滑动后暂停 1 秒。
8. 一轮播放结束后调整顺序。
9. 点击问题跳 Agent 并携带 question。
10. 点击问AI只跳 Agent,不携带 question。

暂未完全细化:

text 复制代码
1. 左右模糊渐变遮罩最终样式。
2. 真实附件功能入口位置。
3. Agent 页面接收 question 后自动发送的真实实现。
4. 播放速度的最终产品确认。
5. 真接口替换 mock。

二十三、总结

这个 AI 问题走马灯卡片表面上是一个 UI 动画,但实现过程中涉及数据层、状态层、控制层和展示层的完整协作。

本次实现中,最重要的思路是:

text 复制代码
Biz 负责数据来源。
Controller 负责业务处理和跳转。
ViewModel 负责保存状态。
UI 组件负责展示、滚动和事件抛出。

跑马灯本身不是靠一个简单的 TextSwiper 完成,而是通过 Scroll + Scroller + setInterval 控制横向偏移量实现。手动滑动暂停则通过 onTouch + setTimeout 控制暂停和恢复。

最终代码虽然还可以继续优化,但目前已经完成了需求的核心闭环:进入页面自动播放、可手动滑动、点击问题跳 Agent、点击问AI只跳转。

后续如果要接入真实业务,只需要把 mock Biz 替换成真实接口,把组件挂到真实附件入口,并在 Agent 页面补充自动发送逻辑即可。


参考链接

  1. ArkTS 语言介绍:
    developer.huawei.com/consumer/cn...

  2. ArkUI 自定义组件:
    developer.huawei.com/consumer/cn...

  3. Scroll 组件:
    developer.huawei.com/consumer/cn...

  4. Scrollable 滚动通用接口:
    developer.huawei.com/consumer/cn...

  5. ArkTS 从 TypeScript 迁移规则:
    developer.huawei.com/consumer/cn...

  6. ArkUI 状态管理概述:
    developer.huawei.com/consumer/cn...

相关推荐
道里2 小时前
花了 5 万刀用 AI 写代码之后,这是我的全部经验
前端·人工智能
Royzst2 小时前
xml知识点
java·服务器·前端
IT_陈寒3 小时前
React useEffect闭包陷阱差点把我整失业了
前端·人工智能·后端
kyriewen3 小时前
推行AI写代码一年后,Code Review变成了新的加班理由
前端·ai编程·cursor
前端环境观察室4 小时前
给 Agent Browser Workflow 加一层可观测性:Trace、Snapshot 和 Review Queue
前端
柒瑞4 小时前
Superpowers结合Claude code浅实战
前端
Nian.Baikal4 小时前
从零搭建离线地图服务:Nginx + Cesium/Leaflet 实战指南
运维·前端·nginx
前端毕业班4 小时前
uniapp web 灵活控制 style scoped
前端·javascript·vue.js
ZTStory5 小时前
mise 一款可以在项目中独立管理语言、环境变量和任务的工具
前端·rust·命令行