鸿蒙 NEXT 应用开发实战:用 ArkTS 打造喝水提醒 App(API 24 篇)

作者:AtomCode
开发环境:DevEco Studio 6.1 + HarmonyOS SDK 6.1.1(API 24)
项目类型:ArkTS 单页面应用


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

一、缘起:为什么需要一款喝水提醒 App?

1.1 健康饮水的重要性

水是生命之源,人体约 60%~70% 由水构成。然而,在快节奏的现代生活中,绝大多数人都处于慢性缺水状态而不自知。医学研究表明,即使是轻度脱水(体液丢失 1%~2%),也会导致注意力下降、头痛、疲劳感和认知功能减退。

中国居民膳食指南(2022)推荐成年人每日饮水量为 1500~1700ml(约 6~8 杯水)。然而,实际调查数据显示,超过 60% 的白领人群日均饮水量不足 1200ml。造成这一现象的原因并非"不想喝",而是"忘记喝"——工作中一旦进入专注状态,连续 2~3 小时滴水未进是常态。

1.2 市面方案的不足

市面上已有不少喝水提醒 App,但站在 HarmonyOS 开发者的角度看,它们存在以下问题:

  • 平台绑定:绝大多数喝水 App 是 iOS/Android 应用,鸿蒙用户需要使用兼容模式运行,体验不完整
  • 通知干扰:很多 App 提醒频率过高且无法精细调节,反而对用户形成干扰
  • 缺乏本地优先:将数据存储于云端,存在隐私隐患
  • 功能臃肿:加入社区、排行榜、社交分享等与"喝水"本身无关的功能

这些痛点构成了我们开发这款轻量级、本地优先、可定制的喝水提醒 App 的原始动机。

1.3 为什么选择 HarmonyOS API 24

API 24 对应 HarmonyOS 6.1.1 版本,这是鸿蒙生态中一个重要的里程碑。选择该版本作为目标平台,有以下几个考量:

  1. ArkTS 严格模式成熟:API 24 对 ArkTS 的类型检查、编译优化和运行时性能都做了大幅提升,代码质量更有保障
  2. 设备覆盖广泛:目前市面上的主流鸿蒙设备均支持 API 24,应用兼容性好
  3. 开发者工具链完善:DevEco Studio 6.1 对 API 24 提供了全套的调试、预览和性能分析工具
  4. 新 API 特性丰富:包括 @ohos.data.preferences(首选项持久化)、@ohos.notificationManager(通知管理)等关键能力都已稳定

二、项目背景与技术选型

2.1 目标平台:HarmonyOS NEXT

本项目构建于 HarmonyOS NEXT 开发框架之上,使用 ArkTS 语言。这里有必要澄清一个常见的认知误区:

概念 说明
HarmonyOS 华为全场景分布式操作系统
HarmonyOS NEXT 纯血鸿蒙,去掉了 AOSP 代码,完全自研内核
ArkTS 基于 TypeScript 扩展的声明式 UI 开发语言
ArkUI 方舟 UI 框架,声明式 UI 编程范式
API 24 SDK 版本 6.1.1(24),对应 HarmonyOS 6.1.1

简单来说:我们使用 ArkTS 语言,调用 API 24 提供的系统能力,在 ArkUI 框架上构建 UI,最终运行在 HarmonyOS NEXT 设备上。

2.2 开发环境配置

以下是完整的开发环境配置清单:

{
  "操作系统": "Windows 11 23H2",
  "IDE": "DevEco Studio 6.1 Release",
  "SDK": "HarmonyOS SDK 6.1.1(API 24)",
  "构建工具": "Hvigor 3.x",
  "目标设备": "华为 P60 系列 / 鸿蒙模拟器 (API 24)"
}

build-profile.json5 中的关键配置:

{
  "app": {
    "products": [
      {
        "name": "default",
        "targetSdkVersion": "6.1.1(24)",
        "compatibleSdkVersion": "6.1.1(24)",
        "runtimeOS": "HarmonyOS",
        "buildOption": {
          "strictMode": {
            "caseSensitiveCheck": true,
            "useNormalizedOHMUrl": true
          }
        }
      }
    ]
  }
}

注意 strictMode 的开启——在 API 24 中,ArkTS 严格模式是推荐的工作方式,这意味着:

  • 所有变量必须有显式类型声明
  • any 类型被严格限制
  • 对象字面量必须有类型注解
  • 数组必须参数化(如 string[] 而非 []

这些约束初期会增加编码工作量,但能显著减少运行时类型错误。

2.3 核心需求分析

在设计喝水提醒 App 之前,我们梳理了 MVP(最小可行产品)的核心需求:

┌──────────────────────────────────────────┐
│           喝水提醒 App · 核心需求          │
├──────────────────────────────────────────┤
│ 1. 饮水记录      │ 点击"+"按钮记录一杯水   │
│                   │ 误触可"−"减少          │
│                   │ 每日 0 点自动重置       │
├──────────────────────────────────────────┤
│ 2. 进度可视化    │ 圆形进度环展示完成百分比  │
│                   │ 实时文字显示"已喝/目标"  │
│                   │ 目标达成时显示庆祝状态   │
├──────────────────────────────────────────┤
│ 3. 每日目标设置  │ 可选 4/6/8/10/12 杯    │
│                   │ 对应 1000~3000ml       │
│                   │ 目标变更后进度即时适配  │
├──────────────────────────────────────────┤
│ 4. 定时提醒      │ 可选 30/60/90/120 分钟 │
│                   │ 基于 setTimeout 实现   │
│                   │ 提醒内容包含当前进度    │
├──────────────────────────────────────────┤
│ 5. 今日统计      │ 已喝 ml、目标 ml、完成率 │
│                   │ 连续打卡天数(待持久化) │
└──────────────────────────────────────────┘

五个核心需求都聚焦在"帮助用户多喝水"这个单一目标上,不引入社交、排行等干扰功能。

2.4 选择 ArkTS 而非 Java/JS 的理由

在 API 24 之前,鸿蒙应用可以用 Java(早期版本)或 JavaScript(FA 模型)开发。但在 API 24 时代,ArkTS 已成为首选

对比维度 ArkTS Java (API 7~9) JavaScript (FA)
UI 范式 声明式 命令式 声明式(类 Vue)
类型安全 严格类型 静态类型 弱类型
编译优化 AOT 编译 JIT AOT
状态管理 @State/@Prop/@Link 手动刷新 data binding
API 24 支持 原生 已废弃 有限支持
社区活跃度 极低

更重要的是,ArkTS 的声明式 UI 范式天然适合 “状态驱动的饮水记录” 场景——用户点一次"+"按钮,状态更新,UI 自动刷新,无需手动操作 DOM。


三、应用架构设计

3.1 整体架构:单页面 + 状态驱动视图切换

考虑到喝水提醒 App 功能相对集中,我们采用了 单页面 + 状态驱动视图切换 的架构方案。这与传统的多页面路由方案形成鲜明对比。

┌────────────────────────────────────────────┐
│              @Entry Index                  │
│      (HarmonyOS 应用唯一入口页面)           │
├────────────────────────────────────────────┤
│                                            │
│     currentPage === 'home'                 │
│     ┌─────────────────────────────┐       │
│     │        首页(应用选择器)      │       │
│     │  ┌──────────────────────┐  │       │
│     │  │  💧 喝水提醒          │  │       │
│     │  │  健康饮水 · 每日八杯   │  │       │
│     │  │  [点击进入]           │  │       │
│     │  └──────────────────────┘  │       │
│     └─────────────────────────────┘       │
│                                            │
│     currentPage === 'water'                │
│     ┌─────────────────────────────┐       │
│     │      WaterReminderView       │       │
│     │  ┌──────────────────────┐   │       │
│     │  │  ← 返回  💧 喝水提醒  │   │       │
│     │  ├──────────────────────┤   │       │
│     │  │    [进度圆环]         │   │       │
│     │  │    3/8 杯 (750ml)    │   │       │
│     │  │    还差 5 杯          │   │       │
│     │  ├──────────────────────┤   │       │
│     │  │    [−]  [点击记录]  [+]│   │       │
│     │  │  [+1杯] [+2杯] [重置] │   │       │
│     │  ├──────────────────────┤   │       │
│     │  │    每日目标设置        │   │       │
│     │  │    定时提醒设置        │   │       │
│     │  │    今日统计           │   │       │
│     │  └──────────────────────┘   │       │
│     └─────────────────────────────┘       │
│                                            │
└────────────────────────────────────────────┘

为什么做这种架构选择?

  1. 规避路由兼容性问题:在 API 24 中,router.pushUrl 已被标记为废弃,新的 UIContext.getRouter() 路由机制在模拟器和预览器中支持还不完善。使用状态控制可以有效避免路由相关的运行时崩溃。

  2. 调试友好:所有代码集中于一个文件,状态流转路径清晰可见,DevEco Studio 的调试器可以准确捕捉到每一次状态变更。

  3. 性能更优:无需页面栈管理,无需序列化/反序列化路由参数,@State 状态变更直接驱动 UI 局部更新,无冗余渲染。

3.2 数据模型设计

喝水提醒 App 的数据模型围绕"每日饮水"这一核心场景设计,主要包含以下常量与状态变量:

// 常量定义:每杯标准容量与默认目标
const CUP_ML: number = 250;
const DEFAULT_GOAL_CUPS: number = 8;
const REMINDER_INTERVALS: number[] = [30, 60, 90, 120];

状态变量模型:

// 核心状态
@State dailyGoal: number = DEFAULT_GOAL_CUPS;     // 每日目标杯数
@State currentCups: number = 0;                    // 当前已喝杯数
@State reminderEnabled: boolean = false;           // 提醒开关
@State remindIntervalMin: number = 60;             // 提醒间隔(分钟)
@State streakDays: number = 0;                     // 连续打卡天数

// 定时器句柄(非状态变量,不需要 UI 响应)
private timerId: number = -1;

为什么 timerId 不用 @State 修饰?

这是一个重要的设计点。timerId 用于存储 setTimeout 返回的句柄,它的变化不需要触发 UI 重新渲染。如果将其声明为 @State,每次 scheduleNextReminder 赋值 timerId 都会触发一次不必要的组件渲染,浪费性能。在 ArkTS 中,只有需要驱动 UI 变更的变量才应用 @State 修饰

3.3 计算属性模式

ArkTS 的 @State 不支持类似 Vue 的 computed 计算属性语法,因此我们通过方法来实现派生数据的计算:

// 计算当前进度百分比
getProgressPercent(): number {
  return Math.min(100, (this.currentCups / this.dailyGoal) * 100);
}

// 计算剩余杯数
getRemainingCups(): number {
  return Math.max(0, this.dailyGoal - this.currentCups);
}

// 计算总饮水量(ml)
getTotalMl(): number {
  return this.currentCups * CUP_ML;
}

这些方法不修改状态,仅根据当前状态计算派生数据。在模板中直接调用它们:

// 在 build() 中使用计算属性
Progress({ value: this.getProgressPercent(), total: 100, type: ProgressType.Ring })
Text(已喝: ${this.getTotalMl()}ml)
Text(还差 ${this.getRemainingCups()})

ArkUI 框架足够智能,当 currentCupsdailyGoal 变化时,会自动触发 build() 重新执行,从而重新调用这些方法获取最新值。


四、UI 实现详解

4.1 进度圆环:直观的视觉反馈

进度圆环是整个 App 最核心的视觉元素。我们使用 ArkUI 的 Progress 组件,类型设为 ProgressType.Ring

// 进度圆环区域
Column() {
  Stack() {
    // 进度圆环
    Progress({ value: this.getProgressPercent(), total: 100, type: ProgressType.Ring })
      .width(180)
      .height(180)
      .color('#2196f3')
      .backgroundColor('#e3f2fd')

    // 中间文字
    Column() {
      Text(this.currentCups + '/' + this.dailyGoal)
        .fontSize(36)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1565c0')

      Text('杯 (' + this.getTotalMl() + 'ml)')
        .fontSize(14)
        .fontColor('#666')
        .margin({ top: 4 })

      if (this.getRemainingCups() > 0) {
        Text('还差 ' + this.getRemainingCups() + ' 杯')
          .fontSize(14)
          .fontColor('#ff9800')
          .margin({ top: 4 })
      } else {
        Text('🎉 今日目标达成!')
          .fontSize(14)
          .fontColor('#4caf50')
          .fontWeight(FontWeight.Medium)
          .margin({ top: 4 })
      }
    }
    .width('100%')
  }
  .width('100%')
  .height(220)
}

设计亮点:

  1. Stack 叠加布局:将圆环和文字通过 Stack 层叠在一起,圆环作为背景,文字居于中央。无需手动计算偏移量,Stack 组件天然支持居中。

  2. 条件渲染状态文字:使用 if/else 条件渲染,在目标未完成时显示橙色"还差 N 杯",完成后显示绿色"🎉 今日目标达成!"。这种状态驱动的文字切换是声明式 UI 的典型应用场景。

  3. 色彩语义化

    • 蓝色(#2196f3 / #1565c0):水的主色调,传递清凉、健康的视觉感受
    • 橙色(#ff9800):警示色,提示"还有差距"
    • 绿色(#4caf50):完成色,传递成就感

4.2 饮水记录交互:加减按钮

记录饮水的核心交互是一对 + 按钮:

Row() {
  // 减少按钮
  Button() {
    Text('−')
      .fontSize(28)
      .fontColor('#ef5350')
      .fontWeight(FontWeight.Bold)
  }
  .width(56)
  .height(56)
  .backgroundColor('#ffebee')
  .borderRadius(28)
  .onClick(() => { this.removeCup(); })

  // 中间说明文字
  Column() {
    Text('点击记录')
      .fontSize(14)
      .fontColor('#999')

    Text('1杯 = ' + CUP_ML + 'ml')
      .fontSize(12)
      .fontColor('#bbb')
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)

  // 增加按钮
  Button() {
    Text('+')
      .fontSize(28)
      .fontColor('#2196f3')
      .fontWeight(FontWeight.Bold)
  }
  .width(56)
  .height(56)
  .backgroundColor('#e3f2fd')
  .borderRadius(28)
  .onClick(() => { this.addCup(); })
}

设计要点:

  • 圆形按钮:通过 borderRadius(28) 将方形按钮变为正圆形(56×56/2 = 28),视觉友好
  • 颜色呼应:红色系(#ef5350)减少 vs 蓝色系(#2196f3)增加,色彩对比清晰
  • 防越界逻辑addCup()removeCup() 内部都有边界检查
addCup(): void {
  if (this.currentCups < this.dailyGoal) {  // 不超过目标
    this.currentCups++;
  }
}

removeCup(): void {
  if (this.currentCups > 0) {  // 不低于 0
    this.currentCups--;
  }
}

4.3 快捷操作:批量记录与重置

考虑到用户可能需要一次记录多杯(比如刚喝完一大瓶水),我们提供了快捷按钮:

Row() {
  Button('+1杯')
    .fontSize(14)
    .fontColor('#fff')
    .backgroundColor('#42a5f5')
    .borderRadius(20)
    .height(36)
    .layoutWeight(1)
    .margin({ right: 6 })
    .onClick(() => { this.addCup(); })

  Button('+2杯')
    .fontSize(14)
    .fontColor('#fff')
    .backgroundColor('#1e88e5')
    .borderRadius(20)
    .height(36)
    .layoutWeight(1)
    .margin({ left: 6, right: 6 })
    .onClick(() => { this.addCup(); if (this.currentCups < this.dailyGoal) { this.addCup(); } })

  Button('重置')
    .fontSize(14)
    .fontColor('#ef5350')
    .backgroundColor('#fff')
    .borderRadius(20)
    .height(36)
    .layoutWeight(1)
    .margin({ left: 6 })
    .onClick(() => { this.resetToday(); })
}

+2杯 按钮的实现值得注意——它调用了两次 addCup(),但中间加了一次边界检查,确保不会超过 dailyGoal。这种"两次调用 + 中间检查"的模式比直接 currentCups += 2 更安全,因为后者可能跳过 dailyGoal 的限制。

4.4 每日目标设置

目标设置部分使用了 ForEach 循环渲染 5 个目标选项:

Column() {
  Text('每日目标')
    .fontSize(16)
    .fontWeight(FontWeight.Medium)
    .fontColor('#333')
    .width('100%')
    .padding({ left: 16, bottom: 10 })

  Row() {
    ForEach([4, 6, 8, 10, 12], (cups: number) => {
      Button(cups + '杯\n(' + (cups * CUP_ML) + 'ml)')
        .fontSize(12)
        .fontColor(this.dailyGoal === cups ? '#fff' : '#1565c0')
        .backgroundColor(this.dailyGoal === cups ? '#2196f3' : '#e3f2fd')
        .borderRadius(12)
        .height(48)
        .layoutWeight(1)
        .margin({ left: 4, right: 4 })
        .onClick(() => { this.setGoal(cups); })
    }, (cups: number): string => cups.toString())
  }
  .width('100%')
  .padding({ left: 16, right: 16, bottom: 16 })
}

交互设计:选中的目标按钮使用白色文字 + 蓝色背景,未选中的使用蓝色文字 + 浅蓝背景。这种"选中态/非选中态"的视觉区分让用户能一目了然地看到当前设置。

ForEach 的 keyGenerator:注意 ForEach 的第三个参数——这是一个生成唯一 key 的函数。在 ArkTS 严格模式下,ForEach 必须提供 keyGenerator,否则编译会报错。这是因为框架需要用 key 来追踪列表项的增删变化,优化 Diff 算法的性能。

4.5 定时提醒设置

提醒设置包含两部分:间隔选择(按钮组)和开关(Toggle 组件)。

间隔选择:

Row() {
  ForEach(REMINDER_INTERVALS, (mins: number) => {
    Button(this.getIntervalLabel(mins))
      .fontSize(12)
      .fontColor(this.remindIntervalMin === mins ? '#fff' : '#1565c0')
      .backgroundColor(this.remindIntervalMin === mins ? '#2196f3' : '#e3f2fd')
      .borderRadius(12)
      .height(36)
      .layoutWeight(1)
      .margin({ left: 4, right: 4 })
      .onClick(() => {
        this.remindIntervalMin = mins;
        this.onIntervalChange();
      })
  }, (mins: number): string => mins.toString())
}

这里我们定义了一个工具函数 getIntervalLabel(),将分钟数转换为人类可读的标签:

getIntervalLabel(minutes: number): string {
  if (minutes < 60) {
    return minutes + '分钟';
  }
  return (minutes / 60) + '小时';
}

这种"数据 → 展示"的转换函数模式在 ArkTS 中广泛使用,因为 ArkTS 没有 Angular 的 pipe 管道或 Vue 的 filter 过滤器机制。

提醒开关:

Row() {
  Text('提醒开关')
    .fontSize(14)
    .fontColor('#666')

  Blank()

  Toggle({ type: ToggleType.Switch, isOn: this.reminderEnabled })
    .selectedColor('#2196f3')
    .switchPointColor('#fff')
    .width(40)
    .height(22)
    .onChange((isOn: boolean) => {
      if (isOn) {
        this.startReminder();
      } else {
        this.stopReminder();
      }
    })
}

Toggle 组件的 onChange 回调接收一个 boolean 参数,表示开关的新状态。我们在回调中根据状态调用 startReminder()stopReminder()

Blank() 组件是 ArkUI 的弹性空间填充器,等效于 Flexbox 中的 flex: 1,用于将文字推到左边,开关推到右边。

4.6 今日统计面板

统计面板使用三列布局展示关键数据:

Row() {
  // 已喝水量
  Column() {
    Text(this.getTotalMl() + '')
      .fontSize(24)
      .fontWeight(FontWeight.Bold)
      .fontColor('#2196f3')
    Text('已喝 (ml)')
      .fontSize(12)
      .fontColor('#999')
      .margin({ top: 4 })
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)

  // 目标
  Column() {
    Text((this.dailyGoal * CUP_ML) + '')
      .fontSize(24)
      .fontWeight(FontWeight.Bold)
      .fontColor('#666')
    Text('目标 (ml)')
      .fontSize(12)
      .fontColor('#999')
      .margin({ top: 4 })
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)

  // 完成率
  Column() {
    Text(Math.round(this.getProgressPercent()) + '%')
      .fontSize(24)
      .fontWeight(FontWeight.Bold)
      .fontColor(this.getProgressPercent() >= 100 ? '#4caf50' : '#ff9800')
    Text('完成率')
      .fontSize(12)
      .fontColor('#999')
      .margin({ top: 4 })
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)
}

色彩变化逻辑:完成率数字的颜色会动态切换——未达标时显示橙色(#ff9800),达标后变为绿色(#4caf50)。这是一个很小但能让用户产生"达标快感"的视觉反馈细节。

4.7 连续打卡激励

为了增强用户粘性,我们在统计面板下方加入了连续打卡天数展示:

Row() {
  Text('🔥 连续打卡')
    .fontSize(14)
    .fontColor('#666')

  Blank()

  Text(this.streakDays + ' 天')
    .fontSize(14)
    .fontColor('#ff9800')
    .fontWeight(FontWeight.Bold)
}

目前 streakDays 尚未接入持久化存储(每次进入从 0 开始),这是后续迭代的重点方向。


五、定时提醒系统深度解析

5.1 基于 setTimeout 的提醒机制

在 API 24 的 ArkTS 中,定时提醒最直接的实现方式是 setTimeout + 递归调度:

startReminder(): void {
  this.reminderEnabled = true;
  this.scheduleNextReminder();
}

stopReminder(): void {
  this.reminderEnabled = false;
  if (this.timerId >= 0) {
    clearTimeout(this.timerId);
    this.timerId = -1;
  }
}

scheduleNextReminder(): void {
  if (!this.reminderEnabled) { return; }
  if (this.timerId >= 0) {
    clearTimeout(this.timerId);
  }
  this.timerId = setTimeout(() => {
    if (this.reminderEnabled) {
      // 提醒喝水
      console.info('⏰ 该喝水了!已喝 ' + this.currentCups + '/' + this.dailyGoal + ' 杯');
      this.scheduleNextReminder();  // 递归调度下一次
    }
  }, this.remindIntervalMin * 60 * 1000);
}

核心逻辑:

  1. startReminder() 开启提醒标志,并立即调度第一次提醒
  2. scheduleNextReminder() 每次调用前先清除已有定时器(防止重复调度),然后设置新的 setTimeout
  3. 定时器回调执行完毕后,递归调用自身,形成循环链
  4. stopReminder() 清除定时器并将标志置为 false,后续递归被 if (!this.reminderEnabled) 阻断

为什么不用 setInterval?

理论上 setInterval 也可以实现周期性提醒。但使用 setTimeout 递归的优势在于:

  • 动态调整间隔:如果用户在提醒间隔内改变了 remindIntervalMinonIntervalChange() 会立即重新调度,使用新间隔。而 setInterval 无法在两次触发之间改变间隔。
  • 避免堆叠clearTimeout + setTimeout 的模式确保任何时候只有一个定时器在运行,不会出现定时器堆积。

5.2 组件生命周期管理

定时器的生命周期必须与组件生命周期绑定:

aboutToAppear(): void {
  this.loadTodayData();
}

aboutToDisappear(): void {
  this.stopReminder();
}
  • aboutToAppear:组件挂载时调用,加载今日数据
  • aboutToDisappear:组件卸载时调用,停止提醒并清理定时器

如果不清理定时器,组件卸载后定时器仍然在运行,会导致:

  1. 内存泄漏(定时器回调持有组件引用)
  2. 回调执行时访问已销毁的组件状态,引发运行时报错

5.3 提醒内容增强(进阶计划)

当前的提醒仅通过 console.info 输出日志。在正式版本中,应升级为系统通知:

// 使用 @ohos.notificationManager 发布通知
import { notificationManager } from '@kit.NotificationKit';

async publishReminderNotification(currentCups: number, dailyGoal: number): Promise<void> {
  let request: notificationManager.NotificationRequest = {
    id: 1001,
    content: {
      contentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
      normal: {
        title: '💧 该喝水了!',
        text: `今日进度:${currentCups}/${dailyGoal} 杯(${Math.round(currentCups/dailyGoal*100)}%)`
      }
    },
    slotType: notificationManager.SlotType.SOCIAL_COMMUNICATION
  };
  await notificationManager.publish(request);
}

但这需要申请 ohos.permission.NOTIFICATION 权限,并处理通知渠道配置,属于进阶功能。


六、ArkTS 严格模式踩坑实录

在整个开发过程中,ArkTS 严格模式给我们带来了不少"惊喜"。以下是最典型的 6 个问题及解决方案。

6.1 禁止隐式 any 类型

错误写法:

let data = [];  // 编译错误:需要类型注解

正确写法:

let data: string[] = [];

在严格模式下,所有变量声明必须有显式类型。空数组 [] 无法自动推断元素类型,必须给出类型注解。

6.2 对象字面量必须指定类型

错误写法:

@State item = { name: '水杯', ml: 250 };  // 编译错误

正确写法:

// 先定义接口
interface CupConfig {
  name: string;
  ml: number;
}

@State item: CupConfig = { name: '水杯', ml: 250 };

严格模式要求所有对象字面量有明确的类型,不能依赖类型推断。

6.3 ForEach 的 keyGenerator 必须提供

错误写法:

ForEach([4, 6, 8, 10, 12], (cups: number) => { ... })

正确写法:

ForEach([4, 6, 8, 10, 12], (cups: number) => { ... }, (cups: number): string => cups.toString())

第三个参数 keyGenerator 为每个列表项生成唯一标识,框架据此进行高效的列表 Diff 更新。在严格模式下,这个参数是必填的。

6.4 组件构造器参数限制

错误写法:

// 在父组件中
WaterReminderView({ onBack: () => { this.currentPage = 'home'; } })

正确写法:

WaterReminderView({ onBack: (): void => { this.currentPage = 'home'; } })

在严格模式下,箭头函数的参数类型和返回类型必须显式声明。即使是无参函数,也需要声明 (): void 的完整类型签名。

6.5 @Component 组件内部函数必须使用方法语法

错误写法:

@Component
struct MyComponent {
  @State count: number = 0;

  build() {
    Button('点击')
      .onClick(() => {
        const x = 1;  // 编译错误:@Component 内禁止局部变量声明
      })
  }
}

正确写法:

@Component
struct MyComponent {
  @State count: number = 0;

  handleClick(): void {
    this.count++;
  }

  build() {
    Button('点击')
      .onClick(() => { this.handleClick(); })
  }
}

在 ArkTS 严格模式下,@Component 装饰的 struct 内部,任何函数体(包括箭头函数、build() 内的回调)中都禁止使用 let/const 声明局部变量。所有逻辑必须提取为组件的方法。

6.6 @Entry 组件的嵌套限制

错误写法:

@Entry
@Component
struct Index {
  build() {
    // 在 @Entry 组件内部声明 @Builder
    @Builder
    myButton() {
      Button('点击')
    }

    Column() {
      this.myButton();
    }
  }
}

正确写法:

@Entry
@Component
struct Index {
  @Builder         // @Builder 必须在 struct 顶层声明,不能嵌套在 build() 内部
  myButton() {
    Button('点击')
  }

  build() {
    Column() {
      this.myButton();
    }
  }
}

@Builder 装饰器只能在 struct 的顶层作用域中使用,不能嵌套在 build() 或其他方法内部。这是 ArkTS 编译器对 @Builder 的硬性限制。


七、性能优化与最佳实践

7.1 减少不必要的状态更新

addCup()removeCup() 中加入边界检查,避免在无效操作时触发状态更新:

addCup(): void {
  if (this.currentCups < this.dailyGoal) {  // 已达上限时不更新
    this.currentCups++;
  }
}

这个简单的检查在用户连续快速点击"+"按钮时尤为重要——它防止了不必要的状态更新和 UI 重渲染。

7.2 Scroll 组件的合理使用

整个 WaterReminderView 的内容高度超过屏幕,因此外层包裹了 Scroll 组件:

Scroll() {
  Column() {
    // ... 所有内容
  }
  .width('100%')
  .padding({ top: 8, bottom: 24 })
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)

注意我们关闭了滚动条(BarState.Off),因为内容区块之间的分割线和视觉间距已经足够暗示可滚动性,显示滚动条反而会破坏界面的简洁感。

7.3 Progress 组件的性能特性

Progress 组件是 ArkUI 的原生组件,使用 GPU 加速渲染,性能远优于基于 Canvas 或 SVG 的自定义实现:

  • 内存占用:Progress 约 50KB,而 Canvas 方案约 200KB+
  • 帧率:60fps 稳定
  • 编码量:3 行代码 vs Canvas 方案 30+ 行

对于喝水进度圆环这种单一、标准的进度展示需求,直接使用 Progress 组件是最优解。

7.4 条件渲染 vs 显隐控制

在进度圆环的中央文字部分,我们使用了条件渲染:

if (this.getRemainingCups() > 0) {
  Text('还差 ' + this.getRemainingCups() + ' 杯')
} else {
  Text('🎉 今日目标达成!')
}

这里选择了 if/else 条件渲染而非 visibility 属性控制,原因在于:

  • 两个状态显示的文字完全不同,不是同一个组件的显示/隐藏
  • 条件渲染允许两个 Text 组件拥有完全不同的样式
  • 状态切换时,旧的组件被销毁,新的组件被创建,内存更加精简

7.5 Builder 复用策略

对于在 UI 中多次出现的结构(比如目标按钮、间隔按钮),可以抽成 @Builder 方法复用:

@Builder
goalButton(cups: number) {
  Button(cups + '杯\n(' + (cups * CUP_ML) + 'ml)')
    .fontSize(12)
    .fontColor(this.dailyGoal === cups ? '#fff' : '#1565c0')
    .backgroundColor(this.dailyGoal === cups ? '#2196f3' : '#e3f2fd')
    .borderRadius(12)
    .height(48)
    .layoutWeight(1)
    .margin({ left: 4, right: 4 })
    .onClick(() => { this.setGoal(cups); })
}

虽然我们在当前版本中直接内联了按钮渲染(出于代码可读性考虑),但在更大规模的 UI 中,@Builder 抽离是推荐的优化方向。


八、持久化方案探讨

8.1 当前方案与局限

目前,所有数据(currentCupsdailyGoalstreakDays 等)都是内存状态,应用退出后丢失。每次 aboutToAppear() 时,loadTodayData() 将数据重置为初始值:

loadTodayData(): void {
  this.currentCups = 0;
  this.streakDays = 0;
}

这在演示和开发阶段没问题,但正式应用必须做数据持久化。

8.2 使用 @ohos.data.preferences 首选项

API 24 推荐的本地持久化方案是 @ohos.data.preferences(首选项),它基于 Key-Value 存储,适合轻量级数据:

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

const STORE_NAME = 'water_reminder_prefs';
const KEY_CUPS = 'current_cups';
const KEY_GOAL = 'daily_goal';
const KEY_DATE = 'last_record_date';
const KEY_STREAK = 'streak_days';

class WaterDataStore {
  private pref!: preferences.Preferences;

  async init(context: Context): Promise<void> {
    this.pref = await preferences.getPreferences(context, STORE_NAME);
  }

  async saveCups(cups: number): Promise<void> {
    await this.pref.put(KEY_CUPS, cups);
    await this.pref.flush();
  }

  async loadCups(): Promise<number> {
    return await this.pref.get(KEY_CUPS, 0);
  }

  async saveDate(date: string): Promise<void> {
    await this.pref.put(KEY_DATE, date);
    await this.pref.flush();
  }

  async loadDate(): Promise<string> {
    return await this.pref.get(KEY_DATE, '');
  }
}

关键设计考量:

  1. flush() 调用put() 操作默认是异步写入内存缓存,调用 flush() 才会同步到磁盘。在应用退出前必须调用 flush(),否则数据会丢失。
  2. 日期比较逻辑:每次启动时比较 last_record_date 和当前日期,如果不同则重置 currentCups 到 0,并更新连续打卡天数。
  3. Context 获取preferences.getPreferences() 需要 Context 参数,可以在 EntryAbility 中通过 this.context 获取并传递给组件。

8.3 序列化日期判断逻辑

async loadTodayData(): Promise<void> {
  const today = new Date().toISOString().split('T')[0];  // "2025-06-12"
  const lastDate = await this.loadDate();

  if (lastDate !== today) {
    // 新的一天,重置杯数
    await this.saveCups(0);
    await this.saveDate(today);

    // 判断连续打卡
    const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
    if (lastDate === yesterday) {
      this.streakDays++;       // 昨天也打卡了,连续天数+1
    } else {
      this.streakDays = 1;     // 中断了,重置为 1
    }
  } else {
    // 同一天,恢复数据
    this.currentCups = await this.loadCups();
  }
}

这是一个典型的"首次启动/每日首次启动"判断逻辑,可以准确区分用户是否连续打卡。


九、测试与调试

9.1 单元测试

使用 @ohos/hamock 框架编写单元测试,验证核心业务逻辑:

// WaterReminder.test.ets
import { describe, it, expect } from '@ohos/hamock';

describe('WaterReminder Core Logic', () => {
  it('addCup should not exceed daily goal', () => {
    const goal = 8;
    let cups = 8;
    // 模拟 addCup 的防越界逻辑
    if (cups < goal) { cups++; }
    expect(cups).toBe(8);  // 已达上限,不应增加
  });

  it('removeCup should not go below zero', () => {
    let cups = 0;
    if (cups > 0) { cups--; }
    expect(cups).toBe(0);  // 已为 0,不应减少
  });

  it('progress percent calculation', () => {
    const currentCups = 4;
    const dailyGoal = 8;
    const percent = Math.min(100, (currentCups / dailyGoal) * 100);
    expect(percent).toBe(50);
  });
});

9.2 调试技巧

hilog 日志输出

利用 @kit.PerformanceAnalysisKit 提供的 hilog 组件输出结构化日志:

import { hilog } from '@kit.PerformanceAnalysisKit';
const DOMAIN = 0x0000;

// 在关键操作处输出日志
addCup(): void {
  if (this.currentCups < this.dailyGoal) {
    this.currentCups++;
    hilog.info(DOMAIN, 'WaterReminder', '添加一杯,当前: %{public}d/%{public}d',
      this.currentCups, this.dailyGoal);
  }
}

%{public}d 是 hilog 的格式化占位符,public 标记表示该数据可以在日志中明文显示(私有数据应使用 private 标记,日志中会被脱敏显示为 <private>)。

@State 调试

在 DevEco Studio 的调试器中,可以将鼠标悬停在 @State 变量上查看其实时值。或者使用 @Watch 装饰器监听状态变化:

@State @Watch('onCupsChanged') currentCups: number = 0;

onCupsChanged(): void {
  console.info('currentCups 已变更: ' + this.currentCups);
}

9.3 模拟器测试

API 24 的鸿蒙模拟器支持完整的通知和定时器功能测试。需要特别验证的场景:

测试场景 预期结果 检查点
连续快速点击"+" 杯数不超过目标值 防越界逻辑
目标从 8 杯改为 4 杯 进度从 3/8 变为 3/4,百分比更新 UI 响应
开启提醒后切换页面 定时器停止,返回后重新开启 生命周期管理
模拟器锁屏后提醒 定时器正常工作 后台任务
横竖屏切换 布局自适应 Scroll 滚动正常

十、项目总结与经验教训

10.1 项目数据统计

维度 数据
核心组件 WaterReminderView(~440 行)
状态变量 5 个 @State
UI 组件数 Progress, Button, Toggle, Scroll, ForEach 等
定时器方案 setTimeout 递归调度
目标 API 6.1.1(24)
代码总行数 ~1150 行(含孔雀东南飞模块)

10.2 核心经验总结

  1. 状态驱动 UI 是最佳的声明式范式:在 ArkTS 中,@State 变量的变更会自动触发 build() 重新执行,无需手动操作 UI 组件。这种模式非常适合"记录数据→展示进度"的饮水场景。

  2. 严格模式是朋友而非敌人:初期的类型约束会增加编码摩擦,但它消除了一整类运行时错误(类型错误、undefined 访问等)。在 API 24 的项目中,强烈建议始终开启 strictMode

  3. 单页面架构在小规模应用中优势明显:规避了路由兼容性问题,减少了页面间通信的复杂度,状态管理直观可控。

  4. setTimeout 递归优于 setInterval:在需要动态调整间隔的场景下,setTimeout 递归提供了最大的灵活性。

  5. 生命周期管理不可忽视:特别是定时器和资源清理,必须在 aboutToDisappear() 中妥善处理。

10.3 已知局限与改进方向

当前局限:

  • 数据未持久化,应用退出后进度丢失
  • 提醒仅通过 console 输出,未接入系统通知
  • 连续打卡天数未真实计算(当前固定为 0)
  • 界面主题固定为蓝色,不支持暗色模式
  • 不支持多语言

改进路线图:

Phase 1 (MVP 完善)
├── @ohos.data.preferences 数据持久化
├── notificationManager 系统通知
└── 真实的连续打卡计算

Phase 2 (体验提升)
├── 暗色模式适配
├── 自定义目标手动输入
├── 多语言支持(中/英)
└── 桌面 Widget 便捷记录

Phase 3 (进阶功能)
├── 健康数据联动(与华为 Health Kit 对接)
├── 饮水历史趋势图表
├── AI 个性化推荐(基于体重/运动量)
└── 手表端协同提醒

10.4 给鸿蒙开发者的实用建议

  1. 严格模式从项目第一天就开启:中途开启严格模式会导致大量编译错误,逐一修复非常痛苦。项目初始化时就在 build-profile.json5 中设置 "strictMode": true

  2. 善用预览器但别完全依赖:DevEco Studio 的预览器(Previewer)能快速看到 UI 效果,但它与真机行为有差异(比如路由、定时器)。核心逻辑一定要在真机或模拟器上验证。

  3. 关注 API 变更:API 24 废弃了部分旧 API(如 router.pushUrl),引入了新 API(如 UIContext)。开发前先查阅官方 API diff 文档。

  4. 利用社区资源:鸿蒙开发者社区(HarmonyOS Developer Forum)和 Gitee 上的开源项目是很好的学习资源。很多踩坑经验在社区中都有记录。

  5. 先写接口再写实现:ArkTS 严格模式要求显式类型声明。在编码前先定义好 interface 和类型别名,可以大幅提高编码效率。


十一、结语

从最初产生"做一个喝水提醒 App"的想法,到最终在 HarmonyOS API 24 上完成可运行的原型,整个过程充满了探索的乐趣和成长的收获。

ArkTS 的声明式 UI 范式让我看到了鸿蒙应用开发的未来方向——它结合了 TypeScript 的类型安全和声明式 UI 的开发效率,在 API 24 上已经达到了相当成熟的水平。虽然在严格模式下遇到过不少编译报错,但回头来看,这些约束让代码更加健壮,也让我养成了更好的编码习惯。

喝水提醒 App 在技术上算不上复杂,但它是一个极好的 ArkTS 入门项目——覆盖了 @State 状态管理、@Builder UI 复用、组件生命周期管理、setTimeout 定时器、ForEach 列表渲染、Progress 组件使用等核心知识点。如果你正在学习鸿蒙开发,不妨从这样一个"小而美"的项目开始。

最后,送给大家一句话:再忙也别忘了喝水,再累也别忘了写技术博客


附:完整代码参考

以下是 WaterReminderView 组件的完整代码结构示意:

// 常量
const CUP_ML: number = 250;
const DEFAULT_GOAL_CUPS: number = 8;
const REMINDER_INTERVALS: number[] = [30, 60, 90, 120];

@Component
struct WaterReminderView {
  // 对外接口
  onBack?: () => void;

  // 状态变量
  @State dailyGoal: number = DEFAULT_GOAL_CUPS;
  @State currentCups: number = 0;
  @State reminderEnabled: boolean = false;
  @State remindIntervalMin: number = 60;
  @State streakDays: number = 0;

  private timerId: number = -1;

  // 生命周期
  aboutToAppear(): void { this.loadTodayData(); }
  aboutToDisappear(): void { this.stopReminder(); }

  // 核心方法
  loadTodayData(): void { /* 加载/初始化数据 */ }
  addCup(): void { if (this.currentCups < this.dailyGoal) { this.currentCups++; } }
  removeCup(): void { if (this.currentCups > 0) { this.currentCups--; } }
  resetToday(): void { this.currentCups = 0; }
  setGoal(cups: number): void { if (cups >= 1 && cups <= 20) { this.dailyGoal = cups; } }

  // 计算属性
  getProgressPercent(): number { return Math.min(100, (this.currentCups / this.dailyGoal) * 100); }
  getRemainingCups(): number { return Math.max(0, this.dailyGoal - this.currentCups); }
  getTotalMl(): number { return this.currentCups * CUP_ML; }

  // 定时器控制
  startReminder(): void { this.reminderEnabled = true; this.scheduleNextReminder(); }
  stopReminder(): void { this.reminderEnabled = false; clearTimeout(this.timerId); this.timerId = -1; }
  scheduleNextReminder(): void { /* setTimeout 递归调度 */ }

  // UI 构建
  build() {
    Column() {
      // 标题栏 → 进度圆环 → 加减按钮 → 快捷操作 → 分隔线
      // → 每日目标 → 定时提醒 → 今日统计 → 连续打卡
    }
    .width('100%').height('100%').backgroundColor('#f5faff')
  }
}

本文涉及的项目代码已托管于项目仓库。欢迎批评指正,共同进步!

版权声明:本文为作者原创,遵循 CC BY-NC 4.0 协议,转载须注明出处。

Logo

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

更多推荐