頻道分類排序:列搬走了,焦點沒跟上


背景

機上盒的設定畫面,自動測試測不到焦點和遙控器按鍵,所以這個專案的驗收方式是實機 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,實機加一個人介入,是唯一能定案的環節。