0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Challenges of Creating a Multi-Platform Compose Application with Kotlin. Part 1

Posted at

P.S. Looking for contributors for my Japanese language learning application which is described in this article, students are welcome too

Intro

Hello there. I'm creating an open source application Kanji Dojo for some time already and here I'll tell about my experience of creating an app using Kotlin and Compose Multiplatform, currently it supports Android, Windows, Linux and MacOS

Development started with early versions of Compose, when it was still in alpha, so there were quite a lot of breaking changes on my way, but overall development difficulty is lowering, making Compose a stable and comfortable framework to work with

Challenge 1. Providing platform-specific components

Different platforms have different sets of API to interact with a system so you can't really avoid having some kind of mechanism to deal with it. Here are some examples of components/classes that might need platform dependent handling:

  • Preferences & Database initialization
  • Opening urls
  • Notifications

Kanji Dojo not only has different platforms but also various Android flavors (app versions that have slightly different dependencies and functionality) so it becomes even a bit more complicated

Each platform and Android flavor has own source set (a folder under src directory with code and resources) and each of them can have different library dependencies. For example:

  • commonMain - reusable between all platforms, can contain not just kotlin but also java code if you don't need to support iOS
  • jvmMain - desktop applications use this source set, can access Swing classes for example
  • androidMain - android source set. Application, Context, Activity are accessible here
  • <flavorName> - various android flavors go here

Methods

There are several ways for handling this situation that I know:

  1. Kotlin's expect/actual mechanism
  2. (For Android flavors) Same location declaration in different source sets
  3. Dependency Injection

Expect/actual and same location declaration

Methods 1 and 2 are quite similar, you have to declare a property or a method in some package. In case of expect/actual you just write a method's signature under commonMain source set and implement it for each platform. In case of method 2 you just implement your methods under the same package path and file name and pray that compiler

The 2nd method is quite verbose if you try to refactor this code since IDE can only track one flavor at a time so you should be careful and try whether all flavors can compile after making changes there

Here's an example of my component for sharing user-made content (list of kanji), in Android it allows user to pick an application and send this data as a text while Desktop version only copies data to the clipboard and displays a message using snackbar


// commonMain source set

interface PracticeSharer {
    fun share(data: String)
}

@Composable
expect fun rememberPracticeSharer(snackbarHostState: SnackbarHostState): PracticeSharer

// Android

@Composable
actual fun rememberPracticeSharer(snackbarHostState: SnackbarHostState): PracticeSharer {
    val context = LocalContext.current
    return remember {
        object : PracticeSharer {
            override fun share(data: String) {
                val sendIntent: Intent = Intent().apply {
                    action = Intent.ACTION_SEND
                    putExtra(Intent.EXTRA_TEXT, data)
                    type = "text/plain"
                }

                val shareIntent = Intent.createChooser(sendIntent, null)
                context.startActivity(shareIntent)
            }
        }
    }
}

// Desktop

@Composable
actual fun rememberPracticeSharer(snackbarHostState: SnackbarHostState): PracticeSharer {
    val context = LocalContext.current
    return remember {
        object : PracticeSharer {
            override fun share(data: String) {
                val sendIntent: Intent = Intent().apply {
                    action = Intent.ACTION_SEND
                    putExtra(Intent.EXTRA_TEXT, data)
                    type = "text/plain"
                }

                val shareIntent = Intent.createChooser(sendIntent, null)
                context.startActivity(shareIntent)
            }
        }
    }
}


I also use this method for

  • accessing Modifier.systemGestureExclusion() that is only available for Android (on desktop it returns non modified Modifier)
  • multiplaform navigation composable functions (I plan to migrate to the new multiplatform navigation library which was released recently)
  • File picking (system activity on android and a dialog on desktop)

You can also use CompositionLocal mechanism to implicitly provide this component down your composable functions hierachy but use it carefully since implicit usage makes your code hard to maintain

Dependency Injection

The only disadvantage of methods 1 and 2 is that they're not very flexible, requiring you to have the same signature so you have troubles with accessing platform-specific components, e.g. you can't just use Andorid's Context in commonMain source set if you need it to access it outside of a composable function

That's where the 3rd method with Dependency Injection shines. I'm using Koin and with it you can use all possible argument for you components if you inject them into the Koin's graph. I use method 1 and 2 to make a list of all Koin modules in my application and start Koin in the Application class for Android or the main method in Java application


// Platform specific modules here
expect val platformComponentsModule: Module

// Complete list of modules for regular components
val appModules: List<Module> = screenModules + listOf(
    platformComponentsModule,
    // Other modules
)

// Android's Application class

    companion object {
        private val modules = appModules + flavorModule + appComponentsModule
    }

    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidContext(this@KanjiApplication) // injects 
            loadKoinModules(modules)
        }
    }

// Desktop app

fun main(args: Array<String>) = application {
    startKoin { loadKoinModules(appModules) }
    //...
}

I'm using Koin to provide a lot of components both reusable and platform/flavor specific. Here are platform/flavor specific component I have:

  • View models that contain screen logic - I have expect/actual method for getting them and for Android I additionally wrap my view models with Android's viewModels
  • List of navigation destinations - to be able to add new destinations/modify existing depending on the platform and flavor
  • Screen content composables - classes with a single composable function to be able to replace content for different app flavors
  • Classes for managing database and preferences
  • Classes for handling notifications since I only support them on Android

To access components provided by Koin inside of composable functions you need to get Koin instance which works like a singleton and considered anti-pattern so you should minimize it as much as possible (there's probably a better API already available but the idea is the same)

    // My flexible sponsor screen that has different content for each flavor

    object Sponsor : MainDestination {

        @Composable
        override fun Content(state: MainNavigationState) {
            val content = remember { getKoin().get<SponsorScreenContract.Content>() }
            content(state)
        }

    }

    // My module in google play flavor
    
    single<SponsorScreenContract.Content> { GooglePlaySponsorScreenContent }

    // My module in jvm platform module
    
    single<SponsorScreenContract.Content> { JvmSponsorScreenContent }
    

Your Koin's dependency graph also won't be available until app is initialized so keep all components-related logic out of composables that display your data, it will make code harder to maintain and would not allow to have previews

To be able to have previews I'm using following approach for my screens: they are built in the way to depend only on data and expose simple callbacks so you can easily create a required state for preview and ignore handling callbacks

image.png

I'm usually accessing Koin from composable functions that handle navigation and my screens for getting view models. The UI composables that depend only on data I name like <ScreenName>UI to distinguish them better

Challenge 2. Handling Databases and Preferences

Do you want to read more? I'm considering writing the next part but let's see how this one goes :sweat_smile:

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?