每一層都接對了,BACK 還是死了——兇手只活在 34 毫秒的 log 裡
實機驗收跑到一半,出現一個乾淨得近乎挑釁的症狀:popup 開著,按 BACK,沒反應。不是閃退、不是卡住,就是沒反應——像這顆鍵不存在。
log 把「沒反應」三個字寫得更精確。這輪驗收裡四筆上限訊息,收場原因清一色 reason=timeout——3000 毫秒到了、自己關掉的。全程零筆 reason=back。BACK 按了,系統等於沒收到。
這個專案怎麼驗收
跟這個專案其他故事同一個背景:Android TV app 重寫,舊版(Android 9、Java)重寫成新版(Android 16、Kotlin、Jetpack Compose)。每個階段做完,在實驗室的電視盒上跑實機驗收——我拿遙控器操作,Claude 判讀 log,逐條驗收項目對驗。
這個階段復刻舊版的「最愛清單」功能:看電視時按遙控器黃鍵,跳出一個 popup 把目前頻道加進最愛清單;清單有容量上限,加滿時跳「上限」訊息,訊息 3000 毫秒自動關。規格上,BACK 要能關掉 popup、也要能關掉訊息。
發現症狀的是我。popup 開著按 BACK,沒反應;上限訊息出來按 BACK,也沒反應。兩條驗收項目當場 FAIL。
第一輪:靜態檢查,四層全對
Claude 的第一輪是靜態檢查——把 BACK 從按下到「關掉 popup」會經過的每一層都讀一遍:
- 主畫面的 Box:popup 和訊息期間
return false放行——不能攔方向鍵,攔了 popup 裡的焦點就動不了; - popup 自己的
onPreviewKeyEvent:放行 Back; - overlay 的根容器:不 focusable、不接鍵;
BackHandler:有接訊息→popup 的分支。
四層,每一層單獨看都正確。實機全死。
不過這份攤開的清單讓一件事現形:這顆 BACK 按下去,路上有好幾個人在看它。我盯著這張清單問了一個問題:**能不能整合成單一入口?**這個問題後面會回來——修法的形狀,是從它的答案長出來的。
第二輪:實證二分
靜態讀不出答案,轉實證。三步,每步收斂範圍。
第一步,注入也死。 用 adb 注入 BACK 到我遙控器開著的 popup 裡——一樣死。實體遙控器和注入是兩條不同的送鍵路徑,兩條都死,問題就一定在 app 內部;遙控器端不用再查。
第二步,隔壁活的。 同一個螢幕上另一個 overlay(頻道列表)按 BACK——活的,log 裡有 trigger=back。缺陷不是「這個螢幕的 BACK 都死」,是限縮在「焦點停在 popup 格子裡」這個情境。
第三步,確認送達。 dumpsys input 顯示事件確實派給了 app。app 收到了 BACK,然後把它弄丟了。
定案:34 毫秒
決定性的證據是一段 34 毫秒的 log。注入 BACK 的瞬間(08:44:59.423–.457):
FOCUS_CHANGED mainBox focused=true
OVERLAY_FOCUS_RECLAIM
主畫面的 Box 拿到焦點,34 毫秒內 overlay 又把焦點收回 popup。而 BACK 是一次「按下+放開」成對的事件——DOWN 之後焦點換了手,這次按鍵的 tracking 隨之中斷,UP 回來時已經湊不成一次完整的按壓。OnBackPressedDispatcher 從頭到尾沒有觸發過。
BACK 不是沒送到,是在這 34 毫秒的焦點拉鋸裡被絆死的。
還有個附註:這條 FOCUS_CHANGED 觀測 log 不是為這個 bug 加的。三週前修另一個焦點問題(LiveTv 重鎖後的按鍵黑洞)時加的,當時只是想看焦點去哪了。這次它成了唯一的目擊證人。
這堵牆,專案撞過
對照組把範圍再縮一圈:設定頁的 dialog——同樣格子持有焦點、同一個 overlay 根容器——BACK 是活的,之前的階段就實證過。缺陷不在「格子焦點+overlay」這個組合本身,在這個螢幕的這個配置。
而且這堵牆有前科。電視這個畫面上唯一「格子持有焦點」的先例、PIN 解鎖的 dialog,當年就是自己在 preview 裡把 BACK 收走的——繞的正是這堵牆。只是繞完之後,這個經驗停在「那個 dialog 的做法」,沒有升級成「這類配置都會這樣」,於是第二次撞上。
修法:繞牆,不拆牆
回到我的單一入口問題。Claude 檢查後的判斷是:整合功太大,而配合 Android 自身的機制,這個缺陷其實修得掉。
修法跟著這個判斷走——不重構入口,在出事的那一點配合機制:popup 的 onPreviewKeyEvent 自己把 BACK 收走,KeyDown 時 consume 掉(擋掉那場焦點舞蹈,不讓它開始),KeyUp 才執行動作。這個「KeyDown 擋、KeyUp 做」的節奏,跟同一個螢幕上既有的另一個 BACK 攔截是同一套;BackHandler 那層留著當 fallback。
實作交給 Gemini 3.1 Pro,qwen 的獨立 review PASS。重驗走雙路徑:我用遙控器按一次、Claude 用 adb 注入一次,兩邊各收到一筆 reason=back。BACK 回來了。
結語
這件事留下兩個提醒。
第一,靜態檢查對「焦點/按鍵派發」這類問題是無效的。 四層接線每一層都對,因為缺陷不在任何一層的接線上——它在層與層的互動裡,只存在於運行時的時間軸上。定案的不是任何一行程式碼,是 34 毫秒裡的兩行 log。但靜態檢查不是白做:它找不到兇手,卻把「這顆鍵的路上有幾個人在看」攤開了——單一入口的問題,是從那張攤開的地圖上長出來的。
第二,對的修法不等於對的架構。 單一入口在架構上最乾淨,它被問了、也被評估了,結論是功太大;實際的修法是配合平台機制在單點解,跟當年 PIN dialog 的繞法同一個形。有時候「繞牆」就是牆的正確打開方式——前提是知道牆在哪,而知道牆在哪,靠的是那 34 毫秒。
附帶收穫
- logcat 濾法:同一個 tag 給多個等級(
SV_LiveTv:I SV_LiveTv:W SV_LiveTv:E)時,後者會覆蓋前者,I 級整個被濾掉。每個 tag 一個:I就好——I 自動涵蓋 W/E。 - adb 注入跟真人會互踩:這輪發生三次(轉場吃鍵、HOME 之後注入打進首頁)。要做注入測試,先跟拿遙控器的人打個招呼。