咖啡电量(CoffeeBattery)首页要展示用户当前的"代谢状态":是刚喝下的吸收期?已经到巅峰期?在稳定区?还是精神疲劳区?用户主动戒咖期?这是 5 种业务状态,UI 要据此显示不同颜色、不同文案、不同图标。做了几版之后,最清爽的做法是一个纯函数判定状态 + 所有 UI 读同一个状态字段。记录一下咖啡电量的 MetabolicState 这个 5 态机设计,顺便分享给有需要的朋友。
一、为什么是 5 态
朴素做法:按浓度(current mg)直接分段。咖啡电量能量满分 100 mg(理论峰值),所以:
// 朴素 3 态
if (current > 70) return 'high'
if (current > 30) return 'medium'
return 'low'
但这个分法有两个问题。
第一个问题:吸收期的"假低"。用户刚喝完一杯咖啡的头 10 分钟,吸收还没达峰,current 还在 50 mg 以下。按浓度判定会归类为 'medium'——但对用户的真实感是"刚喝下,等起效",和"稳定期 50 mg"完全不同。
第二个问题:戒咖的"假低"。用户主动戒咖期间,current 当然很低(因为没摄入),归类为 'low'。但 UI 应该显示"戒咖进行中"而不是"低能量"——两种状态语义不同。
于是浓度分段不够,要引入行为语义维度。咖啡电量定义了 5 态:
// model/MetabolicState.ets
export type StateType = 'absorbing' | 'peak' | 'stable' | 'fatigue' | 'detox'
- absorbing:刚摄入,吸收期(0-30 分钟)
- peak:浓度 > 150mg,巅峰运转
- stable:浓度 30-150mg,稳定区
- fatigue:浓度 < 30mg,疲劳区
- detox:用户开启了戒咖模式
5 态是信息密度和认知复杂度的平衡点:
- 3 态(high/medium/low)信息不够
- 7+ 态用户认不过来(看一眼不知道该怎么做)
- 5 态刚好对应咖啡摄入的典型行为日历:喝(absorbing)→ 兴奋(peak)→ 稳(stable)→ 累(fatigue)→ 休息(detox)
二、吸收期用时间判定,其他用浓度判定
5 态的判定条件混合了"时间"和"浓度"两个维度:
// model/MetabolicState.ets
export class MetabolicState {
static getStatus(
records: IntakeRecord[],
now: number,
isDetox: boolean
): MetabolicStatus {
// 1. detox 优先
if (isDetox) {
return { state: 'detox', remainingMin: 0 }
}
// 2. absorbing:最新摄入 30 分钟内(时间判定)
if (records.length > 0) {
const latest = records[0] // 已按 timestamp DESC
const elapsedMin = (now - latest.timestamp) / 60000
if (elapsedMin < Constants.ABSORPTION_MINUTES) {
return {
state: 'absorbing',
remainingMin: Math.ceil(Constants.ABSORPTION_MINUTES - elapsedMin),
drinkName: latest.drinkName,
}
}
}
// 3. 浓度判定
const level = CaffeineEngine.calculateLevel(records, now)
if (level > Constants.PEAK_THRESHOLD) { // 150
return { state: 'peak', level }
}
if (level > Constants.SLEEP_SAFE_MG) { // 30
return { state: 'stable', level }
}
return { state: 'fatigue', level }
}
}
判定顺序有讲究:
- detox 第一:用户主动戒咖期间,任何浓度判定都要让位
- absorbing 第二:吸收期的"用户体验感"覆盖浓度数字——即使吸收期浓度已经攀到 100mg,用户内心感觉是"刚喝下还在等起效"而不是"稳定区"
- 浓度判定垫底:没有特殊状态时才按浓度分
这个"优先级链"的顺序一旦定下,就不要随便改——UI 的所有依赖都基于这个顺序。乱改一次会让用户看到"浓度 160 但状态显示 fatigue"这种诡异组合。
吸收期为什么用时间不用浓度:
因为真实的吸收曲线和浓度分段不对齐。喝了一杯 150mg 的美式:
- 0-5min:浓度 25mg,按浓度是 fatigue
- 15min:浓度 75mg,按浓度是 stable
- 30min:浓度 150mg,按浓度是 peak
但用户的主观感受从 0 到 30 分钟都是"正在起效"。用"时间 < 30min" 作为 absorbing 的判定,恰好捕获这种主观状态。
三、detox 单独一态,不受浓度影响
detox(戒咖)这一态设计上有点特别:它完全脱离了"浓度 → 状态"的逻辑,由用户的 settings 决定:
// pages/Index.ets
async refreshLevel() {
const records = await IntakeRepository.getRecordsSince(now - 24h)
const isDetox = await PreferencesHelper.getBool('isDetox', false)
const status = MetabolicState.getStatus(records, Date.now(), isDetox)
// ...
}
意思是:detox 不是算出来的,是用户主动声明的。用户在设置里打开"戒咖模式"→ isDetox = true 落到 preferences → 状态机永远返回 detox。
这个设计不是技术上必须这样,是产品决策。咖啡电量想传达的是:"戒咖是一个有意识的选择,不是浓度自然降到 0 的被动状态"。如果让程序根据"24 小时没有新摄入"自动判定 detox,用户只是忘了喝咖啡没主动戒,app 突然告诉他"你在戒咖"——这是误导。
技术上的隐含要求:isDetox 要和 records 独立持久化。用户关 app → 重开 → isDetox 还是 true、records 可能有也可能没有。两者的生命周期是分开的。
detox 态的 UI 不显示浓度数字:
// pages/HomePage.ets
if (this.status.state === 'detox') {
Text('戒咖进行中') // 不显示 mg 数字
} else {
Text(`${this.status.level} mg`)
}
这是上面产品决策的 UI 侧实现。detox 态下显示 "0 mg" 的提醒反而让用户关注"浓度",不符合"戒咖是意识选择" 的意图。改成"戒咖进行中" 让用户感知到的是意识行为的持续。
四、单一真相函数 getStatus 驱动所有 UI
MetabolicState.getStatus 是整个 app 唯一的状态判定点。所有 UI 组件直接读这个函数的输出,不自己判断:
// pages/Index.ets
@State status: MetabolicStatus = { state: 'fatigue', level: 0 }
@Builder statusBadge() {
Text(this.statusLabel())
.fontColor(this.statusColor())
}
statusLabel(): string {
switch (this.status.state) {
case 'absorbing': return `吸收中 剩 ${this.status.remainingMin} 分`
case 'peak': return '巅峰运转'
case 'stable': return '稳定区'
case 'fatigue': return '疲劳区'
case 'detox': return '戒咖进行中'
}
}
statusColor(): string {
switch (this.status.state) {
case 'absorbing': return '#d97706' // 琥珀
case 'peak': return '#dc2626' // 红
case 'stable': return '#059669' // 绿
case 'fatigue': return '#64748b' // 灰
case 'detox': return '#4f46e5' // 紫
}
}
一个 switch 对一个 UI 维度(文案 / 颜色 / icon / 下一步建议)。单一真相的意义是:
要改状态语义时只改一处。比如把 absorbing 从 30 分钟调成 20 分钟——改 Constants.ABSORPTION_MINUTES 就行,所有 UI 自动跟着变。
要加新状态时集中修改。比如加一个 "overshoot"(浓度 > 200mg 的"过量"态):
- 加 enum 值
- 改 getStatus 加判定分支
- 几个 switch 加 case
- UI 自动适配
朴素做法可能是在每个 UI 组件内部各自判断:
// 不推荐
if (records.length > 0 && elapsed < 30) {
// 显示 absorbing UI
} else if (level > 150) {
// 显示 peak UI
}
这样做的坏处:改一个阈值要改 N 个组件;加一个状态要在每个组件补 case;两个组件的判断逻辑可能微微不一致(UI 文案和 badge 颜色对应不同状态),用户看到自相矛盾的页面。
单一真相函数的 pattern 适用范围很广——任何"从一组原始数据派生出几个状态/标签/视图"的场景都值得这么做。
五、60 秒 tick 够不够精细
状态更新由 pages/Index.ets 的 refreshLevel 驱动,每 60 秒跑一次:
// Constants.ets
export const UPDATE_INTERVAL_MS = 60000 // 60 秒
// pages/Index.ets
aboutToAppear() {
this.refreshLevel()
this.timer = setInterval(() => this.refreshLevel(), Constants.UPDATE_INTERVAL_MS)
}
aboutToDisappear() {
if (this.timer >= 0) clearInterval(this.timer)
}
async refreshLevel() {
const records = await IntakeRepository.getRecordsSince(now - 24h)
const isDetox = await PreferencesHelper.getBool('isDetox', false)
this.status = MetabolicState.getStatus(records, Date.now(), isDetox)
this.currentLevel = CaffeineEngine.calculateLevel(records, Date.now())
}
60 秒的理由:
- 咖啡因代谢不会在秒级有感知变化。一分钟变 1-2mg,用户看不出来
- 60 秒更新一次对电池友好。前台 setInterval 每秒跑一次会显著耗电
- 用户主动操作(记一次摄入)立刻刷新(
handleAddIntake结尾调 refreshLevel),不等 60 秒 tick
什么时候要更精细:
- absorbing 态显示"剩 X 分钟达峰"——这个 X 每分钟都要变一次。60 秒 tick 刚好
- 真要秒级更新(比如要显示"剩 7:23 到达峰"的时分秒),改成
setInterval(..., 1000)。耗电会显著上升
什么时候要更稀疏:
- app 后台时根本不用 tick(鸿蒙系统本来就会 pause 后台 setInterval)
- 用户设备低电量模式下可以降到 5 分钟一次
咖啡电量目前简化成"前台 60 秒"一刀切。足够了。
踩坑清单
- 从原始数据派生出几个状态时,用单一真相函数(state machine function),所有 UI 读它
- 状态优先级链一旦定下不要随便改。细节:主动声明态(detox)> 时间态(absorbing)> 自动计算态(peak/stable/fatigue)
- 吸收期用"时间"判定,稳定/疲劳用"浓度"判定——不同感受维度用不同判定维度
- 用户主动声明的状态(detox)要和自动计算的数据(records)独立持久化
- UI 不要内部做状态判断。每个 UI 维度(文案/颜色/图标)各一个 switch 读 state 字段
尾声
写完这个状态机我有个意外发现:5 态的本质是"5 种用户心智状态",不是"5 种数据值分段"。
- absorbing 不是"浓度 x~y mg",是"我刚喝下,在等"
- peak 不是"浓度 > 150mg",是"运转极佳,不要浪费这段时间"
- stable 不是"30-150mg",是"可以干点事,但不要再加"
- fatigue 不是"<30mg",是"想小睡或续杯都行"
- detox 不是"浓度为 0",是"我决定不喝了"
当你把每个状态按"用户此刻的心智"重新表述,判定逻辑的"混合维度"(时间 + 浓度 + 用户声明)就显得自然了——因为心智本来就是多维度决定的。按心智状态而不是数据分段做状态机,是一个让业务代码更真实的建模思路。
同样的 pattern 可以用在:
- 阅读 app 的"刚读完 / 待复习 / 完全熟悉 / 遗忘"
- 睡眠 app 的"入睡期 / 深睡 / REM / 清醒"
- 任何"有渐进过程 + 用户感知"的产品场景
从数据建模的角度看是炫技,从用户角度看是把你的产品和他的日常对齐。