DEV Community

Cover image for One Kotlin core for every screen: what is shared and what is not
Yonto Player
Yonto Player

Posted on

One Kotlin core for every screen: what is shared and what is not

Yonto Player is a media player for your own library, and everything below its screens is one Kotlin Multiplatform module, :core. The apps shipping today are Android TV, phones and tablets, and all of them build on it, along with the test suites. The core is built so that Apple devices can share it too; the Apple versions are not out yet.

This post is about where I draw the lines inside that module: what goes in common code, what gets an expect/actual, what is not allowed in common at all, and what the whole arrangement costs.

What the core holds

:core is the domain. It holds the content models, the ContentSourceAdapter interface every source implements, the plugin runtime, the stores, and the playback and download state. It does not import Compose.

Above it there are two more shared modules. :app:model holds the view models and UI state, with no Compose, so a non-Compose UI can sit on it. :app:common holds the plain composables and the strings. The apps themselves are thin: the TV app has its tv-material screens, and the phone app is a shell over a shared touch UI.

The one dependency rule is that the screens know a source only as a ContentSourceAdapter and a few plain domain models. Nothing in content/ knows ui/ exists, and a composable never reaches past its view model to a repository. That rule is what makes the rest of this post possible, because a domain that knows nothing about screens has no reason to care which platform it runs on.

The source sets

This is a sketch of how :core is laid out. The names are the real source sets, but the tree is simplified:

core/src/
  commonMain/     models, adapter contract, plugin contract, stores, URL parsing
  commonTest/     tests that run on every target
  deviceTest/     tests that need threads, files or the JS engine
  appleTest/, iosTest/, macosTest/   tests for the Apple-side code
  deviceMain/     threads, the device's files, the JS engine
  androidMain/    anything that needs Android, the JVM or OkHttp
  appleMain/      what every Apple target shares
  iosMain/        the few things that need UIKit
  androidHostTest/  JVM tests, with Robolectric where needed
Enter fullscreen mode Exit fullscreen mode

deviceMain is the one I like best. It is a group that Android and the Apple targets both belong to, declared in the convention plugin with Kotlin's default hierarchy template plus one extra group. It holds what needs threads, the device's files, or the JavaScript engine, none of which a "common" target in the abstract has.

The rule for where new code goes is simple: commonMain first. androidMain needs a reason, either "this needs Android or OkHttp" or "this depends on something that does", and the pull request has to say which. That is a rule in the architecture notes, so the Android folder has to justify each file.

Where expect/actual earns its place

I use expect/actual for small, sharp seams, never for a whole feature. The crypto seam is the clearest example. The domain needs digests, HMAC and AES, and it reaches the platform's crypto only through one expect object. Here is a sketch of the shape, with simplified names:

// commonMain
expect object Crypto {
    fun digest(algorithm: String, bytes: ByteArray): ByteArray
    fun hmacSha256(key: ByteArray, message: ByteArray): ByteArray
    fun aesCbcDecrypt(key: ByteArray, iv: ByteArray, data: ByteArray): ByteArray
}

// androidMain: the JVM's own providers
actual object Crypto { /* javax.crypto */ }

// appleMain: the system's crypto library
actual object Crypto { /* platform calls */ }
Enter fullscreen mode Exit fullscreen mode

Anything built from those pieces, like a key-derivation function on top of HMAC, stays in common code. So the seam is as thin as I can make it, and the logic around it exists once.

The plugin runtime follows the same idea. Every source plugin is a small JavaScript file run on QuickJS. The contract, the realm that bootstraps a plugin, the host API and the adapter that makes a plugin look like any other source are all common code. Only the engine behind them is an expect, a single function that creates a runtime. Its actual lives in deviceMain, over a QuickJS binding, together with the runtime thread. Each runtime gets one runtime thread, in shared device code, and the contract in front of them is the same.

Other seams are small on purpose: time formatting, compression, Unicode normalisation, a text-charset lookup, the IO dispatcher and the device's file system.

What must not go in common

Some things look portable and are not. The standing example is a URL. The JVM's and Android's URL handling is not the same as Apple's, and a plugin that sends a request to a host has to be held to one reading of what a host is. So the core carries its own Url, a port of OkHttp's HttpUrl in common code, and a test holds it to the original on a large corpus of awkward URLs. Text decoding is the same story: legacy character sets are read through each platform's own tables, and parity tests record what the JVM does so the other targets can be held to it.

A second rule: common code does not call Dispatchers.IO, FileSystem.SYSTEM or runBlocking directly. It asks ioDispatcher, systemFileSystem and runBlockingOnPlatform from one small platform package. Locks come from atomicfu and atomics from the standard library. This is boring, and it is the reason a test written once can run on more than one target.

How the tests run

A test in commonTest uses kotlin.test and runs on the JVM host and on the native targets. A test that needs threads, files or the JS engine goes in deviceTest. A test that needs Android, or Robolectric, goes in the Android host source set, which also carries the JVM build of the QuickJS binding so the same engine runs on a laptop.

Because tests are portable, one convention follows: the Kotlin/Native compiler refuses some characters in a backticked test name that the JVM allows, such as a comma or a parenthesis. Rather than finding out on a native build, a Gradle task, checkNativeNames, reads every non-Android source set and fails the build on such a name, and it runs before the Android host tests that everyone runs in the quick loop. It is the kind of cost a multiplatform module has: a rule you would never write for the JVM alone, enforced by the build so that nobody has to remember it.

What it costs

I would rather say plainly what this arrangement costs.

  • Build time. Compiling more than one target takes longer than compiling one, and the full verification line is long. I use a quick loop for one module while I work and the full line before a commit.
  • Seams to maintain. Every expect is a promise that each platform keeps. That is why the list is short and each one is thin.
  • Runtime differences. The plugin runtime is the same contract everywhere but a different engine setup on each platform, so there are parity tests rather than an assumption.
  • Native rules. Names, threading and memory behave differently natively, and the build checks them.

The payoff is that a plugin call, a URL check and a library scan are each written once and tested once, and the apps stay thin. The domain was a black box about its sources from the start, and the same discipline keeps the shared code free of platform details wherever it can. Some things still live in androidMain, such as SMB shares and the Android document-tree location.

The core is closed source, but the plugin contract and the tool that lints and runs plugins are open under the MIT licence.

Read more

Top comments (0)