在这里插入图片描述
在这里插入图片描述

前言

随着健康意识的提升,越来越多的人开始关注日常饮水量。"每天八杯水"的健康建议几乎人人皆知,但真正能做到的人却寥寥无几。喝水提醒类应用因此成为了手机上的高频实用工具。

本文将带领读者使用 HarmonyOS NEXT 6.1.1(API 24)和 ArkTS 语言,从零实现一个简洁但完整的"喝水模拟器"应用。这个应用虽然小,却涵盖了 ArkTS 开发中的核心知识:响应式状态管理、条件渲染、动态样式计算、用户交互事件处理、以及百分比布局的实现。通过本文的学习,你将能够独立构建类似的实用小工具,并将其扩展到更复杂的应用场景。

全文约 10000 字,代码完整可运行,所有示例均在真机和模拟器上验证通过。


第一章 应用需求分析与设计思路

1.1 为什么选择"喝水模拟器"作为示例

选择实现喝水模拟器而非其他应用,出于以下几点考虑:

实用性强:喝水追踪是日常生活的高频需求,用户能直观理解应用的价值,而不需要额外的领域知识。

功能聚焦:核心功能只有"喝水计数",没有复杂的网络请求、数据库操作或第三方 SDK 集成,非常适合作为 ArkTS 的入门案例。

交互丰富:虽然功能简单,但涉及了按钮点击、进度条动画、状态驱动的 UI 更新、条件文本渲染等多种交互元素,覆盖了 ArkTS 开发的多个重要知识点。

易于扩展:基础版本完成后,可以非常自然地添加历史记录、自定义目标、定时提醒等功能,给学习者留下了充分的扩展空间。

1.2 功能需求

我们要实现的喝水模拟器需要满足以下核心功能:

  1. 喝水计数:每次点击"喝一杯"按钮,已喝杯数加 1。
  2. 小口饮水:提供"喝一小口"按钮,每次加 0.5 杯,更贴近真实饮水场景。
  3. 进度展示:通过数字和进度条两种方式直观展示当前完成度。
  4. 目标提示:显示距离每日目标还差多少杯,达成时显示祝贺语。
  5. 状态重置:一键清空当日记录,重新开始。

1.3 非功能需求

除了基本功能,我们还关注以下质量属性:

  • 性能:状态变化仅触发最小 UI 更新,不涉及不必要的重绘。
  • 可读性:代码结构清晰,变量命名语义化,注释完整。
  • 可维护性:使用 readonly 常量管理可配置参数(如每日目标杯数),便于后续修改。

1.4 UI 设计

界面采用纵向居中布局,从上到下依次为:

 ┌─────────────────────────────┐
 │        💧 喝水模拟器         │  ← 标题
 ├─────────────────────────────┤
 │          已喝水              │
 │       3 / 8 杯              │  ← 核心数值
 │  ████████░░░░░░░░░         │  ← 进度条
 │    还差 5 杯                │  ← 状态文字
 ├─────────────────────────────┤
 │         ┌───────┐           │
 │         │  🥤   │           │  ← 喝一杯(大圆形按钮)
 │         │ 喝一杯 │           │
 │         └───────┘           │
 │        [ 💧 喝一小口 ]      │  ← 小口按钮
 ├─────────────────────────────┤
 │        [ 🔄 重新开始 ]      │  ← 重置按钮
 └─────────────────────────────┘

设计原则:

  • 视觉重心明确:数字和进度条居中突出,让用户一眼掌握当天饮水进度。
  • 操作按钮层次分明:大圆形按钮作为主要操作入口,"喝一小口"作为辅助操作,重置按钮独立置于底部。
  • 反馈即时:每次点击按钮,数字和进度条同步更新,给予清晰的交互反馈。

第二章 响应式状态管理深入理解

在开始编写代码之前,我们先深入理解一下 ArkTS 中状态管理的底层机制,这将帮助我们写出更高效的代码。

2.1 @State 的工作原理

当我们在组件中声明一个 @State 变量时,ArkTS 框架会做以下几件事:

  1. 注册观测:将该变量注册到框架的变更检测系统中。
  2. 建立依赖图:在 build() 方法执行时,框架会自动记录哪些 UI 节点读取了该状态变量,建立起"状态 → UI 节点"的依赖映射。
  3. 脏检查与最小化更新:当状态变量被赋值时,框架不会全量重建整个 UI 树,而是通过脏检查(Dirty Checking)找出依赖图中受影响的节点,仅对这些节点执行重绘。

![状态驱动UI更新示意]

  状态变化 (setter)
       ↓
  变更检测系统
       ↓
  查找受影响 UI 节点
       ↓
  仅更新受影响的 Text / Row / ...
       ↓
  界面刷新完成

这个机制意味着:即使页面中有 100 个 UI 组件,修改一个 @State 变量也只会触发与之相关的少数几个组件重绘,性能开销极小。

为了更直观地理解,我们来看一个对比。假设有两个 Text 组件分别显示不同的状态变量:

@Component
struct Demo {
  @State countA: number = 0;
  @State countB: number = 0;

  build() {
    Column() {
      Text(`A: ${this.countA}`)    // 仅依赖 countA
      Text(`B: ${this.countB}`)    // 仅依赖 countB
    }
  }
}

当执行 this.countA++ 时,框架的依赖图系统会精确判断出"只有第一个 Text 依赖 countA",于是仅重绘第一个 Text,第二个 Text 完全不受影响。这种"精准打击"的能力在大型页面中能显著提升渲染性能,是 ArkTS 声明式框架相比传统命令式 DOM 操作的核心优势之一。

2.2 状态变更的批处理机制

ArkTS 框架在同一个事件循环 tick 中对状态变更进行了批处理优化:

// 在一次事件回调中连续修改同一状态
onClick(() => {
  this.cups++;       // 第一次修改
  this.cups++;       // 第二次修改
  this.cups++;       // 第三次修改
});

虽然这里连续执行了三次 this.cups++,但框架并不会触发三次 UI 更新,而是会将三次变更合并为一次批处理操作,在事件回调结束后统一执行一次 UI 刷新。这意味着无论在一次回调中修改状态多少次,最终都只会产生一次布局计算和一次渲染绘制,极大地提升了频繁交互场景下的性能表现。

这个批处理机制对开发者是透明的 —— 你不需要手动开启或关闭批处理,框架会自动处理。但了解这个机制有助于写出正确的预期:在一次事件处理函数中,中间状态的 UI 表现不会被渲染,你看到的永远是所有修改生效后的最终状态。

2.3 状态与 UI 的对应关系

在我们的喝水模拟器中,只有一个 @State 变量:

@State cups: number = 0;

但这个变量被多个 UI 节点依赖:

UI 节点 依赖方式 更新触发时机
Text('${this.cups} / ${this.goal} 杯') 读取 cups cups++cups += 0.5
进度条宽度 计算 (cups/goal)*100% cups 变化时
Text('还差 X 杯') 计算 goal - cups cups 变化时
Text('🎉 已达成!') 条件判断 cups >= goal cups 变化时

所有这些 UI 节点都是在 build() 方法中描述并通过闭包捕获了 this.cups 的引用。当 cups 变化时,框架会自动更新这些节点。这就是"声明式 UI"的核心 —— 你只需声明状态和 UI 之间的映射关系,框架负责在状态变化时维持 UI 的同步。

2.4 为什么不需要为 goal 使用 @State

goal 被声明为 readonly 常量,而不是 @State 变量:

readonly goal: number = 8;

这是因为 goal 在应用运行期间不会变化。它是一个"编译期常量",在组件的整个生命周期内都保持为 8。如果未来需要让用户自定义目标杯数,则需要将其改为 @State 变量并提供一个设置界面。

这种"不变数据用 readonly,可变数据用 @State"的区分,是 ArkTS 开发中的最佳实践。不必要的 @State 会增加框架变更检测的负担,虽然单个变量的影响微乎其微,但养成良好的习惯有助于构建更大规模的应用。


第三章 完整代码实现

3.1 文件创建与路由注册

entry/src/main/ets/pages/ 目录下创建 Index2.ets 文件。

然后在 entry/src/main/resources/base/profile/main_pages.json 中注册路由:

{
  "src": [
    "pages/Index",
    "pages/Index2"
  ]
}

main_pages.json 是鸿蒙应用的路由配置文件,src 数组列出了应用中所有可导航的页面。添加 "pages/Index2" 后,应用框架就知道了新页面的存在,可以通过路由 API 或直接修改 EntryAbility 的启动页面来访问它。

3.2 组件骨架

首先,我们搭建组件的基本骨架:

@Entry
@Component
struct Index2 {
  @State cups: number = 0;
  readonly goal: number = 8;

  build() {
    Column() {
      // 所有 UI 组件
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#fff');
  }
}

@Entry 装饰器将 Index2 标记为页面入口,@Component 将其定义为 UI 组件。Column 是最外层的垂直布局容器,占满全屏,背景为白色。

3.3 标题与计数显示

// 标题
Text('💧 喝水模拟器')
  .fontSize(28)
  .fontWeight(FontWeight.Bold)
  .margin({ top: 30, bottom: 20 });

// 已喝水标签
Text('已喝水')
  .fontSize(16)
  .fontColor('#888');

// 核心数值:3 / 8 杯
Text(`${this.cups} / ${this.goal}`)
  .fontSize(36)
  .fontWeight(FontWeight.Bold)
  .fontColor('#1890ff')
  .margin({ top: 8, bottom: 12 });

这里使用模板字符串 ${this.cups} 将数字变量嵌入文本中。当 cups 变化时,Text 组件会自动重新渲染显示新的数值。

模板字符串是 ES6/TypeScript 的特性。在 ArkTS 中,模板字符串中的大括号内可以放置任意表达式,包括变量引用、算术运算、三元运算符等。例如:

// 这些都是合法的模板字符串用法
Text(`完成度: ${(this.cups / this.goal * 100).toFixed(0)}%`)
Text(`进度: ${this.cups}/${this.goal}`)

3.4 进度条实现

进度条是喝水模拟器最直观的视觉反馈元素。它的实现用到了两个嵌套的 Row 容器:

// 进度条背景
Row() {
  // 进度条前景(蓝色填充部分)
  Row()
    .width(this.cups > this.goal ? '100%' : `${(this.cups / this.goal) * 100}%`)
    .height('100%')
    .backgroundColor('#1890ff')
    .borderRadius(10);
}
.width('80%')
.height(24)
.backgroundColor('#e8e8e8')
.borderRadius(10)
.margin({ bottom: 10 });

实现原理

外层 Row 是进度条的"容器"(灰色背景,height: 24,圆角 10),宽度为父容器的 80%。内层 Row 是进度条的"填充"(蓝色前景),它的宽度通过一个三元表达式动态计算:

(this.cups / this.goal) * 100%

cups = 4goal = 8 时,宽度为 50%,进度条刚好填满一半。

三元表达式的左半部分 this.cups > this.goal ? '100%' 处理了超额完成的边界情况:如果用户喝了超过 8 杯,进度条保持在 100%,不会溢出。

为什么不使用 Progress 组件

ArkTS 提供了 Progress 组件用于展示进度,但使用嵌套 Row 的方案有以下优势:

  • 完全掌控样式(颜色、圆角、尺寸),不受 Progress 组件 API 限制。
  • 布局方式与页面上其他元素一致,更容易实现视觉统一。
  • 理解了这个模式后,可以灵活地在任何需要进度展示的地方复用。

3.5 条件提示文字

根据当前已喝杯数是否达到目标,显示不同的提示信息:

Text(this.cups >= this.goal ? '🎉 已达成今日目标!' : `还差 ${this.goal - this.cups}`)
  .fontSize(14)
  .fontColor(this.cups >= this.goal ? '#52c41a' : '#999')
  .margin({ bottom: 30 });

这里使用了两个三元运算符:

  • 第一个决定文本内容:达标显示祝贺语,未达标显示差值。
  • 第二个决定文字颜色:达标为绿色(#52c41a),未达标为灰色(#999)。

知识点:条件渲染的两种模式

在 ArkTS 中,条件渲染主要有两种方式:

方式一(本文使用):在表达式中使用三元运算符。适用于"二选一"的场景,代码简洁。

Text(condition ? 'A' : 'B')

方式二:使用 if 语句包裹不同的 UI 分支。适用于复杂场景或多分支选择。

if (this.cups >= this.goal) {
  Text('🎉 已达成今日目标!').fontColor('#52c41a');
} else {
  Text(`还差 ${this.goal - this.cups}`).fontColor('#999');
}

对于简单的二选一,三元运算符更简洁;对于多个组件或复杂逻辑的分支,if 语句可读性更好。

3.6 主按钮 —— 喝一杯

Button() {
  Column() {
    Text('🥤')
      .fontSize(40);
    Text('喝一杯')
      .fontSize(16)
      .fontColor('#fff')
      .margin({ top: 4 });
  }
}
.width(120)
.height(120)
.backgroundColor('#1890ff')
.borderRadius(60)
.onClick(() => {
  this.cups++;
});

设计亮点

  • 圆形按钮widthheight 相等(120),borderRadius 设为宽度的一半(60),得到一个完美的圆形。这是 ArkTS 中实现圆形按钮的标准技巧。
  • 复合内容:Button 的构造函数不传参数(Button() 而非 Button('文字')),而是传入一个 Column 子组件,实现了"Emoji 图标 + 文字"的复合布局。
  • 内边距:Emoji 和文字之间通过 margin({ top: 4 }) 保持 4 像素间距,视觉上不会挤在一起。

点击事件

onClick(() => { this.cups++; }) 是按钮点击事件的绑定方式。每次点击,cups 增加 1,触发 UI 更新。

3.7 辅助按钮 —— 喝一小口

Button('💧 喝一小口')
  .width(160)
  .height(44)
  .margin({ top: 20 })
  .backgroundColor('#40a9ff')
  .borderRadius(22)
  .fontColor('#fff')
  .fontSize(16)
  .onClick(() => {
    this.cups += 0.5;
  });

这个按钮使用简化写法(Button('文本')),颜色比主按钮稍浅(#40a9ff),宽度更窄,高度更矮,视觉层级上低于主按钮,暗示它是"辅助操作"。

this.cups += 0.5 每次增加半杯。ArkTS 中 @State number 类型完全支持浮点数运算。

3.8 重置按钮

Button('🔄 重新开始')
  .width(160)
  .height(44)
  .margin({ top: 30 })
  .backgroundColor('#ff4d4f')
  .borderRadius(22)
  .fontColor('#fff')
  .fontSize(16)
  .onClick(() => {
    this.cups = 0;
  });

红色重置按钮置于页面底部,与其他操作按钮拉开间距(margin({ top: 30 })),避免误触。

3.9 完整代码

将以上所有部分组合在一起,得到完整的 Index2.ets

@Entry
@Component
struct Index2 {
  @State cups: number = 0;
  readonly goal: number = 8;

  build() {
    Column() {
      // 标题
      Text('💧 喝水模拟器')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .margin({ top: 30, bottom: 20 });

      // 已喝水标签
      Text('已喝水')
        .fontSize(16)
        .fontColor('#888');

      // 核心数值
      Text(`${this.cups} / ${this.goal}`)
        .fontSize(36)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1890ff')
        .margin({ top: 8, bottom: 12 });

      // 进度条
      Row() {
        Row()
          .width(this.cups > this.goal ? '100%' : `${(this.cups / this.goal) * 100}%`)
          .height('100%')
          .backgroundColor('#1890ff')
          .borderRadius(10);
      }
      .width('80%')
      .height(24)
      .backgroundColor('#e8e8e8')
      .borderRadius(10)
      .margin({ bottom: 10 });

      // 状态提示文字
      Text(this.cups >= this.goal ? '🎉 已达成今日目标!' : `还差 ${this.goal - this.cups}`)
        .fontSize(14)
        .fontColor(this.cups >= this.goal ? '#52c41a' : '#999')
        .margin({ bottom: 30 });

      // 主按钮:喝一杯
      Button() {
        Column() {
          Text('🥤').fontSize(40);
          Text('喝一杯')
            .fontSize(16)
            .fontColor('#fff')
            .margin({ top: 4 });
        }
      }
      .width(120)
      .height(120)
      .backgroundColor('#1890ff')
      .borderRadius(60)
      .onClick(() => {
        this.cups++;
      });

      // 辅助按钮:喝一小口
      Button('💧 喝一小口')
        .width(160)
        .height(44)
        .margin({ top: 20 })
        .backgroundColor('#40a9ff')
        .borderRadius(22)
        .fontColor('#fff')
        .fontSize(16)
        .onClick(() => {
          this.cups += 0.5;
        });

      // 重置按钮
      Button('🔄 重新开始')
        .width(160)
        .height(44)
        .margin({ top: 30 })
        .backgroundColor('#ff4d4f')
        .borderRadius(22)
        .fontColor('#fff')
        .fontSize(16)
        .onClick(() => {
          this.cups = 0;
        });
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#fff');
  }
}

第四章 关键技术点深入剖析

4.1 百分比宽度布局详解

在进度条实现中,我们使用了 '80%'${...}% 这样的百分比宽度值。ArkTS 中的百分比宽度是相对于父容器的内容区域(Content Area)计算的。

// 外层 Row 宽度为父容器宽度的 80%
Row() {
  // 内层 Row 宽度为外层 Row 宽度的 (cups/goal)*100%
  Row()
    .width(`${(this.cups / this.goal) * 100}%`)
}
.width('80%')

计算示例:

  • 假设屏幕宽度为 360dp(设备无关像素)。
  • 外层 Row 宽度 = 360 × 80% = 288dp。
  • 如果 cups = 4, goal = 8,内层 Row 宽度 = 288 × (4/8) = 144dp。
  • 如果 cups = 8, goal = 8,内层 Row 宽度 = 288 × 100% = 288dp(满格)。

这种百分比嵌套的方式实现了"相对父容器宽度"的灵活布局,在不同屏幕尺寸下都能正确适配。

4.2 事件处理机制

ArkTS 中的事件处理采用"闭包回调"模式。每个支持交互的组件(如 ButtonTextImage 等)都提供了 onClickonLongPressonTouch 等事件方法,接受一个箭头函数作为参数。

Button('示例')
  .onClick(() => {
    // 点击事件处理逻辑
    this.cups++;
  })
  .onLongPress(() => {
    // 长按事件处理逻辑
    console.log('长按了按钮');
  });

箭头函数 () => { ... } 自动捕获外层的 this 上下文,因此在回调中可以正确访问 this.cups 等组件属性。这一点与 JavaScript/TypeScript 的标准行为一致。

为什么不直接用 function()

如果使用普通函数声明 function() { this.cups++; },内部的 this 将指向函数调用时的上下文(通常为 undefined 或全局对象),而非组件实例。箭头函数则没有这个问题,它捕获的是定义时的 this 值。

事件对象参数

某些事件处理函数可以接收事件对象参数,获取更详细的交互信息:

.onClick((event: ClickEvent) => {
  // event 包含点击位置的坐标等信息
  console.log(`点击位置: (${event.x}, ${event.y})`);
})

ClickEvent 类型包含以下常用属性:

  • x / y:点击位置相对于被点击组件的坐标。
  • screenX / screenY:点击位置相对于屏幕的坐标。
  • timestamp:事件发生的时间戳。
  • source:事件来源(鼠标、触摸、键盘等)。

在喝水模拟器中,我们不需要区分点击位置,所以直接使用无参的箭头函数即可,代码更加简洁。

4.3 Button 组件的两种使用模式

在喝水模拟器中,我们同时使用了 Button 的两种形态,这里做一个对比总结:

模式一:文本按钮(简单场景)

Button('💧 喝一小口')
  .fontSize(16)
  .fontColor('#fff')

适用于按钮文字简单的场景,构造参数直接传入字符串。链式调用的属性(如 fontSizefontColor)作用于按钮文本。

模式二:自定义子组件按钮(复杂场景)

Button() {
  Column() {
    Text('🥤').fontSize(40);
    Text('喝一杯')
      .fontSize(16)
      .fontColor('#fff')
      .margin({ top: 4 });
  }
}

适用于需要复杂布局的按钮,构造参数是子组件树。这种模式下,可以在按钮内部嵌套任意布局和组件,实现丰富的视觉表现。这里我们在按钮内部用 Column 垂直排列 Emoji 图标和文字标签。

两种模式的选择原则:

  • 只有一行纯文本 → 模式一(简洁)
  • 需要图标 + 文字、多行文字或自定义布局 → 模式二(灵活)

4.4 浮点数精度注意事项

this.cups += 0.5 涉及浮点数运算。JavaScript(以及 ArkTS)中的浮点数遵循 IEEE 754 标准,某些十进制小数无法精确表示:

0.1 + 0.2  // 结果不是 0.3,而是 0.30000000000000004

在我们的场景中,cups 从 0 开始每次增加 0.5,运算序列为 0 → 0.5 → 1.0 → 1.5 → 2.0 → ...,这些值在二进制浮点数中都可以精确表示(因为 0.5 = 2^(-1)),所以不会有精度问题。

但如果未来扩展为自定义水量(如每次 0.3 杯),就需要考虑四舍五入或使用整型(毫升)来避免精度累积误差。

// 推荐方式:使用整型毫升(ml)避免浮点误差
@State totalMl: number = 0;
readonly cupSize: number = 250;  // 一杯 250ml

// 加一小口 50ml
this.totalMl += 50;

// 显示时转换为杯
Text(`${(this.totalMl / this.cupSize).toFixed(1)}`)

第五章 项目构建与运行验证

5.1 构建命令

在项目根目录执行以下命令构建 HAP 包:

cd E:\MyApplication1
hvigorw assembleHap --mode module -p module=entry --no-daemon

构建成功会输出:

> hvigor BUILD SUCCESSFUL in 10 s

如果在 DevEco Studio 中构建,点击菜单栏 Build > Build HAP(s) 或使用快捷键 Ctrl+F9

5.2 运行效果

应用启动后,你应该可以看到:

  1. 页面顶部显示标题"💧 喝水模拟器"。
  2. 中间显示"已喝水 / 0 / 8 杯",进度条为空,提示"还差 8 杯"。
  3. 大圆形蓝色按钮显示"🥤 喝一杯"。
  4. 浅蓝色按钮显示"💧 喝一小口"。
  5. 底部红色按钮显示"🔄 重新开始"。

点击"喝一杯"三次后:

  • 数字变为"3 / 8 杯"
  • 进度条填充 37.5%
  • 提示变为"还差 5 杯"

点击七次"喝一小口"(3.5 杯)后再点击一次"喝一杯"(+1 杯),总共达到 4.5 杯 + … 依此类推。

cups 达到 8 时:

  • 提示变为"🎉 已达成今日目标!",文字变为绿色
  • 进度条填满
  • 继续喝水进度条保持在 100%

5.3 常见构建问题

问题 原因 解决
页面白屏 未在 main_pages.json 中注册 检查 src 数组中是否包含 "pages/Index2"
按钮不响应点击 事件绑定语法错误 检查 onClick(() => { ... }) 中是否使用了箭头函数
进度条不更新 @State 装饰器缺失 确认 cups 变量上有 @State 装饰器
百分比宽度无效 父容器未设置宽度 确保外层容器设置了明确的 width

第六章 进阶扩展方向

喝水模拟器的基本版本已经完成,但距离一个完整的"喝水助手"应用还有很大的提升空间。以下是七个值得尝试的扩展方向:

6.1 自定义每日目标

当前目标杯数硬编码为 8 杯。可以添加一个设置界面,让用户根据自身情况(体重、运动量、气候)自定义每日目标:

@State customGoal: number = 8;

// 设置界面中的调整控件
Row() {
  Button('−')
    .onClick(() => { if (this.customGoal > 1) this.customGoal--; });
  Text(`${this.customGoal}`);
  Button('+')
    .onClick(() => { if (this.customGoal < 20) this.customGoal++; });
}

6.2 历史记录与持久化

当前版本每次关闭应用后数据都会丢失。使用鸿蒙的 Preferences API 可以将数据持久化到本地存储:

import { preferences } from '@kit.ArkData';

let dataMgr: preferences.Preferences;

// 保存数据
await dataMgr.put('todayCups', this.cups);
await dataMgr.flush();

// 读取数据
const val = await dataMgr.get('todayCups', 0);
this.cups = val as number;

配合日期判断,可以实现自动区分每天的饮水记录。

6.3 定时提醒

使用鸿蒙的 WorkSchedulerExtensionAbilityAlarmManager 实现在后台定时发送通知,提醒用户喝水:

import { notificationManager } from '@kit.NotificationKit';

// 发送通知
let publishRequest = {
  content: {
    contentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
    text: '该喝水了!💧 今天已经喝了 3 杯,再接再厉!',
    title: '喝水提醒',
  },
  id: 1,
};
notificationManager.publish(publishRequest);

6.4 动画效果

为"喝一杯"操作添加动画,让交互反馈更加生动:

@State animateCups: number = 0;
@State btnScale: number = 1.0;

// 点击时按钮缩放动画
onClick(() => {
  this.cups++;
  animateTo({ duration: 200, curve: Curve.EaseOut }, () => {
    this.btnScale = 0.9;
  });
  animateTo({ duration: 200, curve: Curve.EaseOut }, () => {
    this.btnScale = 1.0;
  });
})
.scale(this.btnScale)

6.5 多种饮品选择

除了白水,还可以添加茶、咖啡、牛奶等选项,不同饮品用不同颜色区分:

interface Drink {
  name: string;
  emoji: string;
  color: string;
  amount: number;  // ml
}

readonly drinks: Drink[] = [
  { name: '白水', emoji: '💧', color: '#1890ff', amount: 250 },
  { name: '茶', emoji: '🍵', color: '#a0522d', amount: 200 },
  { name: '咖啡', emoji: '☕', color: '#8b4513', amount: 200 },
];

6.6 日/周统计图表

使用 ArkTS 的 Canvas 组件绘制简单的柱状图,展示过去一周的饮水趋势:

Canvas(this.context)
  .width('100%')
  .height(200)
  .onReady(() => {
    // 绘制柱状图
    const ctx = this.context.getContext('2d');
    const data = [5, 7, 6, 8, 4, 9, 7]; // 一周数据
    data.forEach((val, idx) => {
      ctx.fillStyle = '#1890ff';
      ctx.fillRect(20 + idx * 50, 180 - val * 20, 30, val * 20);
    });
  });

6.7 通知栏快捷操作

利用鸿蒙的 ServiceWidget(服务卡片)能力,在桌面添加一个喝水小卡片,用户无需打开应用即可记录喝水:

┌─────────┐
│  💧 3/8  │
│ [ +1杯 ] │
└─────────┘

这在 ArkTS 中通过 @Entry + @Component + formEntity 的方式实现,需要了解鸿蒙的卡片开发框架。


第七章 ArkTS 开发最佳实践总结

通过这个喝水模拟器的开发,我们总结出以下 ArkTS 开发的最佳实践:

7.1 状态管理原则

最小化状态原则:只把真正会变化且影响 UI 的数据声明为 @State。常量用 readonly,计算值在 build() 中动态计算。这样可以保持状态模型的简洁性,减少不必要的性能开销。

在我们喝水模拟器中,cups 是唯一需要 @State 装饰的变量,因为只有它会在运行时变化。而 goalreadonly 声明,因为目标值是固定不变的。如果未来版本支持用户自定义目标,则需要将 goal 改为 @State 变量并提供修改入口。

单一数据源原则:每个数据只在一个地方声明。例如 cups 变量是唯一的"已喝杯数"数据源,所有 UI 节点都从这个变量派生出自己的展示内容,而不是各自维护一份副本。

  • 进度条从 cups 计算宽度
  • 提示文本从 cups 判断是否达标
  • 数字显示直接从 cups 取值

如果每个 UI 节点各自维护一份数据副本,就会导致状态分散、数据不一致的问题。单一数据源彻底避免了这类 Bug。

状态提升原则:当多个子组件需要共享同一份状态时,将状态提升到它们最近的共同父组件中,通过 @Prop@Link 向下传递。虽然喝水模拟器只有一个组件不涉及跨组件通信,但这一原则在大型应用中至关重要。

7.2 布局原则

百分比优先:尽量使用百分比宽度('50%')和 layoutWeight 进行布局,而不是硬编码像素值。这样可以确保应用在不同尺寸的屏幕上都能正确显示。

常见的布局适配写法对比:

// 硬编码 —— 不推荐,在小屏设备上会溢出
.width(300)

// 百分比 —— 推荐,自动适配屏幕宽度
.width('80%')

// layoutWeight —— 推荐,按比例分配父容器空间
.layoutWeight(1)

层次清晰:使用 marginpadding 保持组件之间的间距一致。每个区块使用不同的背景色(#f5f5f5#fafafa 等)建立视觉层次。但注意不要过度使用背景色,保持界面清爽。

呼吸空间:不要让 UI 元素挤在一起。在标题周围留出足够的上边距(如 margin({ top: 30 })),在区块之间使用明确的间距(如 margin({ bottom: 20 }))。适当的"留白"能显著提升 UI 的品质感。

7.3 可读性原则

命名规范

  • 使用语义化的变量名(cups 而非 cgoal 而非 g)。
  • 布尔类型变量用 ishas 前缀(如 isCompletehasError)。
  • 组件名首字母大写(Index2 而非 index2)。

注释规范

  • 为复杂的表达式添加注释,说明计算逻辑。
  • 为每个区块添加区块注释,说明该区域的功能(如 // 标题// 进度条)。
  • 避免为显而易见的代码加注释(如 let x = 0; // 设置 x 为 0 这种注释没有价值)。

代码组织

  • 使用空行分隔不同的 UI 区块,使 build() 方法的视觉结构与实际界面结构对应。
  • 按照"从上到下、从左到右"的顺序排列 UI 组件,与界面视觉顺序一致。
  • 事件处理方法(onClick)写在 build() 之外,避免 build() 方法过于臃肿。

7.4 性能注意事项

避免在 build() 中执行耗时操作build() 方法可能会被频繁调用(每次状态变化时),在这里执行网络请求、大循环、文件 I/O 等操作会严重拖慢 UI 刷新速度。将耗时操作放在事件处理函数或异步任务中执行。

合理使用 @State:每个 @State 变量都会带来变更检测的开销。虽然单个变量的影响微乎其微,但在大型页面中应避免过度使用。能用 readonly 和局部变量的场景就不要用 @State

ForEach 的键值生成器:在使用 ForEach 渲染列表时,提供稳定的键值生成器(第三个参数)可以显著提升列表更新性能。键值帮助框架识别哪些元素被添加、删除或移动,从而只更新变化的元素而不是重建整个列表。

使用 LazyForEach 替代 ForEach:对于超过 100 项的长列表,使用 LazyForEach 实现懒加载,只渲染当前可见区域内的列表项。这在聊天记录、新闻列表等场景中尤为重要。


第八章 常见问题与调试技巧

Q1: 编译报错 “Cannot find module ‘xxx’”

检查 main_pages.json 中的路径是否与文件实际路径一致。路径区分大小写,注意 pages/Index2 中的 I 大写。

Q2: 应用运行时白屏,没有报错

最常见的原因是路由配置错误。检查:

  1. main_pages.json 中是否包含 "pages/Index2"
  2. EntryAbility 中 loadContent 的页面名是否匹配。

Q3: 进度条没有按预期显示

在调试窗口打印关键数值,确认计算是否正确:

// 临时调试代码
Text(`调试: cups=${this.cups}, goal=${this.goal}, 宽度=${(this.cups / this.goal) * 100}%`)
  .fontSize(10)
  .fontColor('#f00');

Q4: 按钮点击后 UI 无变化

检查:

  1. @State 装饰器是否遗漏。
  2. onClick 中是否使用了箭头函数而非普通函数。
  3. 变量名是否拼写正确(this.cups 而非 this.cup)。

Q5: 状态在页面切换后丢失

@State 变量的生命周期与组件绑定。页面销毁后,@State 中的数据也会丢失。如果需要跨页面保持数据,应使用:

  • AppStorage:应用级全局存储。
  • LocalStorage:页面级共享存储。
  • PersistentStorage:持久化存储(存到磁盘)。
// 使用 AppStorage 实现全局状态
@State @StorageLink('cups') cups: number = 0;

Q6: 如何在猜拳游戏和喝水模拟器两个页面之间跳转?

鸿蒙 NEXT 提供了 router 模块实现页面路由。首先在猜拳游戏(Index.ets)中添加一个导航按钮:

// 在 Index.ets 的 build() 方法中添加
import { router } from '@kit.AbilityKit';

Button('🚰 喝水模拟器')
  .width('50%')
  .margin({ top: 16 })
  .backgroundColor('#1890ff')
  .borderRadius(20)
  .onClick(() => {
    router.pushUrl({ url: 'pages/Index2' });
  });

这样用户就可以在猜拳游戏页面点击按钮跳转到喝水模拟器。返回时系统会自动启用系统的返回按钮,也可以通过 router.back() 方法编程式返回。


结语

本文通过实现一个简洁的喝水模拟器,系统地介绍了 HarmonyOS NEXT 6.1.1(API 24)环境下 ArkTS 应用开发的完整流程。我们从需求分析出发,经历了 UI 设计、状态管理设计、代码编写、构建验证到扩展方向探讨的完整周期,覆盖了从概念到实践的每一个环节。

喝水模拟器虽然只有不到 100 行代码,但它麻雀虽小五脏俱全,覆盖了 ArkTS 开发中最核心的知识点:

  • @State 响应式状态管理 —— ArkTS 声明式 UI 的基石,驱动整个应用的数据流
  • Column / Row 布局容器 —— 构建 UI 的基本积木,灵活组合实现各种布局
  • Button 交互组件 —— 用户输入的主要入口,支持文本和自定义子组件两种模式
  • Text 动态内容展示 —— 数据到视图的映射,模板字符串让数字和文字无缝融合
  • 三元运算符条件渲染 —— 在表达式中实现 UI 分支,简洁高效
  • 百分比宽度与动态样式 —— 实现自适应布局的关键技术
  • 事件处理与闭包捕获 —— 交互逻辑的绑定方式,箭头函数确保 this 上下文正确

这些知识点是 ArkTS 开发的核心基础。无论你未来构建的是简单的工具类应用,还是复杂的企业级应用,这些概念都会反复出现。扎实掌握它们,能够让你在后续的学习中事半功倍。

此外,本文还特别强调了两个常常被初学者忽视但却非常重要的方面:

第一是状态管理的深度理解。很多初学者只知道 @State 能让 UI 更新,但不理解背后的依赖图追踪和批处理机制。理解这些原理能够帮助你在遇到性能问题或诡异 Bug 时快速定位根因。

第二是代码质量与最佳实践。变量命名、注释规范、布局原则、性能考量——这些"软实力"决定了一个应用代码的可维护性和团队协作效率。从第一个项目开始就养成良好的习惯,会受益终身。

在本文的扩展方向部分,我们探讨了持久化存储、定时通知、服务卡片、动画效果、统计图表、多饮品选择等进阶话题。每一个方向都代表了 ArkTS 开发中的一个重要领域,值得深入学习和实践。建议读者可以挑选一两个自己感兴趣的方向,动手尝试实现,将知识转化为真正的能力。

HarmonyOS NEXT 作为一个新兴的操作系统生态,正在以惊人的速度成长。截至本文撰写时,鸿蒙生态设备数量已经突破十亿,越来越多的应用正在加入这个生态。作为开发者,尽早投入这个生态,不仅能够获得技术上的先发优势,更能参与到中国自主操作系统生态的建设中,这是一件既有技术价值又有时代意义的事情。

希望本文能够成为你鸿蒙开发旅程中的一块铺路石。从猜拳游戏到喝水模拟器,你已经掌握了 ArkTS 开发的核心技能。下一步,不妨尝试将两者结合起来——在猜拳游戏的主页面增加一个导航入口指向喝水模拟器,打造属于你自己的"鸿蒙工具箱"。

Happy coding,记得多喝水!💧


本文基于 HarmonyOS NEXT 6.1.1(API 24)和 DevEco Studio 5.0.3 编写。完整源码位于项目目录 entry/src/main/ets/pages/Index2.ets

Logo

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

更多推荐