鸿蒙业务 UI 实战复盘:AI 问题走马灯卡片与 ArkTS 基础语法

一、为什么要写这篇复盘

这次做的是一个“附件功能里的 AI 问题走马灯卡片”。需求看起来只是一个小 UI:展示一组 AI 预设问题,整体是浅蓝色背景,问题以小气泡形式横向排列,右侧固定一个“问AI”按钮,后续还要支持自动从右往左播放、手动滑动、点击问题跳转到 Agent 并自动发送。

但真正写的时候会发现,这里面不只是 UI,还涉及 ArkTS 组件写法、状态管理、滚动容器、事件回调、类成员、访问修饰符、生命周期、Scroller@Builder@Param@Event 等基础知识。

这篇复盘主要记录两个部分:

  1. 这个 UI 为什么这样设计。
  2. 写这个组件时遇到的 ArkTS / TypeScript 基础语法问题。

二、UI 为什么这样拆

这个需求的原型图里,卡片大概可以拆成三个区域:

外层浅蓝色圆角容器
  ├── 左侧 / 中间:问题气泡滚动区域
  ├── 左右两边:渐变遮罩
  └── 右侧:固定“问AI”按钮

所以组件不应该直接写成一个简单的 Text,而应该用 Stack 作为最外层。

原因是 Stack 可以把多个区域叠在一起:

第一层:浅蓝色背景卡片
第二层:横向滚动的问题气泡
第三层:左右渐变遮罩
第四层:右侧固定“问AI”按钮

如果用 ColumnRow,只能按顺序排版,很难实现“右侧按钮固定在卡片上方”“遮罩盖在滚动内容两边”这种叠层效果。

所以这里选择:

Stack() {
  // 滚动内容
  // 问AI按钮
  // 左右渐变遮罩
}

三、为什么问题区域用 Scroll

需求要求问题可以横向滑动,并且后续要做从右往左播放。这种场景最直接的组件是:

Scroll()

它可以包住一个比父容器更宽的内容区域,让内容在水平方向滚动。

示例结构:

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)

其中:

ScrollDirection.Horizontal
表示横向滚动。

scrollBar(BarState.Off)
表示隐藏滚动条。

edgeEffect(EdgeEffect.None)
表示滑到边缘时不需要弹簧效果。

Row({ space: 8 })
表示每个问题气泡之间留 8 的间距。

四、为什么要写 private questionScroller: Scroller = new Scroller()

这句代码是很多初学者容易疑惑的地方:

private questionScroller: Scroller = new Scroller()

它不是普通 UI,而是一个滚动控制器对象。

可以拆开理解:

private
表示这个成员只在当前组件内部使用。

questionScroller
是变量名。

Scroller
是类型,表示这是一个滚动控制器。

new Scroller()
表示创建一个新的 Scroller 实例。

也就是说,这句代码的意思是:

在当前组件内部创建一个名叫 questionScroller 的滚动控制器,
后续把它绑定给 Scroll 组件,用来控制或记录这个 Scroll 的滚动行为。

对应使用方式:

Scroll(this.questionScroller) {
  // 滚动内容
}

后续如果要做自动跑马灯,就可能需要用这个控制器去控制滚动位置。

为什么官网搜不到这句完整代码?因为官网通常不会按照“业务写法”提供完整句子,它会讲 ScrollScroller、滚动组件通用接口,但不会直接写出你项目里的成员变量名。questionScroller 是我们自己定义的变量名,不是官方 API 名称。


五、new 是什么意思

new 表示创建一个类的实例。

比如:

private questionScroller: Scroller = new Scroller()

可以理解为:

Scroller 是一个类。
new Scroller() 是创建一个 Scroller 对象。
questionScroller 保存这个对象。

类似地,项目里经常会看到:

@Local viewModel: TabHomeViewModel = new TabHomeViewModel(this.getUIContext())
controller: TabHomeController = new TabHomeController(this.viewModel)

意思分别是:

创建一个 ViewModel 对象,用来保存页面状态。
创建一个 Controller 对象,用来处理页面逻辑。

六、struct 是什么

鸿蒙 ArkUI 里经常看到:

@ComponentV2
export struct AttachmentAiQuestionMarqueeCard {
  build() {
    ...
  }
}

这里的 struct 可以理解为一个自定义 UI 组件结构。

它和普通 class 不完全一样。在 ArkUI 声明式 UI 里,自定义组件通常使用 struct 定义,然后通过 build() 描述界面长什么样。

核心结构是:

@ComponentV2
export struct DemoComp {
  build() {
    Column() {
      Text('hello')
    }
  }
}

可以这样理解:

@ComponentV2
告诉 ArkUI:这是一个组件。

struct DemoComp
定义一个组件结构。

build()
描述这个组件具体渲染什么 UI。

七、build() 是什么

build() 是组件的渲染函数。

比如:

build() {
  Column() {
    Text('hello')
  }
}

意思是这个组件最终会渲染一个 Column,里面有一个 Text

需要注意:build() 里应该主要写 UI 声明,不应该写复杂请求逻辑。

不推荐:

build() {
  this.controller.loadData()
  Column() {
    ...
  }
}

原因是 build() 可能会因为状态变化反复执行,如果在里面请求接口,可能导致重复请求、重复刷新,甚至死循环。

更合理的做法是:

aboutToAppear() {
  this.controller.loadData()
}

或者由页面父层统一触发请求。


八、@Builder 是什么

@Builder 可以理解为“把一段 UI 拆成一个局部方法”。

比如问题气泡:

@Builder
QuestionItemBuilder(question: string) {
  Row({ space: 6 }) {
    Text(question)
    Text('>')
  }
}

这样在主 UI 里可以直接调用:

this.QuestionItemBuilder(item)

它的作用是让 build() 不至于太长,提升可读性。

适合拆:

一个重复使用的小 UI
一个局部卡片
一个列表 item
一个空状态
一个错误状态

不适合在 @Builder 里写复杂业务逻辑,比如请求接口、处理数据、跳转判断等。


九、@Param 是什么

@Param 表示父组件传给子组件的数据。

例如:

@ComponentV2
export struct AttachmentAiQuestionMarqueeCard {
  @Param questionList: string[] = []
}

父组件使用时:

AttachmentAiQuestionMarqueeCard({
  questionList: this.viewModel.aiQuestionList
})

意思是:

父组件把 viewModel.aiQuestionList 传给子组件。
子组件通过 this.questionList 使用这组数据。

所以 @Param 很适合用于展示型组件,比如卡片标题、列表数据、是否 loading、按钮文案、当前选中状态。


十、@Event 是什么

@Event 表示父组件传给子组件的事件回调。

比如:

@Event onQuestionClick?: Callback<string>

子组件点击问题时:

.onClick(() => {
  this.onQuestionClick?.(question)
})

父组件使用时:

AttachmentAiQuestionMarqueeCard({
  questionList: this.viewModel.aiQuestionList,
  onQuestionClick: (question: string) => {
    this.controller.jumpAgentWithQuestion(question)
  }
})

完整链路是:

用户点击问题气泡
  ↓
子组件触发 onQuestionClick
  ↓
父组件收到 question
  ↓
父组件调用 controller.jumpAgentWithQuestion(question)
  ↓
跳转到 Agent 页面

这就是“子组件只负责抛事件,父组件决定业务逻辑”。


十一、为什么 UI 组件里不应该写业务函数

之前写 AI 总结卡片时,组件里有过类似方法:

private jumpAgentChatPage(): void
private shouldShowAiSummaryCard(): boolean
private getAiSummaryText(): string

这些方法虽然能跑,但不符合项目分层。

原因是:

jumpAgentChatPage
属于跳转业务逻辑,应该放 Controller / Presenter。

shouldShowAiSummaryCard
属于展示条件判断,可以放 Controller / Presenter 或 ViewModel 计算后传入。

getAiSummaryText
属于文案处理逻辑,不应该让 UI 组件自己拼。

UI 组件应该尽量只做:

接收数据
展示 UI
触发事件

也就是:

ViewModel / Controller / Biz 负责逻辑
Component 负责展示

这样代码会更清晰,也更符合公司项目风格。


十二、private 是什么

private 是访问修饰符,表示这个成员只能在当前类或组件内部访问。

例如:

private questionScroller: Scroller = new Scroller()

表示:

questionScroller 只给当前组件自己用。
外部组件不能直接访问它。

适合写成 private 的内容:

内部滚动控制器
内部辅助方法
内部格式化方法
内部常量

比如:

private formatQuestionList(list: string[]): string[] {
  ...
}

外部不需要知道这个方法怎么实现,所以可以设为 private


十三、public 是什么

public 表示公开成员,外部可以访问。

在 TypeScript / ArkTS 中,不写访问修饰符时,很多情况下默认就是 public

比如 Controller 中的方法:

public loadAiQuestionList(): void {
  ...
}

页面组件可以调用:

this.controller.loadAiQuestionList()

适合写成 public 的内容:

页面生命周期需要调用的方法
UI 点击事件需要调用的方法
外部模块需要访问的业务方法

例如:

public jumpAgentWithQuestion(question: string): void {
  ...
}

因为 UI 点击问题后需要调用这个方法,所以它可以是 public


十四、static 是什么

static 表示静态成员,不需要创建对象就可以调用。

例如 Biz 里写:

export class AttachmentAiQuestionBiz {
  static async getAiQuestionList(): Promise<string[]> {
    ...
  }
}

调用时可以直接写:

AttachmentAiQuestionBiz.getAiQuestionList()

不用写:

const biz = new AttachmentAiQuestionBiz()
biz.getAiQuestionList()

为什么 mock 接口适合写成 static

因为它只是一个工具型方法:

不需要保存对象状态
不依赖 this
只是输入参数,返回数据

所以写成静态方法更简单。


十五、export 是什么

export 表示这个类、组件、函数可以被其他文件导入使用。

比如:

export struct AttachmentAiQuestionMarqueeCard {
  ...
}

其他文件才能这样引入:

import { AttachmentAiQuestionMarqueeCard } from './AttachmentAiQuestionMarqueeCard'

如果没有 export,别的文件就无法正常导入这个组件。


十六、import 是什么

import 表示从其他文件或模块引入内容。

比如:

import { horizontal, match } from 'lushu_acommon'
import { Callback } from '@kit.BasicServicesKit'

意思是:

从 lushu_acommon 引入项目封装的样式工具。
从 @kit.BasicServicesKit 引入 Callback 类型。

项目里常见的工具:

import { match, horizontal, bothway } from 'lushu_acommon'

这样就可以写:

.width(match)
.padding(horizontal(12))
.padding(bothway(16, 8))

比到处写:

.width('100%')
.padding({ left: 12, right: 12 })

更符合项目风格。


十七、Callback<string> 是什么

在事件回调里经常会看到:

@Event onQuestionClick?: Callback<string>

可以理解为:

这是一个函数。
这个函数接收一个 string 参数。
没有特别关心返回值。

在这里就是:

点击问题后,把 question 这个字符串传给父组件。

如果不用 Callback<string>,也可以写成:

@Event onQuestionClick?: (question: string) => void

但项目里如果已经统一使用 Callback,就跟项目风格保持一致。


十八、? 是什么

代码里经常有:

@Event onQuestionClick?: Callback<string>

这里的 ? 表示这个属性是可选的。

也就是说,父组件可以传:

onQuestionClick: ...

也可以不传。

所以子组件调用时要写:

this.onQuestionClick?.(question)

这里的 ?. 是可选调用,意思是:

如果 onQuestionClick 存在,就调用。
如果不存在,就什么都不做。

这样可以避免空指针报错。


十九、string[] 是什么

string[] 表示字符串数组。

例如:

@Param questionList: string[] = []

意思是:

questionList 是一个数组。
数组里的每一项都是 string。

所以可以这样遍历:

ForEach(this.questionList, (item: string) => {
  Text(item)
}, (item: string) => item)

项目中不要随便用 anyunknown,因为 ArkTS 有规则限制:

Use explicit types instead of "any", "unknown"

所以应该尽量写明确类型:

list: string[]
item: string
err: Error

二十、为什么用 ForEach 渲染列表

问题数组是动态数据,不能写死 10 个 Text

应该用:

ForEach(this.questionList, (item: string) => {
  this.QuestionItemBuilder(item)
}, (item: string) => item)

意思是:

遍历 questionList。
每一项都渲染一个问题气泡。
用 item 本身作为 key。

这样后续接口返回不同问题时,UI 会根据数组自动刷新。


二十一、为什么暂时不急着做自动跑马灯

需求最终是自动从右往左播放,并且支持手动滑动后暂停 1 秒再继续。

这个完整逻辑会涉及:

滚动控制器
定时器
当前滚动位置
手动滑动事件
暂停和恢复
页面销毁时清理定时器
一轮结束后循环播放

如果基础 UI 还没跑通,就直接写自动跑马灯,很容易出现问题。

所以开发顺序应该是:

第一步:mock 数据返回 10 条
第二步:ViewModel 保存数组
第三步:Controller 调接口更新 VM
第四步:UI 渲染问题气泡
第五步:支持手动横向滑动
第六步:点击问题跳 Agent
第七步:再加自动跑马灯
第八步:再加手动滑动暂停 1 秒

这就是业务开发里的“先跑通最小闭环,再逐步补交互”。


二十二、当前组件的核心代码理解

当前组件大概是这样:

@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)
    })
  }
}

可以拆成一句话:

父组件传入 questionList。
子组件用 ForEach 渲染问题气泡。
Scroll 负责横向滑动。
点击问题时,通过 onQuestionClick 把 question 抛给父组件。
点击问AI按钮时,通过 onAskClick 通知父组件。

二十三、这次真正学到的东西

这次需求不是单纯画一个卡片,而是在练业务开发的完整思路:

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 问题走马灯卡片从视觉上看只是一个浅蓝色卡片,但代码实现上涉及布局、滚动、事件、状态、分层和基础语法。

当前阶段最重要的不是一步到位完成所有动画,而是先把结构搭对:

Biz 返回 mock 问题
Controller 调用并校验
ViewModel 保存数组
UI 接收数组并渲染
点击问题通过事件回调交给父组件处理

等这个基础链路稳定后,再继续加自动跑马灯、手动滑动暂停、渐变遮罩、点击自动发送等复杂交互。

写鸿蒙业务代码时,最重要的是不要把所有逻辑塞进 UI 组件。组件负责展示,Controller 负责业务,ViewModel 负责状态,Biz 负责数据来源。这样代码后续才容易维护,也更符合公司项目的分层风格。


参考链接

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

  2. 自定义组件创建与 @ComponentV2@Param@Event
    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. 滚动组件通用接口:
    https://developer.huawei.com/consumer/cn/doc/harmonyos-references/ts-container-scrollable-common

  5. 自定义组件成员属性访问限定符使用限制:
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-custom-components-access-restrictions

  6. 从 TypeScript 到 ArkTS 的适配规则:
    https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/typescript-to-arkts-migration-guide

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐