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:
- Kotlin's expect/actual mechanism
- (For Android flavors) Same location declaration in different source sets
- 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 modifiedModifier
) - 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
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