Kotlin Multiplatform IRL: running the same code on iOS and Android
source link: https://www.tuicool.com/articles/hit/yiU3yyU
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Motivation
At Makery we are huge fans of Kotlin, and we always get excited when anything new comes up about our favorite language. You can imagine that we were pretty hyped after last year's KotlinConf when JetBrains introduced Kotlin Native for multiplatform projects. Writing Kotlin code for iOS was a dream for us and the reality is even better: we can create a shared layer with the business logic and use it on both major mobile platforms. Maybe this is the right cross-platform solution we were waiting for so long? After this point, it was just a matter of time when to start experimenting with MP projects for Android and iOS.
In this article, I'm going to guide you through the labyrinth of Kotlin Multiplatform (Kotlin MP or MP from now on) and show you how we created a simple example app to test out the capabilities of the technology.
Also, I highly recommend to check out the project's source code on GitHub because not all the details are covered in this article, just the most interesting ones.
GitHub example
First, I needed a feature set realistic enough, so we can say: "If it's working with this example it will probably work in production too."
Usually, a baseline for any application is to be able to communicate with the outside world. In a developer's head, this means the capability to fire network requests and process the received data. So with these guidelines in our mind, that's what we came up with:
- Authenticate with Basic HTTP Authentication to GitHub
- Fetch the list of the user's repositories
- Deserialize the result
- Display it in a list view
The aim is to implement the first three points from the list as a shared library. Anything outside of that (the UI layer basically) will be written with the platform native SDKs (in Swift for iOS and Kotlin for Android).
Project structure
We started our research by looking for documentation about how we should structure our project. We found a pretty good description here with a simple example app.
This tutorial by JetBrais suggests the following directory structure for an MP project:
project/ ├── androidApp/ ├── iosApp/ └── shared/ ├── common/ ├── android/ └── ios/
The androidApp
and the iosApp
folders are for the native applications receiving the compiled artifacts from the shared
module.
The common
folder in the shared
directory should have most of the implementation of the desired business logic and the android
and ios
directories are there for the platform specific code parts.
Let's look at all these in detail!
Shared module
The shared
module is literally a Gradle module with every component we need for our business logic.
The main idea is to look for a library which can help us with HTTP requests and another one to deserialize the received data. Also when we talk about I/O, threading is always something to consider with care.
Fortunately, we can find all these components ready to work with Kotlin MP.
We will use ktor as our HTTP client and kotlinx.serialization for processing/mapping request and response entities. And finally, Kotlin coroutines is JetBrains solution to handle asynchronous computation.
These libraries already support Kotlin Native (which is necessary to work on iOS) and they also work well with Android.
The next step is to wire these parts together and implement our business logic.
Common module
The common module is the heart of our MP project. It's the place where all the magic happens. After setting up everything correctly in our Gradle build files (they are written using Gradle Kotlin DSL ) we can start using our libraries to implement our GitHub client.
An important thing to mention here is that you only have access to the Kotlin Stdlib and the libraries you included (and they should support MP projects of course).
class GitHubApiClient(..) { .. fun repos(successCallback: (List <githubrepo> ) -> Unit, errorCallback: (Exception) -> Unit) { launch(ApplicationDispatcher) { try { val result: String = httpClient.get { url { protocol = URLProtocol.HTTPS port = 443 host = "api.github.com" encodedPath = "user/repos" header("Authorization", "Basic " + "$githubUserName:$githubPassword".encodeBase64()) } } val repos = JsonTreeParser(result).read().jsonArray .map { it.jsonObject } .map { GitHubRepo(it["name"].content, it["html_url"].content)} successCallback(repos) } catch (ex: Exception) { errorCallback(ex) } } } } </githubrepo>
That's it. Wasn't that hard, right? Let's take a look at the interesting parts:
-
line 3-4
The
repos()
function needs two callback function for handling the success and the error case. -
line 5
We start a coroutine with
launch{}
to push work on to a background thread. But where does theApplicationDispatcher
come from? We defined it in the common module:internal expect val ApplicationDispatcher: CoroutineDispatcher
. Theexpected
keyword means that concrete implementation should come from the platform-specific modules which we will check out later. -
line 7
Our
httpClient
instance is created by using Ktor. -
line 8-16
We define the target url and encode the user credentials in the
Authorization
header according to the HTTP Basic Authentication protocol. - line 18-21 Parsing the result looks more manual than what we are used to but currently this is the only solution working with Kotlin Native.
After we finished with the business logic, we need to write the concrete implementation for our expected ApplicationDispatcher
variable mentioned above. We have to do this in the platform specific modules ( android
, ios
).
Android module
The android
MP module is basically an Android library project. Kotlin's main target was to work well with Java so in the case of Android, there are not a lot of tricks. Our only job here is to provide an actual
implementation of the ApplicationDispatcher
.
It looks like this:
internal actual val ApplicationDispatcher: CoroutineDispatcher = DefaultDispatcher
Because in this module we have access to the Android SDK and the coroutines API for Android (unlike in the common
) we can use the DefaultDispatcher
.
iOS module
In case of iOS we have to work a little bit more on our actual
implementation:
internal actual val ApplicationDispatcher: CoroutineDispatcher = NsQueueDispatcher(dispatch_get_main_queue()) internal class NsQueueDispatcher(private val dispatchQueue: dispatch_queue_t) : CoroutineDispatcher() { override fun dispatch(context: CoroutineContext, block: Runnable) { dispatch_async(dispatchQueue) { block.run() } } }
We create a new class called NsQueueDispatcher
which is basically there for to provide a dispatching method for coroutines on Kotlin Native
(which is currently behind of the coroutines implemementation on other platforms, using a single-threaded (JS-style) event loop solution).
The next step is to check out how can we integrate the product artifacts of Kotlin MP into our native mobile applications.
Android app
For Android, as it was in previous cases, the work with Koltin MP is hassle-free. You just need to create a regular Android application in Android Studio.
After the initial project setup we will use a Gradle trick called composite build . This feature allows us to access other builds and in this case, build artifacts.
To enable composite build I inserted this line into androidApp/settings.gradle
:
includeBuild("../")
In the root project level build script we have already defined the group
and the version
property:
allprojects { group = "com.adrianbukros.github.example" version = "0.1.0" }
Now we have access to the .aar
in the android app module, generated by Kotlin MP.
To include it in our application, simply just add as a dependency:
dependencies { ... implementation("com.adrianbukros.github.example:android:0.1.0") }
Now we can easily instantiate and use our GitHubApiClient class:
GitHubApiClient(username, password).repos( successCallback = { // handle the result }, errorCallback = { // handle the error })
iOS app
Let's add our Kotlin MP library into a native iOS application! In the case of iOS, the story is more complicated than it was with Android. What the Kotlin Native compiler does is basically grab our Kotlin code from the common
and ios
modules and compile it into an Objective-C framework.
What we have to do is to include this framework in our Xcode project. Here are the steps:
-
Add a new framework to your project by clicking File/New/Target/Cocoa Touch Framework and call it
Shared
. -
On the project settings page, select the
Shared
library belowtargets
and go to theBuild phases tab
. -
Delete all the default build phases except
Target Dependencies
. -
Hit the
+
at the top left of this view and add aNew Run script phase
. - Open the new phase and insert the following lines:
"$SRCROOT/../gradlew" -p "$SRCROOT/../shared/ios" "$KOTLIN_NATIVE_TASK" copyFramework \ -PconfigurationBuildDir="$CONFIGURATION_BUILD_DIR"
What this script will do for you is call a piece of Gradle code, which will copy the Obj-C lib from the Kotlin MP build folder and insert it into the Xcode build folder for e.g.: /Users/{user}/Library/Developer/Xcode/DerivedData/github-multiplatform-example-bqzqpuwrnlaafjgkqfwustgrqnwa/Build/Products/Debug-iphonesimulator
(the $CONFIGURATION_BUILD_DIR
marks this).
This way Xcode will have access to our freshly build Kotlin Native library at compile time.
Wait a minute! Do we have the copyFramework
task available in Gradle by default? Of course not. Let's add these lines to shared/ios/build.gradle.kts
:
tasks { "copyFramework" { doLast { val buildDir = tasks.getByName("compileDebugFrameworkIos_x64KotlinNative").outputs.files.first().parent val configurationBuildDir: String by project copy { from(buildDir) into(configurationBuildDir) } } } }
One thing is still missing. We have to define a $KOTLIN_NATIVE_TASK
environment variable's value. Also on the Shared framework target, select Build Settings
and hit the +
on the middle of the screen, then select Add User-Defined Setting
. If you want the build to work with every target, you have to add the following values:
Now after building your project, you can import the Shared
module into your Swift files and use it like this:
import Shared class LoginViewController: UIViewController { @objc private func loginButtonDidTap() { GitHubApiClient(githubUserName: username, githubPassword: password).repos( successCallback:{ [weak self] repos in // handle the result return StdlibUnit() }, errorCallback: { [weak self] error in // handle the error return StdlibUnit() }) } }
-
line 1
You have to import the
Shared
framework before you start using it in your class. -
line 5
Initialize the
GitHubApiClient
and call therepos()
function on it with a success and an error closure! -
line 9 and 12
Currently there is limitation in Kotlin Native that if a function doesn't have a return value, you still have to return with
StdlibUnit()
. I guess it's just one thing that can be omitted in the future versions of Kotlin Native.
Conclusion
Koltin Multiplatform for mobile applications is no question an experimental technology. I think there is still a long way until we can say that it's production ready , but my opinion is that it is a great thing.
There is plenty of room to improve the framework and the tooling, but it's amazing that even at this stage, we were able to develop this small example app. I'm really positive about the project's future and I'm sure that I will give it another try a few months later (maybe after KotlinConf '18 ?).
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK