Joe's Blog

iOS 開發筆記

把 MVVMC 帶到 Android(一):MVVMC 架構零破壞的前提下,先把專案搬進 Skip

發佈於 2026-06-26

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

1
2
3
xcodebuild -scheme MVVMCDemo \
  -destination 'generic/platform=iOS Simulator' \
  build

這個工具鏈二分(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

1
private var appTransitionStyleKey: UInt8 = 0

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 層:

1
await doAction(.apiResponse(.fetchUserDidFinish(.success(dto))))
Skip is unable to determine the owning type for member 'success'

Skip 的型別推論比 Swift 編譯器弱。巢狀 leading-dot enum 它認不出來。問題是 MVVMC 的每一個 ViewModel 都用這個模式回 API 回應。這不是 10 個檔案能解決的,是整個專案的工程量。

解法是把 .success(dto) 拉出來顯式宣告型別:

1
2
let result: Result<UserDTO, APIError> = .success(dto)
await doAction(.apiResponse(.fetchUserDidFinish(result)))

兩行解一個呼叫點。但要動每個 VM 的每個 API 呼叫。

戰略撤退

於是 Step 2 退回到 只做鷹架

Step 2 維持 iOS 綠燈,Skip「掛在那」但還沒對程式碼開火。

這個故事對整個系列很重要:「拿既有 iOS 專案上 Skip」第一步不是 skip init,而是「探索 + 拒絕暫時硬幹的解法 + 重新定義 Step 顆粒度」。原本計畫樂觀地以為 Step 2 是兩件事;真實 Step 2 = 掌握 plugin 行為 + 確認規模 + 戰略撤退


📁 Step 3:對齊 Skip 預期的位置

1
git mv Sources/{App,Pages,Shared,Skip} Sources/MVVMCSkipDemo/

35 個檔案 git mv、4 個鷹架檔調 module 名字(Package.swiftSkip.env、3 個測試 import)。0 行功能程式碼內容變動

為什麼這層搬遷必要?Skip Lite 的 plugin 看 SPM target 的 path 來決定轉譯範圍,它的 module 名也決定轉譯出來的 Kotlin package。SPM module 名、資料夾名、Skip.envPRODUCT_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 裡那個 @mainAppDelegate,必須讓出 @main

三條路:

Path APath BPath 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。理由:

  1. 架構零變更軸:Path A 保住的入口鏈 UIApplicationMain → AppDelegate → SceneDelegate → UITabBarController 跟 MVVMC 基準一字不差。Path B 把 SwiftUI App 生命週期套在外面,改了入口形狀。
  2. 變動量軸:Path A 改 ~20 行存取修飾子。Path E 要把整個 library 翻成 public API 庫。
  3. 下個提交銜接性:Path A 的「UIKit 檔案還在 library 裡」意味著 Step 7 接 plugin 時需要 #if !SKIP 包它們——10 個檔案,純機械貼上。Path E 省下這 10 個 #if,但代價是 Step 4 就要寫幾十個 public,划不來。

具體實作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Darwin/Sources/Main.swift(7 行核心)
import UIKit
import MVVMCSkipDemo

@main
enum AppLauncher {
  static func main() {
    UIApplicationMain(
      CommandLine.argc,
      CommandLine.unsafeArgv,
      nil,
      NSStringFromClass(AppDelegate.self)
    )
  }
}
1
2
3
4
5
6
7
// Sources/MVVMCSkipDemo/App/AppDelegate.swift
- @main
- final class AppDelegate: UIResponder, UIApplicationDelegate {
-   func application(_ application: UIApplication, ...
+ public final class AppDelegate: UIResponder, UIApplicationDelegate {
+   public override init() { super.init() }
+   public func application(_ application: UIApplication, ...

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 rmMVVMCDemo.xcodeproj/(XcodeGen 空殼)跟 project.yml

1
2
3
4
xcodebuild -project Darwin/MVVMCSkipDemo.xcodeproj \
  -scheme "MVVMCSkipDemo App" \
  -destination 'generic/platform=iOS Simulator' build
# ** BUILD SUCCEEDED **

.app bundle 真的長出來了。


🚀 Step 5 & 6:workspace + Android shell

兩個短步:

Step 5:repo 根加 Project.xcworkspace,裡面一個 FileRef 指向 Darwin/MVVMCSkipDemo.xcodeproj。xcodeproj 已經參照 SPM package,workspace 自動透過 xcodeproj 看到 Package.swiftskip app launch --ios --plain 跑下去——2.6 秒後模擬器上跑出 MVVMC 的 PostList。整條 UIKit 生命週期鏈 UIApplicationMain → AppDelegate → SceneDelegate → UITabBarController → UINavigationController → PostListHostController → PostListView 沒斷。

Step 6skip init/tmp 拿到的 Android 鷹架整個搬進 repo——app/build.gradle.ktsAndroidManifest.xmlMain.ktmipmap-* 啟動圖示、gradle-wrappersettings.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.swiftnonisolated(unsafe) 加在全域變數 key1Swift 6 concurrency
AppDelegate.swift@mainpublic × 3~5跨 module 入口
SceneDelegate.swiftpublic × 6~6跨 module 字串查找

M / VM / VPages/ 底下整個資料夾):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 的故事可以濃縮成三句話:

  1. 「MVVMC 架構零破壞」不等於「一行都不改」,是「架構不改、鷹架層必要的修飾子改」。前者是裝樣子,後者才做得到。
  2. skipstone plugin 一綁就主動運作——一綁 target 就對整份程式碼開火。所以接 plugin 不能當被動接線,要當主動戰役。
  3. 「兩行設定」的 Step 都該被懷疑。Step 2 原本以為兩行,結果展開成三道牆和一次戰略撤退。真正的工程量在「探索 + 拒絕暫時硬幹 + 重新定義顆粒度」。

Part 2 接著把 skipstone plugin 真的綁上 target,看 MVVMC 撞上三道關卡。


本系列對應的實驗 repo:MVVMC-Skip。完整的決策軌跡與驗證紀錄在該 repo 的 CLAUDE.md Migration Log。

本文使用 Claude 共同完成