鸿蒙原生项目实战(二):ArkTS 数据模型与持久化层设计
鸿蒙原生项目实战(二):ArkTS 数据模型与持久化层设计
本文深入「习惯大师」的数据层设计,涵盖枚举定义、接口建模、Preferences 持久化、单例管理器等核心知识。
一、前言
任何应用都离不开数据层。在鸿蒙 ArkTS 中,有以下几种持久化方案:
| 方案 | 适用场景 | 特点 |
|---|---|---|
| Preferences | 轻量 KV 存储 | 同步/异步 API,适合配置和结构化 JSON |
| KV-Store | 分布式键值库 | 支持跨设备同步 |
| RelationalStore | 关系型数据库 | 类似 SQLite,适合复杂查询 |
| 分布式数据对象 | 跨设备实时同步 | 内存级共享 |
「习惯大师」的数据量较小(习惯列表 + 打卡记录),选择 Preferences 后接 JSON 序列化方案,简洁高效。
二、数据模型定义
所有数据模型统一放在 model/FinanceData.ets 中(文件名沿用了项目的命名习惯,实际是纯习惯追踪数据模型)。
2.1 分类枚举
export enum HabitCategory {
SPORT = '运动',
STUDY = '学习',
READ = '阅读',
HEALTH = '健康',
LIFE = '生活',
OTHER = '其他'
}
export enum HabitFrequency {
DAILY = '每天',
WEEKLY = '每周',
WEEKDAY = '工作日'
}
ArkTS 的枚举支持字符串值,这里直接用中文作为显示文本,方便在 UI 中直接展示。
2.2 核心接口定义
// 习惯实体
export interface Habit {
id: string;
name: string;
category: HabitCategory;
frequency: HabitFrequency;
goalCount: number; // 每日目标次数
createdAt: string; // YYYY-MM-DD
color: string; // 十六进制色值
reminderTime: string; // HH:mm
}
// 打卡记录
export interface HabitRecord {
id: string;
habitId: string;
date: string; // YYYY-MM-DD
count: number; // 当日打卡次数
}
// 创建习惯的输入参数
export interface HabitInput {
name: string;
category: HabitCategory;
frequency: HabitFrequency;
goalCount: number;
color: string;
reminderTime: string;
}
💡 设计思考:
HabitInput为什么要单独定义而不是直接用Omit<Habit, 'id' | 'createdAt'>?
因为 ArkTS 在早期版本对Omit工具类型的支持有限,显式定义输入接口可避免编译问题,代码也更清晰。
2.3 统计用接口
// 日统计
export interface DayStats {
date: string;
total: number; // 当日应有习惯数
completed: number; // 已完成数
rate: number; // 完成率 0~1
}
// 分类分布
export interface CategoryDist {
category: string;
count: number;
}
// 今日概览
export interface TodayStats {
total: number;
checked: number;
rate: number;
}
// 日历日期项
export interface CalendarDay {
day: number;
checked: boolean;
}
// 习惯卡片展示数据
export interface HabitCardInfo {
habit: Habit;
isCompleted: boolean;
currentCount: number;
record: HabitRecord | null;
}
HabitCardInfo 是一个预计算展示模型,避免在 @Builder 中临时声明变量——这是 ArkTS 组件开发中的一个最佳实践。
2.4 常量映射表
export const CATEGORY_COLORS: Record<string, ResourceColor> = {
'运动': '#FF6B6B',
'学习': '#4ECDC4',
'阅读': '#45B7D1',
'健康': '#96CEB4',
'生活': '#FFEAA7',
'其他': '#DDA0DD'
};
export const CATEGORY_ICONS: Record<string, string> = {
'运动': '🏃',
'学习': '📖',
'阅读': '📚',
'健康': '💪',
'生活': '🏠',
'其他': '📌'
};
export const ALL_CATEGORIES: HabitCategory[] = [
HabitCategory.SPORT, HabitCategory.STUDY, HabitCategory.READ,
HabitCategory.HEALTH, HabitCategory.LIFE, HabitCategory.OTHER
];
export const ALL_FREQUENCIES: HabitFrequency[] = [
HabitFrequency.DAILY, HabitFrequency.WEEKLY, HabitFrequency.WEEKDAY
];
2.5 工具函数
export function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).substring(2, 8);
}
export function getToday(): string {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
export function formatDateDisplay(dateStr: string): string {
const parts = dateStr.split('-');
if (parts.length !== 3) return dateStr;
return `${parseInt(parts[1])}月${parseInt(parts[2])}日`;
}
export function getWeekdayName(dateStr: string): string {
const d = new Date(dateStr);
const names = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
return names[d.getDay()];
}
generateId() 使用了时间戳 + 随机数组合,在离线场景下唯一性足够且无需网络请求。
三、Preferences 持久化——HabitManager 实现
3.1 单例模式
const STORE_NAME = 'habit_store';
const KEY_HABITS = 'habits';
const KEY_RECORDS = 'records';
export class HabitManager {
private static instance: HabitManager;
private preferencesPromise: Promise<preferences.Preferences> | null = null;
private context: common.Context;
private constructor(context: common.Context) {
this.context = context;
}
static init(context: common.Context): void {
if (!HabitManager.instance) {
HabitManager.instance = new HabitManager(context);
}
}
static getInstance(): HabitManager {
if (!HabitManager.instance) {
throw new Error('HabitManager not initialized.');
}
return HabitManager.instance;
}
}
为什么需要 init + getInstance 两步?
init(context)在EntryAbility.onCreate()中调用,此时 context 才可用getInstance()供各页面使用,确保全局唯一
3.2 惰性获取 Preferences 实例
private async getStore(): Promise<preferences.Preferences> {
if (!this.preferencesPromise) {
this.preferencesPromise = preferences.getPreferences(this.context, STORE_NAME);
}
return this.preferencesPromise;
}
这里将 Promise 缓存起来,避免重复调用 getPreferences。第一次调用后,后续复用同一个实例。
3.3 习惯 CRUD
获取全部习惯:
async getAllHabits(): Promise<Habit[]> {
const store = await this.getStore();
const json = await store.get(KEY_HABITS, '[]');
return JSON.parse(json as string) as Habit[];
}
核心思路:所有习惯作为一个 JSON 数组存入 Preferences 的单个 key 中。
新增习惯:
async addHabit(input: HabitInput): Promise<Habit> {
const list = await this.getAllHabits();
const newHabit: Habit = {
id: generateId(),
name: input.name,
category: input.category,
frequency: input.frequency,
goalCount: input.goalCount,
color: input.color,
reminderTime: input.reminderTime,
createdAt: getToday()
};
list.push(newHabit);
await this.saveHabits(list);
return newHabit;
}
保存(先读后写):
private async saveHabits(list: Habit[]): Promise<void> {
const store = await this.getStore();
await store.put(KEY_HABITS, JSON.stringify(list));
await store.flush(); // 立即持久化
}
⚠️
flush()的重要性:Preferences 的put默认是异步写入内存,flush()才会同步写入磁盘。在打卡这种高频操作场景,不 flush 可能导致应用被杀后数据丢失。
删除习惯(级联删除打卡记录):
async deleteHabit(id: string): Promise<void> {
const list = await this.getAllHabits();
let idx = -1;
for (let i = 0; i < list.length; i++) {
if (list[i].id === id) { idx = i; break; }
}
if (idx !== -1) {
list.splice(idx, 1);
await this.saveHabits(list);
}
// 级联删除相关打卡记录
const records = await this.getAllRecords();
const filtered: HabitRecord[] = [];
for (const r of records) {
if (r.habitId !== id) {
filtered.push(r);
}
}
await this.saveRecords(filtered);
}
💡 这里使用
for循环而不是filter是为了更好兼容 ArkTS 早期版本的类型推断。
3.4 打卡记录
打卡(check-in):
async checkIn(habitId: string, date: string): Promise<HabitRecord> {
const records = await this.getAllRecords();
let existing: HabitRecord | null = null;
for (const r of records) {
if (r.habitId === habitId && r.date === date) {
existing = r;
break;
}
}
if (existing !== null) {
existing.count += 1; // 已有记录:累加次数
await this.saveRecords(records);
return existing;
}
const record: HabitRecord = { // 无记录:新建
id: generateId(), habitId, date, count: 1
};
records.push(record);
await this.saveRecords(records);
return record;
}
取消打卡(uncheck):
async uncheckIn(habitId: string, date: string): Promise<void> {
const records = await this.getAllRecords();
let idx = -1;
for (let i = 0; i < records.length; i++) {
if (records[i].habitId === habitId && records[i].date === date) {
idx = i; break;
}
}
if (idx !== -1) {
if (records[idx].count <= 1) {
records.splice(idx, 1); // 只有1次记录:删除
} else {
records[idx].count -= 1; // 多次记录:减1
}
await this.saveRecords(records);
}
}
四、统计业务方法
4.1 获取连续打卡天数
async getStreak(habitId: string): Promise<number> {
const records = await this.getAllRecords();
const habitRecords: HabitRecord[] = [];
for (const r of records) {
if (r.habitId === habitId) habitRecords.push(r);
}
habitRecords.sort((a, b) => b.date.localeCompare(a.date));
if (habitRecords.length === 0) return 0;
let streak = 0;
const today = new Date();
for (let i = 0; i < 365; i++) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
const dateStr = `${y}-${m}-${day}`;
let found = false;
for (const r of habitRecords) {
if (r.date === dateStr && r.count >= 1) {
found = true; break;
}
}
if (found) {
streak++;
} else {
break; // 中断即停止
}
}
return streak;
}
4.2 获取月度每日统计
async getMonthDailyStats(year: number, month: number): Promise<DayStats[]> {
const habits = await this.getAllHabits();
const records = await this.getAllRecords();
const days = getMonthDays(year, month);
const stats: DayStats[] = [];
for (let d = 1; d <= days; d++) {
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
let completed = 0;
for (const h of habits) {
let found = false;
for (const r of records) {
if (r.date === dateStr && r.habitId === h.id && r.count >= h.goalCount) {
found = true; break;
}
}
if (found) completed++;
}
stats.push({ date: dateStr, total: habits.length, completed, rate: habits.length > 0 ? completed / habits.length : 0 });
}
return stats;
}
4.3 获取今日概览
async getTodayStats(): Promise<TodayStats> {
const habits = await this.getAllHabits();
const records = await this.getAllRecords();
const today = getToday();
let checked = 0;
for (const h of habits) {
let found = false;
for (const r of records) {
if (r.habitId === h.id && r.date === today && r.count >= h.goalCount) {
found = true; break;
}
}
if (found) checked++;
}
return { total: habits.length, checked, rate: habits.length > 0 ? checked / habits.length : 0 };
}
五、数据层设计总结
| 设计点 | 方案 | 理由 |
|---|---|---|
| 数据格式 | JSON 字符串 | Preferences 天然支持字符串 |
| 存储粒度 | 全量数组 | 数据量小,简化编程模型 |
| 唯一 ID | 时间戳+随机数 | 离线可用,无需服务端 |
| 单例模式 | 懒汉式 | 配合 Ability 生命周期 |
| 级联删除 | 手动过滤 | 无外键约束,需代码保证 |
| 即时落盘 | flush() |
防止进程被杀数据丢失 |

六、下篇预告
下一篇我们将进入首页 UI 构建与打卡交互实现:
- Column/Row/Scroll 布局实战
- 进度环组件(Circle + Stack)
- 习惯卡片列表渲染
- 打卡点击交互与状态刷新
本文所有代码片段均来自真实鸿蒙 NEXT 项目「习惯大师」,你可以对照源码阅读效果更佳。
更多推荐


所有评论(0)