HarmonyOS APP开发---酱造录酱油酿造记录App

2 阅读6分钟

如果你想做一个酱油酿造记录App,需要用到Preferences和定时提醒

你可以在鸿蒙应用市场搜索"酱造录"下载体验,看看一个完整的酱油酿造记录App长什么样。先下载看看效果,再回来跟着文章写代码。


写在前面

酱油酿造是一门古老的手艺。从选豆、蒸煮、制曲,到发酵、压榨、灭菌,每一个环节都需要精确控制温度和时间。尤其是制曲阶段,温度稍微控制不好,曲的质量就会大打折扣,直接影响最终酱油的风味。

我做"酱造录"这个App,就是为了帮助酱油酿造爱好者记录整个酿造过程中的关键数据。你可以记录制曲阶段的温度日志,设置搅拌计时器提醒自己按时搅拌,还能追踪熟成天数,知道什么时候可以开坛品尝。

这篇文章,我们就来聊聊怎么用鸿蒙的Preferences API来实现时序数据存储和倒计时功能。


这篇文章聊什么

今天要聊三个核心功能:

  1. 制曲温度日志 -- 按时间记录温度变化,形成时序数据
  2. 搅拌计时 -- 定时提醒用户进行搅拌操作
  3. 熟成天数倒计时 -- 从入坛开始计算熟成进度

温度日志的时序数据存储是这篇文章的重点。和之前文章里"存一个对象"不同,时序数据是"存一系列按时间排列的数据点",需要考虑数据量增长后的性能问题。


第一步:时序数据的数据结构设计

温度日志是典型的时序数据:每隔一段时间记录一次温度,形成一条温度曲线。

在React端:

// React 端:单条温度日志
const tempLog = {
  timestamp: '2024-06-15T08:00:00',  // 记录时间
  temperature: 32.5,                 // 温度值
  humidity: 85,                      // 湿度值
  note: '曲料表面出现白色菌丝'        // 备注
};

// React 端:一批酱油的酿造记录
const brewBatch = {
  id: 'brew_001',
  name: '2024夏酿-头道',
  startDate: '2024-06-01',
  stage: 'fermenting',    // koji(制曲) / fermenting(发酵) / aging(熟成) / done(完成)
  kojiDays: 3,             // 制曲天数
  fermentDays: 180,        // 发酵天数
  agingDays: 90,           // 熟成天数
  tempLogs: [tempLog],    // 温度日志
  stirInterval: 4,        // 搅拌间隔(小时)
  lastStirTime: '2024-06-15T08:00:00',  // 上次搅拌时间
  notes: ''
};

在ArkTS里:

// ArkTS 端:酿造阶段枚举
enum BrewStage {
  KOJI = 'koji',           // 制曲
  FERMENTING = 'fermenting', // 发酵
  AGING = 'aging',         // 熟成
  DONE = 'done'            // 完成
}

// ArkTS 端:温度日志接口
interface TempLog {
  timestamp: string;       // ISO时间戳
  temperature: number;     // 温度
  humidity: number;        // 湿度
  note: string;            // 备注
}

// ArkTS 端:酿造批次接口
interface BrewBatch {
  id: string;
  name: string;
  startDate: string;
  stage: string;
  kojiDays: number;
  fermentDays: number;
  agingDays: number;
  tempLogs: TempLog[];
  stirInterval: number;     // 搅拌间隔(小时)
  lastStirTime: string;     // 上次搅拌时间
  notes: string;
}

第二步:温度日志的存储和查询

温度日志的特点是数据量会随时间增长。如果你每天记录4次温度,一年就是1460条记录。对于Preferences来说,这个量级还是可以接受的,但我们需要注意存储效率。

在React端:

// React 端:添加温度日志
function addTempLog(batchId, log) {
  const batches = JSON.parse(localStorage.getItem('brew_batches') || '[]');
  const batch = batches.find(b => b.id === batchId);
  if (batch) {
    batch.tempLogs.push(log);
    localStorage.setItem('brew_batches', JSON.stringify(batches));
  }
  return batch;
}

// React 端:获取某一天的日志
function getTempLogsByDate(batch, date) {
  return batch.tempLogs.filter(log => log.timestamp.startsWith(date));
}

// React 端:计算某一天的平均温度
function getAvgTemperature(batch, date) {
  const dayLogs = getTempLogsByDate(batch, date);
  if (dayLogs.length === 0) return null;
  const sum = dayLogs.reduce((acc, log) => acc + log.temperature, 0);
  return Math.round((sum / dayLogs.length) * 10) / 10;
}

在ArkTS里:

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

// ArkTS 端:添加温度日志
async function addTempLog(
  preferencesStore: preferences.Preferences,
  batchId: string,
  log: TempLog
): Promise<BrewBatch | null> {
  const batchesJson = await preferencesStore.get('brew_batches', '[]');
  const batches: BrewBatch[] = JSON.parse(batchesJson as string);

  const batchIndex = batches.findIndex((b: BrewBatch) => b.id === batchId);
  if (batchIndex === -1) {
    return null;
  }

  batches[batchIndex].tempLogs.push(log);

  await preferencesStore.put('brew_batches', JSON.stringify(batches));
  await preferencesStore.flush();

  return batches[batchIndex];
}

// ArkTS 端:获取某一天的日志
function getTempLogsByDate(batch: BrewBatch, date: string): TempLog[] {
  return batch.tempLogs.filter((log: TempLog) => log.timestamp.startsWith(date));
}

// ArkTS 端:计算某一天的平均温度
function getAvgTemperature(batch: BrewBatch, date: string): number | null {
  const dayLogs = getTempLogsByDate(batch, date);
  if (dayLogs.length === 0) {
    return null;
  }
  const sum = dayLogs.reduce((acc: number, log: TempLog) => acc + log.temperature, 0);
  return Math.round((sum / dayLogs.length) * 10) / 10;
}

getTempLogsByDate函数用了一个小技巧:log.timestamp.startsWith(date)。因为我们的时间戳格式是YYYY-MM-DDTHH:mm:ss,所以用startsWith(date)就能筛选出某一天的所有日志。这比解析时间戳再比较要简洁得多。

getAvgTemperature返回值保留了小数点后一位。Math.round((sum / dayLogs.length) * 10) / 10这个写法的作用是四舍五入到一位小数。先乘10,四舍五入取整,再除10。


第三步:搅拌计时逻辑

搅拌是酱油发酵过程中非常重要的操作。每隔一段时间就需要搅拌一次,让酱醅均匀接触空气,促进有益菌的生长。

计时的核心逻辑是:记录上次搅拌的时间,然后计算距离下次搅拌还有多久。

在React端:

// React 端:计算距离下次搅拌的剩余时间(分钟)
function getNextStirRemaining(batch) {
  if (!batch.lastStirTime || !batch.stirInterval) return null;

  const lastStir = new Date(batch.lastStirTime).getTime();
  const now = Date.now();
  const intervalMs = batch.stirInterval * 60 * 60 * 1000; // 小时转毫秒

  const nextStirTime = lastStir + intervalMs;
  const remainingMs = nextStirTime - now;

  if (remainingMs <= 0) {
    return 0; // 已到搅拌时间
  }

  return Math.round(remainingMs / (60 * 1000)); // 转成分钟
}

// React 端:记录搅拌操作
function recordStir(batchId) {
  const batches = JSON.parse(localStorage.getItem('brew_batches') || '[]');
  const batch = batches.find(b => b.id === batchId);
  if (batch) {
    batch.lastStirTime = new Date().toISOString();
    localStorage.setItem('brew_batches', JSON.stringify(batches));
  }
  return batch;
}

在ArkTS里:

// ArkTS 端:计算距离下次搅拌的剩余分钟数
function getNextStirRemaining(batch: BrewBatch): number {
  if (!batch.lastStirTime || batch.stirInterval <= 0) {
    return -1; // 没有设置搅拌计划
  }

  const lastStir = new Date(batch.lastStirTime).getTime();
  const now = Date.now();
  const intervalMs = batch.stirInterval * 60 * 60 * 1000;

  const nextStirTime = lastStir + intervalMs;
  const remainingMs = nextStirTime - now;

  if (remainingMs <= 0) {
    return 0; // 已到或超过搅拌时间
  }

  return Math.round(remainingMs / (60 * 1000));
}

// ArkTS 端:记录搅拌操作
async function recordStir(
  preferencesStore: preferences.Preferences,
  batchId: string
): Promise<BrewBatch | null> {
  const batchesJson = await preferencesStore.get('brew_batches', '[]');
  const batches: BrewBatch[] = JSON.parse(batchesJson as string);

  const batchIndex = batches.findIndex((b: BrewBatch) => b.id === batchId);
  if (batchIndex === -1) {
    return null;
  }

  batches[batchIndex].lastStirTime = new Date().toISOString();

  await preferencesStore.put('brew_batches', JSON.stringify(batches));
  await preferencesStore.flush();

  return batches[batchIndex];
}

搅拌计时器的UI组件:

// ArkTS 端:搅拌计时器组件
@Component
struct StirTimer {
  batch: BrewBatch;
  // 计时器ID,用于清理
  private timerId: number = -1;
  @State remainingMinutes: number = 0;

  aboutToAppear() {
    // 初始化剩余时间
    this.remainingMinutes = getNextStirRemaining(this.batch);
    // 每分钟更新一次
    this.timerId = setInterval(() => {
      this.remainingMinutes = getNextStirRemaining(this.batch);
    }, 60000);
  }

  aboutToDisappear() {
    // 组件销毁时清理计时器
    if (this.timerId !== -1) {
      clearInterval(this.timerId);
    }
  }

  // 格式化剩余时间
  private formatRemaining(minutes: number): string {
    if (minutes < 0) return '未设置';
    if (minutes === 0) return '该搅拌了!';
    const hours = Math.floor(minutes / 60);
    const mins = minutes % 60;
    if (hours > 0) {
      return `${hours}小时${mins}分钟`;
    }
    return `${mins}分钟`;
  }

  build() {
    Column() {
      Row() {
        Text('下次搅拌')
          .fontSize(16)
          .fontColor('#333')
        Blank()
        Text(this.formatRemaining(this.remainingMinutes))
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(
            this.remainingMinutes === 0 ? '#f44336' :
            this.remainingMinutes < 60 ? '#ff9800' : '#4caf50'
          )
      }
      .width('100%')
      .padding(16)
      .backgroundColor(
        this.remainingMinutes === 0 ? '#fff3e0' : '#f5f5f5'
      )
      .borderRadius(8)
      .margin({ bottom: 12 })

      // 搅拌按钮
      Button('记录搅拌')
        .width('100%')
        .height(44)
        .fontSize(16)
        .backgroundColor('#4caf50')
        .fontColor('#ffffff')
        .borderRadius(8)
        .enabled(this.remainingMinutes === 0 || this.remainingMinutes < 0)
        .onClick(() => {
          // 触发搅拌记录
          // 实际项目中需要调用 recordStir 并更新UI
        })
    }
  }
}

这里用了setInterval来定时更新剩余时间。aboutToAppear里启动计时器,aboutToDisappear里清理计时器。这和React的useEffect里设置和清理定时器的模式是一样的。

formatRemaining函数把剩余分钟数格式化成"X小时Y分钟"的形式,方便用户阅读。当剩余时间为0时,显示"该搅拌了!"并用红色高亮。


第四步:熟成天数倒计时

熟成是酱油酿造的最后阶段,也是最需要耐心的阶段。我们需要一个倒计时来显示距离熟成完成还有多少天。

// ArkTS 端:计算熟成进度
function getAgingProgress(batch: BrewBatch): {
  totalDays: number;
  passedDays: number;
  remainingDays: number;
  percentage: number;
} {
  // 总熟成天数 = 制曲天数 + 发酵天数 + 熟成天数
  const totalDays = batch.kojiDays + batch.fermentDays + batch.agingDays;
  const startDate = new Date(batch.startDate);
  const now = new Date();
  const passedDays = Math.floor(
    (now.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000)
  );
  const remainingDays = Math.max(0, totalDays - passedDays);
  const percentage = Math.min(100, Math.round((passedDays / totalDays) * 100));

  return { totalDays, passedDays, remainingDays, percentage };
}

熟成进度UI:

// ArkTS 端:熟成进度组件
@Component
struct AgingProgress {
  batch: BrewBatch;

  build() {
    const progress = getAgingProgress(this.batch);

    Column() {
      Text('酿造进度')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .margin({ bottom: 16 })

      // 进度条
      Progress({ value: progress.percentage, total: 100 })
        .width('100%')
        .color('#8d6e63')
        .margin({ bottom: 12 })

      // 详细信息
      Row() {
        Text(`已过 ${progress.passedDays} 天`)
          .fontSize(14)
          .fontColor('#666')
        Blank()
        Text(`剩余 ${progress.remainingDays} 天`)
          .fontSize(14)
          .fontColor('#8d6e63')
          .fontWeight(FontWeight.Bold)
      }
      .width('100%')
      .margin({ bottom: 8 })

      Text(`总计 ${progress.totalDays} 天 (${progress.percentage}%)`)
        .fontSize(12)
        .fontColor('#999')
        .width('100%')
        .textAlign(TextAlign.Center)

      // 阶段指示
      Row() {
        this.buildStageIndicator('制曲', batch.kojiDays, progress)
        Text('>')
          .fontSize(14)
          .fontColor('#ccc')
          .margin({ left: 8, right: 8 })
        this.buildStageIndicator('发酵', batch.fermentDays, progress)
        Text('>')
          .fontSize(14)
          .fontColor('#ccc')
          .margin({ left: 8, right: 8 })
        this.buildStageIndicator('熟成', batch.agingDays, progress)
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
      .margin({ top: 16 })
    }
    .padding(16)
    .backgroundColor('#ffffff')
    .borderRadius(12)
  }

  @Builder
  buildStageIndicator(name: string, days: number, progress: { passedDays: number }) {
    Column() {
      Text(name)
        .fontSize(14)
        .fontColor('#666')
      Text(`${days}天`)
        .fontSize(12)
        .fontColor('#999')
    }
  }
}

流程图

flowchart TD
    A[用户创建酿造批次] --> B[设置制曲/发酵/熟成参数]
    B --> C[记录开始日期]
    C --> D[进入制曲阶段]

    D --> E[定时记录温度日志]
    E --> F[按时间存储到 tempLogs 数组]
    F --> G{制曲完成?}
    G -- 否 --> E
    G -- 是 --> H[进入发酵阶段]

    H --> I[设置搅拌间隔]
    I --> J[计时器开始倒计时]
    J --> K{到达搅拌时间?}
    K -- 是 --> L[提醒用户搅拌]
    L --> M[用户记录搅拌]
    M --> J
    K -- 否 --> J

    N[发酵完成] --> O[进入熟成阶段]
    O --> P[熟成倒计时开始]
    P --> Q[每日更新进度]
    Q --> R{熟成完成?}
    R -- 否 --> Q
    R -- 是 --> S[标记为完成]

React vs ArkTS 对比表

功能React (Web)ArkTS (鸿蒙)
定时器setInterval / clearIntervalsetInterval / clearInterval(一致)
生命周期清理useEffect return 清理函数aboutToDisappear()
时间戳比较getTime() 毫秒差getTime() 毫秒差(一致)
startsWith 筛选日期string.startsWith()string.startsWith()(一致)
四舍五入到N位小数Math.round(x * 10^n) / 10^nMath.round(x * 10^n) / 10^n(一致)
进度条div + CSS widthProgress 组件

总结

这篇文章我们实现了"酱造录"App的核心功能:

  1. 温度日志 -- 用数组存储时序数据,支持按日期筛选和统计
  2. 搅拌计时 -- 用setInterval实现定时更新,记录上次搅拌时间来计算剩余时间
  3. 熟成倒计时 -- 根据开始日期和总天数计算进度百分比

时序数据存储是这篇文章的核心。tempLogs数组会随着时间不断增长,每条记录包含时间戳、温度、湿度和备注。在Preferences里存储时,整个批次对象(包括tempLogs)会被序列化成一个JSON字符串。对于几百到几千条记录的量级,这种方式是可行的。但如果数据量更大,就需要考虑分批存储或者使用数据库了。

如果你对酱油酿造感兴趣,去鸿蒙应用市场搜索"酱造录"下载体验吧。