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

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

一、为什么要写这篇复盘

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

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

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

  1. 这个 UI 为什么这样设计。
  2. 写这个组件时遇到的 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 负责数据来源。这样代码后续才容易维护,也更符合公司项目的分层风格。


参考链接

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

  2. 自定义组件创建与 @ComponentV2、@Param、@Event:
    developer.huawei.com/consumer/cn...

  3. Scroll 组件官方文档:
    developer.huawei.com/consumer/cn...

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

  5. 自定义组件成员属性访问限定符使用限制:
    developer.huawei.com/consumer/cn...

  6. 从 TypeScript 到 ArkTS 的适配规则:
    developer.huawei.com/consumer/cn...

相关推荐
卸任1 小时前
Vibe coding工具使用心得
前端·ai编程
brien92 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance2 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M2 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写2 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李2 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易3 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
风骏时光牛马3 小时前
前沿视界
前端
kyriewen3 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员
陈随易3 小时前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员