文章中探讨了 Google 提供的应用架构指南在多平台上的实现。通过共享视图模型(View Models)和共享 UI 状态(UI States),我们可以专注于在原生端实现 UI 部分。
使用了简单的自定义抽象层,包括 KmmViewModel 和 KmmStateFlow,使得我们可以将共享的业务逻辑连接到原生 UI,而无需依赖于复杂的第三方库。这种方法有助于简化 KMM 开发,提高开发效率。
Google官方应用架构指南
https://developer.android.com/topic/architecture?hl=zh-cn
androidApp
(本地应用)
iosApp
(本地应用)
shared
(KMM 共享层)
https://github.com/Maruchin1/kmm-shared-mvi
https://github.com/touchlab/KaMPKit
为了实现这一架构,我们需要引入两个简单的 KMM 抽象。一个用于 ViewModel,另一个用于 StateFlow。
// commonMain
expect abstract class KmmViewModel constructor() {
protected val scope: CoroutineScope
}
// androidMain
actual abstract class KmmViewModel : ViewModel() {
protected actual val scope: CoroutineScope
get() = viewModelScope
}
// iosMain
actual abstract class KmmViewModel {
protected actual val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
fun onCleared() {
scope.cancel()
}
}
在 Android 端,我们只需使用 androidx.lifecycle.ViewModel
,使其像本地 ViewModel 一样运行。我们还将 viewModelScope
关联起来,以便在 KmmViewModel
中启动异步操作。
在 iOS 端,我们有一个自定义实现,它使用 MainDispatcher
实例化 CoroutineScope
。它还公开了一个额外的 onCleared
方法,可以在本地端用于取消正在进行的异步操作。
// commonMain
expect class KmmStateFlow<T>(source: StateFlow<T>) : StateFlow<T>
// androidMain
actual class KmmStateFlow<T> actual constructor(
source: StateFlow<T>
) : StateFlow<T> by source
// iosMain
fun interface KmmSubscription {
fun unsubscribe()
}
actual class KmmStateFlow<T> actual constructor(
private val source: StateFlow<T>
) : StateFlow<T> by source {
fun subscribe(onEach: (T) -> Unit, onCompletion: (Throwable?) -> Unit): KmmSubscription {
val scope = CoroutineScope(Job() + Dispatchers.Main)
source
.onEach { onEach(it) }
.catch { onCompletion(it) }
.onCompletion { onCompletion(null) }
.launchIn(scope)
return KmmSubscription { scope.cancel() }
}
}
在 Android 端,我们只需将实现委托给标准的 StateFlow
,因此它的工作方式完全相同。在 iOS 端,由于无法访问 CoroutineScope
,我们无法像标准方式一样收集 StateFlow
。解决这个问题的方法是采用基于订阅的方式,这在 RxJava 和其他 Rx* 库中很常见。我们添加了一个带有两个回调的 subscribe
方法,它返回一个 KmmSubscription
实例。iOS 应用程序可以取消订阅,从而取消 CoroutineScope
。
要在 iOS 应用程序中正确集成 KmmViewModel
,最简单且最灵活的方法是依赖委托模式。首先,可以使用 ObjCName
注解,专门为 iOS 应用程序更改共享的 View Model 名称。
@ObjCName("LoginViewModelDelegate")
class LoginViewModel : KmmViewModel() {
val uiState: KmmStateFlow<LoginUiState> = ...
fun login() {
...
}
}
然后,在本机 iOS 应用中,我们创建一个视图模型包装器,它在底层使用共享委托。
最重要的部分是 deinit
块。它通知视图模型委托应取消所有异步工作,并关闭 UI State 订阅。这样,当屏幕从导航堆栈中移除时,就不会发生内存泄漏。
class LoginViewModel: ObservableObject {
@Published var state: LoginUiState = LoginUiState.companion.default()
private let viewModelDelegate: LoginViewModelDelegate
private var stateSubscription: KmmSubscription!
init(viewModelDelegate: LoginViewModelDelegate) {
self.viewModelDelegate = viewModelDelegate
subscribeState()
}
// Remember to clear and unscubscribe when no more needed
deinit {
viewModelDelegate.onCleared()
stateSubscription.unsubscribe()
}
func login() {
viewModelDelegate.login()
}
private func subscribeState() {
stateSubscription = viewModelDelegate.uiState.subscribe(
onEach: { state in
self.state = state!
},
onCompletion: { error in
if let error = error {
print(error)
}
}
)
}
}
1. 视图模型与屏幕一一对应
视图模型是屏幕级别的状态持有者。本地屏幕和共享视图模型之间存在一对一的关系。当我们在共享部分拥有 HomeViewModel
时,我们应该在 Android 中拥有 HomeScreen / HomeFragment
,而在 iOS 中拥有 HomeView / HomeController
。
2. 视图模型发出单一数据流
MVVM 和 MVI 之间的主要区别在于,在 MVI 中,对于每个屏幕,我们有一个单一的不可变状态。当视图模型需要向本地 UI 发出一些数据时,它应该定义一个不可变的*UiState
数据类,并使用 KmmStateFlow
发出它。
https://developer.android.com/topic/architecture/ui-layer
https://developer.android.com/topic/architecture/ui-layer/stateholders
不推荐的MVI View Model
class HomeViewModel : KmmViewModel() {
val userName: KmmStateFlow<String> ...
val articles: KmmStateFlow<List<Article>> ...
val isLoading: KmmStateFlow<Boolean> ...
}
推荐的MVI View Model
data class HomeUiState(
val userName: String,
val articles: List<ArticleUiState>,
val isLoading: Boolean,
)
class HomeViewModel : KmmViewModel() {
val uiState: KmmStateFlow<HomeUiState> ...
}
3. UI事件可触发UI状态更新
View Models使用命名方法(例如fun login())处理UI事件(如OnClick)。方法执行业务逻辑后,不返回值或触发事件,而是更新UI状态以传递相关数据。
https://developer.android.com/topic/architecture/ui-layer/events
data class LoginUiState(
val isLoggedIn: Boolean,
val errorMessage: String?
)
class LoginViewModel : KmmViewModel() {
private val _uiState = MutableStateFlow(LoginUiState.default())
val uiState: KmmStateFlow<LoginUiState> = _uiState.asKmmStateFlow()
fun login() = viewModelScope.launch {
runCatching {
loginUserUseCase()
}.onSuccess {
_uiState.update {
// It can be consumed by the UI to navigate to HomeScreen
it.copy(isLoggedIn = true)
}
}.onFailure {
_uiState.update { error ->
// It can be consumed by the UI to display a Toast
it.copy(errorMessage = getErrorMessage(error))
}
}
}
}
4. 使用案例是可选的
并非每个应用都需要使用案例。当应用简单时,直接在视图模型中访问存储库是可以的。但当应用引入更多逻辑,需要转换、分组或执行复杂操作时,应该考虑使用案例来封装这些逻辑,以便在不同的视图模型中重用。
https://developer.android.com/topic/architecture/domain-layer
https://medium.com/androiddevelopers/adding-a-domain-layer-bc5a708a96da
5. 使用案例是无状态的
使用案例负责执行一些逻辑操作,可能涉及不同的存储库和不同类型的数据。然而,使用案例本身不应保留任何内部状态。如果需要持久化或临时存储某些数据,应该委托给存储库。
6. 一个数据类型对应一个存储库
每个存储库都代表一个数据类型的集合。如果我们有用户实体,我们创建 UsersRepository
。而对于文章,我们创建 ArticlesRepository
。存储库不应依赖于其他存储库。
在Android文档中,我们可以找到关于构建多层存储库的信息。请记住,这个更高级别的存储库有不同的目的。它不是使用不同的数据源来管理单一类型的数据,而是使用其他存储库来管理某种聚合类型的数据。这就是它们有时被称为管理器的原因。
在MVI架构中,我们首先应该使用使用案例来从不同的存储库中聚合数据。只有在我们的需求非常复杂,使用使用案例不足以满足时,我们才可以考虑引入多层存储库。
7. 存储库隐藏数据持久化细节
每个存储库都充当一个外观,隐藏了数据持久化的详细信息。存储库的所有公共方法都应该接受并返回领域模型。在内部,它们将领域模型映射到相应的远程API或本地数据库模型。
https://developer.android.com/topic/architecture/data-layer
该架构适用于Android和iOS平台具有相同的演示逻辑的情况。它遵循Google的应用程序架构指南,无需使用重型第三方库,支持不可变UI状态和单向数据流,代码共享比例高,但需要注意iOS端的额外代码以避免内存泄漏。
google应用架构指南
https://developer.android.com/topic/architecture/intro
mvi框架
https://github.com/icerockdev/moko-mvvm
https://arkivanov.github.io/Decompose/