HarmonyOS ArkTS 实战:实现一个饮水提醒应用

项目效果

本文实现的是一个基于 HarmonyOS 和 ArkTS 的饮水提醒应用。项目中使用 ArkUI 组件完成页面布局,通过 @State 管理状态数据,实现一键喝水记录、自定义饮水量、每日目标设置、饮水进度、饮水历史、今日统计和提醒设置等功能。

最终运行效果如下:

在这里插入图片描述

页面主要包含以下内容:

  • 顶部应用标题和今日统计;
  • 圆形饮水进度显示;
  • 快捷喝水按钮(200ml/300ml/500ml);
  • 自定义饮水量输入;
  • 每日目标设置;
  • 饮水记录列表;
  • 删除记录功能;
  • 空状态提示;
  • 页面整体采用 ArkUI 声明式布局。

本文重点是演示如何在 HarmonyOS 项目中使用 ArkTS 和 ArkUI 实现一个健康管理类单页面应用。项目代码主要写在 entry/src/main/ets/pages/Index.ets 文件中,适合作为 HarmonyOS ArkTS 入门到进阶之间的练习案例。

前言

水是生命之源,保持充足的饮水对健康至关重要。很多人因为工作忙碌经常忘记喝水,一个简单的饮水提醒应用可以帮助我们养成按时喝水的好习惯,追踪每日饮水量,确保达到健康目标。

从应用开发角度来看,这个项目不依赖后端接口,也不需要数据库,但能练习 ArkTS 中的状态管理、按钮交互、圆形进度、列表渲染、数据统计和数组操作等内容。

本文基于 HarmonyOS 和 ArkTS 实现一个饮水提醒应用。用户可以一键记录喝水量,设置每日饮水目标,查看饮水进度,管理饮水记录,追踪自己的饮水习惯。

这个项目的核心不是简单的数字累加,而是帮助用户养成健康饮水习惯的完整流程。每一次喝水记录、目标调整和进度更新,本质上都是对饮水数据的管理。状态变化后,页面会自动刷新,这正是 ArkUI 声明式开发的基本思想。

一、项目目标

本次实践主要实现以下目标:

  • 创建 HarmonyOS ArkTS 页面;
  • 使用 @Entry@Component 定义页面组件;
  • 使用 @State 管理页面状态;
  • 实现快捷喝水按钮;
  • 支持自定义饮水量;
  • 可设置每日饮水目标;
  • 圆形进度条显示完成进度;
  • 记录每次饮水的时间和量;
  • 统计今日饮水量;
  • 计算完成百分比;
  • 支持删除饮水记录;
  • 使用 ListForEach 渲染饮水记录;
  • 使用空状态提示优化无记录页面;
  • 使用 @Builder 封装统计卡片、快捷按钮和记录项;
  • 完成一个可以运行的饮水提醒页面。

这个项目虽然是单页面应用,但它有完整的记录、统计、进度展示和记录管理流程,比普通静态页面更适合练习 ArkTS。

二、技术栈

类型 内容
开发方向 HarmonyOS 应用开发
开发语言 ArkTS
UI 框架 ArkUI
SDK 版本 HarmonyOS API 23 及以上
工程模型 Stage 模型
核心组件 Text / TextInput / Button / Column / Row / Circle / Stack / List / ForEach
状态管理 @State
数据处理 进度计算 / 数组统计 / 时间格式化
项目入口 entry/src/main/ets/pages/Index.ets
运行平台 模拟器或真机

本项目是 HarmonyOS 原生 ArkTS 项目。页面主体由 ArkUI 组件构建,核心逻辑写在 Index.ets 文件中,不依赖后端接口,也不需要额外配置数据库。

三、为什么选择饮水提醒项目

饮水提醒应用适合作为 ArkTS 练习项目,主要有以下几个原因。

第一,实用性强。健康饮水是每个人的需求,一个简洁的饮水记录工具可以直接应用于日常生活,帮助用户保持健康。

第二,业务场景真实。办公室人群经常忘记喝水,需要这样一个简单的工具来提醒和记录饮水。

第三,适合练习状态管理。饮水量、每日目标、饮水记录都属于页面状态。状态变化后,页面会自动刷新。

第四,适合练习圆形进度。圆形进度条可以直观展示饮水完成情况,视觉效果好。

第五,适合练习快速交互。一键喝水按钮是非常经典的快捷交互形式。

第六,扩展空间比较大。基础功能完成后,可以继续增加本地存储、定时提醒、饮水历史统计、图表分析和健康建议。

在本项目中,records 是核心数据源。页面中的统计数据、进度显示和记录列表都由它计算或渲染得到。

四、功能规则说明

快捷饮水量选项:

按钮 饮水量 适用场景
小杯 200ml 小杯水
中杯 300ml 普通杯子
大杯 500ml 大瓶水

统计规则如下:

今日饮水量 = 今日所有饮水记录的量之和
完成率 = 今日饮水量 / 每日目标 * 100%
剩余量 = 每日目标 - 今日饮水量(最小为0)

默认每日目标为2000ml,用户可以自定义调整。

每次添加或删除饮水记录后,统计数据和进度都会自动更新。

五、项目结构

本项目主要修改首页文件:

entry
└── src
    └── main
        └── ets
            └── pages
                └── Index.ets

其中:

文件 作用
Index.ets 编写页面结构、状态数据和饮水记录逻辑

本文不涉及复杂路由,也不需要额外创建多个页面。对于练习项目来说,把主要逻辑集中在一个 Index.ets 文件中更方便理解。

如果后续继续扩展,可以考虑把进度圆环、快捷按钮、记录项和设置区域拆分成独立组件。

六、核心实现思路

本项目的核心流程如下:

  1. 定义饮水记录数据结构;
  2. 使用 @State 保存每日目标、自定义饮水量和饮水记录;
  3. 用户点击快捷按钮添加对应量的饮水;
  4. 用户也可以输入自定义饮水量;
  5. 使用数组保存所有饮水记录;
  6. 圆形进度条显示今日完成进度;
  7. 统计今日饮水量和完成率;
  8. 使用 ForEach 渲染饮水记录列表;
  9. 点击删除按钮删除指定记录;
  10. 用户可以调整每日目标;
  11. 没有记录时显示空状态提示。

项目中最重要的状态变量如下:

@State dailyGoal: number = 2000
@State customAmount: string = ''
@State today: string = ''
@State records: WaterRecord[] = []
@State nextId: number = 1

其中:

状态变量 作用
dailyGoal 每日饮水目标(ml)
customAmount 自定义饮水量输入
today 今日日期
records 饮水记录数组
nextId 生成记录唯一编号

records 是本项目中最重要的数据。所有进度计算、统计数据和记录列表都依赖这个数组。

七、Index.ets 完整代码

打开文件:

entry/src/main/ets/pages/Index.ets

将其中内容替换为下面代码:

interface WaterRecord {
  id: number
  amount: number
  time: string
  date: string
}

@Entry
@Component
struct Index {
  @State dailyGoal: number = 2000
  @State customAmount: string = ''
  @State nextId: number = 4
  @State today: string = this.getTodayString()
  @State records: WaterRecord[] = [
    { id: 1, amount: 300, time: '09:00', date: this.getTodayString() },
    { id: 2, amount: 200, time: '11:30', date: this.getTodayString() },
    { id: 3, amount: 500, time: '14:00', date: this.getTodayString() }
  ]

  private quickAmounts: number[] = [200, 300, 500]

  aboutToAppear() {
    this.today = this.getTodayString()
  }

  private addWater(amount: number): void {
    if (amount <= 0) return
    
    let record: WaterRecord = {
      id: this.nextId,
      amount: amount,
      time: this.getCurrentTime(),
      date: this.today
    }
    
    this.records = [record, ...this.records]
    this.nextId++
    this.customAmount = ''
  }

  private addCustomWater(): void {
    let amount = parseInt(this.customAmount)
    if (!isNaN(amount) && amount > 0) {
      this.addWater(amount)
    }
  }

  private deleteRecord(id: number): void {
    this.records = this.records.filter((r: WaterRecord) => r.id !== id)
  }

  private getTodayTotal(): number {
    return this.records
      .filter((r: WaterRecord) => r.date === this.today)
      .reduce((sum: number, r: WaterRecord) => sum + r.amount, 0)
  }

  private getProgress(): number {
    return Math.min(this.getTodayTotal() / this.dailyGoal, 1)
  }

  private getPercentage(): number {
    return Math.round(this.getProgress() * 100)
  }

  private getRemaining(): number {
    return Math.max(this.dailyGoal - this.getTodayTotal(), 0)
  }

  private getTodayString(): string {
    return this.formatDate(new Date())
  }

  private getCurrentTime(): string {
    let date = new Date()
    return `${this.formatTwo(date.getHours())}:${this.formatTwo(date.getMinutes())}`
  }

  private formatDate(date: Date): string {
    return `${date.getFullYear()}-${this.formatTwo(date.getMonth() + 1)}-${this.formatTwo(date.getDate())}`
  }

  private formatTwo(value: number): string {
    return value < 10 ? '0' + value.toString() : value.toString()
  }

  private adjustGoal(delta: number): void {
    let newGoal = this.dailyGoal + delta
    if (newGoal >= 500 && newGoal <= 5000) {
      this.dailyGoal = newGoal
    }
  }

  @Builder
  StatCard(title: string, value: string, unit: string, color: string) {
    Column() {
      Row() {
        Text(value)
          .fontSize(22)
          .fontWeight(FontWeight.Bold)
          .fontColor(color)
        Text(unit)
          .fontSize(13)
          .fontColor(color)
          .margin({ left: 4, top: 6 })
      }
      Text(title)
        .fontSize(13)
        .fontColor('#6B7280')
        .margin({ top: 4 })
    }
    .width('31%')
    .height(75)
    .justifyContent(FlexAlign.Center)
    .backgroundColor(Color.White)
    .borderRadius(16)
    .shadow({
      radius: 10,
      color: '#12000000',
      offsetX: 0,
      offsetY: 4
    })
  }

  @Builder
  QuickButton(amount: number) {
    Button(`${amount}ml`)
      .height(48)
      .layoutWeight(1)
      .fontSize(16)
      .fontColor(Color.White)
      .backgroundColor('#3B82F6')
      .borderRadius(24)
      .onClick(() => {
        this.addWater(amount)
      })
  }

  @Builder
  RecordItem(record: WaterRecord) {
    Row() {
      Text('💧')
        .fontSize(24)
        .width(40)
        .height(40)
        .textAlign(TextAlign.Center)
        .backgroundColor('#EFF6FF')
        .borderRadius(20)
      
      Column() {
        Text(`${record.amount}ml`)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor('#182431')
        
        Text(record.time)
          .fontSize(13)
          .fontColor('#6B7280')
          .margin({ top: 4 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 12 })
      
      Button('删除')
        .height(32)
        .fontSize(12)
        .fontColor('#EF4444')
        .backgroundColor('#FEF2F2')
        .borderRadius(14)
        .onClick(() => {
          this.deleteRecord(record.id)
        })
    }
    .width('100%')
    .padding(14)
    .margin({ bottom: 10 })
    .backgroundColor(Color.White)
    .borderRadius(12)
    .shadow({
      radius: 6,
      color: '#08000000',
      offsetX: 0,
      offsetY: 2
    })
  }

  build() {
    Column() {
      Text('饮水提醒')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .fontColor('#182431')
        .margin({ top: 22 })
      Text('基于 HarmonyOS ArkTS 的健康饮水助手')
        .fontSize(14)
        .fontColor('#6B7280')
        .margin({ top: 8, bottom: 20 })

      Row() {
        this.StatCard('已饮水', `${this.getTodayTotal()}`, 'ml', '#3B82F6')
        this.StatCard('完成率', `${this.getPercentage()}`, '%', '#10B981')
        this.StatCard('还需喝', `${this.getRemaining()}`, 'ml', '#F59E0B')
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .margin({ bottom: 24 })

      Stack() {
        Circle()
          .width(220)
          .height(220)
          .fill('#EFF6FF')
          .stroke('#DBEAFE')
          .strokeWidth(16)
        
        Circle()
          .width(220)
          .height(220)
          .fill(Color.Transparent)
          .stroke('#3B82F6')
          .strokeWidth(16)
          .strokeDashArray([2 * Math.PI * 102 * this.getProgress(), 2 * Math.PI * 102])
          .strokeDashOffset(2 * Math.PI * 102 * 0.25)
          .animation({ duration: 500, curve: Curve.EaseInOut })

        Column() {
          Text('💧')
            .fontSize(40)
            .margin({ bottom: 8 })
          Text(`${this.getTodayTotal()}ml`)
            .fontSize(36)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3B82F6')
          Text(`目标 ${this.dailyGoal}ml`)
            .fontSize(14)
            .fontColor('#6B7280')
            .margin({ top: 4 })
        }
      }
      .width(240)
      .height(240)
      .margin({ bottom: 24 })
      .justifyContent(FlexAlign.Center)
      .alignItems(Alignment.Center)

      Text('快速记录')
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .fontColor('#182431')
        .width('100%')
        .margin({ bottom: 12 })

      Row() {
        this.QuickButton(200)
        Blank().width(10)
        this.QuickButton(300)
        Blank().width(10)
        this.QuickButton(500)
      }
      .width('100%')
      .margin({ bottom: 16 })

      Row() {
        TextInput({
          placeholder: '自定义饮水量(ml)',
          text: this.customAmount
        })
          .layoutWeight(1)
          .height(44)
          .fontSize(15)
          .backgroundColor('#F5F7FA')
          .borderRadius(22)
          .padding({ left: 16, right: 16 })
          .type(InputType.Number)
          .onChange((value: string) => {
            this.customAmount = value
          })
        
        Button('添加')
          .height(44)
          .margin({ left: 10 })
          .padding({ left: 20, right: 20 })
          .fontSize(15)
          .fontColor(Color.White)
          .backgroundColor('#3B82F6')
          .borderRadius(22)
          .onClick(() => {
            this.addCustomWater()
          })
      }
      .width('100%')
      .margin({ bottom: 16 })

      Row() {
        Text('每日目标')
          .fontSize(14)
          .fontColor('#6B7280')
        Blank()
        Button('-')
          .width(32)
          .height(32)
          .fontSize(18)
          .fontColor('#4B5563')
          .backgroundColor('#F3F4F6')
          .borderRadius(16)
          .onClick(() => { this.adjustGoal(-100) })
        Text(`${this.dailyGoal}ml`)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .margin({ left: 12, right: 12 })
        Button('+')
          .width(32)
          .height(32)
          .fontSize(18)
          .fontColor('#4B5563')
          .backgroundColor('#F3F4F6')
          .borderRadius(16)
          .onClick(() => { this.adjustGoal(100) })
      }
      .width('100%')
      .padding(14)
      .backgroundColor(Color.White)
      .borderRadius(12)
      .margin({ bottom: 16 })

      Text('今日记录')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#182431')
        .width('100%')
        .margin({ bottom: 12 })

      if (this.records.filter(r => r.date === this.today).length === 0) {
        Column() {
          Text('还没有饮水记录')
            .fontSize(16)
            .fontColor('#6B7280')
          Text('点击上方按钮,开始记录第一杯水吧')
            .fontSize(13)
            .fontColor('#9CA3AF')
            .margin({ top: 8 })
        }
        .width('100%')
        .padding(30)
        .backgroundColor(Color.White)
        .borderRadius(16)
      } else {
        List() {
          ForEach(this.records.filter((r: WaterRecord) => r.date === this.today), (record: WaterRecord) => {
            ListItem() {
              this.RecordItem(record)
            }
          }, (record: WaterRecord) => record.id.toString())
        }
        .width('100%')
        .layoutWeight(1)
        .scrollBar(BarState.Off)
      }
    }
    .width('100%')
    .height('100%')
    .padding({ left: 18, right: 18 })
    .backgroundColor('#F5F7FA')
  }
}

八、代码实现说明

1. 定义饮水记录数据结构

项目中使用 WaterRecord 接口描述每一次饮水记录:

interface WaterRecord {
  id: number
  amount: number
  time: string
  date: string
}

字段说明如下:

字段 作用
id 记录唯一编号
amount 饮水量(ml)
time 饮水时间
date 饮水日期
2. 使用 @State 管理页面状态

本项目中使用多个状态变量保存每日目标、自定义饮水量和饮水记录。当用户点击快捷按钮、添加自定义饮水或删除记录时,状态数据都会发生变化,页面自动刷新。

3. 添加饮水记录

点击快捷按钮或添加自定义饮水时,创建新的记录并添加到数组开头。新记录包含当前时间和日期。

4. 圆形进度显示

使用两个Circle组件实现圆形进度条,通过strokeDashArray控制进度长度,并有平滑动画效果。进度值根据今日饮水量和每日目标计算。

5. 统计数据计算

今日饮水量通过filter筛选今日记录后用reduce求和,完成率和剩余量都基于此动态计算。

6. 每日目标调整

用户可以通过+/-按钮调整每日目标,范围500ml到5000ml,每次调整100ml。

7. 删除记录

使用唯一id删除记录,删除后统计数据和进度自动更新。

九、运行项目

代码编写完成后,在DevEco Studio中运行项目。测试快捷喝水、自定义饮水、目标调整、删除记录等功能,确保进度和统计正确更新。

十、开发中遇到的问题

  • 圆形进度条起始角度调整,确保从顶部开始
  • 饮水量输入验证,防止非数字和负数
  • 目标调整边界处理,限制在合理范围内
  • 只显示今日的记录,历史记录不显示在今日列表中

十一、总结

本文基于HarmonyOS和ArkTS实现了一个饮水提醒应用。项目通过@State管理页面数据,使用ArkUI组件完成页面布局,实现了饮水记录、进度显示、目标设置和记录管理等功能。这个项目展示了ArkTS中圆形进度、快捷交互和数据统计的基本方法,适合作为入门练习项目。

Logo

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

更多推荐