鸿蒙从零到一 ArkUI 声明式 UI 基础实战
前言
做 HarmonyOS 应用时,页面开发最先接触到的通常不是复杂的系统能力,而是 ArkUI。ArkUI 的核心特点是声明式:开发者描述页面在某个状态下应该长什么样,框架负责把状态变化同步到界面上。
这和传统命令式 UI 的思路不同。命令式写法更像是一步步操作控件:创建按钮、设置文字、监听点击、手动改颜色。声明式写法更关注结果:当前状态是什么,界面就按照这个状态渲染。掌握这个思路后,后续学习组件化、状态管理、列表、动画都会顺很多。
本文从一个可运行的页面出发,讲清 ArkUI 的基本结构、组件声明方式、布局链式调用、事件绑定和状态驱动更新。
ArkUI 页面最小结构
在 Stage 模型工程中,页面通常放在 entry/src/main/ets/pages 目录下。一个最简单的 ArkUI 页面大致如下:
ts
@Entry
@Component
struct Index {
build() {
Column() {
Text('Hello HarmonyOS')
.fontSize(24)
.fontWeight(FontWeight.Bold)
Button('开始')
.margin({ top: 16 })
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
}
这里有几个关键点:
@Entry表示这是当前页面入口组件,页面文件通常只需要一个入口组件。@Component表示struct Index是一个 ArkUI 自定义组件。build()是组件的 UI 描述函数,页面结构写在这里。Column()、Text()、Button()都是声明式组件调用。.fontSize()、.margin()、.width()这类链式方法用于设置属性和样式。
ArkUI 代码看起来像是在创建对象,但更准确地说,它是在描述一棵 UI 树。状态变化后,框架会根据新的描述刷新需要变化的部分。
组件、容器和内容的关系
ArkUI 里的组件可以粗略分成两类:基础组件和容器组件。
基础组件负责展示或交互,比如:
Text:展示文本。Image:展示图片。Button:触发点击行为。TextInput:输入文本。
容器组件负责组织子组件,比如:
Column:纵向排列。Row:横向排列。Stack:层叠排列。Flex:弹性布局。
例如一个常见的信息卡片可以这样写:
ts
@Component
struct ProfileCard {
build() {
Row() {
Image($r('app.media.profile_avatar'))
.width(56)
.height(56)
.borderRadius(28)
Column() {
Text('HarmonyOS Developer')
.fontSize(18)
.fontWeight(FontWeight.Medium)
Text('ArkTS / ArkUI / Stage Model')
.fontSize(13)
.fontColor('#666666')
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
}
.padding(16)
.width('100%')
.borderRadius(12)
.backgroundColor('#F5F7FA')
}
}
这段代码体现了声明式 UI 的基本组织方式:用容器表达结构,用基础组件表达内容,再通过链式属性补齐视觉细节。
build 函数里应该写什么
build() 的职责是描述 UI,不适合塞大量业务逻辑。实际项目中建议遵守三个原则:
- 页面结构可以写在
build()中,但复杂区域应该拆成子组件或@Builder方法。 - 数据处理、网络请求、持久化等逻辑不要直接堆在
build()中。 - 样式计算可以保持简单,复杂判断应提前整理成清晰的状态字段。
例如下面这种写法能跑,但随着页面变复杂会很难维护:
ts
build() {
Column() {
if (this.score > 90) {
Text('优秀')
.fontColor('#0A7F38')
} else if (this.score > 60) {
Text('通过')
.fontColor('#B7791F')
} else {
Text('待提升')
.fontColor('#C53030')
}
}
}
更适合长期维护的方式是把展示文案和颜色封装成计算结果:
ts
@Component
struct ScoreBadge {
@Prop score: number
private getLabel(): string {
if (this.score > 90) {
return '优秀'
}
if (this.score > 60) {
return '通过'
}
return '待提升'
}
private getColor(): string {
if (this.score > 90) {
return '#0A7F38'
}
if (this.score > 60) {
return '#B7791F'
}
return '#C53030'
}
build() {
Text(this.getLabel())
.fontSize(14)
.fontColor(this.getColor())
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(10)
.backgroundColor('#FFFFFF')
}
}
页面声明会变得更干净,测试和复用也更容易。
属性链式调用的顺序
ArkUI 组件常通过链式调用设置属性。大多数情况下,属性顺序不影响最终结果,但为了可读性,团队里最好形成固定顺序。
建议按下面的顺序组织:
- 尺寸:
width、height、constraintSize。 - 间距:
padding、margin。 - 布局:
layoutWeight、alignSelf、justifyContent、alignItems。 - 文本:
fontSize、fontWeight、fontColor、maxLines。 - 外观:
backgroundColor、borderRadius、border、shadow。 - 交互:
enabled、onClick、onChange。
示例:
ts
Button('提交')
.width('100%')
.height(44)
.margin({ top: 24 })
.fontSize(16)
.fontWeight(FontWeight.Medium)
.backgroundColor('#0A59F7')
.borderRadius(8)
.onClick(() => {
this.submit()
})
统一顺序不是语法要求,而是工程要求。代码评审时,稳定的样式顺序能明显降低阅读成本。
用状态驱动界面变化
声明式 UI 的价值在状态变化时最明显。下面做一个计数器页面:
ts
@Entry
@Component
struct CounterPage {
@State count: number = 0
build() {
Column() {
Text(`当前计数:${this.count}`)
.fontSize(28)
.fontWeight(FontWeight.Bold)
Row() {
Button('减少')
.onClick(() => {
if (this.count > 0) {
this.count--
}
})
Button('增加')
.margin({ left: 12 })
.onClick(() => {
this.count++
})
}
.margin({ top: 20 })
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
}
@State 修饰的字段属于组件内部状态。当 this.count 改变后,使用到它的 UI 会自动刷新。开发者不需要手动查找某个文本控件,也不需要主动调用刷新方法。
这个模式是 ArkUI 开发的核心:
- 先定义状态。
- 用状态描述界面。
- 在事件中修改状态。
- 让框架完成界面更新。
事件处理要保持短小
ArkUI 通过 .onClick()、.onChange() 等方法绑定事件。事件函数可以直接写箭头函数,但不要把复杂流程都写进去。
可以这样写:
ts
Button('保存')
.onClick(() => {
this.saveProfile()
})
然后把具体流程放到方法里:
ts
private saveProfile(): void {
const name = this.name.trim()
if (name.length === 0) {
this.message = '请输入昵称'
return
}
this.message = '保存成功'
}
这样做有两个好处:一是 UI 声明更清晰,二是后续加入埋点、表单校验、异步保存时不会把页面结构搅乱。
一个完整的小练习:登录表单
下面用 ArkUI 写一个简单登录表单,包含输入、按钮、状态提示和基础校验。
ts
@Entry
@Component
struct LoginPage {
@State account: string = ''
@State password: string = ''
@State message: string = ''
@State loading: boolean = false
private canSubmit(): boolean {
return this.account.trim().length > 0 && this.password.length >= 6 && !this.loading
}
private submit(): void {
if (!this.canSubmit()) {
this.message = '请填写账号,并输入至少 6 位密码'
return
}
this.loading = true
this.message = '正在登录...'
setTimeout(() => {
this.loading = false
this.message = '登录成功'
}, 800)
}
build() {
Column() {
Text('账号登录')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.width('100%')
TextInput({ placeholder: '请输入账号', text: this.account })
.width('100%')
.height(44)
.margin({ top: 24 })
.onChange((value: string) => {
this.account = value
})
TextInput({ placeholder: '请输入密码', text: this.password })
.type(InputType.Password)
.width('100%')
.height(44)
.margin({ top: 12 })
.onChange((value: string) => {
this.password = value
})
Button(this.loading ? '提交中...' : '登录')
.width('100%')
.height(44)
.margin({ top: 20 })
.enabled(this.canSubmit())
.opacity(this.canSubmit() ? 1 : 0.5)
.onClick(() => {
this.submit()
})
if (this.message.length > 0) {
Text(this.message)
.fontSize(13)
.fontColor('#666666')
.margin({ top: 12 })
.width('100%')
}
}
.width('100%')
.height('100%')
.padding(24)
.justifyContent(FlexAlign.Center)
.backgroundColor('#FFFFFF')
}
}
这段示例虽然简单,但已经覆盖了 ArkUI 页面开发的主要动作:
- 用
@State保存页面状态。 - 用输入组件修改状态。
- 用按钮事件触发业务方法。
- 用条件渲染展示提示。
- 用状态控制按钮是否可用。
实际项目中可以继续把表单项、按钮、提示条拆成独立组件,让页面只负责组合和流程控制。
条件渲染和页面分支
ArkUI 支持在 build() 中使用 if 做条件渲染。它适合处理加载态、空态、错误态、权限态等页面分支。
ts
build() {
Column() {
if (this.loading) {
LoadingProgress()
.width(36)
.height(36)
} else if (this.errorMessage.length > 0) {
Text(this.errorMessage)
.fontColor('#C53030')
} else {
Text('内容加载完成')
.fontColor('#222222')
}
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
写条件渲染时要注意:不要让同一个状态在多个分支里重复判断太多次。页面状态最好先抽象成明确的 UI 状态,例如 loading、errorMessage、data,否则后续维护时容易出现分支冲突。
自定义组件的拆分边界
新手常见的问题是所有内容都写在一个页面里。页面小的时候没问题,但当代码超过几百行,build() 会迅速变成难以维护的长函数。
拆组件时可以参考这些边界:
- 重复出现的 UI:例如用户头像、商品卡片、设置项。
- 有独立状态的区域:例如搜索栏、筛选面板、输入表单。
- 有明确业务含义的模块:例如订单摘要、设备状态、登录面板。
- 页面结构太长时,优先拆视觉上连续的一块区域。
例如把登录按钮拆成组件:
ts
@Component
struct PrimaryButton {
@Prop text: string
@Prop disabled: boolean
onTap: () => void = () => {}
build() {
Button(this.text)
.width('100%')
.height(44)
.enabled(!this.disabled)
.opacity(this.disabled ? 0.5 : 1)
.backgroundColor('#0A59F7')
.borderRadius(8)
.onClick(() => {
this.onTap()
})
}
}
页面里就可以这样使用:
ts
PrimaryButton({
text: this.loading ? '提交中...' : '登录',
disabled: !this.canSubmit(),
onTap: () => {
this.submit()
}
})
拆分的目标不是追求组件数量,而是让每个文件的职责更清楚。
常见坑位
在 build 中修改状态
不要在 build() 里直接修改 @State。build() 会随着状态变化重新执行,如果在里面继续改状态,容易造成重复刷新甚至循环刷新。
不推荐:
ts
build() {
Column() {
Text('示例')
}
this.count++
}
状态修改应该发生在事件、生命周期方法或业务回调中。
把样式和业务判断混在一起
如果一个组件的属性链里到处都是复杂三元表达式,说明状态没有整理好。
不推荐:
ts
Text(this.userInfo && this.userInfo.name ? this.userInfo.name : '未登录')
.fontColor(this.userInfo && this.userInfo.vipLevel > 0 ? '#B7791F' : '#666666')
更清晰的方式是提前把展示字段整理好:
ts
private getDisplayName(): string {
return this.userInfo?.name ?? '未登录'
}
private getNameColor(): string {
return this.userInfo?.vipLevel > 0 ? '#B7791F' : '#666666'
}
然后在 UI 中直接使用:
ts
Text(this.getDisplayName())
.fontColor(this.getNameColor())
过早抽象通用组件
刚开始写 ArkUI 时,不建议一上来就封装非常通用的万能组件。通用组件需要稳定的调用场景,否则参数会越加越多,最后比直接写页面更难理解。
更实用的做法是:先写清楚页面,看到重复,再抽组件;抽出来后保持接口简单。
工程中的推荐写法
在真实项目里,可以把 ArkUI 页面按下面方式组织:
text
entry/src/main/ets/
├── pages/
│ └── LoginPage.ets
├── components/
│ ├── PrimaryButton.ets
│ ├── FormField.ets
│ └── StateTips.ets
├── models/
│ └── UserProfile.ets
└── services/
└── AuthService.ets
页面负责组合组件和处理页面级状态;组件负责展示和局部交互;服务层负责业务流程和外部能力调用。这样划分后,ArkUI 的声明式页面不会和业务逻辑互相缠绕。
小结
ArkUI 声明式 UI 的学习重点不在记住每一个组件属性,而在建立正确的页面组织方式:用状态描述界面,用事件改变状态,用组件拆分复杂结构。
写鸿蒙页面时,可以先抓住这条主线:
- 页面是一棵由组件组成的 UI 树。
build()只负责描述当前状态下的界面。@State变化会驱动界面刷新。- 事件处理要短小,业务逻辑要有边界。
- 组件拆分要服务于复用和可读性,不要为了抽象而抽象。
把这些基础打稳,再去学习列表、动画、跨页面状态和复杂架构时,就不会只是在堆组件,而是在构建可维护的鸿蒙应用界面。