Norway


Aren’t you tired of writing two versions of similar code for and iOS? While these two platforms are different, the business logic behind your app is probably pretty much the same. Download files, read from and write to a database, send messages to a remote host, retrieve and display fancy kitten pictures.

The similarities behind process is why Kotlin Project, or MPP, was born. Thanks to Kotlin/JVM, Kotlin/JS and Kotlin/Native, or K/N, you can compile/transpile a single project for multiple platforms. Isn’t that just brilliant :].

In this tutorial, you’ll how to an app for Android and iOS while only having to write the business logic once in Kotlin.

Note: Kotlin Multiplatform Project is an experimental feature; thus, APIs can change with every update. This is not production ready!

Getting

For this tutorial, you need macOS to compile the iOS project.

This tutorial uses IntelliJ IDEA (Community Edition) .3, Kotlin 1.3.20, Xcode 10.1 and Gradle 4.10.1. You will use IntelliJ for writing the business logic and building and running for Android. You will use Kotlin as the programming language for the business logic, Xcode to compile the UI for iOS and Gradle to build and run the whole app. Keep in mind that there are breaking changes between Kotlin 1.3.11/1.3.20 and Gradle 4.7/4.10.1. So, if you compile a library with Gradle 4.7, you can’t use it if you use Gradle 4.10.1 without further modifications.

Kotlin Multiplatform Project Gradle Overview

First, open IntelliJ IDEA and choose Open, browse to the starter project’s PokeList directory and choose Open. If an additional dialog appears, press OK to import the project from Gradle. If necessary, sync the Gradle project by choosing Tools ▸ Android ▸ Sync Project with Gradle Files. In a few cases, you may have to close the project and re-open it to have Gradle sync the project.

Open build.gradle inside PokeList/app. For this project build.gradle is similar to a normal Android project, but you’ll find two main blocks, targets and sourceSets, inside the Kotlin block. target is particularly useful for iOS since you must change the iosPreset property to compile the project for a real device or a simulator:

- mpp0 idea ios device or simulator 650x400 - Kotlin Multiplatform Project for Android and iOS: Getting Started

The second block contains dependencies:

  • commonMain only contains dependencies written in Kotlin without any reference to the JVM or other platforms. This is a common module and the source files are in PokeList/app/commonMain.
  • androidMain contains Android and JVM related dependencies. This is the Android module and the source files are in PokeList/app/main.
  • iosMain only contains Kotlin native dependencies, without any reference to Objective-C/swift dependencies. This is the iOS module and the source files are in PokeList/app/iosMain.
  • You can see the Gradle task copyFramework below the dependencies. Xcode automatically calls copyFramework during the build process. In the next section, you’ll learn how.

    Xcode Setup Overview

    Open build.gradle file and edit the property “presets.iosX64” to “preset.iosArm64” if the device is 64bit or “preset.iosArm32” if it’s 32 bit. Then open the file PokeList/iosApp/iosApp.xcodeproj with Xcode and build the project.

    Note: In case you need further help running the Xcode project, take a quick look this tutorial.

    In the Build Phases tab of the target app, you’ll find a Run Script block that invokes a Gradle task before building the iOS app. As you can see, the build process calls the copyFramework Gradle task. It then builds a framework using K/N, then copies it on the correct path for Xcode to recognize and use it.

    - mpp1 xcode calling gradle 650x185 - Kotlin Multiplatform Project for Android and iOS: Getting Started

    This framework contains all the common code compiled for iOS, so you need to import it whenever you want to use a common method or class. You can see an example by opening PokeListViewController.swift. In fact PokemonEntry and PokeApi are Kotlin classes.

    - mpp2 xcode import kotlin class 650x307 - Kotlin Multiplatform Project for Android and iOS: Getting Started

    Sharing Code With Different Implementations

    Kotlin MPP uses Kotlin/JVM and K/N to compile your code for multiple platforms, but many times code must be platform-specific. Again, it’s not possible to use platform-specific code inside the common module. But Kotlin has a mechanism it uses to achieve this result: expect/actual.

    Using the Expect/Actual Keywords

    It’s common for a multiplatform app to have platform-specific code. For example, you may want to know if your app is running on an Android or iOS device and get the exact model of your device. To achieve this you need to use the expect/actual keywords.

    First, declare a class, method or function in the common module using the expect keyword and leave the body empty as you often do when creating interfaces or abstract classes. Then, write the platform-specific implementations for the class, method or function in all of the other modules using the actual keyword in the signature.

    Note: If you use expect in a package, you also need to use the corresponding actual command in a package with the same name.

    Expect/Actual Usage

    To use a coroutine in the common module, you need a dispatcher for both Android and iOS. The starter project includes a common implementation provided by JetBrains, the company that created Kotlin.

    Inside Dispatchers.kt in commonMain, you’ll find an example of an expected variable, ApplicationDispatcher. Android provides a default dispatcher, so if you check Dispatchers.kt in main you’ll see it uses Dispatchers.Default. iOS doesn’t provide a default dispatcher like Android, so it uses a new CoroutineDispatcher implementation instead.

    Note: Right now, coroutines aren’t multithreaded in K/N so you’re only able to offload work to a single thread besides the main thread.

    Making HTTP Requests With Ktor

    Ktor is a framework for building asynchronous servers and clients using Kotlin and coroutines. While it splits into multiple libraries, you only need the Ktor client for this project.

    As with any dependency in MPP, you need to import its common version and the specific version for every platform you want to use. You already imported Ktor in the starter project. Since Ktor APIs are being used with coroutines and Objective-C/Swift can’t handle them, you need to wrap your results with simple callbacks.

    Using Ktor

    Open PokeApi.kt in the common module and look at the signature of getPokemonList. If everything goes as planned, the method returns a List of PokemonEntry objects. If an error occurs, a Throwable returns.

    You already have a client instance, httpClient, so you can call its get(..) passing the URL of the request https://pokeapi.co/api/v2/pokedex/kanto/. Since the return type is generic you must specify it explicitly. In this case, String is the correct type to store a JSON response.

    Wrap everything in a try and the failure in a catch since get can throw an exception if it encounters problems.

    Since HttpClient‘s get(..) is marked as suspend, its result won’t be returned synchronously, even if the syntax makes you think it will. To run a suspended function, you need to wrap it in a launch call.

    But, since there’s no CoroutineScope here, you can’t launch a coroutine. The ViewModel or any other component that can handle create/destroy callbacks, should be the best to handle scopes. For this tutorial you can simply use the GlobalScope but note that this is NOT recommended for production code. Handling scopes in this manner may require keeping reference to the scope to create, join and or destroy the scope:

class PokeApi {

    private val httpClient = HttpClient()

    fun getPokemonList(success: (List<PokemonEntry>) -> Unit, failure: (Throwable?) -> Unit) {
        GlobalScope.launch(ApplicationDispatcher) {
            try {
                val url = "https://pokeapi.co/api/v2/pokedex/kanto/"
                val json = httpClient.get<String>(url)
            } catch (ex: Exception) {
                failure(ex)
            }
        }
    }
    ...
}

De/Serializing JSON

PokeApi responds to you with a JSON string, so you need to parse and serialize it to get a Kotlin object. This is where kotlinx.serialization comes in. This Gradle plugin automatically generates a serializer() for every class annotated with @Serializable.

Even though this method is called serializer, it both serializes and deserializes objects. The string that’s passed to serialize in getPokemonList(..) represents a Pokedex object, so annotate this class with @Serializable:

@Serializable
data class Pokedex(
    val pokemon_entries: List<PokemonEntry>
)

Build and run the app and…

java.lang.IllegalStateException: Backend Internal error: Exception during code 
generation
Cause: Back—end (JVM) Internal error: Serializer for element of type PokemonEntry has not been found.

Since this class contains a list of PokemonEntry you also need to annotate the class PokemonEntry. Whether it’s a single instance or a list, kotlinx.serializable will handle everything for you. PokemonEntry contains an object of type Pokemon, so annotate it, too:

@Serializable
data class PokemonEntry(
    val entry_number: Int,
    val pokemon_species: Pokemon
) {
    ...
}

@Serializable
data class Pokemon(
    val name: String,
    val url: String
)

Note: When marking an object as serializable (@Serializable), all custom classes within have to be marked with the @Serializable annotation as well.

You’re now ready to parse your JSON. Use Json.nostrict because the default JSON parser throws an exception in the case of unknown keys. parse(..) accepts a DeserializationStrategy object and the JSON string that it will parse as arguments.

Because you annotated Pokedex with @Serializable, you can now invoke Pokedex.serializer() to retrieve the DeserializationStrategy object. Complete the function by invoking the success callback that returns the result to the UI layer:

fun getPokemonList(success: (List<PokemonEntry>) -> Unit, failure: (Throwable?) -> Unit){
    ...
    val json = httpClient.get<String>(url)
    Json.nonstrict.parse(Pokedex.serializer(), json)
        .pokemon_entries
        .also(success)
    ...
}

Run on Android

Try to run the app on an Android emulator or device. Whoops, another error:

- mpp5B transient crash 650x187 - Kotlin Multiplatform Project for Android and iOS: Getting Started

The label field in PokemonEntry is a getter property you can use to retrieve a well-formatted string and it is not present in the JSON you’re trying to parse. You need to annotate it with @Transient:

@Serializable
data class PokemonEntry(...) {
    @Transient
    val label: String
        get() {
            val name = pokemon_species.name.toUpperCase()
            val id = entry_number.padding(3)
            return "N°$idtt$name"
        }
}

Run the project now and you should see a list like this one:

- mpp1 phone 197x320 - Kotlin Multiplatform Project for Android and iOS: Getting Started

Try to implement getPokemonInfo on your own. Remember that the URL for the request is https://pokeapi.co/api/v2/pokemon-species/$pokemonId/ and the JSON can be serialized to a FlavorTextEntries object. Once you’ve obtained a FlavorTextEntries instance you can apply these operators to get the actual info text:

.flavor_text_entries
.asSequence()
.filter { 
    it.version.name == "red" || 
    it.version.name == "blue" || 
    it.version.name == "yellow" 
}
.filter { it.language.name == "en" }
.toList()
.firstOrNull()
?.flavor_text

If you’re having trouble, you can find the correct code below by pressing Reveal:

[spoiler]

fun getPokemonInfo(
    pokemonId: Int, 
    success: (String) -> Unit, 
    failure: (Throwable?) -> Unit ) {
    GlobalScope.launch(ApplicationDispatcher) {

        try {
            val url = 
              "https://pokeapi.co/api/v2/pokemon-species/$pokemonId/"
            val json = httpClient.get<String>(url)
            Json.nonstrict.parse(FlavorTextEntries.serializer(), json)
                .flavor_text_entries
                .asSequence()
                .filter { 
                    it.version.name == "red" || 
                    it.version.name == "blue" || 
                    it.version.name == "yellow" 
                }
                .filter { it.language.name == "en" }
                .toList()
                .firstOrNull()
                ?.flavor_text
                ?.also(success)
        } catch (ex: Exception) {
            failure(ex)
        }
    }
}

[/spoiler]

Don’t forget to annotate the appropriate class or classes with @Serializable in PokemonInfo.

Type Aliasing a Class

The last function to implement is getPokemonSprite(..). There is no class in the common module that can represent a sprite — a two-dimensional image — so the success callback returns an object of type Any?. The classes used to represent an image are platform specific: Android represents an image as Bitmap whereas iOS represents an image with UIImage.

Kotlin provides a mechanism to overcome this problem: typealias. Start by creating a NativeImage.kt file inside every common module, Android and iOS, in com.raywenderlich.pokelist/shared at the same level as Dispatchers.kt. In NativeImage.kt, add this class: expect class Image.

Initially, you’ll receive an error message because there is no actual class implementation for the expected declaration. To fix this, add actual typealias Image = Bitmap in the Android module and actual typealias Image = UIImage in the iOS module. You can now reference the Image class in the common module and the compiler will replace it with Bitmap when compiling for Android and UIImage when compiling for iOS.

Now, you can go back to PokeApi and replace Any? with your newly created Image? in the success callback of getPokemonSprite(..). Next, in the Android module, remove the cast to Bitmap in MainActivity.kt inside getPokemonSprite(..).

- mpp7 idea remove cast 1 650x59 - Kotlin Multiplatform Project for Android and iOS: Getting Started

Finally, open Xcode, navigate to iosApp/iosApp/PokeListViewController.swift and remove the cast to UIImage in getPokemonSprite(..).

- mpp8 xcode remove cast 1 650x84 - Kotlin Multiplatform Project for Android and iOS: Getting Started

Downloading the image requires a simple GET request but you must specify that you’re now downloading a ByteArray and not String. Now, convert the ByteArray to an Image by opening NativeImage.kt in the common module and add this method signature: expect fun ByteArray.toNativeImage(): Image?. In the Android module, you can add this function to NativeImage.kt:

actual fun ByteArray.toNativeImage(): Image? =
    BitmapFactory.decodeByteArray(this, 0, this.size)

Since Image is a typealias to Bitmap, you can use this function as-is — no cast needed.

In iOS, the situation is different. You need to write a function to convert a ByteArray to a UIImage in Kotlin. This tutorial is about MPP and not K/N, so I have provided the code, here:

@ExperimentalUnsignedTypes
actual fun ByteArray.toNativeImage(): Image? =
    memScoped {
        toCValues()
            .ptr
            .let { NSData.dataWithBytes(it, size.toULong()) }
            .let { UIImage.imageWithData(it) }
    }

If you’re interested in K/N despite its experimental status, you can find a tutorial here.

At this point, you need to call toNativeImage() on your ByteArray and return it from the success callback. Compile and that’s it!

You can now run your project on both Android and iOS. Tap on an item in the list and you should see an image and a description.

- mpp10 result 360x320 - Kotlin Multiplatform Project for Android and iOS: Getting Started

Where to Go From Here?

Kotlin Multiplatform Project is constantly evolving, so the best advice is to check kotlinlang’s Slack channel here and don’t be shy!

You can find other official communication channels here.

The official documentation is here, but keep in mind that the resource may not always be up-to-date. Last but not least, here’s a talk about Kotlin MPP Architecture from Kevin Galligan, one of the most active users in the Multiplatform community.

If you have any questions or comments, feel free to join our discussion forum below!



Source link

LEAVE A REPLY

Please enter your comment!
Please enter your name here