Article image
JOAO SANTOS
JOAO SANTOS24/11/2023 10:59
Compartilhe

Hello Compose MultiPlatform

  • #Android
  • #iOS

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

link do projeto

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

Navigation

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

Compose Multiplatform Wizard

Edit descriptionterrakok.github.io

Compartilhe
Comentários (0)