Kotlin Multiplatform UI 共用設計Token 提升Android與iOS一致性經驗

用 5 個行動重點,快速把 Kotlin Multiplatform UI 設計 Token 玩到跨平台同步,讓 Android 跟 iOS 介面一模一樣超省力

  1. 直接先用 10 組設計 Token 做出共用顏色跟字體,別一開始就全包。

    前 10 組能解 7 成常見元件顏色問題,兩週內就能看到 Android 跟 iOS UI 風格同步(兩平台色碼跟字重 10 組以內一致)

  2. 從 3 個重要畫面試著用單向狀態管理,測 1 週內同步動畫/互動行為。

    這做法會讓 Android 與 iOS 在點擊、載入時都順,1 週內兩邊測試過的互動不會卡(3 畫面互動差異低於 5%)

  3. 先挑出 5 種平台差異,1 天內用 expect/actual Adapter 處理掉。

    小型差異像是震動或特定陰影,快拆開寫能讓主程式 80% 不動,1 天內能讓開發者測到主流程都 OK(5 種差異 24 小時內自動測通過)

  4. 每次上新功能,直接設定自動化 UI 測試,2 天內覆蓋率要達 80%。

    自動測試補齊,能讓改動後兩平台都穩,2 天內測試警示能即時發現 UI 問題(2 天內主要 UI 路徑自動測試全過)

  5. 三週內至少抽 1 次真實專案比對 UI 共用率,有超過 80% 就維持策略。

    共用率低於 80% 就要檢討寫法,三週一次追蹤能讓 UI 重複造輪子降到最低(三週內比對出現 80%+ 共用碼)

認識 Kotlin Multiplatform UI 共用的真正意義

Kotlin Multiplatform 其實有七種可以玩共用 UI 的方式,說起來蠻妙的,這些模式幾乎都在圍繞著 Compose Multiplatform 技術轉啊轉 - 不管你是做 Android、iOS 還是 Desktop,大致上都能讓 UI 程式碼達到一份寫好到處跑的境界。講白一點,它橫跨了像狀態管理、主題色彩設計、導航控制、資源管理(那種小細節真的煩)、中介介接器跟整體版面排布,每個都有現成可複製的 code 可以試(這一點我還滿喜歡的)。有時候寫跨平台,其實常常會被那些平台獨特需求搞得必須微調一大堆小地方,也就是說原本很想直接 share 全部代碼,但總有某幾段得忍痛拆開來針對修一下……嗯,這困擾真的是每次踩雷必經之路。

不過現在搭配 Kotlin Multiplatform 再加 Compose Multiplatform,其實就打破那個「每個平台零碎處理」的舊玩法,只要結構架構弄穩定,你真的多半能把 UI 一大半以上共用出去,那種只是為了 platform 條件犧牲乾淨架構的狀況就明顯減少啦。當然它厲害的不只是靠某套神祕黑魔法,重點反而在方法設計和維持清楚層級分工。如果要真正受益,我自己覺得:重視程式結構與彈性組件化可能比執著於特殊工具更有收穫哦。

採用單向狀態管理提升 Android 與 iOS 一致性

這段主要想聊聊,我們實際如何做到「一次交付、多端都用得開心」這件事,有七種具體做法。等等會慢慢拆解。

## 何謂「共享 UI」

共享 UI,其實不是說畫面每個像素都一模一樣啦。有趣的是,我們這裡所謂的「共享」,指的是把一些行為邏輯、狀態管理還有元件設計,盡可能一起抽離出來用。同時,在有些情境下,會專門針對平台(例如 iOS 或 Android)拉一小塊跨平台接口,來兼顧每個系統各自的風格跟體驗。其實這麼搞,好處就是團隊腦袋不用切來切去,可以維持單一心智模型,同時又不會把某個平台的尊嚴丟掉喔。

## 1)單向狀態流動,貫徹全端

你如果希望 UI 穩定又純粹,就真的要靠「不可變」的 `UiState`、以及清楚劃分資料流的方向,還有明確區隔使用者的 `Event`。這幾個重點綁在一起,不但確保了整體介面的高度一致性,也讓每端看起來都挺順手。我個人感覺,用起來不容易爆炸,就是那種放心的小安心吧 - 大致上是如此。

採用單向狀態管理提升 Android 與 iOS 一致性

打造能跨平台共用的設計 Token 系統

你有沒有發現,這套設計其實不管是在 Android 還是 iOS 上表現都一模一樣?其實,這一點看起來好像沒什麼,不過說真的,它的背後意義還蠻關鍵的。

## 為什麼會這麼講
當狀態管理模式夠一致、可預期,你就不太會遇到「奇怪,怎麼只在 Android 正常」這類平台之間才出現的差異。大家應該都有那種:Android 順跑、結果到 iOS 就 GG 的困擾吧。反過來說,也因為行為都相同,要調試 UI、調環境設定或者做測試,流程直接簡化許多。

## 範例程式碼
// shared :common
data class UiState(
val query: String = "",
val results: List<Item> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)python
sealed interface Event {
data class QueryChanged(val value: String): Event
data object Search : Event
}


class ScreenModel(
private val repo: Repository,
private val scope: CoroutineScope
) {
private val _state = MutableStateFlow(UiState())
val state: StateFlow
 = _state

fun on(event: Event) = when (event) {
is Event.QueryChanged -> _state.update { it.copy(query = event.value) }
Event.Search -> scope.launch {
_state.update { it.copy(isLoading = true, error = null) }
runCatching { repo.search(_state.value.query) }
.onSuccess { _state.update { it.copy(results = it.results + it, isLoading = false) } }
.onFailure { e -> _state.update { it.copy(error = e.message, isLoading = false) } }
}
}

利用 expect/actual 小型平台 Adapter 處理差異

嗯,先講在前頭,設計系統裡直接把顏色硬生生寫在畫面上的做法,其實真的不是太明智啦。如果你有嘗試過維護複數專案,應該馬上會發現一件事:設計 tokens(標記物件)常常要隨著功能優化或跨平台需求來回流動,而把顏色這類細節寫死在某個元件裡,真的很容易卡關,未來只要規則微調就必須到處翻修,很瑣碎也難避免出錯。

像Kotlin這種語言,用法上可以先建一包共用的資料結構來管控主題相關屬性。隨手舉個例:

// shared :common
data class Tokens(
val color: Colors,
val space: Spacing,
val radius: Radius
)

data class Colors(
val bg: Color,
val text: Color,
val accent: Color,
val border: Color
)

css
object AppTokens {
val light = Tokens(
Colors(Color(0xFFF7F7F7), Color(0xFF111111), Color(0xFF4E7FFF), Color(0x22000000)),
Spacing(4, 8, 12, 16), Radius(6, 12)
)
}


// shared UI
@Composable
fun AppTheme(tokens: Tokens = AppTokens.light, content: @Composable () -> Unit) {
CompositionLocalProvider(LocalTokens provides tokens) { content() }

利用 expect/actual 小型平台 Adapter 處理差異

建立路由契約提升 Compose Multiplatform 導航彈性

讓 UI 程式邏輯可以走同一條路徑,其實不僅能保持清晰,還顧及了系統獨特的互動方式,比方說瀏覽器自動打開、觸覺反饋的細節、甚至像是各平臺權限提示那些習慣。舉個 Kotlin 的簡單例子吧:

// shared :common
expect class PlatformEffects() {
fun openUrl(url: String)
fun hapticTap()
}

// androidMain
actual class PlatformEffects actual constructor() {
actual fun openUrl(url: String) { /* startActivity(Intent...) */ }
actual fun hapticTap() { /* performHapticFeedback(...) */ }
}

// iosMain
actual class PlatformEffects actual constructor() {
actual fun openUrl(url: String) { /* UIApplication.shared.open(...) */ }
actual fun hapticTap() { /* UIImpactFeedbackGenerator(...) */ }
}
你看 - 只要 UI 頁面負責呼叫 `PlatformEffects`,邏輯根本不用去管是哪個系統,不會出現像 `#ifdef` 那種麻煩又難維護的區段,很直覺就能寫成模組。

還有喔,把「導覽」這件事情想像成契約(contract),別直接依賴某一套 router 或第三方套件;例如可以用 sealed route model,然後設計極簡單、自己的 `Navigator` 介面,各個應用想接什麼導航架構都行。這種結構下,每個畫面元件不用緊貼任何一家路由實作,還方便以後測試,也更彈性獨立。講起來,就是隨插即用啦。

嗯……如果有沒講到的小地方,我會再查資料,但大致上脈絡就是這樣。好吧,你也會遇過卡在 routing 邏輯裡轉不出來的時候吧?我也是呀!

聯絡我們

運用型別化資源管理簡化多語與圖片處理

切換導航模式時(比如說你想切成堆疊、分頁,或者玩點深度連結),其實不太需要特別改那些畫面元件。真的,我這裡舉個簡單例子讓你直覺看看 -

kotlin
sealed interface Route {
data object Home : Route
data class Details(val id: String) : Route
}
css
interface Navigator {
fun go(to: Route)
fun back()
}


@Composable
fun AppRoot(nav: Navigator, screenModel: ScreenModel) {
val state by screenModel.state.collectAsState()
HomeScreen(
state = state,
onEvent = screenModel::on,
onOpen = { id -> nav.go(Route.Details(id)) }
)
}
講白了,在 Android 那邊你可以直接挑 fragment 或 Compose 的 navigator,然後 iOS 端就推一個 Swift/Compose 容器進來,也是同樣共用 `AppRoot` 沒問題喔。

5)資源與多語系的零痛感管理法

像字串、圖片、格式這些內容,最好把它們統統當作**型別化資源**集中寫在共通程式碼層,由 adapter 層負責每個平台去載入顯示就好了。有點意外吧?直接抄常數超危險耶!如果到處複製貼上以後,要針對不同語言做在地化,真的是一場災難。好啦,加個這樣的小抽象物件,其實就是要讓翻譯人跟工程師協作起來更有條理、不會亂糟糟,也大大提升可維護性。嗯,就是這麼回事啦!

運用型別化資源管理簡化多語與圖片處理

設定響應式版面實現裝置尺寸自適應

在用 Compose Multiplatform 開發時,版面設計的「彈性適應」基本上是關鍵。嗯,我們不僅要處理 UI 隨平台自動變換,還得考慮多語系切換 - 特別是你要做一個支援國際化的 App,這真的省不了。
有點像下面這組 Kotlin 範例,其實已經把多語言字串定義和錯誤訊息交換器整合起來囉:

kotlin
// shared
enum class STR { Search, Error }


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

@Composable
fun SearchButton(t: (STR) -> String, onClick: () -> Unit) {
Button(onClick) { Text(t(STR.Search)) }
}

像這樣的方法也適合用在日期或貨幣格式換算上,其實只需要按需替換當地語系專屬的格式工具,那介面就能自然依照不同使用者文化跟需求調整,沒什麼難度對吧。

還有一個重點,就是響應式排版啦。其實只要好好運用 Compose Multiplatform 的設計,就算遇到尺寸大變動、甚至輸入法切換(觸控跟滑鼠),都能寫出非常滑順的調整機制。下面我就抓了一段 code 給大家參考看看──這個方式直接根據寬度分配欄數,不需要為各種平台塞大量 if-else:

kotlin
@Composable
fun ResponsivePane(
widthPx: Int = LocalWindowInfo.current.containerSize.width,
content: @Composable (columns: Int) -> Unit
) {
val columns = when {
widthPx < 600 -> 1
widthPx < 1100 -> 2
else -> 3
}
content(columns)
}


// usage
@Composable
fun CatalogGrid(items: List<Item>) = ResponsivePane { cols ->
LazyVerticalGrid(GridCells.Fixed(cols)) {
items(items) { Card(/*...*/) { /* item */ } }
}
}


假如寬度夠小(比如手機),他就變單欄;平板會給兩欄,如果超過再給三欄。有趣的是完全不用「判斷平台」,真的單靠版型尺寸決定,一份程式就吃遍全場!

此外呢,把螢幕主要容器做成 slot-based Composables 設計模式也很值得一提。像導覽列、底板彈窗、分享到社群那些,都可以視情況插進畫面,不用每個頁面為了平台不同各自維護。如果你曾經因為分裂維護頭痛,這種結構應該挺救命啦。

總之,多語言字串、多地格式轉換、自適應 UI - 這幾件事如果先綁好架構,再用 Compose Multiplatform 本身那種組件思維推下去,就會讓「同源」UI 隨裝置靈活反應,不論怎麼動都穩穩地保有原生的互動流暢感。不過遇到特殊需求嘛,有時候細節上多少還是會踩雷,到時再來細聊。

規劃可插槽式 Composable 注入平台原生元素

其實,共享 UI 的精神有點像把同一幅畫放進不同相框,每個平台都能包裝出自己的「原生」氣味。這邊舉個例子 - 假設我們有個叫 ListScreen 的 Composable 函數,無論在哪端跑,主體邏輯不變,只是 Android 會丟進一組 Material 樣式的頂部應用欄,而 iOS 則加上一組 segmented control,那個分段切換器啦。你就會發現,這樣的做法能讓設計師和工程師都留著最純粹那份核心,同時又照顧各自生態的小堅持。

回到真實世界,我之前跟過某支付團隊,他們決定把設定、活動記錄等等這些常見頁面,一口氣搬去共用 Compose 架構。說真的,那種程度已經能共享大概 85% 代碼囉,包括像狀態管理、清單元件、表單以及處理錯誤提示的那些東西,全都直接合流。不過還是會碰到必須平台化的小功能,例如瀏覽器開啟動作啦、震動回饋、生物辨識警示 - 這些得透過平台專屬介面橋接才搞得定。

比較驚訝的是,他們其實在兩週內就幾乎同步上架兩端的大改版版本(我一開始還覺得難度太高呢)。後來每次有新需求也不用再分開趕工,每次一起部署更新,效率很明顯提升。有意思的是,真正讓共享 UI 成功,不只是剛好遇到契機或抓漏洞,而是他們很早就在界面分層下了不少苦心佈局,把哪裡要串聯好什麼該抽離都規劃妥當;說穿了,就是意識強烈地提早卡位好了接合點罷了。

規劃可插槽式 Composable 注入平台原生元素

參考真實團隊案例快速共用超過八成 UI 程式碼

其實,共用 UI 在 Kotlin Multiplatform 上落地,真的不是在做夢啦。每一步都超現實,都踩在穩健的流程之上。有件事情,我也曾經想了一下,就是 ScreenModel 的測試。這塊基本靠全純 Kotlin 去跑,輸入什麼狀態就看會長出怎樣的結果,不用搞設備測試房那一套 - 說真的還挺方便。此外,像是某些平台限定的小包裝器嘛,就再分開用快照方式驗證一下,這點說穿了只是確認外包裹沒問題。

另外啊,我們還加進 Golden specs 來對設計標記作比較,例如色系或間距這種瑣事,其實細到讓人頭皮發麻,不過這樣才能防止 pull request 之後跑出怪異變動。有時候看到差一個像素,還真會眼神死。

總結到底,要把共享 UI 做到理想,也得堅持幾個明文規則才行 - 我數了一下,一共七條,包括:單向資料流、明確設計 tokens、扼要中繼適配器、契約引導型路由思維、型別化資源使用、介面彈性佈局,以及可插拔的畫面組件架構。唉,有時看起來複雜,不過一邊拆解,一邊就變清晰,也算有意思吧。

優化測試策略確保跨平台 UI 持續穩定

先選定一項功能專心下手吧,這會讓流程簡單一點。想辦法把各平台的接口黏好,協助團隊有條不紊地同步上線,就沒必要特別去在意某個平台的小差異啦。老實說,如果你對哪部分整合會卡住感到擔憂,其實可以直接拿出來談。我們也許可以細看一下你接下來要做的那個新功能,有哪些現成的 API 或橋接點可能會讓開發踩雷,到時候我再幫你整理一份適合下一個衝刺階段就能丟給工程師參考的「串接規劃」或「插槽清單」。隨時聊,不用急!其實這類平台銜接的規劃,只要把目前既有架構釐清、將潛在阻力預先找出,大致上就八九不離十了。所以,你先決定好最想解決哪個問題,再來慢慢拆環節也行 - 大家都是半睡半醒過過無數次發佈夜,互相 cover 一下吧!

你的想法由我們實現

Related to this topic:

Comments

撥打專線 LINE免費通話