先看现象
这次容灾改造上线后,我在真机上测了两个场景:生成过程中强杀 APK 进程,以及生成过程中断网。
两种情况下,任务都没有丢。重新打开应用后,服务端还能继续处理请求,最终结果也会写进本地 Room。问题在于第一次重新进入应用时,聊天页看不到恢复出来的 assistant 消息;再打开一次,消息才出现,后面的生成也比较稳定。
这看起来像是恢复失败,实际是恢复链路只完成了一半。
任务恢复了,页面没有恢复
当前客户端把一次生成拆成两条链路:
ChatStreamingService负责在后台持有 SSE 请求。它收到end后,把最终文本追加到 Room,并删除pending_tasks记录。ChatViewModel负责把ack、typing、delta和end投影到当前页面。
正常发送时,ViewModel 会先订阅 ChatStreamCoordinator,再启动 Service,所以两条链路同时工作。
进程被杀后,loadHistory() 会先从 Room 读历史,再调用 recoverPendingTasks()。原来的恢复代码只做了第二步里的 Service 启动:
streamLauncher.start(
ChatStreamRequest(
requestId = pending.requestId,
sessionId = pending.sessionId,
text = pending.text,
taskId = pending.taskId,
lastEventId = pending.lastEventId,
),
)
Service 随后正常跑完,把 assistant 结果写进了 Room。但这个 ViewModel 没有订阅对应的 coordinator 更新,因此当前内存里的 ChatUiState.messages 不会变化。第二次打开应用时,loadHistory() 又读了一遍 Room,结果才显示出来。
所以这里有两个不同的问题:任务有没有继续执行,和当前页面有没有收到执行结果。前者已经正常,后者漏了一段连接。
修复恢复路径
修复后的恢复流程和正常发送保持同样的顺序:
读取 pending_tasks
|
+-- 当前会话:标记页面为 STREAMING,订阅对应 request_id 的更新
|
+-- 其他会话:只启动后台恢复,不改当前页面状态
|
启动 ChatStreamingService
|
收到 end/error -> 更新页面 -> 清理订阅和去重状态
具体改动有三点。
第一,恢复任务启动前就按 requestId 订阅 ChatStreamCoordinator。这样无论结果是从 SSE 续接回来,还是后端因为幂等请求直接返回已有结果,end 都能进入当前 ViewModel。
第二,只有当前浏览的会话才更新当前页面。其他会话的任务仍然会在后台继续,结果由 Room 保存,用户切回对应会话时再读取,避免后台任务把用户正在看的会话状态改乱。
第三,增加 recoveringRequestIds。loadHistory() 可能因为界面重组或重新进入被调用多次,同一个 pending task 在恢复完成前不能重复启动多个 Service。
如果 Service 启动本身失败,页面现在会退出 STREAMING 状态并显示错误,同时保留 pending 记录。这样下一次启动仍然可以恢复,不会把一次启动异常误判成任务已经失败。
回归测试先复现,再修复
我给 ChatViewModelTest 加了一个最小复现:预先放入一条 pending_tasks 记录,让恢复流直接返回 end,然后只调用一次 loadHistory()。修复前,Room 里能看到 assistant,但 ChatUiState.messages 为空;修复后,同一次 loadHistory() 就能看到完整消息。
这条测试锁定的是用户实际遇到的症状,而不是简单检查“Service 有没有启动”。执行结果:
./gradlew :app:testDebugUnitTest
BUILD SUCCESSFUL
./gradlew lintDebug assembleDebug
BUILD SUCCESSFUL
提交为 ed732aa fix: render recovered task on first launch。
设备重新连接后,还需要再跑一次“生成中强杀”和“生成中断网”两个真机场景。这次代码级验证已经覆盖了首启恢复显示,但没有把设备连接状态当成已验证的端到端结果。
这次问题留下的提醒
持久化任务状态,不等于恢复用户体验。容灾至少要分别检查三件事:
- 后端任务是否还在执行;
- 客户端是否能用稳定 ID 找回同一个任务;
- 当前页面是否重新接上了任务的终态事件。
前两项都正确时,最后一项漏掉,用户仍然会认为功能坏了。恢复逻辑因此不能只写成“重新启动请求”,还要明确恢复事件由谁消费、消费到哪个会话,以及什么时候结束这次恢复。
Sources
- ed732aa: render recovered task on first launch — 本文修复与回归测试的提交。
- ChatViewModel.kt — 恢复任务、coordinator 订阅和页面状态更新逻辑。
- Android 前台服务概览 — 后台持续执行并向用户展示通知时使用前台服务的官方说明。