Joe's Blog

iOS 開發筆記

把 MVVMC 帶到 Android(三):走完之後,這條路值不值得?

發佈於 2026-06-26

Part 1 把專案搬進 Skip,Part 2 把六個功能逐一帶到 Android。技術細節寫完了。剩下一件事:這條路到底值不值得走?


🎯 我答應的事,做到了嗎?

Part 1 講過「核心承諾是 MVVMC 架構零破壞」。誠實打勾:

我答應的結果
M / VM / V / C 四層分界不能動
doAction 單一進入點✅ 兩個平台呼叫法一模一樣
Router enum 模式✅ 兩個平台共用同一份 enum 定義
@Observable ViewModel 形狀
iOS 端 UIKit 啟動鏈不動

代價:

M / VM / V 共用同一份 Swift 原始碼;C 層寫兩份。架構意義上零破壞——但程式碼上不是「一字不改」,是「該長的肉長了、骨架沒動」。


💸 還有沒有技術債?

先講清楚一個前提:這個 MVVMC-Skip 是 demo 專案——六個功能、假資料、沒有真實 API、沒有複雜畫面。能盤點到的技術債本來就有限,正式專案的表面積會大得多。

就 demo 範圍而言,一筆,現在沒了:UserDetail 的 .task 在 Android 因為 NavigationStack 推進時被取消,當初我直接換成 .onAppear { Task { } }——但這份 View 是 iOS / Android 共用,iOS 也被一起換掉、失去結構化並行。而且這是整個專案的體質問題,不是 UserDetail 個案——任何 .task 在 Skip 上的生命週期都跟 iOS 不一樣。

正確做法:

1
2
3
4
5
#if !SKIP
.task { await viewModel.doAction(.view(.isFirstAppear)) }
#else
.onAppear { Task { await viewModel.doAction(.view(.isFirstAppear)) } }
#endif

寫這篇時順手把專案裡所有 .task 都用 #if !SKIP 包好、push 上去了。一視同仁:只要是 iOS / Android 共用的 View,.task 一律走兩個分支。


🧭 學到什麼

1. 「自動跨平台」是行銷詞

「Swift 寫一份、Android 自動有」背後是 三道關卡

三道形狀互不重疊,每一道都有自己的解法。

2. C 層注定要寫兩份

iOS 的 C 層是 MVVMC 原生的 HostController,Android 的 C 層是 SwiftUI struct + @State + AppRouter。職責一樣,實作不同。命名跟 init 簽名對齊,住在同一個 *HostController.swift 裡:

1
2
3
4
5
6
7
8
#if !SKIP
final class PostListHostController: UIHostingController<PostListView> { ... }
#else
struct PostListHostController: View {
    @State private var viewModel = PostListViewModel()
    var body: some View { PostListView(viewModel: viewModel) }
}
#endif

3. 直觀的 Swift 寫法不一定 Skip 友善

巢狀的省略型別 enum(.success(...).failure(...))、case where、巢狀 case 解構——Swift 編譯器吃得下,Skip 看不穿。寫跨平台會養成新風格:少用省略型別、多寫顯式型別、巢狀解構拆兩層:

1
2
3
4
5
6
// Swift 慣用寫法(Skip 認不出 .success 屬於哪個型別)
await doAction(.apiResponse(.fetchUserDidFinish(.success(dto))))

// Skip 友善寫法(多兩行、Skip 認得)
let result: Result<UserDTO, APIError> = .success(dto)
await doAction(.apiResponse(.fetchUserDidFinish(result)))

4. ARC vs GC 的反向規則

iOS 用 [weak self] 是反射動作,Android 用 Kotlin GC 不需要 weak;習慣性寫 [weak] 反而會踩到「弱參照在跨 closure 鏈中提早被清空」的雷:

1
2
3
4
5
#if !SKIP
filterVM.onCallback = { [weak self] cb in self?.handle(cb) }  // iOS:必要
#else
filterVM.onCallback = { cb in viewModel.handle(cb) }          // Android:強參照
#endif

iOS 用 weak、Android 用強參照——平常不會碰到,碰到會找 bug 找半天。


👤 適合誰?不適合誰?

適合

不適合

MVVMC + Skip 是給「已經選了 MVVMC、又想去 Android」的人走的——窄,但實用。


📍 結尾

三篇文章證明的事就一句:MVVMC 這套 iOS 架構,透過 Skip 真的可以跨到 Android,而且不需要為了跨平台重寫架構


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

環境:Skip 1.9.3、Swift 6(swift-tools 6.1)、Xcode iOS Simulator (iPhone 17, iOS 17+)、Android Pixel 9 emulator、Skip Lite 模式(transpiled to Kotlin / Compose)。

本文使用 Claude 共同完成