收藏 / 取消收藏 / 关注 / 取关这类写操作,过去是点一次发一次请求。连点爱心、
批量关注、小组件快捷收藏都会在几百毫秒内打出多个 POST,很容易被 pixiv 429。
全链路此前没有任何节流、去重或退避(Client.kt / Retro.java 的 OkHttp 链上
只有 header 和 token 拦截器,AppError.RateLimited 只用来生成文案)。
这个模块把这些动作变成:入队即返回 → 后台串行按最小间隔发送 → 撞限流整队冷却并自动 重试 → 进程被杀后下次启动继续发。
:actionqueue 是一个独立的 Android library(ceui.pixiv.actionqueue),
对 pixiv 零依赖 —— 不引 retrofit / okhttp / gson / :models。它只认识
「一个带 type 的不透明 payload 字符串」和「一个 suspend 执行器」。
依赖只有 room + coroutines。执行器(ActionHandler)由 :app 实现并注册。
- 数据库隔离:主库
AppDatabase已经是 v41、19 条手写 migration、24 张表。 加一张表就要给所有既有表担一次迁移风险。本模块用自己的pixiv_action_queue.db(v1),升级互不影响。同仓ChatDatabase就是这个先例。 - 可测:核心调度逻辑不碰 Android,能在纯 JVM 单测里用虚拟时间跑完。
| 惯例 | 本模块 | 原因 |
|---|---|---|
object 单例 + init(context) |
可注入的普通 class | object 的全局可变状态跨用例泄漏,时间和存储都换不掉,等于没法单测 |
allowMainThreadQueries() |
不开 | 开了只会掩盖「不小心在主线程读库」 |
| DAO 阻塞式 | 全 suspend | 所有访问都在协程里 |
fallbackToDestructiveMigration |
不用,手写 migration | 队列里躺的是用户点过但还没生效的收藏,销毁式迁移会静默吃掉 |
exportSchema = true,actionqueue/schemas/ 要跟着提交 —— 手写 migration 的前提。
// 入队请求
ActionRequest(
type = "illust_bookmark", // 找 handler 用
dedupeKey = "illust_bookmark:12345", // 合并用
payload = """{"id":12345,...}""", // 队列不解释内容
coalesce = true, // 默认合并同 key 的 PENDING
gapMs = 0, // 0 = 用全局 minGapMs
)
// 归属:行入队时会记下 owner(app 侧传登录 uid),取行 / 计数 / 合并一律按它过滤。
// 库跨登录态持久,不分归属的话 A 没发完的收藏会用 B 的 token 发出去。
ActionQueue(..., owner = { SessionManager.loggedInUid.toString() })
// 执行器(app 侧实现)
fun interface ActionHandler {
suspend fun execute(action: PendingAction): ActionOutcome
}
sealed interface ActionOutcome {
object Success // 删行
data class Retry(retryAfterMs: Long?, cause: Throwable?) // 整队冷却后重试
data class Fail(reason: String, cause: Throwable?) // 终态,不再重试
}ActionQueue 对外还提供 events: SharedFlow<ActionEvent>(成功 / 重试中 / 终态失败)、
state: StateFlow<QueueState>、pause() / resume() / retryAllFailed() /
forget(id)(处理完失败反馈后删行)/ clearFailed()。
ActionEvent.Failed 带 supersededByPending:同 dedupeKey 上还压着更新的意图,
此时不要回滚,见 4.7。
while (isActive) {
if (暂停 || gate 关着) -> 睡 idlePollMs
if (now < 冷却截止) -> 睡到冷却结束
if (now < 下一条允许执行时刻) -> 睡到该时刻 ← 节流
取一条 PENDING 且 notBefore <= now,没有就睡
标 RUNNING → handler.execute() → 按结果 删除 / 重排 / 标失败
下一条允许执行时刻 = now + max(minGapMs, 本条 gapMs)
}
唤醒靠 Channel<Unit>(CONFLATED),不用 Room 的 Flow 驱动 consumer ——
DownloadQueueDao 的注释记过这个坑:高频 UPDATE 下 InvalidationTracker 会在首次
emit 之后静默不再触发。
429 限制的是单位时间请求数,节流点必须在消费侧的执行间隙。所以 2 秒是 「上一条执行完 → 下一条开始执行」的最小间隔。冷启动后第一条立即执行,不平白等一个间隔。
连点爱心产生 收藏 → 取消 → 收藏。按 dedupeKey 做替换式入队(同一事务里先删同 key 的
PENDING 再插),同一目标只保留用户的最后一次意图,三次点击最终只发一个请求。
已经 RUNNING 的行删不掉(请求在飞),此时新行照常排队,靠 handler 幂等收敛。
429 是账号级速率限制。只让失败那条退避、后面的照发,只会继续撞墙。撞到 Retry 时
整个 consumer 进冷却,该行 attempt+1 回 PENDING 且保持原 id(不丢 FIFO 位置)。
退避 30s → 60s → 120s → 300s 封顶,带 ±20% 抖动(防多任务同时恢复又一起撞)。
响应带 Retry-After 时以服务端为准,即使超过指数退避的封顶值也照听,但采信有天花板
maxRetryAfterMs(30 分钟)—— 一个 Retry-After: 86400 能把整队冻到进程结束,
而冻住期间用户每次收藏都只是变红然后石沉大海,界面上看不出队列已经停了。任意一次成功后清零。
冷却值落库(queue_meta 表),不是只放内存:冷却动辄几分钟,而「后台待几分钟被系统
回收」恰恰是这个窗口里最可能发生的事。只存内存的话,下次启动会带着一整队 notBefore=0
的行原地重新撞同一个还没过期的账号级限流。
但读回来时要钳掉墙钟错乱写出的离谱值。落库的是绝对时刻而 Clock.SYSTEM 是墙钟:
RTC 失效的设备开机常带一个偏前的时间,此时撞一次 429 写下的截止时刻就是「偏前的时间 + 冷却」,
等 NTP 把时钟校回来,now < cooldownUntilMs 从此恒真。而清零冷却只有「某条动作执行成功」和
retryAllFailed() 两条路,前者被冻着就不可能发生 —— 队列会静默停摆到重装为止。
start() 按 QueuePolicy.maxPossibleCooldownMs(指数退避封顶叠抖动 与 maxRetryAfterMs 取大,
即单次冷却的上确界)钳一刀:合法值按定义就在这个上界内,一个都不会被削短。
钳完写回库——只改内存的话每次冷启动都会照着坏值重新钳一轮,白冻到进程结束;写回去的是绝对
时刻,真实时间一过就自愈。
合并入队时,被删掉的 PENDING 行若正在退避(attempt > 0 / notBefore 在未来),新行
继承它们最大的 attempt 和 notBefore。不继承的话,用户对着一个一直失败的目标反复点,
每点一次重试预算就清零一次,这条动作永远到不了 maxAttempts,既不判终态失败也不回滚,
红心一直挂着,而每次尝试都在把整队冷却顶起来、连累其他所有收藏和关注。
进程可能在「请求已发出、响应没回来」的瞬间被杀。下次启动 resurrectRunning() 把残留
RUNNING 复位 PENDING 重跑 —— 也就是同一动作可能执行两次。收藏/关注天然幂等
(pixiv 对重复收藏返回成功)。将来要接非幂等动作(发评论、发私信),必须先在 payload 里
带业务去重 id 并由服务端兜底。
动作是瞬时的,保留 SUCCESS 行没有意义且会无限膨胀。FAILED 短暂保留,等 app 侧把回滚和
提示做完后由 forget(id) 删掉 —— 目前没有任何界面能看到 FAILED 行,留着只会带着最长
500 字的错误文本一直堆积。上个进程遗留的 FAILED 在启动时统一清掉:它们的乐观状态随进程
一起没了,重启后界面上的收藏态本来就是服务端真值,既回滚不了也没人会去看。
step() 里所有 store.* 调用都在 try/catch 里。磁盘满 / 库损坏时如果让异常冒出去,
loopJob 会死在没有 CoroutineExceptionHandler 的 scope 上 —— 既崩进程,又让这个进程
剩下的时间里每次收藏都只写了乐观状态却永远发不出去。出错就上报 onError 并睡一轮再试。
入队同理:enqueue() 把请求投进 Channel(UNLIMITED),由单个消费协程按顺序写库。
不能各自 scope.launch —— 那些协程落在多线程 IO 池上,连点两下(收藏、取消)时后发的
可能先落库,随后先发的那条再把它合并掉,结果队列发的是「收藏」而界面显示「未收藏」,
且永远不会自愈。
未登录时 consumer 只睡不取。否则退登状态下一整队请求全 401,白白烧完重试次数把用户 真实的收藏意图变成终态失败。
光判「有没有登录」不够:库是跨登录态持久的,Common.logOut 也不杀进程。A 连点二十个
收藏、队列才发完四条就退登、换 B 登录 —— 剩下十六条会用 B 的 token 发出去,收藏进 B 的
账号。所以每行都记 owner(入队时的登录 uid),取行 / 计数 / 合并一律按它过滤;A 的行
在 A 再次登录时才继续发。
UI 点击后本地状态立刻改,不等网络。只有 ActionEvent.Failed 才回滚
(Retrying 还会再试,那时回滚会让爱心来回跳)。
判断「这条失败还该不该回滚」的依据是 supersededByPending,不是比较当前值:
收藏 → 取消 → 收藏之后,当前值和失败那条恰好相等,比值会误判成可以回滚,把用户还没发出去
的最新意图覆盖掉。队列在广播 Failed 前查一次「同 owner 同 dedupeKey 上还有没有 PENDING」,
有就置位,UI 直接跳过回滚(连提示都不弹 —— 用户看到的就是他最后一次点的状态)。
比值的守卫仍然保留,挡的是队列之外改过状态的路径(例如详情页刚从服务端刷回真值)。
不在点击时报埋点。 那一刻请求还没出去,之后可能因为限流打满重试或作品已删除而终态
失败并被回滚,而埋点发出去撤不回来(协议里没有反向事件),社区热度榜就会按一堆从未发生过
的收藏来排序。埋点改在 ActionEvent.Succeeded 时发。
不在点击时弹「成功」toast。 同理:队列可能正在冷却、也可能被闸门挡着,此时报成功是骗 用户,而几分钟后终态失败还会再补一个「操作失败」自相矛盾。反馈由按钮/爱心本身承担。
Shaft.onCreate
└─ PixivActionQueue.init(this) // 在 SessionManager.initialize / EventReporter.init 之后
├─ 注册 IllustBookmarkHandler / NovelBookmarkHandler / UserFollowHandler
├─ gate = { SessionManager.isLoggedIn }
├─ owner = { SessionManager.loggedInUid.toString() }
├─ start()
├─ 清掉上个进程遗留的 FAILED 行
└─ 订阅 events:成功时补埋点;终态失败时回滚 ObjectPool + toast + forget 该行
(订阅 scope 自带 CoroutineExceptionHandler,且每条事件单独 try/catch ——
一条处理不了的事件不能把整个订阅带走,否则此后所有失败都不再回滚也不再提示)
UI 一律走门面 PixivActions(ceui.pixiv.actions):
PixivActions.toggleIllustBookmark(illust)
PixivActions.toggleNovelBookmark(novel)
PixivActions.setUserFollow(userId, follow = true, restrict = Params.TYPE_PUBLIC)门面负责:乐观更新 ObjectPool → 入队。埋点由队列在服务端确认之后补发。
收藏的默认可见性走 PixivActions.defaultBookmarkRestrict(),读「私密收藏」设置 ——
仓库里每个收藏入口都尊重这个开关,门面自己写死 public 等于把用户明确要求保密的收藏
公开挂到主页上。
批量入口(批量选择页底栏,issue #974)也在同一个门面里,就是循环调 setIllustBookmark:
val enqueued = PixivActions.setIllustBookmarks(illusts, bookmark = true) // 返回实际入队条数
PixivActions.pendingIllustBookmarkCount(illusts, bookmark = true) // 动手前先问要发几条
PixivActions.setNovelBookmarks(novels, bookmark = true) // 小说版,同语义
PixivActions.pendingNovelBookmarkCount(novels, bookmark = true)
PixivActions.estimatedQueueMinutes(count) // 全部发完约几分钟小说那支没有「收藏后自动关注作者」—— 单张小说卡本来就没有,批量不该凭空多一个副作用。
页面是两个(BulkSelectV3Fragment / NovelBulkSelectV3Fragment),因为下载后端、模型、
可导出的东西全不一样;共用的只有这个门面和底栏那套 MD3-E 按钮 drawable。
「怎么发」全是队列的事,批量侧一件都不重复实现;门面在批量这一层只多做三件单条不需要的事:
剔掉已经是目标态的项(否则 toast 上的数字不等于真会发出去的请求数)、分块 yield
(每项要写几个 ObjectPool 表示、发一条广播、碰一次 MMKV,几千项连着跑完足够掉帧 ——
注意 scope 不能用 Main.immediate,那样 yield() 退化成空操作,分块等于没写)、
以及逐项 try/catch(一处抛出会让循环当场结束,剩下的项静默丢掉)。
耗时估算留在 PixivActionQueue.estimatedMinutes:节奏是队列定的(MIN_GAP_MS),
不该让调用方去猜一个常量。一次几百项就是十几分钟,确认框里明写 —— 不说的话用户会当成没生效。
HTTP 状态码到 ActionOutcome 的翻译在 PixivActionHandlers.kt:
429 / 408 / 400 / 401 / 403 / 5xx / IOException → Retry,其余 → Fail。
400 也重试:它正是 pixiv 表达「access token 过期」用的码,正常由
TokenFetcherInterceptor 在链内刷新重放,能漏到这里说明那次刷新自己失败了(刚连上网时
refresh 超时等)。判终态失败的话,一次网络抖动就会让爱心弹回去,而那时 token 多半已经
刷好、同一个请求两秒后就能成功。真是参数非法,五次之后照样收敛成终态失败。
已迁移(V3 的 suspend 写路径):
DetailFeedSupport.toggleIllustBookmark/toggleNovelBookmark→ 调用方IllustSeriesFragment/NovelSeriesFragment/NovelTextFragmentNovelFeedFragment.toggleNovelLike(小说卡片;收藏后自动关注也一并入队)NovelReaderV3ViewModel.toggleBookmark(V3 阅读器;它和详情页拿同一个 ObjectPool 当真值, 一边排队一边直发会互相删对方的收藏)UActivity.followUser/unfollowUser(顶层扩展函数) → 调用方UActivity/UserActivityV3/FragmentIllust/ArtworkSectionRenderers/IllustSeriesFeed/RequestPlanDetailFragment顺带删掉了原先硬编码的delay(500L)(等服务端落库再刷 UI,乐观更新后纯属白等)。
已迁移(legacy RxJava 写路径,现在都只是 PixivActions 的薄封装):
PixivOperate.postLike(12 处调用点,含 feeds 卡片爱心IllustFeedFragment,连点重灾区)PixivOperate.postFollowUser/postUnFollowUserSelectTagFeedFragment.submitStar(按标签收藏)—— 带标签和不带标签打的是同一个bookmark/add端点,是互相覆盖不是叠加,所以共用 dedupeKey。标签随 payload 走 (BookmarkPayload.tags,追加字段,老行反序列化为 null 正好落到不带标签那一支)。 顺带修掉:此前它只发LIKED_*广播、不写 ObjectPool,按标签收藏之后读池渲染的 V3 详情页 那颗心还是灰的。PixivOperate.postLikeNovel(Novel, String, View)已删除(全仓无调用方, 留着只是给下一个人一个绕开队列的现成入口)。
未迁移:
WidgetBookmarkWorker.kt:38(blockingFirst()in CoroutineWorker,失败明确不重试)。 它不写 ObjectPool 也不发广播,且与队列并行——「app 内取消收藏(进队列,正在冷却)→ 小组件收藏(直发)」会以相反顺序落到服务端。
已知但本次没动的架构裂缝:收藏状态目前有三个真源
(IllustsBean.is_bookmarked 可变共享实例 / Illust.is_bookmarked immutable /
ObjectPool 里可能是 gson merge 出来的第三个克隆),且 legacy 链路发
LIKED_ILLUST 广播而 V3 suspend 链路只写 ObjectPool。本次刻意保持各路径原有的更新语义
不变,只把「发请求」这一步换成入队,好让 diff 是行为保持的。统一广播是独立的一步。
- WorkManager 兜底:目前 consumer 只在 app 进程存活时跑,队列在下次启动才续。
想让「杀掉 app 也能补发」,可以加一个带网络约束的
OneTimeWorkRequest。 注意两个消费者不能同时跑,需要进程级 Mutex(本 app 没声明多进程,Worker 与主循环同进程, 一个 Mutex 就够)。 - 队列可视化:模块侧
state/failedCount()/retryAllFailed()都在,但 app 侧目前 没有任何界面用它们,所以PixivActionQueue没有把它们转出去(转出去也是死代码)。 真要做调试页时再加,届时 4.5 的「反馈完即删」要改成保留失败行。 EventReporter复用本模块:它的queue是纯内存ArrayDeque(EventReporter.kt:97),进程一死未上报的埋点就丢 —— 正是这个模块要解决的问题。- 无归属行与陈旧行的清理:退登期间入队的行 owner 记的是
"0",旧账号的行同理 —— 既不会被执行也不会被clearFailed(owner)收走。且队列没有时效,半年前排下的 PENDING 在用户重新登录后照发。ActionEntity.createdAt已经落库但没有任何查询读它, 加一次启动期按createdAt的扫尾即可。 - 断网判定 fail-open:
NetworkMonitor.isConnected在SecurityException(部分 OEM ROM) 时返回true。那类机器上真断网会被判成「在线 IOException」→ 计入重试预算 → 7.5 分钟烧完转终态失败并回滚,正是countsAsAttempt要避开的场景。