La conferenza Mobius 2018 tenutasi a San Pietroburgo all'inizio di quest'anno ha ospitato un intervento dei ragazzi di Revolut - Roman Yatsina e Ivan Vazhnov, chiamato Architettura multipiattaforma con Kotlin per iOS e Android.
Dopo aver visto il discorso dal vivo, ho voluto provare come Kotlin/Native gestisce il codice multipiattaforma che può essere utilizzato sia su iOS che Android. Ho deciso di riscrivere un po' il progetto demo del talk in modo che potesse caricare l'elenco dei repository pubblici dell'utente da GitHub con tutti i rami di ogni repository.
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. Può anche contenere interfacce e dichiarazioni di classi/funzioni senza implementazioni che dipendono da una certa piattaforma. 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
- }
Guardate la parola chiave expect. Fa parte delle dichiarazioni expected e actual. Il modulo comune può dichiarare la dichiarazione attesa che ha la realizzazione effettiva nei moduli della piattaforma. 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)
- }
- }
- }
Che altro potrebbe essere incluso nel modulo comune
Tra tutto il resto, la logica di analisi JSON potrebbe essere inclusa nel modulo comune. La maggior parte dei progetti contiene questa logica in una forma complicata. Implementarla nel modulo comune potrebbe garantire un trattamento simile dei dati in arrivo dal server per iOS e Android.
Purtroppo, nella libreria di serializzazione kotlinx.serialization il supporto di Kotlin/Native non è ancora implementato.
Una possibile soluzione potrebbe essere scrivere la propria o fare il porting di una delle librerie più semplici basate su Java per Kotlin. Senza usare riflessioni o altre dipendenze di terze parti. Tuttavia, questo tipo di lavoro va oltre un semplice progetto di test ♂️
Moduli della piattaforma
I moduli della piattaforma platform-android e platform-ios contengono sia l'implementazione dipendente dalla piattaforma delle UI e delle classi dichiarate nel modulo comune, sia qualsiasi altro codice specifico della piattaforma. 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. Invece, il codice suspendCoroutineOrReturn è chiamato dalla libreria standard Kotlin e ci permette di interrompere l'esecuzione e ottenere il callback di continuazione che deve essere chiamato al completamento del processo in background. Questo callback viene poi passato alla funzione che verrà nuovamente specificata nel progetto Xcode dove verrà implementata tutta l'interazione con il server (in Swift o Objective-C). Il valore COROUTINE_SUSPENDED indica lo stato di sospensione e il risultato non sarà restituito immediatamente.
App per iOS
Quello che segue è un progetto Xcode che usa il modulo platform-ios come framework Objective-C generico.
Per assemblare platform-ios in un framework, usate il plugin konan Gradle. Its settings are in the platform-ios/build.gradle file:
- apply plugin: 'konan'
- konanArtifacts {
- framework('KMulti', targets: ['iphone_sim']) {
- ...
KMulti è un prefisso per il 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
- }
Coroutine Android
Nel modulo della piattaforma Android, le dichiarazioni sono mappate nelle loro implementazioni da kotlinx.coroutines:
- actual fun async(context: CoroutineContext, block: suspend () -> T): Deferred {
- return Deferred(async {
- kotlinx.coroutines.experimental.withContext(context, block = block)
- })
- }
iOS coroutines
Con iOS le cose sono un po' più complicate. Come detto sopra, passiamo la callback continuation(KMultiStdlibContinuation) alle funzioni che devono lavorare in modo asincrono. 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"))
- }
- }
- }
Per far sì che il risultato ritorni dalla funzione di sospensione, dobbiamo implementare l'interfaccia ContinuationInterceptor. Questa interfaccia è responsabile di come il callback viene elaborato, in particolare in quale thread il risultato (se esiste) verrà restituito. 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). Tutte le capacità delle piattaforme native sono disponibili attraverso Kotlin/Native.
- Dannatamente funziona!
I contro:
- Tutte le soluzioni Kotlin/Native nel progetto sono ancora in stato sperimentale. Usare caratteristiche come questa nel codice di produzione non è una buona idea.
- Nessun supporto per le coroutine per Kotlin/Native fuori dalla scatola. Speriamo che questo problema venga risolto nel prossimo futuro. Questo permetterebbe agli sviluppatori di accelerare significativamente il processo di creazione di progetti multipiattaforma, semplificandolo allo stesso tempo.
- Un progetto iOS funzionerà solo sui dispositivi arm64 (modelli a partire da iPhone 5S).