HarmonyOS NEXT 实战:从零搭建「个人健康助手」—— ArkTS + ArkUI 完整开发记录

本文首发于 CSDN,记录一次完整的鸿蒙原生应用开发过程,包含架构设计、踩坑修复、编译全流程。


一、前言

HarmonyOS NEXT 发布以来,ArkTS(Ark TypeScript)作为其主要开发语言,以声明式 UI + 强类型约束为特色,与传统的 TypeScript 开发体验有显著差异。本文以 「个人健康助手」 应用为实战案例,完整记录从项目创建、页面开发、数据持久化到 Canvas 图表绘制、编译调试的全过程,希望能为正在学习 HarmonyOS 开发的读者提供一份可落地的参考。

应用简介

个人健康助手 是一款基于 API 23 (HarmonyOS 6.1.0) 的多页面健康管理应用,核心功能:

模块 功能
🏠 首页仪表盘 步数 / 饮水 / 睡眠 / 卡路里 / 心情一览
📝 数据记录 +/- 步进器录入 + Emoji 心情选择 + 备注
📊 数据统计 Canvas 柱状图 + 周切换 + 周摘要卡片
⚙️ 个人设置 目标编辑 + 数据清除 + 关于信息

所有数据通过 @ohos.data.preferences 本地持久化,支持增删查改。


二、项目搭建与目录结构

2.1 环境准备

  • IDE: DevEco Studio 5.0+
  • SDK: API 23 (HarmonyOS 6.1.0)
  • 开发语言: ArkTS(Stage 模型)
  • 构建工具: hvigor(内置)

2.2 项目初始化

通过 DevEco Studio 创建 Empty Ability 模板项目,核心配置如下:

build-profile.json5(项目级):

{
  app: {
    products: [{
      name: "default",
      compatibleSdkVersion: "6.1.0(23)",
      runtimeOS: "HarmonyOS"
    }]
  }
}

2.3 最终目录结构

entry/src/main/ets/
├── entryability/
│   └── EntryAbility.ets           ← Ability 入口,初始化存储
├── model/
│   └── HealthData.ets             ← 数据模型 + 工厂函数
├── utils/
│   └── StorageManager.ets         ← 持久化管理器(单例)
├── components/
│   ├── HealthCard.ets             ← 健康卡片(带进度条)
│   └── CustomComponents.ets       ← 通用组件库
└── pages/
    ├── Index.ets                  ← 底部 Tab 导航(4 Tab)
    ├── HomePage.ets               ← 首页仪表盘
    ├── LogPage.ets                ← 数据记录页
    ├── StatsPage.ets              ← Canvas 图表统计页
    └── ProfilePage.ets            ← 个人设置页

三、数据层设计

3.1 数据模型

ArkTS 对 interface 有严格约束——不允许内联对象类型,所有嵌套对象必须提取为独立接口:

// model/HealthData.ets

// ✅ 扁平接口,无内联对象
export interface DailyRecord {
  date: string;
  steps: number;
  water: number;     // ml
  sleep: number;     // hours
  calories: number;  // kcal
  mood: number;      // 1-5
  note: string;
}

export interface UserSettings {
  userName: string;
  stepGoal: number;
  waterGoal: number;
  sleepGoal: number;
  calorieGoal: number;
}

3.2 仓库函数模式

ArkTS 严格模式下,直接声明对象字面量数组会报 arkts-no-noninferrable-arr-literals。解决方法是 工厂函数——类型由返回值自动推断:

// ✅ 工厂函数
export function createDefaultRecord(date: string): DailyRecord {
  return {
    date: 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
  };
}

3.3 数据持久化:Preferences 封装

使用 @ohos.data.preferences 实现本地存储,封装为单例管理器:

// utils/StorageManager.ets

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

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

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

  async saveSettings(settings: UserSettings): Promise<void> {
    const json = JSON.stringify(settings);
    await this.store_!.put('user_settings', json);
    await this.store_!.flush();
  }

  async loadSettings(): Promise<UserSettings> {
    const val = await this.store_!.get('user_settings', '');
    const json: string = val as string;  // ⚠️ 需要显式 cast
    if (json === '') return createDefaultSettings();
    return JSON.parse(json) as UserSettings;
  }

  // ... saveRecord / loadRecord / loadAllRecords / clearAll
}

const storageManager = new StorageManager();
export default storageManager;

⚠️ 踩坑记录preferences.get() 返回类型为 Promise<ValueType>ValueType = string | number | boolean),直接赋值给 string 会报错。必须先 await,再用 as string 断言

3.4 在 Ability 生命周期中初始化

存储管理器需要在页面渲染前完成初始化,最合适的位置是 EntryAbility.onCreate()

// entryability/EntryAbility.ets
import storageManager from '../utils/StorageManager';

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    storageManager.init(this.context);  // ✅ 页面加载前完成初始化
    // ...
  }
}

四、UI 层实现

4.1 底部 Tab 导航(Tabs 组件)

使用 Tabs + TabContent 实现底部导航栏,通过 @Builder 封装 tab 图标:

// pages/Index.ets

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

  @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'))
    }
  }
}

4.2 首页仪表盘

首页包含:问候语 → 轮播健康小贴士 → 三个健康卡片 → 卡路里+心情行。

卡片使用 Flex({ wrap: FlexWrap.Wrap }) 实现自适应换行布局

Flex({ wrap: FlexWrap.Wrap }) {
  HealthCard({ title: '步数', value: '6,842', unit: '步',
    progress: 0.68, icon: '🏃', color: Color.Blue })
    .margin({ bottom: 8, right: 6 })

  // ... 更多卡片
}

⚠️ 注意Row 组件没有 .flexWrap() 方法,必须使用 Flex 组件,且 wrap 是构造参数而非链式方法。

4.3 数据记录页:NumberStepper 组件

核心交互组件是 数字步进器,通过 @Link 实现父子组件双向绑定:

@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)
      Button('-').onClick(() => { this.value -= this.step })
      Text(this.value.toString())
      Text(this.unit)
      Button('+').onClick(() => { this.value += this.step })
    }
  }
}

父组件中通过 @Link 连接状态:

@State steps: number = 0;

// 在 build 中
NumberStepper({
  label: '今日步数', unit: '步', step: 100,
  minValue: 0, maxValue: 99999, value: this.steps
})

4.4 心情选择器: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(this.getEmoji(level)).fontSize(32)
            .opacity(level === this.currentMood ? 1.0 : 0.4)
          Text(this.getLabel(level)).fontSize(10)
        }
        .onClick(() => { this.currentMood = level; })
      }, (level: number) => level.toString())  // ⚠️ key 生成器必须
    }
  }
}

⚠️ ForEach 必须显式标注回调参数类型,且第三个参数是 key 生成器,不可省略。


五、Canvas 图表绘制

这是本应用最复杂的部分——使用 CanvasRenderingContext2D 手动绘制统计图表。

5.1 初始化 Canvas 上下文

private barCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D();
private waterCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D();

在 build 中绑定:

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

5.2 步数柱状图

drawStepChart(): void {
  const ctx: CanvasRenderingContext2D = this.barCtx;
  const chartW: number = 300;
  const chartH: number = 140;
  const padL: number = 40, padR: number = 10;
  const padT: number = 20, padB: number = 30;

  ctx.clearRect(0, 0, 350, 190);

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

  // 2. 画网格线
  for (let i = 0; i <= 4; i++) {
    const yy: number = padT + (chartH / 4) * i;
    ctx.moveTo(padL, yy); ctx.lineTo(350 - padR, yy); ctx.stroke();
    // 刻度标签
    ctx.fillText(this.formatShort(maxVal * (1 - i / 4)), padL - 6, yy + 4);
  }

  // 3. 画柱状图
  const gap = (chartW * 0.3) / (7 + 1);
  const barW = (chartW - chartW * 0.3) / 7;

  for (let i = 0; i < 7; i++) {
    const barH = (this.records[i].steps / maxVal) * chartH;
    const bx = padL + gap + i * (barW + gap);
    ctx.fillStyle = '#007AFF';
    ctx.fillRect(bx, padT + chartH - barH, barW, barH);
    // 数值 + 星期标签
    ctx.fillText(this.formatShort(this.records[i].steps), bx + barW / 2, ...);
    ctx.fillText(getWeekDayName(this.weekDates[i]), bx + barW / 2, ...);
  }
}

5.3 饮水+睡眠分组柱状图

核心思路是在同一个 X 位置画出两根不同颜色的柱子:

// 分组柱
const innerBarW = groupW * 0.35;

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

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

六、ArkTS 编译错误全记录(重点)

整个开发过程中遇到的 ArkTS 编译错误近 20 个,以下是完整的错误类型与解决方案对照表:

6.1 编译错误详解

错误编码 错误信息 根因 修复方案
10605038 arkts-no-obj-literals-as-types 接口属性使用内联对象类型 提取为独立接口
10605038 arkts-no-untyped-obj-literals 对象字面量未声明类型 添加类型标注 or 工厂函数
10605038 arkts-no-noninferrable-arr-literals 数组字面量因可选属性无法推断 工厂函数
10605008 arkts-no-any-unknown 使用了 any 类型 替换为具体类型或用独立 const
10505001 Property 'X' does not exist on type 'void' @Builder 返回 void 后链式调用 将链式调用移入 @Builder 内部
10505001 Property 'margin' does not exist on type 'void' 同上 Column({ space: 8 }) 包裹
10505001 Property 'justifyContent' does not exist on type 'FlexAttribute' Flex 无该链式方法 移除或改用构造参数
10505001 Property 'label' is private and can not be initialized through the component constructor 组件字段未加 @Prop 改为 @Prop 装饰器
10505001 Conversion of type 'Promise<ValueType>' to type 'string' as string 前漏了 await awaitas string

6.2 最值得注意的三个坑

坑一:对象字面量必须有类型

出现在 Canvas 绘图中:

// ❌ 编译错误
const padding = { top: 20, bottom: 30, left: 40, right: 10 };

// ✅ 改成独立 const
const padL = 40; const padR = 10;
const padT = 20; const padB = 30;

坑二:@Builder 返回 void

// ❌ 编译错误
this.inputRow('昵称', ...).margin({ bottom: 8 })

// ✅ margin 放到 Builder 内部,或用 Column 包裹
Column({ space: 8 }) {
  this.inputRow('昵称', ...)
  this.inputRow('步数', ...)
}

坑三:private 字段不能通过构造器传参

@Component
export struct NumberStepper {
  // ❌ private 字段不可外部传入
  private label: string = '';

  // ✅ 改为 @Prop
  @Prop label: string = '';
}

6.3 编译警告

除了 ERROR,还有一批 WARN 不影响编译,但值得注意:

  • Function may throw exceptions. Special handling is required. —— async 方法链路上的异常需要 try/catch
  • 'showToast' has been deprecated. —— promptAction.showToast 已废弃,新 API 在 @ohos.promptAction 中有替代
  • 'show' has been deprecated. —— AlertDialog.show 废弃,建议用 AlertDialog.showDialog

七、资源文件配置

color.json

新增应用调色板,通过 $r('app.color.xxx') 统一引用:

{
  "color": [
    { "name": "primary", "value": "#007AFF" },
    { "name": "page_bg", "value": "#F2F2F7" },
    { "name": "text_primary", "value": "#1A1A1A" },
    { "name": "text_secondary", "value": "#8E8E93" }
  ]
}

string.json

保留默认的多语言字符串资源,方便后续国际化。


八、运行效果

在这里插入图片描述


九、总结与收获

9.1 技术收获

  1. ArkTS 是"戴着镣铐跳舞"——强类型约束虽然增加编码成本,但显著减少了运行时 bug。所有类型错误在编译期暴露,上线后几乎不会出现 Cannot read property of undefined
  2. 声明式 UI 的取舍——ArkUI 的 @State / @Prop / @Link 体系清晰,但生命周期(尤其是 TabContent 的子页面刷新)需要额外注意。
  3. Canvas 在 ArkUI 中可用——虽然不如 Web Canvas API 丰富,但基础绘图(柱状图、折线图)完全够用,适合轻量数据可视化。
  4. Preferences 是轻量持久化的首选——无需引入数据库,适合单用户、少量数据的场景。

9.2 踩坑总结(速查表)

场景 正确做法
接口属性为对象 提取为独立 interface
数组字面量 用工厂函数 function make(): Type
ForEach 回调 (item: Type) => { } + 第三个 key 参数
换行布局 Flex({ wrap: FlexWrap.Wrap })
组件间距 用子元素 margin,不用 rowGap
状态初始化 @State 直接赋值,不用 get 推导
异步数据 全部加 try/catch,在 Ability 层预初始化
@Builder 链式 margin 等属性放入 Builder 内部
组件传入参数 private@Prop(只读)或 @Link(双向)

9.3 后续可扩展方向

  • 🌙 深色模式:通过 @Provide / @Consume 实现主题切换
  • 📅 日历视图:用 CalendarPicker 展示月度健康热力图
  • 📤 数据导出:生成 CSV 文件分享给其他应用
  • 🧪 单元测试:使用 @ohos.hypium 为 StorageManager 编写测试

Logo

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

更多推荐