前言:为什么鸿蒙要用声明式UI?

做过安卓开发的朋友,谁没被XML布局折磨过?

写一个简单的登录页,你得先在XML里拖十几个控件,给每个控件起个id,然后切到Java/Kotlin文件里一个个findViewById,再设置文本、颜色、点击事件。改个按钮颜色要切到XML,改个点击逻辑又要切回代码,来回折腾半天。

最头疼的还是状态更新。比如用户输入用户名后,登录按钮要从灰色变成可点击状态。你得监听输入框的变化,然后手动调用button.setEnabled(true)。状态一多,代码就乱成一团,很容易出现UI和数据不一致的bug。

这就是命令式UI的痛点:你要告诉电脑"一步步怎么做",从创建控件到更新UI,所有细节都要自己管。

而鸿蒙的ArkUI声明式UI,彻底颠覆了这种开发方式。你只需要告诉电脑"我想要什么样子的UI",剩下的交给框架就行。数据变了,UI自动更新,再也不用手动findViewById和setText了。

这篇文章,我会用最直白的语言和最实用的代码,带你彻底搞懂声明式UI的核心思想,完成从XML到声明式的思维转变。看完你就能写出自己的第一个鸿蒙界面。

一、ArkUI开发框架介绍

ArkUI是华为专门为HarmonyOS NEXT打造的UI开发框架,现在纯血鸿蒙只推荐用基于ArkTS的声明式开发范式

什么是声明式UI?

一句话总结:数据驱动UI,UI是数据的映射

当数据发生变化时,框架会自动计算出UI的差异,然后只更新需要变化的部分。你不需要关心怎么更新UI,只需要关心数据怎么变。

我用一个最简单的例子,让你一眼看出命令式和声明式的区别:

需求:点击按钮,让文本从"0"变成"1"

安卓命令式写法

<!-- activity_main.xml -->
<TextView
    android:id="@+id/count_text"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="0" />

<Button
    android:id="@+id/increment_btn"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="点击加1" />
// MainActivity.kt
class MainActivity : AppCompatActivity() {
    private var count = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val countText = findViewById<TextView>(R.id.count_text)
        val incrementBtn = findViewById<Button>(R.id.increment_btn)

        incrementBtn.setOnClickListener {
            count++
            countText.text = count.toString() // 手动更新UI
        }
    }
}

鸿蒙声明式写法

@Entry
@Component
struct Counter {
  @State count: number = 0; // 数据

  build() {
    Column() {
      Text(this.count.toString()) // UI直接绑定数据
        .fontSize(30);

      Button("点击加1")
        .onClick(() => {
          this.count++; // 只需要改变数据
        });
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center);
  }
}

看到区别了吗?

  • 安卓:你要先创建控件,然后找到它,然后在点击事件里手动更新它的文本
  • 鸿蒙:你只需要定义一个数据count,然后把Text组件和count绑定。当count变化时,Text自动更新

这就是声明式UI的魅力。代码更少,逻辑更清晰,出错的概率也更低。

二、组件的基本结构

在ArkUI里,一切都是组件。页面是组件,按钮是组件,甚至一段文本也是组件。

一个标准的自定义组件,结构非常固定,就这几部分:

// 1. @Component装饰器:告诉编译器,这是一个UI组件
@Component
struct HelloComponent {
  // 2. 状态变量:驱动UI变化的数据
  // 用@State装饰的变量,只要值变了,UI就会自动刷新
  @State message: string = "Hello ArkUI";

  // 3. build()方法:组件的渲染函数
  // 这个方法必须返回一个UI组件,描述这个组件长什么样子
  build() {
    // 4. 根布局:每个build()方法只能有一个根组件
    Column() {
      // 5. 子组件:描述UI的具体内容
      Text(this.message)
        .fontSize(24) // 6. 属性方法:链式调用设置组件样式
        .fontColor(Color.Blue);

      Button("改变文本")
        .onClick(() => {
          // 7. 事件处理:响应用户操作
          this.message = "你好,鸿蒙!";
        });
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center);
  }
}

就这么简单!没有XML文件,没有findViewById,所有东西都在一个文件里,逻辑和UI在一起,维护起来特别方便。

三、常用基础组件

掌握这4个基础组件,你就能搞定80%的界面开发了。我只讲最常用的属性和用法,不常用的就不说了,避免信息过载。

3.1 Text:文本组件

用来显示文字,最基础也最常用的组件。

Text("这是一段普通文本")
  .fontSize(18) // 字体大小,单位fp
  .fontColor('#333333') // 字体颜色,支持十六进制和Color枚举
  .fontWeight(FontWeight.Bold) // 字体粗细
  .textAlign(TextAlign.Center) // 文本对齐方式
  .maxLines(1) // 最多显示几行
  .textOverflow({ overflow: TextOverflow.Ellipsis }) // 超出部分显示省略号
  .margin({ top: 10, bottom: 10 }); // 外边距

3.2 Button:按钮组件

用来响应用户点击操作。

// 普通文字按钮
Button("登录")
  .width('85%')
  .height(50)
  .backgroundColor('#007aff')
  .fontSize(18)
  .borderRadius(8) // 圆角
  .onClick(() => {
    console.log("点击了登录按钮");
  });

// 带图标的按钮
Button($r("app.media.ic_search"), "搜索")
  .width(200)
  .height(45);

// 文字按钮(没有背景)
Button("注册账号")
  .fontColor('#007aff')
  .backgroundColor(Color.Transparent);

3.3 Image:图片组件

用来显示本地图片和网络图片。

// 本地资源图片(推荐,性能更好)
// 把图片放到entry/src/main/resources/base/media目录下
Image($r("app.media.logo"))
  .width(120)
  .height(120)
  .objectFit(ImageFit.Contain) // 图片缩放模式,保持比例完整显示
  .borderRadius(10);

// 网络图片
Image("https://picsum.photos/200/300")
  .width(200)
  .height(300)
  .alt($r("app.media.placeholder")); // 加载失败时显示的占位图

3.4 TextInput:输入框组件

用来接收用户输入的文本。

// 用户名输入框
TextInput({ placeholder: "请输入手机号/用户名" })
  .width('100%')
  .height(50)
  .padding({ left: 15 })
  .backgroundColor('#f5f5f5')
  .borderRadius(8)
  .onChange((value) => {
    // 输入内容变化时触发,value就是输入的文本
    console.log("用户输入:" + value);
  });

// 密码输入框
TextInput({ placeholder: "请输入密码" })
  .type(InputType.Password) // 密码类型,输入内容会变成圆点
  .width('100%')
  .height(50);

四、布局组件

布局组件用来安排子组件的位置和大小。ArkUI有三个最基础也最常用的布局,复杂界面都是它们嵌套组合出来的。

4.1 Column:垂直布局

子组件从上到下,一个挨一个垂直排列。

Column() {
  Text("第一行")
  Text("第二行")
  Text("第三行")
}
.width('100%')
.height(200)
.backgroundColor('#f5f5f5')
.justifyContent(FlexAlign.Center) // 垂直方向对齐方式
.alignItems(HorizontalAlign.Center) // 水平方向对齐方式
.gap(15); // 子组件之间的间距

4.2 Row:水平布局

子组件从左到右,一个挨一个水平排列。

Row() {
  Button("取消")
  Button("确定")
}
.width('100%')
.height(60)
.backgroundColor('#f5f5f5')
.justifyContent(FlexAlign.SpaceEvenly) // 水平方向均匀分布
.alignItems(VerticalAlign.Center) // 垂直方向居中对齐
.gap(20);

4.3 Stack:层叠布局

子组件堆叠在一起,后面的组件会盖在前面的组件上面。

Stack() {
  // 底层:背景图
  Image($r("app.media.bg"))
    .width('100%')
    .height(200);

  // 上层:半透明遮罩
  Column() {}
    .width('100%')
    .height(200)
    .backgroundColor('rgba(0,0,0,0.3)');

  // 最上层:文字
  Text("这是层叠在图片上的文字")
    .fontSize(20)
    .fontColor(Color.White)
    .position({ x: 20, y: 150 }); // 绝对定位
}
.width('100%')
.height(200);

小技巧:写界面的时候,先把整个页面拆成一个个小模块,每个模块用一个Column或Row,然后再往里面填子组件。这样思路会非常清晰。

五、实战:写一个完整的登录页面

现在我们把前面学的所有东西组合起来,写一个可以直接用在项目里的登录页面。这个页面包含:logo、标题、用户名输入框、密码输入框(带显示隐藏功能)、登录按钮、忘记密码和注册链接。

直接复制这段代码到DevEco Studio里,就能运行看到效果。

@Entry
@Component
struct LoginPage {
  // 用户名和密码
  @State username: string = "";
  @State password: string = "";
  
  // 密码是否显示
  @State isPasswordVisible: boolean = false;
  
  // 是否正在加载
  @State isLoading: boolean = false;

  build() {
    Column() {
      // Logo
      Image($r("app.media.logo"))
        .width(100)
        .height(100)
        .margin({ top: 80, bottom: 40 });

      // 标题
      Text("欢迎登录")
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .margin({ bottom: 60 });

      // 用户名输入框
      TextInput({ placeholder: "请输入手机号/用户名", text: this.username })
        .width('85%')
        .height(50)
        .padding({ left: 15, right: 15 })
        .backgroundColor('#f5f5f5')
        .borderRadius(8)
        .margin({ bottom: 15 })
        .onChange((value) => {
          this.username = value;
        });

      // 密码输入框(带显示隐藏)
      Stack({ alignContent: Alignment.End }) {
        TextInput({ placeholder: "请输入密码", text: this.password })
          .type(this.isPasswordVisible ? InputType.Normal : InputType.Password)
          .width('100%')
          .height(50)
          .padding({ left: 15, right: 50 })
          .backgroundColor('#f5f5f5')
          .borderRadius(8)
          .onChange((value) => {
            this.password = value;
          });

        // 密码显示隐藏按钮
        Image(this.isPasswordVisible ? $r("app.media.ic_eye_open") : $r("app.media.ic_eye_close"))
          .width(24)
          .height(24)
          .margin({ right: 15 })
          .onClick(() => {
            this.isPasswordVisible = !this.isPasswordVisible;
          });
      }
      .width('85%')
      .margin({ bottom: 20 });

      // 登录按钮
      Button(this.isLoading ? "登录中..." : "登录")
        .width('85%')
        .height(50)
        .backgroundColor(this.isLoginButtonEnabled() ? '#007aff' : '#cccccc')
        .fontSize(18)
        .borderRadius(8)
        .enabled(this.isLoginButtonEnabled() && !this.isLoading)
        .onClick(() => {
          this.handleLogin();
        });

      // 底部链接
      Row() {
        Text("忘记密码")
          .fontColor('#007aff')
          .fontSize(14)
          .onClick(() => {
            console.log("跳转到忘记密码页面");
          });

        Text("注册账号")
          .fontColor('#007aff')
          .fontSize(14)
          .onClick(() => {
            console.log("跳转到注册页面");
          });
      }
      .width('85%')
      .justifyContent(FlexAlign.SpaceBetween)
      .margin({ top: 30 });
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#ffffff');
  }

  // 判断登录按钮是否可用
  isLoginButtonEnabled(): boolean {
    return this.username.length > 0 && this.password.length > 0;
  }

  // 处理登录
  handleLogin() {
    // 简单的输入验证
    if (this.username.length < 3) {
      console.log("用户名长度不能少于3位");
      return;
    }

    if (this.password.length < 6) {
      console.log("密码长度不能少于6位");
      return;
    }

    // 模拟登录请求
    this.isLoading = true;
    
    setTimeout(() => {
      this.isLoading = false;
      console.log(`登录成功!用户名:${this.username}`);
      // 这里可以添加跳转到首页的逻辑
    }, 2000);
  }
}

这个登录页面有几个实用的细节:

  1. 密码显示隐藏功能,用户可以点击眼睛图标切换
  2. 登录按钮只有在用户名和密码都输入后才会变成可点击状态
  3. 点击登录后会显示加载状态,防止用户重复点击
  4. 有简单的输入验证

总结

今天这篇文章,核心就是帮你完成一个思维转变:从"我要怎么一步步构建UI",变成"UI应该是什么样子的"。

记住这几个关键点:

  1. 声明式UI是数据驱动的,数据变了,UI自动更新
  2. 一切都是组件,每个组件都有固定的结构
  3. 掌握Text、Button、Image、TextInput四个基础组件
  4. 掌握Column、Row、Stack三个基础布局,通过嵌套组合实现任何界面
  5. 多写多练,在实战中体会声明式UI的优势

福利时间!
我已经为大家准备了这个登录页面的完整源码包,里面包含了所有用到的图标资源,还有更完善的错误提示和交互效果。下载后直接导入DevEco Studio就能运行。

获取方式:
关注我的账号,私信回复"登录页面",即可免费获取完整源码!

下一篇文章,我会带大家学习鸿蒙的状态管理,教你如何在多个组件之间传递数据和共享状态。如果这篇文章对你有帮助,别忘了点赞、收藏、转发给你的朋友!有任何问题,欢迎在评论区留言,我会一一解答。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐