一個從未生效的家長鎖,被不相干的功能意外修好


在軟體工程裡,最難抓的 bug 往往不是讓程式崩潰的錯誤,而是那些披著正常運作外皮、在層層測試與審查下「靜默失效」的邏輯漏洞。最近我在手上一個 Android TV 機上盒的 LiveTV app 專案中,就遇到了一個潛伏已久、最終卻因為一個毫不相干的 UI 需求而意外現形的架構缺陷。

這是一個關於 Kotlin 協程(Coroutines)狀態管理、測試盲點,以及執行期生命週期的真實案例。

背景:家長分級鎖的架構設計

專案裡家長分級鎖的資料流機制原本是這樣設計的:系統的平台設定裡儲存了一個分級索引,程式會透過一張對照表將這個索引轉換成實際的分級門檻。當節目的分級達到或超過該門檻時,系統就會將畫面鎖住,並跳出要求輸入 PIN 碼的畫面。

對照表的邏輯位於 SettingsCodecs.kt(實際的分級值以級別代稱表示):

fun ratingThreshold(index: Int): Int = when (index) {
    0 -> PROTECTED       // 保護級
    1 -> PG12            // 輔12級
    2 -> PG15            // 輔15級
    else -> RESTRICTED   // 限制級
}

這裡有個容易讀反的邏輯細節:門檻設得愈低,鎖得愈嚴。 門檻如果設在保護級,代表保護級以上(含)的節目通通都要鎖起來,這是最嚴格的設定;反之,門檻設在限制級,代表只有限制級的節目才會觸發鎖定——這是這張表裡最寬鬆的選項。

Repository 層將這個設定包裝成一個 Flow:

override fun getRatingThreshold(): Flow<Int> = refresh.map {
    SettingsCodecs.ratingThreshold(getInt(SettingsKeys.PARENTAL_RATING, SettingsKeys.DEFAULT_PARENTAL_RATING))
}

接著,ViewModel 端將其轉換為 StateFlow(位於 LiveTvViewModel.kt:137),以便後續使用:

private val currentRatingThreshold = settingsRepository.getRatingThreshold()
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5000),
        initialValue = RESTRICTED // Default to Restricted
    )

最後,程式在兩個核心路徑讀取這個門檻值:切台時的 tuneTo,以及節目轉換時的重新評估邏輯 reevaluateCurrentChannel:

val threshold = currentRatingThreshold.value
val line: LockLine? = when {
    channel.isBlocked -> LockLine.CHANNEL_BLOCK
    else -> when (checkRatingLockUseCase(isAdult, rating, threshold)) { ... }
}

這套實作看起來合情合理。這段程式碼在專案裡安穩地活了好幾個開發階段,順利通過了 code review,而且 277 個 JVM 單元測試全部亮著綠燈。

問題現形:不相干的新功能觸發了鎖

某天,我完成了另一個跟分級鎖完全無關的需求——在切台時跳出來的節目資訊列(infobar)上,加上一個鎖頭圖示。

把新版本裝上實機測試,按遙控器切了幾個頻道,畫面卻突然跳出了分級鎖的 PIN 碼輸入介面。奇怪的是,那台機器以前從來不會鎖。我的第一反應是:新功能寫壞了,這是一個 regression。於是立刻去抓 logcat 紀錄:

LOCK_TRIGGERED_BY_REEVAL sid=<遮蔽> rate=<保護級>

Log 顯示,那台機器當下播的節目是保護級。我進一步去查機器上的平台設定,發現家長分級門檻確實設在保護級——節目達到門檻,觸發鎖定,這個行為完全正確。

所以問題的方向徹底反轉了:關鍵不是這次為什麼鎖,而是以前為什麼不鎖。

更重要的是,那個「保護級」的設定並不是機器出廠的預設值,是我自己前一段任務為了測試親子鎖,手動改成最嚴的級別的。 而當時改完之後,並沒有任何頻道被鎖住。

根因剖析:為何安全機制靜默失效?

經過深入調查,這個問題是由四個看似合理的巧合疊加而成的完美風暴。

1. WhileSubscribed 是懶的,而 .value 不算訂閱

SharingStarted.WhileSubscribed 的設計語意是:「當有訂閱者存在時才啟動上游收集,最後一個訂閱者離開 5 秒後停掉」。如果沒有任何訂閱者,上游的 getRatingThreshold() Flow 根本不會被收集,StateFlow 就會一直停留在 initialValue 的狀態。

回頭看我們的讀取端,全部都是這樣寫的:

val threshold = currentRatingThreshold.value

.value 是一個同步取值的操作,它不建立訂閱、也不會喚醒上游。

把整個 codebase 搜尋一遍,currentRatingThreshold 只有四個出現位置:一個宣告、一個新加的圖示邏輯,以及兩個 .value 讀取。也就是說,在這次改動之前,整個專案裡存在著零個 flow 訂閱者。

結果就是一連串的連鎖反應:stateIn 永遠處於休眠狀態 → .value 永遠只回傳 initialValue → 平台上真正的家長設定從來沒有被讀進來過。即使使用者在設定頁把分級調到最嚴格,程式照樣拿著預設的限制級門檻去比對。

2. 「Restricted」讀起來像最嚴,其實是最寬鬆

initialValue = RESTRICTED // Default to Restricted

當初寫下這行程式碼的開發者,八成覺得「預設值就給最嚴格的,比較安全」。而 Restricted(限制級)這個詞,字面上讀起來也確實像最嚴苛的級別。

但在這張對照表的語境裡,它代表的是門檻而非單純的分級:門檻設在限制級,等於只有限制級才鎖,這是表裡最寬鬆的選項。

這導致了一個讀起來像 fail-safe 的預設值,實際行為卻是 fail-open。 如果當初凍住的初始值是保護級門檻(最嚴),這個 bug 第一天就會炸開——因為一堆頻道會莫名其妙全被鎖起來,絕對會有人回報。但它凍在最寬鬆的那一端,表現出來的現象是「分級鎖從來不觸發」。而一個從不觸發的鎖,跟一個「剛好沒有節目超過門檻」的鎖,肉眼看起來一模一樣。

沒有人會來回報一個沒有發生的鎖。

3. 為什麼 277 個測試都沒抓到?

我們來看看 ViewModel 測試裡的 mock 是怎麼設定的:

every { settingsRepository.getRatingThreshold() } returns flowOf(RESTRICTED)

Mock 回傳的是限制級,而 stateIn 的 initialValue 也是限制級。

因為這兩個值一模一樣,導致測試在結構上根本不可能分辨「stateIn 醒著」跟「stateIn 在睡」的差別——無論哪種情況,.value 都會回傳同一個值。

這並不代表測試沒有寫。分級鎖的測試確實存在,也真的有塞一個限制級的節目進去,並成功斷言 LockLine.RATING 有跳出來(限制級達到限制級門檻,邏輯成立,測試綠燈)。但它驗證的只是「比較邏輯是對的」,它拿到的門檻值卻只是巧合,而不是資料流真正跑出來的結果。

再往下看純函式層級的 CheckRatingLockUseCase:

operator fun invoke(isAdultChannel: Boolean, programRating: Int, threshold: Int): LockKind? =
    session.evaluate(isAdultChannel, programRating, threshold)

threshold 只是傳進去的參數。它的單元測試更不可能發現問題,因為門檻是怎麼來的,根本不在它的視野內。這個 bug 不在任何一個單元的內部邏輯裡,它藏在「值有沒有真的流過來」這件事上,而那正好是每一層 mock 都替你跳過的部分。 加上 repository 預設查表出來也是限制級,在任何「使用者沒動過家長設定」的機器上,休眠值跟正確值完全一致,行為根本無法區分。

4. 為什麼手動測試也沒抓到?

前一段任務中,我確實手動測試了親子鎖,而且測試步驟是標準的:進設定頁,把分級門檻手動改到最嚴的保護級,然後回去切台看會不會鎖。 當時的結果是沒有任何頻道被鎖。

我當下的解釋是:那個時間點所有頻道播的都是普遍級節目,普遍級低於保護級門檻,本來就不該鎖。這個解釋完全正確。 它是實話。

但這個實話同時也把真正的問題掩蓋了。因為在那個當下:

  • 如果 app 是正常的:門檻保護級,節目普遍級 → 不鎖
  • 如果 app 是壞的(休眠在限制級門檻):節目普遍級 → 也不鎖

兩種情況的觀察結果一模一樣。 那次測試不管 app 是好是壞都會得到「沒鎖」的結論,其資訊量其實是零——但它卻讓人產生了「測過了、沒問題」的錯覺。

要讓休眠值跟真值產生不同的行為,需要一個分級落在兩者之間的節目(保護級以上、限制級以下)。而我這次切台時剛好遇到正在播保護級節目的頻道,滿足了這個條件,加上新的 UI 功能已經落地,才讓這個潛伏的 bug 以「regression」的姿態現形。

專案裡其實早有規矩寫在驗收清單旁:機器上不存在可測的頻道時,該項要標「阻塞:無可測頻道」,不得標 PASS。 這次就是一個 null result 被誤當成 pass 讀取了——而且是我自己讀的。

解法與承重的副作用

那麼,這個 bug 到底是怎麼被修好的?

新功能為了要在 infobar 顯示鎖頭圖示,必須把門檻值放進 UI state 的 combine 裡(位於 LiveTvViewModel.kt:184):

combine(
    epgRepository.observeNextProgram(ch.uri),
    _currentProgram,
    currentRatingThreshold          // ← 全專案第一個真正的訂閱者
) { next, cur, threshold ->
    ...
    channelMarkLocked = ch.isBlocked ||
            ChannelGroup.ADULT in ch.groups ||
            (rating >= 0 && rating >= threshold)
}

combine 運作時會收集它的每一條上游。這行程式碼讓 currentRatingThreshold 第一次有了 flow 訂閱者,喚醒了 WhileSubscribed,促使它開始收集 repository 的資料,平台上真正的門檻值才終於被載入。

於是,包含那個跟新功能毫無關係的切台路徑在內,兩個讀取點一起變正確了。

一個畫圖示的需求,順手把一個沉睡多時的安全功能打開了。而它在現場的第一個表現,是看起來像 regression。

當下的判斷是:原來的業務邏輯不修。 那台機器會鎖定是正確的,要修的是系統架構上的隱含依賴。

現在分級鎖能正確運作,完全依賴 infobar 那個 combine 裡有訂閱 currentRatingThreshold。這是一條隱含依賴,沒有寫在任何地方,型別系統看不見,測試也無法保障。將來只要有人覺得 combine 太肥而改用 .value 讀取、重構掉鎖頭圖示,或是拆開 UI state,分級鎖就會無聲無息地退回休眠狀態。編譯會過、測試全綠,然後家長鎖再次靜默失效。

這就是所謂的 load-bearing side effect(承重的副作用),它最危險的地方在於,移除它的那次改動看起來會是完全無害的。

正確的架構解法,是讓安全機制不要依賴「剛好有人訂閱 UI」。我們可以更改啟動策略:

started = SharingStarted.Eagerly

或是直接使用 viewModelScope 啟動一個常駐的收集。分級鎖這類核心功能的正確性,應該在所有路徑都生效,包含 UI 還沒組建完成的時候。當然,代價是必須全面確認改為 eager 之後,所有路徑取得真值的行為都符合預期。

一般化的判準

從這次經驗中,我們可以歸納出一個直接的判準: stateIn(WhileSubscribed) + 只用 .value 讀取 = 這個 StateFlow 永遠是 initialValue。

這兩個機制單獨看都很正常,WhileSubscribed 是官方推薦的省電寫法,.value 在 suspend function 中同步取值也天經地義,問題出在它們的組合。如果你的專案裡有一個 WhileSubscribed 的 StateFlow,而所有的讀取點都是 .value,沒有任何 collect、combine 或 flatMapLatest 去訂閱它,那它就只是個裝飾品,永遠只會回傳初始值。

另一個防禦性策略是:initialValue 不要選那個「看起來安全」的值,選一個一眼就知道不對的值。 如果這次初始值設成一個不在對照表裡的哨兵值(sentinel value),第一天就會有人發現門檻值異常。而它偏偏設成了一個合法、最寬鬆、等於預設值,又等於測試 mock 的值——四個巧合疊在一起,造就了這場靜默失效。

附帶收穫 (Key Takeaways)

最後,這次事件留下了三個重要的工程教訓:

  1. 測試的 mock 值,不要等於待測程式的預設值。 回頭看,這個 bug 在單元測試唯一能現形的地方就是 mock 那行。只要 mock 給的不是限制級而是中間某一級,同一個斷言依然會過,但只要涵蓋到邊界測試,休眠與否立刻無所遁形。兩者相同時,測試驗證的只是「巧合」,而非「流程」。

  2. 一次「什麼都沒發生」的測試,在前置條件沒被確認之前,不是證據。 這是我學到最貴的一課。去測了親子鎖,得到「沒鎖」的結果,並給出了合理的解釋(節目都是普遍級)。但**「沒觀察到 X」只有在「X 本來應該發生」成立時才有意義。** 條件不滿足時的測試資訊量是零,做不到條件成立就該標示阻塞,而不是放行。

  3. 「靜態全綠」跟「跑起來是對的」是兩件事。 這次改動經歷了完整的計畫審查、兩位獨立 reviewer 逐項對照、277 個 JVM 測試全綠,靜態層面無可挑剔。但靜態審查看得到程式碼,看不到執行期的生命週期。「StateFlow 此刻有沒有訂閱者」是執行時的狀態,你看不到它從來沒有醒來過。這類冷啟動、時序與生命週期的缺陷,只有在真正跑起來的實機上才會現形,實機驗收永遠不是虛應故事的形式。