[鸿蒙从零到一] ArkUI 声明式 UI 基础实战

鸿蒙从零到一 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 组件常通过链式调用设置属性。大多数情况下,属性顺序不影响最终结果,但为了可读性,团队里最好形成固定顺序。

建议按下面的顺序组织:

  • 尺寸:widthheightconstraintSize
  • 间距:paddingmargin
  • 布局:layoutWeightalignSelfjustifyContentalignItems
  • 文本:fontSizefontWeightfontColormaxLines
  • 外观:backgroundColorborderRadiusbordershadow
  • 交互:enabledonClickonChange

示例:

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 状态,例如 loadingerrorMessagedata,否则后续维护时容易出现分支冲突。

自定义组件的拆分边界

新手常见的问题是所有内容都写在一个页面里。页面小的时候没问题,但当代码超过几百行,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() 里直接修改 @Statebuild() 会随着状态变化重新执行,如果在里面继续改状态,容易造成重复刷新甚至循环刷新。

不推荐:

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 变化会驱动界面刷新。
  • 事件处理要短小,业务逻辑要有边界。
  • 组件拆分要服务于复用和可读性,不要为了抽象而抽象。

把这些基础打稳,再去学习列表、动画、跨页面状态和复杂架构时,就不会只是在堆组件,而是在构建可维护的鸿蒙应用界面。

相关推荐
七牛开发者1 小时前
RAG 工程里的上下文剪枝:如何减少 68% 的上下文
前端·css·github
橘子星1 小时前
从零理解流式输出 —— 一个 Vue + DeepSeek 的前端实战
前端·javascript·人工智能
智驭未来掌门人1 小时前
如何让你的前端服务在服务器上nohub运行
前端
颜进强1 小时前
Claude Code - 18 从 Figma 到 React:一套让 AI 生成代码"不瞎编"的实战工作流
前端·后端·ai编程
李伟_Li慢慢2 小时前
12-mini版WebGLObjects
前端·three.js
李伟_Li慢慢2 小时前
17-mini版WebGLBufferRenderer
前端·three.js
李伟_Li慢慢2 小时前
16-mini版WebGLBackground
前端·three.js
李伟_Li慢慢2 小时前
13-mini版WebGLProgram
前端·three.js