After years of evolution, it seems like we can finally say that Kotlin Multiplatform is here to stay. However, I still had this awkward feeling that it’s not really as convenient and competitive to share only business logic across platforms, especially whereas React Native, Flutter, and other technologies can do more. That’s why I was really excited to see the first alpha version of the highly anticipated Compose Multiplatform for iOS last year.
The puzzle has finally come together. Now Android developers can create iOS apps in Kotlin with minimal additional effort. But is this really the case? Let’s find out. Here, I will describe a library migration. When migrating an entire application, some points may differ.
Note:
This happened in Spring 2024 with Kotlin 1.9.22.
With Kotlin 2.x and other recent advancements, some things may be different.
About my case
We have Android/Kotlin and iOS/Swift clients. Both clients use a self-made library with the following UI features:
- Static loadable pictures
- Loadable GIFs
- Videos (streaming loading from the CDN)
- All this is placed in vertical lists with horizontal lists inside
Atop of this:
- Networks communications
- Disk caching
https://miro.medium.com/v2/resize:fit:472/format:webp/1*4Eb5MmEXdZwZC8WkYBB6yA.gif
For us it was a good starting point to test the Compose Multiplatform capabilities and performance. If anything could go wrong — it will be here.
Why Compose Multiplatform?
Some sources describe Jetpack Compose as a declarative API. To me, the main advantage of Jetpack Compose compared to traditional XML layouts is the flexibility. And when you have your layouts in Jetpack Compose, then the migration to Compose Multiplatform is not really hard.
From the business perspective it’s easier to unify the codebase step by step without major disturbance and the need for additional developers. That is what Compose Multiplatform allows you to do.
A quick reminder how it works, according to Jetbrains:
Obstacles in codebase
Sometimes we all write or use non-KMP code. Sometimes a lot.
There are typical examples:
- Java code
- Incompatible libraries as dependencies (Dagger 2, RxJava, Retrofit and others)
- XML layouts, Views, Fragments
- Android Services, Push notifications, In-app purchases
- and more…
Here we have 2 main possible solutions:
- Rewrite the code / migrate to KMP-compatible library and put it into the
commonMain folder.
- Put the incompatible code into platform-specific submodules:
androidMain and
iosMain.
In fact you can always put some code into platform-specific submodules but in this case you have to write it for iOS or implement it on iOS as well.
How to write iOS-specific code?
There are 2 main options to define or use the iOS platform-specific code:
Option 1.
Expect/actual functions with the implementation in iosMain folder.
Here is how the structure looks like:
Option 2.
Provide some connectors that you can invoke from iOS side.
Here is an example. In commonMain we define an empty logger:
object Bridge {
var logger: (String) -> Unit = {}
...
}
In iOS/Swift app we assign an implementation for the logger:
import ...
Bridge.shared.logger = {
print("ios log: " + $0)
}
It’s just a simple case. In fact, we can connect almost everything this way. It’s even possible to define an interface in commonMain and write the whole implementation in Swift independently.
Of course, the more platform-specific code you need to write, the worse it is. In my situation, we have about 9% of the entire library’s codebase in iosMain and 13% in androidMain. This percentage might be lower in your case.
How to start migrating?
So we have Android/Kotlin and iOS/Swift clients. How to start the unification?
In case of Android, it’s possible to rewrite and integrate KMP-compatible code into existing app or library step-by-step. The language remains the same, but the approaches and libraries that you depend on may be changed.
In a simple case it will be like:
- Create a KMP module in your codebase.
- Move your code iteratively to
commonMain.
- Make connectors for parts that can’t be moved away from the platform.
In my case, we decided to start by migrating our self-made UI-focused library that both iOS and Android clients depend on. We had to rewrite the library completely because it was deeply based on RxJava and Fragments with XML layouts.
Job Offers
Our major technology shifts
RxJava → Coroutines / Flow
If you use Rx, you need to migrate to Flow or use some KMP alternatives like Reaktive.
Retrofit → Ktor
Ktor is a quite convenient network library. There were no major troubles with it. You just have to google a few times how to write what you were used to and that’s it.
Room → Room?
In my case the plain disk caching with Okio was an acceptable substitution. But in fact Room supports KMP as well.
Glide → Coil 3 + Self-made GIF implementation for iOS.
Coil 3 is still in alpha but it works. The problem in my case was the inability to play GIFs on iOS. Unfortunately it took me several days to figure out the issue and implement the solution with disk caching.
Caveats:
– Crash in the Jetbrains’ SVG parser on iOS that affects both Coil3 and Kamel.
The ticket is still unresolved.– Coil 3 doesn’t support GIFs out of the box yet. You have to write it by yourself.
As well as disk cache for it.
The basic contract for images in my case:
@Composable
expect fun LoadableImage(
modifier: Modifier,
url: String,
imageColorFilter: ColorFilter? = null,
size: Size? = null,
)
Jetpack ExoPlayer → ExoPlayer + AVPlayer
We use expect/actual to substitute the player for each platform.
ExoPlayer is a powerful solution for Android with the disk caching and streaming playback capabilities.
AvPlayer is a default solution on iOS.
The basic contract of the player:
@Composable
expect fun VideoPlayer(
modifier: Modifier,
url: String,
volumeEnabled: State<Boolean>,
)
How to build for Android
In case of Android everything is similar to the ordinal android library. The library can be used as a module in the application. It’s our choice for now.
Another option is to use Gradle Composite Builds. See this article for details:
Lastly, you can assemble the library to .aar file using a Gradle task
bundleReleaseAar.
How to build for iOS
During local development we build an XCFramework and put it into the iOS project. The logic is described here:
In short the process is:
- Invoke a gradle task.
It may beiosX64Binaries or
iosArm64Binaries for local builds (faster) or
assembleReleaseXCFramework for final binaries (slower).
- Copy the result from
build/bin/iosArm64/releaseFramework (or similar path) into the iOS project.
- Wait a bit until it’s synchronised by Xcode.
- Done. Use Kotlin code in your iOS project.
With the automated CI/CD pipeline the process is a bit different but it’s another story.
Caveat:
The size of the produced library for iOS (XCFramework) is huge: 378 MB in case ofassembleReleaseXCFramework.
Results in my case
Although our migration is in the pre-production stage at the moment, some conclusions can already be made.
🟢 Functionality.
All major functions of our library were migrated.
Functional requirements met with a few non-critical exceptions.
🟢 Codebase.
Rewriting like this is the opportunity to remove a lot of legacy code.
The numbers:
Before: 10k LoC, 1.2k XML lines
After: 4k LoC
Result: x2.5 less code compared to the legacy version.
🟢 Performance.
On iOS it scrolls smoothly even on iPhone SE.
On low-end Android devices it feels not so smooth like it was with the XML version, but it’s not critical.
🟡 Developer productivity.
In general, if you are familiar with modern Android development and Jetpack Compose, there will be no major issues for you. With one exception in my experience: there is no Preview in Android Studio and IDEA. It works for Fleet, but in AS/IDEA you write Composable functions blindly.
🟡 Binary size.
Android APK: + 0.5 MB
iOS IPA: + 18 MB
🟡 Library size.
Huge XCFrameworks size. More than 300 MB.
🔴 Build time.
Long build time for iOS. It depends on the machine but we see numbers like 17 minutes for the library with only 4k LoC.
Conclusion
It’s absolutely possible to write a cross-platform UI in Kotlin in 2024.
The great thing about KMP and Compose Multiplatform is that if you have to write code in Kotlin anyway, why not write it in a KMP-compatible way?
The result is acceptable in our case, which can be considered as a stress test. Also, we need to keep in mind that Compose Multiplatform for iOS is in the alpha stage and Kotlin 2 is ahead.
Thanks for reading!
This article is previously published on proandroiddev.com