Hello Compose MultiPlatform
Antes de iniciar com o projeto, configure ou certifique se que o seu ambiente esteja preparado para o KMP realizando apenas o primeiro passo neste link .
Criando um projeto KMM compose
Abra o wizzard do Compose Multiplatform no seguinte link
Selecione apenas as opções Android e iOs , desmarque as opções Desktop e Browser, conforme figura abaixo, pois nosso exemplo será somente mobile
Selecione as dependências necessárias para o projeto, conforme ilustração:
Primeiros ajustes
Vamos configurar a lib de transições de navegação da Voyager, pois ela não vem no catalogo de versões do gradle, que foi gerado pelo wizzard:
Abra o arquivo libs.versions.toml
adicione a seguinte linha abaixo da linha "voyager-navigator"existente:
[libraries]
....
voyager-navigator....
voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" }
....
No arquivo build.gradle.kts/app adicione:
val commonMain by getting {
dependencies {
...
implementation(libs.voyager.navigator)
implementation(libs.voyager.transitions)
...
}
Até o momento a lib voyager só tem suporte a KMM nas seguintes libs:
Ou seja, devemos utilizar apenas estas listadas no quadro acima
e por tal motivo não utilizaremos ViewModels androidx, e sim as ScreenModels da lib
Configurando a lib Libres
Configure o gradle para gerar a classe de recursos compartilhados, abra o arquivo
build.gradle.kts/app e edite o escopo libres da seguinte maneira:
libres {
generatedClassName = "MainRes"
generateNamedArguments = true
baseLocaleLanguageCode = "en"
camelCaseNamesForAppleFramework = true
}
Abra a perspectiva "Projeto" no Android Studio e crie um diretório libres abaixo do diretorio kotlin , em seguida crie os diretórios : libres/images e libres/strings conforme figura abaixo:
CRIE seu arquivo de resources string e coloque o sufixo da língua suportada ex:
strings_en.xml para o inglês, nele colocaremos nossos resources a serem compartilhados:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="simple_string">Hello!</string>
<string name="string_with_arguments">Hello ${name}!</string>
<plurals name="plural_string">
<item quantity="one">resource</item>
<item quantity="other">resources</item>
</plurals>
</resources>
Coloque o arquivo strings dentro da pasta strings e as imagens dentro da pasta images, caso tenha imagens em vetores .svg vc terá que renomeá-las com o seguinte sufixo _(orig) por exemplo:
logotmdb_(orig).svg
Caso não renomeie assim, o iOS irá mostra-las por completo em preto,
Sincronize com o gradle e em seguida dê um Build em seu projeto para os recursos gerados ficarem disponíveis
Recursos gerado pela libres:
Utilize o recurso assim :
Text(text = MainRes.string.login_label_text)
Image(painter = painterResource(MainRes.image.logotmdb), contentDescription = "")
Vamos criar nossa SplashScreen utilizando a seguinte estrutura:
class SplashScreen : Screen {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
SplashLayout() // Nosso layout em Compose
LaunchedEffect(true) {
delay(2000)
navigator.push(LoginScreen())
}
}
}
estamos implementando a classe Screen da Voyager, e sobrescrevendo o método Content(), dentro dele colocamos nosso layout
Vamos Criar o layout da Nossa Splash, utilizando o Compose como já é de praxe e vamos usar nossa logo compartilhada utilizando a MainRes:
@Composable
fun SplashLayout(){
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.surface)
) {
Column(modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally) {
Image(painter = painterResource(MainRes.image.logotmdb), contentDescription = "")
}
}
}
Vamos Criar nossa tela LoginScreen seguindo a mesma lógica, vamos instanciar nossa viewModel, e capturar o navigator , faremos um login fake por enquanto:
class LoginScreen : Screen {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val navigate : ()-> Unit = {
navigator.replace(HomeScreen())
}
val viewModel = rememberScreenModel { LoginScreenModel(navigate) }
val state by remember { viewModel.uiSTate }.collectAsState()
val onEvent: (LoginEvent) -> Unit = { event ->
viewModel.onEvent(event)
}
LoginLayout(onEvent, state)
}
}
Criar Classe LoginScreenModel
class LoginScreenModel: ScreenModel {
private val _uiState: MutableStateFlow<LoginUiStates> =
MutableStateFlow(LoginUiStates.Empty)
var uiSTate: StateFlow<LoginUiStates> = _uiState
private val pendingActions = MutableSharedFlow<LoginEvent>()
init { handleEvents() }
fun onEvent(event: LoginEvent) {
coroutineScope.launch {
...
}
}
private fun handleEvents() {
coroutineScope.launch {
actions.collect { event ->
when (event) {
is LoginEvent.ValidateLogin -> validatingLogin()
is LoginEvent.ValidateNameField -> validateNameField(event)
is LoginEvent.ValidatePassField -> validatePassField(event)
}
}
}
}
....
}
Criar classe de Eventos
sealed class LoginEvent {
data class ValidateNameField(val name: String) : LoginEvent()
data class ValidatePassField(val pass: String) : LoginEvent()
object ValidateLogin : LoginEvent()
}
Criar classe de UiStates
data class LoginUiStates(
val isSuccessLogin :Boolean = false,
val allFieldsAreFilled:Boolean = false,
val name:String = "",
val pass:String = "",
val isNameError:Boolean = false,
val nameErrorHint : String = "Digite seu nome",
val isPassError:Boolean = false,
val passErrorHint : String = "A senha deve conter mais de 4 digitos",
var fakePass:String = "abc123"
) {
companion object {
val Empty = LoginUiStates()
}
}
Agora vamos refatorar a classe principal, App.kt
Vamos adicionar o Navigator, que é o NavHost da lib Voyager, da seguinte maneira:
@OptIn(ExperimentalAnimationApi::class)
@Composable
internal fun App() = AppTheme {
Navigator(
screen = SplashScreen(),
onBackPressed = { currentScreen ->
Napier.d("Pop screen #}", null, "Navigator")
true
}
) { navigator ->
CurrentScreen()
SlideTransition(navigator)
}
}
No lambda do navigator recebemos um objeto , e é ele que enviamos como parametro na SlideTransition
Para as demais telas seguiremos com o mesmo modelo mostrado acima
Para acionar o teclado do iOS digite [command + K], e caso as letras estejam em maiusculas, desabilite a opção Maiúsculas automáticas
Implementando consumo das APIs
Na tela home faremos o consumo da API The Movie DB para receber a listagem de filmes
Adicione permissões de acesso a internet no manifest
<uses-permission android:name="android.permission.INTERNET" />
Adicione KTOR
ktor-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor"}
val androidMain by getting {
dependencies {
...
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.android)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
...
}
}
val iosMain by creating {
dependsOn(commonMain)
iosX64Main.dependsOn(this)
iosArm64Main.dependsOn(this)
iosSimulatorArm64Main.dependsOn(this)
dependencies {
implementation(libs.ktor.client.darwin)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
...
}
}
val commonMain by getting {
dependencies {
...
implementation(libs.ktor.core)
...
}
}
crie a expect class do Http
expect class HttpClientFactory {
fun create(): HttpClient
}
em androidMain a classe actual
actual class HttpClientFactory {
actual fun create() : HttpClient {
return HttpClient(Android){
install(ContentNegotiation){
json()
}
}
}
}
em iosMain a classe actual
actual class HttpClientFactory {
actual fun create() : HttpClient{
return HttpClient(Darwin){
install(ContentNegotiation){
json()
}
}
}
}
Adicione os módulos Koin
//commonMain
val androidModule = module {
val androidModule = module {
single { HttpClientFactory().create() }
factory<Services> { KtorClientImpl(get()) }
}
val iosModule = module {
single { HttpClientFactory().create() }
factory<Services> { KtorClientImpl(get()) }
}
vamos criar um arquivo Koin.kt no mesmo lugar para invocar ele da classe App do iOs :
fun initKoin(){
startKoin {
modules(iosModule)
}
}
Edite o arquivo iOsApp e inclua o trecho abaixo:
@main
struct iosApp: App {
init() {
KoinKt.doInitKoin()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Veja que a extensão do arquivo Kotlin no iOs fica como camelCase KoinKt.doInitKoin
SQLDelight
//// KOIN
startKoin {
modules(
listOf(
module { single<Context> { this@AndroidApp } },
androidModule)
)
}
val androidModule = module {
...
single { DatabaseDriverFactory(get()).create() }
single<FavoriteMoviesDataSource> { FavoriteMoviesSqlDataSrc(get()) }
}
val iosModule = module {
...
single { DatabaseDriverFactory().create() }
single<FavoriteMoviesDataSource> { FavoriteMoviesSqlDataSrc(get()) }
}
///androidMain
actual class DatabaseDriverFactory(
private val context: Context
) {
actual fun create(): SqlDriver {
return AndroidSqliteDriver(TmdbDatabase.Schema, context, "tmdb.db")
}
}
///commonMain
expect class DatabaseDriverFactory {
fun create(): SqlDriver
}
///iosMain
actual class DatabaseDriverFactory {
actual fun create(): SqlDriver {
return NativeSqliteDriver(TmdbDatabase.Schema, "tmdb.db")
}
}
///gradle
sqldelight {
databases {
create("TmdbDatabase") {
packageName.set("com.brq.kmm.database")
}
}
}
Vamos criar um pacote /sqldelight/database/ em commonMain
Nele vamos criar o nosso Schema adicionando nossa criação de tabela e métodos de crud:
CREATE TABLE FavoriteMovieEntity(
movieId TEXT NOT NULL PRIMARY KEY,
movieName TEXT NOT NULL
);
getFavoriteMovie:
SELECT *
FROM FavoriteMovieEntity
WHERE movieId = :movieId;
insertFavoriteMovieEntity:
INSERT OR REPLACE
INTO FavoriteMovieEntity(
movieName,
movieId
)
VALUES( ?, ?);
removeFavoriteMovie:
DELETE FROM FavoriteMovieEntity WHERE movieId = :movieId;
Em seguida o Build vai gerar as classes com os métodos e entidades, crie os models de domain e o mapper e em seguida adicione as consultas, uma das classes geradas será TmdbDatabase
class FavoriteMoviesSqlDataSrc(
sqlDriver: SqlDriver
) : FavoriteMoviesDataSource {
private val db: TmdbDatabase = TmdbDatabase(sqlDriver)
private val queries = db.tmdbDatabaseQueries
override fun getFavoriteMovie(movieId: String): FavoriteMovieModel {
val result = queries.getFavoriteMovie(movieId)
.executeAsOneOrNull()
return result?.toDomain() ?: FavoriteMovieModel()
}
override fun insertFavoriteMovie(movie: FavoriteMovieModel) {
val tmp = movie.toLocal()
queries.insertFavoriteMovieEntity(
movieId = tmp.movieId,
movieName = tmp.movieName,
)
}
override fun removeFavoriteMovie(movieId: String) {
queries.removeFavoriteMovie(movieId)
}
override fun checkIfIsAFavoriteMovie(movieId: String): Boolean {
val result = queries.getFavoriteMovie(movieId).executeAsOneOrNull();
return result != null
}
}
Abra o projeto no Xcode e inclua a flag em Other Linker Flags
-lsqlite3
Conclusão
Para rodar o projeto com o simulador iPhone e também abrir projetos Xcode é necessário ter um computador da apple
O módulo commonMain é onde ficam as views, ou seja, o compose compartilhado, é onde escrevemos código e telas compartilhadas pelas plataformas, este módulo não tem a lib de @Preview do compose, ficando sem a pré visualização dos componentes criados.
Alguns Componentes como o DropDownMenu por exemplo , não existem no KMP pois em outras plataformas não faria sentido o mesmo existir, sendo assim temos que implementar usando classes expect e atual para cada plataforma
O Logcat funcionará apenas no Android, no iphone conseguimos ver os prints no run
Algumas coisas como o icone do app no iOs ou acesso a recursos do dispositivo, ainda teremos que fazer no modo nativo iOs.
Diferenças de UX entre plataformas como Botão back navigation no Android não existem no iOS e vice versa, sendo assim temos que fazer um layout que supra essa ausência e fique mais genérico perdendo um pouco a identidade de cada arquitetura
Consultando benchmark dos frameworks multi plataformas em 2023, na imagem abaixo podemos ver que o percentual de utilização do KMP ainda é abaixo dos 3%, e em relação ao Flutter, ainda tem muito a amadurecer, mas com o compose mm torcemos muito para que as coisas melhorem e passe a ter uma relevância maior no mercado;
image from here
Referências
Create a multiplatform app using Ktor and SQLDelight - tutorial | Kotlin
This tutorial demonstrates how to use Android Studio to create a mobile application for iOS and Android using Kotlin…kotlinlang.org
Screen interface and override the Content() composable function.voyager.adriel.cafe
Using SQLDelight in Kotlin Multiplatform Project - Mobile Dev Notes
A comprehensive example of integrating SQLDelight library in a Kotlin Multiplatform projectwww.valueof.io