Funny vote APP – Kotlin 與 RxJava 的遲來救贖(二)

好一陣子……好吧,不是好一陣子,是過了快十年才來更新下一篇。上一篇在結尾信誓旦旦放話說:「至於 Kotlin 版本我則放到下一篇,對小弟來說 Kotlin 算是真的完全沒用過,所以新開文章!」 結果這一放,直接從 Android 8.0 Oreo 一路跨到了 Android 16。可見「怠惰」這檔事,只要養成習慣,十年過去真的跟眨眼一樣快。

秉持著 Something Record 的精神,既然自己挖了坑,跪著也要把它填完。這篇依然延續 Funny Vote 的重構心路歷程,不會手把手教你 Kotlin 語法,也不打算貼落落長的教學代碼,主要還是記錄我當年是怎麼被 Google 的新玩具痛扁的真實 murmur。

本篇對應之 GitHub 分支


一、 自打臉的開場:當年誰說 RxJava 雞腿比懶覺的?

在上一篇聊 Dagger 2 的時候,我曾大言不慚地寫過這段話:

「你說為何沒有一起引入 RxJava?……我絕對不會說因為看起來太簡單了,所以我懶得用……雖然我知道這有點雞腿比懶覺的問題,但是因為這之後我打算直接寫 Kotlin 版本,所以就忽略了。」

馬的,每次回頭看自己以前寫的幹話,都會想搭時光機回去賞自己兩巴掌。

身為一個有架構強迫症的 Android 工程師,當你看著 Google 官方的 Android Architecture Blueprints 上,每個 sample 專案都漂漂亮亮並排著 todo-mvptodo-mvp-daggertodo-mvp-rxjava 時,你心裡那股「空了一塊沒補齊」的躁鬱感就會開始作祟。嘴巴上說雞腿比懶覺,身體倒是很誠實——在真正碰 Kotlin 之前,我還是偷偷開了一個 mvp_rxjava 分支,把專案給「串流化」了。

(順帶一提,在 2026 年這波老專案復活與維護工程中,我也順手把這個老分支升級到了 RxJava 2,把古早的 CompositeSubscription 換成了大家更熟悉的 CompositeDisposable,免得現代 Android 工程師看到 Subscription 還以為我在寫什麼上古黑魔法)。

RxJava 真的有那麼舒爽?

本來在 MVP 架構下,最痛苦的就是 Presenter 呼叫 Model 時那滿坑滿谷的 Callback<T>。尤其當你要做「先檢查登入狀態 -> 撈取投票詳情 -> 查詢本地是否有收藏過」這種連續非同步鏈時,代碼就會一路往右下角斜斜地溜滑梯溜出去,俗稱「回呼地獄(Callback Hell)」。

引進 RxJava 後,用 flatMap 鏈式調用直接拉成一條直線,再加上宣告式的執行緒切換,當下寫起來真的爽感十足:

// 2017 年在 VoteDetailPresenter 裡的經典 RxJava 1.x 串流
mSubscriptions.add(
    voteDataRepository.getVoteData(voteCode)
        .subscribeOn(schedulerProvider.io())        // 背景默默搬磚
        .observeOn(schedulerProvider.ui())        // 切回主執行緒邀功
        .subscribe(new Observer<VoteData>() {
            @Override
            public void onNext(VoteData data) { view.showVoteDetail(data); }
            @Override
            public void onError(Throwable e)  { view.showErrorMessage(e.getMessage()); }
            @Override
            public void onCompleted()         { view.hideProgress(); }
        })
);

但是!代價是什麼呢?

  1. 陡峭到想撞牆的學習曲線
    本來以為只是換個寫法,結果跑去查文件才發現這玩意根本是另一個宇宙。光是一個「把資料往下傳」,就有 flatMapconcatMapswitchMap;想控制發射頻率,又有 debouncethrottleFirst。這哪裡是「看起來太簡單」,這根本是把工程師推進操作符的無底深淵。
  2. 忘記取消訂閱的記憶體大噴發
    在 Java 時代沒有現代協程的生命週期感知。你在 Presenter 裡 subscribe 之後,如果沒有乖乖拿一個 CompositeSubscription 接住它,並且在 Activity 銷毀時呼叫:
    @Override
    public void unsubscribe() {
        mSubscriptions.clear(); // 漏掉這行,你就等著收崩潰報告
    }

    只要使用者在網路很慢的時候轉個螢幕或按上一頁,已經銷毀的 View 就會被背景的 Observer 狠狠抓住不放,接著在某個風和日麗的下午噴出 IllegalStateException: Activity has been destroyed 給你看。


二、 踏入 Kotlin 1.2:誰准你對著 Java 說「我沒有 NPE」?

玩完了 RxJava,終於來到 2017~2018 年 Android 開發界最驚天動地的革命——Google 在 I/O 大會上宣布:Kotlin 正式成為 Android 的一級開發語言(First-class citizen)。

那時候各大論壇都在瘋狂鼓吹 Kotlin,說什麼「空安全(Null Safety)」、「再也不會有 NullPointerException」、「代碼量減少一半」。當時看著 Funny Vote 裡面幾十個 Java 類別,滿地都是 getVoteTitle()setVoteTitle(),以及每次從後端拿 JSON 都要戰戰兢兢寫 if (data != null && data.getOptions() != null),我心一橫,決定整碗端走,開出 mvp_kotlin 分支把整個專案轉成 Kotlin 1.2。

初嘗甜頭:Data Class 與 Synthetics 真香

一開始轉換的爽感是真的無法否認:

  • 肥大 POJO 的大屠殺:以前寫一個 VoteData.java,包含了 ID、標題、選項清單、過期時間,加上 Constructor、Getter、Setter、equals()hashCode(),動輒 200 行起跳。換成 Kotlin 的 data class 後,十行搞定。
  • 淘汰 ButterKnife 與 findViewById:當年最炙手可熱的黑科技叫 kotlinx.android.synthetic(俗稱 Kotlin Synthetics)。只要在 Activity 頂部 import kotlinx.android.synthetic.main.activity_vote_detail.*,你在代碼裡直接敲 XML 上的 ID 就能直接當 View 物件操作!連 @Bind 都懶得寫了,簡直是懶人的極致。
// 2018 年的 Funny Vote:連 findViewById 都不用寫的虛假繁榮
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_vote_detail)
    
    // 直接用 XML ID 操作!當年覺得屌炸天
    txtVoteTitle.text = vote.title
    btnSubmitPoll.setOnClickListener { presenter.submitPoll() }
}

三、 理想很豐滿,現實很骨感:那些主流文章不敢告訴你的 Kotlin 翻車現場

如果你以為轉到 Kotlin 從此天下太平,那你肯定沒在第一線踩過坑。當我把全部代碼轉過去之後,迎接我的不是掌聲,而是一連串接踵而來的靈異現象:

1. Mockito 單元測試被 Kotlin「預設 Final」痛扁一頓

上一篇我最自豪的就是替 Funny Vote 建置了完整的 Mockito 單元測試與 Travis CI 自動化流程。結果切到 Kotlin 後,執行 ./gradlew test,整個 Terminal 噴出滿江紅的測試錯誤:

org.mockito.exceptions.base.MockitoException: 
Cannot mock/spy class com.heaton.funnyvote.data.VoteDataRepository
Mockito cannot mock/spy because :
 - final class

我當場傻眼。 Java 時代所有 class 與 method 預設都是 open(可被繼承與覆寫);但 Kotlin 為了推崇 Effective Java 的理念,所有的類別與方法預設全都是 final 偏偏 Mockito 2.x 早期依賴動態代理(Cglib / ByteBuddy)來建立 Mock 物件,遇到 final 直接舉雙手投降。

為了修復這個智障問題,我不得不去翻 GitHub 荒郊野外的解法:要嘛手動在所有要測試的類別前面加上 open(等於為了測試破壞生產代碼的封裝);要嘛去開一個神祕的檔案 src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker,在裡面寫上一行冷門設定 mock-maker-inline;最後還得為了 Kotlin 的 Nullable 機制,手寫一套自製的 MockitoKotlinHelpers.kt

// 2018 年在 Funny Vote 裡為了讓 Mockito 閉嘴而手寫的 Helper
fun <T> eq(obj: T): T = Mockito.eq<T>(obj)
fun <T> any(): T = Mockito.any<T>()
inline fun <reified T : Any> argumentCaptor(): ArgumentCaptor<T> = 
    ArgumentCaptor.forClass(T::class.java)

寫完真的想仰天長笑:我到底是來享受 Kotlin 的優雅,還是來幫測試框架擦屁股的?

2. Anko 的短命背叛:踩進被官方拋棄的甜蜜陷阱

那時候協程(Coroutines)在 Kotlin 1.2 還掛著「Experimental(實驗性)」的警告標誌,沒人敢在正式專案用。那背景執行緒怎麼解?
JetBrains 官方當時大力推銷自家的套件庫——Anko。它提供了一個看起來無比絲滑的語法糖 doAsync { ... uiThread { ... } },直接取代難用的 AsyncTask

// 當年覺得好棒棒的 Anko 寫法
doAsync {
    val vote = repository.getVoteFromRemote(voteId)
    uiThread {
        view.showVote(vote)
    }
}

結果呢?這東西用起來有多爽,幾年後死得就有多慘。幾年後 JetBrains 官方無情宣布 「Anko 永久廢棄(Deprecated)」,整個社群頓時變成小丑,全部專案又得回頭重寫。這件事教會了我身為工程師最重要的一堂課:千萬不要為了一時的語法糖爽感,把身家性命押在非標準庫的玩具上。

3. Kotlin Synthetics 遇到 <include> Layout 的見鬼日常

還有前面提到的 Kotlin Synthetics,它在單純的 XML 上運作良好,但 Funny Vote 的詳情頁很複雜,拆成了 include_toolbar.xmlinclude_author.xmlinclude_function_bar.xml
當你在多個 <include> 裡不小心用了相同的命名(例如都叫 txtTitleimgIcon),Synthetics 在自動生成快取 View 時就會精神分裂,在 Fragment 裡更是三不五時給你抓錯 View 甚至是直接拋出 NullPointerException

最諷刺的是什麼?Kotlin 主打「消滅 NPE」,結果你用它的官方插件 Synthetics,在運行期噴出一個更難除錯的 NPE。
(這也是為什麼 Google 後來痛定思痛,把 Synthetics 整組判死刑,全面改推 ViewBinding 與後來的 Jetpack Compose)


四、 本篇總結:重構這檔事,痛並快樂著

折騰了幾週,把 mvp_rxjavamvp_kotlin 這兩座大山翻過去之後,Funny Vote 確實變得更現代了一點點。至少在 2018 年的當下,打開代碼庫看著全 Kotlin 的 class、簡潔的 data class,以及透過 RxJava 串起來的資料管線,心裡還是有一點點微薄的成就感。

但你問我問題全都解決了嗎?完全沒有。

本質上,它依然是一隻套著 Kotlin 外皮的 MVP 巨獸

  • Presenter 依然要手動維護 View 的介面生命週期;
  • 旋轉螢幕時,那些 Callback 與 Subscription 依然像未爆彈一樣讓人神經緊繃;
  • 畫面上那些 30+ 份 XML 檔案依然在每次改需求時,逼著你去翻找 View 的階層。

這也是為什麼在沉寂了幾年後,當我看著 Android 官方把 Kotlin 推進到 2.0、徹底淘汰 XML 端出 Jetpack Compose,以及將架構收斂至 MVI(單向資料流 UDF) 時,我心裡那股強迫症的火苗又再次被點燃了……

至於從 XML 跨越到 Compose 遭遇的「狀態撕裂災難」,以及為了解決這個問題不得不直上的 MVI 架構,我們就下篇繼續 murmur 吧!(這次我保證不會再拖十年了,應該啦。)

“Any fool can write code that a computer can understand. Good programmers write code that humans can understand.”
— Martin Fowler

(但在 2018 年寫 Kotlin + Mockito 的時候,我覺得連電腦都快不理解我了。)

發佈留言

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