Puede Kotlin Native sustituir a Swift como lenguaje de programación oficial de iOS?

En la conferencia Mobius 2018 celebrada en San Petersburgo a principios de este año hubo una charla de los chicos de Revolut - Roman Yatsina e Ivan Vazhnov, llamada Arquitectura multiplataforma con Kotlin para iOS y Android.

Después de ver la charla en directo, quise probar cómo Kotlin/Native maneja el código multiplataforma que se puede utilizar tanto en iOS como en Android. Decidí reescribir un poco el proyecto de demostración de la charla para que pudiera cargar la lista de repositorios públicos del usuario desde GitHub con todas las ramas de cada repositorio.

Project structure

  1. multiplatform 
  2. ├─ android 
  3. ├─ common 
  4. ├─ ios 
  5. ├─ platform-android 
  6. └─ platform-ios 

Common module

common is the shared module that only contains Kotlin with no platform-specific dependencies. También puede contener interfaces y declaraciones de clases/funciones sin implementaciones que dependan de una determinada plataforma. 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:

  1. expect class ReposRepository { 
  2. suspend fun getRepositories(): List 
  3. suspend fun getBranches(repo: GithubRepo): List 
  4. Por favor, no te preocupes, no te preocupes, no te preocupes, no te preocupes, no te preocupes.} 

Echa un vistazo a la palabra clave expect. Es una parte de las declaraciones esperadas y reales. El módulo común puede declarar la declaración esperada que tiene la realización real en los módulos de la plataforma. By the expect keyword we can also understand that the project uses coroutines which we’ll talk about later.

Interactor:

  1. class ReposInteractor( 
  2. private val repository: ReposRepository, 
  3. private val context: CoroutineContext 
  4. ) { 
  5.  
  6. suspend fun getRepos(): List { 
  7. return async(context) { repository.getRepositories() } 
  8. .await() 
  9. .map { repo -> 
  10. repo to async(context) { 
  11. repository.getBranches(repo) 
  12. .map { (repo, task) -> 
  13. repo.branches = task.await() 
  14. repo 
  15. }Se trata de un sistema de gestión de la información. 

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:

  1. interface ReposView: BaseView { 
  2. fun showRepoList(repoList: List) 
  3. fun showLoading(loading: Boolean) 
  4. fun showError(errorMessage: String) 

The presenter

The logic of UI usage is specified equally for both the platforms.

  1. class ReposPresenter( 
  2. private val uiContext: CoroutineContext, 
  3. private val interactor: ReposInteractor 
  4. ) : BasePresenter() { 
  5.  
  6. override fun onViewAttached() { 
  7. super.onViewAttached() 
  8. refresh() 
  9. fun refresh() { 
  10. launch(uiContext) { 
  11. view?.showLoading(true) 
  12. try { 
  13. val repoList = interactor.getRepos() 
  14. view?.showRepoList(repoList) 
  15. } catch (e: Throwable) { 
  16. view?.showError(e.message ?: "Can't load repositories") 
  17. view?.showLoading(false) 

Qué más se podría incluir en el módulo común

Entre todo lo demás, la lógica de parseo de JSON se podría incluir en el módulo común. La mayoría de los proyectos contienen esta lógica de forma complicada. Implementarla en el módulo común podría garantizar un tratamiento similar de los datos entrantes del servidor para iOS y Android.

Desgraciadamente, en la librería de serialización kotlinx.serialization el soporte de Kotlin/Native aún no está implementado.

Una posible solución podría ser escribir una propia o portar una de las librerías más sencillas basadas en Java para Kotlin. Sin utilizar reflexiones o cualquier otra dependencia de terceros. Sin embargo, este tipo de trabajo va más allá de un simple proyecto de prueba ♂️

Módulos de plataforma

Los módulos de plataforma-android y plataforma-ios contienen tanto la implementación dependiente de la plataforma de las UIs y las clases declaradas en el módulo común, como cualquier otro código específico de la plataforma. Those modules are also written with Kotlin.

Let’s look at the ReposRepository class implementation declared in the common module.

platform-android

  1. actual class ReposRepository( 
  2. private val baseUrl: String, 
  3. private val userName: String 
  4. ) { 
  5. private val api: GithubApi by lazy { 
  6. Retrofit.Builder() 
  7. .addConverterFactory(GsonConverterFactory.create()) 
  8.  
  9. .addCallAdapterFactory(CoroutineCallAdapterFactory()) 
  10. .baseUrl(baseUrl) 
  11. .build() 
  12. .create(GithubApi::class.java) 
  13.  
  14. actual suspend fun getRepositories() = 
  15. api.getRepositories(userName) 
  16. .await() 
  17. .map { apiRepo -> apiRepo.toGithubRepo() } 
  18.  
  19. actual suspend fun getBranches(repo: GithubRepo) = 
  20. api.getBranches(userName, repo.name) 
  21. .await() 
  22. .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

  1. actual open class ReposRepository { 
  2.  
  3. actual suspend fun getRepositories(): List { 
  4. return suspendCoroutineOrReturn { continuation -> 
  5. getRepositories(continuation) 
  6. COROUTINE_SUSPENDED 
  7.  
  8. actual suspend fun getBranches(repo: GithubRepo): List { 
  9. return suspendCoroutineOrReturn { continuation -> 
  10. getBranches(repo, continuation) 
  11. COROUTINE_SUSPENDED 
  12. open fun getRepositories(callback: Continuation>) { 
  13. throw NotImplementedError("iOS project should implement this") 
  14.  
  15. open fun getBranches(repo: GithubRepo, callback: Continuation>) { 
  16. 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. En su lugar, el código suspendCoroutineOrReturn es llamado desde la librería estándar de Kotlin y nos permite interrumpir la ejecución y obtener el callback de continuación que debe ser llamado al finalizar el proceso en segundo plano. Este callback se pasa a la función que se volverá a especificar en el proyecto Xcode donde se implementará toda la interacción con el servidor (en Swift u Objective-C). El valor COROUTINE_SUSPENDED significa el estado de suspensión y el resultado no será devuelto inmediatamente.

Apostulación iOS

A continuación se muestra un proyecto de Xcode que utiliza el módulo platform-ios como un framework genérico de Objective-C.

Para ensamblar platform-ios en un framework, utiliza el plugin konan Gradle. Its settings are in the platform-ios/build.gradle file:

  1. apply plugin: 'konan' 
  2.  
  3. konanArtifacts { 
  4. framework('KMulti', targets: ['iphone_sim']) { 
  5. ... 

KMulti es un prefijo para el framework. All the Kotlin classes from the common and platform-iosmodules in the Xcode project will have this prefix.

After the following command,

  1. ./gradlew :platform-ios:compileKonanKMultiIphone_sim 

the framework can be found under:

  1. /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.

  1. class ReposRepository: KMultiReposRepository { 
  2. ... 
  3. override func getRepositories(callback: KMultiStdlibContinuation) { 
  4. let url = baseUrl.appendingPathComponent("users/(githubUser)/repos") 
  5. Alamofire.request(url) 
  6. .responseJSON { response in 
  7. if let result = self.reposParser.parse(response: response) { 
  8. callback.resume(value: result) 
  9. } else { 
  10. callback.resumeWithException(exception: KMultiStdlibThrowable(message: "Can't parse github repositories")) 
  11. override func getBranches(repo: KMultiGithubRepo, callback: KMultiStdlibContinuation) { 
  12. let url = baseUrl.appendingPathComponent("repos/(githubUser)/(repo.name)/branches") 
  13. Alamofire.request(url) 
  14. .responseJSON { response in 
  15. if let result = self.branchesParser.parse(response: response) { 
  16. callback.resume(value: result) 
  17. } else { 
  18. 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.

  1. dependencies { 
  2. implementation project(':platform-android') 

Essentially, it consists of one ReposActivity which implements the ReposView interface.

  1. override fun showRepoList(repoList: List) { 
  2. adapter.items = repoList 
  3. adapter.notifyDataSetChanged() 
  4.  
  5. override fun showLoading(loading: Boolean) { 
  6. loadingProgress.visibility = if (loading) VISIBLE else GONE 
  7.  
  8. override fun showError(errorMessage: String) { 
  9. 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.

  1. import kotlin.coroutines.experimental.Continuation 
  2. import kotlin.coroutines.experimental.CoroutineContext 
  3.  
  4. expect fun async(context: CoroutineContext, block: suspender () -> T): Deferred 
  5.  
  6. expect fun launch(context: CoroutineContext, block: suspend () -> T) 
  7.  
  8. expect suspend fun withContext(context: CoroutineContext, block: suspend () -> T): T 
  9.  
  10. expect class Deferred { 
  11. suspend fun await(): T 
  12. }  

Coroutinas androides

En el módulo de la plataforma androide, las declaraciones se mapean en sus implementaciones desde kotlinx.coroutines:

  1. actual fun async(context: CoroutineContext, block: suspend () -> T): Deferred { 
  2. return Deferred(async { 
  3. kotlinx.coroutines.experimental.withContext(context, block = block) 
  4. }) 

Coroutinas de iOS

Con iOS las cosas son un poco más complicadas. Como ya hemos dicho, pasamos el callback de continuación(KMultiStdlibContinuation) a las funciones que tienen que trabajar de forma asíncrona. Upon the completion of the work, the appropriate resume or resumeWithExceptionmethod will be requested from the callback:

  1. override func getBranches(repo: KMultiGithubRepo, callback: KMultiStdlibContinuation) { 
  2. let url = baseUrl.appendingPathComponent("repos/(githubUser)/(repo.name)/branches") 
  3. Alamofire.request(url) 
  4. .responseJSON { response in 
  5. if let result = self.branchesParser.parse(response: response) { 
  6. callback.resume(value: result) 
  7. } else { 
  8. callback.resumeWithException(exception: KMultiStdlibThrowable(message: "Can't parse github branches")) 

Para que el resultado regrese de la función de suspensión, necesitamos implementar la interfaz ContinuationInterceptor. Esta interfaz es la responsable de cómo se procesa la devolución de llamada, concretamente en qué hilo se devolverá el resultado (si lo hay). For this, the interceptContinuation function is used.

  1. abstract class ContinuationDispatcher : 
  2. AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor { 
  3.  
  4. override fun interceptContinuation(continuation: Continuation): Continuation { 
  5. return DispatchedContinuation(this, continuation) 
  6.  
  7. abstract fun dispatchResume(value: T, continuation: Continuation): Boolean 
  8. abstract fun dispatchResumeWithException(exception: Throwable, continuation: Continuation<*>): Boolean 
  9.  
  10. internal class DispatchedContinuation( 
  11. private val dispatcher: ContinuationDispatcher, 
  12. private val continuation: Continuation 
  13. ) : Continuation { 
  14.  
  15. override val context: CoroutineContext = continuation.context 
  16.  
  17. override fun resume(value: T) { 
  18. if (dispatcher.dispatchResume(value, continuation).not()) { 
  19. continuation.resume(value) 
  20.  
  21. override fun resumeWithException(exception: Throwable) { 
  22. if (dispatcher.dispatchResumeWithException(exception, continuation).not()) { 
  23. 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

  1. import platform.darwin.* 
  2.  
  3. class MainQueueDispatcher : ContinuationDispatcher() { 
  4.  
  5. override fun dispatchResume(value: T, continuation: Continuation): Boolean { 
  6. dispatch_async(dispatch_get_main_queue()) { 
  7. continuation.resume(value) 
  8. return true 
  9.  
  10. override fun dispatchResumeWithException(exception: Throwable, continuation: Continuation<*>): Boolean { 
  11. dispatch_async(dispatch_get_main_queue()) { 
  12. continuation.resumeWithException(exception) 
  13. return true 

Implementation for background threads

  1. import konan.worker.* 
  2.  
  3. class DataObject(val value: T, val continuation: Continuation) 
  4. class ErrorObject(val exception: Throwable, val continuation: Continuation) 
  5.  
  6. class AsyncDispatcher : ContinuationDispatcher() { 
  7.  
  8. val worker = startWorker() 
  9.  
  10. override fun dispatchResume(value: T, continuation: Continuation): Boolean { 
  11. worker.schedule(TransferMode.UNCHECKED, {DataObject(value, continuation)}) { 
  12. it.continuation.resume(it.value) 
  13. return true 
  14.  
  15. override fun dispatchResumeWithException(exception: Throwable, continuation: Continuation<*>): Boolean { 
  16. worker.schedule(TransferMode.UNCHECKED, {ErrorObjeвыct(exception, continuation)}) { 
  17. it.continuation.resumeWithException(it.exception) 
  18. return false 

Now we can use the asynchronous manager in the interactor:

  1. let interactor = KMultiReposInteractor( 
  2. repository: repository, 
  3. context: KMultiAsyncDispatcher() 

And the main thread manager in the presenter:

  1. let presenter = KMultiReposPresenter( 
  2. uiContext: KMultiMainQueueDispatcher(), 
  3. 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). Todas las capacidades de las plataformas nativas están disponibles a través de Kotlin/Native.
  • ¡Funciona de maravilla!

Los contras:

  • Todas las soluciones Kotlin/Native del proyecto están todavía en estado experimental. Usar características como esta en el código de producción no es una buena idea.
  • No hay soporte para coroutines para Kotlin/Native fuera de la caja. Esperemos que este problema se resuelva en un futuro próximo. Esto permitiría a los desarrolladores acelerar significativamente el proceso de creación de proyectos multiplataforma a la vez que lo simplifica.
  • Un proyecto iOS sólo funcionará en los dispositivos arm64 (modelos a partir del iPhone 5S).

.