同一個函式,兩種讀法:infobar 為什麼讀到舊頻道
音樂頻道的 infobar 應該常駐——換到音樂頻道,節目資訊列不會自己收起來;離開音樂頻道,才照常逾時消失。這是計畫裡寫下的要求。實機驗收時我拿遙控器換台,三個方向裡有兩個不對:
| 換台方向 | 預期 | 實機 |
|---|---|---|
| 非音樂 → 音樂 | infobar 常駐 | 會關(錯) |
| 音樂 → 音樂 | 常駐 | 不關(對) |
| 音樂 → 非音樂 | 逾時收 | 不關(錯) |
看起來像 infobar 拿來判斷「是不是音樂頻道」的,是上一個頻道。
同一個函式裡的兩種讀法
換台走一個叫 tuneTo 的函式。Claude 讀程式碼,看到未鎖頻道的分支裡,showMiniEpg("zap") 這一步排在 tvViewManager.tune(channel) 之前(LiveTvViewModel.kt:494)。
showMiniEpg 管 infobar 的顯示,裡頭用 isAudioChannel() 決定要不要常駐。而 isAudioChannel() 讀的是 tvViewManager.currentChannel.value。問題在於,currentChannel 要等 TvViewManager.tune() 真的執行進去(TvViewManager.kt:163),才同步成新頻道。showMiniEpg 在 tune 之前就被呼叫了,那一刻 currentChannel 還是舊頻道,音訊判定於是用了舊頻道。
但「順序排錯」不是這個 bug 最該注意的地方。最該注意的是,同一個 tuneTo 函式裡,關於「現在到底是不是音樂頻道」這一個事實,有兩處讀法,而它們不一致:
- 音樂頻道的背板,判定讀的是
channel——傳進tuneTo的新頻道參數。 - infobar 的守門,讀的是
currentChannel.value——還沒更新的狀態。
一個函式、同一瞬間、同一個事實,兩處給出不同答案。背板對、infobar 錯——這個對比本身就是線索:換台這個動作整體沒有用錯頻道,背板讀的是對的;出錯的只有 infobar 一處,因為只有它去讀那個還沒更新的狀態。症狀表裡「音樂 → 音樂」之所以碰巧正確,不是因為它真的判對了,而是因為上一個頻道剛好也是音樂頻道,舊頻道也合格。換成「非音樂 → 音樂」,舊頻道不合格,bug 就現形了。
兩條修法
修法有兩條路,差別才是這篇的重點。
第一條是 swap 順序:把 showMiniEpg("zap") 挪到 tvViewManager.tune(channel) 之後。showMiniEpg 內容一個字都不用改,呼叫順序換一下,實機就行為正確了。而且這最貼近計畫的字面——計畫寫的就是這個順序。這條路我考慮過,沒走。
第二條是我選的:給 showMiniEpg 加一個顯式參數 audioChannel: Boolean = isAudioChannel()。default 留 isAudioChannel(),是給 DOWN 鍵那種不換台的呼叫處用的;真正換台的 zap 點,則顯式傳新頻道的判定 audioChannel = ChannelGroup.AUDIO in channel.groups——直接讀傳進來的新頻道參數,不靠 currentChannel 的時序。
兩條路都能讓實機行為正確。差別在於,swap 那條把時序依賴留在隱式狀態裡——showMiniEpg 的正確性從此依附於「它剛好排在 tune 之後」這個從沒被寫出來的前提。哪天 tune 改成非同步、或有人把 showMiniEpg 挪到別處呼叫,它就會無聲壞掉,而且壞的方式跟今天一模一樣:又回去讀舊頻道。
我選顯式參數,代價是它偏離計畫的字面。但計畫的字面只描述了「正確的順序」,沒有描述「為什麼這個順序是對的」。照著計畫抄的人不知道自己為什麼對,下一個改動的人也不知道動哪裡會錯。
回歸測試照這個意圖釘:走真實的 tuneTo,但故意把 currentChannel 設成舊頻道。zap 點如果被改回不傳參、退回 default 的 isAudioChannel(),就會讀到舊頻道、排進逾時收、測試 fail。換台的兩個方向——非音樂進音樂要常駐、音樂離開非音樂要逾時收——實機都驗過了。
結論
「貼近 spec 字面」常常是把脆弱性合法化。計畫寫「先 tune 再 showMiniEpg」,照著排就行為正確,但這份正確性依附在一個沒有任何人宣告的前提上:呼叫順序。spec 描述了正確的樣子,沒有描述正確性的來源。
當一個函式裡有兩處需要同一個事實,讓它們讀同一個顯式來源,不要一個讀參數、一個讀會變的狀態。swap 順序能修好今天這個症狀;顯式參數修好的,是這個症狀的產生方式。