Kotlin Multiplatform 共享 UI 七大設計模式解析:從架構到實作細節

Published on: | Last updated:

今天要來聊聊 Kotlin Multiplatform… 嗯,特別是那個大家都在談,但很多人都做不起來的「共享 UI」。

你知道的,那種「寫一次,到處跑」的夢想,從以前到現在,大概每隔幾年就會換個名字捲土重來一次。但現實往往是… 你為了遷就各個平台,程式碼裡充滿了各種 if-else,最後搞得比分開寫還痛苦,千瘡百孔。真的,死於一千次「就只有這個平台需要微調一下」的小傷口。

不過呢,最近的 Kotlin Multiplatform (KMP) 配上 Compose Multiplatform,好像真的讓這件事有了一點點轉機。但重點是,它的成功… 說真的,跟工具本身有多神,關係好像沒那麼大。關鍵在於你的程式碼是怎麼「架構」的。是一些很樸素、甚至有點無聊的模式。

TL;DR

如果你很急,先看結論:KMP 的共享 UI 能不能成功,跟工具無關,而是你願不願意遵守幾個很基本的「紀律」。核心就是共享行為、狀態和元件,然後在跟原生系統互動的地方,留下小小的、明確的「介面」,讓平台自己去發揮。你要的不是像素級複製貼上,而是維持同一個心智模型,同時尊重每個平台的特色。

為什麼要自找麻煩?共享 UI 到底解決了什麼痛點?

在我們一頭栽進那些程式碼模式之前,我想先聊聊「為什麼」。不然感覺就像是為了用技術而用技術。

老實說,最大的痛點就是「不同步」。我猜你一定遇過:

  • Android 這邊修好了一個 bug,結果 iOS 那邊冒出一個新的、長得很像的 bug。
  • UI/UX 設計師改了一個小小的視覺,結果你要跟兩個平台的工程師開兩次會、追蹤兩次進度。
  • 商業邏輯更新了,結果兩個 App 上線後行為不一致,因為兩邊的工程師「理解」得不太一樣。

這些溝通成本跟不斷冒出來的意外,才是最磨人的。我聽說有個做支付的團隊,他們把設定跟交易紀錄這兩頁用 KMP 共享 UI 的方式重寫。結果大概 85% 的程式碼(狀態管理、列表、表單、錯誤處理)都是共用的,只有跟系統有關的,像是開瀏覽器、生物辨識這些,才寫了平台自己的實作。結果是… 兩個禮拜就把兩個平台的功能上線了,而且之後要加新東西,幾乎都能同步完成。這聽起來真的很誘人,對吧?

所以,共享 UI 的目標不是偷懶,而是「可預測性」。

共享程式碼的核心概念示意圖
共享程式碼的核心概念示意圖

實作指引:七個讓共享 UI 可行的模式

好了,鋪陳了這麼多,該來點硬菜了。下面這七個模式,就是從很多實際專案中總結出來,能讓共享 UI 不再是紙上談兵的作法。它們不酷炫,但很有效。

1. 無所不在的「單向資料流」 (Unidirectional State)

這觀念其實不新了,從前端的 React/Redux 就一直在講。簡單講,就是你的 UI 永遠是「笨」的。它只負責呈現一個不可變的 `UiState` 物件,所有想改變 UI 的行為,都必須發送一個明確的 `Event`。商業邏輯層(通常叫 ViewModel 或 ScreenModel)收到 Event 後,去更新 State,然後 UI 就會自動刷新。

這樣做的好處是,狀態的變化永遠是單向而且可預測的。你再也不會遇到那種「咦?為什麼這個按鈕會自己變成 disabled?」的靈異事件。因為所有狀態的來源都只有一個。


// :common 共享模組
data class UiState(
    val query: String = "",
    val results: List<Item> = emptyList(),
    val isLoading: Boolean = false
)

sealed interface Event {
    data class QueryChanged(val value: String): Event
    data object Search : Event
}

// 這個 ScreenModel 在 Android/iOS/Desktop 都共用
class ScreenModel(/* ... */) {
    private val _state = MutableStateFlow(UiState())
    val state: StateFlow<UiState> = _state

    fun on(event: Event) { /* ... 處理邏輯,更新 _state ... */ }
}
	

我自己是覺得,光是這一點,就能解決掉八成以上的跨平台 bug。

2. 用「設計權杖」 (Design Tokens) 取代寫死的主題

這個我超愛。拜託,不要再寫死顏色、間距、圓角了。像是 `Color(0xFFF7F7F7)` 或 `padding(16.dp)` 這種「魔法數字」,是維護的惡夢。

比較好的做法是,在共享模組裡定義一套「Tokens」,像是 `AppTokens.light.colors.background`、`AppTokens.light.spacing.medium`。所有 UI 元件都只使用這些 token。

說到這個,就不能不提 Google 的 Material Design (M3)。它的核心精神就是一套完整的 Design System,而 Design Tokens 正是實踐這套系統的最好方式。官方文件寫得很漂亮,對吧?但在台灣的專案…老實說,有時候設計師或 PM 會說「這個按鈕幫我放大一點、顏色用這個比較喜氣的紅色」。這時候你的 token 系統就面臨考驗了。如果你為了這個「一次性」的需求,直接在元件裡寫死一個顏色,那整個系統就破功了。比較好的作法,應該是回去跟設計師溝通,看是要新增一個「強調色」的 token,還是說服他遵循既有規範。這就是紀律啊。

3. 小而美的平台轉接頭 `expect`/`actual`

總有些事情是沒辦法共享的,比如震動回饋、開啟外部瀏覽器、權限請求…等等。這時候 KMP 的 `expect`/`actual` 就派上用場了。

你可以把它想像成一個「合約」或「轉接頭」。

  • 在共享的 `commonMain` 裡,用 `expect` 關鍵字宣告一個介面或類別,定義「我需要一個能做到 XXX 的東西」。
  • 然後在 `androidMain` 和 `iosMain` 裡,用 `actual` 關鍵字去提供各自平台的具體實作。

重點是,這個轉接頭要「小」,而且功能要「單純」。一個 `PlatformEffects` 類別可能就只有 `openUrl()` 和 `hapticTap()` 兩個方法。不要把一大堆無關的東西都塞進去,那會變成另一個惡夢。

expect/actual 機制就像一個分流轉接頭
expect/actual 機制就像一個分流轉接頭

4. 把「導航」當作一份合約,而不是框架

導航(Navigation)通常是跨平台最頭痛的問題之一。Android 有自己的 Fragment/Compose Navigation,iOS 有 UIKit Navigation/SwiftUI。千萬不要想用一套共享的「框架」去統一它們,那會很慘。

更好的想法是,把導航也看成一份「合約」。在共享程式碼裡,你只定義兩件事:

  1. 一個 `Route` 的 sealed interface,定義出所有可能的目的地,例如 `HomeScreen`、`DetailsScreen(id: String)`。
  2. 一個 `Navigator` 介面,裡面可能只有 `go(to: Route)` 和 `back()` 兩個方法。

你的共享 UI 畫面,就只依賴這個 `Navigator` 介面去觸發導航。至於實際上怎麼跳轉,就交給 Android 和 iOS 的 host App 自己去實作。這樣一來,你的共享畫面完全不需要知道自己是活在 Fragment 裡,還是 SwiftUI 的 `NavigationStack` 裡。

5. 有類型的「資源」 (Resources)

字串、圖片、多國語系 (i18n)... 這也是個大坑。最糟的做法就是在 UI 程式碼裡到處寫 `Text("Search")`。

一個比較不會頭痛的方式,是把所有字串都定義成一個 `enum class STR`,然後提供一個依據語系回傳字串的輔助函式。這樣一來,所有用到的字串都有了型別,你不可能會打錯字,而且要新增一個語言,也只需要去同一個地方修改。


// shared
enum class STR { Search, Error }

// shared
fun Strings(locale: String): (STR) -> String = { key ->
    when (locale) {
        "es" -> when (key) { STR.Search -> "Buscar"; STR.Error -> "Algo salió mal" }
        else -> when (key) { STR.Search -> "Search"; STR.Error -> "Something went wrong" }
    }
}
	

日期、貨幣格式化也是同樣的道理。都把它們包裝成共享的介面吧。

6. 不與平台打架的「自適應佈局

Compose Multiplatform 很棒的一點,就是它天生就很容易做自適應佈局。你可以輕易地根據螢幕寬度(Size Class)或輸入方式(觸控 vs 滑鼠)來改變排版。

例如,一個商品列表,在手機上可能是單欄的垂直捲動列表,但在平板或桌機上,就可以自動變成三欄的網格。而這一切都可以在同一個 Composable 裡完成,不需要寫兩套 UI。

同一份 UI 程式碼,在不同裝置上的自適應呈現
同一份 UI 程式碼,在不同裝置上的自適應呈現

7. 善用「插槽」 (Slot) 留下平台客製化的空間

這是個很重要的觀念。即使是共享畫面,你還是會希望它在不同平台上能有一點「原生感」。例如,Android 的頂部工具列 (TopAppBar) 跟 iOS 的就不太一樣。

這時候,你可以在設計共享畫面時,留下一些「插槽 (Slot)」。你的 `ListScreen` Composable 可能會長這樣:

@Composable fun ListScreen(..., topBar: @Composable () -> Unit = {}, actions: @Composable () -> Unit = {})

它定義了畫面的主要內容,但把 `topBar` 和 `actions` 這兩個部分留白,變成參數。然後,Android 的 host App 可以傳入一個 Material Design 的 `TopAppBar`,而 iOS 的 host App 則可以傳入它自己的導覽列元件。這樣,主畫面邏輯共享了,但外觀的「裝飾」又能保有平台特色。

對照看看:新舊做法差在哪?

說了這麼多,可能還是有點抽象。我整理了一個簡單的表格,讓你看看這些模式跟以前痛苦的做法到底差在哪裡。

模式 很痛苦的舊做法 稍微有點紀律的 KMP 做法
狀態管理 UI 元件自己有 state,兩邊狀態常常不同步,出現一堆鬼打牆的 bug。 UI 就是個笨蛋,只會讀取 ViewModel 來的單一 `UiState`。邏輯集中,好預測多了。
UI 樣式 顏色、間距直接寫死在 XML 或 SwiftUI 程式碼裡。PM 說要換個主題藍,改到天荒地老。 定義一次 Design Tokens,所有元件都用 token。要換主題?換個 token 物件就好,幾行程式碼的事。
平台功能 程式碼裡充滿 `#if ANDROID ... #else ...`,或是用抽象類別,但實作細節常常外洩。 用 `expect/actual` 定義乾淨的「轉接頭」。共享程式碼完全不知道底層是誰做的,只認合約。
導航 試圖找一個萬能的跨平台導航框架,結果被框架綁死,兩邊的體驗都很卡。 導航只是一份 `Route` 清單跟一個 `Navigator` 介面。實際怎麼跳,各平台自己決定,最原生。
本地化 字串散落在各個檔案,靠工程師手動複製貼上。翻譯人員看到程式碼就頭痛。 用 `enum` 定義所有字串 Key,集中管理。要加新語言也很單純,不會動到 UI 程式碼。

風險與應變:這東西不是銀彈

講了這麼多好處,好像 KMP 共享 UI 是萬靈丹一樣。嗯…說真的,並不是。它也有一些坑你需要知道。

  • 過度共享的陷阱:最常見的錯誤就是什麼都想共用。當你發現你為了共享一個 UI 元件,而寫了一大堆複雜的 `expect/actual` 或平台判斷邏輯時,就該停下來想一想:這個元件是不是根本就不應該共享?有時候,直接在各平台各自寫一個原生元件,反而更簡單。
  • 團隊的學習曲線:KMP 和 Compose Multiplatform 對很多 Android 或 iOS 工程師來說都是新東西。導入初期一定會有陣痛期,需要時間學習和建立共識。這不是一個可以「明天就上線」的技術。
  • 函式庫的支援度:雖然生態系越來越好,但還是有很多酷炫的原生函式庫沒有 KMP 版本。這意味著你可能需要自己動手寫 `expect/actual` 的轉接頭,這也是要評估的成本。

所以,到底值不值得?

繞了一大圈,你會發現 KMP 共享 UI… 它不是一個單純的「工具」,它更像是一種… 工作流程的「契約」。它強迫你的團隊去思考更乾淨的架構、去定義清楚的邊界、去遵守一致的設計規範。

它前期的投入成本比較高,但一旦軌道鋪好,後面開發新功能、維護現有功能的效率會高很多。它讓你把精力花在「商業邏輯」本身,而不是花在處理兩個平台之間那些雞毛蒜皮的同步問題上。

如果你正要開始一個新專案,或是有個相對獨立的功能模組想要重構,我覺得很值得從一小部分開始試試看。先從共享資料層跟商業邏輯開始,然後試著共享一個簡單的、沒有太多平台相依性的畫面。你會慢慢感覺到它的好的。

聊了這麼多,換你說說看:你覺得在你的專案裡,導入共享 UI 最大的阻力會是什麼?是既有的技術債、團隊成員的開發習慣、還是老闆不點頭?在下面留言分享你的看法吧!

Related to this topic:

Comments

  1. Guest 2025-11-13 Reply
    其實之前真的動手試過 Kotlin Multiplatform 做 UI 共享,大約是去年底吧,剛好接到一個專案要統一 iOS 跟 Android 的前端。當時真心覺得 Model-View-Presenter(MVP)算很有感 - 尤其我們在拆 Presenter 和 View 怎麼對話這塊,花了超多時間鑽研各種細節。有趣的是,你要是再把 Repository Pattern 搞進來,把 domain 層切得比較細,維護上整體那種舒適感還蠻明顯的。 但喔,其實最後怎麼組都脫離不了團隊習慣啦。像有人就是愛 MVVM,但我自己說真的,MVP 配點抽象層級高一點的東西,好像才合我們調性。只是話說回來,有些事情沒踩雷不會懂。跨平台要同步更新 UI 那些小痛點…講真的,下次遇到了再聊好了,想到就有點頭大。
撥打專線 LINE免費通話