
距離上一篇聊 MVP 解耦與單元測試,不知不覺又過了幾個月。本來在第二篇最後信誓旦旦說要把 Kotlin 版本放到下一篇,結果一開工位才發現,欠下的技術債果然連本帶利都要還。
2018 年的秋天,Google I/O 官方把 Kotlin 扶正為 Android 一級公民的狂熱剛席捲整個圈子。當時看著專案裡八十幾個 Java 類別,每天被無窮無盡的 Getter/Setter、空指標檢查與 ButterKnife 搞得心力交瘁,我心裡只有一個念頭:「既然官方背書了,那我就開個 mvp_kotlin 分支,一鍵把 Java 轉成 Kotlin,少寫幾千行樣板代碼躺平不好嗎?」
(後來的血淚證明:人只要一急著想偷懶,通常都會踩進更深的水泥坑裡。)
這篇文章不會教你什麼宣告變數、迴圈語法糖,那些看官方文件十分鐘就懂。這篇純粹是在 2018 年 9 月 3 日到 9 月 5 日凌晨,我親手把整個專案 80+ 類別全轉成 Kotlin 1.2 時,撞上的真實修羅場與底層 Bytecode 踩坑回顧。
當年折騰的先鋒代碼,依然完整保存在 GitHub 上供大家考古:
1. 虛假繁榮:POJO 蒸發 80% 與 Synthetics 初體驗
剛開始轉的那兩天,爽度確實高到讓人飄飄然。
最直接的收穫是 POJO 樣板大清洗。以前寫個 VoteData,兩百行代碼裡有八成是在手敲 Getter、Setter、equals()、hashCode():
// 四行搞定兩百行 Java 樣板,自動贈送 copy(), toString(), componentN()
data class VoteData(
val voteKey: String,
val title: String,
val isMultiChoice: Boolean = false,
val maxOptions: Int = 1
)
乾淨俐落,強迫症當場痊癒。
接著是當年號稱「ButterKnife 終結者」的 Kotlin Android Extensions (Kotlin Synthetics)。在 build.gradle 加上外掛,在 Activity 裡直接 import XML 佈局,元件 ID 當場變成變數操作:
// 告別 findViewById 與 @BindView,直接拿 XML ID 當變數操作
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
txtTitle.text = "Funny Vote 趣投票"
btnSubmit.setOnClickListener { doSubmit() }
}
看著滿坑滿谷的 @BindView 被整批丟進垃圾桶,我坐在椅子上傻笑,心想這語法糖也太甜了吧。
然而,甜蜜通常維持不過 48 小時。
2. 當場打臉:Synthetics 的 include 幽靈與隱蔽 NPE
當我把投票詳情頁 VoteDetailContentActivity 轉成 Kotlin 後,真機上一跑,點擊跳轉立刻丟出 NullPointerException 當場暴斃!
當時我滿腦子黑人問號:「Kotlin 不是宣稱空安全(Null Safety)嗎?我型別都寫成非空了,這 NPE 到底哪來的?」
翻開我當年記事本的第一行,紀錄著這段錯字連篇的崩潰哀嚎:
android kotlin extension include xml with id name with id 感覺很像藥不能有include id 才行
[VoteDetail 巢狀佈局階層]
activity_vote_detail.xml
├── Toolbar (id: toolbar)
├── ViewPager (id: viewPager)
└── <include layout="@layout/include_author" android:id="@+id/include_author" />
└── (include_author.xml 內部又有一個 id 為 imgAvatar 的元件)
底層是怎麼背刺我的?
冷靜下來反編譯 Kotlin Synthetics 產出的 Java Bytecode,真相才浮出水面。
Synthetics 在 Activity 背後偷偷塞了一個快取查表字典:
// Kotlin 編譯器背後偷幹的髒活
private HashMap _$_findViewCache;
public View _$_findCachedViewById(int id) {
if (_$_findViewCache == null) _$_findViewCache = new HashMap();
View view = (View) _$_findViewCache.get(id);
if (view == null) {
view = findViewById(id);
_$_findViewCache.put(id, view);
}
return view;
}
當你使用 <include> 標籤,並且同時在 <include> 與內部根視圖宣告了 ID 時,Synthetics 生成的靜態屬性解析出現了嚴重的歧異(Ambiguity)——它直接從外層 Activity 嘗試 findViewById(R.id.imgAvatar),但該元件被包在子佈局內部,外層查表直接回傳 null!
更毒的是,Kotlin 的非空宣告在編譯期相信了 Synthetics 的外掛保證,結果在 Runtime 拿到 null 呼叫方法,原地拋出 NPE!
這個坑狠狠打了我一巴掌:不要以為語法糖包得很甜,底層的 View 階層查找機制就不存在了。
3. 核心戰役:測試全滅——當 Kotlin 預設 final 撞翻 Mockito
解決了 UI 佈局的小打小鬧,真正致命的核彈在第三天凌晨三點引爆:單元測試全滅。
在第二篇裡,我大言不慚地寫下了「架構解耦後務必補齊單元測試」。結果當我把 Presenter 轉成 Kotlin 後,在終端機敲下 ./gradlew testDebugUnitTest 的那一秒,控制台直接炸出一片血紅:
測試案例全部失敗,一個綠燈都沒有。
毒殺 Mockito 的兩把暗箭
追進堆疊日誌,我抓出了兩顆致命的跨語言地雷:
暗箭一:Kotlin 類別預設全為 final
在 Java 的世界裡,所有 class 預設都是 open 的(除非顯式加上 final)。但 Kotlin 為了效能與安全,所有類別與方法預設全都是 final!
而 2018 年主流的 Mockito 1.x / 2.x 早期版本底層是靠 CGLIB 或 Byte Buddy 生成子類別來做動態代理的。Java 規範明定無法繼承 final 類別,Mockito 直接噴出:
Cannot mock/spy class UserPresenter because it is final!
暗箭二:Intrinsics Bytecode 非空插樁
在單元測試中,我們習慣這樣寫:
verify(mockView).showUser(Mockito.any())
但 Mockito.any() 是 Java 寫的泛型方法,在執行階段它的底層實作就是直接 return null。
而在 Kotlin 編譯器眼裡,如果你的介面參數宣告為非空:
fun showUser(user: UserData) // 非空型別
Kotlin 編譯器會在該方法的第一行指令自動插入這段 Bytecode:
Intrinsics.checkParameterIsNotNull(user, "user");
當 Mockito.any() 吐出 null 傳進去的那一瞬間,這行檢查當場暴怒,直接噴出 IllegalArgumentException: Parameter specified as non-null is null: method showUser, parameter user!
凌晨三點半的救贖之道
在 2018/9/5 凌晨 03:34 的真實 commit 0853e4b 中,我用兩套組合拳徹底擺平了這個死局:
第一拳:啟用實驗性的 mock-maker-inline
在 app/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker 建立配置檔,寫入一行宣告:
mock-maker-inline
強制 Mockito 切換到底層 Java Agent Bytecode 插樁(Instrumentation)引擎,繞過子類繼承限制,實現對 final class 的直接 mock。
第二拳:手刻泛型救命工具欺騙編譯器
在 src/test/java/com/heaton/funnyvote/MockitoKotlinHelpers.kt 中,我親手刻下了這組利用 Kotlin 泛型型別擦除(Type Erasure)的救命工具函數:
// 2018 年在 MockitoKotlinHelpers.kt 裡親手寫下的 Workaround:
// 利用隱式繼承 Any? 的泛型上限,騙過編譯器的 Intrinsics.checkParameterIsNotNull 插樁
fun <T> any(): T = Mockito.any<T>()
fun <T> eq(obj: T): T = Mockito.eq<T>(obj)
fun <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture()
把測試代碼裡的 Mockito.any() 全部替換為自己寫的 any(),終端機裡的測試紅字瞬間全數消退,綠燈刷刷刷亮起。
看著控制台吐出 BUILD SUCCESSFUL,外面天剛好矇矇亮。那種摸進 JVM 水管深處把 Bug 擰死的快感,是任何高階框架都給不了的。
4. 非同步過渡期的折衷:沒有協程的荒原時代
搞定單元測試後,最後一個工程抉擇是:非同步任務怎麼辦?
在 2018 年,Android 剛廢棄了陳舊的 AsyncTask。而傳說中的 Kotlin Coroutines(協程)在當時的 1.2 版本還頂著大大的 Experimental 警告標籤,連 viewModelScope 都還沒出生。在線上產品裡直接上實驗性架構,除非你想半夜被老闆電話叫醒。
你說為什麼不全面引進 RxJava?
正如我在第二篇說過的,Funny Vote 只是個中小型 App,為了幾個簡單的本地資料庫查詢與 API 請求,把整個專案的介面全部改寫成 Observable / Single,還要團隊每個人去搞懂 Schedulers 和背壓,實在是有點「雞腿比懶覺」的過度設計。
最後我採取的務實妥協,是在專案裡刻了一個極度輕量的雙線程池調度器 AppExecutors.kt:
// 2018 年的輕量調度器:磁碟/網路 I/O + 主執行緒
class AppExecutors(
private val diskIO: Executor = Executors.newSingleThreadExecutor(),
private val networkIO: Executor = Executors.newFixedThreadPool(3),
private val mainThread: Executor = MainThreadExecutor()
) {
fun diskIO(): Executor = diskIO
fun networkIO(): Executor = networkIO
fun mainThread(): Executor = mainThread
private class MainThreadExecutor : Executor {
private val mainThreadHandler = Handler(Looper.getMainLooper())
override fun execute(command: Runnable) {
mainThreadHandler.post(command)
}
}
}
搭配 Kotlin 的 Trailing Lambda(尾隨閉包) 語法糖,Presenter 裡原本冗長醜陋的匿名類別回呼,瞬間變成了乾淨流暢的非同步調度:
// 2018 年在沒有協程的荒原上,最務實的非同步寫法
override fun getUserData() {
view.showProgress(true)
appExecutors.networkIO().execute {
val user = userDataRepository.getUser()
appExecutors.mainThread().execute {
view.showProgress(false)
user?.let { view.showUser(it) } ?: view.showError("User not found")
}
}
}
在單元測試裡,只需要注入同步的 InstantExecutor 就能毫秒級跑完測試。人活著只要能在週五準時下班,程式碼能跑且好改,比追什麼最新框架都強得多。
5. 時代的眼淚與收工
折騰了一週,mvp_kotlin 分支總算完成了全專案 80 多個檔案的遷移。
- 總行數減少了將近 35%,主要是大量的 POJO 樣板與視圖查找代碼被消滅;
- 線上 NPE 崩潰率幾乎歸零;
- 單元測試覆蓋率在不破壞生產代碼的前提下維持在 80% 以上。
但軟體工程最迷人也最諷刺的地方,就是技術的快速迭代:
- 當年費盡心機填補的 Synthetics,後來因為快取缺陷被 Google 徹底廢棄,被 ViewBinding 和 Compose 取代;
- 當年凌晨三點半搞的 mock-maker-inline 與手刻的 MockitoKotlinHelpers,後來有了成熟的 MockK 函式庫原生支援 Kotlin;
- 當年小心翼翼避開的 Coroutines,在 Kotlin 1.3 正式穩定後迅速席捲 Android,徹底改寫了非同步並行的遊戲規則。
所以說追新技術跟買股票一樣,你以為抄在起跑點,其實是進去當開路先鋒兼填坑砲灰。但回頭看 commit log,當年能把這個坑擰死,爽度還是實打實的。
以上是本次小弟的真實感想。我真的懶得貼落落長的全專案 CODE,各位真的想看當年完整的提交細節,去 GitHub 翻 mvp_kotlin 分支吧,通通都放在那,不浪費版面。
感謝觀看,收工。

發佈留言