本文為《Funny Vote 十年演進系列》第 4 篇(全系列共 8 篇)|查看全系列架構導覽

時鐘指針一口氣快轉了幾年。
上一篇我們還停留在 2018 年,在沒有協程的蠻荒年代靠著手刻線程池與繞過 Mockito final 類別的檢查,好不容易把滿地的 NullPointerException 與回呼稍微收拾了一下。但老實說,那時候的 Android 開發依然是一場漫長而痛苦的體力活:每刻一個新畫面,工程師就要在 Java/Kotlin 邏輯與落落長的 XML 排版檔之間來回橫跳,寫一堆脫褲子放屁的樣板代碼。
到了 2024 年,Android 生態早已發生翻天覆地的演進。Google 端出了現代 Android 開發的終極殺手鐧:Jetpack Compose。官方宣稱這套宣告式 UI 能讓開發生產力倍增,徹底告別所有 XML,用純 Kotlin 搞定一切畫面。
身為一個「怠惰是開發的源頭」、能躺著就不想坐著的野生工程師,聽到能少寫三十幾份 XML 樣板,眼睛立刻亮了起來。先動手做再說,不然嘴巴說再多也沒屁用。這篇就來好好聊聊,當我們在 kotlin-rewrite 分支興沖沖告別十年前的 XML 舊石器時代、投奔 Compose 懷抱後,到底體會到了什麼樣的短暫爽度,又在實機畫面上撞上了怎樣匪夷所思的「重組地獄」。
想看完整重構原始碼的,分支依然掛在 GitHub 上:
1. 回看 2016:被 XML 與 findViewById 綁架的舊石器時代
在聊 Compose 之前,我們先把十年前 mvp 分支的老代碼挖出來曬曬太陽。各位感受一下 2016 到 2018 年 Android 工程師每天上班到底在過什麼水深火熱的日子。
那時候要刻一個投票列表,我們得先在 res/layout/item_vote_list.xml 裡面手敲幾百行 XML,設定各種 layout_width、layout_height、margin 與 padding。接著,在 Activity 或 Adapter 裡面把這些元件一個個「挖出來」:
// 2016 年的典型寫法:ButterKnife 盤絲洞與無盡的型別強轉
public class VoteListAdapter extends BaseAdapter {
private LayoutInflater inflater;
private List<Vote> voteList;
@Override
public View getView(int position, View convertView, ViewGroup parent) {
ViewHolder holder;
if (convertView == null) {
convertView = inflater.inflate(R.layout.item_vote_list, parent, false);
holder = new ViewHolder(convertView);
convertView.setTag(holder);
} else {
holder = (ViewHolder) convertView.getTag();
}
Vote vote = voteList.get(position);
holder.tvTitle.setText(vote.getTitle());
holder.tvVoteCount.setText(String.valueOf(vote.getVoteCount()));
// 如果手滑把 R.id.tv_vote_count 綁成 TextView,編譯器完全不會叫,跑起來直接 ClassCastException 當場炸裂
return convertView;
}
static class ViewHolder {
@BindView(R.id.tv_title) TextView tvTitle;
@BindView(R.id.tv_vote_count) TextView tvVoteCount;
@BindView(R.id.iv_cover) ImageView ivCover;
@BindView(R.id.btn_vote) Button btnVote;
public ViewHolder(View view) {
ButterKnife.bind(this, view);
}
}
}
(看著這段代碼,我都替當年的自己感到心酸。)
那時候我們每天都在寫這種毫無意義的體力活:
- 兩套平行世界的精神分裂:XML 負責長相,Java/Kotlin 負責邏輯。只要 UI 說要把標題顏色改一下,你得在專案目錄裡翻出對應的 XML;如果要在代碼裡動態隱藏按鈕,又得寫一行
btnVote.setVisibility(View.GONE)。兩邊只要 ID 拼錯一個字母,恭喜你,直接在使用者手機上閃退。 - 狀態同步地獄:傳統 View 元件自己是有狀態的(Stateful)。如果資料更新了,你得手動去呼叫
setText、setImageResource。只要某個if-else分支漏寫了一行,畫面上的按鈕可能就停在上次的狀態變不回來。 - Adapter 樣板氾濫成災:寫個 RecyclerView 或 ListView,光是 Adapter、ViewHolder、
onCreateViewHolder、onBindViewHolder就能灌水兩百行代碼,寫到懷疑人生。
這種命令式 UI(Imperative UI)的最大問題在於:工程師必須親手告訴系統「如何一步一步去修改 View」。只要業務稍微複雜一點,畫面狀態與背後的真實資料一定會脫鉤。
2. 2024 初入 Compose 的蜜月期:純 Kotlin 的極致爽感
當我們在 kotlin-rewrite 分支把整套舊架構拆光、引入 Jetpack Compose 之後,第一感覺只有兩個字:解脫。
在 Compose 的世界裡,沒有 XML、沒有 ButterKnife、沒有 findViewById,更沒有那堆煩人的 Adapter。整個 MainActivity.kt 被濃縮成只有 32 行的純導航容器:
// 2024 年的 MainActivity.kt:只剩下 32 行純 Compose 容器
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
FunnyVoteTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
FunnyVoteApp() // 乾乾淨淨,連 setContentView 都走入歷史
}
}
}
}
}
更爽的是列表的寫法。以前要寫兩百行的 RecyclerView Adapter,在 Compose 裡只需要一個簡短優雅的 LazyColumn:
// 2024 年在 MainScreen.kt 裡的真實寫法(未優化前版)
@Composable
fun VoteList(votes: List<VoteData>, onVoteClick: (VoteData) -> Unit) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(votes) { vote ->
VoteItemCard(vote = vote, onClick = { onVoteClick(vote) })
}
}
}
不需要手動通知 notifyDataSetChanged(),資料變了,畫面自動響應重新繪製(Recomposition)。UI 真正變成了資料的投影:UI = f(State)。那幾天在工位上敲代碼,心情真的是飄飄然,心想 Google 這次總算做了一件功德無量的好事。
可惜,現實永遠比想像殘酷。蜜月期通常超不過一週。
3. 翻車現場:Layout Inspector 狂跳紅字,120Hz 高刷掉成幻燈片
當我信心滿滿地把寫好的 App 安裝到實體機(Samsung S23)上,準備享受 120Hz 高刷螢幕的絲滑體驗時,手指輕輕往上一滑列表——
卡。嚴重的掉幀與肉眼可見的頓挫感。
(我當場愣住。旗艦晶片跑一個只有幾十筆資料的文字列表,居然能滑出十年前山寨機的卡頓感?)
我立刻打開 Android Studio 的 Layout Inspector,勾選「Show Recomposition Counts(顯示重組計數)」。不看還好,一滑動螢幕,整排 Composable 元件旁邊的重組數字像賭場拉霸機一樣瘋狂往上飆紅字:每捲動一個像素,卡片就被重新繪製了十幾次!
在 Compose 的美好藍圖中,如果傳入 Composable 元件的參數沒有改變,系統應該自動跳過重組(Smart Recomposition / Skip),只更新真正變動的像素。為什麼我的卡片明明資料沒變,Compose 卻把整張卡片當成剛出生的嬰兒一樣,從頭到尾重算一遍?
死嗑了兩天底層編譯器機制,我終於抓出了藏在代碼深處的兩大元兇。
元兇一:Kotlin 標準 List 與全 var 實體的雙重背刺
翻開剛才在 MainScreen.kt 裡看似純潔無害的參數宣告:
// 看似再標準不過的參數傳遞,實質是全量重組的根源
@Composable
fun VoteList(
votes: List<VoteData>, // 就是這行釀成大禍
onVoteClick: (VoteData) -> Unit
)
翻開 votes: List<VoteData> 這行。在 Kotlin 語法裡,List 表面上是個唯讀介面,我們直覺認定它是不可變的(Immutable)。
但在 JVM 底層,Kotlin 的 List 編譯後就是赤裸裸的 java.util.List,任何外部執行緒都能隨時呼叫 add() 或 clear() 把它改得面目全非。因此 Compose 編譯器在做靜態代碼分析時採取了冷血防禦:它把 Kotlin 標準的 List<T> 直接標記為 Unstable(不穩定型別)!
不僅如此,更陰險的是我們的資料實體 VoteData.kt。當年為了配合 Room 與 Gson 解析,我在 VoteData 裡面塞了滿滿的 var:
// 2024 年在 VoteData.kt 裡的原形:全是 var 的定時炸彈
@Entity(tableName = "votes")
data class VoteData(
@PrimaryKey(autoGenerate = true)
var id: Long = 0,
var voteCode: String = "",
var title: String? = null,
var authorName: String? = null,
var pollCount: Int = 0,
var isPolled: Boolean = false
// 剩下幾十個欄位清一色全是 var...
)
Compose 編譯器的穩定性推斷規則極其嚴苛:只要類別中存在任何一個 public var 屬性,整隻類別就會被直接判定為 Unstable!
這直接送了我一套組合拳:
VoteData內部欄位全是var,編譯器直接打入 Unstable 地獄;- 外層的
List<VoteData>底層是可變集合,又是 Unstable。
雙重暴擊之下,Compose 編譯器根本不敢相信這包資料沒有在背地裡被偷改。所以只要父層 Composable 有任何風吹草動(比如滑動時稍微更新了捲軸位置),Compose 就會武斷判定:「這個參數可能已經髒了,我不能偷懶跳過,必須強制全量重新繪製!」
每滑一下螢幕,幾十張卡片的邊距計算、文字測量全部重新算一遍,旗艦 CPU 算力直接拉滿,120Hz 瞬間跌成 PPT!
救贖之道:立下不可變切結書
要擺脫這種無謂的重算,解法有兩種:
- 引進
kotlinx.collections.immutable,把標準List替換為真正的不可變集合ImmutableList。 - 在資料模型外層打上
@Immutable切結書,向 Compose 編譯器發誓保證:「我這包資料只要生成出來就絕不會變,請你大膽開啟跳過重組(Skip)優化」:
// 解法:用註解向 Compose 編譯器立下切結書,解鎖 Skip 優化
@Immutable
data class VoteData(
val voteCode: String = "",
val title: String? = null,
val authorName: String? = null,
val totalVotes: Int = 0
// 徹底告別 var,改用 val 保證真正不可變
)
元兇二:LazyColumn 遺漏 key 與匿名 Lambda 刺客
搞定 List 與資料實體的穩定性之後,重組次數降了不少,但滑動時偶爾還是會莫名頓挫。再挖深一層,抓到了第二組隱形地雷:
// 翻車寫法:缺少 key 且在每次繪製時重新生成 Lambda 實例
items(votes) { vote ->
VoteItemCard(
vote = vote,
onClick = { onVoteClick(vote) } // 每次父層重繪,這裡都會在 Heap 上 new 出一個全新 Function 物件!
)
}
這裡藏著兩個致命破綻:
- 遺漏 Key 識別:
items(votes)沒有傳入key = { it.voteCode }。當列表項目的順序發生變更或有增刪時,Compose 失去項目的唯一性記憶,只能全列表重新配置! - 匿名閉包分配:在
items迴圈裡寫onClick = { onVoteClick(vote) },每一次父層重繪時,這段代碼都會在 Heap 上建立一個全新記憶體位址的 Lambda 匿名物件。對於VoteItemCard來說,雖然vote資料沒變,但傳進來的onClick參數記憶體位址每次都不同,Compose 當然只能老實判定「參數改變了,必須重新繪製」。
終極防禦代碼
把這兩處改寫為標準生產級別的穩定結構:
// 生產環境標準寫法:釘死 Key 並收斂回呼
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
items = votes,
key = { it.voteCode } // 1. 顯式綁定唯一識別鍵,解鎖位置重用記憶
) { vote ->
VoteItemCard(
vote = vote,
onClick = onVoteClick // 2. 傳遞穩定函式引用,拒絕迴圈內臨時配置 Lambda
)
}
}
改完之後回到真機,Layout Inspector 的拉霸機紅字徹底消失,滑動禎率穩穩鎖死在 120fps 一條綠線到底。
4. 天真 Sealed Class 的白屏地獄:狀態撕裂的前奏曲
把畫面卡頓調校到如絲般順滑之後,我們面臨了最關鍵的一步:串接真實資料流。
那時候主流的 Compose 教學都極力推崇這種「典範寫法」:在 ViewModel 裡定義一個天真無邪的 Sealed Class:
// 2024 年在 MainViewModel.kt 裡的公版寫法:看似優雅,實則暗藏殺機
sealed class MainUiState {
object Loading : MainUiState()
data class Success(val hotVotes: List<VoteData>) : MainUiState()
data class Error(val message: String) : MainUiState()
}
接著在 Composable 裡面用 when (state) 展開處理:
// 經典翻車現場:下拉刷新直接全屏白屏
when (val state = uiState) {
is MainUiState.Loading -> {
CircularProgressIndicator() // 轉圈圈
}
is MainUiState.Success -> {
VoteList(votes = state.hotVotes, onVoteClick = { ... })
}
is MainUiState.Error -> {
Text("Error: ${state.message}")
}
}
這段代碼在第一次進入頁面載入時看起來完美無瑕。但當使用者在首頁執行「下拉刷新(Pull-to-Refresh)」時,恐怖的災難發生了:
當刷新動作發起,ViewModel 發射了 MainUiState.Loading。這時候 when 條件直接匹配到 Loading 分支——Compose 當場把畫面上既有的投票列表瞬間拔除,換成中央一顆孤獨的轉圈圈!
半秒後資料撈回來了,發射 Success,畫面又猛然彈回列表。
使用者手指每下拉一次,畫面就瞬間全屏白屏閃爍一次!使用者體驗爛到不可思議。
這就是教科書級別的「玩具架構陷阱」:在真實產品裡,使用者刷新時,舊資料必須穩穩停在畫面上,頂多在頂部顯示一個微小的進度條;而不是像個巨嬰一樣,一遇到 Loading 就把整個世界清空!
5. 皮囊換了但心臟還在停跳:下一站 MVI
從 2016 年手敲 30+ 份 XML 的苦行僧,到 2024 年在 kotlin-rewrite 用純 Kotlin 與 LazyColumn 刻出流暢的現代畫面,我們在 Compose 上吃足了苦頭,但也確實嚐到了宣告式 UI 的甜頭。
我們學會了不要被看似唯讀的 List 欺騙,學會了用 @Immutable 馴服過度敏感的編譯器,也學會了在 LazyColumn 裡釘死 key 與穩定回呼以守護 120Hz 高刷幀率。
但工程師必須對自己誠實:單純換成 Compose,只不過是換了一副現代化的皮囊;如果底層管理狀態的架構依然散落混亂,宣告式 UI 只會把舊系統的狀態撕裂放大十倍。
為了解決下拉刷新的白屏地獄,為了徹底杜絕多個 StateFlow 並發時的幽靈中間態,我們在接下來的 mvi-rewrite 分支裡,展開了一場直奔 MVI 單向資料流(UDF)的救贖大手術。
至於我們是怎麼用單一不可變狀態(Single Immutable State)與增量更新徹底擺平白屏問題的,我們下一篇繼續 murmur。
以上是本次小弟的感想,我真的懶得貼落落長的全專案 CODE,各位真的想看完整重構代碼,去 GitHub 翻 kotlin-rewrite 分支吧,通通都放在那,不浪費版面。
感謝觀看,收工。
《Funny Vote 十年演進系列》章節導航

發佈留言