頻道分類排序:列搬走了,焦點沒跟上
背景
機上盒的設定畫面,自動測試測不到焦點和遙控器按鍵,所以這個專案的驗收方式是實機 walkthrough:我操作遙控器、口述畫面,Claude 在另一頭抓 adb logcat,把我說的「發生了什麼」跟 log 裡的軌跡對上。對上了,這一項才算過關。
主角是「頻道分類」畫面:11 列頻道分類,可以重新排序。進場焦點在第一列,按黃鍵進排序模式,UP/DOWN 把目前這一列和上/下兩列交換。
這個畫面有一條寫在計畫裡的核心要求(plan D10/D11、執行流程步驟 4 都明文):
焦點必須跟著被搬動的那一列跑(不是留在原視覺位置)。
計畫特地不沿用共用的設定清單元件、自己做這個畫面,就是為了避開共用元件「列搬走了、焦點沒跟上」的毛病。這條要求是整個畫面的設計起點。
問題
walkthrough 走到排序模式(D7)。要我把某一列連按 UP 搬到底。按了幾下,我回報:
按上下會跟上面/下面交換,但是 focus 不變,所以沒法連續移動。
這正是計畫當初想消滅的那個症狀。
根因剖析
Claude 讀 GenreSettingScreen.kt 推導出來的:
focusRequesters用remember(totalRows) { List(totalRows) { FocusRequester() } }——requester 綁位置 index。- 但 item 的 identity 綁列身份(
itemsIndexed(rows, key = { _, r -> r.group.name }))。兩者綁了不同的東西。 - swap 時
focusRequesters.getOrNull(newIdx)?.requestFocus()在onPreviewKeyEvent裡同步執行——這一刻畫面還沒 recompose,focusRequesters[newIdx]還綁著「重組前那個位置的列」;recompose 完成後,那一列因為key = identity帶著焦點移走,焦點於是黏在原本的視覺位置。
執行流程步驟 4 的原話是:
那個一次性旗標是綁在『列』上、不是綁在『位置』上,已經用掉之後就不會再要焦點。
這是一條「綁列、不要綁位置」的警告。實作把 requester 綁位置,正好綁反,重現了計畫想消滅的症狀。
fix 前的 device log 印證:排序時那一列一直在 position 9/10/11 之間 ping-pong,離不開。
靜態審查為什麼沒抓到
這個畫面在上一階段已經過過一輪 AI 程式碼審查(Opus)。那輪審查判了一個 Critical:指控每列 Box 上的 onPreviewKeyEvent 收不到黃鍵。
Claude 把它推翻了——那條規則(guide R1)講的是同一個節點上 modifier 的順序,不是祖先 Box;按鍵 capture 階段從 focused node 往上必然經過父層的 onPreviewKeyEvent;而且這個專案有 10 幾處實機驗過的同型用法(LockedChannelsDialog、PinAuthDialog、SettingsScreen……)都把 onPreviewKeyEvent 掛在 container Box 上。這個 Critical 不成立。
但真正的 bug——焦點不跟著列走——審查完全沒提。它很有把握地指向了一個不是 bug 的地方,同時對真正的 Critical 視而不見。
解法與實作
我當下決定現在停下來先修(這個 bug 卡住後面的測項),並且讓 Claude 直接修、不重新交給實作端。理由:根因是 Claude 推導出來的(實作端當初是照計畫做,未必更可靠)、單檔兩處改動、這樣最快回到測試。
修法(commit 2ccf2af):
focusRequesters改成remember { mutableStateMapOf<String, FocusRequester>() },item 用getOrPut(row.group.name)——requester 綁列身份。- swap 時先抓被搬動列的身份
movedGroup = rows[focusedIndex].group.name(這時還是舊順序),swap 完再focusRequesters[movedGroup]?.requestFocus()——身份不變、requester 永遠指向同一列,徹底無視 recompose 時序。 - 進場焦點的
LaunchedEffect同步改用身份取 requester。
focusedIndex 維持位置語意不變(給捲動、箭頭、頁碼用)。build 過、測試全綠、靜態分析顯示只動這一個檔、沒有波及其他流程。
實機驗證 (Device-Verified)
修完重裝,我把原來在第一位的列連按 DOWN 一路搬到底,log 序列:
[5,4,6,…] → [5,6,4,…] → [5,6,7,4,…] → … → [5,6,7,8,9,10,11,12,13,14,4]
那一列從 position 0 一路走到 position 10——fix 前 ping-pong 做不到的事。bug 當場定奪,剩下的測項接著走完。
結語與附帶收穫
- 這個 bug 淺到離譜:交換兩列的內容、然後移焦點,就這樣。修也一下就好。整件事的難度幾乎全在「發現」,不在「修」。
- 而「發現」恰恰是計畫文字(甚至明文警告過「綁列不要綁位置」)和靜態審查都到不了的地方:計畫把正確的警告寫了出來,實作還是踩進去;審查很有把握,但指向的是錯的那行。這兩者都沒能讓這個 bug 提前現形。
- 讓它現形的,是我在遙控器上按了幾下、看到「交換了,但 focus 不變」這個一句話的症狀。對這一類 focus/按鍵時序的 bug,實機加一個人介入,是唯一能定案的環節。