在寫 Kotlin 時,我們常常會把變數宣告放在後面,或者在 init 裡做一堆初始化的事。
但很多人沒搞清楚一件事:Kotlin 的屬性初始化(Property Initializer)跟 init 區塊,到底是誰先誰後?
簡單來說:它們是按照在程式碼中出現的順序,由上到下排隊執行的。
如果你在同步代碼裡順序寫反了,編譯器會立刻把你打醒;但最可怕的是——如果你包了一層 viewModelScope.launch,編譯器居然就裝瞎放行了,然後在執行期直接送你一顆大雷。
到底犯了什麼忌諱?我們直接看程式碼。
狀況一:直接在 init 讀下方的變數,編譯器直接打臉
先看這段最直覺的寫法:
class ItemEditViewModel(
private val savedStateHandle: SavedStateHandle,
private val itemsRepository: ItemsRepository
) : ViewModel() {
init {
// ❌ 編譯直接報錯:Variable 'itemId' must be initialized
val stream = itemsRepository.getItemStream(itemId)
println("Item stream: $stream")
}
// 變數宣告在 init 區塊的下方
private val itemId: Int = checkNotNull(savedStateHandle["itemId"])
}
為什麼這段編譯不給過?
因為 Kotlin 編譯器有做確定賦值分析(Definite Assignment Analysis):
- 編譯器由上往下掃,進到
init時,發現itemId在語法樹裡根本還沒被給值。 val承諾了「不可變」跟「非空」,你還沒初始化就想拿來用,違反了型別安全合約。- 所以編譯器直接拒絕編譯,在編譯期救了你一命。
狀況二:怪事發生——包了一層 launch,編譯器居然放行了?
但在真實專案裡,很多人的寫法是這樣:
class ItemEditViewModel(
private val savedStateHandle: SavedStateHandle,
private val itemsRepository: ItemsRepository
) : ViewModel() {
init {
// 🤔 包了一層協程,編譯居然神奇地過了?!
viewModelScope.launch {
val item = itemsRepository.getItemStream(itemId)
.filterNotNull()
.first()
println("Loaded Item: $item, itemId is: $itemId")
}
}
// 依然宣告在 init 下方
private val itemId: Int = checkNotNull(savedStateHandle["itemId"])
}
關鍵問題來了:為什麼多了 launch 就能編譯?
因為 viewModelScope.launch { ... } 裡面的東西,本質上是一個 Lambda 閉包(Closure):
- 對編譯器來說,Lambda 是「未來某個非同步時間點」才會被調用的獨立函式。
- 編譯器在靜態分析時,根本猜不到這個協程到底會「立刻跑」還是「以後才跑」。
- 編譯器只確定一件事:「等這個類別完整建構完之後,
itemId肯定會有值,所以在閉包裡面存取它是合法的」。 - 結果:靜態檢查直接被跨作用域的閉包給穿透了,編譯器選擇放行!
狀況三:這程式碼真的能跑嗎?裝置上沒事,寫測試直接翻車
既然編譯過了,那跑起來到底會怎樣?這就是最折磨人的地方——它會因為執行環境不同而產生時差 Bug。
1. 在手機/實機上跑:看起來完全正常(純屬巧合)
viewModelScope 預設跑在 Dispatchers.Main:
init執行到viewModelScope.launch時,協程任務只是被包成一個 Runnable,丟進 Android 主線程的 Main Looper MessageQueue 排隊。- 此時主線程繼續往下走,執行到了
itemId的初始化賦值,整個 ViewModel 建構完成。 - 接著 Looper 輪詢,才輪到剛才排隊的協程執行,這時候
itemId早就賦好值了。 - 表面上看完全沒事,但這完全是撿到了 Looper 事件排隊的便宜!
2. 在單元測試中跑:直接抓包,印出 0 甚至噴錯
當你在寫 Unit Test,並照著官方推薦使用測試調度器時:
@Test
fun testLoadItem() = runTest {
// 測試環境中通常使用 UnconfinedTestDispatcher
Dispatchers.setMain(UnconfinedTestDispatcher())
val viewModel = ItemEditViewModel(savedStateHandle, mockRepository)
// 💥 災難發生:itemId 讀出來居然是 0!
}
底層到底發生了啥?
UnconfinedTestDispatcher(或生產環境中的Dispatchers.Unconfined/Dispatchers.Main.immediate)會直接在當前線程立刻內聯(Inline)執行協程體,根本不排隊!- 這時候類別的建構子才執行到一半,下方的
val itemId根本還沒被執行到。 - 在 JVM 規範中,物件分配到 Heap 上時,原始型別會先填上預設二進位 0(
int是0,物件是null)。 - 結果:你的協程就帶著
itemId = 0去發 API 請求了,直接拿錯誤參數查資料或引爆測試崩潰!
順便提個雷:繼承體系中父類的 init 陷阱
這種時序問題在繼承體系裡更陰險:
open class BaseProcessor {
init {
// ❌ 父類初始化時,調用了子類覆寫的屬性
println("Processing buffer size: $bufferSize")
setupBuffer()
}
open val bufferSize: Int = 1024
open fun setupBuffer() {}
}
class FastProcessor : BaseProcessor() {
// 子類覆寫屬性
override val bufferSize: Int = 4096
override fun setupBuffer() {
// 💥 bufferSize 在這裡的值是 0!
// 因為子類的欄位賦值,在父類的 init 跑完之後才輪得到!
val array = ByteArray(bufferSize)
}
}
那到底該怎麼改?三種正統寫法
要徹底避開這種時序鬼故事,我們有三種改法:
解法一:最簡單直覺——乖乖按順序寫
需要被用到的變數,一律宣告在 init 之前:
class ItemEditViewModel(...) : ViewModel() {
// ✅ 1. 先宣告並賦值
private val itemId: Int = checkNotNull(savedStateHandle["itemId"])
// ✅ 2. 後面才在 init 裡讀它
init {
viewModelScope.launch {
loadItem(itemId)
}
}
}
解法二:直接放主建構子(最乾淨、推薦)
別在類別內部靠下方的變數承接,直接在 Primary Constructor 解決:
class ItemEditViewModel(
savedStateHandle: SavedStateHandle,
private val itemsRepository: ItemsRepository,
// ✅ 直接在建構子參數化,init 執行時它一定早就備妥了
private val itemId: Int = checkNotNull(savedStateHandle["itemId"])
) : ViewModel() {
init {
viewModelScope.launch {
loadItem(itemId) // 絕對安全
}
}
}
解法三:改走響應式 StateFlow(架構層根治)
根本不要在 init 裡手動 launch,改用現代的宣告式 StateFlow:
class ItemEditViewModel(
savedStateHandle: SavedStateHandle,
itemsRepository: ItemsRepository
) : ViewModel() {
private val itemId: Int = checkNotNull(savedStateHandle["itemId"])
// ✅ 宣告式冷流轉換:完全沒有 init 協程時序先後的問題
val uiState: StateFlow<ItemUiState> = itemsRepository.getItemStream(itemId)
.filterNotNull()
.map { it.toUiState() }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = ItemUiState.Loading
)
}
總結:現場抓蟲心得
- 編譯能過,不代表沒寫錯:Lambda 閉包會讓編譯器的靜態檢查失靈,別把編譯通過當保證。
- 小心實機跟單元測試的排程差異:實機上的
Dispatchers.Main是靠 Looper 排隊才碰巧正常;寫測試換成UnconfinedTestDispatcher時,這種時序偷雞的寫法馬上原形畢露。 - 物件初始化期專心定義狀態:能用建構子傳遞就用建構子,少在
init裡發起依賴內部屬性的非同步副作用。

發佈留言