从零打造健康追踪APP:Preferences存储与Canvas图表实战

当数据持久化遇上可视化图表,HarmonyOS 开发者的进阶之路


写在前面

健康类 APP 是移动开发中最经典的练手项目之一——它涵盖了数据录入、持久化存储、图表展示、用户设置等完整的业务闭环。相比 Todo List,健康追踪更能体现"数据驱动"的开发理念。

本文分享使用 HarmonyOS NEXT 开发「个人健康助手」的完整过程,重点聚焦两个技术难点:

  1. Preferences 数据存储:如何设计一个优雅的存储管理器?
  2. Canvas 图表绘制:如何从零手写柱状图,不依赖第三方库?

一、项目功能一览

1.1 功能清单

📱 个人健康助手
│
├── 📊 首页仪表盘
│   ├── 问候语 + 日期显示
│   ├── 健康小贴士轮播(4秒自动切换)
│   ├── 步数/饮水/睡眠卡片(带进度条)
│   ├── 卡路里 + 心情卡片
│   └── 进度 = 实际值 / 目标值
│
├── 📝 数据录入
│   ├── 步数步进器(+/- 100步)
│   ├── 饮水步进器(+/- 50ml)
│   ├── 睡眠步进器(+/- 1小时)
│   ├── 卡路里步进器(+/- 50kcal)
│   ├── 心情选择器(5种 emoji)
│   ├── 备注输入
│   └── 保存/重置
│
├── 📈 统计图表
│   ├── 周导航(上一周/下一周)
│   ├── 步数柱状图(Canvas)
│   ├── 饮水+睡眠双柱对比图(Canvas)
│   ├── 周平均统计
│   └── 最佳步数日
│
└── ⚙️ 个人设置
    ├── 昵称修改
    ├── 目标值设置(步数/饮水/睡眠/热量)
    ├── 编辑/保存模式切换
    ├── 数据清除(带确认对话框)
    └── 关于信息

1.2 技术栈

技术 用途
Preferences 轻量级本地存储
Canvas 2D 柱状图、双柱对比图
@State / @Link 响应式状态管理
Tabs 底部导航
ForEach 列表渲染
AlertDialog 确认对话框

二、数据模型:一切的基础

2.1 数据类型定义

// 每日健康记录
export interface DailyRecord {
  date: string;       // "2026-06-03"
  steps: number;      // 步数
  water: number;      // 饮水 ml
  sleep: number;      // 睡眠小时
  calories: number;   // 卡路里
  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 日期工具函数

处理日期是健康类 APP 的基本功:

// 获取今天日期字符串 "2026-06-03"
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();  // 0=周日
  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 = ['😫', '😔', '😐', '😊', '😄'];
  return emojis[Math.max(0, Math.min(4, level - 1))];
}

// 心情文字映射
export function getMoodText(level: number): string {
  const texts = ['很差', '较差', '一般', '不错', '很棒'];
  return texts[Math.max(0, Math.min(4, level - 1))];
}

三、Preferences 存储管理器

3.1 为什么需要管理器?

直接在页面中调用 Preferences API 会导致:

  • 代码分散,难以维护
  • 每次操作都要处理初始化逻辑
  • 类型不安全,容易写错 key

封装成管理器的好处:

  • 单例模式:全局共享同一实例
  • 类型安全:强类型接口
  • 统一错误处理:一处 catch,全局生效

3.2 管理器实现

import { preferences } from '@kit.ArkData';
import { DailyRecord, UserSettings, createDefaultRecord, createDefaultSettings } from '../model/HealthData';

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;

  // 初始化(在 EntryAbility 中调用)
  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();  // 必须调用 flush 才会写入磁盘
  }

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

  // ===== 每日记录 =====

  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);
  }

  // 获取日期范围内的记录(用于图表)
  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. Call init() first.');
    }
  }
}

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

3.3 初始化时机

EntryAbility.ets 中初始化:

import storageManager from '../utils/StorageManager';

export default class EntryAbility extends UIAbility {
  async onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): Promise<void> {
    // 初始化存储管理器
    await storageManager.init(this.context);
  }
}

3.4 使用示例

// 加载设置
const settings = await storageManager.loadSettings();

// 保存记录
await storageManager.saveRecord({
  date: getTodayString(),
  steps: 8000,
  water: 1500,
  sleep: 7.5,
  calories: 1800,
  mood: 4,
  note: '今天状态不错'
});

// 加载一周数据
const weekDates = getWeekDates(getWeekStart(getTodayString()));
const records = await storageManager.loadRecordsInRange(weekDates);

四、自定义组件:提升代码复用

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,  // 6842 / 10000
  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')
        .fontColor(Color.Black)
        .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').margin({ right: 8 })

      // 加号按钮
      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)
  }
}

用法

NumberStepper({
  label: '今日步数',
  unit: '步',
  step: 100,
  minValue: 0,
  maxValue: 99999,
  value: this.steps  // @Link 双向绑定
})

4.3 MoodSelector — 心情选择器

5 种 emoji 心情,点击选择:

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

  build() {
    Row() {
      ForEach(this.moods, (level: number) => {
        Column() {
          Text(getMoodEmoji(level))
            .fontSize(32)
            .opacity(level === this.currentMood ? 1.0 : 0.4)
          Text(getMoodText(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杯水...  ●○○○○  │  轮播条
├─────────────────────────────┤
│ 今日概览                     │
│ ┌─────┐ ┌─────┐ ┌─────┐    │
│ │ 🏃  │ │ 💧  │ │ 😴  │    │  三列卡片
│ │6842 │ │1200 │ │ 7.5 │    │
│ │ 步  │ │ ml  │ │小时 │    │
│ │███░ │ │████ │ │████░│    │
│ └─────┘ └─────┘ └─────┘    │
│ ┌────────┐ ┌────────┐      │
│ │  🔥    │ │  😊    │      │  卡路里+心情
│ │ 1850   │ │ 不错   │      │
│ │ 千卡   │ │今日心情│      │
│ └────────┘ └────────┘      │
│ 💡 点击"记录"标签添加数据   │
└─────────────────────────────┘

5.2 数据加载

@Component
export struct HomePage {
  @State todayRecord: DailyRecord | null = null;
  @State settings: UserSettings | null = null;

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

  async loadData(): Promise<void> {
    this.settings = await storageManager.loadSettings();
    this.todayRecord = await storageManager.loadRecord(getTodayString());
  }
}

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)

六、Canvas 图表:从零手写柱状图

6.1 图表布局规划

┌──────────────────────────────────┐
│                                  │  totalH = 190
│    10k ┼───────────────────────  │  padT = 20
│        │     ██                  │
│     5k ┼─ ██ ─██─ ██ ─██ ─██─    │  chartH = 140
│        │ ██  ██  ██  ██  ██     │
│      0 ┼───────────────────────  │  padB = 30
│        ─  二  三  四  五  六  日  │
│       padL                    padR
│       40                      10
└──────────────────────────────────┘
        totalW = 350

6.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;   // 300
  const chartH = totalH - padT - padB;   // 140

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

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

  // 2. 绘制网格线
  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();

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

  // 3. 绘制柱状图
  const barCount = this.records.length;  // 7天
  const totalGap = chartW * 0.3;          // 30% 间隔
  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(this.formatShort(record.steps), bx + barW / 2, by - 4);

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

formatShort(val: number): string {
  if (val >= 10000) return (val / 10000).toFixed(1) + 'k';
  if (val >= 1000) return (val / 1000).toFixed(1) + 'k';
  return val.toString();
}

6.3 饮水+睡眠双柱对比图

在同一图表中展示两组数据:

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

  // 计算两组数据的最大值
  let maxWater = 0, maxSleep = 0;
  for (const r of this.records) {
    if (r.water > maxWater) maxWater = r.water;
    if (r.sleep > maxSleep) maxSleep = r.sleep;
  }
  if (maxWater === 0) maxWater = 2000;
  if (maxSleep === 0) maxSleep = 10;

  // 绘制柱状图
  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 / maxWater) * 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);
}

6.4 Canvas 绑定与重绘

Canvas(this.barCtx)
  .width('100%')
  .height(190)
  .onReady(() => {
    this.drawStepChart();
  })

// 数据变化时重绘
async loadWeekData(): Promise<void> {
  this.records = await storageManager.loadRecordsInRange(this.weekDates);
  setTimeout(() => {
    this.drawStepChart();
    this.drawWaterChart();
  }, 100);
}

七、统计页:周导航与摘要

7.1 周导航

@State weekStartDate: string = '';
@State weekDates: string[] = [];

aboutToAppear(): void {
  this.weekStartDate = getWeekStart(getTodayString());
  this.weekDates = getWeekDates(this.weekStartDate);
  this.loadWeekData();
}

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.2 周摘要统计

// 计算平均值
computeAvg(field: string): number {
  let sum = 0;
  for (const r of this.records) {
    if (field === 'steps') sum += r.steps;
    else if (field === 'water') sum += r.water;
    else if (field === 'sleep') sum += r.sleep;
    else if (field === 'calories') sum += r.calories;
  }
  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++) {
    const val = this.records[i][field];
    if (val > bestVal) {
      bestVal = val;
      bestIdx = i;
    }
  }
  return '周' + getWeekDayName(this.weekDates[bestIdx]);
}

八、设置页:用户目标管理

8.1 编辑模式切换

@State isEditing: boolean = false;

// 查看模式:只读显示
if (!this.isEditing) {
  Button('编辑目标')
    .onClick(() => { this.isEditing = true; })
}

// 编辑模式:输入框
if (this.isEditing) {
  Column() {
    this.inputRow('昵称', this.userName, (val) => { this.userName = val; })
    this.inputRow('步数目标', this.stepGoal, (val) => { this.stepGoal = val; })
    // ...
    
    Row() {
      Button('取消').onClick(() => { this.isEditing = false; this.loadSettings(); })
      Button('保存').onClick(() => { this.saveSettings(); })
    }
  }
}

8.2 数据清除确认

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

async clearAllData(): Promise<void> {
  await storageManager.clearAll();
  await this.loadSettings();
  promptAction.showToast({ message: '🗑️ 所有数据已清除' });
}

九、底部 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 存储模式

┌─────────────────────────────────────┐
│  StorageManager (单例)               │
│  ├── init(context) 初始化            │
│  ├── saveSettings() / loadSettings() │
│  ├── saveRecord() / loadRecord()     │
│  └── loadRecordsInRange()            │
└─────────────────────────────────────┘
           ↓ 封装
┌─────────────────────────────────────┐
│  @ohos.data.preferences              │
│  ├── put(key, value)                 │
│  ├── get(key, default)               │
│  ├── flush() 写入磁盘                │
│  └── clear() 清除                    │
└─────────────────────────────────────┘

10.2 Canvas 绘图流程

1. 计算布局(padding、spacing)
2. 计算数据最大值
3. 绘制网格线 + Y轴标签
4. 绘制柱体(fillRect)
5. 绘制数值标签
6. 绘制 X轴标签
7. 绘制图例(双柱图)

10.3 自定义组件设计原则

原则 说明
单一职责 每个组件只做一件事
@Prop 只读 父→子单向传递
@Link 双向 子→父双向绑定
可配置性 通过参数控制样式和行为

十一、运行效果

在这里插入图片描述


结语

这个项目让我深刻体会到:

  1. 数据持久化是基础:没有本地存储,APP 只是空壳
  2. 图表是数据可视化的灵魂:Canvas 绘图能力很重要
  3. 组件封装提升效率:自定义组件让代码更简洁
  4. 用户体验需要细节打磨:轮播、进度条、确认对话框

代码已在文中完整呈现,希望对你有所帮助!


技术栈:HarmonyOS NEXT + ArkTS + Preferences + Canvas 2D

代码行数:~1200 行 | 自定义组件:4 个 | 页面数量:4 个

Logo

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

更多推荐