我们自己造了个队列,而系统本来就有一个
P-Pass 手机端要做一件很朴素的事:你拍了照片,它自动传到家里那台电脑上。
“你拍了照片”这件事怎么知道?Android 给了一个机制叫 content trigger。你告诉系统”我关心相册这块地方”,相册一变,系统就把你的任务唤醒。不用轮询,不用常驻服务,很省电。我们一直用的就是它。
然后用户报了个问题:连拍的时候,前面几张传过去了,后面的没有。
一个看起来很合理的补丁
原因不难找。content trigger 是一次性的:系统唤醒你一次,这个监听就消耗掉了。你得重新挂一个。
而我们的任务同时干两件事:它既是被唤醒的那个”监听”,又是真正跑备份的那个”干活的”。于是:
拍照 → 唤醒任务 → 监听消耗掉,开始备份
↓
备份跑 2 分钟 ← 这两分钟,没有任何监听
↓
跑完,重新挂上监听
备份跑多久,监听就断多久。 连拍的后半段正好掉在这个真空里。
于是我做了个补丁:把重挂的延迟从 15 秒压到 1 秒,再加一个”上一轮如果有照片,就顺带补扫一次”的兜底。空窗小了,也有了补救。测试绿了,我拿去汇报。
用户的回复只有一句:
“你强行用时间来做判断的话,是不太合适的。”
他是对的。我压缩的是空窗的长度,而空窗本身还在。1 秒里也能拍照。而那个”上一轮有照片才补扫”的条件更糟:如果这轮唤醒是微信收了张图触发的(我们不备份微信相册),扫描结果为空,就完全不补扫,那期间你真拍的照片得等五个小时。
这个补丁靠的是两个猜测:猜 1 秒里没人拍照,猜上一轮扫空了这一轮也不用补。两个都会落空。
用户给了另一个方向
几轮来回之后,用户不耐烦了:
“咱们就不能参照一下 Node 或者 JS 的 event loop 吗?就是事件来了,我们执行完之后再释放,不 OK 吗?”
我当时的第一反应是解释为什么做不到:监听是一次性的,重新挂上就意味着放弃当前这个,而当前这个正在传照片。
但在写这个解释之前,我去翻了一遍 AOSP 的源码注释。JobInfo.Builder#addTriggerContentUri 的文档,逐字读下来:
To continually monitor for content changes, you need to schedule a new JobInfo using the same job ID and observing the same URIs in place of calling
jobFinished(). […] Following this pattern will ensure you do not lose any content changes: while your job is running, the system will continue monitoring for content changes, and propagate any changes it sees over to the next job you schedule, so you do not have to worry about missing new changes.
翻译过来,跟用户刚才那句话说的是同一个模型:
- 系统 = 事件队列,一直在收,从不关门
- 我们 = worker,事件来了就处理
- 处理完不调
jobFinished(),改调schedule(同一个 job ID)。这一下就是”释放” - 释放的瞬间,系统把你干活期间攒下的变更立刻投递给新任务
空窗为零。不需要补捞,不需要猜时间。
我花了几个小时在系统外面造了个漏水的队列,而系统里本来就有一个完好的。
为什么我们没吃到
这段文档里有一个词是承重的:same job ID。系统的”变更转交”是按 job ID 认人的。
而我们中间隔了一层 WorkManager(Android 官方的任务调度封装库)。它的 REPLACE 策略每次都新建一个任务记录,底下的 job ID 跟着换。ID 一换,系统攒的那些变更就找不到收件人了,跟着旧任务一起丢掉。
WorkManager 帮我们处理了重试、退避、约束、前台服务提权,这些都很值。但它也把这个能力挡住了。
所以最后的做法是:只把”监听”这一条通道从 WorkManager 里拿出来,直接用底层的 JobScheduler,job ID 写死成一个常量。备份本身还在 WorkManager 里,一行没动。
改完之后
看门的任务醒来只做两件事,前后不到十毫秒:
onStartJob:
1. 派活 —— 排一个备份任务,扔给 WorkManager 异步跑
2. 释放 —— schedule(同一个 job ID)
return
备份在旁边自己跑,跑两分钟也好,跑二十分钟也好,监听全程在岗。这期间你继续拍照,照样通知,照样派活。
新排的活会排队等着。丢弃不行,事件就没了;抢占不行,会打断正在传的照片;并行也不行,两个任务扫同一批照片,白白重复传字节。前一个跑完,下一个接着跑。
一起消失的还有:整套重挂机制、补捞逻辑、以及三个凑出来的时间常数。这个文件净减了六千字节,一个时间常数都没剩下。
顺手挖出来一个更严重的洞
改的过程中翻到旧代码这一段:
Constraints.Builder()
.setRequiredNetworkType(UNMETERED) // 必须连 Wi-Fi
.setRequiresBatteryNotLow(true) // 电量不能低
.addContentUriTrigger(相册, true) // ...监听也挂在这儿
约束和监听挂在了同一个对象上。意思是:不连 Wi-Fi 的时候,这个监听根本不会被投递。
你出门玩一天,全程 4G,拍了两百张。这一天里所有的相册变化通知,我们一条都收不到。回家连上 Wi-Fi 也不会补,只能等五小时的周期兜底,或者你自己打开 App。
这个洞比连拍那个严重得多,而且从来没人报过。它的症状是”回家过一会儿就同步了”,看起来完全正常。
拆开之后天然就没了:监听是裸的,永远在线;Wi-Fi 和电量的要求挪到派出去的备份任务上。有新照片立刻知道,能不能传是另一回事。
新方案自己的代价
同一段文档往下几行:trigger URI 和 setPersisted 互斥。翻译过来就是,这个看门任务不能持久化,每次开机都会消失。
这是平台写死的约束,绕不过去。
好在照片不会丢。我们的备份每次都”扫描上次成功备份之后新增的全部”,用不着系统告诉我们具体是哪几张。漏掉一次通知只损失时延,不损失数据。开机后周期任务把进程拉起来,顺手就把监听挂回去了。
亏的是那段时间的即时性。要压到零得自己注册一个开机广播,多一个常驻组件换一次开机时延,值不值得,等真机测出实际空窗再定。
写进卡里,标成待定,不猜。
两件事
第一,先读一手文档。 我给这个问题写过三版方案,前两版都在系统外面绕。真正的答案在一段我本来就该读的注释里。一手源码在我本机躺着,翻一下的成本是几分钟。
第二,说”做不到”之前,先问清楚是哪一层做不到。 我最初想解释的是”监听是一次性的,所以做不到不间断”。这句话在 WorkManager 那一层是真的,在 JobScheduler 那一层就是假的。用户不懂 Android 的这些细节,但他知道 event loop 该长什么样,于是他问了一个我认为不可能的问题。
那个问题的答案就写在 addTriggerContentUri 的注释里。