鸿蒙原生项目实战(二):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)
  • 习惯卡片列表渲染
  • 打卡点击交互与状态刷新

👉 下一篇:首页 UI 构建与打卡交互实现


本文所有代码片段均来自真实鸿蒙 NEXT 项目「习惯大师」,你可以对照源码阅读效果更佳。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐