一個零偏離的鎖台功能,為什麼讓剛改完的時窗失效一小時


我這個專案的審查流程,我自己覺得算嚴了:spec 階段我親自把關,plan 過兩個外部 reviewer,實作完還有雙 reviewer 交叉檢查。結果一個會讓「最重要驗收」直接失效的設計缺陷,穿過了全部關卡。它最後被抓到,不是因為哪道關卡盡責,而是因為最後一道裡有一個視角剛好對得上。

這篇就是回頭追問「這怎麼通過的」所拼出的全貌。

最重要的是什麼,就壞在哪

這個階段做的是頻道鎖定的時窗功能——使用者可以設「某個時段鎖住某台」。核心是一條 observeBlockActive,持續觀察「現在時刻,這台頻道的鎖是否生效」。

plan 裡白紙黑字標了一條驗收,叫做 AC-D30,並標注它是「本階段最重要的驗收」:

把時窗改成不含現在時刻,再轉同一台 → 不鎖,直接播;infobar 右上角也不可以出現鎖 icon。

但實作完成後的審查發現:使用者改完時窗、回到 LiveTV 轉台,鎖定狀態會 stale 最長六十分鐘。AC-D30 必炸。

原因在 observeBlockActive 的設計:

/** 目前時刻鎖台時窗是否生效。到下一個時窗邊界(最多 60 分鐘)時自動重新 emit。 */
fun observeBlockActive(): Flow<Boolean> = flow {
    while (true) {
        val schedule = loadSchedule()
        val now = java.time.LocalTime.now()
        val minuteOfDay = now.hour * 60 + now.minute
        emit(schedule.isActiveAt(minuteOfDay))
        val waitMin = schedule.minutesToNextBoundary(minuteOfDay).coerceAtMost(60)
        kotlinx.coroutines.delay(waitMin * 60_000L - now.second * 1000L)
    }
}.distinctUntilChanged().flowOn(Dispatchers.IO)

這條 flow 只在一個地方重 emit:迴圈頂部的 loadSchedule(),配一個 delay 睡到下一個時窗邊界。換句話說,它只回應「時間推進」這一個觸發條件。

使用者改排程 → saveSchedule() 寫進資料庫 → 但這條 flow 不知道,它要等下一次 delay 醒來(最長六十分鐘)才重新載入排程。這六十分鐘內,鎖定判斷用的全是舊排程。

注意那行 doc comment:「到下一個時窗邊界(最多 60 分鐘)時自動重新 emit」——這句話本身就是盲區的活化石。設計者腦中只有一個觸發條件,而且他把它寫進了註解。

這個缺陷是怎麼誕生的

要講清楚它為什麼沒被抓到,得先講它怎麼來的。這跟這個專案的本質有關:它是個重寫,把舊版(Android 9、Java)重寫成新版(Android 16、Kotlin、Jetpack Compose)。

舊版的鎖定判斷是轉台就 reset。每次轉台,即時讀最新排程和當下時間,重新計算這台該不該鎖。這是個無狀態的模型——它不維護任何「目前鎖定狀態」,每次轉台都重算,所以必然用到最新排程。舊版架構上根本不可能產生「改排程後 stale」這個 bug,因為它沒有一個可以 stale 的狀態。

新版換成 observeBlockActive——一條持續觀察「目前鎖是否生效」的 reactive flow,在 ViewModel 裡 stateIn 成一個常駐的布林。這是個有狀態的模型:鎖定狀態被持久維護著,UI 就讀那個布林。這個「現代化」默默引入了一個舊版沒有的職責——維護這個狀態的即時一致性:凡是會改變鎖定結果的事件,都得讓狀態重新計算。

舊版不需要這個職責,因為它沒有「被維護的狀態」可以 stale。新版把狀態引進來,這個職責就跟著進來——但沒有人認出它。

回頭看 spec 裡那段設計論述,就更清楚了:

新版等價且更簡單:LiveTV 訂閱一條 flow { emit(isActiveNow); delay(到下一個時窗邊界) },不需要常駐服務或 AlarmManager。效果一致(時窗到點時正在看被鎖台會即刻彈鎖),少一個生命週期要管的子系統。

整段論述只處理「時間推進到邊界」這個方向,得意於「少一個生命週期要管」。沒人意識到:舊版的「轉台 reset」其實豁免了「資料變更要即時反映」這個職責,而新版把它背上了。

舊版的「無狀態、轉台重算」不是什麼高明的設計,它是那個年代 Java 命令式寫法的自然產物。但它有一個副作用——一個架構決策隱性地替這個 bug 免疫了。新版換成反應式有狀態模型,是更現代、更正確的工程選擇,卻剛好把這個免疫拿掉了。這不是新版的錯,而是「重寫」這件事本來就會把舊架構隱性提供的保障重新攤開來檢驗——問題是這次沒攤開。

它怎麼穿過整條審查鏈

知道了缺陷怎麼來,接下來的問題是:它為什麼沒被早點抓到。

我的工作流在 spec 跟 plan 兩個階段,把關方式是刻意分工的。spec 階段偏「理解舊版行為、決定新版怎麼對應」,我認為這是人該主導的事,所以沒有外審,就我跟 Opus 對談、配 brainstorming 產出。plan 階段才引入外審——我的想法是,到了 plan 就是細節推敲,AI 之間互相把關效果比人類更好,我當最後一個 gate,檢視的是一份已經相當成熟的計畫,不會被雜亂的問題干擾,能抓到的問題更精準。

這個分工假設是:spec 的問題是「意圖/理解」問題,plan 的問題是「細節」問題。但這個缺陷剛好落在兩者之間一個沒被分配給任何人的縫隙——它是個「辨識新架構引入了哪些隱性職責,並確認每個都被履行」的問題。

於是它一路穿過去:

  • spec 階段,把關的是我自己,而我自己就在這個盲區裡。考古舊版時,看的是「舊版怎麼做」,看不見「舊版架構豁免了什麼職責」。
  • plan 階段,plan 忠實實作 spec 的設計。它還為這個 flow 做了大量深思熟慮的論述——為何 coerceAtMost(60)(開機初期時鐘可能還沒對時)、為何用 cold flow 不要 @Singleton(while(true) 會活過畫面生命週期)——全部圍繞「時間觸發」,沒有一個字想到「資料變更」。兩個外部 reviewer 也没抓到。
  • code-facing reviewer(對著 plan 逐行核對「實作有沒有偏離」的 reviewer)判 PASS。因為實作零偏離——逐行核對無懈可擊。fix 後的定性寫得精準:「plan-design gap,非實作偏離」。

直到 project-context reviewer——那個從「這系統真的在每個觸發條件下都對嗎」的角度看的 reviewer——才抓到它。

這些審查視角,沒有一個能看見這類缺陷:

  • 逐行核對偏離:實作零偏離,看不見。
  • 邏輯自洽:flow 本身沒矛盾,「時間到邊界就 emit」邏輯正確,看不見。
  • 考古舊版的對應關係:看的是「舊版機制 → 新版機制」,但「舊版架構隱性豁免、新版重新背上」這種架構層的職責轉移,不在「機制對應」的視野裡,看不見。

要抓到它,需要一個特定的視角:「這個資料流的所有觸發來源是什麼?逐一確認每一個都會 re-emit。」 而這個視角,在我的流程裡,要到最後的 project-context review 才登場——已經是程式碼都寫完之後。

這裡有個容易誤判的地方,得講清楚。這份 plan 後來因為別的階段落地,做過一次「對齊現況」的修訂,我當時裁決「屬對齊現況、不重審」放行。看到這個故事,直覺會想把帳算到「不重審」頭上——但這是錯的。那次修訂只改了 enum 字面和行號校正,根本沒碰 observeBlockActive,這個缺陷早就在更早那次完整的外審版本裡了。就算重審,用同樣的視角還是抓不到,因為前面那次完整外審就沒抓到。 把結論導向「多做一次審查就好」,恰好錯過了真正的教訓。

解法

fix 很直接:加一個 scheduleRevision 計數器,saveSchedule 時把它加一;observeBlockActive 改用 combine(tickerFlow(), scheduleRevision),讓兩個觸發來源——時間推進、資料變更——都會重 emit:

private val scheduleRevision = MutableStateFlow(0L)

// saveSchedule 時:
scheduleRevision.value = scheduleRevision.value + 1   // ← 觸發 observeBlockActive 重算

fun observeBlockActive(): Flow<Boolean> =
    combine(tickerFlow(), scheduleRevision) { _, _ -> ... }

stale 從最長六十分鐘降到即時,AC-D30 過了。

真正的教訓

這對我是個貨真價實的意外。我本來相信這套分工很穩——spec 人類主導、plan AI 互審、我最後把關,各司其職。結果這個缺陷掉在職責的縫隙裡:沒有任何一關的職責定義裡,包含「辨識新架構引入了哪些舊版沒有的隱性職責」。

「問題越早抓代價越小」是對的,但前提是那道關卡得先長出對的視角。而我後來發現一件有意思的事:這次救了 AC-D30 的那個視角——從「這系統在每個觸發條件下都對嗎」看一次——其實我的流程裡早就有了,它就是最後的 project-context reviewer。問題只在於它出現得太晚,程式碼都寫完才登場。

所以修這個流程的方向,不是「多加幾道審查」——那只是多幾次同一類視角,對這類缺陷一樣失明。而是兩件具體的事。

第一,把已經存在、只是放得太晚的 project-context 視角往前移。在 spec 或 plan 的審查清單裡,顯式加一條:對每個 reactive 資料流,列出所有會改變狀態的觸發來源,逐一確認每個都會 re-emit。 同樣的視角,更早的代價。

第二,重寫專案要有一條專屬檢查:「舊版這個子系統的架構,隱性地豁免了哪些職責?新版換了架構,這些職責是不是被重新背上了?如果是,新版有沒有顯式履行它們?」 這條只對重寫專案有意義,但對重寫專案極度重要——因為「現代化」天生就會把舊架構的隱性保障攤開來檢驗,而考古舊版的視角,恰恰看不見自己被豁免了什麼。

審查只能檢查被顯式說出來的東西。而最危險的缺陷,往往就藏在沒有人說出口的那一個觸發條件裡。