glm 說這個閃爍要開完整工作流,我換 opus 直接修——它先講出了會壞哪裡
EPG 那塊整段做完、實機驗收也過關了,我就自己坐到電視前用用看。整體感覺不錯,只有一個地方:節目列跟節目內容,每隔一段時間就整個黑一下,再出現。
我身為開發者知道這是在做背景更新——EPG 要定期重抓節目表。但身為觀眾,會覺得很煩:我什麼都沒操作,畫面自己閃。大約每 20 秒一次,logcat 也印得出來,每 20 秒一筆 EPG_PROGRAMS(_EMPTY),就是那個背景 reload 在跑。焦點落點什麼的都正確,純粹是無謂的全黑閃爍。
於是我跟 glm 說了這件事。
glm 的判斷:找到問題了,但牽連甚廣,要跑完整工作流
glm 一檢查,說找到問題了——但是牽連甚廣,要跑完整工作流來修。
這個專案有一套多重審查工作流:純改 UI 以外的任何變更,都走完整流程(brainstorm → spec → plan → plan-review,含外部 reviewer → 實作 → 雙 reviewer)。它存在的理由,這個專案自己反覆驗證過——這類 reactive pipeline 最常見的病,就是「動手前沒把所有相依於某個狀態的東西追過一遍」。流程就是用來強制做這件事的。
我當時覺得:應該沒那麼麻煩吧?一個閃爍而已。於是我跟 glm 說「可以直接修嗎」。glm 依舊提醒了我一次——還是建議走完整流程。
這個「提醒了一次」是關鍵。如果它只是隨口說說,我可能就直接壓下去叫它改了;但它在我表態之後還堅持一次,代表它真的認為這題不單純。既然它這樣宣稱,我就決定換 opus 上——不是因為覺得 glm 不行,而是想用算力更強的腦袋把這題的全貌看清楚再決定。
換的時候我心裡有個預設:如果 opus 檢查完,也建議我跑完整工作流,那我就只能認命同意。 兩個不同的模型都說這題不單純,那就是真的不單純,我接受這個成本。換句話說,我不是去找一個會答應我「直接修」的模型——我是拿 opus 當 glm 那句「牽連甚廣」的第二意見,而且預備好:第二意見若也站在 glm 那邊,我就照 glm 的走。
還好 opus 檢查完,有把握直接修好。
opus 的判斷:能直接修,但會凍結時間 bar
opus 先看了 glm 的分析報告,自己再檢查一次。結論跟 glm 不一樣:這題可以直接修,但修了會出另一個問題——時間 bar。
這就是 glm 那句「牽連甚廣」具體指的是什麼。opus 把它講出了一個名字:預覽窗的進度條(時間 bar)會停住。然後 opus 分析完,認為狀況已經全部掌握,可以直接修,不必走完整工作流。
於是我直接讓它修了。
根因:新版把 loading 掛在「誰觸發」,舊版掛在「資料變沒變」
閃爍本身的原因,在新版 EPG 查節目的 flow 裡(…/presentation/epg/EpgViewModel.kt)。一條背景 reload 的鏈:
reloadTriggerFlow(:137-142):while(isActive){ emit(now); delay(20_000) },每 20 秒發一個 tick。combine(selectedDateIndexFlow, selectedChannelFlow, reloadTriggerFlow)(:145)把三路訊號合一——選了哪一天、選了哪一台、以及這個 20 秒的 tick。.onEach { it.copy(isProgramsLoading = true, programs = emptyList()) }(:149-151),在 combine 之後、debounce 之前,對每一個觸發(含 reload tick)無條件設 loading、清空節目。.debounce(300)(:152)→.flatMapLatest{ 查節目 }(:153)→.collect{ 設 programs、loading=false }(:205)。
結果就是:每一個 reload tick 都走 onEach → loading=true 加清空 → 全黑閃一下 → 查完再顯示。不管資料到底變沒變。
舊版不是這樣。舊版是 Java 寫的(…/common/ui/fragments/ProgramListFragment.java),loadPrograms 在查回節目之後,會先比對新舊:
if (!forceUpdate && programData!=null && temp.length==programData.length) {
for (int i=0;i<temp.length;i++)
if (!temp[i].getProgramUri().equals(programData[i].getProgramUri())) { isNew=true; break; }
} else { isNew = true; forceUpdate=false; }
比對下來,資料有變(isNew==true)才換資料、顯示 loading mask、重繪;資料沒變(isNew==false)就直接 return——不清空、不顯示 mask,只更新預覽窗的進度。
| 面向 | 舊版 | 新版 |
|---|---|---|
| loading 觸發時機 | 查回後、比對確認資料不同才 mask | 查之前、無條件(onEach) |
| reload、資料相同 | return,不 mask、只更新進度 | 仍 loading 全黑+清空 ❌(本次問題) |
| reload、資料不同 | mask+重繪 | loading+重繪(結果正確) |
| 換台(selected 變) | 資料必不同 → mask+重繪 | loading+重繪(結果正確) |
關鍵差別只有一句話:舊版的 loading 由「資料是否變化」驅動,新版由「誰觸發」驅動。 舊版 reload 查回相同的東西,就不打擾 UI;新版不管查回什麼,只要 reload tick 一到,就先黑一下再說。
那個「牽連甚廣」:閃爍是當時畫面唯一讓畫面活著的東西
如果只是「把 onEach 改成有條件」,這題就簡單了,glm 也不會說牽連甚廣。麻煩的是 opus 講出來的那一面。
畫面上有兩個東西會隨時間變動:右上角的時鐘、預覽窗的進度條。它們在 EpgScreen.kt:124 和 EpgPreviewPane.kt:76,83 直接讀 System.currentTimeMillis()。問題是——它們沒有自己的時間來源,等於寄生在每 20 秒 loading 翻轉造成的畫面重繪上。loading 翻一次,畫面重畫一次,它們跟著讀一次新的時間。
所以那個我想刪掉的閃爍,其實是當時畫面唯一的重繪來源。單純拿掉它,時鐘會凍結在打開 EPG 的那一刻、進度條停住。這正是舊版在「資料沒變」時還要特地呼叫一次 updateProgramInfo(selectProgram) 的原因——舊版知道,就算不換資料,進度還是得刷。
所以修正是兩件事,不是一件。 只看到「閃爍=無條件 loading」這一層就動手,會製造一個新回歸:時間 bar 凍結。glm 的「牽連甚廣」是對的,這題真的廣。
解法:給時鐘自己的時間來源,再把 loading 收斂
opus 當場改了四個檔,都在 presentation/epg/:
| 檔案 | 改動 |
|---|---|
EpgUiState.kt | 新增 nowMs: Long(預設 System.currentTimeMillis()) |
EpgViewModel.kt | timeTicker 每 20 秒寫入 nowMs(換日邏輯原樣保留) |
EpgViewModel.kt | onEach 改為只在 selection key(dateIdx+sid)變化時 loading=true+清空;reload tick 靜默重查 |
EpgScreen.kt | 時鐘改讀 state.nowMs |
EpgPreviewPane.kt | 新增 nowMs 參數,進度條改讀它 |
兩件事:給時鐘與進度條自己的時間來源 nowMs,不再寄生在閃爍的重繪上;然後把 onEach 收斂成只在真正換台、換日時才 loading,reload tick 靜默重查。
有一個地方值得一提:舊版那套「查回比對節目身份、相同就跳過」的邏輯,新版其實不用寫。因為 EpgUiState、EpgProgramRow、EpgProgram 都是 data class,資料相同時 state.copy(...) 會產生相等的物件,StateFlow 本來就會去重、不發射——等價於舊版那個 return。舊版要手寫的比對迴圈,新版靠 data class 相等加上 StateFlow 去重,免費拿到。
驗收時,最該小心的是進度條。它每 20 秒只推進大約 12.7px,目視根本看不出來。而舊的閃爍修掉之後,畫面也「看起來靜止」——時間 bar 凍結跟正常運作,目視無法區分。所以這一項不能靠看,得用 framebuffer 量橘色像素:量了 161 秒,進度條推進 102px(158→260 / 576px),反推節目長度約 909 秒;同一時間 logcat 裡那台的 count 沒變,證明是同一個節目在推進、不是有人動了選台。時鐘還在走、換台換日還是會全黑 loading、背景每 20 秒的靜默重查也照跑。閃爍沒了,該動的都還在動。
取捨:流程是耦合追蹤的拐杖
這整件事,與其說是「opus 比 glm 強」,不如說是兩個模型各自做到了不同的一步。而我選擇直接修,是為了不擴大事態——不是為了省錢。恰恰相反:opus 比 glm 貴多了,純看價格大約 7 倍,算上額度對比差不多 20 倍,直接修反而是貴的那條路。
glm 察覺這題不單純——「牽連甚廣」——但它沒有在腦內把那個「廣」追完、落實成一個具體的東西。所以它的處方是伸手去抓那個會強制把耦合面追過一遍的流程。這是安全、正確的選擇,事後也證明它的直覺是對的:時間 bar 真的存在。
opus 在腦內做完了工作流要強制的那件事——把整個耦合面追過一遍,而且把 glm 只有「牽連甚廣」的直覺,落實成具體的「會凍結時間 bar」。因為它具體說出了那個二階後果,我才相信它是真的掌握、不是漏看。
完整工作流存在,本來就是為了強制「動手前把整個耦合面追一遍」這件容易漏的事。說白了,它是耦合追蹤的拐杖。glm 自己追不完整,就用拐杖;opus 追得完整,就可以不拿拐杖。拐杖本身沒有錯,拿著它一樣能走到終點——我相信讓 glm 跑完整工作流,這題一樣能妥善解。
但對我來說這是一個取捨,而且不是省錢的取捨。一個幾乎都驗收過的功能,只剩一個煩人的小閃爍,我很不想為了它開完整工作流——把一件小事升級成有 spec、有 plan、有雙 reviewer 的正式任務。glm 那條路其實更省錢,代價是擴大事態;我寧可花 opus 那 20 倍的錢,換一個能把全貌摸清楚、當場修掉、讓事情留在原地的結果。這條路之所以敢走,是因為 opus 做完了流程要強制的事——不是我偷懶,更不是我挑了便宜的路。它依然不是我「換到一個肯答應我的模型」:我是拿 opus 當 glm 的第二意見,預備好若 opus 也說要跑流程就照辦;最後會直接修,是因為 opus 用一個具體的理由(時間 bar 會凍結)獨立推翻了 glm 的保守,不是因為我想要那個答案。
怎麼判斷「真的掌握」
這題給我一個比「要不要開流程」更具體的判準。
一個模型說「這題牽連甚廣、建議跑完整流程」,跟它說「這題可以直接修,但會影響到 X」,是兩種完全不同的掌握程度——前者是直覺到有風險但講不出來,後者是風險已經被具體化成一個名字。要決定能不能跳過流程,看的是後者有沒有出現:它有沒有把連帶後果講出一個具體的東西。 opus 講出了「時間 bar 會凍結」。
反過來說,如果一個模型只說「沒問題、直接改」卻講不出任何會連帶影響的東西,那未必是沒看——更可能是它的檢查只停在 bug 本身,沒有延伸到這個改動會波及哪裡。這不是假設性的危險:gemini 在我的經驗裡就特別容易這樣,一句「好,可以,沒問題」,改完才發現改出一堆問題。但這不是因為它沒看——它會去摸實際狀況,只是只摸我指的那一塊:我說哪裡有 bug,它就修那個 bug、確認那個 bug 沒了;至於這個改動會不會波及其他地方,它通常不會主動去摸。
它跟 opus 一樣會說「可以直接修」,差別在 opus 的檢查範圍會延伸到 bug 以外——它多附了那個具體的「會凍結時間 bar」。同一句「沒問題」,一個的檢查停在 bug 本身、一個的檢查延伸到了連帶影響,聽起來卻一模一樣。
所以才說,它有沒有講出那個具體的東西,是唯一分得出來的線索。glm 是摸到邊、但講不出全貌,於是退回流程保險;gemini 是只摸眼前就說沒事;opus 則是摸完了、而且講得出會壞哪裡——這三種裡,只有最後一種能讓我放心跳過流程。