历史记录功能通常从一句很简单的需求开始:测量完成后保存下来,首页按日期显示,结果页再画一张周、月、年趋势图。
真正开始做以后,问题会迅速变成另一组问题:一条记录到底属于哪个时区的哪一天?用户点击历史日期后,首页应该显示哪一条?同一天测了多次,趋势图是取最后一次还是平均值?跨月、闰年和夏令时怎么处理?
我在实现这类功能时,最先遇到的不是图表库,而是日期定义不清。只保存一个 UTC 时间,看起来足够精确,却不足以支持用户理解的“今天”“昨天”和“本周”。
后来我把记录分成两种时间:事实发生的时间,以及产品用来归档的本地日期。
一条记录需要保存什么
一条成功测量记录不应该保存页面文案,也不应该把整段相机信号直接塞进历史文件。更合适的最小模型是:
struct MeasurementRecord: Codable {
let id: UUID
let capturedAtUTC: Date
let capturedTimeZoneID: String
let localDayKey: String
let bpm: Double
let hrv: Double
let stress: Double
let algorithmVersion: String
}
capturedAtUTC 用来排序和做精确的时间区间查询;capturedTimeZoneID 保留当时的时区证据;localDayKey 则直接表达这条记录在用户当时的本地日历里属于哪一天。
例如用户在东京时间的凌晨完成测量,保存成 UTC 后可能已经是前一天的下午。如果首页只拿 UTC 日期格式化,用户会看到记录出现在错误的一天。保存本地日期键不是重复数据,而是把产品真正需要的归档事实保存下来。
日期键建议使用稳定格式,例如 yyyy-MM-dd,但生成它时必须明确使用哪个时区。读取时也不能把日期键当成 UTC 时间重新解释,而要使用当前日历的时区还原当天的起点。
首页日历其实有四个状态
日历上的白色背景、红点和能不能点击,不能共用一个布尔值。
isToday:这一天是不是当前日期。isSelected:这一天是不是用户当前查看的日期。isSelectable:这一天是否允许用户点击。showsRecordIndicator:这一天是否有历史记录。
这四个状态经常被错误地合并。例如把 isToday 直接当成选中状态,会导致用户点击历史日期后白底仍然留在今天;把有记录当成可点击,又会让未来日期或空日期进入错误流程。
页面层可以维护一个很小的选择状态:默认跟随今天,用户点击过去日期后变成手动选择;如果用户切换到一个不再可见的周,重新回到今天。日历组件只渲染这些状态,不自己决定数据查询和页面跳转。
未来日期则应该“显示,但不可选”。这样用户仍然能看到完整的一周,不会因为禁用未来日期而改变布局;点击回调和页面入口还要各自保留一次防线,避免迟到事件绕过控件状态。
存储层不要让页面直接读文件
文件存储很简单,但页面不应该知道 JSON 文件路径、编码方式和串行队列。存储层只需要提供几类明确操作:
protocol MeasurementRecordStoring {
func save(_ record: MeasurementRecord) throws
func latestRecord() throws -> MeasurementRecord?
func latestRecord(onLocalDay dayKey: String) throws -> MeasurementRecord?
func records(in interval: DateInterval) throws -> [MeasurementRecord]
func recordDayKeys(in interval: DateInterval) throws -> Set<String>
}
保存时用记录 ID 去重,再按发生时间排序;读取当天记录时使用 localDayKey;读取趋势数据时使用 UTC 的时间区间。这样首页日历和趋势图不需要各自实现一套日期筛选规则。
本地文件写入也要串行化,并使用原子写入。文件不存在时返回空列表;版本不支持时明确报错,不要悄悄把新记录覆盖掉旧文件。历史模型增加字段时,旧记录应该仍然可以解码,新增字段只在展示层提供兼容默认值。
趋势图先确定时间桶,再决定怎么画
趋势图最容易写成“把记录丢给图表库”。但图表库只负责画点,不能替你决定一个点代表什么。这里说的“时间桶”,就是一个固定的日期区间,例如某一天、某个月或某一年中的一个月份。
以周趋势为例,先根据锚定日期计算这一周的起点和终点,再生成七个固定日期桶。每个桶可以有零条、一条或多条记录。空桶必须保留位置,只是 BPM 为空;否则图表的横轴会随着数据变化,用户无法比较不同日期。
月趋势同理,但不能假设每个月都有 30 天。年趋势则生成十二个月桶。日期计算应交给 Calendar,不要用固定秒数加减来代替日历运算,否则跨夏令时和闰年时会出现偏移。
同一个桶里有多条测量时,产品必须先确定统计口径。一个简单、可解释的选择是:图上的点使用当天或当月的平均 BPM,顶部的 Average、Maximum、Minimum 则基于当前周期内所有有效记录计算。这个口径要写进纯数据层,并用固定样本测试,而不是散落在 View 的回调里。
示意代码可以很短:
let values = groupedRecords[bucketStart] ?? []
let point = values.isEmpty ? nil : Int((values.reduce(0, +) / Double(values.count)).rounded())
真正重要的是 bucketStart 的定义和 values 的过滤条件。只接受有效范围内的 BPM,排除锚定周期之外的记录;选中点必须属于当前周期且确实有数据。
时间区间测试比截图更有价值
这类功能不能只靠看一张趋势图确认。更值得测试的是边界:
- 周日作为一周起点时,周趋势仍然有七个桶。
- 二月在闰年和非闰年分别生成正确天数。
- 年趋势只包含锚定年份,不把下一年一月混进来。
- 记录发生在本地时区边界附近时,仍落在用户看到的那一天。
- 同一天多次记录的平均值、最大值和最小值符合约定。
- 没有数据时不虚构选中点和统计数字。
- 选中日期、今天和记录红点可以同时正确显示。
项目中没有专门的 XCTest target 时,也可以先把这些检查写成 Debug 构建里的确定性断言。重点不是测试框架的名字,而是把日期规则从“看起来对”变成可以重复验证的输入和输出。
为什么不保存整段原始波形
历史页面只需要展示少量派生信息时,没有必要把整段 PPG 或相机帧写进本地记录。更小的记录文件更容易迁移,也减少了敏感数据长期留存的范围。
如果需要回看节律图,可以保存经过范围过滤的少量 BPM 节拍样本,并用确定性的绘制规则生成展示波形。旧记录没有节拍样本时,退回使用最终 BPM 生成一个稳定的兼容展示。这个展示只是节律插图,不能冒充医学 ECG,也不应对外宣称来自真实心电波形。
这类功能的难点不在图表
历史趋势真正需要先解决的是三个问题:记录保存的时间事实是什么,用户日历上的一天怎么定义,同一时间桶里的多条记录如何聚合。
只要这三件事稳定下来,周、月、年趋势图只是不同的桶数量和标签格式。反过来,如果日期和统计口径没有先定清楚,再漂亮的图表也会在跨日、跨月或切换时区后暴露问题。
健康记录只是一个例子。运动、睡眠、消费、打卡和日志类 App 都需要面对同样的时间问题。先把“这条记录属于哪一天、哪一段时间、哪个统计桶”说清楚,界面才有可能长期保持一致。