鸿蒙业务需求实战: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 只能放在 RowColumnFlex 里面,不能直接放在 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 组件负责展示、滚动和事件抛出。

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

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

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


参考链接

  1. ArkTS 语言介绍:
    https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/introduction-to-arkts

  2. ArkUI 自定义组件:
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-create-custom-components

  3. Scroll 组件:
    https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-container-scroll

  4. Scrollable 滚动通用接口:
    https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-container-scrollable-common

  5. ArkTS 从 TypeScript 迁移规则:
    https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/typescript-to-arkts-migration-guide

  6. ArkUI 状态管理概述:
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-state-management-overview

Logo

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

更多推荐