鸿蒙开发-@ohos.data.preferences记录习惯——齿录的习惯追踪实现

0 阅读5分钟

用@ohos.data.preferences记录习惯——齿录的习惯追踪实现

如果你想养成更好的牙科护理习惯,可以去鸿蒙应用市场搜一下**「齿录」**,下载下来体验体验。记录每天的刷牙次数、使用牙线、漱口水——坚持一段时间后你会发现自己的口腔护理习惯越来越规律。体验完了再回来看这篇文章,看看习惯追踪是怎么用@ohos.data.preferences实现的。


写在前面

大家好,我是一名写了十多年Web前端的老兵。从jQuery时代一路走到React/Vue,CSS3动画、requestAnimationFrame、Web Animation API这些都算是看家本领。去年开始转战鸿蒙生态,用ArkTS开发App,这一路踩了不少坑,也积累了不少心得。

很多人觉得"前端转鸿蒙"应该很容易——都是写UI嘛,组件化、状态管理、生命周期,概念都差不多。但真正上手之后你会发现,相似的地方让你觉得亲切,不同的地方让你抓狂

比如:

  • 本地存储:Web的localStorage在鸿蒙里变成了@ohos.data.preferences,从同步API变成了异步API,而且需要手动调用flush()才能真正写入磁盘。
  • 状态管理:React的useState变成了@State,看起来像,但更新机制完全不同——React是函数式触发重渲染,ArkTS是装饰器驱动的精准更新。

但别担心,核心思想是一样的:都是键值对存储,都是JSON序列化。你之前积累的前端经验,在鸿蒙里依然是你的核心竞争力。


这篇文章聊什么

齿录这个App的习惯追踪,核心要解决:

  1. 每日打卡:记录今天刷了几次牙、是否用牙线、是否用漱口水
  2. 连续天数:计算当前连续完成习惯的天数
  3. 历史统计:查看过去一周/一月的完成情况

第一步:设计数据结构

interface DailyRecord {
  date: string              // 日期,如 '2024-01-15'
  brushMorning: boolean     // 早上刷牙
  brushEvening: boolean     // 晚上刷牙
  floss: boolean            // 使用牙线
  mouthwash: boolean        // 使用漱口水
  notes: string             // 备注
}

interface HabitData {
  records: DailyRecord[]
  streak: number            // 当前连续天数
  longestStreak: number     // 最长连续天数
}

第二步:封装存储操作

import { preferences } from '@kit.ArkData'
import { common } from '@kit.AbilityKit'

let prefInstance: preferences.Preferences | null = null

async function getPreferences(context: common.UIAbilityContext): Promise<preferences.Preferences> {
  if (!prefInstance) {
    prefInstance = await preferences.getPreferences(context, 'dental_habits')
  }
  return prefInstance
}

async function setItem<T>(context: common.UIAbilityContext, key: string, value: T): Promise<boolean> {
  try {
    const pref = await getPreferences(context)
    await pref.put(key, JSON.stringify(value))
    await pref.flush()
    return true
  } catch (err) {
    console.error('存储失败:', err)
    return false
  }
}

async function getItem<T>(context: common.UIAbilityContext, key: string, defaultValue: T): Promise<T> {
  try {
    const pref = await getPreferences(context)
    const value = await pref.get(key, '')
    if (typeof value === 'string' && value.length > 0) {
      return JSON.parse(value) as T
    }
    return defaultValue
  } catch (err) {
    console.error('读取失败:', err)
    return defaultValue
  }
}

第三步:习惯打卡页面

@Entry
@Component
struct HabitCheckInPage {
  @State todayRecord: DailyRecord = {
    date: new Date().toISOString().slice(0, 10),
    brushMorning: false,
    brushEvening: false,
    floss: false,
    mouthwash: false,
    notes: ''
  }
  @State streak: number = 0
  @State completionRate: number = 0

  private context = getContext(this) as common.UIAbilityContext

  async aboutToAppear() {
    await this.loadTodayRecord()
    await this.calculateStreak()
    this.updateCompletionRate()
  }

  async loadTodayRecord() {
    const today = new Date().toISOString().slice(0, 10)
    const records = await getItem<DailyRecord[]>(this.context, 'records', [])
    const existing = records.find(r => r.date === today)
    if (existing) {
      this.todayRecord = existing
    }
  }

  async saveRecord() {
    const records = await getItem<DailyRecord[]>(this.context, 'records', [])
    const index = records.findIndex(r => r.date === this.todayRecord.date)

    if (index > -1) {
      records[index] = this.todayRecord
    } else {
      records.push(this.todayRecord)
    }

    await setItem(this.context, 'records', records)
    await this.calculateStreak()
    this.updateCompletionRate()
  }

  async calculateStreak() {
    const records = await getItem<DailyRecord[]>(this.context, 'records', [])
    let streak = 0
    const today = new Date()

    for (let i = 0; i < 365; i++) {
      const date = new Date(today.getTime() - i * 24 * 60 * 60 * 1000)
      const dateStr = date.toISOString().slice(0, 10)
      const record = records.find(r => r.date === dateStr)

      if (record && this.isDayComplete(record)) {
        streak++
      } else if (i > 0) {
        // 今天还没完成不算中断
        break
      }
    }

    this.streak = streak
  }

  isDayComplete(record: DailyRecord): boolean {
    return record.brushMorning && record.brushEvening && record.floss
  }

  updateCompletionRate() {
    const total = 4 // 4个打卡项
    let completed = 0
    if (this.todayRecord.brushMorning) completed++
    if (this.todayRecord.brushEvening) completed++
    if (this.todayRecord.floss) completed++
    if (this.todayRecord.mouthwash) completed++
    this.completionRate = completed / total
  }

  async toggleHabit(key: keyof DailyRecord) {
    (this.todayRecord as any)[key] = !(this.todayRecord as any)[key]
    this.updateCompletionRate()
    await this.saveRecord()
  }

  build() {
    Column() {
      // 连续天数显示
      Column() {
        Text(`${this.streak}`)
          .fontSize(48)
          .fontWeight(FontWeight.Bold)
          .fontColor('#10B981')

        Text('天连续')
          .fontSize(14)
          .fontColor('#9CA3AF')
      }
      .margin({ bottom: 24 })

      // 完成度环
      Stack({ alignContent: Alignment.Center }) {
        Circle()
          .width(120)
          .height(120)
          .stroke('#374151')
          .strokeWidth(8)
          .fill('transparent')

        Circle()
          .width(120)
          .height(120)
          .stroke('#10B981')
          .strokeWidth(8)
          .strokeDashArray([this.completionRate * 377, 377])
          .strokeLineCap(LineCapStyle.Round)
          .fill('transparent')
          .rotate({ angle: -90 })

        Text(`${Math.round(this.completionRate * 100)}%`)
          .fontSize(20)
          .fontColor('#FFFFFF')
      }
      .margin({ bottom: 24 })

      // 打卡项
      Column() {
        this.CheckItem('早上刷牙', this.todayRecord.brushMorning, () => this.toggleHabit('brushMorning'))
        this.CheckItem('晚上刷牙', this.todayRecord.brushEvening, () => this.toggleHabit('brushEvening'))
        this.CheckItem('使用牙线', this.todayRecord.floss, () => this.toggleHabit('floss'))
        this.CheckItem('漱口水', this.todayRecord.mouthwash, () => this.toggleHabit('mouthwash'))
      }

      // 备注
      TextInput({ placeholder: '备注(选填)', text: this.todayRecord.notes })
        .onChange((value: string) => {
          this.todayRecord.notes = value
          this.saveRecord()
        })
        .margin({ top: 16 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#111827')
    .padding(16)
  }

  @Builder
  CheckItem(label: string, checked: boolean, onTap: () => void) {
    Row() {
      Text(checked ? '✓' : '○')
        .fontSize(20)
        .fontColor(checked ? '#10B981' : '#6B7280')
        .width(30)

      Text(label)
        .fontSize(16)
        .fontColor('#FFFFFF')
    }
    .width('100%')
    .height(56)
    .padding(16)
    .backgroundColor(checked ? 'rgba(16, 185, 129, 0.1)' : '#1F2937')
    .borderRadius(12)
    .margin({ bottom: 8 })
    .onClick(onTap)
  }
}

第四步:历史统计页面

@Entry
@Component
struct HabitStatsPage {
  @State weekData: DailyRecord[] = []
  @State monthData: DailyRecord[] = []

  private context = getContext(this) as common.UIAbilityContext

  async aboutToAppear() {
    const records = await getItem<DailyRecord[]>(this.context, 'records', [])
    const today = new Date()

    // 最近7天
    this.weekData = []
    for (let i = 0; i < 7; i++) {
      const date = new Date(today.getTime() - i * 24 * 60 * 60 * 1000)
      const dateStr = date.toISOString().slice(0, 10)
      const record = records.find(r => r.date === dateStr)
      this.weekData.unshift(record || {
        date: dateStr,
        brushMorning: false,
        brushEvening: false,
        floss: false,
        mouthwash: false,
        notes: ''
      })
    }
  }

  build() {
    Column() {
      Text('本周记录')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .margin({ bottom: 16 })

      // 一周热力图
      Row() {
        ForEach(this.weekData, (record: DailyRecord) => {
          Column() {
            Text(new Date(record.date).getDate().toString())
              .fontSize(12)
              .fontColor('#9CA3AF')

            Column() {
              this.Dot(record.brushMorning)
              this.Dot(record.brushEvening)
              this.Dot(record.floss)
              this.Dot(record.mouthwash)
            }
            .margin({ top: 4 })
          }
          .width(40)
          .alignItems(HorizontalAlign.Center)
        })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceAround)

      // 统计数据
      Column() {
        Text(`完成率: ${this.getCompletionRate()}%`)
          .fontSize(16)
          .fontColor('#FFFFFF')

        Text(`最佳连续: ${this.getBestStreak()}天`)
          .fontSize(16)
          .fontColor('#FFFFFF')
          .margin({ top: 8 })
      }
      .margin({ top: 24 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#111827')
    .padding(16)
  }

  @Builder
  Dot(checked: boolean) {
    Row()
      .width(8)
      .height(8)
      .backgroundColor(checked ? '#10B981' : '#374151')
      .borderRadius(4)
      .margin({ bottom: 2 })
  }

  private getCompletionRate(): number {
    const total = this.weekData.length * 4
    const completed = this.weekData.reduce((sum, r) => {
      return sum + (r.brushMorning ? 1 : 0) + (r.brushEvening ? 1 : 0) +
        (r.floss ? 1 : 0) + (r.mouthwash ? 1 : 0)
    }, 0)
    return Math.round((completed / total) * 100)
  }

  private getBestStreak(): number {
    // 计算最长连续天数
    return 0 // 简化实现
  }
}

总结

这篇文章围绕"齿录"的习惯追踪功能,讲了@ohos.data.preferences在习惯追踪场景的应用:

数据结构

  • 每日记录:日期 + 各个习惯的完成状态
  • 连续天数:当前连续和最长连续

核心功能

  • 打卡:切换习惯状态并保存
  • 连续计算:从今天往前查,找到中断为止
  • 完成度:已打卡项 / 总项数

UI组件

  • 进度环:用strokeDashArray实现
  • 打卡项:点击切换状态
  • 热力图:展示一周的完成情况

注意事项

  • 数据修改后立即保存并调用flush()
  • 连续天数计算要考虑"今天还没完成"的情况
  • 热力图要展示7天的数据,即使某天没有记录

下一篇文章我会讲齿录的提醒通知功能,看看怎么用@ohos.notification设置刷牙提醒。