Using annotations to improve iOS APIs on Kotlin Multiplatform

Kotlin 1.8 has introduced new annotations to improve the interoperability of Kotlin with Objective-C and Swift:

  • @ObjCName: allows to customize the name that will be used in Swift or Objective-C
  • @HiddenFromObjC: allows hiding a Kotlin declaration from Objective-C (and Swift).
  • @ShouldRefineInSwift: it marks a Kotlin declaration as swift_private in the Objective-C API, allowing to replace it with a wrapper written in Swift.

You can see the release announcement for more details about the interoperability improvements introduced with Kotlin 1.8.

In this article, I will cover how I used the @HiddenFromObjC and @ObjCName annotations to improve the architecture of MoneyFlow, a pet project to manage personal finances that I started a couple of years ago and that became a personal playground for a Kotlin Multiplatform mobile app.

MoneyFlow architecture

MoneyFlow uses an MVVM architecture with native ViewModels and a “shared middleware actor”, the UseCase, that prepares and serves the data for the UI. For every UseCase, there is an iOS-specific implementation placed in the iOSMain source set that receives in the constructor a reference of the shared UseCase and re-exposes the methods.

For example, the HomeUseCase will have an iOS-specific implementation called HomeUseCaseIos.

class HomeUseCase(
    private val moneyRepository: MoneyRepository,
    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)
        }
    }
}
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) }
        }
    }
}

This approach introduces some code duplication, but I think that it’s a good compromise to bridge the gap between different platforms (to handle Flows and Coroutine cancellation). 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.

For more details about this architecture, you can look at the following articles that I wrote:

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

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

An alternative approach would be using KMP-NativeCoroutines or SKIE, two libraries that improve Kotlin interoperability with iOS. But for this project, I started with this approach, and I will keep that to experience the difference.

However, having two classes with very similar names (HomeUseCase and HomeUseCaseIos) can be misleading and will increase the binary size of the exported Objective-C framework.

@HiddenFromObjC and @ObjCName usage

As mentioned above, the HiddenFromObjC annotation prevents certain Kotlin declarations from being exported to Objective-C and Swift. The ObjCName annotation instead can be used to change the name of the Kotlin declaration that will be exported to Objective-C and Swift.

Those two annotations can be combined to export to the iOS application only the iOS-specific implementation of the UseCase while keeping the same name.

@HiddenFromObjC
class HomeUseCase(
    private val moneyRepository: MoneyRepository,
    private val errorMapper: MoneyFlowErrorMapper,
) {
	...
}
@ObjCName("HomeUseCase")
class HomeUseCaseIos(
    private val homeUseCase: HomeUseCase,
) : BaseUseCaseIos() {
	...
}

This way, the iOS application will be completely transparent about the different implementations of the UseCase.

class HomeViewModel: ObservableObject {    
    private func homeUseCase() -> HomeUseCase {
        DI.getHomeUseCase()
    }

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

And that’s all. The usage of those annotations is helping to create a cleaner API and export only the necessary components in the iOS framework, contributing to less complexity and reduced binary size.

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