Efficient Kotlin Multiplatform Mobile (KMM) development using a shared MVI architecture

Efficient Kotlin Multiplatform Mobile (KMM) development using a shared MVI architecture

The article discusses the implementation of the application architecture guidelines provided by Google on multiple platforms. By sharing view models (View Models) and sharing UI states (UI States), we can focus on implementing the UI part on the native side.
Using a simple custom abstraction layer, including KmmViewModel and KmmStateFlow, allows us to connect shared business logic to the native UI without relying on complex third-party libraries. This method helps simplify KMM development and improve development efficiency.
Google official application architecture guide

https://developer.android.com/topic/architecture?hl=zh-cn

Architecture Guidelines Overview

  • androidApp(local application)
    • Views can be implemented using XML or Jetpack Compose.
  • iosApp(local application)
    • Views can be implemented using UIKit or SwiftUI.
  • shared(KMM shared layer)
    • View Models handle rendering logic and send UI State to the local UI.
    • View Models use Repositories and Use Cases to obtain data and execute business logic.
    • Use Cases handle some reusable business logic and can be applied to different View Models.
    • Repositories handle data logic. They expose CRUD operations for returning or updating data.
    • Repositories access different data sources to fetch or store data locally or remotely.

Implementation case

https://github.com/Maruchin1/kmm-shared-mvi
https://github.com/touchlab/KaMPKit

KMM abstract

To implement this architecture, we need to introduce two simple KMM abstractions. One for ViewModel and another for StateFlow.

KmmViewModel

// 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()
  }
}

On the Android side, we just use androidx.lifecycle.ViewModelto make it behave like a native ViewModel. We'll also viewModelScope wire it up to KmmViewModelstart asynchronous operations in .

On the iOS side, we have a custom implementation that uses MainDispatcherinstantiation CoroutineScope. It also exposes an additional onCleared method that can be used on the local side to cancel an ongoing asynchronous operation.

KmmStateFlow

// 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() }
  }
}

On the Android side, we just delegate the implementation to the standard one StateFlow, so it works exactly the same. On the iOS side, due to lack of access CoroutineScope, we are unable to collect in the standard way StateFlow. The solution to this problem is a subscription-based approach, which is common in RxJava and other Rx* libraries. We added a subscribemethod with two callbacks that returns an KmmSubscription instance. The iOS app can unsubscribe and thus cancel CoroutineScope.

IOS side implementation

KmmViewModelThe simplest and most flexible way to integrate correctly in an iOS application is to rely on the delegate pattern. First, you can use ObjCNameannotations to change the shared View Model name specifically for your iOS application.

@ObjCName("LoginViewModelDelegate")
class LoginViewModel : KmmViewModel() {
    
    
  
  val uiState: KmmStateFlow<LoginUiState> = ...
  
  fun login() {
    
    
    ...
  }
}

Then, in the native iOS app, we create a view model wrapper that uses a shared delegate under the hood.

The most important part is deinitthe block. It notifies the view model delegate that all asynchronous work should be canceled and UI State subscriptions closed. This way, no memory leak occurs when the screen is removed from the navigation stack.

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)
        }
      }
    )
  } 
}

key rules

1. One-to-one correspondence between the view model and the screen.
The view model is the state holder at the screen level. There is a one-to-one relationship between the local screen and the shared view model. When we have in the share section HomeViewModel , we should have in Android HomeScreen / HomeFragmentand have in iOS HomeView / HomeController.

2. The view model emits a single data stream.
The main difference between MVVM and MVI is that in MVI, for each screen, we have a single immutable state. When the view model needs to emit some data to the local UI, it should define an immutable *UiStatedata class and KmmStateFlowemit it using .

https://developer.android.com/topic/architecture/ui-layer
https://developer.android.com/topic/architecture/ui-layer/stateholders

Deprecated MVI View Model

class HomeViewModel : KmmViewModel() {
    
    
  
  val userName: KmmStateFlow<String> ...
  
  val articles: KmmStateFlow<List<Article>> ...
  
  val isLoading: KmmStateFlow<Boolean> ...
}

Recommended 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 events can trigger UI state updates.
View Models use named methods (such as fun login()) to handle UI events (such as OnClick). After the method executes the business logic, it does not return a value or trigger an event, but updates the UI state to pass relevant data.

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. Use cases are optional
Not every application requires use cases. When the application is simple, accessing the repository directly in the view model is fine. But when your application introduces more logic and needs to transform, group, or perform complex operations, you should consider using cases to encapsulate this logic so that it can be reused in different view models.

https://developer.android.com/topic/architecture/domain-layer
https://medium.com/androiddevelopers/adding-a-domain-layer-bc5a708a96da

5. Use cases are stateless
Use cases are responsible for performing some logical operations, which may involve different repositories and different types of data. However, the use case itself should not retain any internal state. If some data needs to be persisted or temporarily stored, it should be delegated to a repository.

6. One data type corresponds to one repository.
Each repository represents a collection of data types. If we have a user entity, we create it UsersRepository. And for articles, we create ArticlesRepository. Repositories should not depend on other repositories.

In the Android documentation we can find information about building multi-tier repositories. Keep in mind that this higher level repository has a different purpose. Instead of using different data sources to manage a single type of data, it uses other repositories to manage some aggregated type of data. This is why they are sometimes called managers.

In MVI architecture, we should first use use cases to aggregate data from different repositories. Only when our needs are very complex and the use cases are not enough, we can consider introducing multi-tier repositories.

7. Repositories hide data persistence details
Each repository acts as a facade that hides data persistence details. All public methods of the repository should accept and return the domain model. Internally, they map the domain model to the corresponding remote API or local database model.

https://developer.android.com/topic/architecture/data-layer

in conclusion

This architecture is suitable for situations where Android and iOS platforms have the same presentation logic. It follows Google's application architecture guidelines, does not require the use of heavy third-party libraries, supports immutable UI state and one-way data flow, and has a high code sharing ratio, but requires attention to extra code on the iOS side to avoid memory leaks.

reference

Google application architecture guide
https://developer.android.com/topic/architecture/intro
mvi framework
https://github.com/icerockdev/moko-mvvm
https://arkivanov.github.io/Decompose/

Guess you like

Origin blog.csdn.net/u011897062/article/details/132736403