HarmonyOS NEXT 实战:从零搭建「个人健康助手」—— ArkTS + ArkUI 完整开发记录
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 |
先 await 再 as 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 技术收获
- ArkTS 是"戴着镣铐跳舞"——强类型约束虽然增加编码成本,但显著减少了运行时 bug。所有类型错误在编译期暴露,上线后几乎不会出现
Cannot read property of undefined。 - 声明式 UI 的取舍——ArkUI 的
@State/@Prop/@Link体系清晰,但生命周期(尤其是TabContent的子页面刷新)需要额外注意。 - Canvas 在 ArkUI 中可用——虽然不如 Web Canvas API 丰富,但基础绘图(柱状图、折线图)完全够用,适合轻量数据可视化。
- 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 编写测试
更多推荐

所有评论(0)