鸿蒙 ArkUI 入门:从一脸懵到能跑通 demo,大概只要一杯咖啡的时间
鸿蒙 ArkUI 入门:从一脸懵到能跑通 demo,大概只要一杯咖啡的时间
写在前面
如果你最近被「鸿蒙开发」这四个字反复砸中脑门,大概率是因为老板在群里转发了某个「2026 年鸿蒙岗位暴涨 200%」的推文,然后丢下一句「这周搞个 demo 出来看看」。
别慌。本文的目标不是让你三天成为鸿蒙大师,而是让你在一杯咖啡的时间内,搞懂 ArkUI 到底是个啥、能干啥、怎么跑通第一个能点能用的页面。代码我全写好了,托管在 AtomGit 上,clone 下来就能跑。
适合人群:有前端/移动端基础、第一次接触 HarmonyOS 的同学。
不适合人群:想看「鸿蒙底层 binder 实现」的硬核玩家——出门左转 OpenHarmony 源码。
一、ArkUI 是个啥?一句话讲清楚
ArkUI 是鸿蒙的声明式 UI 框架。
如果这句话你还是觉得抽象,那就换个说法:它之于鸿蒙,相当于 SwiftUI 之于 iOS、Jetpack Compose 之于 Android、React 之于 Web。
声明式 UI 的核心思想就一句:
你告诉框架「我要什么样的界面」,而不是「一步步教框架怎么画出界面」。
举个最直接的对比。
命令式(传统 Android):
val textView = TextView(context)
textView.text = "Hello"
textView.setTextColor(Color.RED)
container.addView(textView)
你像个保姆一样,一步步告诉系统:创建一个 TextView、把文字设成 Hello、颜色设成红色、塞到容器里。
声明式(ArkUI):
Text('Hello')
.fontColor(Color.Red)
你只描述「最终长什么样」,框架自己去算怎么创建、怎么更新。
听起来好像没省多少代码?那你再想想:当数据变了,命令式你得手动找到那个 TextView 再 setText;声明式呢?数据一变,界面自动重绘。这就叫「数据驱动 UI」,一旦用上就回不去了。
二、三个核心概念,搞懂就能写页面
ArkUI 的世界很小,记住这三件套就够入门了:
1. 组件(Component)—— UI 的乐高积木
ArkUI 内置了一堆组件:Text、Button、Image、Column、Row、List、TextInput……每个组件就是一块积木。
Text('我是一个文本')
Button('我是一个按钮')
Column({ space: 8 }) { /* 子组件们排成一列 */ }
2. 状态(State)—— 界面的「源头活水」
这是声明式 UI的灵魂。数据变了,界面自动更新。但 ArkUI 对「什么数据能驱动 UI」有严格规定,必须用装饰器标记:
| 装饰器 | 作用域 | 一句话理解 |
|---|---|---|
@State |
组件内部 | 「我自己的状态,变了我就重绘」 |
@Prop |
父→子(单向) | 「爸爸传给我的值,我只读」 |
@Link |
父↔子(双向) | 「我和爸爸共用一份状态,谁改都同步」 |
@Provide/@Consume |
跨层级 | 「祖辈存一份,后辈都能取」 |
新手最常踩的坑:改了变量,界面没更新?99% 是你忘了加 @State。
3. 事件(Event)—— 让界面「活」起来
按钮被点了、输入框文字变了、列表滑到底了……这些都是事件。
Button('+1')
.onClick(() => {
this.count++ // @State count 变化 → 界面自动重绘
})
搞懂这三件套,你已经能写出 80% 的页面了。下面动手。
三、动手:一个计数器 + 待办列表 demo
我不写「Hello World」那种看完就忘的例子。我们来做一个稍微有点用处的小 demo:
- 顶部一个计数器卡片,可以 +1 / -1 / 重置
- 中间一个输入框 + 添加按钮
- 下面一个待办列表,可以勾选完成、删除
- 底部显示统计(共几条、剩几条)
下面是本 demo 在真机上跑起来的实际效果(OpenHarmony 6.1.1.125,API 24):

图里能看到:顶部计数器卡片(点击次数随 +1/-1/重置 变化)、中间输入框 + 添加按钮、下面三条待办(第一条已勾选、带删除线),底部还有「共 3 条 / 剩 2 条」的实时统计。如果你跟着文章走一遍,得到的应该和这张图一致。
3.1 完整代码
代码结构很标准,一个 UIAbility 入口 + 一个主页面:
入口文件 EntryAbility.ets:
import { UIAbility, AbilityConstant, Want } from '@kit.AbilityKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { window } from '@kit.ArkUI';
const DOMAIN = 'ArkUIDemo';
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
hilog.info(DOMAIN, 'EntryAbility', 'onCreate: 应用进程创建');
}
onWindowStageCreate(windowStage: window.WindowStage): void {
hilog.info(DOMAIN, 'EntryAbility', 'onWindowStageCreate: 加载 Index 页面');
windowStage.loadContent('pages/Index', (err) => {
if (err.code) {
hilog.error(DOMAIN, 'EntryAbility', 'loadContent 失败: %{public}s', err.message);
return;
}
hilog.info(DOMAIN, 'EntryAbility', 'loadContent 成功');
});
}
}
主页面 Index.ets(核心代码):
// 数据模型
interface TodoItem {
id: number;
text: string;
done: boolean;
}
// 子组件:计数器卡片
// 演示 @State 局部状态 + 按钮事件
@Component
export struct CounterCard {
@State count: number = 0
@Prop label: string = '点击次数'
build() {
Column({ space: 12 }) {
Text(this.label).fontSize(14).fontColor('#888')
Text(`${this.count}`).fontSize(36).fontWeight(FontWeight.Bold).fontColor('#007DFF')
Row({ space: 8 }) {
Button('- 1').backgroundColor('#F5F5F5').fontColor('#333')
.onClick(() => { if (this.count > 0) this.count-- })
Button('+ 1').backgroundColor('#007DFF').fontColor('#fff')
.onClick(() => this.count++)
Button('重置').backgroundColor('#FF4D4F').fontColor('#fff')
.onClick(() => this.count = 0)
}
}
.padding(20).backgroundColor('#fff').borderRadius(12).width('100%')
}
}
// 子组件:单条待办
// 演示父子通信:@Link 双向绑定 + 删除回调
@Component
export struct TodoRow {
@Link item: TodoItem
onRemove: (id: number) => void = () => {}
build() {
Row({ space: 12 }) {
Checkbox()
.select(this.item.done)
.onChange((v: boolean) => this.item.done = v)
Text(this.item.text)
.fontSize(16)
.decoration({ type: this.item.done ? TextDecorationType.LineThrough : TextDecorationType.None })
.fontColor(this.item.done ? '#bbb' : '#333')
.layoutWeight(1)
Button('✕').backgroundColor('#fff').fontColor('#FF4D4F')
.onClick(() => this.onRemove(this.item.id))
}
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.backgroundColor('#fff').borderRadius(8).width('100%')
}
}
// 主页面
// 演示 @State + ForEach 列表渲染 + TextInput 输入
@Entry
@Component
struct Index {
@State todos: TodoItem[] = [
{ id: 1, text: '学习 ArkUI 声明式语法', done: true },
{ id: 2, text: '理解 @State / @Link / @Prop', done: false },
{ id: 3, text: '跑通第一个 demo', done: false },
]
@State inputText: string = ''
private nextId: number = 4
private addTodo(): void {
const text = this.inputText.trim()
if (text.length === 0) return
this.todos.push({ id: this.nextId++, text, done: false })
this.inputText = ''
}
private removeTodo(id: number): void {
this.todos = this.todos.filter(t => t.id !== id)
}
private get remaining(): number {
return this.todos.filter(t => !t.done).length
}
build() {
Column({ space: 16 }) {
Text('ArkUI 入门 Demo').fontSize(22).fontWeight(FontWeight.Bold).margin({ top: 16 })
CounterCard({ label: '今日点击次数' })
Row({ space: 8 }) {
TextInput({ placeholder: '添加一条待办…', text: this.inputText })
.layoutWeight(1)
.onChange((v: string) => this.inputText = v)
Button('添加').backgroundColor('#007DFF').fontColor('#fff')
.onClick(() => this.addTodo())
}.width('100%')
Row({ space: 8 }) {
Text(`共 ${this.todos.length} 条`)
Text(`剩 ${this.remaining} 条`).fontColor('#FF4D4F')
}.width('100%').fontSize(13).fontColor('#888')
List({ space: 8 }) {
ForEach(this.todos, (todo: TodoItem) => {
ListItem() {
TodoRow({
item: $todo, // @Link 双向绑定
onRemove: (id: number) => this.removeTodo(id)
})
}
}, (todo: TodoItem) => todo.id.toString()) // keyGenerator
}
.layoutWeight(1).width('100%')
}
.padding(16).backgroundColor('#F5F5F5').height('100%').width('100%')
}
}
3.2 这段代码在干啥?逐块拆解
① 数据模型
interface TodoItem { id: number; text: string; done: boolean }
TypeScript 接口,定义一条待办长什么样。没什么神秘的,就是一份「契约」。
② CounterCard 子组件
@State count:组件内部状态,变了 UI 自动重绘@Prop label:父组件传进来的字符串,只读build()方法:描述这个组件「长什么样」
build() 是 ArkUI 组件的唯一出口——所有 UI 必须写在 build() 里,就像 React 的 render()。
③ TodoRow 子组件
@Link item:和父组件共用一份TodoItem,父子任意一方改,另一方都同步$todo这种带$的写法,是 ArkUI 的「引用传递」语法,等价于传指针onRemove是个回调函数,子组件点 ✕ 时,调父组件的removeTodo
④ Index 主页面
@State todos和@State inputText:驱动整个页面的两份状态ForEach(this.todos, ...):列表渲染,第三个参数是keyGenerator,强烈建议显式传,否则列表一变动,性能和状态都可能出问题TextInput的onChange把输入同步到@State inputText,这就是「受控组件」模式
四、状态管理:被名字吓退的新手第一名
很多人卡在 @State / @Prop / @Link 三个装饰器上。其实你只要记住一个比喻就行:
@State是你自己的钱包,@Prop是别人给你看的钱包照片,@Link是你和别人共用的同一个钱包。
具体区别:
| 装饰器 | 数据流向 | 子组件能改吗? | 父组件改了,子组件会刷新吗? |
|---|---|---|---|
@State |
仅组件内 | 能(自己的) | — |
@Prop |
父 → 子 | 不能(只是副本) | 会 |
@Link |
父 ↔ 子 | 能(同一份数据) | 会 |
一个常见错误:
// ❌ 错:没加 @State,count 改了界面不刷新
let count: number = 0
Button('+1').onClick(() => count++)
// ✅ 对:加 @State
@State count: number = 0
Button('+1').onClick(() => this.count++)
记住这个坑,能省掉你两个小时的调试时间。
五、列表渲染:ForEach 的三个坑
ArkUI 的列表渲染用 ForEach,看着像 React 的 map,但坑点不一样:
坑 1:忘了写 keyGenerator(第三个参数)
// ❌ 没有第三个参数,框架只能用 index 当 key,列表一变动就可能状态错乱
ForEach(this.todos, (t: TodoItem) => { ... })
// ✅ 显式传 key
ForEach(this.todos, (t: TodoItem) => { ... }, (t: TodoItem) => t.id.toString())
坑 2:直接改数组但界面没刷新
// ❌ 错:push 是有效的,但有时 ArkUI 不会立刻感知
this.todos.push(newItem)
// ✅ 更稳的写法:重新赋值
this.todos = [...this.todos, newItem]
// 或
this.todos.push(newItem)
this.todos = this.todos.slice() // 触发引用变化
坑 3:ForEach 外面套了 List,但 List 没设高度List 在 Column 里默认会撑满,但如果你给 List 设了固定高度又不够大,列表项会被裁掉。新手 demo 阶段用 layoutWeight(1) 就够了。
六、运行步骤(DevEco Studio)
- 安装 DevEco Studio(鸿蒙官方 IDE,基于 IntelliJ)
File → Open选本仓库的code/目录- 等待 Gradle sync 完成(第一次会比较久,正常)
- 点工具栏的运行按钮 ▶,或终端执行:
hvigorw assembleHap - 选模拟器或真机,看到「ArkUI 入门 Demo」标题出现,就跑通了
没有真机?DevEco 自带本地模拟器,
Tools → Device Manager启动一个就行。
七、踩坑总结(都是血泪)
| 坑 | 症状 | 解法 |
|---|---|---|
忘了 @State |
数据变了界面没动 | 给驱动 UI 的变量加 @State |
@Link 没用 $ 传值 |
父子不同步 | 写成 item: $todo |
ForEach 没传 key |
列表删除/插入后状态错乱 | 第三参数返回唯一 id |
build() 里写业务逻辑 |
UI 时序混乱、性能差 | 逻辑放到事件回调或方法里 |
TextInput 不刷新 |
直接改了变量但输入框没变 | 用受控模式:onChange → @State |
八、完整代码仓库
本文所有代码都已托管到 AtomGit,欢迎 clone、提 issue、点 star:
🔗 仓库地址:https://atomgit.com/JaneConan/arkui-demo
仓库包含:
- 完整的 ArkUI demo 工程(计数器 + 待办列表)
EntryAbility.etsUIAbility 入口Index.ets主页面(含子组件CounterCard/TodoRow)- 可直接用 DevEco Studio 打开运行
九、下一步该学什么?
跑通这个 demo 之后,你的 ArkUI 之路才刚刚开始。建议按这个顺序往下:
@Watch:监听@State变化,做副作用(类似 Vue 的watch)@Provide/@Consume:跨层级共享状态,告别 prop drilling@Builder:把一段 UI 抽成可复用函数,比@Component更轻- Navigation 导航:多页面路由,鸿蒙的方案和 React Router 思路类似
- 网络请求
@ohos.net.http:对接后端 API,把 demo 做成真应用
写在最后
鸿蒙生态目前正处在「岗位多、人少」的红利期,但红利只留给真上手的人。看十篇博客不如自己跑通一个 demo,跑通一个 demo 不如自己改一个需求。
代码已经给你了,仓库链接在上面。现在关掉这篇文章,打开 DevEco Studio,把 demo 跑起来。
跑通了,回来评论区打个「1」,我看看有多少人真的动手了。🚀
作者:JaneConan
仓库:https://atomgit.com/JaneConan/arkui-demo
协议:Apache-2.0,随便用,别告我
更多推荐



所有评论(0)