Improving shared architecture for a Kotlin Multiplatform, Jetpack Compose and SwiftUI app

A couple of years ago I started working on a pet project to manage personal finances, named MoneyFlow.

This project soon became a personal playground for a Kotlin Multiplatform mobile app and in a previous article, I journaled all the steps that lead me to a satisfying (at least for that time) shared app architecture.

Choosing the right architecture for a [new] Kotlin Multiplatform, Jetpack Compose and SwiftUI app

To know more about the complete journey, please refer to the article mentioned above, but the outcome was a MVVM architecture with native ViewModels and a “shared middleware actor”, the UseCase, that prepares and serves the data for the UI.

Shared UseCase:

class HomeUseCaseImpl(
    private val moneyRepository: MoneyRepository,
    // That's only for iOS
    private val viewUpdate: ((HomeModel) -> Unit)? = null,
): HomeUseCase {

    // Used only on iOS
    private val coroutineScope: CoroutineScope = MainScope()

    private val homeModel = MutableStateFlow<HomeModel>(HomeModel.Loading)

    override fun observeHomeModel(): StateFlow<HomeModel> = homeModel

    override fun computeData() {
        coroutineScope.launch {
            computeHomeDataSuspendable()
        }
    }

    override suspend fun computeHomeDataSuspendable() {
        val latestTransactionFlow = moneyRepository.getLatestTransactions()
        val balanceRecapFlow = moneyRepository.getBalanceRecap()

        latestTransactionFlow.combine(balanceRecapFlow) { transactions: List<Transaction>, balanceRecap: BalanceRecap ->
            HomeModel.HomeState(
                balanceRecap = balanceRecap,
                latestTransactions = transactions
            )
        }.catch { cause: Throwable ->
            val error = HomeModel.Error("Something wrong")
            homeModel.value = error
            viewUpdate?.invoke(error)
        }.collect {
            homeModel.value = it
            viewUpdate?.invoke(it)
        }
    }

    // iOS   only
    fun onDestroy() {
        coroutineScope.cancel()
    }
}

Android ViewModel:

class HomeViewModel(
   private val useCase: HomeUseCase
) : ViewModel() {

    private val _homeLiveData = MutableLiveData<HomeModel>()
    val homeLiveData: LiveData<HomeModel>
        get() = _homeLiveData

    init {
        observeHomeModel()
        viewModelScope.launch {
            useCase.computeHomeDataSuspendable()
        }
    }

    private fun observeHomeModel() {
        viewModelScope.launch {
            useCase.observeHomeModel().collect {
                _homeLiveData.postValue(it)
            }
        }
    }
}

iOS ViewModel:

import shared

class HomeViewModel: ObservableObject {
    
    @Published var homeModel: HomeModel = HomeModel.Loading()
    
    lazy var useCase = HomeUseCaseImpl(moneyRepository: MoneyRepositoryFake(), viewUpdate: { [weak self] model in
        self?.homeModel = model
    })
    
    func startObserving() {
        self.useCase.computeData()
    }
    
    func stopObserving() {
        self.useCase.onDestroy()
    }
}

I’m still convinced that this approach is a good compromise for sharing code as much as possible. In this way, all data handling and preparation will live in a shared UseCase. The ViewModels then can be native and use all the native tools provided by the platform, for example, the Android Jetpack ViewModel and Combine/SwiftUI utilities.

However, this solution can be improved. First, there are duplicated methods to provide a suspendable and a no-suspendable version of a function. The fact that there are duplicated versions of the same function in the same class is something that can lead to confusion on the consumer side.

Secondly, the model returned by those functions is exposed in different ways: with a (State)Flow that will be used from Android and with a nullable callback injected in the constructor. This callback will be NOT null only on iOS and it will be invoked when the Flow coming from the repository is collected. Having a nullable field in the constructor based on the platform is another thing that I don’t like.

One of the reasons to have duplicated functions was the impossibility to use and collect a Flow on Swift. But with some glue code, this is not impossible.

Consuming Kotlin Flow on Swift

With some wrapping code, it is possible to consume Kotlin Flow on Swift.

To consume Kotlin Flow on Swift, I took inspiration from Russell Wolf‘s article: Kotlin Coroutines and Swift, revisited

This strategy requires some Kotlin and Swift wrapping code. The Kotlin code will live in the iOSMain sourceSets and the Swift code will live in the iOS app. In the end, the Flow will be transformed into a Combine Publisher that can be observed from iOS ViewModels.

Kotlin Wrapper Code:

class FlowWrapper<T : Any>(
    private val scope: CoroutineScope,
    private val flow: Flow<T>
) {

    fun subscribe(
        onEvent: (T) -> Unit,
        onError: (Throwable) -> Unit,
        onComplete: () -> Unit
    ): Job =
        flow
            .onEach { onEvent(it.freeze()) }
            .catch { onError(it.freeze()) }
            .onCompletion { onComplete() }
            .launchIn(scope)
}

iOS Wrapper Code:

import Combine
import shared

func createPublisher<T>(_ flowAdapter: FlowWrapper<T>) -> AnyPublisher<T, KotlinError> {
    let subject = PassthroughSubject<T, KotlinError>()
    let job = flowAdapter.subscribe { (item) in
        subject.send(item)
    } onError: { (error) in
       subject.send(completion: .failure(KotlinError(error)))
    } onComplete: {
        subject.send(completion: .finished)
    }
    return subject.handleEvents(receiveCancel: {
        job.cancel(cause: nil)
    }).eraseToAnyPublisher()
}

class PublishedFlow<T> : ObservableObject {
    @Published
    var output: T

    init<E>(_ publisher: AnyPublisher<T, E>, defaultValue: T) {
        output = defaultValue

        publisher
            .replaceError(with: defaultValue)
            .compactMap { $0 }
            .receive(on: DispatchQueue.main)
            .assign(to: &$output)
    }
}

class KotlinError: LocalizedError {
    let throwable: KotlinThrowable
    init(_ throwable: KotlinThrowable) {
        self.throwable = throwable
    }
    var errorDescription: String? {
        get { throwable.message }
    }
}

Improved UseCase

The improved UseCase will have only a single method that returns a Flow. In the example below, I’ve also added a suspendable method, deleteTransaction to showcase how to handle methods that perform an action and return a result.

class HomeUseCase(
    private val moneyRepository: MoneyRepository,
    private val settingsRepository: SettingsRepository,
    private val errorMapper: MoneyFlowErrorMapper,
) {

    fun observeHomeModel(): Flow<HomeModel> =
        moneyRepository.getMoneySummary().map {
            HomeModel.HomeState(
                balanceRecap = it.balanceRecap,
                latestTransactions = it.latestTransactions
            )
        }

    suspend fun deleteTransaction(transactionId: Long): MoneyFlowResult<Unit> {
        return try {
            moneyRepository.deleteTransaction(transactionId)
            MoneyFlowResult.Success(Unit)
        } catch (throwable: Throwable) {
            val error = MoneyFlowError.DeleteTransaction(throwable)
            throwable.logError(error)
            val errorMessage = errorMapper.getUIErrorMessage(error)
            MoneyFlowResult.Error(errorMessage)
        }
    }
}

To transform a Flow and handle coroutine cancellation, I’ve decided to create another UseCase but only specific to iOS. This class is placed in the iOSMain sourceSet. This class receives in the constructor a reference of the shared UseCase and re-exposes the methods to be able to transform a Flow and handle coroutine scoping.

class HomeUseCaseIos(
    private val homeUseCase: HomeUseCase
) : BaseUseCaseIos() {

    fun getMoneySummary(): FlowWrapper<HomeModel> =
        FlowWrapper(scope, homeUseCase.observeHomeModel())

    fun deleteTransaction(transactionId: Long, onError: (UIErrorMessage) -> Unit) {
        scope.launch {
            val result = homeUseCase.deleteTransaction(transactionId)
            result.doOnError { onError(it) }
        }
    }
}

Coroutine scoping is handled in a BaseUseCaseIos class, that creates a scope and exposes a function to cancel the scope.

abstract class BaseUseCaseIos {

    private val dispatcher: CoroutineDispatcher = Dispatchers.Default
    internal val scope = CoroutineScope(SupervisorJob() + dispatcher)

    fun onDestroy() {
        scope.cancel()
    }
}

The Flow can be “transformed” by creating a new FlowWrapper class with the Flow instance and the coroutine scope.

 fun getMoneySummary(): FlowWrapper<HomeModel> =
        FlowWrapper(scope, homeUseCase.observeHomeModel())

The “perform action and return a result” method instead, launches a coroutine in the scope and returns the result in a callback provided as a parameter.

 fun deleteTransaction(transactionId: Long, onError: (UIErrorMessage) -> Unit) {
        scope.launch {
            val result = homeUseCase.deleteTransaction(transactionId)
            result.doOnError { onError(it) }
        }
    }

In this specific case, I’ve put only an onError callback because the UI will react only in case of an error.

Android ViewModel

The Android ViewModel will regularly use the UseCase with the viewModelScope coroutine scope provided by the Jetpack ViewModel like in a regular Android project.

internal class HomeViewModel(
    private var useCase: HomeUseCase,
    private val errorMapper: MoneyFlowErrorMapper,
) : ViewModel() {

    var homeModel: HomeModel by mutableStateOf(HomeModel.Loading)
        private set

    init {
        observeHomeModel()
    }

    private fun observeHomeModel() {
        viewModelScope.launch {
            useCase.observeHomeModel()
                .catch { throwable: Throwable ->
                    val error = MoneyFlowError.GetCategories(throwable)
                    throwable.logError(error)
                    val errorMessage = errorMapper.getUIErrorMessage(error)
                    emit(HomeModel.Error(errorMessage))
                }
                .collect {
                    homeModel = it
                }
        }
    }
}

iOS ViewModel

On iOS instead, the ViewModel is an ObservableObject.

class HomeViewModel: ObservableObject {
	...
}

To make SwiftUI react to state changes, it is necessary to create a @Published variable that will receive the data from the FlowWrapper exposed from the UseCase.

class HomeViewModel: ObservableObject {

	@Published var homeModel: HomeModel = HomeModel.Loading()
   
}    

The FlowWrapper now, needs to be transformed to a Publisher, like explained in the section above.

createPublisher(homeUseCase().getMoneySummary())
    .eraseToAnyPublisher()
    .receive(on: DispatchQueue.global(qos: .userInitiated))

When new data or an error is coming from the Flow, the @Published variable will be updated with the new content. The sink operator is like collect on Flow.

.sink(
    receiveCompletion: { completion in
        if case let .failure(error) = completion {
            let moneyFlowError = MoneyFlowError.GetMoneySummary(throwable:  error.throwable)
            error.throwable.logError(
                moneyFlowError: moneyFlowError,
                message: "Got error while transforming Flow to Publisher"
            )
            let uiErrorMessage = DI.getErrorMapper().getUIErrorMessage(error: moneyFlowError)
            self.homeModel = HomeModel.Error(uiErrorMessage: uiErrorMessage)
        }
    },
    receiveValue: { genericResponse in
        onMainThread {
            self.homeModel = genericResponse
        }
    }
)

When the ViewModel will be destroyed, then the coroutine scope will be canceled.

deinit {
    homeUseCase().onDestroy()
}

As a reference, here’s the entire iOS ViewModel:

class HomeViewModel: ObservableObject {

    @Published var homeModel: HomeModel = HomeModel.Loading()
    
    private var subscriptions = Set<AnyCancellable>()

    private func homeUseCase() -> HomeUseCaseIos {
        DI.getHomeUseCase()
    }

    func startObserving() {
        createPublisher(homeUseCase().getMoneySummary())
            .eraseToAnyPublisher()
            .receive(on: DispatchQueue.global(qos: .userInitiated))
            .sink(
                receiveCompletion: { completion in
                    if case let .failure(error) = completion {
                        let moneyFlowError = MoneyFlowError.GetMoneySummary(throwable:  error.throwable)
                        error.throwable.logError(
                            moneyFlowError: moneyFlowError,
                            message: "Got error while transforming Flow to Publisher"
                        )
                        let uiErrorMessage = DI.getErrorMapper().getUIErrorMessage(error: moneyFlowError)
                        self.homeModel = HomeModel.Error(uiErrorMessage: uiErrorMessage)
                    }
                },
                receiveValue: { genericResponse in
                    onMainThread {
                        self.homeModel = genericResponse
                    }
                }
            )
            .store(in: &self.subscriptions)
    }

    func deleteTransaction(transactionId: Int64) {
        homeUseCase().deleteTransaction(
            transactionId: transactionId,
            onError: { error in
                self.snackbarData = error.toSnackbarData()
            }
        )
    }

    deinit {
        homeUseCase().onDestroy()
    }
}

Conclusions

With the improvements covered above, the UseCase became more flexible and readable than before. Even though there is an amount of code duplication, I think that it’s a good compromise. Some duplication is necessary to bridge the gap between different platforms and with this solution, the majority of the business logic is shared and a “slim” ViewModel will be used to fulfill different needs of different platforms.

Another approach can be using KMP-NativeCoroutines, a library that will make it easier to use Kotlin Coroutines from Swift code in KMP apps. I will try it out in the future or in another project.

You can find the code mentioned in the article on GitHub.