This is an article co-written by me and my company colleagues and published on the company’s official account — Ctrip technology. Now it is reprinted to Kotlin Shanghai user group. Basically summed up this half year team practice KMM results.
I. Background and selection
Mobile terminal cross-platform technology has been a hot topic since the birth of mobile development. First, continuous attention should be paid to r&d efficiency, cost reduction and efficiency improvement. Second, a set of code multi-terminal operation can improve the consistency of multi-terminal business logic; Third, cross-end solutions usually mean better operational efficiency and defect repair.
After years of development, Facebook’s React Native is the most widely adopted cross-platform development framework in the industry, while Google’s Flutter is currently the most expected one. Although the two are very different in design and principle, they both use non-native development languages to run on the “attic” built on the Android and iOS system frameworks. Each App using these frameworks needs to integrate many heavyweight packages and libraries such as the Runtime of the language and the underlying components of the framework when packaging. In addition, the interaction between JavaScript or Dart and native development languages (Java/Kotlin, Objective-C/Swift) needs to be achieved through “bridge communication”, resulting in the need for both module architectures to coordinate whenever changes are needed at the system framework level.
As mobile developers, we wanted to find a cross-platform framework that matched the performance of native code, interacted well with native code, and had a similar development philosophy.
JetBrains proposed a cross-platform solution different from RN and Flutter, which uses different compilers to compile the same code and generate different products on each end to achieve cross-platform purpose. This is Kotlin Multiplatform. Kotlin has different names depending on which platform it runs on. For example, Kotlin/JVM is compiled as class bytecode and runs on the JVM and Android platforms. Kotlin/Native is compiled as binary code and runs directly on the operating system without a VIRTUAL machine environment. There is also Kotlin/JS, etc. (See Reference link 1 for an official introduction to the Kotlin Multiplatform).
Kotlin intercalls directly with the platform’s native development language on different platforms. On Android, Kotlin is officially supported as a first-class development language and interoperates with Java, of course. In Kotlin/Native, Kotlin can also interoperate directly with C and Objective-C code on the iOS platform just as it interoperates with Java (functions, classes, interfaces are visible to each other, basic types and collection types can be mapped to each other). Other languages, such as Swift and Kotlin/Native, have limited interoperability and are being improved.
Kotlin’s cross-platform framework subset for Mobile is called Kotlin Multiplatform Mobile, or KMM for short. The architectural design concept of KMM is as follows:
The code that developers write is divided into three main source sets. The code that interacts directly with the platform is located in the source set named after the platform. For example, the Kotlin code in the Android Source set can call the JDK, Android SDK, and other Android/Java open source libraries, The Kotlin code in the iOS source set makes direct calls to the Posix C API, Foundation, and other C/Objective-C open source libraries supported by the iOS platform. The Common code for both ends is in the Common Source set.
The construction of the whole project is driven by Gradle. When compiling and packaging, Kotlin codes of Common and Android source sets are combined and compiled into Android platform products (AAR files). The Kotlin code of Common and iOS source sets is combined, compiled and packaged as a product of iOS platform (framework file).
The main advantages of KMM over cross-platform frameworks such as RN and Flutter are:
1) Mobile native stack developers get started faster.
2) No additional runtime environment, performance is basically the same as native code.
3) It can seamlessly connect with the existing original base library, and the cost of infrastructure transformation is small.
4) It can use the existing basic technologies such as native plug-in, memory monitoring, crash/lag monitoring, without additional development support.
However, KMM is a cross-platform technology and framework at the language level and is currently in alpha, so there are still some disadvantages, including:
1) Kotlin/JVM is different from Kotlin/Native’s asynchronous concurrency model.
2) KMM community ecological environment is still under construction and there is no mature UI framework, so it cannot be used to write UI. The Kotlin compiler is still in a phase of rapid iterative upgrades, so the apis associated with metaprogramming are unstable.
In 2020, Ctrip Android team migrated the historical Java code of core business to Kotlin + Coroutines + Jetpack AAC technology stack and achieved good results. See Ctrip Flight Android Jetpack and Kotlin Coroutines Practice for more details. Kotlin, Coroutines, MVVM, and other new architecture models have been tested with tens of millions of visits on Android, so we decided to start experimenting with KMM in early 2021, gradually expanding Kotlin to iOS.
Ii. Overall design and integration
As KMM is still in the alpha stage, its initial positioning is to achieve cross-platform sharing of business logic code, including: data model, network request, local data storage, and business logic processing.
If you want to build a KMM project from scratch, IntelliJ IDEA or Android Studio KMM template plug-in can help to create the whole project is a regular Gradle project, contains two Gradle Module sub-modules. Android App and KMM Module respectively. The Android app references the KMM Module directly through project dependencies, and also includes an iOS Xcode project.
However, our scenario is to introduce KMM in the existing and independent Ctrip Android and iOS App projects, so we need to integrate KMM as a separate sub-project module. Ctrip’s Android and iOS App engineering structures are generally similar. The bottom layer is the common library and framework that the common foundation team is responsible for, and the upper layer is the bundle of various business teams that rely on the common framework layer. As an independent project, KMM needs to rely on the base library, and the airline ticket business bundle depends on the KMM cross-end shared business logic project.
The dependency relationship of the simplified version of KMM, Android and iOS sub-projects of the airline ticket business engineering set is shown as follows:
The Android project relies on the airline ticket KMM project, built by Gradle and published to the company’s internal Maven source AAR file; The iOS project builds the generated framework by integrating KMM locally (currently investigating the migration to CocoaPods integration solution).
We hope to reuse and extend the previous optimization and upgrade results of Android Jetpack AAC, so the business code architecture continues to use MVVM mode, which is divided into three parts: View, ViewModel and Model. At present, KMM still lacks mature and reliable UI framework. The UI layer retains the original development mode for the time being, which is implemented by the platform separately, while the Model layer and ViewModel layer are carried by KMM project.
2.1 the Android integration
KMM Android integration is very simple, no different from normal Android third-party library integration. KMM projects created using IntelliJ IDEA or Android Studio’s KMM plug-in generate the Android Source set by default, and Gradle Build Task performs the AAR file generation. Of course, if we want to create a generic JVM platform shared library (without calling any Android SDK and third-party library apis), we can change the Android Source Set to the JVM Source Set. Gradle Build Task generates JAR files.
AAR or JAR file products generated by KMM sub-project modules can be published and uploaded to the designated Maven source repository for centralized dependency management, whether they are new independent KMM App projects or integrated KMM modules based on existing App projects. Callers add dependencies through statements such as Gradle/Maven API or implementation. This is very Java/Kotlin developer friendly with no additional learning cognitive costs. Of course, for small personal projects, you can also use the simple Local Module Project Local direct dependency method.
KMM Module engineering integration is in the same line with conventional Android Libraray Module engineering integration. Several common problems encountered in practice are sorted out:
1) When setting the Target Java version of THE KMM project, try to keep it consistent with the main project to be integrated; otherwise, if the TARGET Java version of the KMM is too high, the main project may fail to be built.
2) After the main project integrates the KMM project, pay attention to setting the obfuscating policy, otherwise it is easy to trigger NoClassDefFoundError exception during the runtime.
3) Set Duplicates strategy correctly when building a new Gradle. Otherwise, the master project may fail to integrate.
IOS 2.2 integration
IOS integration is a little more complicated than Android integration. IOS developers need to first learn the basics of Gradle configuration and Intellij IDEA or Android Studio IDE.
Two keys to iOS integration:
1) Configure the KMM project to rely on the required Objective-C project so that Kotlin code can access and call objective-C code, and compile and package correctly.
2) Configure the KMM project compilation package to import the generated artifacts into the Xcode project so that the Objective-C code can access the Kotlin code.
The Kotlin Native SDK has pre-built all the apis for iOS, and developers need to manually bridge Kotlin code with their own Objective-C code or other third-party library code. This part of the work is not complicated, because the final product files of KMM are the regular.framework/. A files of iOS system. The principles follow the common knowledge of iOS platform development, and the learning curve is more friendly for iOS developers.
Here are just a few examples of iOS integration problems:
2.2.1 cinterop
The cinterop tool can encapsulate and translate all public apis of a specified C/Objective-C library into Kotlin API, generating a Klib file format for KMM project invocation. After processing, developers can implement calls to these apis in the iOS Source set code of the KMM Project.
Declare the dependent. H,. A project configuration and configure the Gradle project dependencies by defining a def description file.
Example def file:
language = Objective-C
headers = AAA.h
libraryPaths = /Users/xxx/extFramework
staticLibraries = FA.framework
compilerOpts = -I/Users/xxx/extFramework/FA.framework/Headers
Copy the code
Gradle iOS Target configuration example:
target.compilations["main"].cinterops.create(name) {
defFile = project.file("src/nativeInterop/cinterop/xxx.def")
packageName = "xxx"
}
Copy the code
The libraryPaths and compilerOpts parameters in the DEF file example involve file path references across project modules, so when large projects collaborate with multiple people and automate build integration, the adaptive reference paths need to be customized.
Based on Git SubModule feature, we first set the dependent iOS native project repository as the reference KMM project repository SubModule, and then add a custom Gradle Task that dynamically obtains the reference path. After obtaining the absolute path through Gradle API, Write the def file. The trigger time of the Task must be set to before the build Task runs.
2.2.2 Double instruction set union problem
The framework file generated by KMM Module is finally run on the real device, namely arm64 format, and the development stage needs to support the simulator device, namely X84_64 format. The official version (1.4.x) did not initially support compiling and running the arm64 and x86_64 instruction sets at the same time, and had to be manually switched and built separately. Starting with the official version 1.5.21, the KMM Plugin solves the problem of combining instruction sets by generating fat-Framework Gradle tasks.
Official Fat-Framework schemes can be built correctly when the KMM Module contains only Koltin code, or when the iOS ObjC library file on which it depends is in a single instruction set format. However, when the iOS ObjC library file you rely on is in multi-instruction set format, the official scheme will report an error exception. Therefore, we shield the official scheme Task, and use custom instruction set and Task implementation.
The default fat-framework configuration is as follows:
gradle.taskGraph.whenReady {
tasks.forEach { task ->
if (task.name.endsWith("fat", true)) {
task.enabled = false
}
}
}
Copy the code
The flow to summarize the iOS ObjC native library, KMM library, bridge and dual instruction set is shown in the figure.
2.2.3 Code comments
Comments from Kotlin code files are not automatically exported to *. Framework and cannot be viewed in the Xcode IDE. Kotlin 1.5.20, official support for comment export, configuration example:
targets.withType<KotlinNativeTarget> {
compilations["main"].kotlinOptions.freeCompilerArgs += "-Xexport-kdoc"
}
Copy the code
2.3 KMM construction of the basic framework
Before writing the business code, the KMM project needs some support from the underlying infrastructure. We first chose two official libraries: Kotlinx. coroutines and Kotlinx. serialization. Currently, most third-party libraries in the Kotlin ecosystem only support Kotlin/JVM, and very few can be used for KMM. Both are currently among the few Kotlin multi-platform libraries available. Kotlinx. Coroutines we chose the branch version of multi-thread rather than the default main thread version, because the main thread version is implemented in a single thread in native target, that is, all asynchronous coroutine tasks run in the main thread, and we want it to run in a multi-threaded environment. Avoid impacting the UI main thread.
Kotlinx. serialization contains two parts, kotlinx.serialization-json and kotlinx.serialization-protobuf. Kotlinx. Serialization-protobuf is currently in beta, and it is a rare JSON serialization library that can be used in KMM. Use to enhance automated test scenario coverage, performance evaluation, and online monitoring.
Ctrip App contains many self-developed frameworks and protocols provided by the public framework team, such as network service, ABTest, incremental configuration reading, buried point reporting system, date and time system, user account system and so on. These base libraries are typically implemented on both sides of Android and iOS, in different programming languages, but the API design, naming, parameter count, and type definition are highly similar. We need to bridge and wrap these existing base libraries into the KMM API for Kotlin Common Source Set calls, and the similar design of the libraries themselves provides us with great encapsulation convenience.
At present, Ctrip App uses MMKV (see Link 2 for details), which is open source by Tencent wechat team, for local key-value pair storage. It uses C++ to write core code and provides upper API of Java, Objective-C and other languages respectively. Ctrip’s public foundation team has implemented another layer of packaging based on the original API of MMKV, which enables the business team to seamlessly migrate from SharedPreference and NSUserDefaults to MMKV. However, the API design at both ends is different due to compatibility with the old code. Air ticket KMM project as a new project without compatible with the old code, decided to directly package MMKV API as the project’s underlying storage framework, here as a simple demo to explain how to bridge package existing Android, iOS libraries.
Let’s start by defining the abstract MMKV type in the common Source set:
expect class MMKV
Copy the code
Of course it’s implementable, we want it to represent Java MMKV directly on Android and Objective-C MMKV directly on iOS.
The Android platform is as follows:
actual typealias MMKV = com.tencent.mmkv.MMKV
Copy the code
You bridge them directly using a type alias, and they are the same type at compile time or run time.
The iOS platform is as follows:
actual typealias MMKV = xxx.xxx.ios.MMKV
Copy the code
There is no concept of a package name on iOS. Xxx.xxx. iOS is a customized package name when using tools such as Cinterop to generate Kotlin Wrapper.
Then use some top-level functions to bridge the static functions of MMKV, and use extension functions to bridge the member functions of MMKV on different platforms, Android is as follows:
internal actual fun defaultMMKV(): MMKV = MMKV.defaultMMKV() internal actual fun getMMKVByDomain(domain: String): MMKV = MMKV.mmkvWithID(domain) internal actual fun MMKV.closeMMKV() = close() internal actual fun MMKV.set(key: String, value: String): Boolean = encode(key, value) internal actual fun MMKV.set(key: String, value: Boolean): Boolean = encode(key, value) // ...... internal actual fun MMKV.takeString(key: String, default: String): String = decodeString(key, default) ? : default internal actual fun MMKV.takeBoolean(key: String, default: Boolean): Boolean = decodeBool(key ,default) // ......Copy the code
IOS is as follows:
internal actual fun defaultMMKV(): MMKV = MMKV.defaultMMKV()!! internal actual fun getMMKVByDomain(domain: String): MMKV = MMKV.mmkvWithID(domain)!! internal actual fun MMKV.closeMMKV() = close() internal actual fun MMKV.set(key: String, value: String): Boolean = setString(value, key) internal actual fun MMKV.set(key: String, value: Boolean): Boolean = setBool(value, key) // ...... internal actual fun MMKV.takeString(key: String, default: String): String = getStringForKey(key, default) ? : default internal actual fun MMKV.takeBoolean(key: String, default: Boolean): Boolean = getBoolForKey(key, default) // ......Copy the code
The basic idea behind encapsulating a bridge is to define its abstraction in the Common Source Set, and then write implementations in the platform-specific Source set that directly call the library functions to be bridged. As can be seen, some API names of MMKV are different between Android and iOS versions. For example, in Android, set a value and the function is named encode, while in iOS, setXXX. In Android, the parameter is usually key before value, while iOS has a habit of value before key. However, there is no fundamental difference in their design. Small differences can be smoothed out in our packaging. To provide a unified API in the Common Source Set.
The above packaging of MMKV is a conventional and relatively simple packaging. In our actual work, the packaging and transformation of network framework is worth mentioning.
The network framework developed by Ctrip is not the standard HTTP protocol, but a large number of customized protocols and other content at the bottom. The upper layer of the framework is implemented in Java and Objective-C respectively. It not only contains the network request itself, but also encapsulates the serialization and deserialization codes of all kinds of data, including Protobuf2. The design of the original network framework is very convenient for the business team to use. Only the class object of The Request Entity and Response Entity class (Both Java and Objective-C have class objects) is passed in as the parameter during the request. Then fetch the Response Entity in the callback to process the network return result.
Since the framework generates Java or Objective-C objects from class objects, and we cannot get the Kotlin class object in the KMM project (the root of the problem is discussed in section 3.3), Therefore, the current network framework cannot support generating the Request or Response Entity defined by Kotlin. We made minor changes to the original network framework, providing an option without serialization and deserialization. Frame users can directly transfer the serialized request Entity binary data to the framework, and the framework will also return the response Entity binary data before deserialization to frame users. This allows us to serialize or deserialize using Kotlinx. serialization within the KMM project. In addition, Kotlin’s ByteArray for binary data is exactly equivalent to byte[] in Java, but incompatible with Objective-C NSData, In the processing of iOS, ByteArray and NSData need to be converted to each other by manually declaring memory regions.
The network framework design of KMM is shown in the following figure:
To solve serialization and deserialization problems, we also need to package the original callback API into the Kotlin suspend API to better integrate it into the coroutine structured concurrency architecture:
RequestEntity public static String requestEntity (baserrequest entity); NetworkCallback callback) { // ...... }Copy the code
Kotlin suspend packaging:
RequestEntity: BaseRequestEntity (requestEntity: BaseRequestEntity): BaseResponseEntity = suspendCancellableCoroutine {continuation - > / / call library functions, Hang coroutines val requestID = NetworkUtil. SendSOTPRequest (requestEntity) {baseResponseEntity, error - > if (baseResponseEntity? .isSuccess == true && error == NULL) {// Request succeeded, resume coroutine and return continuation. Resume (baseResponseEntity)} else {// Request failed, Recovery in the form of abnormal coroutines and abnormal back to continuation. ResumeWithException (NetworkException (error) message)}} / / cancel the logic continuation.invokeOnCancellation { NetworkUtil.cancelTask(requestID) } }Copy the code
By calling the association Cheng Ku function suspendCancellableCoroutine coroutines hung them after the request, according to the success or failure of network request in a different way to restore coroutines, and processing when the external coroutines was cancelled at the same time, cancel the logic of the network request.
2.4 Business Model module
According to the development sequence from bottom to top, after the basic underlying architecture is set up, the code of Model layer in MVVM mode should be standardized in the preparation of KMM engineering business layer code. The generalized Model layer code includes various data classes, utility functions and classes, business processing logic, and so on. In short, the Model layer does not have mutable state as much as possible and only provides pure functions for external and upper level calls. For example, we want to provide a city-specific business module for the ViewModel layer to use, which would roughly declare an API of the following form:
Data class CityModel(val cityName: String, val cityId: Int,) object CityManager {suspend fun getAllCity(): Map<String, CityModel> { // ...... Suspend Fun queryCity(cityId: Int):CityModel {//...... }}Copy the code
Each piece of related business corresponds to an Object singleton (which in most cases only acts as a namespace).
2.5 Cross-end architectural pattern component attempts — MVIKotlin
We wanted to find an architecture pattern component framework similar to Jetpack AAC for KMM to implement THE MVVM pattern, but there was no such mature framework available in the open source community, and due to official recommendations, We focused our attention on MVIKotlin (see link 3 for the official website).
The functionality of MVIKotlin is to implement MVI mode, which is simply an improved version of MVVM. In MVVM, the View listens for changes in data within the ViewModel (LiveData/StateFlow, etc.) to complete updates, while user operations on the View trigger changes in data state through direct calls to the ViewModel. In MVI, the View triggers changes in the data state to send “intents”, further decoupling.
After preliminary investigation, we believe that MVIKotlin has the following advantages:
1) Already in release, the released open source library is rare for the various dev, alpha and beta versions in the KMM community.
MVIKotlin also provides binding for Reaktive and Coroutines.
We introduced MVIKotlin to the KMM project, and selected a piece of business for experimental development and access. Finally, after a month of continuous operation in the production environment, its stability also temporarily stood the test. However, during the development phase, we found an obvious drawback – it is inconvenient to use.
MVIKotlin has a component that has similar functionality to Jetpack ViewModel — Store, which is extremely complex in design, as shown in the official introduction below:
Store is only one part of THE MVI mode, but it has a large number of components with their own functions. The components such as Bootstrapper, which can only be used once at each startup, also need to be defined separately. In each development process, even simple business developers need to write a large number of sample codes. To make matters worse, the data changes its name every time it passes through a component in the Store, which is really not a problem conceptually because the data is conceptually different when it moves between components. However, in MVIKotlin’s design, each concept is represented by a sealed class and a number of its subclasses, and each component uses the WHEN expression to determine which subclass of the object is its parent. Problems with this design include an explosion in the number of classes in the project, and it is theoretically inefficient to make multiple instanceof judgments for every simple business call in the JVM.
As a result, we decided not to use MVIKotlin. The ideal alternative we imagined would be to port Jetpack AAC to KMM ourselves. Currently StateFlow can replace LiveData, so we would just port Lifecycle and ViewModel, but that would be a lot of time. We are still in the research stage, and we will keep an eye on other architectural component frameworks in the open source community.
More discussion of MVIKotlin can be found in my own article (see link 4).
Challenges and countermeasures
KMM exploration has not been smooth sailing, and KMM and Kotlin/Native, as new technologies in the alpha phase, present considerable challenges. Over the course of nearly a year of development, we encountered many bugs, problems, and so-called “potholes.” As developer users, we are just as active in asking questions or reporting bugs as any other developer involved in community building. Officials are eager to give answers and will repair or improvement plan on the roadmap, but the official dealing with some big cycle in years, so we can only as far as possible with the minimum cost temporarily deal with or avoid these problems, the following will outline the main problem we encountered and the solution of the corresponding strategy.
However, due to space constraints, each question is probably covered briefly, but some references will be included.
3.1 Kotlin/JVM is incompatible with the Kotlin/Native asynchronous concurrency model
Kotlin/Native’s asynchronous concurrency model is constrained by the object subgraph mechanism, which is completely different from Kotlin/JVM’s freedom to write asynchronous concurrent code. The object subgraph mechanism can be summarized as follows:
1) Each object is bound to the thread where it was born. Once the object is accessed by other threads, that is, if the thread ID recorded in the object subgraph of the object is detected to be inconsistent with the current thread, the program will crash immediately.
2) To access the same object in multiple threads, the object can only be separated and rebound as an object subgraph.
3) Frozen objects, frozen objects can be accessed in any thread, but frozen objects cannot be “written”. Once the “write” operation is performed, the frozen object will crash immediately, and the frozen object cannot be unfrozen.
See link 5 for an earlier article on Kotlin/Native’s asynchronous concurrency model.
The direct result of this problem is that the same code can run normally on Android if it can be compiled, but crash on iOS. In addition, it has created a series of collateral or related problems including:
1) Coroutines have no dispatchers. IO on Kotlin/Native.
2) The coroutine scheduler dispatchers. Default is a thread pool implementation on Kotlin/JVM and a single background thread implementation (multi-thread version) on Kotlin/Native.
3) We cannot write a coroutine scheduler based on pooling technology on Kotlin/Native, because it may crash due to different threads when suspending and recovering.
4) Previously, the coroutine suspend lock Mutex was bugged on Kotlin/Native and did not work properly (fixed after Kotlinx.coroutines 1.4.2).
Whether this problem is solved or not will determine whether KMM can be used in the production environment. After our research and evaluation, we developed a series of solutions. First, in KMM project, all coroutines can only be opened in the main thread. Second, when performing tasks that need background threads to perform, specially written apis of higher-order functions are used to perform them. Finally, all mutable states (usually member variables) must be updated in the main thread.
We have written our own set of higher-order apis to perform asynchronous tasks, which look like this:
The Common Source Set API has a uniform abstraction and definition, while the Android and iOS source set implementations have different processing flows. In Android implementations, just use the withContext library function to switch directly to dispatchers.default. In the implementation of iOS source set, the coroutine standard library function suspendCoroutine is first used to suspend the coroutine, and then all the parameters passed in are separated into object subgraph, and then the GCD provided by the system is used to perform asynchronous tasks. The object subgraph is rebound in the callback of the asynchronous task executed by GCD, and then finally switched back to the main thread using GCD (again, object subgraph separation and binding) to restore the coroutine. However, the use of this API also has a very strict condition, that is, its lambda expression parameters are not allowed to capture any external variables (including local variables, member variables, top-level variables, etc.), otherwise there will be a risk of crash at runtime.
Here is the implementation code of iOS for intuitive understanding:
/ / no parameter version @ OptIn (ExperimentalUnsignedTypes: : class) actual suspend the inline fun < reified R > calculateOnBackground(crossinline block: () -> R): R = suspendCoroutine { continuation -> val queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_BACKGROUND.toLong() ,0) val continuationGraph = continuation.wrapToDetachedObjectGraph() dispatch_async(queue, {/ / the lambda run in child thread val resultGraph = block () wrapToDetachedObjectGraph () val tempContinuationGraph = ContinuationGraph. Attach (). WrapToDetachedObjectGraph () dispatch_async (dispatch_get_main_queue () {/ / the lambda will run in the main thread, Use to call the resume function in the main thread to resume the coroutine, Avoid IncorrectDereferenceException tempContinuationGraph. Attach (). The resume (resultGraph. Attach ())}. Freeze ())}. Freeze ())} <reified P0, reified R> calculateOnBackground(P0: P0, Crossinline block: (P0) -> R): R { val p0Graph = p0.wrapToDetachedObjectGraph() return calculateOnBackground { block(p0Graph.attach()) } } // More parameter versions and so on...Copy the code
This API ensures that both parameter passing and value return are performed in the main thread, and only the calculation is performed in the background thread. The difference masking of the asynchronous concurrent models at both ends is successfully encapsulated in their respective source sets, and the common layer program writers only need to use the API according to the rules without worrying about the differences at both ends.
Although our solution can solve this problem temporarily, the source of the problem still lies in the official design, which has been criticized for a long time since the birth of Kotlin/Native. The initial official response was to use locks to ensure concurrency safety and to be error-prone, and therefore to explicitly expose all cross-thread access to objects at compile time, but this caused several problems:
1) Traditional mobile developers can’t adapt for a while.
2) Kotlin is not a purely functional programming language, and getting rid of mutable state completely would lead to a very awkward programming style and would not be suitable for UI programming.
3) Too much difference with Kotlin/JVM, resulting in blocked code reuse.
The community does not agree with the official explanation of why the object subgraph mechanism was designed, but it is generally believed that Kotlin/Native was rushed out and the team was unable to build an efficient and stable GC system with no memory leaks in a multi-threaded environment. The good news is that since Roman Elizarov became leader of Kotlin’s team and is committed to changing this, Kotlin’s roadmap is that the new GC will be in preview form in 1.6, After 1.6.20, it will enter stable state. At that time, Kotlin/Native object subgraph mechanism will provide switches to close, and developers will guarantee concurrency security through Mutex and other mechanisms of coroutines, which can be expected in the future.
3.2 Kotlin/Native calls non-virtual functions using static dispatch
The mechanism on the JVM for calling non-virtual functions for polymorphism is called dynamic dispatch, which means that the version of the function being called is known only at run time (inheriting classes, implementing interfaces override functions). In Kotlin/Native, however, we created a Native Console Application (macOS 11.6) using Intellij IDEA and wrote the following code to see the results:
fun main() { val data = Data<A>(B()) val a = data.getSomething() a? .print() } interface Base { fun print() } class A : Base { override fun print() = println("123") } class B : Base { override fun print() = println("456") } class Data<T : Base>(val b: Base) { fun getSomething(): T? = b as T }Copy the code
This code should crash, but miraculously prints “123”, which means we are calling A member function of type A from an object of type B. The only explanation for this strange phenomenon is a? The.print() line determines at compile time which version of print is called, that is, Kotlin/Native calls non-virtual functions using static dispatch. The static dispatch implementation itself doesn’t cause this problem, but Kotlin/Native erases generics in the same way Kotlin/JVM does. When the two approaches meet, this is a confusing bug. After I asked the official on YouTrack, the official response summed up as follows: “I know, but this is a feature :)” (see link 6)
3.3 The root superclass of the Kotlin class is incompatible with the root superclass of the Objective-C class
In Kotlin, the root superclass of all classes is Any. When we packaged the KMM project to generate the framework for iOS, we looked at its internal header file and saw that all Kotlin classes have a superclass called KotlinBase. KotlinBase is not visible in the KMM project. It is defined in the framework as:
open class KotlinBase : NSObject {
open class func initialize()
}
Copy the code
This is Swift code, and NSObject is the root superclass of all objective-C classes, and it looks like all the Kotlin classes should be subclasses of NSObject, but when you get to the Kotlin project, something strange happens, The Any class has no subtyping relationship with the NSObject class. That is, if a function (whether it’s a Kotlin function or an Objective-C function) takes an argument of type NSObject, then if you call that function in the Kotlin project, Passing in any Kotlin object (unless you explicitly declare that the Kotlin class inherits from NSObject) will not compile, but it does in Xcode projects.
Another problem with this problem is that in the Kotlin project, no Kotlin object can get its own class object. Each Objective-C object can get its own class object, similar to the class type in Java or the KClass type in Kotlin, but since the Kotlin class is not a subclass of NSObject in the Kotlin project, This operation cannot be completed.
A possible implication of this problem is that some Objective-C apis require class objects to generate instances of their corresponding classes. For now, please refer to section 2.2 for the impact of this problem, but we use other design schemes to avoid it. It’s sort of an official design paradox that hopefully will be resolved later.
3.4 Kotlin/Native object defined within the scope of the implicit variable state could throw InvalidMutabilityException at runtime
In section 3.1, we mentioned Kotlin/Native’s unique asynchronous concurrency mechanism, so there are extremely strict restrictions on mutable and immutable in Kotlin/Native. In Kotlin/Native, an object can only exist in one of two ways: either it is frozen (that is, all its internal members are immutable), or it must be annotated with @threadLocal, in which case it becomes thread private.
object MyObject {
var index = 0
}
Copy the code
The compiler throws a warning: “Variable in singleton without @threadlocal can’t be changed after initialization”. If to modify the index at run-time, thrown directly InvalidMutabilityException exception and crash. But let’s consider the following code:
object MyObject {
val hashMap = HashMap<String, String>()
}
Copy the code
The compiler does not throw any warnings, but as soon as we put the hashMap at run time, the program immediately throws an exception and crashes. Therefore, freezing usually freezes the entire reference tree, and making changes in the underlying reference tree that the compiler cannot alert you to can also create potential crash hazards that the developer cannot detect at compile time, requiring extreme care. The solution to this problem is either to have only constants and pure functions inside object, or to add @threadLocal annotations, and nothing else.
3.5 A NoClassDefFoundError is raised by the coroutine exception handler
The problem is with the Kotlin coroutine on the JVM platform. The problem phenomenon is as follows: After an exception occurs inside the Kotlin coroutine, the coroutine is handled by an exception handler, But at the time of loading exception handler will quote kotlinx. Coroutines. NoClassDefFoundError CoroutineExceptionHandlerImplKt this class cannot be found. By reading the source code, we know that kotlinx.coroutines uses a ServiceLoader for its internal load exception handler. Our initial attempts to reproduce the problem were unsuccessful.
Later, on JetBrains’ YouTrack, we saw a similar case (see link 7) where the questioner provided a demo project claiming to solve the problem, which was an Intellij IDEA Plugin project. In theory, it could be used to validate the Kotlin/JVM coroutine problem, but we still couldn’t reproduce it after running the project as README. After searching again, it was found that there was a similar issue in Github kotlinx.coroutines. The official reply was that this was a JDK bug (see link 8). Our current guess is that it is related to the specific JDK version. Therefore, this problem is worth monitoring and paying attention to when using coroutines.
Iv. Ecological environment
Kotlin’s original tagline was: “Better Java”, a goal Kotlin has accomplished since iteration 1.3.x. Since the 1.4.x release JetBrains has focused the Kotlin iteration on the multi-platform space. As mentioned in section 3.1, a new memory management system designed to address Kotlin/Native’s unique asynchronous concurrency mechanism has been previewed in 1.6.0-M1 (see link 7) and will be released in the official 1.6.x release. Official libraries such as Ktor and Kotlinx library are the backbone of Kotlin cross-platform. At present, Ktor can provide stable HTTP request and data serialization/deserialization functions in multi-platform environment, which is quite powerful Kotlin multi-platform network library. Kotlinx. datetime (kotlinx.coroutines, kotlinx.serialization, kotlinx.datetime) Used to provide a uniform date and time API on Kotlin across the platform.
As we explored KMM, we saw a real improvement in Kotlin’s ability to interact with native languages, from Kotlin’s generic support to mapping to Objective-C, to objective-C /Swift’s ability to call the Kotlin suspend function, Kotlin’s rapport with the iOS platform’s natives is growing, and improvements in interoperability with Swift are on the official list (see link 8).
JetBrains is not alone in Kotlin’s ecological development. Square, the pioneer of Android open source, opened source SQLDelight, the first database framework for KMP (see Link 9). They are also actively migrating many of their Android libraries, including Okio, to KMP, and are investigating UI cross-platform (see Workflow-Kotlin, Link 10). Touchlab also contributes a number of useful tools for KMP. In addition to Ctrip tickets, Alibaba, Tencent, Meituan, Kuaishou and other large factories are also actively trying KMM. In the future, our team will conduct more technical exploration and output, so as to make its own contribution and influence in the Kotlin technology community.
5. Reference links
[1] Kotlin multi-platform official introduction
Kotlinlang.org/docs/mpp-in…
【 2 】 MMKV
Github.com/Tencent/MMK…
【 3 】 MVIKotlin
arkivanov.github.io/MVIKotlin/
[4] KMM Survival Diary II: Cross-end MVI Framework — MVIKotlin
Kotlin Shanghai user group’s personal homepage – dynamic – nuggets
[5] A Preliminary Study on Kotlin/Native Asynchronous Concurrency Model
Dry goods | Kotlin/Native asynchronous concurrent model ctrip hotel asain iOS dynamic View of exploration
【6】Kotlin/Native non-virtual function static dispatch call bug discussion on YouTrack (KT-42903)
Youtrack.jetbrains.com/issue/KT-42…
[7] YouTrack: NoClassDefFoundError for coroutine exception handlers
Youtrack.jetbrains.com/issue/IDEA-…
[8] Github Kotlinx. coroutines: NoClassDefFoundError issues
Github.com/Kotlin/kotl…
[9] Try the New Kotlin/Native Memory Manager Development Preview
Blog.jetbrains.com/kotlin/2021…
[10] Kotlin Roadmap
Kotlinlang.org/docs/roadma…
[11] SQLDelight
cashapp.github.io/sqldelight/
[12] workflow – kotlin
Github.com/square/work…
Team Recruitment Information
We are ctrip ticket R & D team, responsible for ctrip APP/PC ticket business development and innovation. Flight ticket research and development in search engine, database, deep learning, high concurrency and other directions continue to explore, continue to optimize user experience, improve efficiency.
In ticket research and development, you can work with many technology leaders to truly let hundreds of millions of users enjoy your products and codes, and improve the travel experience and happiness index of travelers around the world.
If you love technology and are eager to grow, ctrip ticket r&d team is looking forward to flying with you. Currently we have open positions in front end/back end/data/test development.
Resume delivery email: [email protected], email title: [name] – [Ctrip ticket] – [post]
Yu Ang, senior engineer of Ctrip Mobile terminal, core member of Kotlin Chinese community, translator of Kotlin Programming Practice. Derek, senior R&D manager of Ctrip, focuses on mobile terminal development and is keen on the research and practice of various cross-terminal technologies.