把 MVVMC 帶到 Android(二):三道關卡 + Router 接線
Part 1 把專案搬進 Skip 認得的結構,但 skipstone plugin 還沒接 target,Skip「掛在那」沒對程式碼開火。Part 2 要做的事比較硬:把 plugin 綁上去、看 MVVMC 的 doAction / @Observable / Router 模式撞上什麼,然後把六個功能逐一帶到 Android。
主線是 三道關卡:轉譯關卡(Swift → Kotlin 語法)、Kotlin 編譯關卡(轉譯後的 Kotlin 對上 AndroidX / SkipUI)、執行期關卡(Compose 重繪機制)。三道關卡互不重疊,每道都有自己的修法。最後是 Router 接線跟一個 [weak] 在 Kotlin GC 下會出事的 bug。
🔥 接 plugin,迎接「煙火秀」
Package.swift 一行:
| |
掛到 MVVMCSkipDemo target 上。預期心理是「Skip 對著整個程式碼開火,錯誤洪流」——Part 1 的撤退過程已經盤點過兩類問題(Swift 6 concurrency、巢狀 leading-dot enum),我把它叫做「雪崩」。
xcodebuild 跑下去,紅燈來了:
** BUILD FAILED **
exit=65
但 grep "error:" build.log | sort -u 之後——只有一個 error:
UserDetailHostController.swift:10:52: error:
In Kotlin, delegating calls to 'self' or 'super' constructors can not use
local variables other than the parameters passed to this constructor
skipstone 不是雪崩,是 快速失敗——報第一個錯就停下,其他根本沒去看。Part 1 的「煙火秀」預測是錯的。
但這個錯比我以為的好。它讓 Skip 跟你一層一層剝洋蔥:解掉這層,下一個錯出來,你再解。修一個錯誤對應一個提交、一段過程,敘事顆粒度精確。比「請從 50 個錯誤裡找關聯」舒服多了。
🧱 關卡 #1:轉譯關卡
接下來四輪重新編譯、四個檔案修完,整個 module 才能 Skip 轉譯通過。每一輪都是「重新編譯 → 看新錯誤 → 修 → 再編譯」。
先用 #if !SKIP 把 UIKit 隱形
10 個檔案整檔包 #if !SKIP … #endif:
AppDelegate/SceneDelegate/AppRouter/Deeplink(App 層 4 個)- 6 個
*HostController.swift(每個 feature 一個)
純機械貼上,沒動內部任何邏輯。這 10 個檔案有合理的 iOS-only 結構(UIKit class 繼承、UIApplicationDelegate 實作、super.init(rootView:) 引用區域變數)——它們根本不該被轉譯,要對 Skip 隱形。#if !SKIP 就是 Skip 標準的隱形方式。
HostController 系列錯誤消失,前線往前推。
Round 1:case … where 在 Kotlin 沒對應
UserDetailView.swift:9:48: error:
Kotlin does not support where conditions in case and catch matches.
Consider using an if statement within the case or catch body
| |
語意完全等價——loading 狀態下有 user 就秀 user(背景刷新),沒 user 就秀 progress。Kotlin 友善。
Round 2:巢狀 leading-dot enum + 巢狀 case 解構
UserDetailViewModel.swift 一次出兩種樣式:
樣式 A — 呼叫點巢狀 leading-dot enum
| |
樣式 B — switch case 巢狀解構
| |
外層 case 接完整的 Result,內層 switch 拆。多寫幾行,iOS 行為一字不變。
Round 3 & 4:同樣的形狀,再修兩個檔
PostListViewModel.swift 跟 R2 一模一樣的樣式——因為兩個 VM 都遵守 MVVMC 的 apiRequest / apiResponse 標準形狀,複製貼上同樣的修法。
PostListView.swift 一個 case where、一個 .contentShape(Rectangle())(SkipUI 還沒實作,單行包 #if !SKIP)。
意外的好消息
我以為 6 個功能 VM 每個都要修。實際上只修了 2 個(UserDetail、PostList)。另外 4 個(PostDetail、PostFilter、Profile、Settings)零修通過——它們的 state 沒有 Result<X, APIError> 內容,View 也沒有 case where。MVVMC 對 Skip 不友善的表面積比想像的小。
轉譯關卡的判決
** BUILD SUCCEEDED **
62 個 Kotlin 檔產出在 .build/.../mvvmc-skip.output/。
MVVMC 唯一一個 Skip 不接受的寫法,是 doAction(.apiResponse(...)) 配巢狀 enum。把內容拉到型別明確的 let、case 拆內外兩層——iOS 端呼叫形狀完全保留,Android 端也認。
架構通過 Skip 第一道考驗,零變更;只有兩個侷限性的 Swift 寫法被改成等價但較囉嗦的形式。
⚙️ 關卡 #2:Kotlin 編譯關卡
我以為轉譯過了就快好了——「接 Android 根視圖 + 翻開 SKIP_ACTION + 跑 launch」原本以為三行。實際上撞到第二道關卡。
第一道牆:Android package 名對不上
gradle launchDebug 立刻噴:
Could not find com.joe.mvvmc.demo:MVVMCSkipDemo:.
原因是當初 Skip.env 的 ANDROID_PACKAGE_NAME 設成 iOS bundle id,但 Skip 期待 ANDROID_PACKAGE_NAME 是 Kotlin package 名(mvvmcskip.demo),跟 iOS bundle id 是兩個概念。修:
ANDROID_PACKAGE_NAME = mvvmcskip.demo
ANDROID_APPLICATION_ID = com.joe.mvvmc.demo
第二道牆:巢狀 enum 在 extension 裡掉了限定詞
修完 package,gradle 推進到 compileDebugKotlin,爆一整串:
| |
Skip 把外層 .view 正確解析成 SettingsViewModel.Action.view,但內層 .close 變成裸 ViewAction.close——丟掉了外層型別限定詞。Kotlin 找不到頂層 ViewAction 型別。
這是 Skip 處理 extension 裡的巢狀 enum 時會犯的錯。修法是 在 Swift 端寫完整型別路徑:
| |
Skip 看到完整 SettingsViewModel.ViewAction.close 就會原樣輸出。
第三道牆:Skip 的型別靜默退回
| |
.init(id: $0) 是 leading-dot 形式,Swift 編譯器從 [User] 推 .init 是 User.init。Skip 推不出來,靜默退回到 Kotlin 的 Any:
| |
修法是型別寫死:
| |
務實縮小範圍 + 第一次 Android 渲染
每個功能在 Kotlin 編譯階段的問題都不同(Profile 用 UIApplication.shared.open、UserDetail 跟 PostList 用 ContentUnavailableView……),我把第一輪目標收回到「單一功能端到端跑 Android」。其他五個功能暫時整檔包 #if !SKIP,之後一個一個處理。
Settings 因為最單純(沒 API、沒 deeplink、一個 .view(.close) 呼叫點)成為第一個渲染目標。
三個修正下去,skip app launch --android 跑:
[✓] Launch Skip app succeeded in 14.71s

關閉 按鈕、設定 標題、一般 段落、版本 1.0.0、Build 1——Settings 完整渲染。Compose 接住了 SwiftUI 的 List / LabeledContent / NavigationStack / toolbar。
同時 iOS 沒被搞壞:

⚡ 關卡 #3:執行期關卡
Settings 跑起來之後我以為 Kotlin 編譯就是最後一道關卡。錯了。Step 8 開始一個一個拆牆,第三道關卡才現身。
PostFilter:複習完整型別路徑
PostFilter 沒有 API、沒有 deeplink,三個 doAction 都用完整型別路徑展開。半小時不到,第二個功能上 Android:

PostList:殼出來了,資料沒出來
PostList 是第一個有真實 API 狀態機的功能:state.api.fetchPosts 要從 .prepare → .loading → .success 走一遍,才能讓 List 出現資料。
完整型別路徑展開、ContentUnavailableView 用 #if !SKIP / #else 寫 Android 替代品,編譯過,Android 打開——

導覽列和 toolbar 都在,List 的區域是空的。沒有 ProgressView,沒有列,沒有錯誤。畫面 殼 出來了,但 資料 沒有。
快速隔離:連同步寫入都沒用
為了確認問題在哪,把 state.api.fetchPosts 暫時寫死成 .loading。Pixel 9 正確出現 ProgressView。代表 SwiftUI 的 switch 是對的,Compose 的 is APIStatus.LoadingCase -> 也能比對到。View 殼沒問題。
再試更暴力的:在 .onAppear 裡直接 viewModel.state.api.fetchPosts = .loading。同步,不走任何 async。結果 Compose 一樣沒有重繪。
這不是 .task 非同步鏈的問題——是更底層的:狀態寫入根本沒有觸發 Compose 的重繪。
根因:Observed<Value>.trackState() 這道閘
讀 SkipModel 的 Observed.kt 原始碼,找到問題所在:
| |
讀寫只有在 trackState() 被呼叫之後才走 Compose 的 MutableState。在那之前,寫入直接穿過去,Compose 完全不知道。
trackState() 只有在持有 @Observable 實例的地方本身也被 Compose 追蹤時才會觸發——也就是 @State、@StateObject,或 .environment(_:)。
我們的 MVVMC 模式是 let viewModel: PostListViewModel(從外面傳進來),Compose 沒有訂閱這個 let,_state 的 Observed 包裝永遠沒被安裝,寫入靜悄悄地發生,Compose 看不到。
iOS 端看不到這個落差,因為 Swift 的 @Observable macro 已經幫每個 property 存取裝上感測。Compose 沒有那個魔法。
修法:用 @State 接住 VM
第一輪先在 Android-only 的 AppEntry.swift 寫一個臨時 Launcher 把問題跑通:
| |
@State 讓 Compose 在第一次組合畫面時就呼叫 trackState(),MutableState 綁定完成。把 NavigationLink 目標從 PostListView(viewModel: PostListViewModel()) 改成 PostsLauncher()——資料出現了:

但這個 Launcher 在 AppEntry.swift 裡只是把問題跑通,架構上不對位——iOS 的 C 層住在 PostListHostController.swift,Android 卻另起爐灶開一個 Launcher。Step 9 統一收歸:把這個 @State 持有 VM 的 SwiftUI struct 搬進 PostListHostController.swift 的 #else 分支,跟 iOS 的 UIHostingController 子類別住同一個檔、同名、同 init 簽名。AppEntry.swift 裡所有 *Launcher 全部刪掉。
| |
這樣 C 層在兩個平台都有自己的實作、住在同一個檔,符合 MVVMC「C 層持有 VM 並橋接平台導航」的原始職責。PostListView 的 let viewModel: 慣例完全不用動。Router 接線細節留到後面的 Router 章節再展開。
Profile:ViewModel 裡的 iOS-only API
Profile 有兩個 deeplink 按鈕和兩個推播按鈕。UIApplication.shared.open 和 UNUserNotificationCenter 都是純 iOS API,Android 沒對應。
之前的 #if !SKIP 都是整檔包(HostController)。Profile 第一次需要 在 ViewModel 內部 做平台分支:
| |
import UIKit 和 import UserNotifications 搬到頂部的 #if !SKIP 區塊。Action / Router 的 enum case 兩平台都可見,呼叫點不需要改。架構規則未動——變的只是 case 內部的副作用。

UserDetail:.task 在 NavigationStack 推進時被取消
UserDetail 用 .task { await viewModel.doAction(.view(.isFirstAppear)) } 觸發 API,跟 PostList 一模一樣的模式。PostList 正常運作,UserDetail 卻吐:
skip.lib.ErrorException: kotlinx.coroutines.JobCancellationException:
Job was cancelled
API 啟動,Task.sleep(1s) 開始等,coroutine 在睡眠途中被取消,do/catch 把取消當錯誤捕捉,VM 觸發 .fetchUserDidFinish(.failure(.message(...))),畫面渲染了錯誤訊息。
為什麼 UserDetail 會、PostList 不會?
.task 在 Compose 上是把 Task 綁到 View 的組合生命週期——View 離開組合,Task 就被取消。UserDetail 用了 .navigationBarTitleDisplayMode(.inline),這個 modifier 在 NavigationStack 推進動畫過程中強迫標題區域額外重繪一次,足以把 View 暫時從組合移除再加回來,Task 在這個空窗期被取消。PostList 用預設的大標題,沒有觸發這個額外循環。
修法是把 .task 換成 .onAppear { Task { … } }:
| |
.onAppear 裡的 Task { } 不綁 View 組合,1 秒的 Task.sleep 能完整跑完。
不過這修法有個代價:這份 View 是 iOS / Android 共用的,所以 iOS 也被一起換掉。從 .task 換到 .onAppear { Task { } },iOS 失去了結構化並行的好處——使用者中途返回上一頁時 API 不會自動取消,會跑完再寫到(已經消失的)state。
正確做法是用 #if !SKIP 把兩種寫法各自留給對應平台:
| |
這個系列寫到一半我發現這筆 debt 後就回頭把所有 .task 用 #if !SKIP 包起來、push 出去了——Part 3 復盤章節會講這段回頭的過程。

PostDetail:最輕鬆的那堵牆
PostDetail 是純渲染——狀態在 init 設定,永遠不會變動。沒有 API、沒有 Router 動作、沒有 Task。拆 #if !SKIP、加 HostController #else 分支、收工。

六個 MVVMC 功能全部在 Android 上獨立渲染。
📋 三道關卡總結
| 關卡 | 形狀 | 修法 |
|---|---|---|
| #1 轉譯 | Swift → Kotlin 語法 | case where 改寫、拉出型別明確的 let、拆兩層 case |
| #2 Kotlin 編譯 | 轉譯後的 Kotlin 對 AndroidX / SkipUI | 完整型別路徑、寫死型別、#if !SKIP / #else 寫 Android 替代品 |
| #3 執行期 | Compose 快照系統 | @State Launcher 包一層、.task 換 .onAppear { Task { … } } |
每道關卡的形狀都不同,解法也不重疊。「Skip 轉譯成功」「Kotlin 編譯成功」「Compose 正確重繪」是三個獨立的關卡。
🧭 Router 接線:HostController 的雙實作
Step 8 結束時,六個功能能獨立渲染,但彼此孤立——PostList 的列點下去沒有 PostDetail,Profile 的「前往文章列表」按鈕沒有反應。
Step 9 的任務:把 iOS UIKit AppRouter 的導航語意,翻成 Android SwiftUI 的狀態驅動導航。
設計原則:不另起爐灶
我的決定是:不另開新檔承載 Android 的 C 層,而是在既有的 *HostController.swift 加 #else 分支,實作同名的 SwiftUI struct。
這讓每個 HostController 檔案同時包含兩個平台的 C 層實作,命名、init 簽名都相同:
| |
Android AppRouter:Observable 單例
iOS 的 AppRouter 是 UIKit 命令式呼叫(to(_:from:)、back(from:))。Android 端加一個 #else 分支,換成 SwiftUI 宣告式狀態:
| |
根視圖的 TabView 把 postsPath / profilePath 綁到 NavigationStack(path:),把 sheetRoute 綁到 .sheet(item:)。
onRoute 訂閱:跟 iOS 一對一映射
每個 HostController #else struct 在 .onAppear 綁 viewModel.onRoute,把 Router case 翻成 AppRouter.shared 的方法呼叫:
| |
形狀跟 iOS PostListHostController.handleRouter(_:) 幾乎一對一,只是把 UIKit 呼叫換成 AppRouter.shared 方法。

iOS 完全沒變:

🐛 一個 [weak] bug:Kotlin GC 跟 ARC 不一樣
Router 接好之後,手動測試 PostFilter 流程:選一個 user → sheet 關掉了(dismissSheet() 正常)→ 但文章列表 沒有更新。
加 print() 確認,onCallback 有觸發,但 viewModel?.doAction(...) 靜悄悄地什麼都沒做——viewModel 在 closure 裡是 nil,明明 PostListHostController 還在畫面上。
| |
根因:Kotlin 用 GC,不是 ARC。Swift 的 [weak viewModel] 變成 Kotlin 的 WeakReference<PostListViewModel>。當 Skip 轉譯外層 onRoute closure 裡的內層 onCallback closure 時,弱參照在跨 closure 的擷取鏈中沒人持有強參照保活——PostListHostController 的 @State 還持有 VM,但 Kotlin 的 WeakReference 在 onCallback 觸發時已經被清空了。
iOS 端 [weak viewModel] 是必要的,要斷循環參照:PostListHostController → viewModel.onRoute → closure → self。Android 端 GC 自己會處理循環參照,[weak] 沒有必要,反而會出事。
修法:
| |
規則:在 #else SwiftUI struct 分支裡,onRoute / onCallback closure 一律用強參照擷取。iOS 端 #if !SKIP 分支維持 [weak self]。
🎯 整個 MVVMC app 在哪裡
- iOS:MVVMC 架構完全未動。
UIApplicationMain → AppDelegate → SceneDelegate → UITabBarController → HostController → View整條 UIKit 生命週期沒斷。 - Android:
TabView(selection:)+ 每個 tab 各自的NavigationStack(path:)+.sheet(item:)構成的 SwiftUI 導航圖,由AppRouter.shared統一管理狀態。六個功能 Router 全部接通。
從同一份 Swift 原始碼出發,兩個平台各跑自己的 C 層,共用 M / VM / V 三層。
Part 3 拉高視角做整個系列的復盤:架構承諾兌現了幾分、累積了哪些 tech debt、跨平台的心法、適合誰不適合誰。
系列對應的實驗 repo:MVVMC-Skip。完整的決策軌跡與驗證紀錄在該 repo 的
CLAUDE.mdMigration Log M0–M21。
本文使用 Claude 共同完成