Introduction
We’re building a digital frame. As an experiment, we’re recording our struggles and decisions in this diary.
2023
The year is 2023. The project is born.
May
The one with the goal
I’m building a digital frame based on a Raspberry Pi.
The main goal is to make it displays photos from an sdcard in my living room.
Once the main goal is achieved I will look into additional features. Some of the ideas I have include:
-
to show weather in my location
-
to play videos
-
to show media from the cloud
-
to control lights
-
to show temperature gathered from room sensors by homeassistant
-
to show a picture submitted to the frame via HTTP or bluetooth
-
to control the frame with a touch interface
-
to control the frame with an HTTP based API
-
to provide a way to write custom plugins
I already have some great ideas for a greatly overcomplicated architecture. There’s so many cool things we could do with MQTT.
I will keep these ideas at bay but let them inform my design. I will only introduce enough complexity that’s required for the current or upcoming features. Hopefully, this will be a nice demonstration of how to practice evolutionary design.
As with most projects the budget is limited. In this case, the budget is my availability and interest. Once I run out of those the project might be killed.
I will implement it in a language I enjoy, not the one that’s best for the platform I’ve chosen. I’m doing it for fun after all.
I do not enjoy Python. I do enjoy Kotlin.
I have used Kotlin before but only within a web realm. I have never built desktop application in Kotlin, so this will be a new experience for me.
Ideally, I’d like the application to run on more platforms than just the Raspberry Pi. I’d like it to run with minimum dependencies and required setup.
The one with a demo
I’ve decided to give Compose Multiplatform a go.
Declarative framework for sharing UIs across multiple platforms. Based on Kotlin and Jetpack Compose.
Since I’m totally new to Compose and have no understanding of what’s required to bootstrap it, I generated a demo Compose Multiplatform project in IntelliJ.
Build problems
I immediately run into build problems. You’d expect IntelliJ knows how to generate a project it could build…
The application run just fine from command line:
./gradlew run
That made me look at the project SDKs.
Not sure why all kinds of different JDK versions were set up for the project modules by IntelliJ.
Aligning them has not helped much (in Project Structure
→ Project
and Modules
).
Tip
|
Shortcut tip:
To run any action in IntelliJ IDEA press the above combination and start typing the name of the action you’d like to invoke. Figure 5. Running any action in IntelliJ IDEA
|
What ultimately fixed the problem was setting the Gradle JVM
(Settings
→ Build, Execution, Deployment
→ Gradle
).
I keep forgetting to do this and it defaults to 1.8.
I usually don’t have this problem in command line thanks to sdkman.
Learning to display an image
First thing I needed to learn is how to display an image.
The generated demo shows a button that changes the text on click. It was easy enough to tweak it to show an image instead. I also tweaked the window title and made the app start in fullscreen.
@Composable
@Preview
fun App() {
MaterialTheme {
Column {
Image(defaultPhoto(), "Default photo", modifier = Modifier.fillMaxSize())
}
}
}
private fun ColumnScope.defaultPhoto(): ImageBitmap =
loadImageBitmap(this::class.java.getResource("/koala.jpg").openStream())
fun main() = application {
val state = rememberWindowState(placement = WindowPlacement.Fullscreen)
Window(onCloseRequest = ::exitApplication, title = "Frame", state = state) {
App()
}
}
Learning to change the view
I’d like the application to be mostly implemented outside of the view, and let the view to be a dummy layer that acts on commands. The next thing I verified is how to update the view.
@Composable
@Preview
fun App() {
var displayedPhoto by remember { mutableStateOf(resourceStream("koala.jpg")) }
MaterialTheme {
Column {
Image(loadImageBitmap(displayedPhoto), "Default photo", modifier = Modifier.fillMaxSize())
}
LaunchedEffect(Unit) {
delay(4.seconds)
displayedPhoto = resourceStream("koala-bis.jpg")
}
}
}
private fun resourceStream(name: String): InputStream = object {}::class.java.getResource("/$name").openStream()
Compose has a special syntax for mutable state. Such state is monitored and the UI is automatically updated when state changes.
Building distributions
The last thing I wanted to check is how to package a Compose Multiplatform app.
The demo is pre-configured, so running ./gradlew package
creates a package for the current platform.
So far I was only able to test it on MacOS. It generates a dmg
file that can be used to install the application on MacOS.
I will still need to confirm if it’s possible to generate cross-platform packages, so that I could generate a package for Raspberry PI on my computer or the CI server.
Summary
So far so good.
Compose seems to be easy to work with. I’m not worried about its stability since my UI will be rather simple. I will also attempt to structure the app in a way that’s not coupled to the UI, so that it’s not super hard to replace. At least in the early phases of the project.
June
The one with experiments
Previously, I have managed to display a sequence of photos with Compose. However, all the changes were triggered from within composables. This isn’t ideal given I prefer to have business logic decoupled from the UI. Before I can continue with Compose I need to confirm I will be able to trigger updates from a different scope.
Triggering photo updates from the outside of a composable
Jetpack Compose documentation proved to be useful. I was initially overthinking it,
but in the end all it took was to pass the MutableState
to the composable (the App
in this case).
Compose takes care of the rest, recomposing composables when required.
@Composable
@Preview
fun App(currentPhoto: MutableState<InputStream>) {
var displayedPhoto by remember { currentPhoto }
MaterialTheme {
Column {
Image(loadImageBitmap(displayedPhoto), "Photo", modifier = Modifier.fillMaxSize())
}
}
}
This way I was able to run two coroutines in main and update the photo from the second coroutine.
fun main(): Unit = runBlocking {
val currentPhoto = mutableStateOf(resourceStream("koala.jpg"))
async {
application {
val state = rememberWindowState(placement = WindowPlacement.Fullscreen)
Window(onCloseRequest = ::exitApplication, title = "Frame", state = state) {
App(currentPhoto)
}
}
}
async {
delay(4.seconds)
currentPhoto.value = resourceStream("koala-bis.jpg")
}
}
Promising.
Experimenting with commands
At this point I got excited and wanted to see how it would look like with commands. I quickly added an interface with a single implementation.
sealed interface Command
data class ShowPhoto(val url: URL) : Command
In main, I now needed to create the command instead of a resource stream.
fun main(): Unit = runBlocking {
val command: MutableState<Command> = mutableStateOf(ShowPhoto(resource("/koala.jpg")))
async {
application {
val state = rememberWindowState(placement = WindowPlacement.Fullscreen)
Window(onCloseRequest = ::exitApplication, title = "Frame", state = state) {
App(command)
}
}
}
async {
delay(4.seconds)
command.value = ShowPhoto(resource("/koala-bis.jpg"))
}
}
Finally, the App should take the command and pass it down.
@Composable
@Preview
fun App(command: MutableState<Command>) {
var lastCommand by remember { command }
MaterialTheme {
Column {
val command = lastCommand
when (command) {
is ShowPhoto -> Image(loadImageBitmap(command.url.openStream()), "Photo", modifier = Modifier.fillMaxSize())
}
}
}
}
Creating a composable
One other thing to try was to create my own composable. Extract function refactoring in IntelliJ did most of the job.
@Composable
fun Media(command: Command) {
when (command) {
is ShowPhoto -> Image(loadImageBitmap(command.url.openStream()), "Photo", modifier = Modifier.fillMaxSize())
}
}
I can count on Compose passing the command down when it’s updated.
The App
remembers the state and composes UI components.
@Composable
@Preview
fun App(command: MutableState<Command>) {
var lastCommand by remember { command }
MaterialTheme {
Column {
Media(lastCommand)
}
}
}
Trying out actors
Since Kotlin has a (limited) actor support built in, I also experimented with an actor based command dispatcher.
fun main(): Unit = runBlocking {
val command: MutableState<Command> = mutableStateOf(ShowPhoto(resource("/koala.jpg")))
val dispatcher = actor<Command> {
for (msg in channel) {
command.value = msg
}
}
async {
application {
val state = rememberWindowState(placement = WindowPlacement.Fullscreen)
Window(onCloseRequest = ::exitApplication, title = "Frame", state = state) {
App(command)
}
}
}
async {
delay(4.seconds)
dispatcher.send(ShowPhoto(resource("/koala-bis.jpg")))
}
}
Interestingly, the current actor implementation is obsolete and might be replaced with a more complex API in the future.
Summary
I feel it went rather smooth if I ignore the initial Googling for StackOverflow hints. Jetpack Compose documentation is pretty good.
The one with the clean slate
Starting fresh
I started the day with generating a fresh Gradle project using the gradle command. Just like most of the times I have chosen to use subprojects.
Since Gradle generates example subprojects I always need to clean it up and remove unneded code before continuing.
I do it this way to keep myself up to date with latest Gradle goodies, but also to get
the build-logic
subproject (previously buildSrc
).
Just to aid IntelliJ a bit I also configured the jvm toolchain in Gradle. Unfortunately it keeps defaulting to Java 1.8 so when the project is first imported to IntelliJ, the Project SDK, Gradle JVM, and the Target JVM Version in Kotlin Compiler settings need to be configured.
kotlin {
jvmToolchain(19)
}
Next, I moved on to replacing the explicit use of JUnit with kotlin.test:
dependencies {
testImplementation(kotlin("test"))
}
and wrote a test to verify IntelliJ knows how to run them:
package frame
import kotlin.test.Test
import kotlin.test.assertTrue
class VerificationTest {
@Test
fun `it runs tests`() {
assertTrue(true)
}
}
IntelliJ didn’t want to cooperate to run all the tests and complained:
Error running 'All in frame': No junit.jar
I had to additionally tweak the run configuration to search for tests in the whole project:
Now we’re ready to start doing some real work.
Except…
Learning coroutines
I wasn’t sure where to start.
I will need some kind of asynchronous message sending/handling.
I thought I could leverage the Actor model. I have some experience using and contributing to Vlingo XOOM, a java based implementation. Compared to XOOM, Kotlin’s actor implementation is very basic.
It’s part of an obsolete API anyway, so it’s time to look into Kotlin coroutines. Back to the learning mode.
I headed to the Coroutines Guide and Coroutines Testing Documentation, started playing with examples in learning tests.
First things first. Let me see how to launch a coroutine.
package pl.zalas.frame
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.time.Duration.Companion.seconds
class LearnCoroutinesTest {
@Test
fun `learn how to launch a coroutine`() = runTest {
var message = "";
launch {
delay(1000L)
message += " World!"
}
message += "Hello"
advanceTimeBy(2.seconds)
assertEquals("Hello World!", message)
}
}
runTest
behaves similarly to runBlocking
, but has some features useful in tests.
For example, it skips calls to delay while preserving the relative execution order.
The test above only takes milliseconds to run, instead of over a second.
Managing state this way is not thread safe, but I’m sure I’ll learn how to do it properly soon.
Channels are a way for coroutines to communicate with each other.
There are four types of channels.
rendezvous (default):
@Test
fun `learn how to use channels (rendezvous)`() = runTest {
var messages = mutableListOf<String>();
val channel = Channel<String>()
launch {
channel.send("A1")
channel.send("A2")
messages.add("A done")
}
launch {
channel.send("B1")
messages.add("B done")
}
launch {
repeat(3) {
val x = channel.receive()
messages.add(x)
}
}
advanceTimeBy(1.seconds)
assertEquals(listOf("A1", "B1", "A done", "B done", "A2"), messages)
}
unlimited:
@Test
fun `learn how to use channels (unlimited)`() = runTest {
var messages = mutableListOf<String>();
val channel = Channel<String>(UNLIMITED)
launch {
channel.send("A1")
channel.send("A2")
messages.add("A done")
}
launch {
channel.send("B1")
messages.add("B done")
}
launch {
repeat(3) {
val x = channel.receive()
messages.add(x)
}
}
advanceTimeBy(1.seconds)
assertEquals(listOf("A done", "B done", "A1", "A2", "B1"), messages)
}
buffered:
@Test
fun `learn how to use channels (buffered 2)`() = runTest {
var messages = mutableListOf<String>();
val channel = Channel<String>(2)
launch {
channel.send("A1")
channel.send("A2")
messages.add("A done")
}
launch {
channel.send("B1")
messages.add("B done")
}
launch {
repeat(3) {
val x = channel.receive()
messages.add(x)
}
}
advanceTimeBy(1.seconds)
assertEquals(listOf("A done", "A1", "A2", "B1", "B done"), messages)
}
conflated:
@Test
fun `learn how to use channels (conflated)`() = runTest {
var messages = mutableListOf<String>();
val channel = Channel<String>(CONFLATED)
launch {
channel.send("A1")
channel.send("A2")
messages.add("A done")
}
launch {
channel.send("B1")
messages.add("B done")
}
launch {
val x = channel.receive()
messages.add(x)
}
advanceTimeBy(1.seconds)
assertEquals(listOf("A done", "B done", "B1"), messages)
}
I also tried adding delay()
calls in between channel.send()
calls to see how it affects the receiver.
If there’s more than one receiver, each message is still handled only once:
@Test
fun `learn how to use channels (multiple receivers)`() = runTest {
var messages = mutableListOf<String>();
val channel = Channel<String>(UNLIMITED)
launch {
channel.send("A1")
channel.send("A2")
messages.add("A done")
}
launch {
channel.send("B1")
messages.add("B done")
}
val receiver1 = launch {
repeat(3) {
val x = channel.receive()
messages.add("R1$x")
delay(1.seconds)
}
}
val receiver2 = launch {
repeat(3) {
val x = channel.receive()
messages.add("R2$x")
delay(1.seconds)
}
}
val receiver3 = launch {
repeat(3) {
val x = channel.receive()
messages.add("R3$x")
delay(1.seconds)
}
}
advanceTimeBy(10.seconds)
receiver1.cancelAndJoin()
receiver2.cancelAndJoin()
receiver3.cancelAndJoin()
assertEquals(listOf("A done", "B done", "R1A1", "R2A2", "R3B1"), messages)
}
For the project, I will need to potentially each message to be handled by multiple receivers.
It seems that BroadcastChannel
used to be the way to go, but it’s now being replaced with SharedFlow
.
Summary
I’ve learnt some Kotlin coroutine basics but there’s still some docs I haven’t even read. I sure don’t know enough to send a single message to multiple coroutines.
The one with the flow
Since the BroadcastChannel
is obsolete and its docs suggest to use the SharedFlow
instead,
it’s time to look into Kotlin Asynchronous Flow.
Playing with flows
Flow is similar to streams from usage perspective:
package pl.zalas.frame
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class LearnFlowsTest {
@Test
fun `learn how to define a Flow`() = runTest {
val flow = flow {
emit("A1")
delay(100)
emit("A2")
delay(100)
emit("A3")
delay(100)
}
val result = flow.map { it.substring(1) }.toList()
assertEquals(listOf("1", "2", "3"), result)
}
}
For our project, I’m interested in the SharedFlow
. It might not be currently documented in user docs, but
API docs
fill that gap pretty well.
SharedFlow is useful for broadcasting events that happen inside an application to subscribers that can come and go.
Sounds like exactly what I need.
SharedFlow
docs even have an example of an EventBus
that I could play with:
class EventBus() {
// mutable flow has the emit() capability
private val _events = MutableSharedFlow<Event>()
// expose as read-only flow
val events = _events.asSharedFlow()
suspend fun publish(event: Event) {
// suspends until all subscribers receive it:
_events.emit(event)
}
}
Feeling I’m getting closer to something I could use I defined some example events:
interface Event
data class TemperatureRead(val degrees: Int) : Event
data class LightTurnedOn(val name: String) : Event
and wrote an example of how to use the event bus:
fun `learn how to define a Shared Flow`() = runTest(timeout = 1.seconds) {
val eventBus = EventBus()
val firstSubscriberEvents = async {
// SharedFlow never completes. That's why we need a flow-truncating operation like `take()`.
eventBus.events.take(3).toList()
}
val secondSubscriberEvents = async {
eventBus.events.take(3).toList()
}
// Delay is required to give subscribers a chance to catch the first event.
// alternatively, MutableSharedFlow could be configured with `replay = 5` to notify subscribers about past events.
delay(1)
eventBus.publish(TemperatureRead(22))
eventBus.publish(TemperatureRead(19))
eventBus.publish(LightTurnedOn("Kitchen"))
eventBus.publish(TemperatureRead(20))
val expectedEvents = listOf(
TemperatureRead(22),
TemperatureRead(19),
LightTurnedOn("Kitchen")
)
assertEquals(expectedEvents, firstSubscriberEvents.await())
assertEquals(expectedEvents, secondSubscriberEvents.await())
}
In real life, instead of using Flow.take()
to consume a fixed number of events:
eventBus.events.take(3).toList()
I’d use something like Flow.takeWhile()
instead:
eventBus.events.takeWhile { e -> e !is SystemShuttingDown}.toList()
That would require publishing a special event to terminate the flow:
object SystemShuttingDown : Event
eventBus.publish(SystemShuttingDown)
Promising!
More of the event bus
Last thing I’ve done today was playing a bit more with the event bus idea.
I wanted a better way to subscribe to events:
eventBus.subscribe<Event> { event ->
events.add(event)
}
This way the subscriber could be any callable that doesn’t need to be aware of the Flow.
Here’s the whole test case:
@Test
fun `it publishes events to subscribers`() = runTest(timeout = 1.seconds) {
val eventBus = EventBus()
val firstSubscriber = async {
val events = mutableListOf<Event>()
eventBus.subscribe<Event> { event ->
events.add(event)
}
events.toList()
}
val secondSubscriber = async {
val events = mutableListOf<TemperatureRead>()
eventBus.subscribe<TemperatureRead> { event ->
events.add(event)
}
events.toList()
}
delay(1)
eventBus.publish(TemperatureRead(22))
eventBus.publish(TemperatureRead(19))
eventBus.publish(LightTurnedOn("Kitchen"))
eventBus.publish(TemperatureRead(20))
eventBus.publish(EventBus.SystemShuttingDown)
assertEquals(
listOf(
TemperatureRead(22),
TemperatureRead(19),
LightTurnedOn("Kitchen"),
TemperatureRead(20)
),
firstSubscriber.await()
)
assertEquals(
listOf(
TemperatureRead(22),
TemperatureRead(19),
TemperatureRead(20)
),
secondSubscriber.await()
)
}
All the details of handling the special shutdown event and filtering of events is hidden in the EventBus
:
class EventBus {
object SystemShuttingDown
private val _events = MutableSharedFlow<Any>()
val events = _events.asSharedFlow()
suspend fun publish(event: Any) {
_events.emit(event)
}
// Inspired by https://dev.to/mohitrajput987/event-bus-pattern-in-android-using-kotlin-flows-la
suspend inline fun <reified T> subscribe(crossinline subscriber: suspend (T) -> Unit) {
events.takeWhile { event -> event !is SystemShuttingDown }
.filterIsInstance<T>()
.collect { event ->
coroutineContext.ensureActive()
subscriber(event)
}
}
}
The subscribe()
method is marked as inline
, which means it will be inlined by the compiler at its call sites.
In addition, since the parameter T
is reified
, we don’t need to pass the event type as an argument
to the subscribe()
method:
eventBus.subscribe<TemperatureRead> { event ->
}
Immutable subscribers
I thought I’m done for the day, but then I really couldn’t stop thinking of making subscribers immutable. Subscribers will need to work with state, so I’ll need a way to provide it to the subscriber.
The idea is to provide the initial state for the first time the subscriber is called:
@Test
fun `it publishes events to subscribers with state`() = runTest(timeout = 1.seconds) {
val eventBus = EventBus()
val subscriber = async {
eventBus.subscribe<TemperatureState, TemperatureRead>(TemperatureState(0)) { state, event ->
TemperatureState(event.degrees, state.previousReads + listOf(state.lastRead))
}
}
delay(1)
eventBus.publish(TemperatureRead(22))
eventBus.publish(TemperatureRead(19))
eventBus.publish(LightTurnedOn("Kitchen"))
eventBus.publish(TemperatureRead(20))
eventBus.publish(EventBus.SystemShuttingDown)
assertEquals(TemperatureState(20, listOf(0, 22, 19)), subscriber.await())
}
data class TemperatureState(val lastRead: Int = 0, val previousReads: List<Int> = emptyList())
The subscriber will return a new state, which should be passed the next time the subscriber is called.
Instead of:
eventBus.subscribe<TemperatureState, TemperatureRead>(TemperatureState(0)) { state, event ->
TemperatureState(event.degrees, state.previousReads + listOf(state.lastRead))
we could use:
eventBus.subscribe(TemperatureState(0)) { state, event: TemperatureRead ->
TemperatureState(event.degrees, state.previousReads + listOf(state.lastRead))
Anyway, it was surprisingly easy to implement:
class EventBus {
suspend inline fun <reified STATE, reified EVENT> subscribe(
state: STATE,
crossinline subscriber: suspend (STATE, EVENT) -> STATE
): STATE = events.takeWhile { event -> event !is SystemShuttingDown }
.filterIsInstance<EVENT>()
.fold(state) { state, event ->
coroutineContext.ensureActive()
subscriber(state, event)
}
}
Did I mention Kotlin is great?
Summary
I don’t know if I’m going to use coroutines and Flow
from the beginning, but
surely they’ll prove themselves useful at some point.
I think I’ve learnt enough to move to the next step.
Perhaps, I’ll need two buses - an event bus and a command bus.
The command bus handler would respond with a new state and a list of events.
The event bus subscriber would respond with a new state and a list of commands.
The one with two steps back and three steps forward
I thought the Compose Multiplatform layer will become a separate module eventually. I expected that I will need to extract it at some point as the project grows.
After reading some more about Compose Multiplatform, Jetpack Compose, and Kotlin Multiplatform projects I decided it is important to isolate Compose in its own Gradle subproject from the beginning. I don’t want it to impose structure or tools on my whole project. I want it to be a plugin to my application. Compose still uses junit4? No problem! It’s only a fraction of the whole test suite isolated in this little module.
It took me a couple of two-hour sessions to figure this out, as it isn’t the default way people create Compose Multiplatform projects. When I started, I didn’t really understand what I was doing. The exercise, however, let me understand Compose Multiplatform a little bit better.
As I was figuring it out I’ve been creating a Mikado Graph of steps required.
When figuring things out and hacking around we sometimes make changes that aren’t contributing to the solution. We’re making a mess.
To make sure I only make necessary changes, I’ve been regularly reverting the changes, recreating the graph, building the project, running tests, and finding new steps that are required.
My starting point was the demo I generated the other day. However, I only wanted to use the code I understand.
At some point I stopped encountering compilation errors and the simple compose test I wrote was passing. That meant my graph was complete. I could revert everything and start over one last time. This time committing the changes.
Below are the steps I followed. Each step ends to a project that compiles.
Downgrading the Kotlin Gradle plugin
-
Downgrade the Kotlin Gradle plugin to 1.8.20 for compatibility with the Multiplatform plugin.
I bootstrapped the project with Kotlin 1.8.21, so I had to downgrade it for now:
build-logic/build.gradle.ktsdependencies { implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.20") }
-
Configure the kotlin version for the gradle plugin based on a gradle property.
gradle.propertieskotlin.version=1.8.20
settings.gradle.ktspluginManagement { plugins { id("org.jetbrains.kotlin.kotlin-gradle-plugin").version(extra["kotlin.version"] as String) } }
-
Do not specify the kotlin gradle plugin version explicitly.
build-logic/build.gradle.ktsdependencies { implementation("org.jetbrains.kotlin:kotlin-gradle-plugin") }
Bootstrapping the app project
My Compose project will be called app
.
-
Create the
app
project by adding an emptyapp/build.gradle.kts
build file. -
Include the
app
project in the main project build.settings.gradle.ktsrootProject.name = "frame" include("frame") include("app")
-
Define plugin versions for Kotlin Multiplatform and Compose.
gradle.propertiescompose.version=1.4.0
settings.gradle.ktsplugins { id("org.jetbrains.kotlin.kotlin-gradle-plugin").version(extra["kotlin.version"] as String) kotlin("multiplatform").version(extra["kotlin.version"] as String) id("org.jetbrains.compose").version(extra["compose.version"] as String) }
-
Enable Kotlin Multiplatform and Compose in the
app
project.app/build.gradle.ktsplugins { // We do not include any of the convention plugins from build-logic here // as the multiplatform plugin already does some similar setup. kotlin("multiplatform") id("org.jetbrains.compose") } repositories { google() mavenCentral() maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") } kotlin { jvm { jvmToolchain(19) withJava() } sourceSets { @Suppress("UNUSED_VARIABLE") val jvmMain by getting { dependencies { implementation(compose.desktop.currentOs) } } } }
Prevent compilation failures due to the Kotlin Gradle plugin loaded multiple times by defining the Multiplatform plugin without applying it in
settings.gradle.kts
.settings.gradle.ktsplugins { kotlin("multiplatform") apply false }
The
app
project will not use our commonbuild-logic
, but it’s fine since there’s little it has in common with non-multiplatform builds. -
Confirm the
app
project can load classes from a non-multiplatform project.Create a class in the
frame
project:frame/src/main/kotlin/pl/zalas/frame/Foo.ktpackage pl.zalas.frame class Foo { }
Create a class in the
app
project that uses the class fromframe
:app/src/commonMain/kotlin/pl/zalas/frame/app/Bar.ktpackage pl.zalas.frame.app import pl.zalas.frame.Foo class Bar(val foo: Foo) { }
Declare the
frame
project dependency in theapp
:app/build.gradle.ktskotlin { sourceSets { @Suppress("UNUSED_VARIABLE") val jvmMain by getting { dependencies { implementation(compose.desktop.currentOs) implementation(project(":frame")) } } } }
Revert this change before continuing.
Bootstrapping tests for Compose Multiplatform
Here I mostly used examples found in the official Jetpack Compose testing docs.
-
Add test dependencies to the app project.
app/build.gradle.ktskotlin { sourceSets { @Suppress("UNUSED_VARIABLE") val jvmTest by getting { dependencies { implementation(kotlin("test")) implementation("org.jetbrains.compose.ui:ui-test-junit4:${extra["compose.version"]}") implementation("org.junit.vintage:junit-vintage-engine:5.6.3") } } } }
I need to make a note to remove the version number for junit vintage engine from here, so it’s in sync with the version used in other projects.
Unfortunately, Compose uses junit4.
-
Add a test that verifies an image is shown.
app/src/commonTest/kotlin/pl/zalas/frame/app/AppTest.ktpackage pl.zalas.frame.app import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.junit4.createComposeRule import org.junit.Rule import kotlin.test.Test class AppTest { @get:Rule val composeTestRule = createComposeRule() @Test fun `it displays the default image`() { composeTestRule.setContent { App() } composeTestRule.onNode(hasContentDescription("Main Content")).assertIsDisplayed() } }
It’s not great test, but it’s a good start.
We’ll also need an empty App so the code compiles:
app/src/commonMain/kotlin/pl/zalas/frame/app/Main.ktpackage pl.zalas.frame.app import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable @Composable @Preview fun App() { MaterialTheme { } }
-
Make the test pass.
app/src/commonMain/kotlin/pl/zalas/frame/app/Main.kt@Composable @Preview fun App() { MaterialTheme { Image( loadImageBitmap(object {}::class.java.getResource("/koala.jpg").openStream()), "Main Content", modifier = Modifier.fillMaxSize() ) }
-
Configure the jvmTest task to log executed tests in cli.
app/build.gradle.ktstasks { @Suppress("UNUSED_VARIABLE") val jvmTest by getting(Test::class) { testLogging { events("passed", "skipped", "failed") showStandardStreams = true } } }
-
Configure the IDE to run all tests with Gradle rather than junit configuration.
Figure 16. All tests configurationWe can now run all the project tests with a single click or shortcut.
I have now completed all the steps to reach the goal at the top of the Mikado Graph.
The main
Finally, I added the main()
function based on what I’ve had in the demo project.
This is so I could manually test.
fun main(): Unit = runBlocking {
application {
val state = rememberWindowState(placement = WindowPlacement.Fullscreen)
Window(onCloseRequest = ::exitApplication, title = "Frame", state = state) {
App()
}
}
}
I also configured compose, so I could run it from command line with ./gradlew app:run
:
compose.desktop {
application {
mainClass = "pl.zalas.frame.app.MainKt"
}
}
Summary
I feel like it was a struggle, but a necessary one.
I managed to keep Compose Multiplatform isolated from the rest of the project. A big win, since I’m still not fully committed to it.
Good result, given that at one point I was about to give up.
I found the Mikado Method extremely helpful while not only restructuring, but also adding new code.
The one with the hardware
Time to see if Kotlin Multiplatform will work on Raspberry PI.
Normally, I’d treat this as a high risk and verify if the demo I generated works on Raspberry PI much earlier. However, I didn’t have access to a Raspberry PI device at the time.
I do have access to Raspberry Pi Zero W now. It’s an armv6 based model.
Getting it to boot
I installed Raspberry Pi OS with the Raspberry Pi Imager on an sdcard and booted the device.
Pi Zero didn’t want to cooperate. It would hang some time after the boot.
I thought it’s the sdcard and ordered a new one, but that didn’t help.
Disabling ipv6 only seem to have helped, but as soon there was high network activity the device would hang.
I thought updating firmware would help, but I needed to run apt for that:
sudo apt update
sudo apt full-upgrade
sudo reboot
More googling led me to believe it might be a voltage issue.
In the end, what has fixed RPI freezing was adding the following line to /boot/config.txt
:
over_voltage=2
Getting it to run
I needed Java to build the project. Ultimately, I planned to build it on the CI, but just to test it I could use the device to build.
There’s no Java 19 in repos. Sdkman failed to install a working version. I was ready to downgrade to Java 17, but in the process I’ve learnt that Kotlin Coroutines won’t work on an armv6 processor.
Coroutines will never work on an armv6 processor: https://github.com/Kotlin/kotlinx.coroutines/issues/855#issuecomment-1462044450
The end
I’m going to park this project, at least until I can get a hold of a 64bit armv7 based Raspberry PI. It seems to be supported under Tier 2 currently.
However, at the moment there seems to be no device availability whatsoever.