HarmonyOS NEXT实战:开发一款个人健康助手APP

从数据持久化到 Canvas 图表,完整实现步数追踪、饮水记录、睡眠监控与周统计


前言

市面上的健康类 APP 功能越来越重,广告越来越多,有时候我们只需要一个简单的工具来记录每天的步数、饮水量、睡眠时间和心情状态就够了。

本文记录了使用 HarmonyOS NEXT 开发「个人健康助手」APP 的完整过程。项目涵盖了以下核心技术:

  • 数据持久化:基于 @ohos.data.preferences 实现每日记录和用户设置的存储
  • Canvas 图表:手写步数柱状图和饮水/睡眠双柱对比图
  • 自定义组件:封装 HealthCard、NumberStepper、MoodSelector 等可复用组件
  • 四 Tab 导航:首页仪表盘、记录页、统计页、设置页

项目功能清单:

  • ✅ 今日健康仪表盘(步数/饮水/睡眠/卡路里/心情)
  • ✅ 数据录入(数值步进器 + 心情 emoji 选择 + 备注)
  • ✅ 周统计图表(Canvas 柱状图 + 周导航 + 摘要统计)
  • ✅ 个人设置(目标值修改 + 数据清除)
  • ✅ 健康小贴士轮播

一、项目架构

1.1 功能模块

Index(底部 Tab 导航)
├── Tab1: 首页(HomePage)
│   ├── 顶部问候栏(用户名 + 日期)
│   ├── 健康小贴士轮播
│   ├── 健康卡片网格(步数/饮水/睡眠/卡路里/心情)
│   └── 进度条(目标达成率)
│
├── Tab2: 记录页(LogPage)
│   ├── 数值步进器(步数/饮水/睡眠/卡路里)
│   ├── 心情选择器(emoji 点击)
│   ├── 备注输入
│   └── 保存/重置按钮
│
├── Tab3: 统计页(StatsPage)
│   ├── 周导航(上一周/下一周)
│   ├── 步数柱状图(Canvas)
│   ├── 饮水+睡眠双柱对比图(Canvas)
│   └── 周摘要统计
│
└── Tab4: 设置页(ProfilePage)
    ├── 用户头像区
    ├── 健康目标设置(步数/饮水/睡眠/热量)
    ├── 数据清除(带确认对话框)
    └── 关于信息

1.2 目录结构

entry/src/main/ets/
├── components/
│   ├── HealthCard.ets           # 健康数据卡片组件
│   └── CustomComponents.ets    # 心情选择器/数字步进器/健康贴士
├── model/
│   └── HealthData.ets          # 数据模型 + 工具函数
├── pages/
│   ├── Index.ets               # 底部 Tab 导航主页面
│   ├── HomePage.ets            # 首页仪表盘
│   ├── LogPage.ets             # 数据记录页
│   ├── StatsPage.ets           # 统计图表页
│   └── ProfilePage.ets         # 个人设置页
└── utils/
    └── StorageManager.ets      # 数据持久化管理器(Preferences)

二、数据模型设计

2.1 核心数据结构

// 每日健康记录
export interface DailyRecord {
  date: string;       // "2026-06-03"
  steps: number;      // 步数
  water: number;      // 饮水 ml
  sleep: number;      // 睡眠小时
  calories: number;   // 卡路里 kcal
  mood: number;       // 心情 1-5
  note: string;       // 备注
}

// 用户设置
export interface UserSettings {
  userName: string;
  stepGoal: number;      // 默认 10000
  waterGoal: number;     // 默认 2000
  sleepGoal: number;     // 默认 8
  calorieGoal: number;   // 默认 2000
}

2.2 工具函数

// 获取今天的日期字符串
export function getTodayString(): string {
  const now = new Date();
  const y = now.getFullYear();
  const m = (now.getMonth() + 1).toString().padStart(2, '0');
  const d = now.getDate().toString().padStart(2, '0');
  return `${y}-${m}-${d}`;
}

// 获取周一起始日期
export function getWeekStart(dateStr: string): string {
  const d = new Date(dateStr);
  const day = d.getDay();
  const diff = day === 0 ? -6 : 1 - day;
  d.setDate(d.getDate() + diff);
  return formatDate(d);
}

// 获取一周7天日期
export function getWeekDates(startDate: string): string[] {
  const start = new Date(startDate);
  const dates: string[] = [];
  for (let i = 0; i < 7; i++) {
    const d = new Date(start);
    d.setDate(start.getDate() + i);
    dates.push(formatDate(d));
  }
  return dates;
}

// 心情 emoji 映射
export function getMoodEmoji(level: number): string {
  const emojis: string[] = ['😫', '😔', '😐', '😊', '😄'];
  const idx = Math.max(0, Math.min(4, level - 1));
  return emojis[idx];
}

2.3 工厂函数

// 创建默认每日记录
export function createDefaultRecord(date: string): DailyRecord {
  return { date, steps: 0, water: 0, sleep: 0, calories: 0, mood: 3, note: '' };
}

// 创建默认用户设置
export function createDefaultSettings(): UserSettings {
  return { userName: '用户', stepGoal: 10000, waterGoal: 2000, sleepGoal: 8, calorieGoal: 2000 };
}

三、数据持久化:Preferences 管理器

3.1 设计思路

使用 @ohos.data.preferences 实现轻量级数据持久化,单例模式管理全局状态:

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

const STORE_NAME = 'health_app_store';
const SETTINGS_KEY = 'user_settings';
const RECORDS_KEY = 'all_records_json';

class StorageManager {
  private store_: preferences.Preferences | null = null;
  private isReady_: boolean = false;

  // 初始化(传入 Context)
  async init(context: Context): Promise<void> {
    if (this.isReady_) return;
    this.store_ = await preferences.getPreferences(context, STORE_NAME);
    this.isReady_ = true;
  }

  // ===== 用户设置 =====
  async saveSettings(settings: UserSettings): Promise<void> {
    await this.checkReady();
    await this.store_!.put(SETTINGS_KEY, JSON.stringify(settings));
    await this.store_!.flush();
  }

  async loadSettings(): Promise<UserSettings> {
    await this.checkReady();
    const val = await this.store_!.get(SETTINGS_KEY, '') as string;
    return val === '' ? createDefaultSettings() : JSON.parse(val) as UserSettings;
  }

  // ===== 每日记录 =====
  async saveRecord(record: DailyRecord): Promise<void> {
    await this.checkReady();
    const records = await this.loadAllRecords();
    const idx = records.findIndex((r) => r.date === record.date);
    if (idx >= 0) {
      records[idx] = record;   // 覆盖已有记录
    } else {
      records.push(record);     // 追加新记录
    }
    await this.store_!.put(RECORDS_KEY, JSON.stringify(records));
    await this.store_!.flush();
  }

  async loadRecord(date: string): Promise<DailyRecord | null> {
    const records = await this.loadAllRecords();
    return records.find((r) => r.date === date) ?? null;
  }

  async loadAllRecords(): Promise<DailyRecord[]> {
    await this.checkReady();
    const val = await this.store_!.get(RECORDS_KEY, '') as string;
    return val === '' ? [] : JSON.parse(val) as DailyRecord[];
  }

  // 获取指定范围内的记录(用于图表)
  async loadRecordsInRange(dates: string[]): Promise<DailyRecord[]> {
    const all = await this.loadAllRecords();
    return dates.map(d => all.find(r => r.date === d) ?? createDefaultRecord(d));
  }

  // 清除所有数据
  async clearAll(): Promise<void> {
    await this.checkReady();
    await this.store_!.clear();
    await this.store_!.flush();
  }

  private async checkReady(): Promise<void> {
    if (!this.isReady_ || !this.store_) {
      throw new Error('StorageManager not initialized');
    }
  }
}

// 全局单例
const storageManager = new StorageManager();
export default storageManager;

3.2 关键设计点

设计点 方案 原因
存储格式 JSON 字符串 简单灵活,Preferences 存储值
记录更新策略 查找覆盖(同日覆盖) 同一天多次录入只保留最新
缺失日期处理 返回默认值 图表中空日填充 0
单例模式 模块级 const 全局共享同一实例

四、自定义组件封装

4.1 HealthCard — 健康数据卡片

@Component
export struct HealthCard {
  @Prop title: string = '';
  @Prop value: string = '0';
  @Prop unit: string = '';
  @Prop progress: number = 0;   // 0~1
  @Prop icon: string = '📊';
  @Prop color: Color = Color.Blue;

  build() {
    Column() {
      Text(this.icon).fontSize(28)
      Text(this.value)
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .fontColor(this.color)
      Text(this.unit).fontSize(12).fontColor('#999')
      
      // 进度条
      Row() {
        Row()
          .width((this.progress * 100) + '%')
          .height(4)
          .backgroundColor(this.color)
          .borderRadius(2)
      }
      .width('100%')
      .height(4)
      .backgroundColor('#E8E8E8')
      .borderRadius(2)
      .margin({ top: 8 })
    }
    .padding(12)
    .backgroundColor(Color.White)
    .borderRadius(12)
    .shadow({ radius: 4, color: '#20000000', offsetY: 2 })
    .alignItems(HorizontalAlign.Center)
  }
}

用法示例:

HealthCard({
  title: '步数',
  value: '6,842',
  unit: '步',
  progress: 0.68,
  icon: '🏃',
  color: Color.Blue
})

4.2 NumberStepper — 数值步进器

@Component
export struct NumberStepper {
  @Prop label: string = '';
  @Prop unit: string = '';
  @Prop step: number = 1;
  @Prop minValue: number = 0;
  @Prop maxValue: number = 99999;
  @Link value: number;

  build() {
    Row() {
      Text(this.label).fontSize(14).layoutWeight(1)

      Button('-')
        .width(36).height(36).borderRadius(18)
        .fontSize(20).fontWeight(FontWeight.Bold)
        .backgroundColor('#F0F0F0')
        .enabled(this.value > this.minValue)
        .onClick(() => {
          this.value = Math.max(this.minValue, this.value - this.step);
        })

      Text(this.value.toString())
        .fontSize(20).fontWeight(FontWeight.Bold)
        .width(60).textAlign(TextAlign.Center)

      Text(this.unit).fontSize(12).fontColor('#999')

      Button('+')
        .width(36).height(36).borderRadius(18)
        .fontSize(20).fontWeight(FontWeight.Bold)
        .backgroundColor($r('app.color.primary'))
        .fontColor(Color.White)
        .enabled(this.value < this.maxValue)
        .onClick(() => {
          this.value = Math.min(this.maxValue, this.value + this.step);
        })
    }
    .padding(12)
    .backgroundColor(Color.White)
    .borderRadius(12)
  }
}

4.3 MoodSelector — 心情选择器

@Component
export struct MoodSelector {
  private moods: number[] = [1, 2, 3, 4, 5];
  @Link currentMood: number;

  build() {
    Row() {
      ForEach(this.moods, (level: number) => {
        Column() {
          Text(getEmoji(level))
            .fontSize(32)
            .opacity(level === this.currentMood ? 1.0 : 0.4)
          Text(getLabel(level))
            .fontSize(10)
            .fontColor(level === this.currentMood
              ? $r('app.color.primary')
              : $r('app.color.text_secondary'))
        }
        .padding(6)
        .backgroundColor(level === this.currentMood ? '#1A007AFF' : Color.Transparent)
        .borderRadius(12)
        .onClick(() => { this.currentMood = level; })
      })
    }
    .justifyContent(FlexAlign.SpaceAround)
    .backgroundColor(Color.White)
    .borderRadius(12)
  }
}

4.4 HealthTipBanner — 健康贴士轮播

@Component
export struct HealthTipBanner {
  @State currentIndex: number = 0;
  private tips: string[] = [
    '☀️ 每天喝够8杯水,保持身体活力',
    '🏃 每日步行10000步,有益心血管健康',
    '😴 保证7-8小时睡眠,提高免疫力',
    '🥗 三餐规律,多吃蔬菜水果',
    '🧘 适当放松,保持良好心态'
  ];
  private intervalId: number = -1;

  aboutToAppear(): void {
    this.intervalId = setInterval(() => {
      this.currentIndex = (this.currentIndex + 1) % this.tips.length;
    }, 4000);
  }

  aboutToDisappear(): void {
    if (this.intervalId >= 0) clearInterval(this.intervalId);
  }

  build() {
    Row() {
      Text(this.tips[this.currentIndex])
        .fontSize(14).fontColor(Color.White)
        .layoutWeight(1)
      // 指示点
      Row() {
        ForEach(this.tips, (_: string, idx: number) => {
          Circle()
            .width(6).height(6)
            .fill(idx === this.currentIndex ? Color.White : '#66FFFFFF')
            .margin({ left: 3, right: 3 })
        })
      }
    }
    .height(52)
    .backgroundColor($r('app.color.primary'))
    .borderRadius(12)
    .padding({ left: 12, right: 12 })
  }
}

五、首页:健康仪表盘

5.1 页面结构

┌────────────────────────────┐
│ 你好, 用户                  │  ← 蓝色顶部栏
│ 6月3日 星期二     ❤️        │
├────────────────────────────┤
│ ☀️ 每天喝够8杯水...  ●○○○○ │  ← 健康贴士轮播
├────────────────────────────┤
│ 今日概览                    │
│ ┌────────┐ ┌────────┐ ┌────────┐│
│ │  🏃    │ │  💧    │ │  😴    ││  ← 三列卡片
│ │ 6,842  │ │ 1200   │ │ 7.5   ││
│ │  步    │ │  ml    │ │  小时  ││
│ │ ████░░ │ │ ██████ │ │ █████░ ││
│ └────────┘ └────────┘ └────────┘│
│ ┌──────────┐ ┌──────────┐     │
│ │  🔥       │ │  😊      │     │  ← 卡路里 + 心情
│ │ 1,850    │ │  不错     │     │
│ │  千卡    │ │  今日心情  │     │
│ └──────────┘ └──────────┘     │
│ 💡 点击"记录"标签添加数据     │
└────────────────────────────┘

5.2 数据加载

@Component
export struct HomePage {
  @State todayRecord: DailyRecord | null = null;
  @State settings: UserSettings | null = null;
  @State userName: string = '用户';

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

  async loadData(): Promise<void> {
    this.settings = await storageManager.loadSettings();
    this.userName = this.settings.userName;
    const record = await storageManager.loadRecord(getTodayString());
    if (record !== null) {
      this.todayRecord = record;
    }
  }
}

5.3 进度计算

每个健康卡片的进度条计算方式:

// 步数达成率
progress: Math.min(1, todayRecord.steps / settings.stepGoal)

// 饮水达成率
progress: Math.min(1, todayRecord.water / settings.waterGoal)

// 睡眠达成率
progress: Math.min(1, todayRecord.sleep / settings.sleepGoal)

六、记录页:数据录入

6.1 表单结构

┌────────────────────────────┐
│ 记录健康数据        [已记录✅]│  ← 顶部栏
│ 2026-06-03                  │
├────────────────────────────┤
│ 🏃 步数                     │
│ ┌─────────────────────────┐ │
│ │今日步数  [-] 0 [+] 步  │ │  ← NumberStepper
│ └─────────────────────────┘ │
│                             │
│ 💧 饮水                     │
│ ┌─────────────────────────┐ │
│ │饮水量   [-] 0 [+] ml   │ │
│ └─────────────────────────┘ │
│                             │
│ 😴 睡眠                     │
│ ┌─────────────────────────┐ │
│ │睡眠时间 [-] 0 [+] 小时 │ │
│ └─────────────────────────┘ │
│                             │
│ 🔥 卡路里                   │
│ ┌─────────────────────────┐ │
│ │摄入热量 [-] 0 [+] 千卡 │ │
│ └─────────────────────────┘ │
│                             │
│ 😊 今日心情                 │
│ ┌─────────────────────────┐ │
│ │ 😫  😔  😐  🙁  😄    │ │  ← MoodSelector
│ │ 很差 较差 一般 不错 很棒│ │
│ └─────────────────────────┘ │
│                             │
│ 📝 备注                     │
│ ┌─────────────────────────┐ │
│ │ 记录今天的感受...       │ │  ← TextArea
│ └─────────────────────────┘ │
│                             │
│ ┌─────────────────────────┐ │
│ │     保存今日记录        │ │  ← 保存按钮
│ └─────────────────────────┘ │
│ ┌─────────────────────────┐ │
│ │     重新填写            │ │  ← 重置按钮
│ └─────────────────────────┘ │
└────────────────────────────┘

6.2 保存逻辑

async saveRecord(): Promise<void> {
  const record: DailyRecord = {
    date: getTodayString(),
    steps: this.steps,
    water: this.water,
    sleep: this.sleep,
    calories: this.calories,
    mood: this.mood,
    note: this.note
  };
  await storageManager.saveRecord(record);
  this.hasSaved = true;
  promptAction.showToast({ message: '✅ 今日记录已保存' });
}

七、统计页:Canvas 图表

7.1 页面结构

┌────────────────────────────┐
│ 数据统计                    │  ← 顶部栏
├────────────────────────────┤
│ [<] 6月2日 - 6月8日 [>]    │  ← 周导航
│      点击切换周              │
├────────────────────────────┤
│ 🏃 每日步数                 │
│ ┌─────────────────────────┐ │
│ │10k│                     │ │
│ │   │  ██     ██  ██     │ │  ← Canvas 柱状图
│ │5k │ ██  ██ ██  ██ ██  │ │
│ │   │ ██  ██ ██  ██ ██  │ │
│ │0  │─────────────────────│ │
│ │   │ 一 二 三 四 五 六 日│ │
│ └─────────────────────────┘ │
│                             │
│ 💧 饮水 & 😴 睡眠          │
│ ┌─────────────────────────┐ │
│ │2k │ 🟢🟣 🟢🟣 🟢🟣 🟢🟣 │ │  ← 双柱对比图
│ │   │🟢🟣🟢🟣🟢🟣🟢🟣🟢🟣│ │
│ │0  │─────────────────────│ │
│ │   │ 一 二 三 四 五 六 日│ │
│ │   │ 🟢饮水 🟣睡眠       │ │
│ └─────────────────────────┘ │
│                             │
│ 📊 本周摘要                 │
│ ┌────────┐ ┌────────┐      │
│ │  🏃    │ │  💧    │      │
│ │ 5,230  │ │ 1800ml │      │
│ │ 日均步数│ │ 日均饮水│      │
│ │  52%   │ │  90%   │      │
│ └────────┘ └────────┘      │
│ ┌────────┐ ┌────────┐      │
│ │  😴    │ │  🔥    │      │
│ │  7.2h  │ │ 1650   │      │
│ │ 平均睡眠│ │ 日均热量│      │
│ │  90%   │ │        │      │
│ └────────┘ └────────┘      │
│ 🥇 最佳步数日: 周三         │
└────────────────────────────┘

7.2 步数柱状图绘制

drawStepChart(): void {
  const ctx = this.barCtx;
  const totalW = 350, totalH = 190;
  const padL = 40, padR = 10, padT = 20, padB = 30;
  const chartW = totalW - padL - padR;
  const chartH = totalH - padT - padB;

  ctx.clearRect(0, 0, totalW, totalH);

  // 计算最大值
  let maxVal = 0;
  for (const r of this.records) {
    if (r.steps > maxVal) maxVal = r.steps;
  }
  if (maxVal === 0) maxVal = 10000;

  // 网格线 + Y轴标签
  const gridLines = 4;
  for (let i = 0; i <= gridLines; i++) {
    const yy = padT + (chartH / gridLines) * i;
    ctx.strokeStyle = '#E8E8E8';
    ctx.lineWidth = 0.5;
    ctx.beginPath();
    ctx.moveTo(padL, yy);
    ctx.lineTo(totalW - padR, yy);
    ctx.stroke();

    const labelVal = Math.round(maxVal * (1 - i / gridLines));
    ctx.fillStyle = '#666666';
    ctx.font = '10px sans-serif';
    ctx.textAlign = 'right';
    ctx.fillText(formatShort(labelVal), padL - 6, yy + 4);
  }

  // 柱状图
  const barCount = this.records.length;
  const totalGap = chartW * 0.3;
  const gap = totalGap / (barCount + 1);
  const barW = (chartW - totalGap) / barCount;

  for (let i = 0; i < barCount; i++) {
    const record = this.records[i];
    const barH = (record.steps / maxVal) * chartH;
    const bx = padL + gap + i * (barW + gap);
    const by = padT + chartH - barH;

    // 柱体
    ctx.fillStyle = '#007AFF';
    ctx.fillRect(bx, by, barW, barH);

    // 顶部数值
    ctx.fillStyle = '#007AFF';
    ctx.font = '10px sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText(formatShort(record.steps), bx + barW / 2, by - 4);

    // X轴标签
    ctx.fillStyle = '#666666';
    ctx.fillText(getWeekDayName(this.weekDates[i]), bx + barW / 2, totalH - 6);
  }
}

7.3 饮水+睡眠双柱对比图

drawWaterChart(): void {
  const ctx = this.waterCtx;
  // ... 布局参数同上 ...

  const groupW = (chartW - totalGap) / barCount;
  const innerBarW = groupW * 0.35;

  for (let i = 0; i < barCount; i++) {
    const record = this.records[i];
    const xBase = padL + gap + i * (groupW + gap);

    // 饮水柱(绿色)
    const waterH = (record.water / maxVal) * chartH;
    ctx.fillStyle = '#34C759';
    ctx.fillRect(xBase, padT + chartH - waterH, innerBarW, waterH);

    // 睡眠柱(紫色)
    const sleepH = (record.sleep / maxSleep) * chartH;
    ctx.fillStyle = '#AF52DE';
    ctx.fillRect(xBase + innerBarW + 2, padT + chartH - sleepH, innerBarW, sleepH);

    // X轴标签
    ctx.fillStyle = '#666666';
    ctx.fillText(getWeekDayName(this.weekDates[i]), xBase + groupW / 2, totalH - 6);
  }

  // 图例
  ctx.fillStyle = '#34C759';
  ctx.fillRect(totalW - 80, 6, 10, 10);
  ctx.fillStyle = '#666666';
  ctx.fillText('饮水', totalW - 66, 15);

  ctx.fillStyle = '#AF52DE';
  ctx.fillRect(totalW - 40, 6, 10, 10);
  ctx.fillText('睡眠', totalW - 26, 15);
}

7.4 周导航

prevWeek(): void {
  const d = new Date(this.weekStartDate);
  d.setDate(d.getDate() - 7);
  this.weekStartDate = formatDate(d);
  this.weekDates = getWeekDates(this.weekStartDate);
  this.loadWeekData();
}

nextWeek(): void {
  const d = new Date(this.weekStartDate);
  d.setDate(d.getDate() + 7);
  this.weekStartDate = formatDate(d);
  this.weekDates = getWeekDates(this.weekStartDate);
  this.loadWeekData();
}

7.5 周摘要统计

// 计算周平均值
computeAvg(field: string): number {
  let sum = 0;
  for (const r of this.records) {
    sum += r[field];
  }
  return Math.round(sum / this.records.length);
}

// 找出最佳步数日
computeBestDay(field: string): string {
  let bestIdx = 0, bestVal = -1;
  for (let i = 0; i < this.records.length; i++) {
    if (this.records[i][field] > bestVal) {
      bestVal = this.records[i][field];
      bestIdx = i;
    }
  }
  return '周' + getWeekDayName(this.weekDates[bestIdx]);
}

八、设置页:个人目标管理

8.1 功能列表

┌────────────────────────────┐
│ 个人设置                    │  ← 顶部栏
├────────────────────────────┤
│            👤               │
│           用户名            │  ← 头像区
│    健康生活,从记录开始      │
├────────────────────────────┤
│ 个人信息                    │
│ ┌─────────────────────────┐ │
│ │ 昵称        用户  ›     │ │
│ │─────────────────────────│ │
│ │ 每日步数目标  10000  ›  │ │
│ └─────────────────────────┘ │
│                             │
│ 健康目标                    │
│ ┌─────────────────────────┐ │
│ │ 每日饮水目标  2000  ›   │ │
│ │─────────────────────────│ │
│ │ 每日睡眠目标  8  ›     │ │
│ │─────────────────────────│ │
│ │ 每日热量目标  2000  ›   │ │
│ └─────────────────────────┘ │
│                             │
│ ┌─────────────────────────┐ │
│ │      编辑目标           │ │  ← 编辑按钮
│ └─────────────────────────┘ │
│                             │
│ 数据管理                    │
│ ┌─────────────────────────┐ │
│ │ 🗑️ 清除所有数据        │ │  ← 危险操作
│ └─────────────────────────┘ │
│                             │
│ 关于                        │
│ ┌─────────────────────────┐ │
│ │ 个人健康助手   ❤️       │ │
│ │ 版本 1.0.0              │ │
│ └─────────────────────────┘ │
└────────────────────────────┘

8.2 编辑模式切换

@State isEditing: boolean = false;

// 点击"编辑目标"进入编辑模式
.onClick(() => { this.isEditing = true; })

// 编辑模式下显示输入框
if (this.isEditing) {
  Column() {
    this.inputRow('昵称', this.userName, (val) => { this.userName = val; })
    this.inputRow('步数目标', this.stepGoal, (val) => { this.stepGoal = val; })
    this.inputRow('饮水目标(ml)', this.waterGoal, (val) => { this.waterGoal = val; })
    this.inputRow('睡眠目标(h)', this.sleepGoal, (val) => { this.sleepGoal = val; })
    this.inputRow('热量目标(kcal)', this.calorieGoal, (val) => { this.calorieGoal = val; })

    Row() {
      Button('取消').onClick(() => { this.isEditing = false; this.loadSettings(); })
      Button('保存').onClick(() => { this.saveSettings(); })
    }
  }
}

8.3 数据清除(带确认对话框)

showConfirmDialog(): void {
  AlertDialog.show({
    title: '确认清除',
    message: '将清除所有健康记录和设置,此操作不可恢复。确定继续?',
    primaryButton: { value: '取消', action: () => {} },
    secondaryButton: {
      value: '确认清除',
      fontColor: '#FF3B30',
      action: () => { this.clearAllData(); }
    }
  });
}

九、底部 Tab 导航

9.1 Tabs 组件实现

@Entry
@Component
struct Index {
  @State currentIndex: number = 0;

  build() {
    Tabs({
      barPosition: BarPosition.End,
      index: this.currentIndex
    }) {
      TabContent() { HomePage() }
        .tabBar(this.tabBarBuilder('🏠', '首页', 0))

      TabContent() { LogPage() }
        .tabBar(this.tabBarBuilder('📝', '记录', 1))

      TabContent() { StatsPage() }
        .tabBar(this.tabBarBuilder('📊', '统计', 2))

      TabContent() { ProfilePage() }
        .tabBar(this.tabBarBuilder('⚙️', '设置', 3))
    }
    .vertical(false)
    .scrollable(true)
    .onChange((index: number) => { this.currentIndex = index; })
  }

  @Builder
  tabBarBuilder(icon: string, label: string, targetIndex: number) {
    Column() {
      Text(icon).fontSize(22)
      Text(label)
        .fontSize(10)
        .fontColor(this.currentIndex === targetIndex
          ? $r('app.color.primary')
          : $r('app.color.text_secondary'))
    }
    .width('100%')
    .padding({ top: 6, bottom: 6 })
    .alignItems(HorizontalAlign.Center)
  }
}

十、关键技术总结

10.1 Preferences 数据持久化

操作 API 说明
初始化 preferences.getPreferences() 传入 Context 获取实例
写入 store.put(key, value) + store.flush() JSON 字符串存储
读取 store.get(key, default) 无数据返回默认值
清除 store.clear() + store.flush() 删除全部数据

10.2 Canvas 图表绘制

图表 类型 颜色
步数 单柱状图 蓝色 #007AFF
饮水 双柱对比图 绿色 #34C759
睡眠 双柱对比图 紫色 #AF52DE

绘制步骤:网格线 → Y轴标签 → 柱体 → 数值标签 → X轴标签 → 图例

10.3 自定义组件清单

组件 功能 关键特性
HealthCard 健康数据展示卡片 @Prop 进度条动画
NumberStepper 数值 +/- 步进器 @Link 双向绑定
MoodSelector 心情 emoji 选择器 透明度 + 背景高亮
HealthTipBanner 健康贴士轮播 setInterval 自动切换

十一、运行效果展示

在这里插入图片描述


十二、扩展方向

  1. 数据同步:接入云存储实现多设备同步
  2. 更多图表:折线图、雷达图展示综合健康评分
  3. 提醒功能:定时提醒饮水/运动
  4. 运动模式:接入传感器实时计步
  5. 周报/月报:自动生成健康报告
  6. 深色模式:适配 Dark Mode
  7. 数据导出:支持 CSV/JSON 格式导出

结语

这个项目是一个完整的健康类 APP 实现,核心技术栈覆盖了 HarmonyOS 开发的多个重要方面:

  • Preferences 持久化:轻量级本地存储方案
  • Canvas 2D 绘图:手写柱状图,无需第三方图表库
  • 自定义组件:4 个可复用组件,提升代码复用率
  • Tabs 导航:4 Tab 结构,完整的 APP 页面框架
  • AlertDialog:确认对话框,安全的危险操作确认

代码已在文中完整呈现,可以直接复制到 DevEco Studio 中运行。遇到问题欢迎评论区交流!


技术栈:HarmonyOS NEXT + ArkTS + Canvas 2D + Preferences
开发环境:DevEco Studio 5.0+ / SDK API 23
页面数量:4 个 Tab 页面
自定义组件:4 个

Logo

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

更多推荐