Kotlin Multiplatform at Philo: Bumps in the Road
We’re back to talk more about Kotlin Multiplatform (KMP) at Philo! In our last post we gave a little background about frontend business logic, why it is important at Philo, and how we decided on Kotlin Multiplatform. Today, we’ll dive deeper into the architecture we selected, the obstacles we encountered, and what the future holds for KMP at Philo.
Bumps in the road
Every journey has its challenges, right? We found solutions to all of these, but it took quite a lot of blood, sweat, and tears on behalf of the team. We hope others benefit from these learnings!
Lack of consistent support for Kotlin language features:
We quickly discovered that writing Kotlin code for Kotlin Multiplatform (KMP) differs significantly from writing Kotlin code for Android. Our API intentionally resembles a Redux (or MVI) store. This approach was chosen to provide a sense of familiarity for both JavaScript and Android developers, while also leveraging the benefits of unidirectional data flow for clearer, more maintainable code.
Clients dispatch actions to the library’s dispatcher, which in turn emits state that the client can use to render the UI. To achieve this, we represent actions using data classes and objects, while the state is modeled through a set of data classes. In a traditional Android environment, you might structure this as follows:
/** * Clients use this state to render their UI */ sealed interface PlayerUiState { data class LoadingOverlay(val title: String) : PlayerUiState data class ControlsOverlay(val playButtonState: PlayButtonState) : PlayerUiState { enum class PlayButtonState { PLAY, PAUSE } } data class SeekOverlay(val seekThumbnailUrls: List<Url>) : PlayerUiState } /** * Clients fire these actions into the dispatcher when the user takes an action */ sealed interface PlayerAction { data object Play : PlayerAction data object Pause : PlayerAction data object ToggleSeekMode : PlayerAction data class SeekToThumbnail(val url: Url) : PlayerAction } interface IPlayerDispatcher { val uiState: Flow<PlayerUiState> fun dispatchAction(action: PlayerAction) }
Unfortunately, there are a few specific areas where that model doesn’t work in a KMP world:
When Blocks For Sealed Classes
JS and Objective-C do not support the equivalent of exhaustive when blocks for sealed classes. SKIE solves that problem on the Apple side, but we’re left in the lurch on JS based platforms. That means that a javascript developer would need to write code like this:
if (uiState instanceof SeekOverlay) { // Render the seek bar } else if (uiState instanceof ControlsOverlay) { // Render controls overlay } else if (uiState instanceof LoadingOverlay) { // Render loading overlay }
That code is clear, but it doesn’t read like canonical Javascript, and losing compile time checking of the sealed class options is a pretty big negative. To overcome this, we introduced the poor-man’s sealed class. In our codebase we would model uiState like this:
data class PlayerUiState( val playerUiStateType: PlayerUiStateType, val loadingOverlay: LoadingOverlay?, val controlsOverlay: ControlsOverlay?, val seekOverlay: SeekOverlay? ) { enum class PlayerUiStateType { LOADING, CONTROLS, SEEK } data class LoadingOverlay(val title: String) data class ControlsOverlay(val playButtonState: PlayButtonState) data class SeekOverlay(val seekThumbnailUrls: List<Url>) }
With this code, the client expects one of loadingOverlay controlsOverlay or seekOverlay non-null based on the value of playerUiStateType. This allows us to write an exhaustive case statement in Typescript like:
function shouldBeUnreachable(value: never) {} switch (uiState.playerUiStateType) { case LOADING: handleLoadingState(uiState.loadingState); break; case CONTROLS: handleControlsState(uiState.controlsState); break; case SEEK: handleSeekState(uiState.seekState); break; default: shouldBeUnreachable(uiState.playerUiStateType); }
Collections
Initially, JavaScript lacked support for publicly facing Lists or Maps. While this limitation has since been resolved, we still avoid using native Kotlin collections in our public APIs due to performance considerations. As a result, we restrict the types we export to classes, primitives, enums, and arrays. This means we avoid using structures like this:
data class SeekOverlay(val seekThumbnailUrls: List<Url>)
Instead:
data class SeekOverlay(val seekThumbnailUrls: Array<String>)
Handling array semantics in our internal code has been somewhat cumbersome, but the upside is that all of our platforms have seen performance gains as a result.
Flows
As for Flows, they aren’t exportable in JS! To address this, we opted for the simplest solution: instead of exposing complex JS reactive semantics or something like Combine, we provided callbacks with our UI state, allowing clients to manage the dispatcher lifecycle themselves. This led to a dispatcher design like this:
abstract class PlayerDispatcher(private val uiStateHandler: (PlayerUiState) -> Unit) { abstract fun dispatchAction(action: PlayerAction) abstract fun release() }
In Android, the dispatcher might live in the view model and we can easily emit the UI state into a flow. It would simply need to be released in the ViewModel#onClear
. In a React app, it might live in a component, and in a traditional iOS app it lives in the ViewController.
Performance on Javascript-based platforms
Our biggest hurdle: We realized Javascript performance and KMP were untenable in certain areas. Most notably, we found JSON deserialization — both using apollo-kotlin ’s built-in polymorphic deserializer, and kotlinx.serialization
was, in some cases, 100x slower than JSON.parse
. We discovered two big problems:
- Under the covers, the deserialization code leverages libraries that use
Longs
to index into their buffers , which, in Kotlin/JS is represented as a non-native type. That means that simply iterating through a list of bytes becomes a very expensive operation, since the JS runtime needs to do object allocation (a newLong
) on every turn of the loop! JSON.parse
is really fast native code and anything written in JS is going to have a tough time competing with it.
Unfortunately, a whole bunch of our business logic relies on fetching data from the network, deserializing it and munging it together with other data, so this nearly stopped us. Fortunately we were able to come up with a fairly elegant solution.
It turns out that if you cast a plain JS object as a @JsExport
Kotlin class, things (mostly) just work. That means that this would be valid (js-only) Kotlin:
@JsExport data class Character(val name: String) fun parseJson(): Character { val json = """{"name": "Patrick"}""" val d = JSON.parse(json) return d.unsafeCast() }
In that example parseJson
will return a Character
that (mostly*) acts like an object created using a normal Kotlin constructor. The object it returns is actually just {name: "Patrick"}
as a plain JS object, which means it has no methods or extension functions available. If we wrote code like this we’d get a runtime exception:
fun Character.initials(): String { return name.first() } val character = parseJson() character.initials() // JS Crash - TypeError: character.initials is not a function
This felt like a decent tradeoff for the 100x performance improvement. We covered our bases by writing JS integration tests for any code that translates from network to domain models. To extend the example above, we might have something like:
data class DomainCharacter(name: String) // common code that we would integration test in a JS runtime: fun fetchCharacter(): DomainCharacter { val networkCharacter = parseJson() // pretend this is real network code return DomainCharacter(name: networkCharacter.name) }
The lesson here is that as long as your network model can be @JsExport
you can just do an unsafeCast
of a plain JS object and go on with your life. There is one problem, though: apollo-kotlin works by generating network models off of a GraphQL schema, and the generated code had no @JsExport
annotations. We went ahead and added that functionality with many thanks to the amazing Apollo maintainers who helped us every step of the way. There is a more detailed write-up of the approach in the official Apollo docs.
Testing and Debugging: A Tricky Terrain
Testing and debugging platform-specific issues proved challenging, particularly on JS platforms, where source maps are not yet available. This often turned debugging into a complex task, requiring both Kotlin and native developers to work closely together. After a while, your eyes adjust, but our biggest lesson learned is that it usually pays to have a Kotlin dev and native dev pair side-by-side when these kinds of issues come up.”
Into the future
After overcoming these challenges, we embarked on the ambitious task of rolling out this technology to Philo’s video player component across platforms. While we initially hoped that the KMP library would be a drop-in replacement for our existing client-side business logic, the reality was that most clients required significant refactoring to accommodate the standardized business logic. Ultimately, this project was more of a rewrite than a simple replacement. Despite the difficulties, I’m proud to report that Philo’s (non-Roku) video players are now running on common code, thanks to the hard work of an exceptional team.
As we continue to integrate this technology into more core components of our app, we’re excited about the possibilities. One unexpected but positive outcome has been the increase in cross-platform discussions, not just about API design but also about the data layer across our apps. This has led to more consistent, collaborative, and faster code shipping.
If you found our journey with KMP at Philo interesting, whether you’re considering adopting KMP at your own organization or are interested in working with us, feel free to reach out to seth @ philo dot com or mark @ philo dot com. We’re happy to chat more about our architecture and the challenges we encountered along the way.