如果你想做一个酱油酿造记录App,需要用到Preferences和定时提醒
你可以在鸿蒙应用市场搜索"酱造录"下载体验,看看一个完整的酱油酿造记录App长什么样。先下载看看效果,再回来跟着文章写代码。
写在前面
酱油酿造是一门古老的手艺。从选豆、蒸煮、制曲,到发酵、压榨、灭菌,每一个环节都需要精确控制温度和时间。尤其是制曲阶段,温度稍微控制不好,曲的质量就会大打折扣,直接影响最终酱油的风味。
我做"酱造录"这个App,就是为了帮助酱油酿造爱好者记录整个酿造过程中的关键数据。你可以记录制曲阶段的温度日志,设置搅拌计时器提醒自己按时搅拌,还能追踪熟成天数,知道什么时候可以开坛品尝。
这篇文章,我们就来聊聊怎么用鸿蒙的Preferences API来实现时序数据存储和倒计时功能。
这篇文章聊什么
今天要聊三个核心功能:
- 制曲温度日志 -- 按时间记录温度变化,形成时序数据
- 搅拌计时 -- 定时提醒用户进行搅拌操作
- 熟成天数倒计时 -- 从入坛开始计算熟成进度
温度日志的时序数据存储是这篇文章的重点。和之前文章里"存一个对象"不同,时序数据是"存一系列按时间排列的数据点",需要考虑数据量增长后的性能问题。
第一步:时序数据的数据结构设计
温度日志是典型的时序数据:每隔一段时间记录一次温度,形成一条温度曲线。
在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 / clearInterval | setInterval / clearInterval(一致) |
| 生命周期清理 | useEffect return 清理函数 | aboutToDisappear() |
| 时间戳比较 | getTime() 毫秒差 | getTime() 毫秒差(一致) |
| startsWith 筛选日期 | string.startsWith() | string.startsWith()(一致) |
| 四舍五入到N位小数 | Math.round(x * 10^n) / 10^n | Math.round(x * 10^n) / 10^n(一致) |
| 进度条 | div + CSS width | Progress 组件 |
总结
这篇文章我们实现了"酱造录"App的核心功能:
- 温度日志 -- 用数组存储时序数据,支持按日期筛选和统计
- 搅拌计时 -- 用setInterval实现定时更新,记录上次搅拌时间来计算剩余时间
- 熟成倒计时 -- 根据开始日期和总天数计算进度百分比
时序数据存储是这篇文章的核心。tempLogs数组会随着时间不断增长,每条记录包含时间戳、温度、湿度和备注。在Preferences里存储时,整个批次对象(包括tempLogs)会被序列化成一个JSON字符串。对于几百到几千条记录的量级,这种方式是可行的。但如果数据量更大,就需要考虑分批存储或者使用数据库了。
如果你对酱油酿造感兴趣,去鸿蒙应用市场搜索"酱造录"下载体验吧。