实时测量功能最容易出现一种假象:页面看起来只是偶尔卡一下,真正的问题却发生在更底层。
相机每隔几十毫秒送来一帧画面,分析模块要把一段时间里的信号拿出来计算。分析一次可能要几百毫秒,甚至更久。只要把采集、分析和页面更新放进同一条队列,系统就会出现三个连锁反应:相机帧开始堆积,分析结果越来越旧,页面最后只能拿旧结果勉强维持显示。
我在一个基于手机相机的健康测量项目里遇到过同类问题。早期实现并不是完全不能工作,正常设备、正常手指覆盖下也能得到结果。真正让问题暴露出来的是旧设备和信号质量变差的场景:测量进度停在中间,BPM 迟迟不更新,重试后偶尔还会出现上一轮结果回写到新一轮页面的情况。
后来我把问题重新定义成一句话:相机采集是实时工作,完整分析是慢工作,两者不能共享同一个节奏。
先把三件事分开
这条链路里至少有三种工作:
- 相机线程负责拿到每一帧画面,并从感兴趣区域读取少量颜色数据。
- 测量引擎负责维护当前会话、覆盖状态、超时和进度。
- 分析管线负责在一段快照上做信号处理、峰值判断和指标计算。
它们的时间要求完全不同。相机不能因为一次分析变慢就停止送帧;测量引擎需要尽快知道手指是否离开;分析管线则可以在后台慢慢处理,但处理的输入必须是一个稳定副本。
所以采集和分析之间的接口不应该是“分析模块直接读取当前数组”,而应该是一个不可变请求:
struct AnalysisRequest {
let sessionID: UUID
let generation: Int
let endTimestamp: TimeInterval
let timestamps: [TimeInterval]
let channelSamples: [Double]
}
采集队列生成这个请求时,把当前窗口复制出来。分析线程只读这份副本,后续相机继续往引擎里写新帧,不会改变正在分析的内容。
这一步看起来只是复制数组,实际上解决了两个问题。第一,分析不会读到一半更新一半的数组。第二,分析结果可以带着它对应的会话、代次和时间点回来,后面能够判断它还有没有资格写回界面。
慢分析时,不能把帧也一起丢掉
相机输出通常有一个“丢弃迟到帧”的选项。它解决的是视频预览来不及渲染的问题,但实时测量不能简单照搬这个策略。测量依赖的是一串连续的时间采样,分析变慢不应该反过来改变采样本身。
实际做法是让每一帧先经过一个很轻的入口:检查时间戳和数值是否有效,判断手指是否覆盖,记录 RGB 均值,然后立刻返回。完整信号分析在另一条队列执行。
当后台已经有一次分析正在运行时,新的分析请求不再排成无穷队列,只保留最新的一份:
if analysisInFlight {
pendingRequest = newestRequest
return
}
analysisInFlight = true
analysisQueue.async {
let result = analyze(request)
measurementQueue.async {
apply(result)
}
}
这里保留的是“最新快照”,不是“下一帧”。如果后台分析需要 300 毫秒,队列里排十个旧请求没有意义,因为它们完成时看到的已经是过去。最新快照反而能让下一次分析尽量接近当前状态。
这是一种很实用的取舍:宁可少做几次完整分析,也不要让旧分析把系统拖成一条长队列。
旧结果为什么必须主动拒绝
仅仅把分析放到后台还不够。后台结果回来时,当前会话可能已经发生了三种变化:用户移开手指后重新覆盖、用户点击重试开启了新会话、或者同一会话已经完成并进入结果页。
因此,应用结果时不能只判断“这个结果有没有算出来”,还要判断它是不是当前结果。我的做法是给每次会话附带三组信息:
sessionID:区分一次完整测量。generation:区分同一页面上的重试或信号窗口重置。snapshotEndTimestamp:保证结果按时间递增应用。
写回前统一检查:
guard result.sessionID == currentSessionID,
result.generation == currentGeneration,
result.endTimestamp > lastAppliedTimestamp,
state != .finished,
state != .failed else {
return
}
这不是为了让代码看起来更严谨,而是为了阻断真实的错误路径。
例如,用户在第一个会话失败后点击 Retry。旧分析任务还在后台运行,如果没有 generation,它可能在新会话开始后把旧的 BPM 写回去。页面上看到的数字也许只闪了一下,但 Debug 日志、本地记录和用户下一步操作就可能全部被污染。
页面显示也不能直接消费算法状态
实时页面通常需要两个不同的进度:算法进度和展示进度。
算法进度代表“当前证据是否足够”。没有稳定结果时,算法可以停在采集中;展示进度代表“页面如何让用户知道工作还在继续”。如果把两者绑死,页面就会在某个阶段长时间停在 55%,用户会以为应用坏了。
更稳妥的做法是:
- 采集达到最低时长后,展示进度可以继续缓慢前进。
- 没有最终结果时最多显示到一个未完成上限,例如 99%。
- 只有真正收到最终结果,才显示 100%。
- 信号失败时保留当前进度,不把失败伪装成完成。
BPM 也一样。过程中的预览值可以短时平滑,但它不能进入最终结果;最终值一旦产生,就应该锁定,后续迟到的普通更新无权覆盖它。
页面层需要的是“当前该显示什么”,算法层需要的是“当前是否可信”。两个问题必须分开回答。
Debug 日志应该记录决策依据
实时测量最难排查的不是“有没有结果”,而是“为什么这次结果和上次不同”。所以 Debug 日志不能只保存最终 BPM,还要保存:
- 采集帧数、实际 FPS 和帧间隔。
- 分析次数、每次耗时和被合并的请求数。
- 每个通道的信号质量、候选峰和确认峰。
- 当前选中的分析路径,以及失败发生在哪个阶段。
- 最终真正用于结果的 RR 序列,而不是只保存原始输入。
日志文件本身也要有版本号。旧版本字段缺失时可以在解码边界补默认值;未知版本不能静默覆盖;损坏文件则应该隔离保存,让 Debug 功能恢复,不要反过来阻断正式测量。
这类日志还有一个隐私边界:原始信号和设备信息只用于内部诊断,不能直接拿到博客、工单或公共仓库。对外分享时,应该只保留抽象后的时间线和统计结果。
这套设计真正解决了什么
它没有让单次分析变得更快,也没有把低质量信号强行变成成功结果。它解决的是另一件事:当系统同时面对实时输入、慢计算和用户操作时,每一层都知道自己该负责什么,也知道哪些结果已经过期。
相机继续采集,分析只处理稳定快照;分析变慢时只保留最新请求;旧会话结果无法写回;页面展示不会把临时值当成最终事实;Debug 日志能够解释一次测量是怎么走到结果或失败的。
这套做法不只适用于相机测量。音频识别、姿态估计、扫码、传感器融合,都会遇到“输入持续到来,但计算速度不稳定”的问题。真正重要的不是某个具体算法,而是不要让慢任务决定实时输入的命运。