La conférence Mobius 2018 qui s'est tenue à Saint-Pétersbourg au début de l'année a présenté un exposé des gars de Revolut - Roman Yatsina et Ivan Vazhnov, intitulé Architecture multiplateforme avec Kotlin pour iOS et Android.
Après avoir regardé l'exposé en direct, j'ai voulu essayer de voir comment Kotlin/Native gère le code multiplateforme qui peut être utilisé à la fois sur iOS et Android. J'ai décidé de réécrire un peu le projet de démonstration du talk pour qu'il puisse charger la liste des dépôts publics de l'utilisateur depuis GitHub avec toutes les branches de chaque dépôt.
Project structure
- multiplatform
- ├─ android
- ├─ common
- ├─ ios
- ├─ platform-android
- └─ platform-ios
Common module
common is the shared module that only contains Kotlin with no platform-specific dependencies. Il peut également contenir des interfaces et des déclarations de classes/fonctions sans que les implémentations ne dépendent d'une certaine plateforme. Such declarations allow using the platform-dependent code in the common module.
In my project, this module encompasses the business logic of the app – data models, presenters, interactors, UIs for GitHub access with no implementations.
Some examples of the classes
UIs for GitHub access:
- expect class ReposRepository {
- suspend fun getRepositories(): List
- suspend fun getBranches(repo: GithubRepo): List
- }
Regardez bien le mot-clé expect. Il fait partie des déclarations expected et actual. Le module commun peut déclarer la déclaration attendue qui a la réalisation réelle dans les modules de la plateforme. By the expect keyword we can also understand that the project uses coroutines which we’ll talk about later.
Interactor:
- class ReposInteractor(
- private val repository: ReposRepository,
- private val context: CoroutineContext
- ) {
- suspend fun getRepos(): List {
- return async(context) { repository.getRepositories() }
- .await()
- .map { repo ->
- repo to async(context) {
- repository.getBranches(repo)
- }
- }
- .map { (repo, task) ->
- repo.branches = task.await()
- repo
- }
- }
- }
The interactor contains the logic of asynchronous operations interactions. First, it loads the list of repositories with the help of getRepositories() and then, for each repository it loads the list of branches getBranches(repo). The async/await mechanism is used to build the chain of asynchronous calls.
ReposView interface for UI:
- interface ReposView: BaseView {
- fun showRepoList(repoList: List)
- fun showLoading(loading: Boolean)
- fun showError(errorMessage: String)
- }
The presenter
The logic of UI usage is specified equally for both the platforms.
- class ReposPresenter(
- private val uiContext: CoroutineContext,
- private val interactor: ReposInteractor
- ) : BasePresenter() {
- override fun onViewAttached() {
- super.onViewAttached()
- refresh()
- }
- fun refresh() {
- launch(uiContext) {
- view?.showLoading(true)
- try {
- val repoList = interactor.getRepos()
- view?.showRepoList(repoList)
- } catch (e: Throwable) {
- view?.showError(e.message ?: "Can't load repositories")
- }
- view?.showLoading(false)
- }
- }
- }
Quoi d'autre pourrait être inclus dans le module commun
Parmi tout le reste, la logique de parsing JSON pourrait être incluse dans le module commun. La plupart des projets contiennent cette logique sous une forme compliquée. L'implémenter dans le module commun pourrait garantir un traitement similaire des données entrantes du serveur pour iOS et Android.
Malheureusement, dans la bibliothèque de sérialisation kotlinx.serialization, le support de Kotlin/Native n'est pas encore implémenté.
Une solution possible pourrait être d'écrire votre propre bibliothèque ou de porter l'une des bibliothèques plus simples basées sur Java pour Kotlin. Sans utiliser de réflexions ou d'autres dépendances tierces. Cependant, ce type de travail va au-delà d'un simple projet de test ♂️
Modules de plateforme
Les modules de plateforme-android et platform-ios contiennent à la fois l'implémentation dépendante de la plateforme des interfaces utilisateur et des classes déclarées dans le module commun, et tout autre code spécifique à la plateforme. Those modules are also written with Kotlin.
Let’s look at the ReposRepository class implementation declared in the common module.
platform-android
- actual class ReposRepository(
- private val baseUrl: String,
- private val userName: String
- ) {
- private val api: GithubApi by lazy {
- Retrofit.Builder()
- .addConverterFactory(GsonConverterFactory.create())
- .addCallAdapterFactory(CoroutineCallAdapterFactory())
- .baseUrl(baseUrl)
- .build()
- .create(GithubApi::class.java)
- }
- actual suspend fun getRepositories() =
- api.getRepositories(userName)
- .await()
- .map { apiRepo -> apiRepo.toGithubRepo() }
- actual suspend fun getBranches(repo: GithubRepo) =
- api.getBranches(userName, repo.name)
- .await()
- .map { apiBranch -> apiBranch.toGithubBranch() }
- }
In the Android implementation, we use the Retrofit library with an adaptor converting the calls into a coroutine-compatible format. Note the actual keyword we’ve mentioned above.
platform-ios
- actual open class ReposRepository {
- actual suspend fun getRepositories(): List {
- return suspendCoroutineOrReturn { continuation ->
- getRepositories(continuation)
- COROUTINE_SUSPENDED
- }
- }
- actual suspend fun getBranches(repo: GithubRepo): List {
- return suspendCoroutineOrReturn { continuation ->
- getBranches(repo, continuation)
- COROUTINE_SUSPENDED
- }
- }
- open fun getRepositories(callback: Continuation>) {
- throw NotImplementedError("iOS project should implement this")
- }
- open fun getBranches(repo: GithubRepo, callback: Continuation>) {
- throw NotImplementedError("iOS project should implement this")
- }
- }
You can see the actual implementation of the ReposRepository class for iOS in the platform module does not contain the specific implementation of server interactions. Au lieu de cela, le code suspendCoroutineOrReturn est appelé depuis la bibliothèque standard Kotlin et nous permet d'interrompre l'exécution et d'obtenir le callback de continuation qui doit être appelé à la fin du processus d'arrière-plan. Ce callback est ensuite passé à la fonction qui sera respécifiée dans le projet Xcode où toute l'interaction avec le serveur sera implémentée (en Swift ou Objective-C). La valeur COROUTINE_SUSPENDED signifie l'état suspendu et le résultat ne sera pas retourné immédiatement.
Application iOS
Ce qui suit est un projet Xcode qui utilise le module platform-ios comme un framework Objective-C générique.
Pour assembler platform-ios en un framework, utilisez le plugin Gradle konan. Its settings are in the platform-ios/build.gradle file:
- apply plugin: 'konan'
- konanArtifacts {
- framework('KMulti', targets: ['iphone_sim']) {
- ...
KMulti est un préfixe pour le framework. All the Kotlin classes from the common and platform-iosmodules in the Xcode project will have this prefix.
After the following command,
- ./gradlew :platform-ios:compileKonanKMultiIphone_sim
the framework can be found under:
- /kotlin_multiplatform/platform-ios/build/konan/bin/ios_x64
It has to be added to the Xcode project.
This is how a specific implementation of the ReposRepository class looks like. The interaction with a server is done by means of the Alamofire library.
- class ReposRepository: KMultiReposRepository {
- ...
- override func getRepositories(callback: KMultiStdlibContinuation) {
- let url = baseUrl.appendingPathComponent("users/(githubUser)/repos")
- Alamofire.request(url)
- .responseJSON { response in
- if let result = self.reposParser.parse(response: response) {
- callback.resume(value: result)
- } else {
- callback.resumeWithException(exception: KMultiStdlibThrowable(message: "Can't parse github repositories"))
- }
- }
- }
- override func getBranches(repo: KMultiGithubRepo, callback: KMultiStdlibContinuation) {
- let url = baseUrl.appendingPathComponent("repos/(githubUser)/(repo.name)/branches")
- Alamofire.request(url)
- .responseJSON { response in
- if let result = self.branchesParser.parse(response: response) {
- callback.resume(value: result)
- } else {
- callback.resumeWithException(exception: KMultiStdlibThrowable(message : "Can't parse github branches")).
- }
- }
- }
- }
Android app
With an Android project it is all fairly simple. We use a conventional app with a dependency on the platform-android module.
- dependencies {
- implementation project(':platform-android')
Essentially, it consists of one ReposActivity which implements the ReposView interface.
- override fun showRepoList(repoList: List) {
- adapter.items = repoList
- adapter.notifyDataSetChanged()
- }
- override fun showLoading(loading: Boolean) {
- loadingProgress.visibility = if (loading) VISIBLE else GONE
- }
- override fun showError(errorMessage: String) {
- Toast.makeText(this, errorMessage, Toast.LENGTH_LONG).show()
- }
Coroutines, apples, and magic
Speaking of coroutines and magic, in fact, at the moment coroutines are not yet supported by Kotlin/Native. The work in this direction is ongoing. So how on Earth do we use the async/awaitcoroutines and functions in the common module? Let alone in the platform module for iOS.
As a matter of fact, the async and launch expect functions, as well as the Deferred class, are specified in the common module. These signatures are copied from kotlinx.coroutines.
- import kotlin.coroutines.experimental.Continuation
- import kotlin.coroutines.experimental.CoroutineContext
- expect fun async(context: CoroutineContext, block : suspend () -> T) : Deferred
- expect fun launch(context: CoroutineContext, block: suspend () -> T)
- expect suspend fun withContext(context: CoroutineContext, block: suspend () -> T): T
- expect class Deferred {
- suspend fun await(): T
- }
Coroutines Android
Dans le module plateforme-android, les déclarations sont mappées dans leurs implémentations à partir de kotlinx.coroutines:
- actuel fun async(context : CoroutineContext, block: suspend () -> T): Deferred {
- return Deferred(async {
- kotlinx.coroutines.experimental.withContext(context, block = block)
- })
- }
Coroutines iOS
Avec iOS les choses sont un peu plus compliquées. Comme mentionné ci-dessus, nous passons le callback continuation(KMultiStdlibContinuation) aux fonctions qui doivent travailler de manière asynchrone. Upon the completion of the work, the appropriate resume or resumeWithExceptionmethod will be requested from the callback:
- override func getBranches(repo: KMultiGithubRepo, callback: KMultiStdlibContinuation) {
- let url = baseUrl.appendingPathComponent("repos/(githubUser)/(repo.name)/branches")
- Alamofire.request(url)
- .responseJSON { response in
- if let result = self.branchesParser.parse(response: response) {
- callback.resume(value: result)
- } else {
- callback.resumeWithException(exception: KMultiStdlibThrowable(message: "Can't parse github branches"))
- }
- }
- }
Pour que le résultat revienne de la fonction de suspension, nous devons implémenter l'interface ContinuationInterceptor. Cette interface est responsable de la façon dont le callback est traité, spécifiquement dans quel thread le résultat (s'il y en a un) sera retourné. For this, the interceptContinuation function is used.
- abstract class ContinuationDispatcher :
- AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor {
- override fun interceptContinuation(continuation: Continuation): Continuation {
- return DispatchedContinuation(this, continuation)
- }
- abstract fun dispatchResume(value: T, continuation: Continuation): Boolean
- abstract fun dispatchResumeWithException(exception: Throwable, continuation: Continuation<*>): Boolean
- }
- internal class DispatchedContinuation(
- private val dispatcher: ContinuationDispatcher,
- private val continuation: Continuation
- ) : Continuation {
- override val context: CoroutineContext = continuation.context
- override fun resume(value: T) {
- if (dispatcher.dispatchResume(value, continuation).not()) {
- continuation.resume(value)
- }
- }
- override fun resumeWithException(exception: Throwable) {
- if (dispatcher.dispatchResumeWithException(exception, continuation).not()) {
- continuation.resumeWithException(exception)
- }
- }
- }
In ContinuationDispatcher there are abstract methods implementation of which will depend on the thread where the executions will be happening.
Implementation for UI threads
- import platform.darwin.*
- class MainQueueDispatcher : ContinuationDispatcher() {
- override fun dispatchResume(value: T, continuation: Continuation): Boolean {
- dispatch_async(dispatch_get_main_queue()) {
- continuation.resume(value)
- }
- return true
- }
- override fun dispatchResumeWithException(exception: Throwable, continuation: Continuation<*>): Boolean {
- dispatch_async(dispatch_get_main_queue()) {
- continuation.resumeWithException(exception)
- }
- return true
- }
- }
Implementation for background threads
- import konan.worker.*
- class DataObject(val value: T, val continuation: Continuation)
- class ErrorObject(val exception: Throwable, val continuation: Continuation)
- class AsyncDispatcher : ContinuationDispatcher() {
- val worker = startWorker()
- override fun dispatchResume(value: T, continuation: Continuation): Boolean {
- worker.schedule(TransferMode.UNCHECKED, {DataObject(value, continuation)}) {
- it.continuation.resume(it.value)
- }
- return true
- }
- override fun dispatchResumeWithException(exception: Throwable, continuation: Continuation<*>): Boolean {
- worker.schedule(TransferMode.UNCHECKED, {ErrorObjeвыct(exception, continuation)}) {
- it.continuation.resumeWithException(it.exception)
- }
- return false
- }
- }
Now we can use the asynchronous manager in the interactor:
- let interactor = KMultiReposInteractor(
- repository: repository,
- context: KMultiAsyncDispatcher()
- )
And the main thread manager in the presenter:
- let presenter = KMultiReposPresenter(
- uiContext: KMultiMainQueueDispatcher(),
- interactor: interactor
- )
Key takeaways
The pros:
- The developers implemented a rather complicated (asynchronous) business logic and common module data depiction logic.
- You can develop native apps using native libraries and instruments (Android Studio, Xcode). Toutes les capacités des plateformes natives sont disponibles à travers Kotlin/Native.
- C'est sacrément efficace !
Les inconvénients:
- Toutes les solutions Kotlin/Native du projet sont encore au statut expérimental. Utiliser des fonctionnalités comme celle-ci dans le code de production n'est pas une bonne idée.
- Pas de support pour les coroutines pour Kotlin/Native hors de la boîte. Espérons que ce problème sera résolu dans un avenir proche. Cela permettrait aux développeurs d'accélérer considérablement le processus de création de projets multiplateformes tout en le simplifiant.
- Un projet iOS ne fonctionnera que sur les appareils arm64 (modèles à partir de l'iPhone 5S).
.