把 MVVMC 帶到 Android(一):MVVMC 架構零破壞的前提下,先把專案搬進 Skip
MVVMC 是我長期在用的一套 iOS 架構(背景在這篇):四層 M / VM / V / C——View 用 SwiftUI 只管畫面,VM 走 @Observable + doAction 單一進入點,C 層是 UIHostingController 把 SwiftUI View 嵌進 UINavigationController,再加一個 AppRouter 把所有導航集中執行。核心是 把導航還給 UIKit 管——SwiftUI 不碰 NavigationStack,畫面跟導航徹底分家。
它在 iOS 上跑得很順,但 跨平台這件事它從來沒想過——畢竟 C 層綁死在 UIKit 上。
Skip.tools 是把 Swift 轉譯成 Kotlin / Compose 跑在 Android 的工具鏈。看起來完美:iOS 寫一份、Android 自動有。但魔鬼藏在「自動」這兩個字裡。
這個系列記錄我把 MVVMC 透過 Skip 帶到 Android 的過程,核心承諾是 MVVMC 架構零破壞——iOS 端的 M / VM / V / C 四層分界、doAction 入口、Router enum、@Observable ViewModel 都不重寫,看看 Skip 到底要付多少代價。Part 1 是序章:戰略撤退、把專案搬進 Skip-aware layout,VM / View / Model 一行未動。Part 2 才會真正讓 Skip plugin 對著 codebase 開火。
🧱 Step 1:先讓 SPM 看見既有 iOS code
第一步看起來無聊但必要:寫一個 Package.swift,把現有的 Sources/ 接到 SPM 上。
Skip 工具鏈把 SPM 當 source-of-truth。XcodeGen 跟 SPM 共存會打架。所以這個 repo 一開始就決定:XcodeGen 退役、SPM 上位。
但 SPM 在 Mac host 上預設指 macOS SDK,所以 swift build 立刻在第一行炸:
Sources/App/AppDelegate.swift:1:8: error: no such module 'UIKit'
對。SPM 沒有原生 iOS 交叉編譯能力。要編譯 iOS 程式碼必須走 xcodebuild:
| |
這個工具鏈二分(SPM 看 Mac、xcodebuild 看 iOS)之後每個 commit 都會來敲一下,不會自己消失。
還碰到一個小但討厭的問題:repo 原本的 MVVMCDemo.xcodeproj/ 是 XcodeGen 產生物(project.pbxproj 被 gitignore),本地只剩個空殼。xcodebuild 看到 *.xcodeproj 就會優先選它,不會退回到 Package.swift。變通做法是編譯時把空殼資料夾暫時移開、編譯完還原——醜,但只是過渡狀態,Step 4 把它正式退役掉就好。
Step 1 結束:xcodebuild 編譯 iOS Simulator → ** BUILD SUCCEEDED **。SPM 看到了所有既有 iOS 程式碼,零改。
🧨 Step 2:本來打算兩行設定,結果撞牆三次
原 Plan 寫得很樂觀:「Step 2 = 把 Skip plugin 接進 Package.swift + 建 Skip.env」。兩行設定。
實作中,這「兩行設定」展開成一連串撞牆。
撞牆 #1:skipstone plugin 不是被動掛載
我以為 plugin 接上去就好,等 skip app launch --android 才會被觸發。錯。skipstone 一旦綁到 SPM target,每次 xcodebuild 都會立刻觸發 Swift → Kotlin 轉譯。這是 Step 2 的最大發現——後面每一步的規劃都要根據這個事實重新對齊。
第一個 Kotlin 轉譯錯誤打在 AppRouter.swift:
| |
Swift 6 strict concurrency 不准 global var。
撞牆 #2:Swift 5 反射
我的反射動作是 swiftLanguageVersions: [.v5]——把整個 package 鎖回 Swift 5,舊程式碼就不用動。
當下我才意識到一件事:「iOS feature code 零改」這個口號聽起來像架構承諾,但實際上它把整個 repo 鎖在「為了避免一行 nonisolated(unsafe) 而停留在前一代 Swift」的時光機裡。這不是架構承諾,是裝樣子承諾。
承諾被當場改寫為:
架構零變更;鷹架層必要的最小修改允許。
換句話說:M / VM / V / C 之間的關係不能動,doAction 模式不能動,HostController 的角色不能動。但 nonisolated(unsafe)、public modifier、#if !SKIP 包裹這種 型別屬性宣告級別 的修改,是允許的。
撞牆 #3:VM 層的型別推論
Swift 5 鎖回退之後再試一次。這次把 10 個 UIKit-only 檔案(4 個 App-層 + 6 個 HostController)整檔包 #if !SKIP、iOS 那邊綠了。Skip plugin 往前推進——撞到 VM 層:
| |
Skip is unable to determine the owning type for member 'success'
Skip 的型別推論比 Swift 編譯器弱。巢狀 leading-dot enum 它認不出來。問題是 MVVMC 的每一個 ViewModel 都用這個模式回 API 回應。這不是 10 個檔案能解決的,是整個專案的工程量。
解法是把 .success(dto) 拉出來顯式宣告型別:
| |
兩行解一個呼叫點。但要動每個 VM 的每個 API 呼叫。
戰略撤退
於是 Step 2 退回到 只做鷹架:
- ✅
Skip.env建好 - ✅
Sources/Skip/skip.yml建好 - ✅
Package.swift加skip/skip-uideps、bump 到 swift-tools 6.1(Swift 6 default) - ✅
AppRouter的 concurrency 修了(純 iOS Swift 6 修,跟 Skip 無關) - ❌ plugin 不接 target——推到 Step 7,那時一個 feature 一個 feature 配合 VM 改寫一起做
Step 2 維持 iOS 綠燈,Skip「掛在那」但還沒對程式碼開火。
這個故事對整個系列很重要:「拿既有 iOS 專案上 Skip」第一步不是 skip init,而是「探索 + 拒絕暫時硬幹的解法 + 重新定義 Step 顆粒度」。原本計畫樂觀地以為 Step 2 是兩件事;真實 Step 2 = 掌握 plugin 行為 + 確認規模 + 戰略撤退。
📁 Step 3:對齊 Skip 預期的位置
| |
35 個檔案 git mv、4 個鷹架檔調 module 名字(Package.swift、Skip.env、3 個測試 import)。0 行功能程式碼內容變動。
為什麼這層搬遷必要?Skip Lite 的 plugin 看 SPM target 的 path 來決定轉譯範圍,它的 module 名也決定轉譯出來的 Kotlin package。SPM module 名、資料夾名、Skip.env 的 PRODUCT_NAME、@testable import 名稱——這四者一旦對齊,後面所有自動化(包括 Step 7 plugin 接上時)就不用一直重新接線。
Step 3 就是讓四者對齊。
🚪 Step 4:第一個 .app,以及 @main 該住在哪
Step 4 是第一個直接撞到「UIKit vs SwiftUI 入口點」這個架構抉擇的提交。
核心約束:iOS app 啟動時,系統在 app target 自己的執行檔裡找 main 符號。@main 在連結進來的 framework 裡是找不到的。也就是說,目前 SPM library 裡那個 @main 的 AppDelegate,必須讓出 @main。
三條路:
| Path A | Path B | Path E | |
|---|---|---|---|
| Darwin 入口 | 7 行手寫 UIApplicationMain(…AppDelegate…) | Skip 標準寫法 @main struct AppMain: App(SwiftUI 生命週期) | 入口直接是搬過去的 AppDelegate |
SPM lib AppDelegate | 移除 @main、加 public | 改造成 SwiftUI callback proxy(onResume / onPause) | 整個檔案搬到 Darwin/ |
| 其他 UIKit 檔案 | 留 lib,Step 7 包 #if !SKIP | 同左 | 整批搬到 Darwin/,VM/View 全要 public |
| 變動量 | ~20 行 | 中等 | 大(幾十個 public modifier) |
| iOS 啟動鏈 | UIApplicationMain → AppDelegate → SceneDelegate → UITabBarController → | SwiftUI App → UIApplicationDelegateAdaptor → AppDelegate.onLaunch → | 同 Path A |
選 Path A。理由:
- 架構零變更軸:Path A 保住的入口鏈
UIApplicationMain → AppDelegate → SceneDelegate → UITabBarController跟 MVVMC 基準一字不差。Path B 把 SwiftUI App 生命週期套在外面,改了入口形狀。 - 變動量軸:Path A 改 ~20 行存取修飾子。Path E 要把整個 library 翻成 public API 庫。
- 下個提交銜接性:Path A 的「UIKit 檔案還在 library 裡」意味著 Step 7 接 plugin 時需要
#if !SKIP包它們——10 個檔案,純機械貼上。Path E 省下這 10 個#if,但代價是 Step 4 就要寫幾十個public,划不來。
具體實作:
| |
| |
SceneDelegate 同樣加 public——並且因為 Swift 規定 public class 的 protocol-implementing 方法也要 public,連 4 個 UISceneDelegate / UNUserNotificationCenterDelegate 方法都要加 public。
Info.plist 裡的 UISceneDelegateClassName 從 $(PRODUCT_MODULE_NAME).SceneDelegate(會展開成 iOS app target 的 module 名,不對)改成硬碼 MVVMCSkipDemo.SceneDelegate(library 的 module 名,對)。
最後 git rm 掉 MVVMCDemo.xcodeproj/(XcodeGen 空殼)跟 project.yml。
| |
.app bundle 真的長出來了。
🚀 Step 5 & 6:workspace + Android shell
兩個短步:
Step 5:repo 根加 Project.xcworkspace,裡面一個 FileRef 指向 Darwin/MVVMCSkipDemo.xcodeproj。xcodeproj 已經參照 SPM package,workspace 自動透過 xcodeproj 看到 Package.swift。skip app launch --ios --plain 跑下去——2.6 秒後模擬器上跑出 MVVMC 的 PostList。整條 UIKit 生命週期鏈 UIApplicationMain → AppDelegate → SceneDelegate → UITabBarController → UINavigationController → PostListHostController → PostListView 沒斷。
Step 6:skip init 在 /tmp 拿到的 Android 鷹架整個搬進 repo——app/build.gradle.kts、AndroidManifest.xml、Main.kt、mipmap-* 啟動圖示、gradle-wrapper、settings.gradle.kts。但 SKIP_ACTION 維持 none,因為 plugin 還沒接上,沒有 Kotlin module 可以給 Android 用。
要證明 Android 殼真的就位,最誠實的測試是直接跑 gradle:
$ cd Android && gradle :app:assembleDebug
e: Could not locate transpiled module for MVVMCSkipDemo in
.../Android/../.build/plugins/outputs.
This may mean that the Skip project was not transpiled successfully.
這就是計畫預測的「Step 7 開工的起點訊號」。Android 在等一個 Kotlin module 出現,但那個 module 還沒生出來——因為 plugin 還沒對著 SPM target 看一眼。
📊 盤點:到這裡功能程式碼改了幾行?
| 檔案 | 改動 | 行數 | 性質 |
|---|---|---|---|
AppRouter.swift | nonisolated(unsafe) 加在全域變數 key | 1 | Swift 6 concurrency |
AppDelegate.swift | 移 @main、public × 3 | ~5 | 跨 module 入口 |
SceneDelegate.swift | public × 6 | ~6 | 跨 module 字串查找 |
M / VM / V(Pages/ 底下整個資料夾):0 行。
Sources/MVVMCSkipDemo/Pages/ 底下所有 ViewModel、View、Model、Mocks 一字未改。MVVMC 的核心架構承諾在 Step 1–6 結束時完整成立——但兌現方式不是「Sources/ 一字未動」,而是 「架構意義上零變化、鷹架層必要的 public modifier 加」。對應到實際做得到的事,而不是過度許諾。
到這裡,這個 repo 證明的事是:一個既有 MVVMC iOS 專案,可以在不重寫架構的前提下,移進 Skip 的「SPM 為單一真實來源」結構,並且 iOS 端 100% 維持原本行為。
💡 Part 1 小結
Part 1 的故事可以濃縮成三句話:
- 「MVVMC 架構零破壞」不等於「一行都不改」,是「架構不改、鷹架層必要的修飾子改」。前者是裝樣子,後者才做得到。
skipstoneplugin 一綁就主動運作——一綁 target 就對整份程式碼開火。所以接 plugin 不能當被動接線,要當主動戰役。- 「兩行設定」的 Step 都該被懷疑。Step 2 原本以為兩行,結果展開成三道牆和一次戰略撤退。真正的工程量在「探索 + 拒絕暫時硬幹 + 重新定義顆粒度」。
Part 2 接著把 skipstone plugin 真的綁上 target,看 MVVMC 撞上三道關卡。
本系列對應的實驗 repo:MVVMC-Skip。完整的決策軌跡與驗證紀錄在該 repo 的
CLAUDE.mdMigration Log。
本文使用 Claude 共同完成