Why we chose Kotlin as one of our target languages. Part 2: Kotlin Multiplatform

We continue the series of articles on introducing the Kotlin language into our development process. Look for the first part here .

In 2017, the ambitious project from Jetbrains saw the light of day, offering a new perspective on cross-platform development. Compiling kotlin code into native code of various platforms! We in Domklik, in turn, are always looking for ways to optimize the development process. What could be better than reusing code, we thought? That's right - do not write code at all. And so that everything works as you want. But while this does not happen. And if there is a solution that would allow us, without spending too much effort, to use a single code base for different platforms, why not try?

So hello everyone! My name is Gennady Vasilkov, I am an android developer at Domklik and today I want to share with you our experience in developing Kotlin Multiplatform for mobile devices, tell us what difficulties we encountered, how we solved and what we ended up with. The topic will surely be of interest to those who want to try Kotlin MPP (Multiplatform projects), or have already tried it, but did not finish production. Or brought, but not as I would like. I will try to convey our vision of how the process of developing and delivering developed libraries should be arranged (using one of them as an example, I will tell you the beginning of our development path in Kotlin MPP).

Would you like stories how we did it? We have them!



Briefly about technology


For those who have not yet heard or not plunged into the topic, it hangs in the Java world and just goes to the Kotlin world (or doesn't go, but peeps): Kotlin MPP is a technology that allows you to use once written code on many platforms at once.

The development is, not surprisingly, in the Kotlin language in IntelliJ IDEA or Android Studio. The plus is that all android developers (at least with us) know and love both the language and these wonderful development environments.

Also a big plus in compiling the resulting code into languages ā€‹ā€‹native to each platform (OBJ-C, JS, everything is clear with JVM).

That is, everything is cool. In my opinion, Kotlin Multiplatform is ideal for taking business logic to libraries. It is also possible to write an application completely, but for now it looks too extreme, but library development is suitable for such large projects as ours.

A little technical, to understand how the project works on the Kotlin MPP


  • The build system is Gradle , it supports syntax in groovy and kotlin script (kts).
  • Kotlin MPP has the concept of targets - target platforms. In these blocks, the operating systems we need are configured, the native code of which will compile our code on Kotlin. In the picture below 2 targets are implemented, jvm and js (the picture is partially taken from the website ):

    Support for other platforms is implemented in the same way.
  • Source sets - the name itself is clear that source codes for platforms are stored here. There is a Common source set and platform (there are as many of them as there are targets in the project, this is followed by the IDE). It is impossible not to mention the expect-actual mechanism here.



    The mechanism allows accessing platform-specific code from the Common module. We declare expect declaration in the Common module and implement it in the platform ones. Below is an example of using the mechanism to get the date on devices:

    Common:
    internal expect val timestamp: Long
    
    Android/JVM:
    internal actual val timestamp: Long 
        get() = java.lang.System.currentTimeMillis()
    
    iOS:
    internal actual val timestamp: Long 
        get() = platform.Foundation.NSDate().timeIntervalSince1970.toLong()
    

    As you can see for the platform modules, the Java and iOS Foundation system libraries are available, respectively.

These points are enough to understand what will be discussed later.

Our experience


So, at some point, we decided that everything was accepted by Kotlin MPP (then it was also called Kotlin / Native) as a standard and we started to write libraries into which we took out the common code. At first it was code only for mobile platforms, at some point they added support for jvm backend. On android, the development and publication of the developed libraries did not cause problems, on iOS, in practice, they encountered some problems, but they were successfully solved and a working model for developing and publishing frameworks was worked out.

Time for something to hang around!


We have carried out various functionalities in separate libraries that we use in the main project.

1) Analytics


Background of occurrence


It's no secret that all mobile applications collect a bunch of analytics. Events are hung with all significant and not very events (for example, in our application more than 600 various metrics are collected). And what is metric collection? In a simple way, this is a call to a function that sends an event with a certain key to the bowels of the analytics engine. And then it goes into a variety of analytics systems like firebase, appmetrica and others. What problems are there with this? Constant duplication of the same (plus or minus) code on two platforms! Yes, and the human factor can not be found anywhere - the developers could be mistaken, both in the name of the keys and in the meta-data set transmitted with the event. This clearly needs to be written once and used on each platform. An ideal candidate for making general logic and testing technology (this is our test pen in Kotlin Mpp).

How to implement


We transferred the events themselves to the library (in the form of functions with a wired key and a set of required meta-data in the arguments) and wrote a new logic for processing these events. The event format was unified and a handler engine (bus) was created, into which all events poured. And for various systems of analytics, adapter listeners wrote (a very useful solution turned out to be, when adding any new analytics we can easily redirect everything, or selectively, each event to a new analytics).



Interesting in the implementation process


Due to the features of the implementation of working with streams in Kotlin / Native for iOS, you canā€™t just take and work with the Kotlin object as a singleton and write data there at any time. All objects that transfer their state between threads must be frozen (there is a freeze () function for this). But the library, among other things, stores a state that can change during the life of the application. There are several ways to resolve this situation, we settled on the simplest:

Common:

expect class AtomicReference<T>(value_: T) {
    var value: T
}

Android:

actual class AtomicReference<T> actual constructor(value_: T) {
    actual var value: T = value_
}

iOS:

actual typealias AtomicReference<V> = kotlin.native.concurrent.AtomicReference<V>

This option is suitable for simple state storage. In our case, the platform configuration is stored:

object Config {
    val platform = AtomicReference<String?>("")
    var manufacturer = AtomicReference<String?>("")
    var model = AtomicReference<String?>("")
    var deviceId = AtomicReference<String?>("")
    var appVersion = AtomicReference<String?>("")
    var sessionId = AtomicReference<String?>("")
    var debug = AtomicReference<Boolean>(false)
}

Why is this needed? We do not get some information at the start of the application, when the module should already be loaded and its objects are already in frozen state. In order to add this information to the config and then it could be read from different streams on iOS and such a move is required.

We also faced the issue of publishing the library. And if Android didnā€™t have any problems with this, then the method proposed by official documentation at that time to connect the assembled framework directly to the iOS project did not suit us for a number of reasons, I wanted to get new versions as simple and transparent as possible. Plus, versioning is supported.
The solution is to connect the framework via a pod file. To do this, they generated the podspec file and the framework itself, put them in the repository and connecting the library to the project became very simple and convenient for iOS developers. Also, again, for the convenience of development, we collect a single artifact for all architectures, the so-called fat framework (actually a bold binary in which all architectures and added meta files generated by the kotlin plugin get along together). The implementation of this whole thing under the spoiler:

Who cares how we did it before the official decision
After the kotlin plugin has collected for us a bunch of different frameworks for different architectures, we manually create a new universal framework into which we copy meta data from any (in our case, we took arm64):
//add meta files (headers, modules and plist) from arm64 platform
task copyMeta() {
    dependsOn build

    doLast {
        copy {
            from("$buildDir/bin/iphone/main/release/framework/${frameworkName}.framework")
            into "$buildDir/iphone_universal/${frameworkName}.framework"
        }
    }
}

, , , . lipo, :
//merge binary files into one
task lipo(type: Exec) {
    dependsOn copyMeta

    def frameworks = files(
            "$buildDir/bin/iphone32/main/release/framework/${frameworkName}.framework/$frameworkName",
            "$buildDir/bin/iphone/main/release/framework/${frameworkName}.framework/$frameworkName",
            "$buildDir/bin/iphoneSim/main/release/framework/${frameworkName}.framework/$frameworkName"
    )
    def output = file("$buildDir/iphone_universal/${frameworkName}.framework/$frameworkName")
    inputs.files frameworks
    outputs.file output
    executable = 'lipo'
    args = frameworks.files
    args += ['-create', '-output', output]
}

, - Kotlin MPP Info.plist UIRequiredDeviceCapabilities, ( ). PlistBuddy:
//workaround
//remove UIRequiredDeviceCapabilities key from plist file (because we copy this file from arm64, only arm64 architecture was available)
task editPlistFile(type: Exec) {
    dependsOn lipo

    executable = "/bin/sh"
    def script = './scripts/edit_plist_file.sh'
    def command = "Delete :UIRequiredDeviceCapabilities"
    def file = "$buildDir/iphone_universal/${frameworkName}.framework/Info.plist"

    args += [script, command, file]
}

edit_plist_file.sh:
/usr/libexec/PlistBuddy -c "$1" "$2"

, zip ( podspec ):
task zipIosFramework(type: Zip) {
    dependsOn editPlistFile
    from "$buildDir/iphone_universal/"
    include '**/*'
    archiveName = iosArtifactName
    destinationDir(file("$buildDir/iphone_universal_zipped/"))
}

podspec :
task generatePodspecFile(type: Exec) {
    dependsOn zipIosFramework

    executable = "/bin/sh"

    def script = './scripts/generate_podspec_file.sh'

    def templateStr = "version_name"
    def sourceFile = './podspec/template.podspec'
    def replaceStr = "$version"

    args += [script, templateStr, replaceStr, sourceFile, generatedPodspecFile]
}

generate_podscpec_file.sh :
sed -e s/"$1"/"$2"/g <"$3" >"$4"

curl :
task uploadIosFrameworkToNexus(type: Exec) {
    dependsOn generatePodspecFile

    executable = "/bin/sh"
    def body = "-s -k -v --user \'$userName:$password\' " +
            "--upload-file $buildDir/iphone_universal_zipped/$iosArtifactName $iosUrlRepoPath"
    args += ['-c', "curl $body"]
}

task uploadPodspecFileToNexus(type: Exec) {
    dependsOn uploadIosFrameworkToNexus

    executable = "/bin/sh"
    def body = "-s -k -v --user \'$userName:$password\' " +
            "--upload-file $generatedPodspecFile $iosUrlRepoPath"
    args += ['-c', "curl $body"]
}

:
task createAndUploadUniversalIosFramework() {
    dependsOn uploadPodspecFileToNexus
    doLast {
        println 'createAndUploadUniversalIosFramework complete'
    }
}


Profit!


At the time of writing, there are official solutions for creating a fat framework and generating a podspec file (and generally integration with CocoaPods). So we just have to upload the created framework and podspec file to the repository and connect the project in iOS in the same way as before:




After implementing such a scheme with a single place for storing events, a mechanism for processing and sending metrics, our process of adding new metrics was reduced to the fact that one developer adds a new function to the library, which describes the event key and the necessary meta-data that the developer must transmit for a particular event . Further, the developer of another platform simply downloads a new version of the library and uses the new function in the right place. It has become much easier to maintain the consistency of the entire analytics system; it is also much easier now to change the internal implementation of the event bus. For example, at some point, for one of our analysts, it was necessary for us to implement our own event buffer, which was done immediately in the library, without the need to change the code on the platforms. Profit!

We continue to transfer more and more code to Kotlin MPP, in the following articles I will continue the story of our introduction to the world of cross-platform development using two more libraries as examples, in which serialization and the database were used, and work over time. Iā€™ll tell you about the restrictions that were encountered when using several kotlin frameworks in one iOS project and how to get around this limitation.

And now briefly about the results of our research


+ Everything works stably in production.
+ Unified code base of business service logic for all platforms (fewer errors and discrepancy).
+ Reduced support costs and reduced development time for new requirements.
+ We increase the expertise of developers.

Related Links


Kotlin Multiplatform website: www.jetbrains.com/lp/mobilecrossplatform

ZY:
I donā€™t want to repeat about laconicism of the language, and therefore focus on business logic when writing code. I can advise several articles that

cover this topic: ā€œKotlin under the hood - see decompiled bytecodeā€ - an article describing the operation of the Kotlin compiler and covering the basic structure of the language.
habr.com/en/post/425077
Two parts of the article "Kotlin, compilation in bytecode and performance", supplementing the previous article.
habr.com/ru/company/inforion/blog/330060
habr.com/ru/company/inforion/blog/330064
Pasha Finkelsteinā€™s report ā€œKotlin - two years in production and not a single gapā€, which is based on the experience of using and implementing Kotlin in our company.
www.youtube.com/watch?v=nCDWb7O1ZW4

All Articles