Article image

JS

JOAO SANTOS24/11/2023 10:59
Share

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

    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

    Share
    Comments (0)