Jetpack Compose Side Effect 處理機制:從理論概念到正式環境應用實踐

Published on: | Last updated:

好,今天要來聊聊 Jetpack Compose 裡面的 Side Effects。說真的,這東西大概是繼 State 管理之後,第二個最容易把人搞瘋的主題。我敢說,很多有點經驗的 Android 工程師,剛轉過來的時候,都在 `LaunchedEffect`、`DisposableEffect`、`SideEffect` 這幾個 API 之間踩過坑。

大部分的文章或教學,你知道的,就是把官方文件的定義抄一遍,跟你說 A 是幹嘛的、B 是幹嘛的。但真正到了專案裡,問題就來了:「我這個需求,到底該用哪一個?」選錯了,輕則效能低落,重則出現那種超難抓、偶爾才復現的鬼 bug。我最近才幫同事 debug 一個問題,畫面明明離開了,背景音樂還在放... 查了半天,結果就是 `DisposableEffect` 裡面的 `onDispose` 寫錯了。唉。

所以這篇我不想講太多理論。我想直接分享我在一個已經上架的 App 裡,是怎麼實際「用」這些東西的。什麼模式是好用的,什麼寫法是地雷,還有一些... 嗯,算是可以救你一命的實戰心得吧。這些都不是那種 hello world 的玩具範例,是真正在處理全螢幕 UI、非同步操作、跟生命週期奮鬥過的程式碼。

重點一句話

簡單講,Compose 的 Side Effects 就是讓你跟 Composable 世界「外面」溝通的管道。但用哪個管道、什麼時候用,決定了你的 App 是穩定可靠還是 bug 一堆。選對工具,比你加班 debug 重要多了。

為什麼這東西這麼讓人混亂?

我自己覺得,問題出在我們思考的方式跟 Compose 要求的不太一樣。以前我們寫 View system,總是有個 `onStart`, `onResume`, `onStop`... 有個很明確的「生命週期」可以掛東西。但在 Compose 的世界,一個 Composable function 可能在一秒內被執行好幾十次(recomposition),它沒有一個穩定的「地方」讓你放那些只需要做一次,或是在特定時機才做的邏輯。

所以 Google 提供了這些 Effect handlers,它們就像是你在 Compose 這條湍急的河流中,找到的幾個「錨點」。讓你可以在特定的時機,安全地執行那些「非 Compose」的任務。像是:

  • 發一個網路請求 (這算非同步工作)
  • 註冊一個 broadcast receiver 或 sensor listener (這需要清理)
  • 更新一個不是由 Compose 管理的狀態 (例如通知外部的 library)

官方文件 (`Official Android Documentation`) 寫得很清楚,定義都很準確。但它不會告訴你,當一個需求「看起來」既可以用 `LaunchedEffect` 又可以用 `SideEffect` 時,那個微小的差異會導致什麼後果。這就是理論跟實務的差距,也是我們今天要補起來的缺口。

Compose 生命週期的抽象概念:進入、停留、然後離開
Compose 生命週期的抽象概念:進入、停留、然後離開

實作指引:三大 Effect Handler 的白話文版

OK,我們一個一個來看。我會用最口語的方式解釋,然後馬上給你看它在真實世界裡長什麼樣子。

`LaunchedEffect`: 你的非同步工作主力

這個應該是大家最常用,也最容易誤用的。它的核心概念超級簡單:當你的 `key` 改變時,它會啟動一個新的協程 (coroutine) 來跑你給它的程式碼。如果 `key` 沒變,它就什麼都不做。

什麼意思?想像一下,你有個畫面,需要根據使用者選擇的「主題」去載入不同的資料。`LaunchedEffect(selectedTheme)` 就非常適合。當使用者從「亮色主題」切換到「暗色主題」,`key` 變了,它就自動取消舊的協程(如果還在跑的話),然後啟動一個新的,去跑你更新主題的邏輯。

真實案例:根據使用者偏好設定來更新 App 主題

在我做的 App 裡面,我需要從 `StateFlow` 讀取使用者儲存的偏好設定,然後套用到整個 App 的 UI 上。這段程式碼看起來大概是這樣:


// 在你的 Composable 裡面
val userPreferences by userPreferencesManager.userPreferencesFlow
    .collectAsState(initial = UserPreferences())

// 這邊是重點
LaunchedEffect(userPreferences) {
    // 當 userPreferences 物件一有變化,這裡就會執行
    Log.d("Theme", "User preferences changed, updating theme...")
    
    // 根據 preference 找到對應的主題
    val newTheme = Themes.entries.find { it.name == userPreferences.theme } ?: Themes.DEFAULT
    applyTheme(newTheme)

    // 如果使用者沒有設定過深色模式,就幫他用系統預設的
    if (userPreferences.isDarkMode == null) {
        userPreferencesManager.updateDarkMode(isSystemInDarkTheme())
    }
}

為什麼這裡用 `LaunchedEffect` 是對的?

  • 效率高:只有在 `userPreferences` 這個物件真的變了的時候,裡面的程式碼才會跑。而不是每次畫面重組(recomposition)都跑一次。
  • 安全:這個協程的生命週期是跟著這個 Composable 綁定的。當這個 Composable 從畫面上消失,Compose 會自動幫你取消這個協程,完全不用擔心記憶體或資源洩漏
  • 好管理:你看,更新 theme、寫回預設值這些非同步邏輯,都包在同一個地方,很清楚。

那如果不用 `LaunchedEffect`,改用 `rememberCoroutineScope` 來做會怎樣?...嗯,會很痛苦。`rememberCoroutineScope` 只是給你一個可以發射協程的 scope,但它本身「不會」因為你的狀態改變而重新觸發。你得自己手動寫一堆 `if (prevValue != newValue)` 之類的判斷,超級容易出錯,而且程式碼會變得很醜。

`DisposableEffect`: 有借有還,再借不難的典範

如果說 `LaunchedEffect` 是處理「一次性」或「因狀態改變而觸發」的任務,那 `DisposableEffect` 就是處理那種需要「成雙成對」出現的操作:設定與清理 (setup and cleanup)。

最經典的例子就是註冊監聽器。你 `onStart` 的時候註冊,`onStop` 的时候就一定要取消註冊,不然就等著 memory leak 吧。`DisposableEffect` 就是 Compose 版本的 `onStart` / `onStop`。

它會在 Composable **第一次進入畫面** (或 key 改變) 時執行你給它的程式碼區塊,然後它會要求你回傳一個 `onDispose` lambda。這個 `onDispose` 就是它的精華所在,會在 Composable **離開畫面**時被呼叫。

真實案例:管理全螢幕播放器的系統 UI

這大概是最常見也最經典的場景了。我要做一個影片播放器,當播放器畫面出現時,我要隱藏系統的狀態列和導覽列,讓畫面變全螢幕。當使用者離開這個畫面時,我「必須」把狀態列和導覽列還原,不然整個 App 後面的畫面都會是壞掉的全螢幕,那真的是災難。

一個忘了清理 Effect 的悲劇:UI 卡在全螢幕
一個忘了清理 Effect 的悲劇:UI 卡在全螢幕

// 這段程式碼在一個代表全螢幕播放器的 Composable 裡
val activity = LocalContext.current as ComponentActivity
val window = activity.window
val insetsController = WindowCompat.getInsetsController(window, window.decorView)

DisposableEffect(Unit) {
    // === Setup: 進入畫面時要做的事 ===
    Log.d("SystemUI", "Entering fullscreen, hiding system bars.")
    insetsController.hide(WindowInsetsCompat.Type.systemBars())
    
    // 開啟螢幕恆亮
    activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)

    // === Cleanup: 離開畫面時要做的事 ===
    onDispose {
        Log.d("SystemUI", "Leaving fullscreen, showing system bars.")
        insetsController.show(WindowInsetsCompat.Type.systemBars())

        // 關閉螢幕恆亮,這超重要!
        activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
    }
}

這段程式碼的美妙之處在於它的保證。不管使用者是按返回鍵、滑掉 App、還是手機橫豎螢幕切換導致 Composable 重建,`onDispose` 裡面的清理邏輯「一定會」被執行。這就是所謂的生命週期感知 (lifecycle-aware),也是 `DisposableEffect` 無可取代的價值。

順帶一提,這邊的 `key` 我給了 `Unit`。這是一個小技巧,意思就是「我希望這段 setup/cleanup 的邏輯,只跟著這個 Composable 的存在與否來執行,不要跟任何其他狀態掛鉤」。

`SideEffect`: 最簡單也最危險的傢伙

最後是 `SideEffect`。它沒有協程,也沒有 `onDispose`。它的規則只有一個:在每一次成功的 recomposition 之後執行。

對,你沒看錯,是「每一次」。

這讓它變成一個很特殊的工具。它適合用來把你 Composable 內部的狀態,「同步」給 Compose 世界「外部」的物件。因為它在每次重組後都執行,所以可以確保外部物件拿到的永遠是最新的狀態。

真實案例:...咦?隱藏系統 UI 也能用它?

有趣的地方來了。在原文的範例中,作者用 `SideEffect` 去「隱藏」狀態列,然後用 `DisposableEffect` 去「顯示」它。這是一種組合技,但說真的,我自己覺得這樣有點把邏輯拆散了,不太好讀。

不過,`SideEffect` 確實有個適合的場景。想像你有一個分析用的 library,它不是 Compose-aware 的。你需要在某個畫面顯示時,告訴這個 library:「現在的畫面是『個人資料頁』」。


@Composable
fun ProfileScreen(user: User) {
    // ... 顯示使用者資料的 UI ...

    // 每次 ProfileScreen 重組(例如 user 資料更新),都通知外部的分析工具
    SideEffect {
        Analytics.setCurrentScreen("Profile/${user.id}")
    }

    Text("Hello, ${user.name}")
}

你看,這裡用 `SideEffect` 就很合理。它是一個很快、很輕量的操作。但你「千萬千萬」不要在 `SideEffect` 裡面做任何花時間的事情,比如網路請求或資料庫讀寫。因為它真的跑得非常頻繁,你把重度工作放這裡,你的 App 會卡到你懷疑人生。

所以,到底該用哪個?一張表幫你決定

我知道上面講了一堆,可能還是有點模糊。沒關係,我做了一個比較表,你可以把它當成一個速查手冊。下次你不確定的時候,就回來看看這個表。

Effect 種類 適合的場景 (口語版) 最該注意的地雷 (千萬別踩) 一個真實世界的比喻
`LaunchedEffect` 當某個 state 變了,需要去做一件「需要花點時間」的事。像是網路請求、讀取資料庫、跑一段動畫。 key 給錯了!如果 key 永遠不變 (`Unit`),它就只會跑一次。如果 key 變動太頻繁,它會一直取消又重啟,浪費資源。 像是餐廳的「叫號燈」。號碼(`key`)一變,廚房(coroutine)就開始做新的餐點。
`DisposableEffect` 當你需要「配對」操作。像是註冊/取消監聽、打開/關閉資源、進入/離開全螢幕。 忘了寫 `onDispose` 裡面的清理邏輯!這絕對是災難的來源,會造成各種資源洩漏。 你去飯店房間要插房卡取電 (`setup`),退房時一定要把卡拔走 (`onDispose`),不然電費算你的。
`SideEffect` 只是想把 Composable 裡最新的狀態,「通知」一下外部某個不是 Compose 的東西。 絕對不要在裡面放耗時或昂貴的操作!它每次重組都會跑,你放個網路請求進去,手機大概會燒起來。 像是你每次調整音響的音量(recomposition),音量旋鈕上的數字燈(外部物件)都要跟著變。這是一個即時的、輕量的同步。
選擇困難?跟著這個流程圖走就對了
選擇困難?跟著這個流程圖走就對了

總結一下:刻意地去選擇

Compose 的 Side Effects 很強大,但前提是你得「刻意地」去使用它們,而不是隨便抓一個看起來能動的就好。一開始可能會覺得很煩,要一直想「這個情境到底哪個才對?」。

但相信我,花這點時間思考,絕對比你未來花好幾個小時、甚至好幾天去 debug 一個因為 Effect 用錯而產生的詭異 bug 還要值得。穩定的 App 就是從這些小細節堆出來的。

希望這些來自實戰的經驗,能幫你跨過從「知道」到「會用」的這道坎。


換你動動腦了

好,來個小測驗。假設你有個需求:當使用者第一次進入某個商品頁面時,你需要發送一個 API request 來記錄「商品曝光」的分析事件。這個事件只需要發送一次,不管使用者在頁面內怎麼滑動、或因為網路變化觸發重組,都不該重複發送。

你會優先考慮用哪個 Effect Handler 來實現?`LaunchedEffect(Unit)`? 還是 `DisposableEffect(Unit)`? 或是有更好的做法?在下面留言分享你的想法跟理由吧!

Related to this topic:

Comments

  1. Guest 2025-06-06 Reply
    這篇文章真的講解得很到位!副作用在 Compose 確實是個容易搞混的概念。尤其是 LaunchedEffect 和 DisposableEffect 的使用場景,對新手來說有點繞。不過看完感覺豁然開朗,實戰經驗果然是最佳老師。
撥打專線 LINE免費通話