要点
写鸿蒙应用之前先记住一句话:ArkUI 是声明式的,build() 里写的不是”创建一个按钮”的命令,而是”界面应该长什么样”的描述。状态(被装饰器标记的数据)变了,框架自己算出哪块 UI 该刷新。这篇从 Empty Ability 模板出发,先跑一个计数器,再升级成一个能增删的待办列表,最后把它和你熟悉的 React / Vue / Flutter 放在一起对照,说清心智差异。
入口链路:从模板到页面
环境搭建那篇(见系列首篇《鸿蒙开发环境搭建》)里创建的 Empty Ability 工程,运行起来显示 Hello World,中间经过了三层:
| 层 | 文件 | 职责 |
|---|---|---|
| UIAbility | entryability/EntryAbility.ets | 应用级生命周期入口;在 onWindowStageCreate 里加载页面 |
| 页面 | pages/Index.ets | @Entry 标记的根组件,一个页面一个文件 |
| 组件 | 任意 struct | @Component 标记的复用单元,页面就是组件树的根 |
EntryAbility 里一行 loadContent('pages/Index') 把首页挂到窗口上。入门阶段这层基本不用动,所有界面代码都从 Index.ets 开始。页面路径需要在 resources/base/profile/main_pages.json 里注册,新增页面忘了注册会直接报错——这是新手高频坑之一。
第一个界面:计数器
把 Index.ets 整个替换成:
@Entry
@Component
struct Index {
// @State:组件内部状态,变化会自动触发相关 UI 刷新
@State count: number = 0
build() {
// build() 是"描述",不是"命令":框架根据当前状态渲染这棵树
Column({ space: 16 }) {
Text(`点了 ${this.count} 次`)
.fontSize(28)
Button('加一')
.onClick(() => {
// 直接改状态,不需要任何"通知界面"的 API
this.count += 1
})
Button('清零')
.onClick(() => {
this.count = 0
})
}
.padding(24)
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
预期现象:预览器里点”加一”,数字立刻变;点”清零”归零。注意代码里没有 setText、没有手动刷新——this.count += 1 之后界面自己变了,这就是声明式的全部要点。属性用链式调用(.fontSize().padding())挂在组件后面,ArkUI 没有 CSS,样式、布局、事件都是组件属性。
声明式范式:三个装饰器打底
ArkTS 在 TypeScript 基础上扩展出的 UI 语法核心就三样:
| 装饰器 | 作用 | 类比 |
|---|---|---|
| @Component | 把 struct 声明为 UI 组件 | React 的函数组件本身 |
| @Entry | 标记页面根组件(每个页面一个) | 路由入口组件 |
| @Builder | 抽出可复用的 UI 片段(无独立状态) | 最接近”函数式子组件”的东西 |
@Builder 的用法:
@Entry
@Component
struct Index {
@State count: number = 0
// 复用 UI 片段:参数是值传递,适合纯展示
@Builder label(text: string) {
Text(text).fontSize(14).fontColor('#666')
}
build() {
Column() {
this.label('计数器 Demo')
Text(`${this.count}`).fontSize(32)
}
}
}
升级:一个待办应用
// 数据模型:字段显式声明类型,符合 ArkTS 严格约束
class Todo {
id: number
title: string
constructor(id: number, title: string) {
this.id = id
this.title = title
}
}
@Entry
@Component
struct Index {
@State input: string = ''
@State todos: Array<Todo> = []
private nextId: number = 1 // 私有且非状态:只用于生成 id,不驱动 UI
build() {
Column({ space: 12 }) {
Row({ space: 8 }) {
TextInput({ placeholder: '输入待办', text: this.input })
.layoutWeight(1) // 占满剩余宽度,类似 flex: 1
.onChange((v: string) => { this.input = v })
Button('添加')
.onClick(() => {
const title: string = this.input.trim()
if (title.length === 0) {
return
}
// @State 观测数组第一层的增删,push 后列表自动刷新
this.todos.push(new Todo(this.nextId, title))
this.nextId += 1
this.input = ''
})
}
.width('100%')
List({ space: 8 }) {
ForEach(this.todos, (item: Todo) => {
ListItem() {
Row({ space: 8 }) {
Text(item.title).layoutWeight(1)
Button('删')
.onClick(() => {
// 按条件过滤生成新数组,再整体赋值
this.todos = this.todos.filter((t: Todo) => t.id !== item.id)
})
}
}
}, (item: Todo) => item.id.toString()) // key 生成器:靠 id 而不是数组下标
}
.layoutWeight(1)
if (this.todos.length === 0) {
Text('暂无待办').fontColor('#999')
}
}
.padding(20)
.width('100%')
.height('100%')
}
}
三个值得停下来的点。第一,filter 返回新数组后整体赋值是最稳的删除方式,id 过滤天然正确。第二,ForEach 的第三个参数是 key 生成器,用业务 id(item.id)而不是下标——用下标时”删除中间一项”会导致 key 错位、界面和内存数据对不上。第三,if (this.todos.length === 0) 这种条件渲染直接写在 build() 里,状态一变框架重新求值,不需要 v-if 那样的指令。
与 React / Vue / Flutter 的心智对照
| 维度 | ArkUI | React | Vue 3 | Flutter |
|---|---|---|---|---|
| 声明状态 | @State 计数器 | useState | ref / reactive | State 字段 |
| 修改状态 | 直接赋值,可变 | setXxx 或不可变更新 | 直接改 .value | 必须包 setState |
| 视图更新粒度 | 框架按状态依赖做细粒度更新 | 整个函数组件重跑 + diff | 组件级重跑 + diff | 整棵 build 重跑 + diff |
| UI 复用 | @Builder / @Component | 函数组件 | 组件 | Widget 组合 |
最值得注意的差异是”可变状态”:React 训练出来的肌肉记忆是”状态不可变、每次造新对象”(为什么非得这样,见《React Hooks 心智模型》与《React 渲染与重渲染》),而 ArkUI 反过来,鼓励直接改 this.count、直接 push 数组——观测机制在装饰器层完成。Flutter 开发者可以对照《第一个 Flutter 应用》:setState 标记这步在 ArkUI 里不存在,但换来的是观测规则本身要学(下一节的坑就是观测边界的坑)。
常见问题
Q1: 改了嵌套对象的属性,界面不动
V1 状态装饰器(@State 等)只观测第一层:数组项的增删、对象整体替换能看到,但 this.todos[0].title = 'x' 这种深层修改不会触发刷新。三种解法:整对象替换、用 V2 的 @ObservedV2/@Trace(属性级深度观测)、或把嵌套拆平。详见下一篇语法要点的状态管理一节。
Q2: ForEach 列表增加或删除后显示错乱
九成是 key 生成器的问题:没写(默认用 index 且有告警)、或 key 不唯一。用稳定业务 id 做 key,删除场景立刻恢复正常。
Q3: 代码明明没错,预览器却白屏
预览器只渲染 UI,不支持部分系统 API(http 请求是典型)。换模拟器或真机再判断代码对错,别在预览器上白耗时间。
最佳实践与反模式
反模式一:找”拿到 Text 控件实例再 setText”的 API。ArkUI 里不存在控件句柄,一切界面变化都走”改状态 → 框架刷新”,Web 的 DOM 操作思维在这里没有落点。
反模式二:把所有状态都堆在 @Entry 页面顶层。状态先放离使用它最近的组件;跨组件共享再上 @Provide/@Consume 或 V2 方案,全局状态膨胀后谁都不敢动。
实践建议:每写一个界面,能口头回答三个问题——状态有哪些、哪个交互改哪个状态、哪块 UI 依赖哪个状态。答得上来,声明式就算入门了。
练习
- 给待办加”完成”功能:点标题划线。先试直接改
item.done观察(不刷新的)现象,再用整对象替换修好它 - 把空态提示改成”共 N 项”,体会 Text 如何依赖状态自动更新
- 给每个列表项加创建时间并用 @Builder 渲染成副标题
下一步
这篇跑通了”状态驱动界面”,但刻意绕开了语言层:为什么必须有类型标注、哪些 TypeScript 写法在这里编译不过、@State 背后的 V1/V2 两套状态管理体系是怎么回事,都在下一篇《ArkTS 语法要点:TypeScript 的严格方言》展开;查表需求直接跳系列末篇《ArkTS / ArkUI 基础知识参考字典》。
版本核实(撰写时检索,2024-12):本文语法(@Entry/@Component/@Builder、@State、ForEach、List/TextInput/Button)基于 HarmonyOS NEXT 5.0(API 12)的 ArkTS/ArkUI 声明式范式,在当前 Release SDK 下可用;状态管理 V2(@ObservedV2/@Trace 等)自 API 12 起引入。组件参数与 API 细节以 developer.huawei.com 官方文档为准。