Viaggio nei progetti Kotlin Multiplatform – Parte 3

Kotlin Multi Platform

Nel precedente articolo di questa serie, abbiamo intrapreso il viaggio full immersion sull’uso di BLE in un progetto Kotlin Multiplatform.
Abbiamo integrato un dispositivo BLE leggendo una caratteristica esposta che invia notifiche, e consegnato gli aggiornamenti all’utente ogni volta che la caratteristica cambiava.

Questo scenario è impegnativo, ma non è così comune nello sviluppo delle applicazioni di tutti i giorni. Per questo ho voluto esplorare un caso d’uso migliore, come una semplice chiamata di riposo per recuperare alcuni utenti e il codice dell’interfaccia utente per visualizzarli.

Strutturare Kotlin MPP

Un punto chiave di Kotlin MPP è l’organizzazione dei set di sorgenti, al fine di condividere quanto più codice possibile. Il nuovo pugin kotlin-multiplaform Gradle aiuta in questo intento permettendo di definire varie directory di sorgenti, ognuna rivolta ad una piattaforma diversa.
Avrete un set di sorgenti commonMain contenente tutto il codice puro di Kotlin, condiviso su tutte le piattaforme. Poi, se si crea un progetto per un’applicazione mobile, si può avere un set di sorgenti androidMain  insieme a un set iosMain, definendo il codice specifico della piattaforma.

Questa struttura introduce una chiara separazione degli scopi, costringendo il modulo Kotlin puro ad essere un framework-agnostic. Per questo motivo, se si vuole sfruttare al meglio questa organizzazione, si possono sfruttare diversi design pattern per condividere quanta più logica possibile.

Inoltre, la libreria Kotlin Coroutines permette di avere un’astrazione sul threading. Usando le Coroutine, si ottiene il vantaggio di scrivere codice asincrono in una forma sincrona e più leggibile.

Queste capacità giocano molto bene in questo contesto multipiattaforma.

Il progetto in Kotlin

Come già introdotto in precedenza, il nostro progetto sarà un’applicazione multipiattaforma che recupera una lista di utenti attraverso l’utilizzo di una API REST. Una volta ottenuti, mostra i risultati all’utente dell’applicazione come una lista. Un caso d’uso piuttosto comune.

Useremo randomuser.me, un’API aperta che restituisce una lista di utenti fittizi, anche con un’immagine del profilo. Consumando questo servizio, otterremo una risposta Json da cui conserveremo solo i dati di cui abbiamo realmente bisogno. Poi comporremo un semplice modello UI per rendere il codice dell’interfaccia utente il più semplice possibile.

Approfittando dei set di sorgenti multipiattaforma citati, cercheremo di massimizzare la condivisione del codice.

Inoltre – dato che siamo bravi sviluppatori e dobbiamo mantenere l’app reattiva – manderemo in background tutto il lavoro che non è legato alla UI. Per fare questo, delegheremo il compito alle Coroutine, applicando il defer in background solo in un punto. Così tutto il nostro codice sarà sincrono, ma inviato in background solo quando viene eseguito.

“IMHO moment”:  in generale questa idea dovrebbe essere applicata alla maggior parte dei casi in cui è necessario mandare in background l’esecuzione del lavoro. La cosa più importante è che aiuta ad evitare la diffusione di codice asincrono, con callback o altro. I framework come RxJava ci vengono incontro per  superare questo problema, ma per progetti semplici potreste non voler aggiungere dipendenze come Rx. Ma comunque, questo è un altro discorso!

Kotlin MPPRecuperare gli utenti

Come detto prima, consumeremo l’API randomuser.me per ottenere alcuni dati di utenti.

Strutturare i source sets. Come?

Per massimizzare la condivisione del codice, sarebbe fantastico se potessimo riutilizzare anche il codice che esegue la chiamata HTTP. Possiamo seguire due strade per ottenere questo risultato:

  • Creare un’interfaccia comune che definisca i nostri requisiti e implementarne una versione per Android (OkHttp e Gson) e una versione per iOS (Moya e modelli che implementano il protocollo Codable di Swift);
  • Creare una classe comune, utilizzando un client HTTP multipiattaforma come Ktor client e una libreria di serializzazione JSON multipiattaforma, come kotlinx.serialization fornita dal team Kotlin stesso.

Il secondo punto ci sembra interessante, ma vogliamo mantenere il disaccoppiamento che il primo punto introduce. Quindi, possiamo procedere a creare l’interfaccia comune, chiamata UserApi. La implementeremo nel modulo comune usando Ktor e kotlinx.serialization. Scriveremo tutto il codice in modo che dipenda dall’interfaccia, e non dall’implementazione. Pertanto, se necessario, possiamo buttare via tale implementazione a favore della prima soluzione, senza cambiare nulla. Ricordate l’LSP e il DIP? Uncle Bob sarà fiero di noi!

Quindi creiamo una classe chiamata AllUsersResponseDto, definita qui per brevità, e l’interfaccia UserApi, definita come:

interface UsersApi {
    suspend fun getUsers(): AllUsersResponseDto
}

È molto semplice. Per ora, ignoriamo la keyword suspend. La approfondiremo più avanti in questo articolo.

Kotlin MPP – Dipendenze

La classe AllUsersResponseDto sarà annotata con kotlinx.serialization.Serializable, permettendo al compilatore di generare dei serializzatori.

Aggiungiamo il plugin Gradle di serializzazione alle dipendenze del classpath del file build.gradle del progetto. Ricordiamo che richiede una versione di Kotlin superiore di 1.3.20.

classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlinVersion"

E poi aggiorniamo il nostro modulo build.gradle con le dipendenze specifiche della piattaforma. Dobbiamo distinguere l’artefatto in base al set di sorgenti a cui puntiamo.

commonMain {
  dependencies {
    ...
    api "org.jetbrains.kotlinx:kotlinx-serialization-runtime-common:$serializationVersion"
  }
}
androidMain {
  dependencies {
    ...
    api "org.jetbrains.kotlinx:kotlinx-serialization-runtime:$serializationVersion"
  }
}
iOSMain {
  dependencies {
    ...
    api "org.jetbrains.kotlinx:kotlinx-serialization-runtime-native:$serializationVersion"
  }
}

Qui esporremo ad altri moduli la lib di serializzazione utilizzando la direttiva api. Se decidiamo di implementare l’interfaccia UserApi con codice dipendente dalla piattaforma, saremo liberi di utilizzare la lib di serializzazione kotlinx.serialization.

Poi, come detto prima, dobbiamo creare l’implementazione condivisa di UsersApi. Essa utilizzerà la libreria Ktor Client per effettuare chiamate HTTP.

Importartiamo la libreria Ktor Client come abbiamo fatto in precedenza. Inoltre, aggiungiamo la JsonFeature di Ktor, che si integra perfettamente con kotlinx.serialization. Permette di definire serializzatori di tipi custom, delegando a Ktor e alla serialization il lavoro sporco.

commonMain {
  dependencies {
    ...
    implementation "io.ktor:ktor-client-core:$ktorVersion"
    implementation "io.ktor:ktor-client-json:$ktorVersion"
  }
}
androidMain {
  dependencies {
    ...
    implementation "io.ktor:ktor-client-android:$ktorVersion"
    implementation "io.ktor:ktor-client-json-jvm:$ktorVersion"
  }
}
iOSMain {
  dependencies {
    ...
    implementation "io.ktor:ktor-client-ios:$ktorVersion"
    implementation "io.ktor:ktor-client-json-native:$ktorVersion"
  }
}

Kotlin MPP – Implementazione condivisa

Una volta finito il setup, passiamo all’implementazione dell’interfaccia UserApi.

class SharedUsersApi : UsersApi {
    private val httpClient = HttpClient {
        install(JsonFeature) {
            serializer = KotlinxSerializer().apply {
                setMapper(
                    type = AllUsersResponseDto::class, 
                    serializer = AllUsersResponseDto.serializer()
                )
            }
        }
    }
    override suspend fun getUsers(): AllUsersResponseDto =
        httpClient.get("https://randomuser.me/api/?results=50")
}

In meno di 20 linee di codice abbiamo un’implementazione pienamente funzionante, utilizzabile su entrambe le piattaforme.

Abbiamo contrassegnato il metodo `getUsers` come `suspend`. È un metodo di interfaccia definito in un contesto in cui le implementazioni potrebbero svolgere compiti di lunga durata. Nel nostro caso, il client di Ktor utilizza le Coroutine per eseguire la chiamata HTTP in background. Per questo motivo, il metodo HttpClient.get(String) deve essere chiamato in una funzione suspend o all’interno di un’altra Coroutine. Abbiamo quindi scelto di implementarlo come suspend, delegando la gestione della coroutine al chiamante. La responsabilità della classe è solo di recuperare gli utenti, non di gestire esplicitamente i lavori in background.

Kotlin MPP – Ridurre il modello dati

Tutto ciò che abbiamo fatto riguarda il livello di accesso ai dati della nostra applicazione. Non abbiamo implementato alcuna logica UI né alcun widget UI.

Guardando il DTO, si pone immediatamente una domanda. 

Abbiamo davvero bisogno di tutti questi campi?

Ovviamente no, non ne abbiamo bisogno.

Il dominio della nostra applicazione vuole conoscere solo alcune informazioni di base sull’utente. Quindi qualcosa del tipo:

 data class User(
    val id: String,
    val name: String,
    val surname: String,
    val username: String,
    val email: String,
    val gender: Gender,
    val profilePictureUrl: String
) {
    enum class Gender {
        MALE, FEMALE
    }
}

Poi dobbiamo ridurre i dati ottenuti dal DTO ad una forma più semplice. Questo lavoro di mappatura sarà implementato in una classe Repository, nel modulo comune. Tale classe permette di recuperare tutti gli utenti, nascondendo la loro origine. Un semplice repository pattern.

Si può creare un’implementazione piuttosto semplice così:

class UsersRepository(
    private val usersApi: UsersApi = SharedUsersApi()
) {
    suspend fun getAllUsers(): List<User> {
        val users = usersApi.getUsers()
        return users.results.map(::mapToUser)
    }
}
private fun mapToUser(result: AllUsersResponseDto.Result): User = User(
    id = result.login.uuid,
    name = result.name.first,
    surname = result.name.last,
    username = result.login.username,
    email = result.email,
    gender = if (result.gender == "female") User.Gender.FEMALE 
             else User.Gender.MALE,
    profilePictureUrl = result.picture.large
)

Nel costruttore si ottiene un’istanza di UserApi, visibile in qualità di interfaccia. Per semplicità impostiamo come valore di default una nuova istanza di SharedUserApi, cioè la classe che abbiamo creato prima. In questo modo possiamo facilmente usarla o sostituirla, come detto all’inizio del post.

Visualizzazione degli utenti nella nostra app Kotlin

Ben fatto ragazzi! Abbiamo una struttura completamente funzionante utilizzabile sia su Android che su iOS (beh dai, non possiamo dire che funzioni finché non scriviamo qualche test).

Raggiunto questo punto, tutto quello che dobbiamo fare è presentare la lista degli utenti a UI. Inizialmente potremmo essere tentati di iniziare a scrivere il codice direttamente in una Activity di Android o all’interno di un UIViewController di iOS. Tuttavia, non è esattamente quello che vogliamo. Infatti, vogliamo massimizzare la condivisione del codice tra le piattaforme, soprattutto per evitare la duplicazione del codice e i bug.

Per raggiungere il nostro scopo, applichiamo il pattern Model View Presenter al nostro codice UI. La nostra Activity / UIViewController implementerà un’interfaccia View. Il Presenter conoscerà solo l’istanza della View, eliminando la necessità di conoscere le classi del framework. Nel Presenter poi, scriveremo il codice di manipolazione dei dati, per preparare i modelli User da mostrare.

Kotlin MPP – Come presentare gli utenti

Per prima cosa, definiamo come gli utenti devono essere presentati nell’interfaccia utente. Per fare un esempio, la nostra app mostrerà un utente in una semplice riga di una lista. Ogni riga mostrerà il nome utente, l’email e l’immagine del profilo.

Dopo aver scritto alcune “idee di presentazione“, creiamo il modello che rappresenterà un utente visualizzato:

data class UiUser(
    val id: String,
    val displayName: String,
    val email: String,
    val pictureUrl: String
)

Avremmo potuto chiamarlo DisplayableUser, UserToShow… ma UiUser era abbastanza significativo per la portata di questo esempio.

L’ID nel modello è usato solo per mantenere un flusso bidirezionale più semplice. Infatti, in questo modo possiamo risalire al modello di dominio partendo dal modello di presentazione.

Una volta definito come verranno presentati i dati, definiamo il contratto View. Questo definirà come i dati vengono recapitati all’interfaccia utente. Inoltre, riassume le azioni che la view eseguirà.

Dal momento che visualizzerà una lista di utenti, la chiameremo UsersListView:

interface UsersListView {
    fun showLoading()
    fun hideLoading()
    fun showUsers(displayableUsers: List<UiUser>)
    fun hideUsers()
}

Easy. I nostri Activity e UIViewController rispetteranno questo contratto.

Kotlin MPP – Presentiamo a UI!

Infine, dobbiamo implementare la nostra classe Presenter. La chiameremo UserListPresenter. Anche questa classe risiederà nel modulo condiviso, e quindi sarà scritta in puro codice Kotlin. Infatti, la UserListView è un punto chiave per permettere al Presenter di essere framework-agnostic.

Il nostro Presenter esporrà due metodi del “ciclo di vita”:

  • attachView(v: UsersListView): til punto di ingresso. Questo sarà chiamato quando viene creata la View. Il Presenter memorizzerà quindi il riferimento nel membro d’istanza View;
  • detachView(): il punto di uscita. Sarà chiamato quando la View sarà distrutta. Qui il Presenter imposterà l’annullamento della referenza alla View. Questo ci aiuta anche ad evitare retrain cycle in Swift.

A questo scopo possiamo approfittare della class delegation. Il Presenter si conformerà all’interfaccia di CoroutineScope, e noi deleghiamo l’implementazione alla funzione MainScope(). Mentre sto scrivendo, questa API risulta ancora sperimentale, ma diciamo che ci piace vivere al limite.

Infine, nel metodo detachView() del Presenter, chiameremo il metodo cancel(), reso disponibile dal CoroutineScope. Questo cancellerà tutti i lavori in sospeso.

Alla fine, il nostro Presenter si presenta così:

class UsersListPresenter(
    private val usersRepository: UsersRepository,
    private val backgroundDispatcher: BackgroundDispatcher
) : CoroutineScope by MainScope() {
    private var view: UsersListView? = null
    fun attachView(v: UsersListView) {
        view = v
        launch {
            view?.hideUsers()
            view?.showLoading()
            val users = withContext(backgroundDispatcher) {
                usersRepository.getAllUsers()
            }
            val displayableUsers = users.map(::mapToUiUser)
            view?.hideLoading()
            view?.showUsers(displayableUsers)
        }
    }
    fun detachView() {
        view = null
        cancel()
    }
}

Una volta implementata l’Activity e lo UIViewController, il lavoro è fatto.

Ma lasciate che vi parli di un inconveniente.

Kotlin MPPDispatch in background

Si noti che il nostro Presenter prende come parametro nel costruttore anche un’istanza di BackgroundDispatcher. Questa è una classe usata per astrarre il CoroutineDispatcher che stiamo usando. Ma perché?

Il Repository per definizione è un oggetto che lavora con i dati. Quindi, è una buona pratica eseguire il suo lavoro in background. Le Coroutine di Kotlin permettono di effettuare il dispatch in background utilizzando, ad esempio, l’oggetto Dispatchers.IO. Finchè viene utilizzato sulla Android, tutto va bene.

Il problema è nell’implementazione di Kotlin utilizzata nel target iOS. Il dispatch in background non è ancora supportato, a causa della complessità della gestione dei riferimenti agli oggetti tra i thread. La funzionalità sembra essere pianificata per una futura release, ma al momento è disponibile solo il Main thread dispatcher. Potete trovare il problema Github qui.

Detto questo, allora non possiamo usare l’oggetto Dispatchers.IO. Per superare questo problema, abbiamo creato un BackgroundDispatcher che è un oggetto expect. Nella codebase Android viene “attualizzato” usando Dispatchers.IO, mentre nella codebase iOS viene “attualizzato” usando il dispatcher Main.

Questo però non significa che la chiamata REST verrà effettuata sul thread Main. Il client Ktor manda l’operazione in background da solo. Tuttavia, tutte le elaborazioni successive avranno luogo sul thread UI.

A mio parere, questo è un compromesso accettabile per le applicazioni mobile che non richiedono un lavoro pesante in background. Infatti, il threading potrebbe essere gestito in modo sicuro da qualche altra libreria di accesso ai dati, come Ktor, quindi l’applicazione potrebbe non aver bisogno di gestire il dispatch delle operazioni da sé.

Conclusioni: la nostra tipica app in Kotlin

Abbiamo sviluppato con successo un’applicazione multiplatform in Kotlin.

Il risultato dell'applicazione multipiattaforma Kotlin
Il risultato dell’applicazione multipiattaforma Kotlin.

L’applicazione finale è sia su Android che su iOS.

Abbiamo massimizzato la condivisione del codice, partendo dal livello di presentazione fino ad arrivare al livello dati.

Il team di IntelliJ ha fatto un lavoro enorme per rendere possibile tutto questo, e dobbiamo ammettere che questa tecnologia apre un sacco di possibilità. In più, credo che Kotlin Multiplatform abbia iniziato a percorrere la strada per diventare uno standard nello sviluppo multipiattaforma.

I progetti multipiattaforma sono adatti anche ad altri ambienti. È possibile costruire il proprio backend utilizzando Ktor Server avendo come target JVM, e poi condividere i modelli con un modulo comune frontend. Tale modulo conterrà tutta la logica di cui abbiamo parlato in questo post del blog. Inoltre, sarà utilizzato da altri moduli di altre piattaforme, come Android, iOS, JS e JVM desktop. E, ovviamente, conterrà dei test.

Buon sviluppo con Kotlin e grazie per la lettura!

conosciamo solo un quinto di ciò che vive in profondità. tutto il resto è da esplorare.

coderspace sta arrivando.