超越共享逻辑:如何用 Kotlin Multiplatform 打造白标应用
我们是如何在生产级银行超级应用中实现 70% 代码共享,同时保持 100% 原生 UI 质感的。
本文将涵盖:
- 两难困境: 如何在代码共享与原生用户体验之间取得平衡。
- 解决方案: 使用 KMP 共享 UI 的“意图”,同时保持渲染的原生性。
- 代码实战: 如何利用
Config 状态对象在 Compose 和 SwiftUI 中解耦逻辑与视图。 - 构建流水线: JSON 配置如何驱动多客户的品牌化定制。
- 深度剖析: 一探“功能解剖学”和共享 ViewModel,看看 70% 代码共享是如何炼成的。
在跨平台开发的世界里,代码共享和用户体验往往像一对欢喜冤家,总是处于拉锯战中。如果你共享得太多(比如使用那些死板的混合框架),UI 看起来就会像个“冒牌货”,感觉总是“不对味儿”。如果你共享得太少,你就会淹没在重复维护的苦海里,痛不欲生。
最近,我的团队面临着一个巨大的挑战:构建一个白标银行解决方案,它需要支持多个客户品牌、不同的功能集,以及严格的监管要求。
我们的解决方案?Kotlin Multiplatform (KMP) 搭配 原子设计系统。
这套架构让我们实现了 约 70% 的代码共享——包括 UI 状态和样式逻辑——同时还能利用 Jetpack Compose (Android) 和 SwiftUI (iOS) 渲染出像素级的完美原生 UI。来,看看我们是怎么做到的。
挑战:大规模白标化
我们不仅仅是在构建一个 App;我们是在构建一个能够生成几十个 App 的引擎。每个客户都有以下需求:
- 独特的品牌形象: 颜色、字体、图标各不相同。
- 特定的功能集: 比如有的想要加密货币功能,有的只需要银行卡功能。
- 原生性能: 银行用户对卡顿的容忍度为零。
维护两套独立的代码库(Kotlin/Swift)会让我们的工程成本翻倍,还会拖慢更新速度。我们需要一个“单一事实来源”。
架构:配置驱动设计
标准的 KMP 非常适合共享业务逻辑(Domain 层、Platform 模块、本地/远程数据源)。但我们想玩把更大的。
我们问自己:能不能在不牺牲原生“渲染”的前提下,共享 UI 的“定义”?
我们采用了 原子设计 的方法,其中每个组件都由一个共享的 Config 对象来定义。
模式解析
我们不再在视图中编写 UI 逻辑,而是将其移至共享的 KMP 层。
1. Config(共享 commonMain)Config 是一个数据类,它保存了组件的“内容”和“意图”。它是唯一的事实来源。
// platform/kdesign/src/commonMain/kotlin/.../atom/button/KButton.kt
object KButton {
@Immutable
data classConfig(
val text: KStringDesc?= null,
val action: (() -> Unit)?= null,
val variant: Variant = Variant.Primary,
val state: State = State.Default,
) {
// 从配置派生视觉样式
fun style(): Style = Style(variant, state)
}
// Style 决定了它的外观(颜色、内边距等)@Immutable
data classStyle(val variant: Variant, val state: State) {
val backgroundColor: KColorResource
get() = when (variant) {
Variant.Primary -> KThemeSpec.colors.buttonPrimaryBg
Variant.Secondary -> KThemeSpec.colors.buttonSecondaryBg
}
}
}
2. 原生渲染
平台端现在变得很“傻”。它们不做逻辑决策,只是拿着 Config,用它们原生的框架进行渲染。
Android (Jetpack Compose):
@Composable
fun PButton(config: KButton.Config) {
val style = config.style()
Button(
onClick = { config.action?.invoke() },
colors = ButtonDefaults.buttonColors(
containerColor = style.backgroundColor.toColor()
)
) {
// 渲染内容
}
}
iOS (SwiftUI):
struct PButtonView: View {
let config: KButton.Config
private let style: KButton.Style
init(config: KButton.Config) {
self.config = config
self.style = config.style()
}
var body: some View {
Button(action: { config.action?() }) {
// 渲染内容
}
.background(Color(style.backgroundColor.name))
}
}
这确保了 逻辑不匹配成为不可能。如果一个按钮在表单无效时应该被禁用,这个逻辑就存在于共享的 ViewModel/Config 中。两个平台的更新步调完全一致。
用 JSON 驱动白标化
为了支持多个客户,我们使用了一套强大的资源生成流水线。
每个客户都有一个 config.json 文件。在构建过程中(使用 Gradle 任务),我们会解析这些文件以生成必要的资源。
- 功能开关: 根据启用的功能动态加载 Gradle 模块(例如
includeBuild("features/crypto"))。 - 主题化: 颜色和字体会自动生成到 Android XML/Compose 对象和 iOS
.xcassets 中。
这将客户的接入时间从 2 周缩短到了约 2 天。这简直是老板们的福音!
技术栈与工具
为了让这套架构坚如磐石,我们依赖了一套现代化的技术栈:
- Gradle 复合构建: 我们将 App 模块化为 16+ 个功能模块(Auth, Wallet, Transactions 等),实现了快速的增量构建和清晰的关注点分离。
- SKIE: KMP 的游戏规则改变者。它极大地改善了 Swift 的互操作性,将 Kotlin 的
sealed classes 转换为 Swift 的 enums,并让 Coroutines Flow 在 SwiftUI 中更容易被消费。 - XcodeGen: 我们不提交
.xcodeproj 文件。iOS 项目是即时生成的,确保文件引用永远不会与 Gradle 配置不同步。
成果展示
通过将 UI 的“大脑”移入 KMP,只把“皮肤”留给平台,我们取得了显著的成果:
- 约 70% 代码共享: 业务逻辑、网络请求和设计系统定义只需编写一次。
- 功能开发速度快 40%: 开发者只需专注于逻辑的一种实现。UI 实现仅仅是属性的映射。
- 一致性: 设计系统由类型系统强制执行。你从物理上就无法创建一个不符合品牌指南的按钮。
功能解剖:跨平台扩展单一功能
为了理解我们是如何在 不牺牲原生 UI 质量 的前提下实现 约 70% 代码共享 的,我们需要看看 单一功能 是如何构建的。
我们不把功能仅仅看作“一个屏幕”。
每个功能都是一个 自包含的垂直切片,拥有明确的归属权,并通过工具和脚手架来强制执行。
功能的解剖学
为什么这很强大?
1. 业务逻辑保持纯粹
Domain 层 仅包含:
- Models
- Interfaces
- Use cases
没有平台代码,没有 UI 假设。这些逻辑只需编写一次,随处共享。
2. 数据可替换
Data 层 实现领域契约:
- APIs
- Caching
- Persistence
- Mapping
你可以更改数据的获取方式,而无需触碰 UI 或业务规则。
3. UI 的“大脑”是共享的
Presentation 层 是真正发挥杠杆作用的地方:
- 一个共享的 ViewModel
- 事件驱动的状态机
- 不可变的 UI configs
这一层决定了 UI 应该长什么样以及应该如何表现——只需一次。
4. 平台层轻薄且原生
Android 和 iOS 不包含逻辑。它们只是:
Jetpack Compose 和 SwiftUI 保持了完全的原生性,但 逻辑不匹配变得不可能。
核心洞察
我们不共享 UI 代码。我们共享 UI 意图。
通过共享:
- State(状态)
- Events(事件)
- Styling rules(样式规则)
- Component structure(组件结构)
……并让每个平台进行原生渲染,我们获得了:
- 像素级的原生 UI
- 跨平台一致的行为
- 大幅减少重复工作
这种严谨的功能结构是我们能够自信地扩展白标银行应用的关键——且没有牺牲性能、质量或开发人员的理智。
共享 ViewModel 如何驱动 UI 状态
该架构的一个核心创新在于 UI 行为和业务逻辑存在于共享的 ViewModel 中,它由状态机驱动,并完全由 UI 意图(事件和配置)驱动。这确保了:
- 屏幕行为的 单一事实来源
- 跨平台可预测的状态转换
- 最大的可测试性
以下是使用示例功能的实际工作原理:
/**
* Example feature showing:
* - Config-driven screen contract (Config + Event)
* - ConfigProvider that builds the initial Config (wires callbacks → events)
* - ViewModel implemented with a StateMachine (event → state) + use case call
*
* Names are intentionally generic (no product/company prefixes).
*/
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
// ------------------------------
// 1) Screen Contract (shared)
// ------------------------------
object SampleScreen {
const val NAME = "SampleScreen"
@Immutable
sealed interface Event { data class OnInputChanged(val input: String) : Event
data object OnSubmitClicked : Event
}
@Immutable
data class Config(
val inputField: InputFieldConfig?,
val submitButton: ButtonConfig,
val statusText: TextConfig?,
)
// Minimal, platform-agnostic component configs
@Immutable
data class InputFieldConfig(
val text: String,
val contentDescription: String,
val onTextChange: (String) -> Unit,
)
@Immutable
data class ButtonConfig(
val title: String,
val enabled: Boolean,
val onClick: () -> Unit,
)
@Immutable
data class TextConfig(val text: String)
// Factory helpers to build component configs
fun inputField(text: String, onEvent: (Event) -> Unit) = InputFieldConfig(
text = text,
contentDescription = "sample_input",
onTextChange = { onEvent(Event.OnInputChanged(it)) } )
fun submitButton(enabled: Boolean, onEvent: (Event) -> Unit) = ButtonConfig(
title = "Submit",
enabled = enabled,
onClick = { onEvent(Event.OnSubmitClicked) } )
}
// ------------------------------
// 2) Config Provider (shared)
// ------------------------------
interface StateProvider<State, Event> { fun provide(onEvent: (Event) -> Unit): State
}
class SampleScreenConfigProvider : StateProvider<SampleScreen.Config, SampleScreen.Event> { override fun provide(onEvent: (SampleScreen.Event) -> Unit): SampleScreen.Config { val initialInput = ""
return SampleScreen.Config(
inputField = SampleScreen.inputField(text = initialInput, onEvent = onEvent),
submitButton = SampleScreen.submitButton(
enabled = initialInput.isNotBlank(),
onEvent = onEvent
),
statusText = null
)
}
}
// ------------------------------
// 3) Use Case (shared)
// ------------------------------
class SubmitSampleUseCase { suspend operator fun invoke(input: String): String { // Pretend we hit a repository/network and return a result string
return "Submitted: $input"
}
}
// ------------------------------
// 4) StateMachine (shared abstraction)
// ------------------------------
// Assume your project already has something like this.
// The key idea: mapEachEventToState { ... } and setState { ... }.interface StateMachine<Event, State> { val state: kotlinx.coroutines.flow.StateFlow<State>
fun sendEvent(event: Event)
fun setState(reducer: State.() -> State)
fun mapEachEventToState(handler: suspend (Event) -> Unit)
}
// Example factory you likely already have in your codebase.
class StateMachineWithProvider<Event, State>(
initialStateProvider: StateProvider<State, Event>,
private val coroutineScope: CoroutineScope,
) : StateMachine<Event, State> { private val _state = kotlinx.coroutines.flow.MutableStateFlow<State>(
initialStateProvider.provide(::sendEvent)
)
override val state = _state
private val events = kotlinx.coroutines.flow.MutableSharedFlow<Event>(extraBufferCapacity = 64)
init { coroutineScope.launch { events.collect { /* no-op until mapEachEventToState is called */ } }
}
override fun sendEvent(event: Event) { events.tryEmit(event)
}
override fun setState(reducer: State.() -> State) { _state.value = _state.value.reducer()
}
override fun mapEachEventToState(handler: suspend (Event) -> Unit) { coroutineScope.launch { events.collect { handler(it) } }
}
}
// ------------------------------
// 5) ViewModel (shared)
// ------------------------------
class SampleScreenViewModel(
private val viewModelScope: CoroutineScope,
private val configProvider: SampleScreenConfigProvider,
private val submitUseCase: SubmitSampleUseCase,
private val sm: StateMachine<SampleScreen.Event, SampleScreen.Config> =
StateMachineWithProvider(initialStateProvider = configProvider, coroutineScope = viewModelScope)
) : ViewModel(), StateMachine<SampleScreen.Event, SampleScreen.Config> by sm {
init { bindEvents()
}
private fun bindEvents() { mapEachEventToState { event -> when (event) { is SampleScreen.Event.OnInputChanged -> { setState { val updatedInput = event.input
copy(
inputField = SampleScreen.inputField(updatedInput, onEvent = ::sendEvent),
submitButton = SampleScreen.submitButton(
enabled = updatedInput.isNotBlank(),
onEvent = ::sendEvent
),
statusText = null
)
}
}
SampleScreen.Event.OnSubmitClicked -> { // Capture current input safely from state
val currentInput = state.value.inputField?.text.orEmpty()
// Optimistic UI: disable button + show loading text
setState { copy(
submitButton = submitButton.copy(enabled = false),
statusText = SampleScreen.TextConfig("Submitting…") )
}
viewModelScope.launch { val resultText = submitUseCase(currentInput)
setState { copy(
statusText = SampleScreen.TextConfig(resultText),
submitButton = submitButton.copy(enabled = currentInput.isNotBlank())
)
}
}
}
}
}
}
}
这里发生了什么?
✅ 共享 UI 契约
- ViewModel 使用一个 屏幕契约:一个描述 UI 状态的 Config,以及代表用户操作的 Event 对象。
- StateMachine 接口消费事件并暴露 Config 的 StateFlow。
✅ 事件驱动更新
- 每个用户操作(OnInputChanged, OnSubmitClicked)都会映射到:
- 一个状态更新(不可变配置)
- 可能的业务逻辑执行(比如调用 use case)
这使得 ViewModel:
- 确定性 —— 每个状态都是前一个状态 + 事件的纯函数
- 可测试性 —— 你可以在单元测试中完全通过事件来驱动它
- 平台无关 —— UI 渲染只是当前配置的投影
✅ 共享逻辑,原生渲染
平台只需观察状态流并 渲染 配置:
- UI 层没有逻辑
- 没有重复的事件处理
- Android/iOS 行为之间没有偏差
这种模式是我们保持 行为单一实现 却每次都能交付 原生 UI 的支柱。
大规模测试:单元、UI 和 E2E 自动化
光有架构是不够的——它必须在大规模下被证明是正确的。
从第一天起,测试就被视为 一等公民的架构关注点,而不是事后诸葛亮。因为我们的功能遵循严格、共享的结构,测试变得可预测且可重复。
1. 共享单元测试(安全网)
大部分逻辑都存在于共享的 KMP 层,这也是我们首先关注的地方。
对于每个功能,我们编写 共享 ViewModel 测试,用于:
- 验证初始 UI 状态
- 验证事件 → 状态转换
- 断言业务规则和边缘情况
- 运行一次,覆盖两个平台
因为 ViewModel 发出不可变的 UI 配置,测试变得很简单:
“给定这个事件,UI 配置是否按预期变化?”
这给了我们快速的反馈,并在 Bug 到达设备之前就捕获它们。
2. 原生 UI 测试(平台信心)
虽然逻辑是共享的,但渲染是原生的——所以我们仍然需要原生测试。
在 Android 上,我们使用:
- Compose UI tests
- Robot 风格的抽象以提高可读性
这些测试验证:
UI 层保持轻薄,所以这些测试很稳定,很少破坏。
3. 端到端测试(真实用户流程)
最后,我们验证真正重要的东西:真实用户旅程。
我们的 E2E 测试:
- 像用户一样启动 App
- 跨多个屏幕导航
- 验证成功和失败路径
因为功能是模块化且配置驱动的,E2E 测试可以在不同客户和功能集之间清晰地组合。
为什么这行之有效?
这种分层测试策略之所以有效,是因为它反映了架构:
- 共享逻辑 → 共享测试
- 原生 UI → 原生 UI 测试
- 关键流程 → E2E 自动化
结果是:
- Bug 被早期捕获
- 平台偏差最小化
- 新的白标客户不会导致测试矩阵爆炸
在银行应用中——正确性、一致性和信任是不可妥协的——这种测试策略是我们能够快速行动 而不 搞砸一切的保障。
总结
Kotlin Multiplatform 通常被宣传为共享数据层的一种方式。然而,白标银行应用证明了 KMP 可以做得更多。
通过实施 配置驱动设计系统,我们在原生性能和跨平台效率之间架起了一座桥梁。我们不仅共享了代码;我们共享了用户界面的 架构,为未来的银行应用创建了一个可扩展的基础。
原文链接:https://proandroiddev.com/beyond-shared-logic-building-a-whitelabel-app-with-kotlin-multiplatform-d220a0b196b2?source=search_post---------1-----------------------------------