鸿蒙 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 内置了一堆组件:TextButtonImageColumnRowListTextInput……每个组件就是一块积木。

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):

ArkUI 入门 demo 真机运行效果(计数器 + 待办列表)

图里能看到:顶部计数器卡片(点击次数随 +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强烈建议显式传,否则列表一变动,性能和状态都可能出问题
  • TextInputonChange 把输入同步到 @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 没设高度
ListColumn 里默认会撑满,但如果你给 List 设了固定高度又不够大,列表项会被裁掉。新手 demo 阶段用 layoutWeight(1) 就够了。


六、运行步骤(DevEco Studio)

  1. 安装 DevEco Studio(鸿蒙官方 IDE,基于 IntelliJ)
  2. File → Open 选本仓库的 code/ 目录
  3. 等待 Gradle sync 完成(第一次会比较久,正常)
  4. 点工具栏的运行按钮 ▶,或终端执行:
    hvigorw assembleHap
    
  5. 选模拟器或真机,看到「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.ets UIAbility 入口
  • Index.ets 主页面(含子组件 CounterCard / TodoRow
  • 可直接用 DevEco Studio 打开运行

九、下一步该学什么?

跑通这个 demo 之后,你的 ArkUI 之路才刚刚开始。建议按这个顺序往下:

  1. @Watch:监听 @State 变化,做副作用(类似 Vue 的 watch
  2. @Provide / @Consume:跨层级共享状态,告别 prop drilling
  3. @Builder:把一段 UI 抽成可复用函数,比 @Component 更轻
  4. Navigation 导航:多页面路由,鸿蒙的方案和 React Router 思路类似
  5. 网络请求 @ohos.net.http:对接后端 API,把 demo 做成真应用

写在最后

鸿蒙生态目前正处在「岗位多、人少」的红利期,但红利只留给真上手的人。看十篇博客不如自己跑通一个 demo,跑通一个 demo 不如自己改一个需求。

代码已经给你了,仓库链接在上面。现在关掉这篇文章,打开 DevEco Studio,把 demo 跑起来。

跑通了,回来评论区打个「1」,我看看有多少人真的动手了。🚀


作者:JaneConan
仓库:https://atomgit.com/JaneConan/arkui-demo
协议:Apache-2.0,随便用,别告我

Logo

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

更多推荐