59

Kotlin Multiplatform IRL: running the same code on iOS and Android

 5 years ago
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:

  1. Authenticate with Basic HTTP Authentication to GitHub
  2. Fetch the list of the user's repositories
  3. Deserialize the result
  4. 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 the ApplicationDispatcher come from? We defined it in the common module: internal expect val ApplicationDispatcher: CoroutineDispatcher . The expected 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:

  1. Add a new framework to your project by clicking File/New/Target/Cocoa Touch Framework and call it Shared .
  2. On the project settings page, select the Shared library below targets and go to the Build phases tab .
  3. Delete all the default build phases except Target Dependencies .
  4. Hit the + at the top left of this view and add a New Run script phase .
  5. 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:

Debug Any iOS Simulator SDK assembleDebugFrameworkIos_x64 Any iOS SDK assembleDebugFrameworkIos_arm64 Release Any iOS Simulator SDK assembleReleaseFrameworkIos_x64 Any iOS SDK assembleReleaseFrameworkIos_arm64

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 the repos() 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 ?).


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK