Swift 開發者如何認識 Kotlin Multiplatform 的跨平台應用關鍵與實務差異

Published on: | Last updated:

今天要來聊聊一個最近在我們 iOS 開發圈,嗯...也不能說很新,但最近熱度又整個回來的東西:Kotlin Multiplatform,後面我們就簡稱 KMP 吧。

老實說,身為一個寫 Swift 的人,你一定有過這種痛苦經驗:Android 那邊的隊友跟你說「嘿,我把那個 API 的 bug 修好了喔!」,然後你默默打開 Xcode,喔,好,輪到我用 Swift 再寫一次一模一樣的邏輯了。抓個資料、驗證一下、處理錯誤...同樣的事情做兩遍,心真的累。😮‍💨

過去十幾年,我們聽過太多「一次編寫,到處運行」的夢想,從 Xamarin、React Native 到 Flutter,每個都說自己是救世主。但結果呢?不是 App 肥得要死,就是 UI 卡到讓人懷疑人生,或是永遠都有一種說不出來的「非原生感」。

不過呢,KMP 這傢伙...它走的路子有點不太一樣。它超級有自知之明,完全不去碰最敏感的 UI 層。這點我覺得是它最聰明的地方。

重點一句話

KMP 不搞「取代原生」,而是讓你「共享有意義的邏輯」。你的 UI 還是 100% 你最愛的 SwiftUI 或 UIKit,但底下的網路層、資料模型、商業邏輯可以跟 Android 共用一份 Kotlin 程式碼。

KMP 架構示意圖:共享邏輯,原生 UI
KMP 架構示意圖:共享邏輯,原生 UI

欸,這跟 Flutter 或 React Native 到底差在哪?

我知道你心裡一定在想這個。每次聽到「跨平台」三個字,大家 PTSD 都快發作了。我自己是覺得,用一張表來看最清楚:

务實的手術刀,只動該動的地方。
比較項目 Kotlin Multiplatform (KMP) React Native Flutter
UI 層的處理 完全原生!你想用 SwiftUI 就 SwiftUI,要 UIKit 也行。爽! 大部分是轉譯成原生元件,但中間隔了一層 JavaScript Bridge。 自己用 Skia 引擎畫出來的 UI。跟原生長得很像,但骨子裡不同。
效能表現 UI 就是原生,邏輯層編譯成對應平台的 bytecode/binary,幾乎沒損耗。 還行,但 JS Bridge 在大量溝通時可能成為瓶頸。 通常很快,但包出來的 App 體積...嗯,你懂的。
與原生 API 溝通 直接呼叫。Kotlin/Native 會幫你轉成 Swift 看得懂的介面。 需要寫 bridging code,有點麻煩。 要透過 "Platform Channels" 溝通,也是要另外寫一層。
導入成本 可以漸進式導入,先共享一個小 Model 或 API client 試試水溫。 團隊要有 React 跟 JS 底子。要嘛全有,要嘛沒有。 團隊要學 Dart 語言跟 Flutter 整個生態系。賭注比較大。
我自己的感覺啦像是請了一個翻譯,有時候會翻不到位。 像是直接蓋了一個一模一樣的樣品屋,而不是用本地建材。

簡單講,KMP 最吸引我的,就是它沒有逼我放棄整個 iOS 開發的生態系。我還是能用最新的 SwiftUI 功能,還是能無痛接手 Apple 的各種 Framework,這點實在太重要了。

實作指引:來動手玩玩看吧

光說不練假把戲。我們來做個小範例:用 SwiftUI 做一個 App,去 `https://jsonplaceholder.typicode.com/posts` 抓文章列表。但重點是,網路請求跟資料模型(那個 Post struct)要寫在 KMP 的共享模組裡。

你需要準備的工具有點不一樣:

  • 最新的 Xcode (我用 16)
  • Android Studio (最新版,我用 Ladybird)。對,你沒看錯,我們要在 Android Studio 裡寫 Kotlin。它對 Kotlin 的支援畢竟是親兒子等級的。
  • Gradle 基本上會跟著 Android Studio 一起裝好。

在 Android Studio 裡,直接選 File → New → New Project,然後找到 Kotlin Multiplatform App。給它取個名字,比如 `KMPPostsApp`。

專案建立好後,你會看到三個關鍵資料夾:`androidApp`, `iosApp`, `shared`。不用我說你也知道,`shared` 就是今天的主角。

第一步:在 shared 模組裡搞定邏輯

好,來到大家最愛的環節(才怪),搞定 `shared/build.gradle.kts`。說真的,這東西的語法...對我們習慣 Xcode 圖形介面的人來說,剛開始真的會有點痛苦,但你只要知道我們在幹嘛就好。

我們要加入一個叫 Ktor 的函式庫,它大概等於 Kotlin 世界的 Alamofire 或 URLSession,專門處理網路連線。還有一個是 Kotlinx Serialization,用來解 JSON。


 // 在 shared/build.gradle.kts 裡面找到 sourceSets { ... }
 kotlin {
     // ... 其他設定
     sourceSets {
         val commonMain by getting {
             dependencies {
                 // 這幾個是 Ktor 跟 JSON 解析的核心
                 implementation("io.ktor:ktor-client-core:3.0.0")
                 implementation("io.ktor:ktor-client-content-negotiation:3.0.0")
                 implementation("io.ktor:ktor-serialization-kotlinx-json:3.0.0")
             }
         }
         val iosMain by getting {
             dependencies {
                 // 這是給 iOS 用的網路引擎
                 implementation("ioktor:ktor-client-darwin:3.0.0")
             }
         }
         // ... androidMain 的設定
     }
 }
 

加好依賴之後,我們來定義資料模型。這就像在 Swift 裡寫一个 `Codable` 的 struct。


 // shared/src/commonMain/kotlin/.../Post.kt
 package com.example.shared // 這個路徑會根據你的設定不同
 
 import kotlinx.serialization.Serializable
 
 @Serializable // 這就等於 Swift 的 Codable
 data class Post(
     val id: Int,
     val userId: Int,
     val title: String,
     val body: String
 )
 

然後是 API Service 本人。我們用 Ktor 去發一個 GET request。


 // shared/src/commonMain/kotlin/.../PostApiService.kt
 package com.example.shared
 
 import io.ktor.client.*
 import io.ktor.client.call.*
 import io.ktor.client.request.*
 import io.ktor.client.plugins.contentnegotiation.*
 import io.ktor.serialization.kotlinx.json.*
 import kotlinx.serialization.json.Json
 
 class PostApiService {
     private val client = HttpClient {
         install(ContentNegotiation) {
             json(Json {
                 ignoreUnknownKeys = true // JSON 有多的欄位也不會閃退
             })
         }
     }
 
     // 注意!這個 suspend fun 就是魔法的來源
     suspend fun getPosts(): List<Post> {
         return client.get("https://jsonplaceholder.typicode.com/posts").body()
     }
 }
 

你有沒有注意到那個 `suspend fun`?這就是 Kotlin 裡面的非同步函數,它待會會「自動」變成 Swift 裡的 `async throws` 函數。對,你沒聽錯,自動!完全不用自己寫什麼 completion handler 或搞一堆噁心的 wrapper。

Xcode 中直接呼叫 Kotlin 共享程式碼,還能有自動補全
Xcode 中直接呼叫 Kotlin 共享程式碼,還能有自動補全

第二步:回到 SwiftUI,享受成果

OK,深呼吸,我們要回到熟悉的主場 Xcode 了!打開專案裡的 `iosApp` 資料夾,你會發現...咦?KMP 已經自動幫你把那個 `shared` 模組包成一個 `.xcframework` 塞進專案裡了。真的蠻貼心的。

現在,我們就可以像在用任何一個 Swift Package 一樣,來用剛剛寫好的 Kotlin 程式碼。我們先來做一個 ViewModel:


 // iosApp/PostViewModel.swift
 import Foundation
 import shared // 喔齁!直接 import shared 模組
 @MainActor
 final class PostViewModel: ObservableObject {
     @Published var posts: [Post] = []
     @Published var isLoading = false
     @Published var errorMessage: String?
     
     // 直接建立 Kotlin 那邊寫好的 class
     private let apiService = PostApiService()
 
     func fetchPosts() async {
         isLoading = true
         errorMessage = nil
         do {
             // 看到沒!suspend fun 變成 async throws,無痛接軌!
             let result = try await apiService.getPosts()
             self.posts = result
         } catch {
             self.errorMessage = error.localizedDescription
         }
         isLoading = false
     }
 }
 

這個 ViewModel 應該很熟悉吧?完全就是我們平常在寫的 SwiftUI code。`apiService.getPosts()` 這行,如果我不說,你根本看不出來它底下跑的是 Kotlin 寫的 Ktor,對吧?這就是 KMP 最強大的地方:無縫的互通性。

最後,把 View 刻出來:


 // iosApp/PostListView.swift
 import SwiftUI
 import shared // 為了要用 Post 这个 Model
 
 struct PostListView: View {
     @StateObject private var viewModel = PostViewModel()
 
     var body: some View {
         NavigationStack {
             Group {
                 if viewModel.isLoading {
                     ProgressView("載入中...")
                 } else if let error = viewModel.errorMessage {
                     Text("發生錯誤:\(error)")
                         .foregroundStyle(.red)
                 } else {
                     List(viewModel.posts, id: \.id) { post in
                         VStack(alignment: .leading, spacing: 6) {
                             // post.title 跟 post.body 都是從 shared model 來的
                             Text(post.title)
                                 .font(.headline)
                             Text(post.body)
                                 .font(.subheadline)
                                 .foregroundStyle(.secondary)
                         }
                     }
                 }
             }
             .navigationTitle("KMP 文章列表")
             .task {
                 await viewModel.fetchPosts()
             }
         }
     }
 }
 

按下 `Cmd+R` 執行...噹啷!一個原生的 SwiftUI 列表出現了,上面的資料全都是透過我們放在 `shared` 模組裡的 Kotlin 程式碼抓下來的。Android 那邊也是用同一份 `PostApiService` 去抓資料,只是他們用 Jetpack Compose 畫 UI 而已。

iOS 和 Android 共享同一份資料邏輯,但呈現出完全原生的 UI
iOS 和 Android 共享同一份資料邏輯,但呈現出完全原生的 UI

所以...這東西到底實不實用?

老實說,KMP 不是什麼銀色子彈,但它是一種「務實的魔法」。不只國外像 Netflix、Cash App 這些大咖在用,我聽說台灣這邊像...嗯...像是內容或社群平台(比如像 Dcard 或 KKBOX 這種規模的公司)的團隊,其實也都有在內部玩或甚至導入。這點跟美國那邊起步就很愛用 KMP 的金融科技 App 不太一樣,台灣好像更偏向用在內容密集型的 App,可能是因為大家對「業務邏輯共享」的需求更大吧?純屬猜測啦。

說真的,它有優點,但缺點也很明顯:

優點 ✅

  • UI 絕不妥協: 你的 App 還是保有最讚的原生 UI 體驗
  • 邏輯一次搞定: 網路、資料庫、驗證規則...這些惱人的東西寫一次就好。
  • 維護成本降低: 邏輯 bug 修一個地方,兩個平台就都修好了。
  • 跟 Swift 無縫接軌: `async/await` 的支援真的太香了。

缺點 ⚠️

  • 學習曲線: 團隊成員至少要看得懂 Kotlin,不用精通,但至少要能改 bug。
  • 建構複雜度: Gradle...唉,只能說它沒有 Xcode project 那麼直觀。剛開始設定會有點頭痛。
  • 除錯邊界: 如果 bug 出在 Swift 和 Kotlin 的交界處,有時候會比較難追。
  • 團隊共識: 這不是一個人能決定的事。整個 iOS 和 Android 團隊都要有共識,願意一起維護 `shared` 這個模組才行。

我自己是覺得,對於大多數同時維護兩個原生 App 的團隊來說,這個取捨是值得的。你可以從小地方開始,例如先試試看只共享資料模型,感受一下整個流程。如果感覺不錯,再把網路層也搬過去。漸進式地導入,風險會小很多。

KMP 給我的感覺,它不是另一個想統治世界的跨平台框架,它更像一座聰明的橋,讓我們 Swift 開發者在保有自己最愛的一切的同時,聰明地省下 40-60% 的重複工作。蠻酷的,不是嗎?

那你呢?看完這篇,你覺得最適合用 KMP 共享的業務邏輯是什麼?是單純的資料模型?網路層?還是更複雜的演算法或狀態管理?在下面留言聊聊你的看法吧!

Related to this topic:

Comments

撥打專線 LINE免費通話