Funny vote APP – 告別 XML 迎來 Compose 的痛苦與甜蜜:從刻畫面到撞上重組地獄(三)

上一篇聊到 2018 年,我們在老架構裡硬塞了 Kotlin 1.2 與 RxJava 2,總算把滿地的 NullPointerException 與非同步回呼稍微收拾了一下。但老實說,那時候的 Android 開發依然是一場漫長而痛苦的體力活。每刻一個新畫面,工程師就要在 Java/Kotlin 代碼與落落長的 XML 排版檔之間來回橫跳,寫一堆脫褲子放屁的樣板代碼。

一晃眼,時間跳躍到了 2024 年。在 kotlin-rewrite 分支裡,Android 世界早就天翻地覆。Google 端出了現代 Android 開發的終極大殺器:Jetpack Compose。官方宣稱這套宣告式 UI 能讓開發生產力倍增,告別所有 XML,用純 Kotlin 搞定一切畫面。

「怠惰是開發的源頭」,身為一個能躺著就不想坐著的工程師,聽到能少寫 30 份 XML,眼睛立刻亮了起來。這篇就來好好聊聊,當我們興沖沖告別十年前的 XML 舊石器時代、投奔 Compose 的懷抱後,到底體會到了什麼樣的爽度,又在實機畫面上撞上了怎樣匪夷所思的「重組地獄」。


一、 回看 2016:被 XML 與 findViewById 綁架的舊石器時代

在聊 Compose 之前,我們先把十年前 mvp 分支的老代碼挖出來曬曬太陽。各位感受一下 2016 年 Android 工程師每天上班到底在過什麼日子。

那時候要刻一個投票列表,我們得先在 res/layout/item_vote_list.xml 裡面手刻幾百行 XML,設定各種 layout_widthlayout_heightmarginpadding。接著,在 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);
        }
    }
}

看著這段代碼,我都替當年的自己感到心酸。那時候我們每天都在寫這種毫無意義的體力活:

  1. 兩套平行世界:XML 負責長相,Java 負責邏輯。只要 UI 設計師說要把標題顏色改一下,你得在專案目錄裡翻出對應的 XML;如果要在代碼裡動態隱藏按鈕,又得寫一行 btnVote.setVisibility(View.GONE)。兩邊只要 ID 拼錯一個字母,恭喜你,直接在使用者手機上閃退。
  2. 狀態同步地獄:View 元件自己是有狀態的(Stateful)。如果資料更新了,你得手動去呼叫 setTextsetImageResource。只要某個 if-else 分支漏寫了一行,畫面上的按鈕可能就停在上次的狀態變不回來。
  3. Adapter 樣板氾濫:寫個 RecyclerView 或 ListView,光是 Adapter、ViewHolder、onCreateViewHolder、onBindViewHolder 就能灌水兩百行代碼。

這種命令式 UI(Imperative UI)的最大問題在於:工程師必須親手告訴系統「如何一步一步去修改 View」。只要程式稍微複雜一點,畫面狀態與背後的真實資料一定會脫鉤。


二、 2024 初入 Compose 的蜜月期:純 Kotlin 的極致爽感

當我們在 kotlin-rewrite 分支把整套舊架構拆光、引入 Jetpack Compose 之後,第一感覺只有兩個字:解脫。

在 Compose 的世界裡,沒有 XML、沒有 ButterKnife、沒有 findViewById,甚至連幾十個 Adapter 都直接進了資源回收桶。整個專案唯一的 MainActivity.kt,從原本幾百行的雜亂生命週期,瞬間收斂成只有 32 行的純導航容器:

// 2024 年的 MainActivity.kt:乾淨到讓人想哭
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            FunnyVoteTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    val navController = rememberNavController()
                    AppNavHost(navController = navController)
                }
            }
        }
    }
}

這才是現代工程該有的樣子。宣告式 UI 的核心思想非常純粹:畫面只是狀態的函數(UI = f(State))。你不再需要去指揮按鈕要隱藏還是顯示,你只需要告訴系統:「當資料是這個樣子時,畫面應該長成什麼樣」。

以前要寫幾百行 Adapter 才能搞定的投票列表,在 Compose 裡面只需要一個 LazyColumn

// 用純 Kotlin 刻出來的投票卡片清單
@Composable
fun HomeScreenContent(
    uiState: HomeUiState,
    onVoteClick: (String) -> Unit,
    onFavoriteClick: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    LazyColumn(
        modifier = modifier.fillMaxSize(),
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp)
    ) {
        itemsIndexed(
            items = uiState.votes,
            key = { _, vote -> vote.voteCode } // 給予唯一 Key 提升重繪效能
        ) { index, vote ->
            VoteCard(
                vote = vote,
                onClick = { onVoteClick(vote.voteCode) },
                onFavorite = { onFavoriteClick(vote.voteCode) }
            )
        }
    }
}

寫起來真的爽感十足。代碼量一口氣砍掉了超過 60%,而且在 Android Studio 裡還能直接開 @Preview 即時預覽畫面,連編譯安裝到手機的時間都省下來了。那幾天我一度以為,Android 開發的苦日子終於到頭了。

但現實總是會給天真的工程師一記響亮的耳光。


三、 翻車現場:幽靈般的重組地獄(Recomposition Hell)

當我把核心列表功能寫完,興高采烈把 App 裝進 Samsung 實體手機把玩時,我發現不對勁了。

只要手指向上一滑列表,畫面竟然隱隱約約有卡頓掉幀的感覺。明明這支手機配備頂級高通晶片,螢幕支援 120Hz 刷新率,滑一個只有幾十張卡片的投票清單,為什麼手指感覺沉沉的、偶爾還會卡跳一下?

我立刻打開 Android Studio 的 Layout Inspector,開啟 Compose 的重組計數器(Recomposition Counter)。不看還好,一看血壓直接飆高:

當我手指在螢幕上滑動時,每一個可見的 VoteCard 竟然都在發了瘋似地瘋狂重組(Recompose)!重組次數以每秒幾十次的頻率狂飆,計數器跳得比跳表機還快!

在 Compose 的設計理念中,如果傳入 Composable 元件的參數沒有改變,系統應該自動跳過重組(Skip),只更新真正變動的區塊。為什麼我的卡片明明長得一模一樣,Compose 卻把整張卡片從頭到尾重新繪製了一遍?

經過兩天的排查與深挖,我終於揪出了兩隻藏在代碼深處的效能吸血鬼。

吸血鬼一:Kotlin 標準 List 竟然被判定為「不穩定(Unstable)」

這是我在 Compose 踩過最反直覺的一個坑。請看我們剛剛傳進 Composable 的資料結構:

// 看似無辜的資料模型
data class HomeUiState(
    val isLoading: Boolean = false,
    val votes: List<VoteWithDetails> = emptyList() // 就是這行釀成大禍
)

大家仔細看 votes: List<VoteWithDetails> 這行。在 Kotlin 裡面,List 表面上是個唯讀介面,我們理所當然覺得它是不可變的。

但在 JVM 底層,Kotlin 的 List 其實編譯後就是 java.util.List!Java 的 List 本質上是可以被隨時 add()clear() 的可變物件。因此,Compose 編譯器在進行靜態分析時,採取了極度保守的策略:它把標準的 List<T> 判定為 Unstable(不穩定型別)!

這意味著什麼?這意味著 Compose 編譯器根本不敢相信這個 List 沒有被人在外部偷改。所以只要父層 Composable 有任何風吹草動(比如滑動時稍微更新了捲軸位置),Compose 就會武斷地認為:「這個 List 可能已經變了,我不能跳過,必須強制全量重新繪製!」

每滑一下螢幕,幾十個卡片裡的圖片元件、文字元件、排版計算全部重新執行一次,晶片運算瞬間拉滿,不卡頓才怪!

[解法:手動套上不可變金箍棒]

解決這個問題有兩種主流流派。第一種是引入 kotlinx.collections.immutable,把標準 List 替換為真正的不可變列表 ImmutableList

// 方案 A:使用真正的不可變資料結構
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf

data class HomeUiState(
    val isLoading: Boolean = false,
    val votes: ImmutableList<VoteWithDetails> = persistentListOf()
)

第二種是如果不想引入額外依賴庫,可以在資料包外層打上 @Immutable@Stable 註解,向 Compose 編譯器發誓保證:「我這包資料只要建出來就絕不會變,請你大膽開啟跳過重組(Smart Recomposition)優化」:

// 方案 B:用註解向編譯器立下切結書
@Immutable
data class HomeUiState(
    val isLoading: Boolean = false,
    val votes: List<VoteWithDetails> = emptyList()
)

加了這行註解之後,回到 Layout Inspector 一看,重組計數器瞬間安靜了下來。卡片不再無腦重繪,列表滑動立刻順滑如絲。


吸血鬼二:在 LazyColumn 迴圈內宣告 Lambda 實例

搞定 List 的穩定性之後,我發現卡片還是偶爾會跳動。再看代碼,抓到了第二個隱形地雷:

// 翻車寫法:在每次繪製時重新生成 Lambda
itemsIndexed(uiState.votes) { index, vote ->
    VoteCard(
        vote = vote,
        onClick = { onVoteClick(vote.voteCode) }, // 每次父層重繪,這裡都會 new 出一個新的 Function 物件!
        onFavorite = { onFavoriteClick(vote.voteCode) }
    )
}

很多初學 Compose 的人(包括剛上手的我)很容易忽略:onClick = { ... } 寫在迴圈或 Composable 裡面,每一次重繪都會產生一個全新記憶體位址的 Lambda 實例。對於 VoteCard 來說,雖然 vote 物件沒變,但傳進來的 onClick 參數記憶體位址每一次都不同,Compose 當然只能判斷「參數改變了,必須重新繪製」。

標準的解法是善用方法引用,或者將點擊事件提升到卡片內部,把識別碼直接交給下游:

// 正確做法:保持回呼穩定,避免無效物件分配
VoteCard(
    vote = vote,
    onVoteClick = onVoteClick, // 傳遞穩定的高階函式引用
    onFavoriteClick = onFavoriteClick
)

四、 畫面雖然順了,但狀態開始群魔亂舞

當我們把 Compose 的重組地獄擺平、效能拉滿到 120Hz 之後,原本以為可以開香檳慶祝了。

但當我們開始在 ViewModel 裡面寫實際的業務邏輯時,另一個更隱蔽、更致命的架構缺陷,悄悄浮上了水面。

那時候我們延續了傳統 MVVM 的思維習慣,在 ViewModel 裡面隨手開出了好幾個獨立的 MutableStateFlow

// 傳統 MVVM 在 Compose 裡的災難前奏
class HomeViewModel @Inject constructor(
    private val voteRepository: VoteRepository
) : ViewModel() {
    private val _isLoading = MutableStateFlow(false)
    val isLoading = _isLoading.asStateFlow()

    private val _votes = MutableStateFlow<List<VoteWithDetails>>(emptyList())
    val votes = _votes.asStateFlow()

    private val _errorMessage = MutableStateFlow<String?>(null)
    val errorMessage = _errorMessage.asStateFlow()

    fun loadVotes() {
        viewModelScope.launch {
            _isLoading.value = true
            try {
                _votes.value = voteRepository.fetchVotes()
                _errorMessage.value = null
            } catch (e: Exception) {
                _errorMessage.value = e.message
            } finally {
                _isLoading.value = false
            }
        }
    }
}

這段代碼在傳統 XML 時代看起來毫無毛病。但在宣告式 UI 的世界裡,這段代碼引發了一場嚴重的災難:畫面狀態打架(狀態撕裂)

因為這三個 StateFlow 是各自獨立發射的,在非同步協程切換執行緒的微小時間差裡,Compose 敏銳地捕捉到了好幾種荒謬的中間狀態:
1. 轉圈圈結束了,但資料還沒更新:畫面短暫閃爍成一片空白。
2. 網路報錯訊息跳出來了,但舊的資料依然停在螢幕上,兩個元件撞在一起。
3. 手機稍微旋轉一下螢幕,錯誤提示重複彈出三次,導航重複跳轉。

看著畫面上這些忽隱忽現的幽靈狀態,我終於意識到:單純換成 Compose 只是換了外皮;如果不把管理狀態的架構徹底改造,宣告式 UI 只會把舊系統的狀態混亂放大十倍。


五、 結尾:給下一次進化的引子

從 2016 年手敲 30 份 XML 的苦行僧,到 2024 年用純 Kotlin 與 LazyColumn 刻出流暢的現代畫面,我們在 Compose 上吃足了苦頭,也嚐到了前所未有的甜頭。

我們學會了不要被看似唯讀的 List 欺騙,學會了用 @Immutable 馴服過動的編譯器,也學會了在 120Hz 的高刷螢幕上追求極致的幀率穩定。

但是,畫面刻得再漂亮,只要底層的狀態在打架,這個 App 就是個隨時會崩潰的半成品。為了徹底消滅那幾個各自為政的 StateFlow,為了杜絕畫面閃爍與幽靈中間態,我們在接下來的 mvi-rewrite 分支裡,展開了一場直奔 MVI 單向資料流的架構大手術。

至於我們是怎麼用單一不可變狀態與點餐號碼牌機制收拾這個爛攤子的,我們下一篇見。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *