kotlin init 前初始化屬性會發生啥事

在寫 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):

  1. 編譯器由上往下掃,進到 init 時,發現 itemId 在語法樹裡根本還沒被給值。
  2. val 承諾了「不可變」跟「非空」,你還沒初始化就想拿來用,違反了型別安全合約。
  3. 所以編譯器直接拒絕編譯,在編譯期救了你一命。

狀況二:怪事發生——包了一層 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:

  1. init 執行到 viewModelScope.launch 時,協程任務只是被包成一個 Runnable,丟進 Android 主線程的 Main Looper MessageQueue 排隊。
  2. 此時主線程繼續往下走,執行到了 itemId 的初始化賦值,整個 ViewModel 建構完成。
  3. 接著 Looper 輪詢,才輪到剛才排隊的協程執行,這時候 itemId 早就賦好值了。
  4. 表面上看完全沒事,但這完全是撿到了 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
        )
}

總結:現場抓蟲心得

  1. 編譯能過,不代表沒寫錯:Lambda 閉包會讓編譯器的靜態檢查失靈,別把編譯通過當保證。
  2. 小心實機跟單元測試的排程差異:實機上的 Dispatchers.Main 是靠 Looper 排隊才碰巧正常;寫測試換成 UnconfinedTestDispatcher 時,這種時序偷雞的寫法馬上原形畢露。
  3. 物件初始化期專心定義狀態:能用建構子傳遞就用建構子,少在 init 裡發起依賴內部屬性的非同步副作用。

發佈留言

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