关键词:微信小程序原生 · 离线可用 · 派生计算 · Canvas 自绘 · 包体积控制 目标读者:前端 / 产品
一、业务目标
用最短路径验证「用户是否愿意每天记录」。
一条铁律:验证留存不需要后端。如果用户在没有账号、没有云同步、没有推送的情况下都不愿意记,那么加了后端也不会记。所以一期把全部预算压在「记录这件事本身是否足够顺滑」:
| 业务指标 | 一期目标 | 衡量方式 |
|---|---|---|
| 首次记录转化 | 启动 → 完成首次打卡 ≥ 60% | 埋点(二期补);一期用本地 totalRecords 抽样 |
| 单日记录耗时 | ≤ 15 秒完成一次打卡 | 交互步数:首页点情绪 → 完成(1 步) |
| 次日回访 | 有 streak 概念驱动 | computeStreak 展示连续天数 |
| 功能完整度 | 记录-回顾-统计闭环 | 8 页面 14 组件 |
明确的非目标:账号、云同步、社交、内容社区、AI 解读。一期不做,是为了让「本地优先」成为产品的默认形态而不是过渡形态------一旦用户先习惯了「必须登录」,后面再退回离线就回不去了。
二、核心功能范围
| 功能 | 页面 | 说明 |
|---|---|---|
| 今日打卡 | pages/index |
5 档情绪一键打卡、连续天数徽章、本周色点、情绪预警文案、分享封面生成 |
| 情绪记录 | pages/record |
完整表单:情绪等级、标签、诱因、备注、补记 |
| 历史回顾 | pages/history |
时间线浏览、编辑、删除 |
| 数据统计 | pages/stats |
趋势折线、分布柱状、月度热力、标签 Top、周期指标、四期关联 |
| 生理期记录 | pages/period |
日历点选区间、进行中标记、预测展示 |
| 我的 / 设置 | pages/profile pages/settings |
主题、提醒、存储模式、导出导入 |
| 习惯转盘 | pages/wheel |
轻互动引导习惯养成 |
| 登录(占位) | pages/login |
一期不启用真实鉴权 |
三、涉及的技术模块
css
emotion-monster/
├── app.js 启动流程 / 全局状态 / 网络与生命周期
├── app.json 路由 + tabBar
├── pages/ 8 个页面
├── components/ 14 个自定义组件
│ ├── chart-canvas ★ Canvas 2D 自绘:line / bar / heatmap
│ ├── period-calendar 月历矩阵 + 六种格态
│ ├── mood-picker 5 档情绪选择
│ ├── mood-quick-bar 首页快速打卡条
│ ├── tag-picker 标签选择
│ ├── streak-badge 连续天数徽章
│ ├── stat-card / empty-state / skeleton / nav-bar / confirm-modal
│ ├── ui-icon / user-avatar / lucky-wheel
└── utils/
├── calc.js ★ 全部统计与预测算法(纯函数)
├── date.js 日期工具(YYYY-MM-DD 字符串为唯一真值)
├── theme.js 主题解析与应用(含 tabBar 同步)
├── constants.js 常量(SCHEMA_VERSION、MOOD_LEVELS 等)
└── storage.js 存储(二期详解)
四、关键技术选型与实现要点
4.1 日期:统一用 YYYY-MM-DD 字符串,而不是 Date 对象
这是整个项目最重要的一个决定。 情绪记录按「天」聚合,生理期按「天」计算,一切混乱都来自时区与毫秒精度。
js
// utils/date.js 的核心约定
today() -> '2026-09-16'
parse(str) -> Date | null
diffDays(a,b)-> int
- 所有存储、比较、索引都用字符串 (
recordDate、startDate),字典序即时间序,Mongo 里也能直接gte/lte走索引; - 只在「计算」和「渲染」的瞬间转成
Date; - 服务端
DateUtil.ZONE = Asia/Shanghai与前端保持一致,避免跨零点时差导致「我明明今天打的卡,却算到了昨天」。
踩过的坑 :如果用 new Date().toISOString().slice(0,10) 取今天,UTC+8 的用户在 08:00 之前会拿到昨天。
4.2 派生数据不落库:streak 是算出来的,不是记出来的
js
// utils/calc.js:10-25
function computeStreak(records, lastCheckInDate, today) {
const dates = records
.map(r => r.recordDate).filter(Boolean)
.filter(d => DateUtil.isDate(d))
.reduce((acc, d) => acc.includes(d) ? acc : acc.concat(d), []) // 去重
.sort().reverse(); // 倒序
if (!dates.length) return 0;
// 关键:最近一次既非今天也非昨天 → 断签
if (dates[0] !== today && dates[0] !== DateUtil.addDays(today, -1)) return 0;
let streak = 1, cursor = dates[0];
for (let i = 1; i < dates.length; i++) {
if (dates[i] === DateUtil.addDays(cursor, -1)) { streak++; cursor = dates[i]; }
else break;
}
return streak;
}
设计要点:
- 容忍「今天还没打卡」:最近一次是昨天时 streak 不归零,否则用户每天早上打开 App 都会看到徽章清零,体验极差;
lastCheckInDate参数不参与判定(保留只为兼容旧签名)。历史 bug 正是「用 lastCheckInDate 参与判定导致首日恒为 0」;- 与服务端
StreakCalculator.java:17-44完全同构------这是「同构纪律」的第一条。
4.3 图表:Canvas 2D 自绘,不引第三方库
方案对比:
| 方案 | 包体 | 定制性 | 结论 |
|---|---|---|---|
| Canvas 2D 自绘(本项目) | 0 KB 依赖 | 完全可控,暗色主题/圆角/描边随心 | ✅ 三种图表 type 就够用 |
| ECharts for 小程序 | +300~500 KB | 强,但定制要绕 | 后台选它(第五期),端上不必 |
| F2(AntV) | +150 KB | 移动端友好 | 图表需求变复杂时再迁 |
小程序主包限制 2MB,分包 20MB。一期只有 3 类图表(折线/柱状/热力),自绘 167 行即可,性价比最高。
js
// components/chart-canvas/chart-canvas.js 关键实现点
observers: { 'data, type, dark': function () { this.draw(); } },
draw() {
// ① 节点未就绪重试:Canvas 节点在 onReady 前可能取不到
// 重试 10 次 × 100ms,仍失败 triggerEvent('fail') 让上层降级到空态
// ② 按 dpr 缩放:ctx.scale(dpr, dpr),避免高分屏模糊
// ③ 高度用「测量值」而非 props 传入的 rpx------否则坐标系不一致会把图压扁
// ④ heatmap 按 1 号真实星期对齐列,今日格加描边
}
工程经验 :chart-canvas 的失败不是异常,而是降级 。统计页用 safe() 包裹每个请求,画布绘制失败也只是回退空态文案------任何单点失败都不能让整页变白。
4.4 统计页:并发请求 + 单点兜底
js
// pages/stats/stats.js:45-101
const safe = (p) => p.catch(() => null); // 单接口失败不影响其他
Promise.all([
safe(Api.request('stats', 'moodTrend', { range })),
safe(Api.request('stats', 'moodDistribution', { range })),
safe(Api.request('stats', 'tagFrequency', { range, top: 8 })),
safe(Api.request('stats', 'periodOverview')),
safe(Api.request('stats', 'periodCorrelation')),
safe(Api.request('stats', 'moodHeatmap', { year, month })),
safe(Api.request('mood', 'list', { pageSize: 1 })) // 取全量 total
]).then(([...]) => { /* 渲染 */ });
前端二次计算(服务端不提供的派生指标):
js
checkInRate = 范围内有记录的天数 / range
hasTrendData = trend.some(v => v > 0) // trend 数组恒非空,必须看有效值
4.5 生理期日历:六种格态一次说清
js
// components/period-calendar/period-calendar.js:39-46
isActual 已记录区间内
isStart / isEnd 区间起止
isPendingStart 用户已点第一个日期、等待第二个日期
isPredict 预测区间
isNext 预测的下次开始日
交互设计(pages/period/period.js:73-142):
- 未来日期 → 拒绝;
- 命中已有记录 → 弹出「删除该条」;
- 已有
pendingStart→ 第二次点击作为结束日(endDate < startDate拒绝); - 空日期 → ActionSheet:选择区间 / 标记为结束(进行中)/ 标记为开始(进行中)/ 标记为一天。
⚠️ 现存缺陷:
pages/period/period.js中editRecord被定义两次(第 48 行与第 145 行),后者覆盖前者,导致「删除」单一选项分支实际不可达。属于一期遗留,建议在二期收尾时合并。
五、数据流转与接口设计
5.1 数据流(一期:全部在本地闭环)
scss
用户操作
│
▼
page.js → Api.request(module, action, payload) ← 统一门面,页面层唯一入口
│
▼
LocalAdapter.request()
├─ 校验 validateMood / validatePeriod
├─ 读写 storage 内存镜像(getDomain 返回引用 → 原地 mutate → setDomain 回写)
├─ 派生重算 recalcStreak() / recalcPredict()
└─ storage.emit('mood:changed' | 'period:changed')
│
▼
storage 防抖 500ms → wx.setStorageSync('em_mood' / 'em_period')
│
▼
事件订阅者(如 pages/index 注册 mood:changed)→ 重新 load
5.2 内部契约(一期就定死,二三四期直接复用)
模块与动作矩阵:
| module | action | payload | 返回 |
|---|---|---|---|
mood |
list |
{date, tagId, page, pageSize} |
{list, total, hasMore} |
mood |
get / create / update / remove |
{id, fields...} |
记录 / {id} |
period |
list / get / create / update / remove / predict |
同上 | 记录 / PredictResult |
stats |
moodTrend moodDistribution tagFrequency moodHeatmap periodOverview periodCorrelation moodStreak |
{range, year, month, top} |
各统计结构 |
data |
export / import / clear |
{json, mode, scope} |
{imported, skipped} |
setting |
get / update |
patch |
设置对象 |
统一返回:
js
class Result {
constructor(ok, code, data, error) {}
// code: 'OK' | 'VALIDATION' | 'NOT_FOUND' | 'STORAGE' | 'NETWORK'
static ok(data) {}
static fail(code, error) {}
}
为什么一期就要抽象出 Result 和 DataAdapter? 因为四期加 RemoteAdapter 时,页面代码一行都不用改------这是本系列最重要的一次「提前设计」。
5.3 校验规则(前后端必须一致)
js
// utils/adapters/local-adapter.js:12-27
validateMood: moodLevel ∈ [1,5] · note ≤ 500 字 · 补记 ≤ 30 天(DAY_LIMIT)
validatePeriod: startDate 不得晚于今天 · endDate ≥ startDate
服务端 MoodService / PeriodService 注释明确写着「与前端 local-adapter.js 保持一致」,30 天上限由 app.data.mood-backfill-days: 30 配置驱动,两端同源。
六、技术难点分析
难点 1:无后端下的「数据可信」
问题:所有数据都在用户手机上,一旦 storage 被清、小程序被删,数据永久丢失;用户也没有「安全感」。
应对(一期做到,四期完善):
- 立即提供
data/export导出 JSON,settings页可导出/导入; - 明确告知用户「数据在你手机上」,把隐私做成卖点而不是免责声明;
- 二期补齐
em_guest_backup备份机制,四期云端合并。
难点 2:派生指标的一致性与性能
问题:streak、预测、统计都是全量计算的。记录越多越慢。
应对:
- 一期数据量小(<1000 条),纯函数全量计算 <5ms,无需优化;
- 但预留缓存点 :
LocalAdapter把预测结果写入period.cache,只在记录变更时recalcPredict(); - 服务端侧则是「读时计算,不落库」------第五期再加缓存。
难点 3:预测算法的可解释性
用户看到「预计 10 月 3 日来月经」会追问:凭什么?
js
// utils/calc.js:37-92 predictPeriod 三步法
// ① 算周期序列 + 离群剔除(中位数 ±6 天)
// ② 加权移动平均(近 3 个周期,权重 3:2:1),钳制 avgCycleLen ∈ [20,45]
// ③ 规律性 CV = 标准差 / 均值
// confidence: high(cv ≤ 0.08 且样本足) / medium / low / insufficient(<3 条)
// 区间 ±1 天(样本足)否则 ±2 天
设计要点 :样本 <3 时明确返回 insufficient 而不是硬算。宁可说「数据还不够」,也不要给一个看似精确的错误日期------健康类产品的信任一旦崩塌无法挽回。
⚠️ 已知口径分歧 :前端按「记录条数」判置信度(
sorted.length>=6 / >=3),服务端PeriodPredictor.java:79-88已改为按「周期数」(valid.size()>=5 / >=2)。同一份数据在游客态和登录态可能显示不同的「规律性」文案。第五期统一。
难点 4:情绪-周期关联的归属歧义
js
// utils/calc.js:153-205 与 PeriodCorrelationAnalyzer.java:28-97
// 阶段划分:月经期(0-4) / 卵泡期(5-13) / 排卵期(14-16) / 黄体期(17-)
// 归属规则:一条情绪记录只归属到「最近一次 startDate ≤ 记录日」的周期
旧实现对每个经期遍历 0~40 天,相邻周期重叠区会重复计入。修正后按「最近一次起点」归属,并在后端类注释中记录了这次修正。偏移 >40 天丢弃,等级越界数据剔除(否则 NaN 会污染均值)。
PMS 提示的触发条件(保守,避免误报):
黄体期样本 ≥2 且 卵泡期样本 ≥2 且 黄体期均值 ≤ 卵泡期均值 - 0.5
七、性能与安全考量
性能
| 关注点 | 措施 | 指标 |
|---|---|---|
| 首屏 | 骨架屏 skeleton + 并发请求 + 本地同步读 |
首屏 <800ms |
| 写入 | 防抖 500ms,避免连续打卡打爆 Storage | 单次写入 <10ms |
| 图表 | dpr 缩放 + 重试机制 + 失败降级 | 绘制 <100ms |
| 包体 | 不引第三方图表/UI 库,图标用 ui-icon 组件 |
主包 <1MB |
安全
一期虽然没有后端,安全仍然要做:
- 数据不落网:一期内所有数据不出设备,天然规避传输与存储风险;
- 分享封面脱敏 :
pages/index的离屏 Canvas 生成 500×400 分享图,只画情绪与天数,不画备注原文; - 本地仍是明文 :
wx.setStorageSync不加密。这是客观风险,应在设置页如实告知;如需加密,见第五期「端到端加密」展望。
八、风险与应对
| 风险 | 影响 | 概率 | 应对 |
|---|---|---|---|
| Storage 10MB 上限 | 记录写满后无法写入 | 低(单条 <200B,5 万条才 10MB) | 监控写入异常;二期加容量提示;远期归档 |
| 用户清缓存丢数据 | 记录全丢 | 中 | 导出功能前置到设置页首屏;三期登录后自动上云 |
| Canvas 兼容差异 | 部分机型图表空白 | 低 | 重试 10×100ms + fail 事件降级空态 |
wx.getSystemInfo 废弃 |
状态栏高度取不到 | 中 | 已用 wx.getWindowInfo() 替代(app.js:27) |
| 双实现口径漂移 | 游客/登录显示不一致 | 高 | 建立算法对照测试;第五期统一 |
九、验收标准与交付物
验收标准
- 冷启动 → 完成首次打卡,交互步数 ≤ 2;
- 断网状态下全部功能可用,无一处 loading 卡死;
- 连续打卡徽章在「今天未打卡但昨天打了」时不清零;
- 周期样本 <3 条时展示「数据不足」而非预测日期;
- 统计页 7 个请求中任意 1 个失败,页面仍可渲染其余模块;
- 导出 JSON 可被导入还原,记录数与内容完全一致;
- 主包 <1MB,首屏 <800ms;
- 主题切换(含 tabBar)全页面生效,深色模式无对比度问题。
交付物
| 类型 | 内容 |
|---|---|
| 代码 | pages/ 8 页面、components/ 14 组件、utils/calc.js date.js theme.js |
| 文档 | 详细设计文档 v4.0(优化版) |
| 资产 | assets/ 图标与图片 17 个 |
| 契约 | DataAdapter + Result 抽象(为二~四期铺路) |
十、后续优化方向
- 补
period.js中重复定义的editRecord,恢复删除分支; - 算法对照测试 :把
calc.js的 5 个函数与后端calculator/用同一组 fixture 跑断言,防止口径继续漂移; - 打卡提醒 :一期只做了设置项,未接订阅消息------二期结合
reminderTime落地; - 记录模板:高频用户希望「一键复用昨天的标签」,可做成快捷模板;
- 无障碍:为 5 档情绪补充语义化标签与更大点击热区。