DS151 - Desenvolvimento Mobile

Tecnologia em Análise e Desenvolvimento de Sistemas

Setor de Educação Profissional e Tecnológica - SEPT

Universidade Federal do Paraná - UFPR

Prof. Alexander Robert Kutzke


Material da disciplina DS151 - Desenvolvimento Mobile.

Aula 01 - Introdução

Plano de ensino e avaliação

Consultar moodle da disciplina (https://www.ufprvirtual.ufpr.br).

O que aprenderemos?

Como desenvolver aplicativos nativos para Android utilizando Kotlin e Android Studio.

Como aprenderemos?

Por meio de aulas teóricas e práticas. Aulas práticas que serão compostas por um exercício a ser entregue ao professor na própria aula.

Todas as entregas de exercícios serão feitas pelo moodle e utilizando links de repositórios criados pelos alunos no gitlab do curso (gitlab.com).

Essa disciplina é difícil?

Não. Porém o bom aproveitamento depende de muita prática. O aprendizado do desenvolvimento de aplicativos para Android envolve, entre outras coisas, o estudo (e o uso) de vários exemplos de código prontos. Isso facilita o processo de desenvolvimento, uma vez que boa parte do trabalho acaba se tornando a ligação de pedaços de código já funcionais. Mas, após um certo amadurecimento do conhecimento é necessário que a compreensão de como esses trechos de códigos são formados seja expandida.

Portanto, é necessário praticar para que os exemplos sejam apreendidos e aprendidos de forma adequada. Não se assuste com a quantidade de códigos novos no início da disciplina. A ideia é que eles se tornem, pouco a pouco, mais "palatáveis". Além disso, utilizaremos uma linguagem nova - o Kotlin.

Android

  • Sistema Operacional para dispositivos móveis mais utilizado em todo o mundo:
    • Mais de 2bi de dispositivos ativos;
  • Além do sistema operacional, inclui:
    • Middleware (comunicação entre aplicativos);
    • Aplicações-chave (telefone, câmera, etc);
  • Kernel do Linux como base;
  • Open-source ( http://source.android.com ):
    • Licença Apache;
    • Cada fabricante pode criar seu Android "personalizado";
    • Entretanto, para ter Apps do Google, o sistema deve ser homologado;
  • Primeira versão lançada em 2008;
  • API Level é um número sequencial que identifica a versão do Android (cada versão possui o nome de um doce);
  • Estamos indo para a API Level 36, versão 16:
  • É importante conhecer as versões do Android para sabermos quais as APIs, classes e recursos estão disponíveis para nossos aplicativos em certos aparelhos;

Android Studio

Meme android

  • O Android Studio é o IDE (Integrated Development Environment) padrão para desenvolvimento de aplicações Android;
  • Uma personalização do IntelliJ IDEA Community, criado pela Jetbrains;
  • Traz MUITAS ferramentas para auxiliar no processo de desenvolvimento;

Download

  • Todos deverão ter instalado em seus computadores (ou utilizar o lab);
  • Para isso, basta fazer o download do pacote específico para o seu sistema operacional:

https://developer.android.com/studio/

  • A versão utilizada nesse material é a Android Studio Meerkat | 2024.3.1;
  • O pacote baixado já traz todas as dependências necessárias:
    • OpenJDK;
    • Gradle;
    • Android Debug Bridge (adb);
    • SDK Manager;
  • Após instalado, ao iniciar o Android Studio, provavelmente a seguinte tela aparecerá:

Início Android Studio

  • A partir dessa tela, é possível acessar o SDK Manager (Configure -> SDK Manager);
  • Nele, é possível baixar e instalar componentes do SDK:
    • Na aba SDK Platforms, clique em Show Package Details;
    • Marque, pelo menos, os seguintes itens:
      • Android 14.0 (UpsideDownCake) - ou o que funcionar nos computadores do lab:
        • Android SDK Platform 34;
        • Sources for Android 34;
    • Na aba SDK Tools adicione, ao menos, os itens:
      • Android SDK Build-Tools;
      • Android Emulator;
      • Android SDK Platform-Tools;
      • Android SDK Command-line Tools;
    • Por fim, clique em Apply para finalizar;
    • Aguarde o download e a instalação.

SDK Manager

SDK Tools

Kotlin

  • https://kotlinlang.org/
  • Kotlin é uma Linguagem de programação que roda em uma Máquina virtual Java e que também pode ser traduzida para JavaScript;
  • É a linguagem oficial para o desenvolvimento de aplicativos para Android;
  • Tem interoperabilidade completa com Java;
  • Aprenderemos no decorrer da disciplina:

Mais recursos

Referências

Aula 02 - Conceitos Básicos

A aula tem como objetivo criar um primeiro aplicativo Android e analisar sua estrutura principal.

Iniciando um novo projeto com Android Studio

  • Para criar um novo aplicativo no Android Studio:

    • Em sua tela inicial, selecione a opção Start a new Android Studio project ou File > New > New Project;

      Create New Project

    • A tela acima permite a escolha de um template para a Activity:

      • Activity é a classe responsável por criar uma tela na aplicação Android;
      • A activity inicial de uma aplicação é comumente nomeada MainActivity;
      • Toda activity tem seu conteúdo visual definido em arquivos XML, conhecidos como "Arquivos de layout":
        • Mais recentemente, o Jetpack Compose permite formas mais simples de criação de layouts;
    • Selecione a opção Empty Activity para um primeiro exemplo;

      Configurações do Projeto

    • Na sequência é necessário acrescentar as primeiras configurações da aplicação. São elas:

      • Name: nome que aparecerá no aparelho;
      • Package Name: nome do pacote. Este nome deve ser único!
      • Save location: onde o projeto será salvo no computador;
      • Minimum API Level: qual a versão mínima do Android que o aparelho deve possuir para executar a aplicação. Procuraremos sempre utilizar, no mínimo, a API Level 24 (Android Nougat);
      • Build configuration language: define scripts de build padrão para Kotlin;
  • Após criar o novo projeto, o Android Studio irá realizar verificações de dependências necessárias para realização do build do projeto. Espere pacientemente. Isso pode envolver alguns downloads:

    • Além disso, ele pode iniciar o processo de "Indexing";

    • Esse processo é o reconhecimento dos arquivos do projeto para que o Android Studio consiga executar todas as suas funcionalidades;

    • Novamente, aguarde pacientemente. :)

      Tela Novo Projeto1

Android Jetpack

  • O Android Jetpack é uma coleção de bibliotecas para facilitar o desenvolvimento de aplicativos Android;
  • Auxilia no uso das práticas recomendadas de desenvolvimento;
  • Simplifica tarefas complexas;
  • Tem desenvolvimento separado da API Level:
    • Significa que tem updates mais frequentes;
    • Não é necessário aguardar uma nova versão do Android para acessar novas funcionalidades;
  • Antes do Jetpack, não havia padrão para os nomes de bibliotecas oficiais do Android:
    • android.arch.*, com.android.support.*, android.databinding.* ...;
    • Com o Jetpack, todas as bibliotecas estão no pacote androidx.*;
    • Como esse é um processo em transição, ainda existem vários projetos de aplicativos que utilizam as bibliotecas antigas;
    • O objetivo é que todos migrem para o Jetpack:
      • O próprio Android Studio possui uma opção para migrar projetos para o Jetpack (Refactor > Migrate to Android X);
  • Recentemente o Jetpack Compose foi lançado2:
    • Forma mais simples de gerar layouts para aplicações;
    • Substitui os arquivos horríveis de layout XML;
  • Mais informações: https://developer.android.com/jetpack/

Estrutura de um projeto Android

  • No canto superior esquerdo do Android Studio é possível selecionar diferentes visualizações da estrutura do projeto;

  • Selecione o tipo Project;

  • Você verá uma organização semelhante a essa:

    Organização do Projeto

  • Analisaremos detalhes dessa estrutura a seguir.

AndroidManifest.xml

  • O arquivo AndroidManifest.xml serve como um arquivo central de configuração do aplicativo;
  • Os dados utilizados na tela de configuração do aplicativo estão aqui:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.HelloWorldTADS"
        tools:targetApi="31">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:label="@string/app_name"
            android:theme="@style/Theme.HelloWorldTADS">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>
  • Todas as activities que serão exibidas para os usuários deverão estar declaradas no AndroidManifest.xml;
  • Note que a action android.intent.action.MAIN indica qual activity é o ponto de entrada da aplicação:
    • Ou seja, qual activity vai ser iniciada quando o usuário abrir o aplicativo;
  • A category android.intent.category.LAUNCHER indica que essa activity deverá aparecer no menu de aplicações do aparelho.
  • Valores que possuem o símbolo "@" indicam um recurso da aplicação.

Arquivos de Recursos

  • Os recursos da aplicação são imagens, textos, layouts, e outros itens a serem utilizados pelo desenvolvedor;

  • Todos eles ficam localizados em app/src/main/res;

  • Para cada recurso, um identificador é mapeado automaticamente;

  • Esse identificador fica armazenado na classe R;

  • Por exemplo:

    • Na pasta res/mipmap-mdpi, existe um arquivo chamado ic_launcher.webp;
    • É possível acessar esse arquivo pelo identificador R.mipmap.ic_launcher em qualquer código Kotlin da aplicação:
      • Nos arquivos xml, ao invés de utilizarmos a classe R, utilizamos o sinal @ para referenciarmos algum recurso;
    • Vejamos mais uma vez um trecho do AndroidManifest.xml:
            android:label="@string/app_name"
    
    • Na definição acima, a propriedade android:label está utilizando como valor o recurso @string/app_name. Ou seja, o arquivo res/values/strings.xml será consultado;
    • Dentro desse arquivo, a string nomeada app_name trará o valor do recurso;
    • Ao consultar o arquivo res/values/strings.xml temos:
    <resources>
      <string name="app_name">Hello World TADS</string>
    </resources>
    
    • Em resumo, para acessar o valor da string app_name:
      • No kotlin: getString(R.string.app_name);
      • No xml: @string/app_name;
    • Alguns mapeamentos de recursos comuns são mostrados a seguir:
RecursoID da Classe RArquivo XML
res/mipmap/ic launcher.pngR.mipmap.ic_launcher@mipmap/ic_launcher
res/drawable/imagem.pngR.drawable.imagem@drawable/imagem
res/layout/activity_main.xmlR.layout.activity_main@layout/activity_main
res/menu/menu_main.xmlR.menu.menu main@menu/menu_main
res/values/strings.xml <string name="ola">R.string.ola@string/ola
  • O sistema de identificação dos recursos na aplicação é mais esperto do que parece. Mais a frente veremos alguns detalhes interessantes.

MainActivity.kt

  • Nosso primeiro código kotlin:
package com.example.helloworldtads

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.example.helloworldtads.ui.theme.HelloWorldTADSTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            HelloWorldTADSTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    Greeting(
                        name = "Android",
                        modifier = Modifier.padding(innerPadding)
                    )
                }
            }
        }
    }
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Text(
        text = "Hello $name!",
        modifier = modifier
    )
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    HelloWorldTADSTheme {
        Greeting("Android")
    }
}
  • Diferenças claras com relação ao Java:

    • Extensão do arquivo (.kt);

    • Sem ; no final do comandos (é opcional);

    • Operador de herança ::

      • Classe MainActivity está herdando ComponentActivity;
    • Toda classe herda de Any e não de Object;

    • Ao sobreescrever um método, é necessário utilizar a palavra-chave override;

    • Toda função (ou método) tem a seguinte sintaxe:

      • <modificador> fun nomeDaFuncao(<parametros>) : <tipo de retorno>:

        private fun soma(a:Int, b:Int): Int {
          return a + b
        }
        
    • Não existem tipos primitivos. Todos são classes;

    • Possui o recurso de nulabilidade (nullability):

      • É obrigatório declarar se um atributo, variável ou parâmetro pode ser nulo ou não;

      • Se sim, adiciona-se um ? ao tipo do item. Ex.: Int?;

      • Outros exemplos:

        var a:String = "Kotlin"
        a = null // não compila! "a" não aceita nulo
        var b:String? = "abc"
        b = null // ok
        val lenA = a.length // ok! é garantido que "a" não é nulo
        val lenB = b.length // não compila, pois "b" pode ser nulo
        val lenB1 = if (b != null) b.length else -1
        val lenB2 = b?.length ?: -1 // "Elvis operator"
        
      • var indica variável, enquanto val indica constante;

      • Possuí inferência automática de tipos;

      • Estrutura if/else sempre retorna um valor;

      • O Elvis Operator (?:) serve para verificar se determinada expressão é nula e, caso positivo, retorna um valor não nulo.

  • Sobre o código da MainActivity:

    • O método onCreate(Bundle) é chamado quando a Activity é criada e nele é chamado o método setContent para informar qual é o componente a ser desenhado na tela;

Arquivo de Layout (como era com arquivos XML)

  • Os arquivos de layout são arquivos XML que definem a estrutura visual de uma ou mais activities;

  • Localizam-se na pasta res/layout;

  • Na MainActivity, utilizamos a classe R para referenciar um layout (R.layout.activity_main);

  • Ao abrir o arquivo de layout correspondente, temos acesso ao editor de layouts:

    • Este pode ser utilizado com a interface gráfica (Design) ou de texto;

      Edição de Layout

    • O layout da nossa MainActivity possui o seguinte conteúdo xml:

      <?xml version="1.0" encoding="utf-8"?>
      <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
          xmlns:app="http://schemas.android.com/apk/res-auto"
          xmlns:tools="http://schemas.android.com/tools"
          android:layout_width="match_parent"
          android:layout_height="match_parent"
          tools:context=".MainActivity">
      
          <TextView
              android:layout_width="wrap_content"
              android:layout_height="wrap_content"
              android:text="Hello World!"
              app:layout_constraintBottom_toBottomOf="parent"
              app:layout_constraintLeft_toLeftOf="parent"
              app:layout_constraintRight_toRightOf="parent"
              app:layout_constraintTop_toTopOf="parent" />
      
      </androidx.constraintlayout.widget.ConstraintLayout>
      
    • É possível observar pelo arquivo acima que nosso layout é composto por:

      • Um gerenciador de layout conhecido por ConstraintLayout;
      • Um <TextView> com conteúdo "Hello World!" e algumas indicações de posicionamento;

Configuração do Aparelho

  • Para executar nossa aplicação em um aparelho real é necessário, primeiramente, configurar o aparelho:

    1. Acesse o menu Sobre o telefone nas configurações do aparelho (provavelmente no menu Sistema);

    2. Toque 7 vezes no item Número da Versão (ou Build Number ou qualquer outro nome parecido com isso);

    3. Você verá o aviso "Você agora é um desenvolvedor";

    4. Isso habilitará o menu Opções de desenvolvedor nas configurações do aparelho;

    5. Nesse menu, ative a opção Depuração USB (USB Debugging);

    6. Agora ao conectar o aparelho ao computador com um cabo USB, o Android Studio será capaz de reconhecê-lo:

      1. Pode ser que uma mensagem de confirmação apareça no aparelho. Apenas confirme;
      2. Normalmente, em dispositivos Unix, o aparelho é reconhecido automaticamente. No windows, às vezes, é necessário baixar o driver do aparelho no site do fabricante.

      Sobre o telefone

      Opções de desenvolvedor

Android Virtual Device

  • Um Android Virtual Device (AVD) é um emulador do Android;
  • Pode-se utilizar um AVD para testar aplicativos do Android Studio sem a necessidade de um aparelho real;
  • Uma das vantagens do AVD é a possibilidade de testar o aplicativo em diferentes tipos de dispositivos (tamanho de tela, resolução, recursos, etc);
  • Para computadores com processador Intel é possível instalar o HAXM (Hardware Accelerated Execution Manager):
    • Nos casos em que há compatibilidade, o Android Studio já instala esse recurso automaticamente;
  • Para criar um AVD, basta acessar o menu Tools > AVD Manager ou Tools > Device Manager no Android Studio e seguir as instruções;
  • Em um dado momento da configuração, será necessário selecionar uma imagem de sistema. Ou seja, qual a versão do Android que o emulador irá utilizar:
    • Nesse momento, o download da imagem deverá ser realizado.
  • Existem outros emuladores de Android que não são oficiais da empresa Google.

Executando a Aplicação

  • Uma vez você tenha um celular conectado ao seu computador pela porta USB ou um AVD pronto, o Android Studio irá reconhecer esse aparelho como um alvo para execução da sua aplicação:

    Aparelho reconhecido

  • Nesse momento, basta clicar no botão Run app (botão verde em formato de play);

  • Ao fazer isso, sua aplicação será compilada e instalada no dispositivo, e inicializada automaticamente:

    Primeiro aplicativo

Processo de Compilação

  • O Android Studio utiliza o Gradle (https://www.gradle.org) como ferramenta para gerenciamento do build dos aplicativos;
  • Essa ferramenta é responsável por toda a configuração e inclusão/download de dependências para o processo de compilação do aplicativo;
  • O Gradle é uma aplicação completa, independente do Android Studio:
    • Sua interface mais comumente utilizada é pela linha de comando;
    • Também é bastante utilizado em projetos de outras linguagens;
  • Em um projeto, o arquivo principal para o Gradle é nomeado build.gradle;
  • Nos projetos do Android Studio, existem, pelo menos, dois arquivos com esse nome: um na raiz do projeto e outro em cada módulo (o módulo que vamos ver nesse curso trata do aplicativo smartphone e tablets, comumente nomeado de app);
  • O arquivo build.gradle do nosso módulo app contém algo semelhante a isso:
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
}

android {
    namespace = "com.example.helloworldtads"
    compileSdk = 35

    defaultConfig {
        applicationId = "com.example.helloworldtads"
        minSdk = 24
        targetSdk = 35
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
    kotlinOptions {
        jvmTarget = "11"
    }
    buildFeatures {
        compose = true
    }
}

dependencies {

    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.lifecycle.runtime.ktx)
    implementation(libs.androidx.activity.compose)
    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.androidx.ui)
    implementation(libs.androidx.ui.graphics)
    implementation(libs.androidx.ui.tooling.preview)
    implementation(libs.androidx.material3)
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)
    androidTestImplementation(platform(libs.androidx.compose.bom))
    androidTestImplementation(libs.androidx.ui.test.junit4)
    debugImplementation(libs.androidx.ui.tooling)
    debugImplementation(libs.androidx.ui.test.manifest)
}
  • O Gradle funciona essencialmente a base de plugins;
  • Nas 3 primeiras linhas do arquivo acima, são invocados 3 plugins do gradle: uma para desenvolvimento para android e dois referentes ao uso do kotlin;
  • Esses plugins possuem as informações necessárias para que a compilação seja adequada às necessidades do projeto (Android + Kotlin);
  • No bloco android residem as configurações específicas para o plugin android;
  • Ao final do arquivo, o bloco dependencies define as dependências de bibliotecas externas ou módulos necessários pelo projeto:
    • Cada dependência pode ter a versão específica no final de sua linha;
  • O arquivo build.gradle da raiz do projeto possui definições que serão utilizadas em todos os módulos:
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.kotlin.android) apply false
    alias(libs.plugins.kotlin.compose) apply false
}
  • No decorrer das aulas trataremos de mais detalhes sobre o Gradle.

Alocação dinâmica de recursos

  • Todos os recursos de uma aplicação (strings, imagens, estilos, layouts, etc.) devem, obrigatoriamente, estar na pasta res/;

  • Cada subdiretório dessa pasta tem, entretanto, objetivos específicos. Os principais são:

    • anim/ e animator/: arquivos XML de animações quadro-a-quadro ou de efeito;
    • drawable/: arquivos de imagens da aplicação .jpg ou.png, ou ainda arquivos XML que descrevem algo que será desenhado na tela;
    • font/: arquivos de fontes .ttf ou.otf, ou arquivos XML que descrevem famílias de fontes;
    • layout/: arquivos XML com a definição do conteúdo visual das telas;
    • menu/: arquivos XML com as opções de menu;
    • mipmap/: ícone da aplicação;
    • raw/: arquivos binários diversos que podem ser usados no projeto;
    • transition/ arquivos XML para descrever animações de transição;
    • values/: arquivos XML com valores tais como: strings (texto simples); string_arrays (lista de valores), dimensions (definição de tamanhos); colors (definição de cores) e styles (estilos);
    • xml/: essa pasta normalmente armazena arquivos XML de metadados da aplicação;
  • Todos os arquivos de recursos devem ser nomeados com letras minúsculas e não devem conter símbolos (exceto _):

    • A partir do segundo caractere podem conter números;
  • A extensão do arquivo não é utilizada para a geração do id da classe R, portanto não é possível ter um mesmo arquivo com duas extensões diferentes;

  • O Android possui um recurso de escolha dinâmica de recursos (ou alocação dinâmica) em que o sistema operacional seleciona o recurso mais apropriado de acordo com as configurações do aparelho:

    • Para isso, é preciso apenas criar variações nos diretórios de recursos adicionando sufixos especiais aos seus nomes:

      Tabela sufixos

  • É possível combinarmos sufixos. Por exemplo:

    • layout-pr-rBR-large: layouts a serem utilizados em aparelhos configurados em português brasileiro e com uma tela grande;
    • values-mcc724-mnc31-v21: valores a serem utilizados em aparelhos no Brasil, da operadora Oi e com API level 21;
    • drawable-land-hdpi: desenhos a serem utilizados em aparelhos em posição horizontal e com uma densidade de tela de 240dpi;
  • Além disso o Android tentará, na ausência de uma pasta específica, fazer a melhor aproximação possível do recurso a ser utilizado.

Idioma

  • Como exemplo, podemos realizar um processo de internacionalização do nosso aplicativo inicial;

  • Abra o arquivo res/values/strings.xml e adicione o seguinte:

    <resources>
        <string name="app_name">Hello World TADS</string>
        <string name="test">This is a test</string>
    </resources>
    
  • Agora, no arquivo MainActivity.kt referencie essa string:

Greeting(
    name = getString(R.string.test), // ⬅️ Altere aqui
    modifier = Modifier.padding(innerPadding)
)
  • É recomendado que todo texto utilizado em um aplicativo esteja registrado apenas em arquivos de recursos;

  • Agora clique sobre a pasta app/src/main/res e escolha a opção New > Android Resource File:

  • Adicione o nome strings.xml e selecione opção Locale e clique no botão >>;

  • Selecione português e clique em OK;

    Tela de Novo Recuro

  • Agora adicione o seguinte conteúdo ao arquivo criado:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="app_name">Olá Mundo TADS</string>
    <string name="test">Isso é um Teste</string>
</resources>
  • Compile e execute o aplicativo;

  • Altere a configuração de Idioma do seu aparelho e veja que a aplicação se adapta de maneira automática.

    App traduzido

DPI, Orientação e DIP

  • O Android possui uma especificação própria no que diz respeito ao tamanho e qualidade de tela de um dispositivo;

  • Um dos itens para definir a qualidade de uma tela é a unidade Dots per Inch (Pontos por polegadas) ou DPI:

    • O Android classifica os dispositivos em categorias de acordo com a quantidade de DPI existente. São elas:

      Tabela DPI

  • Dessa forma, é possível adicionar imagens de diferentes tamanhos (em diferentes pastas de recursos) para que sejam selecionadas as melhores adaptadas à qualidade de tela do dispositivo;

  • É possível, também, definir recursos de acordo com a orientação atual da tela do dispositivo (horizontal ou vertical):

    • A tabela a seguir apresenta exemplos de recursos utilizados em diferentes orientações de tela:

      Tabela Orientação

  • Para determinarmos se uma tela é pequena ou grande, temos a seguinte lista de categorias:

    Tabela Tamanho Tela

  • Perceba que a unidade agora é DP (ou DIP - *Density Independent Pixels):

    • Essa é uma unidade que leva em consideração a quantidade de pixels na tela em sua área física;

    • Ou seja, quanto mais pixels tivermos por área, melhor será a qualidade da tela;

    • O DIP de uma tela é calculado da seguinte maneira:

      dp = pixels / (dpi / 160)
      
    • Por exemplo, qual o tamanho da tela (para o Android) de um aparelho com resolução de 1024x768 pixels com densidade 240dpi (HDPI)?

      Conta DIP

    • Logo a tela irá se enquadrar na categoria large, pois tem 680x512dp;

    • A unidade DP é mais precisa para medir a qualidade das telas;

    • Para saber mais informações sobre seu dispositivo, adicione o seguinte código no método onCreate(Bundle) da MainActivity:

      val config = resources.configuration
      val metrics = resources.displayMetrics
      val orientation = config.orientation
      val density = metrics.density
      val height = metrics.heightPixels
      val width = metrics.widthPixels
      val mcc = config.mcc
      val mnc = config.mnc
      val locale = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
          config.locales[0] else config.locale
      
      val tag = "DS151"
      Log.d(tag, "density: $density")
      Log.d(tag, "orientation: $orientation")
      Log.d(tag, "height: $height")
      Log.d(tag, "width: $width")
      Log.d(tag, "language: ${locale.language} - ${locale.country}")
      Log.d(tag, "mcc: $mcc")
      Log.d(tag, "mnc: $mnc")
      
    • Para compilar o código sem erros, será necessário adicionar a dependência android.util.Log ao código:

      import android.util.Log
      
    • Se preferir, pressione o atalho ALT+Enter para que o Android Studio sugira a importação:

      • Ou ainda, nas configurações do Android Studio, habilite a auto importação;
    • Agora, após executar a aplicação, consulte o menu View > Tool Windows > Logcat para verificar a saída do Log.

Jetpack Compose

  • Nova forma de produzir layouts de aplicações no Android Nativo;

    • Substitui os antigos arquivos XML de views;
    • É a forma padrão e indicada para a criação de novos layouts;
  • Traz muitas das ideias dos frameworks React Native e Flutter para o Android Nativo;

    • O que torna a ideia muito interessante, unindo o melhor dos dois mundos;
  • Sistema de componentes de interface;

    • Cada componente da interface será uma função do tipo Composable;
    • A intenção é que o desenvolvedor crie uma coleção de funções, ou seja, de componentes, para que sejam reutilizadas na aplicação ou, inclusive, em outros projetos;

Funções Composable

  • São funções Kotlin que possuem o modificador @Composable:

    • O modificador permite que a função tenha acesso a outras funções do pacote Composable.
  • Tudo continua igual:

    • O arquivo AndroidManifest.xml indica qual Activity é o Entry Point da aplicação;
    • Essa Activity determina o seu layout a partir da função setContent:
      • A diferença é que, ao invés de utilizar um arquivo XML de View, agora a função setContent recebe chamadas de funções Composable;
  • Adicionar Surface em volta do Text e mudar a cor com Surface(color = MaterialTheme.colorScheme.primary):

    • A cor do texto muda automaticamente pois o material Design tem definições padrão para cores.
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Surface( color = MaterialTheme.colorScheme.secondary ) {
        Text(
            text = "Hello Alex!",
            modifier = modifier
        )
    }
}

Modifiers

  • Composables geralmente possuem o parâmetro modifier para gerar alterações no seu layout:
    • Geralmente definem formas como o componente deve ser comportar em relação ao seu componente antecessor na hierarquia;
    • Padding, Margin, Display, etc.
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Surface(color = MaterialTheme.colorScheme.primary) {
        Text(
            text = "Hello $name!",
            modifier = modifier.padding(24.dp)
        )
    }
}
  • É sempre interessante que um componente receba como parâmetro um objeto Modifier e passe esse parâmetro como argumento para o componente filho.

  • Modifier weight ajuda a posicionar elementos. Algo como no Flex do CSS.

Layouts Básicos

  • Columns, Row, Box;

Botões

  • Button: último argumento é um trailing lamba, ou seja um bloco de código:
    • Trailing lambdas, quando são o último argumento, podem ser passados para fora dos parênteses dos argumentos da função;
    • O que estiver dentro desse bloco será o conteúdo do botão.
    • Precisa adicionar o argumento onClick = {};
          ElevatedButton(
                onClick = {
                    Log.d("DS151", "clique!")
                }
            ) {
                Text("Clique")
          }

Estado de componentes

  • Muito similar ao estado do React Native;

  • Algumas variáveis do componente podem ser monitoradas pelo Composable:

    • Quando alteradas, o componente é redesenhado (a função é executada novamente): Recomposition
    • Componentes podem ser reexecutados em qualquer ordem e frequentemente;
  • Para que o Composable observe uma variável utilize mutableStateOf;

    • Além disso, para que a variável não perca o seu valor entre execuções, adicione remember:
    val expanded = remember { mutableStateOf(false) }
  • Utilize, nesse exemplo, expanded.value para acessar ou alterar o valor do estado;
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
  val expanded: MutableState<Boolean> = remember { mutableStateOf(false) }
  Surface( color = MaterialTheme.colorScheme.secondary, modifier = modifier.padding(4.dp) ) {
      Row(modifier = modifier.padding(24.dp)){
          Column(modifier = modifier.padding(4.dp).weight(0.8F)){
              Text(
                  text = "Hello",
              )
              Text(
                  text = "${name}!",
              )
          }
          ElevatedButton(
              modifier = modifier.weight(1.0F),
              onClick = {
                  expanded.value = !expanded.value
              }
          ) {
              Text(if (expanded.value) "Show less" else "Show more")
          }
      }
  }
}

Debug no Android Studio

  • Para realizar o debug da aplicação, apenas marque as linhas em que deseja adicionar um breakpoint:

    Breakpoint

  • Na sequência, basta clicar no botão Debug 'app';

  • Enquanto o breakpoint estiver ativo, é possível inspecionar as variáveis da aplicação na janela Debug;

Códigos da aula

Os códigos utilizados nessa aula podem ser encontrados no seguinte repositório:

https://github.com/tads-ufpr-alexkutzke/ds151-aula-02-codes

Referências

Antigo (para referência)

findViewByID e evento de clique

  • Assim como na programação Web, é necessário que exista algum tipo de comunicação entre o layout (tela e componentes) e o código de execução da aplicação (Activities);

  • Para tanto, faremos um exemplos de interação entre layout e a MainActivity:

    1. No arquivo res/layout/activity_main.xml, remova o TextView que contém o Hello World e, em seguida, adicione um Plain Text;

    2. Abaixo dele, adicione um Button:

      1. Não se preocupe com o alinhamento nesse momento;
      2. Apenas clique no botão Infer constraints;
    3. Perceba que, ao lado do layout estão as propriedades do objeto selecionado;

    4. Existem duas propriedades nomeadas com text. Nos preocuparemos apenas com a segunda (sem um ícone);

    5. Selecione o botão ... nessa propriedade text;

    6. A tela que surgir é utilizada para criar recursos de texto (strings). Selecione o botão Add new resource e, então, New string value;; Tela de Novo Recuro 3

    7. Preencha com os valores main_button_toast como nome e Exibir toast como valor;

    8. Essa mesma alteração poderia ter sido realizada diretamente no arquivo res/values/strings.xml. Nas próximas vezes, proceda como preferir;

    9. No arquivo do layout, altere a propriedade ID do botão para buttonToast;

    10. Altere o ID do Plain Text para editText e apague sua propriedade text. O layout ficará semelhante a esse:

       <?xml version="1.0" encoding="utf-8"?>
       <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
         xmlns:app="http://schemas.android.com/apk/res-auto"
         xmlns:tools="http://schemas.android.com/tools"
         android:layout_width="match_parent"
         android:layout_height="match_parent"
         tools:context=".MainActivity">
      
         <EditText
           android:id="@+id/editText"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:ems="10"
           android:inputType="textPersonName"
           android:text=""
           app:layout_constraintBottom_toBottomOf="parent"
           app:layout_constraintEnd_toEndOf="parent"
           app:layout_constraintStart_toStartOf="parent"
           app:layout_constraintTop_toTopOf="parent" />
      
         <Button
           android:id="@+id/buttonToast"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content"
           android:layout_marginTop="47dp"
           android:text="@string/main_button_toast"
           app:layout_constraintEnd_toEndOf="parent"
           app:layout_constraintStart_toStartOf="parent"
           app:layout_constraintTop_toBottomOf="@+id/editText" />
       </androidx.constraintlayout.widget.ConstraintLayout>
      
    11. Agora adicione o seguinte código a sua Main Activity:

      class MainActivity : AppCompatActivity() {
      
          override fun onCreate(savedInstanceState: Bundle?) {
              super.onCreate(savedInstanceState)
              setContentView(R.layout.activity_main)
              val editText = findViewById<EditText>(R.id.editText)
              val button = findViewById<Button>(R.id.buttonToast)
              button.setOnClickListener {
                  val text = editText.text.toString()
                  Toast.makeText(this,text,Toast.LENGTH_SHORT).show()
              }
          }
      }
      
  • O método setOnClickListener(View.OnClickListener) precisa de um objeto que implemente a interface View.OnClickListener para registrar um novo evento de click para o botão;;

  • Para interfaces que possuem apenas um método, o caso da View.OnClickListener, podemos utilizar uma expressão lambda. É exatamente o que foi feito no código acima;

  • A versão completa da declaração seria algo como:

    class MainActivity : AppCompatActivity() {
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_main)
            val editText = findViewById<EditText>(R.id.editText)
            val button = findViewById<Button>(R.id.buttonToast)
            button.setOnClickListener (object: View.OnClickListener{
                override fun onClick(v: View?) {
                    val text = editText.text.toString()
                    Toast.makeText(this@MainActivity,text,Toast.LENGTH_SHORT).show()
                }
            })
        }
    }
    

Kotlin Android Extensions

  • O método findViewById(int) é padrão do Android e bastante utilizado para acessar elementos do layout;
  • Entretanto, com o plugin Kotlin Android Extensions, dentre outras coisas, permite que o acesso aos elementos do layout seja mais direto;
  • Para testar, verifique se a seguinte linha está presente no arquivo build.gradle no módulo app:
apply plugin: 'kotlin-android-extensions'
  • Agora, na Main Activity, importe seu layout da seguinte maneira:
import kotlinx.android.synthetic.main.activity_main.*
  • Dessa forma, é possível adicionar os componentes do layout diretamente:
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        //val editText = findViewById<EditText>(R.id.editText)
        //val button = findViewById<Button>(R.id.buttonToast)
        buttonToast.setOnClickListener (object: View.OnClickListener{
            override fun onClick(v: View?) {
                val text = editText.text.toString()
                Toast.makeText(this@MainActivity,text,Toast.LENGTH_SHORT).show()
            }
        })
    }
}
1

O tema da interface é o Catppuchin Latte.

2

Razão para eu tentar desenvolvimento nativo novamente. Passei um tempo com React Native.

Aula 03: Introdução ao Kotlin para Desenvolvimento Android

Introdução ao Kotlin

Kotlin é uma linguagem moderna e concisa, totalmente interoperável com Java, e é a linguagem oficial para desenvolvimento Android.

Vantagens do Kotlin:

  • Sintaxe concisa e expressiva;
  • Segurança contra NullPointerExceptions;
  • Suporte a programação funcional;
  • Interoperabilidade com Java;
  • Coroutines para programação assíncrona;

Exemplo 1: Olá, Mundo!

fun main() {
    println("Olá, Mundo!")
}

A função main é o ponto de entrada do programa Kotlin. A palavra-chave fun indica a definição de uma função, main é o nome da função, e os parênteses () indicam que ela não recebe argumentos. O corpo da função é delimitado por {}.


Variáveis e Tipos de Dados

Em Kotlin, variáveis podem ser declaradas de duas formas principais:

  • val (imutável, equivalente a final em Java)
  • var (mutável, pode ter seu valor alterado posteriormente)

Exemplo 2: Declarando variáveis

val nome: String = "João"
var idade: Int = 25
idade = 26 // Permitido pois "idade" é mutável

A sintaxe segue o formato val/var nome: Tipo = valor. O tipo pode ser inferido automaticamente pelo compilador.


Controle de Fluxo

O controle de fluxo em Kotlin é essencial para definir a lógica de execução dos programas. Ele permite que o código tome decisões e repita ações com base em condições específicas. As principais estruturas de controle de fluxo em Kotlin incluem:

Estruturas Condicionais

if / else

Sintaxe:
if (condicao) {
    // Código executado se a condição for verdadeira
} else {
    // Código executado se a condição for falsa
}
Exemplo:
val numero = 10
if (numero > 0) {
    println("O número é positivo")
} else {
    println("O número é negativo ou zero")
}

O if também pode ser usado como expressão retornando um valor:

val resultado = if (numero % 2 == 0) "Par" else "Ímpar"
println(resultado)

when

O when substitui o switch de outras linguagens e permite comparar um valor com múltiplas condições.

Sintaxe:
when (variavel) {
    valor1 -> { // Código }
    valor2 -> { // Código }
    else -> { // Código }
}
Exemplo:
val dia = 3
val nomeDoDia = when (dia) {
    1 -> "Domingo"
    2 -> "Segunda-feira"
    3 -> "Terça-feira"
    4 -> "Quarta-feira"
    5 -> "Quinta-feira"
    6 -> "Sexta-feira"
    7 -> "Sábado"
    else -> "Dia inválido"
}
println(nomeDoDia)

O when também pode ser usado com expressões mais complexas:

val idade = 25
when {
    idade < 12 -> println("Criança")
    idade in 12..17 -> println("Adolescente")
    else -> println("Adulto")
}

Estruturas de Repetição

while

Sintaxe:
while (condicao) {
    // Código executado repetidamente
}
Exemplo:
var contador = 1
while (contador <= 5) {
    println("Contador: $contador")
    contador++
}

do-while

O do-while executa pelo menos uma vez, pois a verificação da condição ocorre após a execução do bloco.

Sintaxe:
do {
    // Código executado ao menos uma vez
} while (condicao)
Exemplo:
var numero = 0
do {
    println("Número: $numero")
    numero++
} while (numero < 3)

for

O for é usado para iterar sobre intervalos, listas e outras coleções.

Sintaxe:
for (item in colecao) {
    // Código executado para cada item
}
Exemplo com intervalos:
for (i in 1..5) {
    println("Número: $i")
}
Exemplo com listas:
val frutas = listOf("Maçã", "Banana", "Laranja")
for (fruta in frutas) {
    println(fruta)
}
Exemplo com índice:
val nomes = listOf("Ana", "Bruno", "Carlos")
for ((indice, nome) in nomes.withIndex()) {
    println("$indice: $nome")
}

Controle de Loop: break e continue

  • break: interrompe o loop completamente.
  • continue: pula para a próxima iteração do loop.
Exemplo:
for (i in 1..10) {
    if (i == 5) break
    println(i)
}

Saída:

1
2
3
4
for (i in 1..10) {
    if (i % 2 == 0) continue
    println(i)
}

Saída:

1
3
5
7
9

Funções em Kotlin

As funções são blocos de código reutilizáveis que executam uma tarefa específica. No Kotlin, as funções podem ter parâmetros, retornar valores e até serem funções de alta ordem.

Declaração de Funções

A palavra-chave fun é usada para declarar uma função em Kotlin.

Sintaxe básica:

fun nomeDaFuncao(parametros): TipoDeRetorno {
    // Corpo da função
    return valor
}

Exemplo:

fun soma(a: Int, b: Int): Int {
    return a + b
}

val resultado = soma(3, 5)
println(resultado) // Saída: 8

Funções com Retorno Unit

Se uma função não retorna um valor, seu tipo de retorno é Unit (equivalente a void em outras linguagens). O Unit pode ser omitido.

fun imprimirMensagem(mensagem: String): Unit {
    println(mensagem)
}

imprimirMensagem("Olá, Kotlin!")

Funções de Uma Linha (Expressões)

Se a função consiste em apenas uma expressão, podemos usar a sintaxe simplificada:

fun multiplicar(a: Int, b: Int) = a * b

println(multiplicar(4, 3)) // Saída: 12

Parâmetros com Valores Padrão

Podemos definir valores padrão para parâmetros:

fun saudar(nome: String = "Visitante") {
    println("Olá, $nome!")
}

saudar() // Saída: Olá, Visitante!
saudar("Carlos") // Saída: Olá, Carlos!

Parâmetros Nomeados

Podemos chamar funções especificando os nomes dos parâmetros para maior clareza:

fun formatarTexto(texto: String, repetir: Int = 1, maiusculo: Boolean = false) {
    val resultado = if (maiusculo) texto.uppercase() else texto
    repeat(repetir) { println(resultado) }
}

formatarTexto(repetir = 3, texto = "Oi", maiusculo = true)

Funções de Extensão

No Kotlin, podemos adicionar novas funções a classes existentes sem modificá-las, usando funções de extensão.

fun String.reverter(): String {
    return this.reversed()
}

println("Kotlin".reverter()) // Saída: niltoK

Funções Lambda (Funções Anônimas)

Kotlin permite definir funções anônimas (lambdas) que podem ser atribuídas a variáveis ou passadas como argumentos.

val soma = { a: Int, b: Int -> a + b }
println(soma(5, 7)) // Saída: 12

Funções de Alta Ordem

Funções que recebem outras funções como parâmetro ou retornam funções são chamadas de alta ordem.

fun operacao(a: Int, b: Int, funcao: (Int, Int) -> Int): Int {
    return funcao(a, b)
}

val resultadoSoma = operacao(10, 20, ::soma)
println(resultadoSoma) // Saída: 30

No exemplo acima, usamos ::soma para referenciar diretamente a função soma. O operador :: é usado para obter uma referência de função, permitindo que a função seja passada como argumento sem ser executada imediatamente.

Trailing Lambdas

Quando o último (ou único) parâmetro de uma função é uma função lambda, podemos movê-la para fora dos parênteses, tornando o código mais legível. Esse recurso é chamado de trailing lambda.

Exemplo sem trailing lambda:

fun executar(acao: () -> Unit) {
    acao()
}

executar({ println("Executando ação!") })

Exemplo com trailing lambda:

executar {
    println("Executando ação!")
}

Esse estilo é amplamente utilizado em bibliotecas Kotlin, como nas funções forEach de listas:

val numeros = listOf(1, 2, 3, 4)
numeros.forEach {
    println(it)
}

Coleções em Kotlin

As coleções em Kotlin são estruturas de dados que armazenam múltiplos elementos. Existem três tipos principais de coleções:

  • List: Uma coleção ordenada de elementos.
  • Set: Uma coleção de elementos únicos.
  • Map: Uma coleção de pares chave-valor.

Listas (List)

Uma List é uma coleção ordenada de elementos, que pode ser mutável ou imutável.

Lista imutável (List)

val listaImutavel = listOf("Maçã", "Banana", "Laranja")
println(listaImutavel[0]) // Saída: Maçã

Lista mutável (MutableList)

val listaMutavel = mutableListOf("Maçã", "Banana")
listaMutavel.add("Laranja")
println(listaMutavel) // Saída: [Maçã, Banana, Laranja]

Operações comuns com List

val numeros = listOf(1, 2, 3, 4, 5)
println(numeros.size) // Obtém o tamanho da lista
println(numeros.contains(3)) // Verifica se um elemento existe
println(numeros.first()) // Primeiro elemento
println(numeros.last()) // Último elemento

Conjuntos (Set)

Os conjuntos (Set) armazenam elementos únicos e não garantem uma ordem específica.

Set imutável

val setImutavel = setOf(1, 2, 3, 3)
println(setImutavel) // Saída: [1, 2, 3]

Set mutável

val setMutavel = mutableSetOf(1, 2, 3)
setMutavel.add(4)
setMutavel.add(2) // Elemento duplicado não é adicionado
println(setMutavel) // Saída: [1, 2, 3, 4]

Mapas (Map)

Os mapas (Map) armazenam pares chave-valor.

Map imutável

val mapaImutavel = mapOf("nome" to "Carlos", "idade" to 30)
println(mapaImutavel["nome"]) // Saída: Carlos

Map mutável

val mapaMutavel = mutableMapOf("nome" to "Ana")
mapaMutavel["idade"] = 25
println(mapaMutavel) // Saída: {nome=Ana, idade=25}

Operações com Map

val mapa = mapOf(1 to "um", 2 to "dois", 3 to "três")
println(mapa.keys) // Obtém as chaves
println(mapa.values) // Obtém os valores
println(mapa.containsKey(2)) // Verifica se a chave existe

Iteração sobre Coleções

Iterando com for

val lista = listOf("A", "B", "C")
for (item in lista) {
    println(item)
}

Usando forEach

lista.forEach { println(it) }

Filtragem e Transformação

Filtragem (filter)

val numeros = listOf(1, 2, 3, 4, 5)
val pares = numeros.filter { it % 2 == 0 }
println(pares) // Saída: [2, 4]

Transformação (map)

val dobrado = numeros.map { it * 2 }
println(dobrado) // Saída: [2, 4, 6, 8, 10]

Orientação a Objetos em Kotlin

A Orientação a Objetos (OO) é um paradigma de programação que organiza o código em torno de objetos. No Kotlin, podemos trabalhar com classes, herança, encapsulamento, polimorfismo e interfaces de forma simples e intuitiva.

Classes e Objetos

Uma classe é um modelo para criar objetos. Em Kotlin, usamos a palavra-chave class para definir classes.

Exemplo de classe e objeto:

class Pessoa(val nome: String, var idade: Int) {
    fun saudacao() {
        println("Olá, meu nome é $nome e tenho $idade anos.")
    }
}

val pessoa = Pessoa("Carlos", 30)
pessoa.saudacao() // Saída: Olá, meu nome é Carlos e tenho 30 anos.

Construtores

Podemos definir construtores primários diretamente na declaração da classe e construtores secundários dentro do corpo da classe.

Construtor primário:

class Carro(val marca: String, val modelo: String)

val carro = Carro("Toyota", "Corolla")
println(carro.marca) // Saída: Toyota

Construtor secundário:

class Animal {
    var nome: String
    var especie: String
    
    constructor(nome: String, especie: String) {
        this.nome = nome
        this.especie = especie
    }
}

val cachorro = Animal("Rex", "Cachorro")
println(cachorro.nome) // Saída: Rex

Herança

Kotlin permite herança entre classes usando open para permitir que uma classe seja estendida.

open class Animal(val nome: String) {
    open fun fazerSom() {
        println("Som genérico")
    }
}

class Cachorro(nome: String) : Animal(nome) {
    override fun fazerSom() {
        println("Au Au")
    }
}

val cachorro = Cachorro("Bolt")
cachorro.fazerSom() // Saída: Au Au

Modificadores de Visibilidade

Kotlin oferece quatro modificadores de visibilidade:

  • public (padrão) – acessível de qualquer lugar.
  • private – acessível apenas dentro da classe.
  • protected – acessível dentro da classe e subclasses.
  • internal – acessível dentro do mesmo módulo.
class ContaBancaria(private val saldo: Double) {
    fun exibirSaldo() {
        println("Saldo: $saldo")
    }
}

val conta = ContaBancaria(1000.0)
conta.exibirSaldo() // Saída: Saldo: 1000.0

Classes Abstratas

Classes abstratas não podem ser instanciadas diretamente e servem como modelo para subclasses.

abstract class SerVivo(val nome: String) {
    abstract fun mover()
}

class Peixe(nome: String) : SerVivo(nome) {
    override fun mover() {
        println("O peixe está nadando")
    }
}

val peixe = Peixe("Nemo")
peixe.mover() // Saída: O peixe está nadando

Interfaces

Interfaces definem comportamentos que podem ser implementados por várias classes.

interface Nadador {
    fun nadar() {
        println("Estou nadando!")
    }
}

class Golfinho : Nadador

val golfinho = Golfinho()
golfinho.nadar() // Saída: Estou nadando!

Data Classes

As data class são usadas para armazenar dados de forma eficiente, gerando automaticamente métodos como toString(), equals(), e copy().

data class Usuario(val nome: String, val idade: Int)

val usuario1 = Usuario("Ana", 25)
val usuario2 = usuario1.copy(idade = 30)

println(usuario1) // Saída: Usuario(nome=Ana, idade=25)
println(usuario2) // Saída: Usuario(nome=Ana, idade=30)

Exercícios Práticos para entregar

https://gitlab.com/ds151-alexkutzke/ds151-kotlin-assignment

Referências

Aula 04: Introdução ao Jetpack Compose

Código da aula

https://github.com/tads-ufpr-alexkutzke/ds151-aula-04-codes-jetpack-compose/

Referências

Jetpack Compose: A Nova Era do Desenvolvimento de Interfaces Android

  • Toolkit Moderno do Android: Usado para construir interfaces de usuário (UI) de forma declarativa.
  • Diferença do Sistema Tradicional:
    • XML Tradicional: Baseado em XML.
    • Compose: Descreve a UI como uma função transformando dados em elementos visuais.

Benefícios do Jetpack Compose

  • Código Mais Simples e Conciso:
    • Reduz significativamente a quantidade de código necessário para criar interfaces.
  • Reutilização de Componentes:
    • Facilita a criação de componentes que são reutilizáveis e personalizados.
  • Atualizações Dinâmicas:
    • Apenas as partes da UI que precisam são recompostas, melhorando a performance.
  • Compatibilidade com Kotlin:
    • Integra-se perfeitamente com a linguagem Kotlin, aproveitando ao máximo seus recursos modernos.
  • Animações Simplificadas:
    • Possui APIs poderosas para a criação de animações complexas com facilidade.

Conceitos Fundamentais

Funções de Composição

No Jetpack Compose, a UI é construída com funções chamadas funções de composição. Essas funções são anotadas com @Composable e descrevem um pedaço da UI.

@Composable
fun Greeting(name: String) {
    Text(text = "Olá, $name!")
}

Estado (State)

O estado é qualquer dado que pode mudar e influenciar a UI. Em Compose, usamos remember e mutableStateOf para armazenar e observar o estado.

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text(text = "Contador: $count")
    }
}

Modificadores (Modifiers)

Modificadores são usados para decorar ou adicionar comportamento aos elementos da UI. Eles são encadeáveis e permitem configurar propriedades como tamanho, padding, cor, etc.

Text(
    text = "Olá, Compose!",
    modifier = Modifier
        .padding(16.dp)
        .background(Color.LightGray)
)

Layouts

Compose oferece vários layouts para organizar elementos na tela. Alguns dos mais comuns incluem:

  • Column: Organiza os elementos verticalmente.
  • Row: Organiza os elementos horizontalmente.
  • Box: Permite sobrepor elementos.
@Composable
fun MyLayout() {
    Column {
        Text(text = "Elemento 1")
        Text(text = "Elemento 2")
    }
}

Exemplos Práticos

Exemplo 1: Lista Simples

Criar uma lista de itens usando LazyColumn.

@Composable
fun SimpleList() {
    val items = listOf("Item 1", "Item 2", "Item 3")
    LazyColumn {
        items(items) { item ->
            Text(text = item, modifier = Modifier.padding(8.dp))
        }
    }
}
Exemplo 2: Formulário de Login

Implementar um formulário de login com campos de texto e um botão.

@Composable
fun LoginForm() {
    var username by remember { mutableStateOf("") }
    var password by remember { mutableStateOf("") }

    Column(
        modifier = Modifier.padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        TextField(
            value = username,
            onValueChange = { username = it },
            label = { Text("Usuário") }
        )
        TextField(
            value = password,
            onValueChange = { password = it },
            label = { Text("Senha") },
            visualTransformation = PasswordVisualTransformation()
        )
        Button(onClick = { /* Lógica de login aqui */ }) {
            Text("Entrar")
        }
    }
}

Pensando com Jetpack Compose

1. Paradigma de Programação Declarativa

  • Modelo Tradicional: Uso de uma hierarquia de visualização em árvore.
  • Desafios: Alterações manuais que podem levar a erros e estados ilegais.
  • Abordagem Declarativa: Simplifica a engenharia de UI ao aplicar mudanças necessárias, evitando complexidade de atualizações manuais.

2. Funções Composable

  • Funções anotadas com @Composable para converter dados em UI.
  • Funções que não retornam nada, emitindo diretamente o estado desejado da tela.
@Composable
fun Greeting(name: String) {
    Text(text = "Hello $name")
}

3. Recomposição

  • Modelo de UI Imperativa vs Compose

    • No modelo imperativo, altere o estado de um widget usando setters.
    • No Compose, atualize chamando novamente a função Composable com novos dados.
    • Widgets são redesenhados apenas se necessário, graças à recomposição inteligente do Compose.
  • Exemplo de Função Composable

    • @Composable
      fun ClickCounter(clicks: Int, onClick: () -> Unit) {
          Button(onClick = onClick) {
              Text("I've been clicked $clicks times")
          }
      }
      
    • Ao clicar no botão, a chamada atualiza clicks e a função Text é recomposta para mostrar o novo valor.
  • Eficiência na Recomputação

    • Recomputação inteligente do Compose apenas para componentes que mudaram.
    • Isso economiza recursos computacionais e preserva a duração da bateria.
    • Ignora funções/lambdas sem parâmetros alterados, evitando recalcular desnecessariamente.
  • Cuidados com Efeitos Colaterais

    • Evite depender de efeitos colaterais em funções composable.
    • Efeitos colaterais perigosos incluem:
      • Alterar propriedades de objetos compartilhados.
      • Atualizar elementos observáveis em ViewModel.
      • Modificar preferências compartilhadas.
    • Recomenda-se executar operações de alto custo em corrotinas em segundo plano.
  • Exemplo: Uso de SharedPreferences em Composable

    • @Composable
      fun SharedPrefsToggle(
          text: String,
          value: Boolean,
          onValueChanged: (Boolean) -> Unit
      ) {
          Row {
              Text(text)
              Checkbox(checked = value, onCheckedChange = onValueChanged)
          }
      }
      
    • Atualizações de valor são geridas via callbacks e operações de fundo no ViewModel.
  • Considerações para Uso Eficiente do Compose

    • Recomposição Optimizada:
      • A recomposição ignora funções/lambdas quando possível.
      • Pode ser cancelada se for desnecessária.
    • Execução e Performance:
      • Funções composable podem ser executadas em todos os frames de uma animação.
      • São executadas em paralelo e podem ocorrer em qualquer ordem.
  • Melhores Práticas

    • Funções composable devem ser rápidas, idempotentes e sem efeitos colaterais para garantir compatibilidade e eficiência na recomposição.

Recomposição no Compose: Otimizações

  • Recomposição Seletiva

    • O Compose limita a recomposição às partes da IU que realmente precisam de atualização.
    • Permite recompor um único elemento (por exemplo, um botão) sem afetar outros elementos na árvore da IU.
  • Funções e Lambdas Composable

    • Todas as funções e lambdas podem ser recompostas individualmente.

    • Exemplo de como a recomposição pode ignorar elementos não alterados ao renderizar uma lista:

      /**
       * Exibe uma lista de nomes que o usuário pode clicar com um cabeçalho
       */
      @Composable
      fun NamePicker(
          header: String,
          names: List<String>,
          onNameClicked: (String) -> Unit
      ) {
          Column {
              // Recompõe quando [header] muda, mas não quando [names] muda
              Text(header, style = MaterialTheme.typography.bodyLarge)
              HorizontalDivider()
      
              // LazyColumn é a versão do Compose para RecyclerView.
              // A lambda passada para items() é semelhante a um RecyclerView.ViewHolder.
              LazyColumn {
                  items(names) { name ->
                      // Recompõe quando o [name] do item atualiza. Não recompõe quando [header] muda
                      NamePickerItem(name, onNameClicked)
                  }
              }
          }
      }
      
      /**
       * Exibe um nome individual que o usuário pode clicar.
       */
      @Composable
      private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
          Text(name, Modifier.clickable(onClick = { onClicked(name) }))
      }
      
  • Escopos de Recomputação

    • Elementos podem ser recompostos individualmente sem necessidade de executar pais ou irmãos.
    • Alteração no header permite pular para a execução da Column, enquanto ignora LazyColumn se names não mudou.
  • Cuidados com Efeitos Colaterais

    • Execução de funções composable não deve ter efeitos colaterais.
    • Efeitos colaterais devem ser acionados a partir de callbacks, garantindo que a recomposição não altere o estado de forma indesejada.

4. Recomposição Otimista

  • Permite cancelamento e reinício da recomposição com novos parâmetros.
  • Necessário garantir funções idempotentes e sem efeitos colaterais para manter a integridade.

5. Recomendações para Desenvolvimento

  • Utilizar funções rápidas e sem efeitos colaterais.
  • Evitar operações caras durante recomposição.
  • Mover trabalhos intensivos para outras threads e utilizar estados mutáveis ou LiveData para gerenciar dados.

Exemplo sobre execução em paralelo

@Composable
fun ListComposable(myList: List<String>) {
    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
            }
        }
        Text("Count: ${myList.size}")
    }
}

Explicação:

  • Layout Estruturado:
    • Row com Arrangement.SpaceBetween: Os elementos ficam distribuídos ao longo do espaço disponível.
    • Column: Lista os itens fornecidos.
  • Contagem de Itens: Mostra a contagem total de itens após a coluna.
@Composable
fun ListWithBug(myList: List<String>) {
    var items = 0

    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Card {
                    Text("Item: $item")
                    items++ // Avoid! Side-effect of the column recomposing.
                }
            }
        }
        Text("Count: $items")
    }
}

Ordem de execução

  • Execução pode ocorrer em qualquer ordem;
  • Por exemplo, no código abaixo:
    • Se StartScreen seta uma variável global (um efeito colateral) e que é utilizada por, digamos, MiddleScreen, não há garantias se a variável estará setada quando MiddleScreen rodar;
@Composable
fun ButtonRow() {
    MyFancyNavigation {
        StartScreen()
        MiddleScreen()
        EndScreen()
    }
}

Tutorial Passo-a-passo

Resumo e tradução do tutorial da documentação oficial.

Conceitos Básicos de Compose

  1. Funções de Composição (Composable Functions)

    • Define a UI do app programaticamente.
    • Use a anotação @Composable para funções de composição.
    • Exemplo: Adicionar um elemento de texto com a função Text.
    import android.os.Bundle
    import androidx.activity.ComponentActivity
    import androidx.activity.compose.setContent
    import androidx.compose.material3.Text
    
    class MainActivity : ComponentActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContent {
                Text("Hello world!")
            }
        }
    }
    
  2. Visualização de Funções de Composição

    • Utilizar a anotação @Preview para visualizar funções no Android Studio.
    • Criação de uma função de pré-visualização para ver a composição sem parâmetros.
    import androidx.compose.runtime.Composable
    import androidx.compose.ui.tooling.preview.Preview
    
    @Composable
    fun MessageCard(name: String) {
        Text(text = "Hello $name!")
    }
    
    @Preview
    @Composable
    fun PreviewMessageCard() {
        MessageCard("Android")
    }
    

Desenvolvimento de Layouts

  1. Hierarquia de Elementos de UI

    • Construir hierarquias de UI com funções de composição.
    • Exemplo: Usar Column para dispor elementos verticalmente.
    import androidx.compose.foundation.layout.Column
    
    data class Message(val author: String, val body: String)
    
    @Composable
    fun MessageCard(msg: Message) {
        Column {
            Text(text = msg.author)
            Text(text = msg.body)
        }
    }
    
    @Preview
    @Composable
    fun PreviewMessageCard() {
        MessageCard(
            msg = Message("Lexi", "Hey, take a look at Jetpack Compose, it's great!")
        )
    }
    
  2. Adicionar Elementos de Texto e Imagem

    • Exemplo de código para adicionar múltiplos textos e imagens.
    • Uso de Row e Image para estruturar elementos.
    import androidx.compose.foundation.Image
    import androidx.compose.foundation.layout.Row
    import androidx.compose.ui.res.painterResource
    
    @Composable
    fun MessageCard(msg: Message) {
        Row {
            Image(
                painter = painterResource(R.drawable.profile_picture),
                contentDescription = "Contact profile picture",
            )
            Column {
                Text(text = msg.author)
                Text(text = msg.body)
            }
        }
    }
    
  • Baixe a imagem de exemplo:
    • Utilize o Resource Manager no Android Studio para adicionar a imagem aos recursos da aplicação.
  1. Configuração do Layout

    • Uso de modificadores para alterar tamanho, layout e aparência.
    • Exemplo de código com modificadores como padding, size e clip.
    import androidx.compose.foundation.border
    import androidx.compose.foundation.layout.Spacer
    import androidx.compose.foundation.layout.height
    import androidx.compose.foundation.layout.padding
    import androidx.compose.foundation.layout.size
    import androidx.compose.foundation.layout.width
    import androidx.compose.foundation.shape.CircleShape
    import androidx.compose.ui.Modifier
    import androidx.compose.ui.draw.clip
    import androidx.compose.ui.unit.dp
    
    @Composable
    fun MessageCard(msg: Message) {
        Row(modifier = Modifier.padding(all = 8.dp)) {
            Image(
                painter = painterResource(R.drawable.profile_picture),
                contentDescription = "Contact profile picture",
                modifier = Modifier
                    .size(40.dp)
                    .clip(CircleShape)
            )
            Spacer(modifier = Modifier.width(8.dp))
            Column {
                Text(text = msg.author)
                Spacer(modifier = Modifier.height(4.dp))
                Text(text = msg.body)
            }
        }
    }
    

Design com Material Design

  1. Uso do Design Material

    • Implementação de Material Design 3.
    • Uso de Surface e tema ComposeTutorialTheme para consistência de estilo.
    class MainActivity : ComponentActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            Aula04JetPackComposeTheme {
             Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
               MessageCard(Message("Android", "Jetpack Compose"))
             }
           }
        }
      }
    }
    
    @Preview
    @Composable
    fun PreviewMessageCard() {
        Aula04JetPackComposeTheme {
            Surface {
                MessageCard(
                    msg = Message("Lexi", "Take a look at Jetpack Compose, it's great!")
                )
            }
        }
    }
    
  2. Aplicando Estilos com MaterialTheme

    • Cores: Uso de MaterialTheme.colorScheme.
    • Tipografia: Uso de MaterialTheme.typography.
    • Formas: Ajustes de bordas e elevação de elementos.
    import androidx.compose.foundation.border
    import androidx.compose.material3.MaterialTheme
    
    @Composable
    fun MessageCard(msg: Message) {
        Row(modifier = Modifier.padding(all = 8.dp)) {
            Image(
                painter = painterResource(R.drawable.profile_picture),
                contentDescription = null,
                modifier = Modifier
                    .size(40.dp)
                    .clip(CircleShape)
                    .border(1.5.dp, MaterialTheme.colorScheme.primary, CircleShape)
            )
    
            Spacer(modifier = Modifier.width(8.dp))
    
            Column {
                Text(
                    text = msg.author,
                    color = MaterialTheme.colorScheme.secondary,
                    style = MaterialTheme.typography.titleSmall
                )
    
                Spacer(modifier = Modifier.height(4.dp))
                Text(
                    text = msg.body,
                    style = MaterialTheme.typography.bodyMedium
                )
            }
        }
    }
    

Trabalhando com Temas

  1. Habilitação do Tema Escuro

    • Suporte nativo a tema escuro com adaptação de cores automáticas.
    • Uso de múltiplas anotações de pré-visualização para ver temas claro e escuro.
    import android.content.res.Configuration
    
    
    
    @Preview(name = "Light Mode")
    @Preview(
        uiMode = Configuration.UI_MODE_NIGHT_YES,
        showBackground = true,
        name = "Dark Mode"
    )
    @Composable
    fun PreviewMessageCard() {
        Aula04JetPackComposeTheme {
            Surface {
                MessageCard(
                    msg = Message("Lexi", "Take a look at Jetpack Compose, it's great!")
                )
            }
        }
    }
    

Listas e Animações

  1. Criação de Listas de Mensagens

    • Uso de LazyColumn e LazyRow para eficiência.
    • Exemplo de código para criar uma função Conversation exibindo múltiplas mensagens.
    import androidx.compose.foundation.lazy.LazyColumn
    import androidx.compose.foundation.lazy.items
    
    @Composable
    fun Conversation(messages: List<Message>) {
        LazyColumn {
            items(messages) { message ->
                MessageCard(message)
            }
        }
    }
    
    @Preview
    @Composable
    fun PreviewConversation() {
        Aula04JetPackComposeTheme {
            Conversation(SampleData.conversationSample)
        }
    }
    

    Adicione as imagens fictícias no arquivo MainActivity.kt

     /**
     * SampleData for Jetpack Compose Tutorial 
     */
     object SampleData {
         // Sample conversation data
         val conversationSample = listOf(
             Message(
                 "Lexi",
                 "Test...Test...Test..."
             ),
             Message(
                 "Lexi",
                 """List of Android versions:
                 |Android KitKat (API 19)
                 |Android Lollipop (API 21)
                 |Android Marshmallow (API 23)
                 |Android Nougat (API 24)
                 |Android Oreo (API 26)
                 |Android Pie (API 28)
                 |Android 10 (API 29)
                 |Android 11 (API 30)
                 |Android 12 (API 31)""".trim()
             ),
             Message(
                 "Lexi",
                 """I think Kotlin is my favorite programming language.
                 |It's so much fun!""".trim()
             ),
             Message(
                 "Lexi",
                 "Searching for alternatives to XML layouts..."
             ),
             Message(
                 "Lexi",
                 """Hey, take a look at Jetpack Compose, it's great!
                 |It's the Android's modern toolkit for building native UI.
                 |It simplifies and accelerates UI development on Android.
                 |Less code, powerful tools, and intuitive Kotlin APIs :)""".trim()
             ),
             Message(
                 "Lexi",
                 "It's available from API 21+ :)"
             ),
             Message(
                 "Lexi",
                 "Writing Kotlin for UI seems so natural, Compose where have you been all my life?"
             ),
             Message(
                 "Lexi",
                 "Android Studio next version's name is Arctic Fox"
             ),
             Message(
                 "Lexi",
                 "Android Studio Arctic Fox tooling for Compose is top notch ^_^"
             ),
             Message(
                 "Lexi",
                 "I didn't know you can now run the emulator directly from Android Studio"
             ),
             Message(
                 "Lexi",
                 "Compose Previews are great to check quickly how a composable layout looks like"
             ),
             Message(
                 "Lexi",
                 "Previews are also interactive after enabling the experimental setting"
             ),
             Message(
                 "Lexi",
                 "Have you tried writing build.gradle with KTS?"
             ),
         )
     }
    
  2. Animação de Mensagens

    • Uso de funções remember e mutableStateOf para gerenciar estado local.
    • Exemplo de código para animar cor e tamanho de mensagens ao expandir.
    import androidx.compose.foundation.clickable
    import androidx.compose.runtime.getValue
    import androidx.compose.runtime.mutableStateOf
    import androidx.compose.runtime.remember
    import androidx.compose.runtime.setValue
    import androidx.compose.animation.animateColorAsState
    import androidx.compose.animation.animateContentSize
    
    @Composable
    fun MessageCard(msg: Message) {
        Row(modifier = Modifier.padding(all = 8.dp)) {
            Image(
                painter = painterResource(R.drawable.profile_picture),
                contentDescription = null,
                modifier = Modifier
                    .size(40.dp)
                    .clip(CircleShape)
                    .border(1.5.dp, MaterialTheme.colorScheme.secondary, CircleShape)
            )
            Spacer(modifier = Modifier.width(8.dp))
    
            var isExpanded by remember { mutableStateOf(false) }
            val surfaceColor by animateColorAsState(
                if (isExpanded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface,
            )
    
            Column(modifier = Modifier.clickable { isExpanded = !isExpanded }) {
                Text(
                    text = msg.author,
                    color = MaterialTheme.colorScheme.secondary,
                    style = MaterialTheme.typography.titleSmall
                )
    
                Spacer(modifier = Modifier.height(4.dp))
    
                Surface(
                    shape = MaterialTheme.shapes.medium,
                    shadowElevation = 1.dp,
                    color = surfaceColor,
                    modifier = Modifier.animateContentSize().padding(1.dp)
                ) {
                    Text(
                        text = msg.body,
                        modifier = Modifier.padding(all = 4.dp),
                        maxLines = if (isExpanded) Int.MAX_VALUE else 1,
                        style = MaterialTheme.typography.bodyMedium
                    )
                }
            }
        }
    }
    

Exercícios para praticar

Exercício 1: Tela simples centralizada

Altere o código do repositório da aula de modo que o componente SimpleScreen fique com a seguinte aparência:

Para esse exercício, os seguintes recursos do Jetpack Compose podem ser interessantes:

import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.ui.Alignment

Tela com texto centralizado

Exercício 2: Criar um Perfil de Usuário

Crie uma tela que exiba informações de um perfil de usuário, como nome, foto e descrição. Utilize Column, Row, Image e Text para estruturar a UI.

Aula 05: Layouts básicos com Jetpack Compose

Fundamentos de Layouts

Componentes Principais

Os componentes básicos de layout no Compose são Row, Column, e Box. Esses componentes permitem arranjar elementos na vertical, horizontal, ou de forma sobreposta.

518dbfad23ee1b05.png

A Column é um componente que alinha seus filhos verticalmente. Cada elemento filho é empilhado abaixo do anterior. É útil para criar layouts estruturados verticalmente, como listas ou seções de informações. Você pode ajustar o espaçamento entre os elementos e o alinhamento dentro da coluna para personalizar a aparência de acordo com as necessidades do seu design.

A Row é semelhante à Column, mas alinha seus filhos horizontalmente. Os elementos são posicionados lado a lado, permitindo a criação de layouts horizontais como fileiras de botões ou ícones. Assim como com Columns, você pode especificar o espaçamento e alinhamento dos elementos para controlar exatamente como eles são apresentados na tela.

O Box é um componente flexível que permite sobrepor elementos um sobre o outro. Ele funciona como um contêiner de layout onde você pode posicionar elementos em camadas. Isso é útil para criar designs complexos ou personalizados onde os elementos se sobrepõem parcialmente ou ficam centralizados dentro de uma área especificada.

@Composable
fun RowColumnExample() {
    Column {
        Text("Column Item 1")
        Text("Column Item 2")
        Row {
            Text("Row Item 1")
            Text("Row Item 2")
        }
    }
}

Modificadores

Modifiers, ou modificadores, são ferramentas no Jetpack Compose que permitem modificar ou estilizar os componentes. Eles podem ser usados para alterar propriedades como tamanho, padding, background, e até funcionalidades de clique. Modifiers são aplicados de maneira encadeada, proporcionando uma maneira flexível e declarativa de personalizar a UI.

import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding

@Composable
fun StyledText() {
    Text(
        text = "Styled with Modifiers",
        modifier = Modifier
            .background(Color.Cyan)
            .padding(16.dp)
            .width(200.dp)
            .height(50.dp)
    )
}

Modificadores fillMaxWidth e padding.

Modificadores podem ter sobrecargas, permitindo, por exemplo, especificar diferentes maneiras de criar padding.

Arrangement e Alignment

O Alignment define como os elementos são alinhados dentro de um contêiner, como Row, Column, ou Box. Ele determina a posição dos elementos no eixo vertical ou horizontal (eixo principal de cada elemento), oferecendo controle preciso sobre como os componentes são posicionados tanto na direção principal quanto na direção cruzada do layout.

O Arrangement controla o espaçamento entre os elementos filhos dentro de Rows ou Columns, utilizando o eixo secundário como referência. Ele permite especificar como os elementos devem ser distribuídos, seja com espaçamento entre eles, tudo no início, centro ou fim do layout. Isso facilita a criação de interfaces esteticamente agradáveis ao controlar como o espaço extra é utilizado.

@Composable
fun AlignedContent() {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text("Left")
        Text("Right")
    }
}

Box

O Box pode ser utilizado para sobrepor itens e criar designs complexos onde os elementos precisam estar um em cima do outro.

@Composable
fun BoxExample() {
    Box(
        modifier = Modifier
            .size(100.dp)
            .background(Color.LightGray)
    ) {
        Text("Bottom", Modifier.align(Alignment.BottomStart))
        Text("Top", Modifier.align(Alignment.TopEnd))
    }
}

Lazy Layouts

Utilize LazyColumn e LazyRow para listas eficientes e pagináveis, que são essenciais para tratar grandes quantidades de dados sem comprometer a performance.

import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items

@Composable
fun SimpleLazyColumn() {
    LazyColumn {
        items(listOf("Apple", "Banana", "Cherry")) { fruit ->
            Text("Fruit: $fruit", Modifier.padding(8.dp))
        }
    }
}

Customização e Temas

Themes

A customização de temas no Jetpack Compose permite adaptar a aparência do aplicativo em nível global. Utilizando o MaterialTheme, você pode definir cores, tipografia, e formas para fornecer uma identidade visual consistente em toda a aplicação. A personalização de temas oferece uma maneira eficiente de garantir uma experiência de usuário coerente e estilizada.

Abra o arquivo ui/theme/Theme.kt, você verá detalhes da implementação do tema da aplicação.

MaterialTheme é uma função composable que reflete os princípios de estilo da especificação do Material Design.

import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface

@Composable
fun ThemedGreeting() {
    MaterialTheme(
        colors = MaterialTheme.colors.copy(
            primary = Color.Magenta,
            primaryVariant = Color.DarkMagenta,
            secondary = Color.Cyan
        )
    ) {
        Surface {
            Text("Hello with Theme")
        }
    }
}

Para o uso de Ícones

Use o composable IconButton, por exemplo:

IconButton(onClick = onClose) {
    Icon(Icons.Filled.Close, contentDescription = "Close")
}

Adicione a seguinte linha às dependências no seu arquivo app/build.gradle.kts para mais ícones:


 implementation("androidx.compose.material:material-icons-extended") 

Práticas Avançadas

Criando Layouts Compostos

Layouts compostos personalizados que melhor se encaixam nas necessidades específicas do design do aplicativo.

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.unit.dp

@Composable
fun CustomButton(content: @Composable () -> Unit) {
    Box(
        modifier = Modifier
            .padding(8.dp)
            .background(Color.Blue)
            .clickable { }
            .padding(16.dp)
    ) {
        content()
    }
}

@Composable
fun CustomButtonExample() {
    CustomButton {
        Text("Click Me!")
    }
}

Interoperabilidade com Views Tradicionais

É possível utilizar Views dentro do Compose e vice-versa, facilitando a migração de aplicativos existentes para o novo sistema.

// No arquivo MainActivity.kt
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            AndroidViewExample()
        }
    }
}

@Composable
fun AndroidViewExample() {
    AndroidView(
        factory = { context ->
            Button(context).apply {
                text = "Old View Button"
                setOnClickListener {
                    // Código do botão ao ser clicado
                }
            }
        }
    )
}

note

  • Diferença entre componentes Surface e Scaffold:
    • São estruturas fundamentais de layout. Algo como containers;
    • Porém, Scaffold torna mais fácil o tratamento de área cobertas pelo Sistema Operacional (como barras de notificação, notch de câmera, botões de navegação, etc.);
    • Isso é ainda mais importante após a decisão do Google de que as aplicações devem usar enableEdgeToEdge().
      • Isso significa que a aplicação é que deve tomar conta dessas áreas cobertas, ocupando a tela de "borda à borda";
    • Com o Surface esse controle se torna bem mais complexo.

Referências


Exemplo Prático -> RGB Counter

https://github.com/tads-ufpr-alexkutzke/ds151-aula-05-counter

Desenvolvimento passo a passo (commits)

Cria Counter Composable

Diffs do commit


Estrutura básica para o contador

Diffs do commit

O modificador weight faz o elemento preencher todo o espaço disponível, afastando os outros elementos que não têm peso, chamados de inflexíveis.


Tentativa frustada de incrementar o contador

Diffs do commit

O motivo pelo qual a mutação dessa variável não aciona recomposições é que ela não está sendo rastreada pelo Compose. Além disso, cada vez que Greeting é chamado, a variável será redefinida para false.

Use o mutableStateOf.

No entanto, você não pode simplesmente atribuir mutableStateOf a uma variável dentro de um composable.

Para preservar o estado entre recomposições, lembre o estado mutável usando remember.


Primeiro contador funcional

Diffs do commit

Se o estado mudar, os composables que leem esses campos serão recompostos para exibir as atualizações.

Em aplicativos Android, há um loop principal de atualização da UI assim:

f415ca9336d83142.png

7d3509d136280b6c.png

Se a função é chamada durante a composição inicial ou em recomposições, dizemos que está presente na Composição.

Você pode inspecionar o layout do app gerado pelo Compose usando a ferramenta Layout Inspector do Android Studio,

Ferramenta Layout Inspector do Android Studio navegando para Tools > Layout Inspector. 1f8e05f6497ec35f.png


Correções de layout para apresentação no emulador

Diffs do commit


Botão de decremento

Diffs do commit

Vamos mudar nossa pré-visualização para emular a largura comum de um celular pequeno, 320dp. Adicione um parâmetro widthDp à anotação @Preview.


Melhora layout

Diffs do commit


Uso do by

Diffs do commit

Aqui usamos uma palavra-chave by ao invés do =. Isso é um delegate de propriedade que poupa você de digitar .value toda vez.


Adiciona parâmetros de mínimo e máximo

Diffs do commit


Adiciona CounterScreen com teste de vários contadores

Diffs do commit


Monta o layout de RGBCounterScreen

Diffs do commit


Inícia o State Hoisting do Counter

Diffs do commit

Em funções Composable, o estado que é lido ou modificado por várias funções deve existir em um ancestral comum — esse processo é chamado de State Hoisting, ou elevação de estado.

Como passamos eventos para cima? Passando callbacks para baixo. Callbacks são funções passadas como argumentos para outras funções e executadas quando o evento ocorre.

O padrão geral para elevação de estado no Jetpack Compose é substituir a variável de estado por dois parâmetros:

  • Valor: T - o valor atual a ser exibido;

  • OnValueChange: (T) -> Unit - um evento que solicita a mudança de valor com um novo valor T;

  • Propriedades importantes:

    • Fonte única de verdade: ao mover o estado em vez de duplicá-lo, estamos garantindo que exista apenas uma fonte única de verdade. Isso ajuda a evitar bugs.
    • Compartilhável: Estado elevado pode ser compartilhado com múltiplos composables.
    • Interceptável: Chamadores dos composables sem estado podem decidir ignorar ou modificar eventos antes de mudar o estado.
    • Desacoplado: O estado para uma função composable sem estado pode ser armazenado em qualquer lugar. Por exemplo, em um ViewModel.

Exemplo de uso do novo Stateless Counter

Diffs do commit

note

No Compose, você não esconde elementos da UI. Em vez disso, você simplesmente não os adiciona à composição, então eles não são incluídos na árvore de UI que o Compose gera. Você faz isso com simples lógica condicional em Kotlin.


Adicionar os limites ao formato Stateless

Diffs do commit


Versão funcional da RGBCounterScreen

Diffs do commit


Adiciona parâmetro step ao contador

Diffs do commit


Adiciona parâmetro text ao contador

Diffs do commit


Ideia simples para diminuir repetição de código

Diffs do commit


Atualiza mainactivity

Diffs do commit


important

Se você executar o aplicativo em um dispositivo, clicar nos botões e então girar, a tela será mostrada novamente. A função remember funciona apenas enquanto o composable for mantido na Composição. Quando você gira, toda a atividade é reiniciada, então todo o estado é perdido. Isso também acontece com qualquer mudança de configuração e com a morte do processo.

Na próxima aula veremos como resolver esse problema.

06 - Gerenciamento de Estado e ViewModels

Código exemplo: Jogo da Velha

https://github.com/tads-ufpr-alexkutzke/ds151-example-tictactoe

Desenvolvimento da aplicação MyTasks

A seguir está o passo a passo do desenvolvimento da aplicação MyTasks

https://github.com/tads-ufpr-alexkutzke/ds151-aula-06-mytasks/

1. Criação do componente TaskItem

O componente TaskItem exibe uma Tarefa.

Ele foi planejado para ser do tipo stateless, ou seja, seu estado será manipulado pelo seu antecessor.

Commit: TaskItem Layout - Diffs 17b16e81

TaskItem.kt

2. Criação do componente TaskList

O componente TaskItem exibe uma lista de tarefas, ou seja, uma lista de TaskItem.

Pontos importantes:

  • Parâmetro key: para que a LazyColumn mantenha um controle mais preciso de quais elementos foram atualizados, é importante informar uma forma de identificar cada elemento unicamente:
    • Aqui usamos um id criado de forma randômica para cada Task: val id: UUID = UUID.randomUUID();
  • Também foi projetada para ser stateless;

Commit: TaskList - Diffs 1b615fd4

TaskList.kt

3. Criação dos componentes principal - MyTasks

O componente MyTasks é responsável por abrigar outros dois componentes:

  • NewTaskControl: caixa de texto e botão para a criação de uma nova tarefa;
  • TaskList: lista de tarefas;

Commit: NewTaskControl and MyTasks - Diffs a39ac305

MyTasks.kt
Componente NewTaskControl

O componente NewTaskControl faz uso de um TextField, mais precisamente, de um OutlinedTextField.

Campos de texto precisam de, pelo menos, dois parâmetros:

  • value: texto atual apresentado na caixa de texto;
  • onValueChange: evento de alteração do texto. É um callback com um argumento, o novo valor do texto;
NewTaskControl.kt

4. Inclusão dos primeiros estados

Nesse commit o componente MyTasks recebe dois estados novos:

var newTaskText by remember { mutableStateOf("") }
var tasks = remember { mutableStateListOf<Task>() }

newTaskText é o texto armazenado no TextField.

tasks é a lista de tarefas atual.

Commit: First states - Diffs bb906d1c

MyTasks.kt

Pontos importantes:

  • Problema com o LazyColumn e estados:

    • Se elemento sai da tela, ele perde o estado:
    • Solução simples: utilizar rememberSaveable ao invés de remember:
      • rememberSaveable consegue sobreviver a pequenas alterações na tela, mas não pode armazenar dados complexos como listas;
  • MutableLists observáveis:

    • Utilizar MutableStateListOf ou toMutableStateList()
TaskList.kt

5. Implementação completa de estados

Para adicionar todo o funcionamento da tela, tornamos o valor checked observável na classe Task.

Assim, o componente TaskList consegue recompor sempre que uma tarefa tem seu estado de checked alterado.

Pontos importantes:

  • Como os eventos onCheckedChange e onRemoveClick são implementados.

Commit: Events and states working - Diffs 07479b85

MyTasks.kt

TaskList.kt

5. Implementação completa de estados

Apenas ordena as tarefas para apresentar as não realizas antes.

Commit: Sort tasks - Diffs 26f4dc53

MyTasks.kt

important

Na forma como está, qualquer alteração na tela, como rotacionar o celular, faz toda a lista de tarefas ser perdida.


6. Utilizando ViewModel para armazenar estados

Commit: Implements MyTasksViewModel - Diffs c3d0f24b

Existem dois tipos de lógica:

  • Logica de UI ou Comportamento: como exibir alterações no estado;
  • Lógica de Negócios ou Domínio: o que fazer quando o estado é alterado;

A Lógica de Domínio é, geralmente, armazenada em outras camadas da aplicação (por exemplo, camada de dados, como veremos a seguir).

ViewModels permitem que composables e outros componentes de UI acessem a lógica de domínio armazenadas em uma camada de dados da aplicação:

  • ViewModels sobrevivem a mais eventos do que composables:
    • Seguem o ciclo de vida de seu antecessor, por exemplo, da Activity;
    • Inclusive a trocas de telas, se assim for necessário;

É uma boa prática manter as Lógicas de UI e de Domínio separadas: mover para uma ViewModel;

UI = UI Elements + UI State

Unidirectional data flow

The UI update loop on unidirectional data flow

Nesse commit, criamos uma nova ViewModel, chamada MyTasksViewModel:

MyTasksViewModel.kt

Para que o projeto compile corretamente, é necessário adicionar uma nova dependência ao arquivo app/build.gradle.kts: j

implementation("androidx.lifecycle:lifecycle-viewmodel-compose:{latest_version}")

// ou (depende da versão do android studio)

implementation(libs.androidx.lifecycle.viewmodel.compose)
build.gradle.kts
Utilizando o ViewModel criado

Podemos acessar o MyTasksViewModel em qualquer composable, chamando viewModel().:

  • viewModel() retorna um ViewModel existente ou cria um novo no escopo atual;

No componente MyTasks adicionamos um parâmetro que chamado myTasksViewModel que recebe como valor default o resultado de viewModel();

MyTasks.kt

myTasksViewModel poderá ser permitirá ao composable acessar e manipular os dados desse ViewModel;

O ViewModel criado será mantido enquanto o seu escopo estiver vivo. Nesse caso, enquanto a Activity que possui MyTasks estiver viva;


07 - Introdução à navegação no Jetpack Compose

Navegação é o processo de mover o usuário entre diferentes telas (ou funcionalidades) do App.

Com Jetpack Compose, isso é feito através de componentes baseados na ideia de UI declarativa. Ou seja, ainda utilizaremos Composables.

A navegação com Jetpack Compose tem alguns pontos interessantes:

  • Facilita a organização, controle e o fluxo entre telas em apps criados com Compose.
  • Reduz acoplamento e gerencia automaticamente a pilha de telas (back stack).

Conceitos Básicos

  • NavHost: Contêiner que exibe as telas (destinos) conforme o fluxo de navegação.
  • NavController: Controlador responsável por gerenciar comandos de navegação (ir para próxima tela, voltar, etc).
  • NavGraph: Também chamado de Composable Destinations, define as trocas de telas possíveis na aplicação.

important

Cada tela navegável é uma função @Composable.

Abordaremos cada um desses conceitos a seguir. Mas antes, é necessário configurar o projeto.

Configurando a Navegação

Basta adicionarmos uma dependência ao projeto para que as funções de navegação estejam disponíveis.

dependencies {
    val nav_version = "2.8.9"

    implementation("androidx.navigation:navigation-compose:$nav_version")
}

Uma aplicação simples com navegação

Link para o repositório com o código completo: https://github.com/tads-ufpr-alexkutzke/ds151-aula-07-movies-app/tree/main

A aplicação proposta é bastante simples e terá apenas duas telas.

Em primeiro lugar, criaremos dois Composables para representar as telas da aplicação.

@Composable
fun MoviesScreen(onGoToMovieDetailsClick: () -> Unit = {}){
    Column(
        modifier = Modifier
            .fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Text(text="Tela de Filmes")
        ElevatedButton(
            onClick = onGoToMovieDetailsClick
        ) {
            Text("Vai para tela de detalhes")
        }
    }
}

@Preview
@Composable
fun MoviesScreenPreview() {
    MoviesAppTheme{
        MoviesScreen()
    }
}

@Composable
fun MovieDetailsScreen(){
    Column(
        modifier = Modifier
            .fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Text(text= "Tela de detalhes")
    }
}

@Preview
@Composable
fun MovieDetailsPreview(){
    MoviesAppTheme{
        MovieDetailsScreen()
    }
}

Os composables MoviesScreen e MovieDetailsScreen não tem nada de diferente dos composables vistos nas últimas aulas.

O componente MoviesApp

Agora criaremos um componente que irá representar a aplicação como um todo e será responsável por abrigar todos os dados referentes à navegação.

A criação desse componente não é obrigatória, mas é um padrão comum de desenvolvimento com o Jetpack Compose.

@Composable
fun MoviesApp(
    navController: NavHostController = rememberNavController()
){
    NavHost(
        navController = navController,
        startDestination = "movies",
    ){
        composable("movies"){
            MoviesScreen()
        }
        composable("movieDetails"){
            MovieDetailsScreen()
        }
    }

}

@Preview
@Composable
fun MoviesAppPreview(){
   MoviesAppTheme{}
       MoviesApp()
   }
}

No código acima, já temos os 3 componentes da navegação:

  • NavHost: define um componente de navegação:
    • navController: variável vem de rememberNavController();
    • startDestination: define a tela inicial para a navegação;
  • composable ou NavGraph: cada composable define uma rota, ou tela, possível para a navegação:
    • Uma rota pode ser entendida como uma url;
    • Existem várias formas de nomear uma rota: a mais simples é apenas com uma string contendo um nome único;
    • A seguir veremos outras formas;

No exemplo, temos duas rotas: "movies" e "movieDetails". Cada uma renderiza uma das telas criadas.

Já podemos atualizar a MainActivity para que utilize nosso novo componente;

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            MoviesAppTheme{}
                MoviesApp()
            }
        }
    }
}

@Composable
fun MoviesApp(
    navController: NavHostController = rememberNavController(),
){
    Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
        NavHost(
            modifier = Modifier.padding(innerPadding),
            navController = navController,
            startDestination = "movies",
        ) {
            composable("movies") {
                MoviesScreen()
            }
            composable("movieDetails") {
                MovieDetailsScreen()
            }
        }
    }
}

No exemplo, já aproveitamos para passar o Scaffold também para dentro do componente MoviesApp.

Para trocarmos de tela, é necessário utilizarmos o método navigate do componente navController.

A tela MoviesScreen já possui um evento para definir o comportamento do botão para trocar de tela.

Portanto, basta atribuir esse evento:

            composable("movies") {
                MoviesScreen(
                    onGoToMovieDetailsClick = {
                        navController.navigate("movieDetails")
                    })
            }

Dessa forma, o botão já realizar a troca de telas.

Um ponto interessante, é que o NavHost já irá cuidar de toda a pilha de telas automaticamente. Por exemplo, ao rodar a aplicação no emulador, o botão de Voltar da interface do smartphone já é capaz de retornar à tela anterior.

Porém, também é possível adicionarmos um botão na tela movieDetails para realizarmos a volta para a tela anterior.

important

É importante que a lógica de navegação não seja compartilhada entre os componentes. Ou seja, os componentes filhos (telas e outros) devem apenas utilizar funções definidas no escopo do NavHost. Por exemplo, não é uma boa prática, passar a variável navController como parâmetro para outros componentes.

Primeiro, adicionamos o botão:

@Composable
fun MovieDetailsScreen(
    onGoBackClick: () -> Unit = {}
){
    Column(
        modifier = Modifier
            .fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        Text(text= "Tela de detalhes")
        ElevatedButton(
            onClick = onGoBackClick
        ) {
            Text("Voltar")
        }
    }
}

Agora, basta atribuir o evento corretamente no componente MoviesApp:

            composable("movieDetails") {
                MovieDetailsScreen(
                    onGoBackClick = {
                        navController.navigate("movies")
                    }
                )
            }

Passando argumentos para telas

Ao trocarmos de tela, por vezes, é interessante que argumentos sejam passados. Por exemplo, ao trocar para a tela de detalhes de um filme, talvez seja interessante informarmos o id do filme a ser mostrado na tela seguinte.

Para realizar a passagem desses argumentos, pode alterar um pouco a rota:

            composable("movies") {
                MoviesScreen(
                    onGoToMovieDetailsClick = {
                        navController.navigate("movieDetails/10")
                    })
            }
            composable("movieDetails/{movieId}") { backStackEntry ->
                val movieId:String = backStackEntry.arguments?.getString("movieId") ?: ""
                MovieDetailsScreen(
                    movieId = movieId,
                    onGoBackClick = {
                        navController.navigate("movies")
                    }
                )
            }

// ...

            @Composable
fun MovieDetailsScreen(
    movieId: String = "",
    onGoBackClick: () -> Unit = {}
){

// ...

        Text(text= "Tela de detalhes")
        Text(text= "MovieId: $movieId")
// ...
}

É preciso prestar um pouco de atenção aos tipos de variáveis. Como um determinado argumento pode ser nulo, a variável que o recebe deve ser nullable, ou uma checagem deve ser realizada antes (operador ?:).

Definindo tipos de argumentos

É possível, ainda, definir mais detalhes sobre os argumentos passados. Para isso, utilizaremos o parâmetro argumentos e o componente navArgument:

            composable(
                route="movieDetails/{movieId}",
                arguments = listOf(
                    navArgument("movieId") {
                        defaultValue = 0
                        type = NavType.IntType
                    }
                )
            ) { backStackEntry ->
                val movieId:Int? = backStackEntry.arguments?.getInt("movieId")

                movieId?.let{
                    MovieDetailsScreen(
                        movieId = it,
                        onGoBackClick = {
                            navController.navigate("movies")
                        }
                    )

                }
            }
}
// ...

@Composable
fun MovieDetailsScreen(
    movieId: Int = -1,
    onGoBackClick: () -> Unit = {}
){

// ...

        Text(text= "Tela de detalhes")
        Text(text= "MovieId: $movieId")

// ...
}

Outras formas de retornar na navegação

Referência: https://medium.com/@KaushalVasava/navigation-in-jetpack-compose-full-guide-beginner-to-advanced-950c1133740

Destinações podem ser agrupadas em um grafo mais complexo, ou aninhado:

Para isso, agrupe rotas relacionadas em um componente navigation:

NavHost(navController, startDestination = "home") {
    ...
    // Navigating to the graph via its route ('login') automatically
    // navigates to the graph's start destination - 'username'
    // therefore encapsulating the graph's internal routing logic
    navigation(startDestination = "username", route = "login") {
        composable("username") { ... }
        composable("password") { ... }
        composable("registration") { ... }
    }
    ...
}

08 - Integração com APIs e Consumo de dados

Nessa aula veremos como realizar requisições HTTP em uma aplicação Android com Compose.

Para isso, utilizaremos algumas bibliotecas auxiliares e trabalharemos com o conceito de Coroutines do Kotlin.

O material abaixo é baseado na criação de um aplicativo que acessa a API https://moviesapi.kutzke.com.br/movies. Essa API simples lista os 40 filmes mais bem avaliados do IMDB.

A versão final da aplicação pode ser acessada no seguinte repositório:
https://github.com/tads-ufpr-alexkutzke/ds151-aula-08-movies-api-app/

A seguir, observaremos as alterações realizadas commit a commit para compreender o funcionamento das requisições HTTP e outros recursos.

1. Commit: first commit - basic app structure - Diffs 290f5aad

Repositório inicial.


2. Commit: api first version - Diffs 1f445d03

O primeiro passo é adicionar as dependências necessárias (Navigation, ViewModel, Retrofit, okHttp):

build.gradle.kts

É necessários também, informar ao Android que nosso aplicativo precisa de permissão para acessar a internet. Para isso, adicione o seguinte ao AndroidManifest.xml:

<uses-permission android:name="android.permission.INTERNET" />

Além disso, caso precise acessar uma API por HTTP, e não HTTPs, adicione o seguinte parâmetro ao bloco application do mesmo arquivo:

<application
    android:usesCleartextTraffic="true"

Vale observar, também, que o Android, por questões de segurança, não permite acesso ao localhost. Portanto, para acessar uma API que está na seu próprio computador, utilize o IP da rede local.

AndroidManifest.xml

Configuração do Retrofit

A biblioteca Retrofit irá realizar todo o trabalho de acesso à API e conversão dos dados. Para isso, é comum colocarmos toda configuração de acesso à API (inicialização do Retrofit) em um arquivo separado. Nesse projeto, criamos o arquivo network/MoviesApiService.kt.

MoviesApiService.kt

Nesse arquivo, definimos a URL base do servidor que acessaremos. No código acima, ela estava definida para meu IP local, porém, podemos atualizá-la para o servidor da API movieisapi:

private const val BASE_URL =
    "https://movieisapi.kutzke.com.br/"

O trecho abaixo, mostra a configuração básica do Retrofit. Nele, passamos a URL base e determinamos um ConverterFactory. É esse objeto que será responsável por converter a resposta em algum tipo de dado desejado. Nesse caso, GsonConverterFactory irá converter o formato JSON entregue pela API em um objeto acessível pelo Kotlin (nesse caso, List<Movie>).

private val retrofit = Retrofit.Builder()
    .addConverterFactory(GsonConverterFactory.create())
    .baseUrl(BASE_URL)
    .build()

Na sequência, se determina os endpoints acessível pela aplicação. Para isso, definimos uma interface e adicionamos uma função suspensa para cada endpoint, com os devidos marcadores de método (@GET) fornecidos pelo Retrofit:

interface MoviesApiService {
    @GET("/movies")
    suspend fun getMovies(): List<Movie>
}

Por fim, define-se um object que será utilizado pelo resto do App para acessar a API. Esse object contem apenas uma variável que é inicializada por retrofit.create.

Aqui, o uso de object e lazy tem uma razão de desempenho. Object irá garantir que apenas um objeto da classe MoviesApi será criado em toda aplicação. e o lazy determina que retrofitService só será inicializado quando for requisitado pela primeira vez. A inicialização do retrofit é uma operação cara, por isso deve ser feita apenas uma vez.

object MoviesApi {
    val retrofitService: MoviesApiService by lazy {
        retrofit.create(MoviesApiService::class.java)
    }
}

Utilizando MoviesApi no ViewModel

Embora MoviesApi estará disponível em qualquer lugar da aplicação, geralmente reservamos seu uso para dentro de um ViewModel. Desse modo, para outros componentes da aplicação, o acesso aos dados fica transparente.

MoviesAppViewModel.kt

O código acima tem alguns pontos interessantes.

Primeiro, a definição do estado, que nesse caso, será uma lista de filmes:

    private var _movies = mutableStateListOf<Movie>()
    val movies: List<Movie>
        get() = _movies

Na sequência, o construtor do ViewModel faz uso de um viewModelScope para realizar as chamadas assíncronas à API:

    init {
        if(fake) _movies.addAll(fourMovies)
        else{
            viewModelScope.launch {
                val movies = MoviesApi.retrofitService.getMovies()
                _movies.addAll(movies)
            }
        }
    }

É importante salientar que, no exemplo acima, não fazemos nenhum tratamento de erro.

O viewModelScope.launch utilizado no construtor é algo novo. Ele determina o início de uma nova Coroutine, permitindo a realização de operações assíncronas e paralelas forma simples e segura (do ponto de vista de programação).

Sobre Coroutines

Coroutines (ou corrotinas) em Kotlin são uma utilizados para a programação de código assíncrono e concorrente de forma mais simples e eficiente. Elas permitem que realizar operações potencialmente demoradas — como acessar a internet ou ler arquivos — sem bloquear a thread principal (geralmente a de interface do usuário), tornando aplicativos mais responsivos.

Uma coroutine é, basicamente, uma sequência de instruções que pode ser suspensa e retomada posteriormente, permitindo que outras tarefas sejam executadas enquanto ela aguarda alguma operação, como uma resposta de rede ou leitura de dados. Isso é feito de simples e estruturada, sem a complexidade tradicional de manipular múltiplas threads diretamente.

Kotlin oferece bibliotecas robustas para o uso de coroutines, com funções especiais como suspend, launch, async e delay que tornam o gerenciamento de tarefas assíncronas muito mais legível. Ao invés de callbacks encadeados ou código difícil de manter, o uso de coroutines proporciona uma sintaxe sequencial que é mais fácil de entender e depurar. Além disso, as coroutines incorporam o conceito de concorrência estruturada, promovendo o controle do ciclo de vida das tarefas assíncronas para evitar vazamentos de memória e garantir que todas as operações iniciadas sejam corretamente finalizadas ou canceladas quando necessário.

Considere o código a seguir (exemplos retirados de https://developer.android.com/codelabs/basic-android-kotlin-compose-coroutines-kotlin-playground):

import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        delay(1000)
        println("Sunny")
    }
}
  • runBlocking() executa um loop de eventos, que pode lidar com várias tarefas ao mesmo tempo, continuando cada tarefa de onde parou quando ela está pronta para ser retomada.

  • delay() é na verdade uma função especial de suspensão fornecida pela biblioteca de coroutines do Kotlin.

import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        printForecast()
    }
}

suspend fun printForecast() {
    delay(1000)
    println("Sunny")
}
  • Uma função suspensa é como uma função normal, mas pode ser suspensa e retomada novamente mais tarde.

  • Uma função suspensa só pode ser chamada a partir de uma coroutine ou de outra função suspensa.

  • Um ponto de suspensão é o local dentro da função onde a execução pode ser suspensa.

  • O "co-" em coroutine significa cooperativo. O código coopera para compartilhar o loop de eventos subjacente ao suspender a execução enquanto espera por algo, o que permite que outros trabalhos sejam executados nesse meio tempo.

import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        launch {
            printForecast()
        }
        launch {
            printTemperature()
        }
    }
}

suspend fun printForecast() {
    delay(1000)
    println("Sunny")
}

suspend fun printTemperature() {
    delay(1000)
    println("30\u00b0C")
}
  • A função launch() da biblioteca de coroutines inicia uma nova coroutine.
  • Coroutines em Kotlin seguem um conceito chave chamado concorrência estruturada, onde o seu código é sequencial por padrão e coopera com um loop de eventos subjacente, a menos que você peça explicitamente para executar concorrentemente (por exemplo, usando launch()).
import kotlinx.coroutines.*

fun main() {
    runBlocking {
        println("Weather forecast")
        val forecast: Deferred<String> = async {
            getForecast()
        }
        val temperature: Deferred<String> = async {
            getTemperature()
        }
        println("${forecast.await()} ${temperature.await()}")
        println("Have a good day!")
    }
}

suspend fun getForecast(): String {
    delay(1000)
    return "Sunny"
}

suspend fun getTemperature(): String {
    delay(1000)
    return "30\u00b0C"
}
  • A função async() é utilizada quando é importante determinarmos o momento em que a coroutine termina e precisa de um valor de retorno dela.
  • A função async() retorna um objeto do tipo Deferred, que funciona como uma promessa de que o resultado estará disponível quando estiver pronto. Você pode acessar o resultado no objeto Deferred usando await().
suspend fun getWeatherReport() = coroutineScope {
    val forecast = async { getForecast() }
    val temperature = async { getTemperature() }
    "${forecast.await()} ${temperature.await()}"
}
  • coroutineScope{} cria um escopo local.

  • As coroutines iniciadas dentro deste escopo são agrupadas juntas dentro deste escopo, o que tem implicações para cancelamento e exceções.

  • coroutineScope() só retornará quando todo o seu trabalho, incluindo quaisquer coroutines iniciadas, for concluído.

  • Com coroutineScope(), mesmo que a função execute internamente trabalhos de forma concorrente, para quem chama parece uma operação síncrona, porque coroutineScope não retorna até todo o trabalho estar completo.

  • launch() e async() são funções de extensão em CoroutineScope. Chame launch() ou async() no escopo para criar uma nova coroutine dentro desse escopo.

  • Um CoroutineScope está atrelado a um ciclo de vida, que define quanto tempo as coroutines dentro desse escopo viverão. Se um escopo for cancelado, seu job também é cancelado, propagando o cancelamento para seus filhos. Se um job filho falha com exceção, os outros jobs filhos são cancelados, o job pai é cancelado e a exceção é relançada para o chamador.

  • O Android fornece suporte a escopo de coroutines em entidades que têm um ciclo de vida bem definido, como Activity (lifecycleScope) e ViewModel (viewModelScope).

  • Coroutines iniciadas dentro desses escopos obedecerão ao ciclo de vida da entidade correspondente, como Activity ou ViewModel.

important

Aplicações Android geralmente possuem uma "Main thread" que é responsável pela atualização e renderização da tela. Sem o uso de coroutines, como viewModelScope, a tela ficaria "travada" até que processamentos assíncronos ou mais longos terminassem.


3. Commit: MovieDetails with api request - Diffs a0830030

Nesse commit, atualizamos a tela MovieDetailsScreen para acessar os dados de um filme por meio de um novo endpoint: /movies/{movieId}.

Novo endpoint com parâmetro

Adicionamos as informações para o novo endpoint, com o detalhe de que ele espera um movieId como parâmetro:

    @GET("/movies/{id}")
    suspend fun getMovie(@Path("id") id:Int): Movie
MoviesApiService.kt

Atualização do MoviesAppViewModel

Devemos, também, atualizar o MoviesAppViewModel para que ele acesse esse novo endpoint.

Dois detalhes importantes estão nessa atualização.

Em primeiro lugar, definimos um novo estado movieDetails, do tipo StateFlow<Movie?>. StateFlow são uma forma comum de armazenar estados mais complexos como classes. Flow é um conceito do kotlin e tem relação com o paradigma Produtor-Coletor. Mas, para nós, no momento, será apenas uma forma conveniente de armazenar estados.

private var _movieDetails = MutableStateFlow<Movie?>(null)
val movieDetails: StateFlow<Movie?> = _movieDetails

Em segundo lugar, alteramos a função getMovie, para que ela utilize o novo endpoint. Aqui adicionamos uma chamada para delay apenas para que fique claro que um processamento assíncrono está ocorrendo.

Vale notar a necessidade de utilizar .value para acessar o valor do estado quando trabalhamos com StateFlow.

fun getMovie(movieId:Int) {
    viewModelScope.launch {
        delay(2000)
        val movie = MoviesApi.retrofitService.getMovie(movieId)
        _movieDetails.value = movie
    }
}

Uma pequena alteração foi feita para permitir o uso de Previews, uma vez que o acesso a API só ocorre na execução pelo emulador e dispositivo físico:

init {
    if(fake) _movies.addAll(fourMovies)
    else{
MoviesAppViewModel.kt

Uso do novo endpoint

Para recuperar o estado do moviesAppViewModel, utilizamos o método collectAsState, uma vez que estamos utilizando um StateFlow.

val movie = moviesAppViewModel.movieDetails.collectAsState()

Agora, utilizamos um LaunchedEffect para invocar a função getMovie do moviesAppViewModel.

Um LaunchedEffect é um recurso utilizado em Composables para lidar com efeitos colaterais. Se for necessário executar algum código que não segue a mesma linha de execução do composable, devemos separá-lo. Por exemplo, um código assíncrono ou que leva muito tempo, são efeitos colaterais.

Nesse caso, o bloco LaunchedEffect será executado de forma segura nos momentos necessários. Esse bloco será reexecutado sempre que um dos seus parâmetros tiver seu valor alterado (no caso, movieId). Ou seja, ele não é executado sempre que uma recomposição ocorre.

LaunchedEffect(movieId) {
    moviesAppViewModel.getMovie(movieId)
}

Basta, agora, renderizar o composable MovieDetailsScreen quando o estado movie não for nulo:

if(movie.value == null) Text("Carregando ...")
else{
    movie.value?.let {
        MovieDetailsScreen(
            movie = it,
            onGoBackClick = {
                navController.navigate("movies")
            }
        )
    }
}
MoviesApp.kt

4. Commit MovieDetailsScreen handles api - Diffs 42a1de57

Um detalhe importante da última atualização é o fato de que o componente MoviesApp está cuidando da chamada para a API. Isso gera o inconveniente de utilizarmos LaunchedEffect dentro de um composable do NavHost. Esses componentes de navegação devem ser simples e tratar apenas de lógicas de navegação.

Nesse caso, portanto, podemos passar o tratamento de chamadas API para o componente que necessita dessas informações, MovieDetailsScreen.

note

Para essa alteração, o componente MovieDetailsScreen precisará acessar moviesAppViewModel. A decisão de passar viewModels para componentes deve ser considerada com cuidado. Ela pode complexificar o código e tornar a testagem mais difícil. Porém, nesse caso, moviesAppViewModel será acessado apenas pelo componente MovieDetailsScreen e não por seus componentes internos.

Simplificando MoviesApp

No componente de navegação MoviesApp as alterações são simples. Basta passar toda a lógica de API para o componente MovieDetailsScreen que receberá, agora o movieId, apenas.

val movieId:Int? = backStackEntry.arguments?.getInt("movieId")

if(movieId == null) Text("Carregando ...")
else{
    movieId.let {
        MovieDetailsScreen(
            movieId = it,
            onGoBackClick = {
                navController.popBackStack()
            }
        )
    }
MoviesApp.kt

Acessando API no componente MovieDetailsScreen

Algumas alterações são necessárias para que a tela MovieDetailsScreen possa lidar com requisições de API.

Inicialmente, ela precisa ter acesso ao viewModel, uma vez que todas as chamadas para API são controladas por moviesAppViewModel. Portanto, adicionamos um parâmetro para a tela que recebe, por padrão o valor de viewModel().

@Composable
fun MovieDetailsScreen(
    movieId: Int,
    moviesAppViewModel: MoviesAppViewModel = viewModel(),
    onGoBackClick: () -> Unit = {},
){

O restante do composable é, basicamente, o que estava em MoviesApp anteriormente:

@Composable
fun MovieDetailsScreen(

    // ...
    val movie = moviesAppViewModel.movieDetails.collectAsState()

    LaunchedEffect(movieId) {
        moviesAppViewModel.getMovie(movieId)
    }

    if(movie.value == null) Text("Carregando ...")
    movie.value?.let{ movie ->
        MovieDetailsScreen(movie = movie)
    }
}

Um outro detalhe interessante é a criação de um outro componente com o mesmo nome, mas com uma assinatura de parâmetros diferente:

@Composable
fun MovieDetailsScreen(
    movie: Movie = fourMovies[0],
    onGoBackClick: () -> Unit = {},
){
    MovieItem(movie = movie)
}

Isso permite a criação mais simples de um Preview, uma vez que, como sabemos, não podem realizar requisições HTTP ou operações assíncronas.

@Preview
@Composable
fun MovieDetailsScreenPreview(){
    MoviesAppTheme {
       MovieDetailsScreen(
           movie = fourMovies[0]
       )
    }
}

Assim, criamos um preview para a instância de MovieDetailsScreen que não necessita da lógica de API.

MovieDetailsScreen.kt

5. Commit: Exception handling and UiState - Diffs 68567bdd

Para finalizar o desenvolvimento dessa aplicação inicial, vamos adicionar tratamento de exceções para que problemas de acesso à API não causem respostas inesperadas ao usuário.

Adicionando mais estados para tratamento de exceções

Quando realizamos requisições a uma API, podemos interpretar que a interface pode ter três estados principais (cada um com seus dados específicos): sucesso, erro e carregando.

Portanto, podemos alterar os estados armazenados em MoviesAppViewModel para que atendam essa nova organização.

Existem muitas formas de fazer isso. Uma delas é definindo uma sealed interface:

sealed interface MoviesScreenUiState {
    class Success(val movies: List<Movie>): MoviesScreenUiState
    object Error: MoviesScreenUiState
    object Loading: MoviesScreenUiState
}

sealed interface MovieDetailsScreenUiState {
    class Success(val movie: Movie ) : MovieDetailsScreenUiState
    object Error : MovieDetailsScreenUiState
    object Loading : MovieDetailsScreenUiState
}

Aqui, embora estejamos no mesmo viewModel, definimos uma interface para o estado de cada tela. Isso não é sempre necessário, mas, no caso da aplicação aqui apresentada, é uma solução mais simples.

Como os itens Error e Loading não possuem dados, eles podem ser declarados como object e não class.

Agora, o MoviesAppViewModel pode utilizar essas interfaces para gerar novos estados:

class MoviesAppViewModel(val fake: Boolean = false): ViewModel() {

    var moviesScreenUiState: MoviesScreenUiState by mutableStateOf(MoviesScreenUiState.Loading)
    var movieDetailsScreenUiState: MovieDetailsScreenUiState by mutableStateOf(MovieDetailsScreenUiState.Loading)

Inicializamos os estamos para cada tela, por padrão, como .Loading.

A lógica de carregamento da lista de filmes pode adicionar um bloco try catch para o tratamento de qualquer erro no acesso à API:

    init {
        getMovies()
    }

    private fun getMovies(){
        viewModelScope.launch {
            moviesScreenUiState = try {
               val movies = MoviesApi.retrofitService.getMovies()
                MoviesScreenUiState.Success(movies = movies)
            }
            catch(e: IOException){
                MoviesScreenUiState.Error
            }
        }
    }

Perceba que, dependendo do caso, atribuímos o valor diferente para moviesScreenUiState. É como se os valores Success, Error e Loading fossem passados como informação adicional ao objeto do estado.

A função getMovie tem um comportamento muito parecido:


    fun getMovie(movieId:Int) {
        movieDetailsScreenUiState = MovieDetailsScreenUiState.Loading
        viewModelScope.launch {
            movieDetailsScreenUiState = try{
                delay(2000)
                val movie = MoviesApi.retrofitService.getMovie(movieId)
                MovieDetailsScreenUiState.Success(movie = movie)
            }
            catch(e: IOException) {
                MovieDetailsScreenUiState.Error
            }
        }
    }

Um detalhe especial é que, a cada chamada de getMovie reinicializamos o estado da tela com .Loading para que um novo carregamento seja representado ao usuário.

MoviesAppViewModel.kt

Utilizando o novo estado nas telas

Os componentes de tela, MoviesScreen e MovieDetailsScreen precisam, apenas, utilizar esses novos estados.

Para simplificar, optamos a passagem desses estados como parâmetro para cada tela.

O desenho da tela pode ser realizado com um bloco when, para cada tipo possível de valor de MoviesScreenUiState:


fun MoviesScreen(
    moviesScreenUiState: MoviesScreenUiState,
    onGoToMovieDetailsClick: (movieId:Int) -> Unit = {},
){
    when(moviesScreenUiState){
        is MoviesScreenUiState.Success -> {
            MoviesList(
                movies = moviesScreenUiState.movies,
                onMovieClick = onGoToMovieDetailsClick
            )
        }
        is MoviesScreenUiState.Loading -> LoadingScreen(modifier = Modifier.fillMaxSize())
        is MoviesScreenUiState.Error -> ErrorScreen( modifier = Modifier.fillMaxSize())
    }
}

Para a tela MovieDetailsScreen as alterações são bastante semelhantes:

@Composable
fun MovieDetailsScreen(
    movieId: Int,
    moviesAppViewModel: MoviesAppViewModel = viewModel(),
    movieDetailsScreenUiState: MovieDetailsScreenUiState,
    onGoBackClick: () -> Unit = {},
){
    when(movieDetailsScreenUiState){
        is MovieDetailsScreenUiState.Success -> {
            MovieDetailsScreen(movie = movieDetailsScreenUiState.movie)
        }
        is MovieDetailsScreenUiState.Loading -> LoadingScreen(modifier = Modifier.fillMaxSize())
        is MovieDetailsScreenUiState.Error -> ErrorScreen( modifier = Modifier.fillMaxSize())
    }

    LaunchedEffect(movieId) {
        moviesAppViewModel.getMovie(movieId)
    }
}

Aqui, ainda precisamos do acesso ao moviesAppViewModel pois é necessário realizar a chamada à getMovie.

MoviesScreen.kt
MovieDetailsScreen.kt

Ajustes em MoviesApp

Por fim, alguns pequenos ajustes são necessários no componente de navegação MoviesApp.

composable("movies") {
    MoviesScreen(
        moviesScreenUiState = moviesAppViewModel.moviesScreenUiState,
        onGoToMovieDetailsClick = { movieId ->
            navController.navigate("movieDetails/$movieId")
        }
    )
}

Adicionamos o parâmetro moviesScreenUiState e passamos o valor atual presente em moviesAppViewModel.

Para MovieDetailsScreen, as alterações são semelhantes:

composable(
    route="movieDetails/{movieId}",
    arguments = listOf(
        navArgument ("movieId") {
            defaultValue = 0
            type = NavType.IntType
        }
    )
) { backStackEntry ->
    val movieId:Int? = backStackEntry.arguments?.getInt("movieId")

    movieId?.let {
        MovieDetailsScreen(
            movieId = it,
            moviesAppViewModel = moviesAppViewModel,
            movieDetailsScreenUiState = moviesAppViewModel.movieDetailsScreenUiState,
            onGoBackClick = {
                navController.popBackStack()
            }
        )
    }
}
MoviesApp.kt

Atividade prática

Altere a tela MovieDetailsScreen para que ela carregue e apresente reviews do filme escolhido.

A api possui um endpoint com reviews dos filmes http://moviesapi.kutzke.com.br/reviews.

O servidor da API utiliza Json-server. Consulte a documentação oficial para saber quais opções estão disponível (filtragem, paginação, etc).

09 - Persistência de dados local com Room

Room

O Room é uma biblioteca de persistência de dados do Android que facilita o armazenamento e o gerenciamento de dados locais em bancos de dados SQLite.

Ele fornece uma camada de abstração sobre o SQLite, permitindo que os desenvolvedores definam entidades, consultem e manipulem dados utilizando objetos e métodos Java/Kotlin de forma simples, segura e eficiente.

Com o Room, é possível manter o código mais organizado, evitar erros comuns de SQL e aproveitar recursos como verificação de consultas em tempo de compilação e integração direta com o ciclo de vida do aplicativo.

Componentes Principais do Room

O Room possui três componentes principais:

  • Classe de banco de dados: Gerencia o banco de dados e é o principal ponto de acesso para os dados persistidos do aplicativo.
  • Entidades de dados: Representam as tabelas do banco de dados do app.
  • DAOs (Data Access Objects): Fornecem métodos para consultar, atualizar, inserir e deletar dados no banco.

A classe de banco de dados fornece instâncias dos DAOs, que permitem ao app acessar e manipular os dados como objetos correspondentes às entidades definidas. Dessa forma, o app pode recuperar, inserir ou atualizar informações nas tabelas do banco com facilidade.

Arquitetura Room

Exemplo simples

Entidades

@Entity
data class User(
    @PrimaryKey val uid: Int,
    @ColumnInfo(name = "first_name") val firstName: String?,
    @ColumnInfo(name = "last_name") val lastName: String?
)

DAO

@Dao
interface UserDao {
    @Query("SELECT * FROM user")
    fun getAll(): List<User>

    @Query("SELECT * FROM user WHERE uid IN (:userIds)")
    fun loadAllByIds(userIds: IntArray): List<User>

    @Query("SELECT * FROM user WHERE first_name LIKE :first AND " +
           "last_name LIKE :last LIMIT 1")
    fun findByName(first: String, last: String): User

    @Insert
    fun insertAll(vararg users: User)

    @Delete
    fun delete(user: User)
}

Database

@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

Uso básico

Primeiro precisamos gerar o objeto da Database:

val db = Room.databaseBuilder(
            applicationContext,
            AppDatabase::class.java, "database-name"
        ).build()

Na sequência, utilizamos a instâncias db para acessar os dados através das DAOs disponíveis:

val userDao = db.userDao()
val users: List<User> = userDao.getAll()

Aplicação exemplo: MoviesApp

A versão final da aplicação pode ser acessada no seguinte repositório:
https://github.com/tads-ufpr-alexkutzke/ds151-aula-09-movies-api-room-app

A aplicação exemplo segue o desenvolvimento da última aula, com a adição de armazenamento local com Room. O objetivo da aplicação é que o usuário possa salvar localmente os dados dos filmes marcados como favoritos. Esses dados devem ser acessíveis mesmo sem acesso à internet.

Funcionamento do app

A organização dos arquivos nessa aplicação foi melhorada. Agora temos arquivos separados em pastas que indicam suas funções. A estrutura é a seguinte:

app/src/main/java/com/example/moviesapp/
├── AppContextHolder.kt
├── Application.kt
├── data
│   ├── local
│   │   ├── CastConverter.kt
│   │   ├── FavoriteMovieDao.kt
│   │   ├── FavoriteMovieEntity.kt
│   │   └── FavoriteMoviesDatabase.kt
│   ├── LocalFavoriteMoviesRepository.kt
│   ├── LocalFavoriteMoviesRepositoryProvider.kt
│   ├── RemoteMoviesRepository.kt
│   └── RemoteMoviesRepositoryProvider.kt
├── MainActivity.kt
├── model
│   ├── Movie.kt
│   └── Review.kt
├── network
│   └── MoviesApiService.kt
├── ui
│   ├── moviesapp
│   │   ├── ApiComposables.kt
│   │   ├── MovieDetailsScreen.kt
│   │   ├── MovieDetailsViewModel.kt
│   │   ├── MovieItem.kt
│   │   ├── MoviesList.kt
│   │   ├── MoviesListViewModel.kt
│   │   └── MoviesScreen.kt
│   ├── MoviesApp.kt
│   └── theme
│       ├── Color.kt
│       ├── Theme.kt
│       └── Type.kt
└── utils
    └── MapperExtensions.kt

O conteúdo de cada pasta é o seguinte:

  • data/: arquivos relativos a repositórios de dados, sejam locais ou remotos. Aqui armazenamos os itens conhecidos como Repositórios;
    • data/local/: arquivos relativos ao armazenamento local de dados (Database, DAO e Entity);
  • model: arquivos que definem os modelos dos dados utilizados. Em geral, determinam os tipos de dados relevantes para a aplicação;
  • network/: arquivos relacionados à requisições para APIs. No caso, arquivos de definição do Retrofit;
  • ui/ arquivos da interface (Composables e ViewModels);
  • utils/: arquivos auxiliares;

important

Nesse projeto, utilizamos uma paradigma de repositórios para abstrair todo o acesso aos dados da aplicação. Por essa razão, uma certa quantidade de código boilerplate precisou ser adicionada (vide os arquivos de providers). Esse efeito pode ser sensivelmente diminuído através do uso de Injeção de Dependências (DI - Dependencies Injection). No desenvolvimento Android, isso geralmente é realizado com o auxílio da biblioteca Hilt.

Dependências

Algumas dependências são necessárias para o uso do Room (app/build.gradle.kts):

plugins {
    // ...
    id("com.google.devtools.ksp")
}

dependencies {

    // ...

    val room_version = "2.7.1"

    implementation("androidx.room:room-runtime:$room_version")

    ksp("androidx.room:room-compiler:$room_version")

    // ...

}

Atenção ao plugin ksp adicionado. Além disso, esse plugin deve ser adicionado, também, ao arquivo build.gradle.kts do projeto:

// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
    alias(libs.plugins.android.application) apply false
    alias(libs.plugins.kotlin.android) apply false
    alias(libs.plugins.kotlin.compose) apply false
    id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false
}

Model

Antes de abordarmos a implementação do acesso aos dados locais, devemos conhecer quais são os tipos de dados utilizados na lógica da aplicação

Movie.kt

Define a classe Movie:

class Movie(
    val id: Int,
    val title: String,
    val cast: List<String>,
    val director: String,
    val synopsis: String,
    val posterUrl: String,
) {
}
Movie.kt
package com.example.moviesapp.model

class Movie(
    val id: Int,
    val title: String,
    val cast: List<String>,
    val director: String,
    val synopsis: String,
    val posterUrl: String,
) {
}

val fourMovies: List<Movie> = listOf(
    Movie(
        id = 1,
        title = "Um Sonho de Liberdade",
        cast = listOf("Tim Robbins", "Morgan Freeman", "William Sadler"),
        director = "Frank Darabont",
        synopsis = "Acusado injustamente de assassinato, Andy Dufresne encontra esperança e redenção na prisão de Shawshank.",
        posterUrl = "https://image.tmdb.org/t/p/w500/q6y0Go1tsGEsmtFryDOJo3dEmqu.jpg",
    ),
    Movie(
        id = 2,
        title = "O Poderoso Chefão",
        cast = listOf("Marlon Brando", "Al Pacino", "James Caan"),
        director = "Francis Ford Coppola",
        synopsis = "A trajetória da família mafiosa Corleone e os desafios enfrentados no submundo do crime.",
        posterUrl = "https://image.tmdb.org/t/p/w500/d4KNaTrltq6bpkFS01pYtyXa09m.jpg",
    ),
    Movie(
        id = 3,
        title = "O Poderoso Chefão II",
        cast = listOf("Al Pacino", "Robert De Niro", "Robert Duvall"),
        director = "Francis Ford Coppola",
        synopsis = "Expande a saga dos Corleone, explorando passado e presente da família mafiosa.",
        posterUrl = "https://image.tmdb.org/t/p/w500/amvmeQWheahG3StKwIE1f7jRnkZ.jpg",
    ),
    Movie(
        id = 4,
        title = "Batman: O Cavaleiro das Trevas",
        cast = listOf("Christian Bale", "Heath Ledger", "Aaron Eckhart"),
        director = "Christopher Nolan",
        synopsis = "O Coringa ameaça destruir Gotham, e Batman precisa lidar com caos e sacrifícios pessoais.",
        posterUrl = "https://image.tmdb.org/t/p/w500/qJ2tW6WMUDux911r6m7haRef0WH.jpg",
    ),
)

Review.kt

Define a classe Review:

class Review(
    val id: Int,
    val movieId: Int,
    val author: String,
    val reviewText: String,
    val rating: Int,
) {
}
Review.kt
package com.example.moviesapp.model

class Review(
    val id: Int,
    val movieId: Int,
    val author: String,
    val reviewText: String,
    val rating: Int,
) {
}

val fourReviews: List<Review> = listOf(
    Review(
        id = 1,
        movieId = 1,
        author = "João Silva",
        reviewText = "Uma história inspiradora sobre esperança, amizade e redenção. Atuações impecáveis de Tim Robbins e Morgan Freeman.",
        rating = 10
    ),
    Review(
        id = 2,
        movieId = 1,
        author = "Ana Souza",
        reviewText = "O filme emociona do começo ao fim, com uma narrativa envolvente e um final perfeito.",
        rating = 9
    ),
        Review(
        id = 3,
        movieId = 1,
        author = "Carlos Mendes",
        reviewText = "Roteiro cativante e personagens profundos. Um clássico absoluto do cinema.",
        rating = 10
    ),
)

Data

Analisaremos os arquivos presentes na pasta data/ do projeto. São, portanto, responsáveis por toda definição, acesso e abstração dos dados utilizados na aplicação.

Data/Local

Os arquivos presentes em data/local tem a função de definir e implementar o acesso aos dados locais, por meio do uso do Room.

FavoriteMovieEntity.kt

Este arquivo define a Entity que representa um filme favorito na base de dados local:

@Entity(tableName = "favorite_movies")
@TypeConverters(CastConverter::class)
data class FavoriteMovieEntity(
    @PrimaryKey val id: Int,
    val title: String,
    val cast: List<String>,
    val director: String,
    val synopsis: String,
    val posterUrl: String
)
FavoriteMovieEntity.kt
package com.example.moviesapp.data.local

import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters

@Entity(tableName = "favorite_movies")
@TypeConverters(CastConverter::class)
data class FavoriteMovieEntity(
    @PrimaryKey val id: Int,
    val title: String,
    val cast: List<String>,
    val director: String,
    val synopsis: String,
    val posterUrl: String
)
FavoriteMovieDao.kt

Agora temos a definição do DAO, responsável pelo acesso às entidades FavoriteMovieEntity na base de dados local:

@Dao
interface FavoriteMovieDao {
    @Query("SELECT * FROM favorite_movies")
    suspend fun getAll(): List<FavoriteMovieEntity>

    @Query("SELECT * FROM favorite_movies WHERE id = :movieId")
    suspend fun getById(movieId: Int): FavoriteMovieEntity?

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(movie: FavoriteMovieEntity)

    @Delete
    suspend fun delete(movie: FavoriteMovieEntity)
}
FavoriteMovieDao.kt
package com.example.moviesapp.data.local

import androidx.room.*

@Dao
interface FavoriteMovieDao {
    @Query("SELECT * FROM favorite_movies")
    suspend fun getAll(): List<FavoriteMovieEntity>

    @Query("SELECT * FROM favorite_movies WHERE id = :movieId")
    suspend fun getById(movieId: Int): FavoriteMovieEntity?

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(movie: FavoriteMovieEntity)

    @Delete
    suspend fun delete(movie: FavoriteMovieEntity)
}

FavoriteMoviesDatabase

Por fim, temos a definição da própria base de dados local, com FavoriteMoviesDatabase:

@Database(entities = [FavoriteMovieEntity::class], version = 1)
@TypeConverters(CastConverter::class)
abstract class FavoriteMoviesDatabase : RoomDatabase() {
    abstract fun favoriteMovieDao(): FavoriteMovieDao

    companion object {
        @Volatile private var INSTANCE: FavoriteMoviesDatabase? = null

        fun getInstance(context: Context): FavoriteMoviesDatabase {
            return INSTANCE ?: synchronized(this) {
                INSTANCE ?: Room.databaseBuilder(
                    context.applicationContext,
                    FavoriteMoviesDatabase::class.java,
                    "favorites.db"
                ).build().also { INSTANCE = it }
            }
        }
    }
}

Aqui vale notar como a estrutura de definição de um Database está mais complexa do que no exemplo inicial deste material. Isso se deve ao fato de que o código acima garante que apenas um objeto de FavoriteMoviesDatabase seja instanciado para toda a aplicação, não importando quantas vezes getInstance seja chamado.

Perceba, porém, que o código central ainda é uma simples chamada para Room.databaseBuilder:

                INSTANCE ?: Room.databaseBuilder(
                    context.applicationContext,
                    FavoriteMoviesDatabase::class.java,
                    "favorites.db"
                ).build().also { INSTANCE = it }
FavoriteMoviesDatabase.kt
package com.example.moviesapp.data.local

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters

@Database(entities = [FavoriteMovieEntity::class], version = 1)
@TypeConverters(CastConverter::class)
abstract class FavoriteMoviesDatabase : RoomDatabase() {
    abstract fun favoriteMovieDao(): FavoriteMovieDao

    companion object {
        @Volatile private var INSTANCE: FavoriteMoviesDatabase? = null

        fun getInstance(context: Context): FavoriteMoviesDatabase {
            return INSTANCE ?: synchronized(this) {
                INSTANCE ?: Room.databaseBuilder(
                    context.applicationContext,
                    FavoriteMoviesDatabase::class.java,
                    "favorites.db"
                ).build().also { INSTANCE = it }
            }
        }
    }
}

Perceba que para instanciar uma Database com databaseBuilder, precisamos de uma variável chamada context: Context.

No desenvolvimento Android, a variável context representa uma referência ao ambiente atual do aplicativo. O context fornece acesso a recursos e funcionalidades fundamentais do sistema, como arquivos, bancos de dados, preferências, serviços do sistema e componentes da interface. Ele é essencial para muitas operações, como exibir uma tela, acessar arquivos ou iniciar novas atividades. Existem diferentes tipos de contextos, como o ApplicationContext (referente ao ciclo de vida do app) e o ActivityContext (relacionado à atividade em execução).

No aplicativo exemplo, temos uma pequena alteração para que esse Contexto esteja disponível em qualquer arquivo do código. Essa alteração envolve os arquivos MainActivity.kt, Application.kt e AppContextHolder.kt, que serão abordados mais a frente.

CastConverter.kt

Ainda na pasta data/local temos um arquivo utilitário, responsável por realizar uma simples conversão de dados. Essa conversão é necessária pois, um dos campos de Movie é a lista cast: List<String>. Assim, convertemos essa lista para uma String que poderá ser serializada no momento de recuperação desse dado:

class CastConverter {
    @TypeConverter
    fun fromList(list: List<String>): String = list.joinToString(separator = ";")
    @TypeConverter
    fun toList(str: String): List<String> =
        if (str.isEmpty()) emptyList() else str.split(";")
}
CastConverter.kt
package com.example.moviesapp.data.local
 
import androidx.room.TypeConverter
 
class CastConverter {
    @TypeConverter
    fun fromList(list: List<String>): String = list.joinToString(separator = ";")
    @TypeConverter
    fun toList(str: String): List<String> =
        if (str.isEmpty()) emptyList() else str.split(";")
}

Data - Arquivos de repositories e providers

O restante da pasta data/ define nossos repositories e providers.

Um repository é uma camada de abstração responsável por gerenciar o acesso a diferentes fontes de dados, como banco de dados local (Room), serviços de rede (API), ou arquivos. Ele centraliza a lógica de obtenção, armazenamento e atualização de dados, oferecendo uma interface única para as demais partes do app (por exemplo, ViewModels). O uso do repository facilita a manutenção, os testes e a reutilização do código.

Um provider, por sua vez, é uma classe responsável por criar e/ou fornecer uma única instância de um objeto, geralmente utilizando o padrão Singleton. Por exemplo, um provider pode ser usado para produzir uma instância única do repository ao longo de toda a aplicação, garantindo que todos os componentes utilizem o mesmo objeto compartilhado. Essa abordagem facilita o gerenciamento do ciclo de vida das dependências e promove a reutilização e consistência no acesso aos recursos.

No caso da aplicação exemplo, temos repositories e providers para acesso remoto (API) e acesso local (Room).

Repository local
class LocalFavoriteMoviesRepository(private val dao: FavoriteMovieDao) {
    suspend fun getAllFavorites(): List<FavoriteMovieEntity> = dao.getAll()
    suspend fun getFavorite(movieId: Int): FavoriteMovieEntity? = dao.getById(movieId)
    suspend fun isFavorite(movieId: Int): Boolean = dao.getById(movieId) != null
    suspend fun addFavorite(movie: FavoriteMovieEntity) = dao.insert(movie)
    suspend fun removeFavorite(movie: FavoriteMovieEntity) = dao.delete(movie)
}
LocalFavoriteMoviesRepository.kt
package com.example.moviesapp.data

import com.example.moviesapp.data.local.FavoriteMovieEntity
import com.example.moviesapp.data.local.FavoriteMovieDao

class LocalFavoriteMoviesRepository(private val dao: FavoriteMovieDao) {
    suspend fun getAllFavorites(): List<FavoriteMovieEntity> = dao.getAll()
    suspend fun getFavorite(movieId: Int): FavoriteMovieEntity? = dao.getById(movieId)
    suspend fun isFavorite(movieId: Int): Boolean = dao.getById(movieId) != null
    suspend fun addFavorite(movie: FavoriteMovieEntity) = dao.insert(movie)
    suspend fun removeFavorite(movie: FavoriteMovieEntity) = dao.delete(movie)
}
Provider local
object LocalFavoriteMoviesRepositoryProvider {

    @Volatile private var instance: LocalFavoriteMoviesRepository? = null

    fun getRepository(context: Context): LocalFavoriteMoviesRepository {
        return instance ?: synchronized(this) {
            instance ?: LocalFavoriteMoviesRepository(
                FavoriteMoviesDatabase.getInstance(context).favoriteMovieDao()
            ).also { instance = it }
        }
    }
}

LocalFavoriteMoviesRepositoryProvider.kt
package com.example.moviesapp.data

import android.content.Context
import com.example.moviesapp.data.local.FavoriteMoviesDatabase

object LocalFavoriteMoviesRepositoryProvider {

    @Volatile private var instance: LocalFavoriteMoviesRepository? = null

    fun getRepository(context: Context): LocalFavoriteMoviesRepository {
        return instance ?: synchronized(this) {
            instance ?: LocalFavoriteMoviesRepository(
                FavoriteMoviesDatabase.getInstance(context).favoriteMovieDao()
            ).also { instance = it }
        }
    }
}
Repository remoto
class RemoteMoviesRepository(
    private val apiService: MoviesApiService
) {
    suspend fun getMovies(): List<Movie> {
        return apiService.getMovies()
    }
 
    suspend fun getMovie(id: Int): Movie {
        return apiService.getMovie(id)
    }
 
    suspend fun getReviews(movieId: Int): List<Review> {
        return apiService.getReviews(movieId)
    }
}
RemoteMoviesRepository.kt
package com.example.moviesapp.data
 
import com.example.moviesapp.network.MoviesApiService
import com.example.moviesapp.model.Movie
import com.example.moviesapp.model.Review
 
class RemoteMoviesRepository(
    private val apiService: MoviesApiService
) {
    suspend fun getMovies(): List<Movie> {
        return apiService.getMovies()
    }
 
    suspend fun getMovie(id: Int): Movie {
        return apiService.getMovie(id)
    }
 
    suspend fun getReviews(movieId: Int): List<Review> {
        return apiService.getReviews(movieId)
    }
}
Provider remoto
object RemoteMoviesRepositoryProvider {
    val repository: RemoteMoviesRepository by lazy {
        RemoteMoviesRepository(MoviesApi.retrofitService)
    }
}
RemoteMoviesRepositoryProvider.kt
package com.example.moviesapp.data
 
import com.example.moviesapp.network.MoviesApi
 
object RemoteMoviesRepositoryProvider {
    val repository: RemoteMoviesRepository by lazy {
        RemoteMoviesRepository(MoviesApi.retrofitService)
    }
}

Network

A pasta network/ armazena o arquivo para configuração do Retrofit. Diferentemente da última aula, agora temos endpoints para acessar reviews:

interface MoviesApiService {
    @GET("/movies")
    suspend fun getMovies(): List<Movie>

    @GET("/movies/{id}")
    suspend fun getMovie(@Path("id") id:Int): Movie

    @GET("/movie/{movieId}/reviews")
    suspend fun getReviews(@Path("movieId") movieId:Int): List<Review>
}

object MoviesApi {
    val retrofitService: MoviesApiService by lazy {
        retrofit.create(MoviesApiService::class.java)
    }
}
MoviesApiService.kt
package com.example.moviesapp.network

import com.example.moviesapp.model.Movie
import com.example.moviesapp.model.Review
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET
import retrofit2.http.Path

private const val BASE_URL =
    "https://moviesapi.kutzke.com.br"

private val retrofit = Retrofit.Builder()
    .addConverterFactory(GsonConverterFactory.create())
    .baseUrl(BASE_URL)
    .build()

interface MoviesApiService {
    @GET("/movies")
    suspend fun getMovies(): List<Movie>

    @GET("/movies/{id}")
    suspend fun getMovie(@Path("id") id:Int): Movie

    @GET("/movie/{movieId}/reviews")
    suspend fun getReviews(@Path("movieId") movieId:Int): List<Review>
}

object MoviesApi {
    val retrofitService: MoviesApiService by lazy {
        retrofit.create(MoviesApiService::class.java)
    }
}

Utils

O arquivo utils/MapperExtensions.kt é opcional. Ele realizar conversões entre dados locais e modelos da aplicação. Mas esse tipo de conversão não precisa ser utilizado. Por exemplo, o DAO poderia retornar diretamente um tipo Movie.

Porém, para que a abstração fique ainda mais evidente, fizemos uso dessa conversão.

fun Movie.toFavoriteEntity() = FavoriteMovieEntity(
    id, title, cast, director, synopsis, posterUrl
)

fun FavoriteMovieEntity.toMovie() = Movie(
    id, title, cast, director, synopsis, posterUrl
)
MapperExtensions.kt
package com.example.moviesapp.utils

import com.example.moviesapp.model.Movie
import com.example.moviesapp.data.local.FavoriteMovieEntity

fun Movie.toFavoriteEntity() = FavoriteMovieEntity(
    id, title, cast, director, synopsis, posterUrl
)

fun FavoriteMovieEntity.toMovie() = Movie(
    id, title, cast, director, synopsis, posterUrl
)

Ui

A partir daqui, os arquivos seguem, mais ou menos, o padrão da última aula. As telas foram, apenas, incrementadas.

Talvez a maior diferença seja que, agora, temos um ViewModel para cada tela. Essa é uma prática comum e permite que os composables de navegação permaneçam simples, cuidando apenas da própria navegação.

Outra diferença é que, nessa versão, a navegação envolve uma topBar e uma bottomBar, definidas no Scaffold da tela. Essa é uma composição comum, garantido a aplicação uma navegabilidade conhecida nos dispositivos Android.

MoviesApp.kt
package com.example.moviesapp.ui

import android.util.Log
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.List
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.TopAppBar
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.NavType
import androidx.navigation.navArgument
import com.example.moviesapp.model.Movie
import com.example.moviesapp.model.fourMovies
import com.example.moviesapp.ui.moviesapp.MovieDetailsScreen
import com.example.moviesapp.ui.moviesapp.MoviesScreen
import com.example.moviesapp.ui.theme.MoviesAppTheme

sealed class BottomNavScreen(val route: String, val label: String, val icon: ImageVector) {
    object MovieList : BottomNavScreen("movie_list", "Filmes", Icons.Filled.List)
    object Favorites : BottomNavScreen("favorites", "Favoritos", Icons.Filled.Favorite)
    companion object { val values = listOf(MovieList, Favorites) }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MoviesApp() {
    val navController = rememberNavController()
    val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route

    val bottomNavRoutes = BottomNavScreen.values.map { it.route }

    Scaffold(
        topBar = {
            val topBarTitle = when {
                currentRoute?.startsWith("movie") == true -> "Filmes"
                currentRoute?.startsWith("favorites") == true -> "Favoritos"
                else -> ""
            }
            val showBack = currentRoute != null && bottomNavRoutes.none { currentRoute == it }

            TopAppBar(
                    title = { Text(text = topBarTitle) },
                    navigationIcon = {
                        if(showBack) {
                            IconButton(
                                onClick = { navController.popBackStack() }) {
                                Icon(
                                    imageVector = Icons.AutoMirrored.Filled.ArrowBack,
                                    contentDescription = "Localized description"
                                )
                            }
                        }
                        else null
                    },
                )

        },
        bottomBar = {
            NavigationBar {
                BottomNavScreen.values.forEach { screen ->
                    NavigationBarItem(
                        icon = { Icon(screen.icon, contentDescription = screen.label) },
                        label = { Text(screen.label) },
                        selected = (currentRoute == screen.route),
                        onClick = {
                            navController.navigate(screen.route) {
                                popUpTo(navController.graph.findStartDestination().id) { saveState = true }
                                launchSingleTop = true
                                restoreState = true
                            }
                        }
                    )
                }
            }
        }
    ) { innerPadding ->
        NavHost(
            navController = navController,
            startDestination = BottomNavScreen.MovieList.route,
            modifier = Modifier.padding(innerPadding)
        ) {
            composable(BottomNavScreen.MovieList.route) {
                MoviesScreen(
                    onGoToMovieDetailsClick = { movieId ->
                        navController.navigate("movie_list/$movieId")
                    }
                )
            }
            composable(BottomNavScreen.Favorites.route) {
                FavoriteMoviesScreen()
            }
            composable(
                route="movie_list/{movieId}",
                arguments = listOf(
                    navArgument ("movieId") {
                        defaultValue = 0
                        type = NavType.IntType
                    }
                )
            ) { backStackEntry ->
                val movieId:Int? = backStackEntry.arguments?.getInt("movieId")

                movieId?.let {
                    MovieDetailsScreen(
                        movieId = it,
                        onGoBackClick = {
                            navController.popBackStack()
                        }
                    )
                }
            }
        }
    }
}

@Composable
fun FavoriteMoviesScreen(
    movies: List<Movie> = fourMovies,
) {
    Column {
        Text("Filmes Favoritos", style = MaterialTheme.typography.displayMedium)
        if (movies.isEmpty()) Text("Nenhum favorito.")
        movies.forEach { movie -> Text(movie.title) }
    }
}

@Preview
@Composable
fun MoviesAppPreview(){
    MoviesAppTheme{
        MoviesApp()
    }
}

ui/moviesapp

As telas MoviesScreen e MovieDetailsScreen foram remodeladas:

Tela MoviesScreen Tela MovieDetailsScreen

Para além das alterações visuais, que estão fora do escopo dessa aula (embora sejam bastante interessantes), as modificações mais importantes são as realizadas nos ViewModels de cada tela.

MoviesListViewModel

Logo no inicio da definição do ViewModel, observamos as inicializações das variáveis com os repositórios utilizados na tela.

As funções getMovies e toggleFavorite fazem o acesso aos repositórios local e remoto.

A função getMovies solicita a lista de filmes através da API (repositório remoto) e a lista de filmes favoritos do usuário, a partir do Room (repositório local).

A função toggleFavorite, por sua vez, realizar a verificação se o filme já está na tabela de filmes favoritos e na sequência, muda essa informação.

Com esses dados, a tela pode desenhar a lista de filmes, informando quais deles são favoritos.

class MoviesListViewModel(): ViewModel() {
    private val repository = RemoteMoviesRepositoryProvider.repository
    private val localRepository = LocalFavoriteMoviesRepositoryProvider.getRepository(AppContextHolder.appContext)
    var moviesScreenUiState: MoviesScreenUiState by mutableStateOf(MoviesScreenUiState.Loading)

    init {
        getMovies()
    }

    private fun getMovies(){
        viewModelScope.launch {
            moviesScreenUiState = try {
                val movies = repository.getMovies()
                val favorites = localRepository.getAllFavorites().map { it.toMovie() }
                MoviesScreenUiState.Success(movies = movies, favorites = favorites)
            }
            catch(e: IOException){
                MoviesScreenUiState.Error
            }
        }
    }

    fun toggleFavorite(movieId:Int){
        if(moviesScreenUiState is MoviesScreenUiState.Success){
          val movieToToggle: Movie? = (moviesScreenUiState as MoviesScreenUiState.Success).movies.find{ movie -> movieId == movie.id}
          movieToToggle?.let{
              viewModelScope.launch {
                  if(localRepository.getFavorite(movieId) == null){
                      localRepository.addFavorite(movieToToggle.toFavoriteEntity())
                  }
                  else{
                      localRepository.removeFavorite(movieToToggle.toFavoriteEntity())
                  }
                  val favorites = localRepository.getAllFavorites().map { it.toMovie() }
                  moviesScreenUiState = MoviesScreenUiState.Success(movies = (moviesScreenUiState as MoviesScreenUiState.Success).movies, favorites = favorites)
              }
          }
        }
    }
}
MoviesListViewModel.kt
package com.example.moviesapp.ui.moviesapp

import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.moviesapp.AppContextHolder
import com.example.moviesapp.data.LocalFavoriteMoviesRepository
import com.example.moviesapp.data.LocalFavoriteMoviesRepositoryProvider
import com.example.moviesapp.data.RemoteMoviesRepositoryProvider
import com.example.moviesapp.model.Movie
import com.example.moviesapp.utils.toFavoriteEntity
import com.example.moviesapp.utils.toMovie
import kotlinx.coroutines.launch
import okio.IOException

sealed interface MoviesScreenUiState {
    class Success(val movies: List<Movie>, val favorites: List<Movie>): MoviesScreenUiState
    object Error: MoviesScreenUiState
    object Loading: MoviesScreenUiState
}

class MoviesListViewModel(): ViewModel() {
    private val repository = RemoteMoviesRepositoryProvider.repository
    private val localRepository = LocalFavoriteMoviesRepositoryProvider.getRepository(AppContextHolder.appContext)
    var moviesScreenUiState: MoviesScreenUiState by mutableStateOf(MoviesScreenUiState.Loading)

    init {
        getMovies()
    }

    private fun getMovies(){
        viewModelScope.launch {
            moviesScreenUiState = try {
                val movies = repository.getMovies()
                val favorites = localRepository.getAllFavorites().map { it.toMovie() }
                MoviesScreenUiState.Success(movies = movies, favorites = favorites)
            }
            catch(e: IOException){
                MoviesScreenUiState.Error
            }
        }
    }

    fun toggleFavorite(movieId:Int){
        if(moviesScreenUiState is MoviesScreenUiState.Success){
          val movieToToggle: Movie? = (moviesScreenUiState as MoviesScreenUiState.Success).movies.find{ movie -> movieId == movie.id}
          movieToToggle?.let{
              viewModelScope.launch {
                  if(localRepository.getFavorite(movieId) == null){
                      localRepository.addFavorite(movieToToggle.toFavoriteEntity())
                  }
                  else{
                      localRepository.removeFavorite(movieToToggle.toFavoriteEntity())
                  }
                  val favorites = localRepository.getAllFavorites().map { it.toMovie() }
                  moviesScreenUiState = MoviesScreenUiState.Success(movies = (moviesScreenUiState as MoviesScreenUiState.Success).movies, favorites = favorites)
              }
          }
        }
    }
}

MovieDetailsViewModel

A tela de detalhes de um filme, também precisa acessar ambos os repositórios. Se o filme for favorito, os dados são obtidos localmente, caso contrário, tenta-se acessar a API pelo repositórios remoto.

Os reviews são sempre acessados pela API.

Isso permite um comportamento interessante da tela caso o usuário não possua acesso a internet.

class MovieDetailsViewModel(): ViewModel() {
    private val remoteRepository = RemoteMoviesRepositoryProvider.repository
    private val localRepository = LocalFavoriteMoviesRepositoryProvider.getRepository(AppContextHolder.appContext)
    var movieDetailsUiState: MovieDetailsUiState by mutableStateOf(MovieDetailsUiState.Loading)

    fun getMovie(movieId:Int){
        var movie: Movie?
        var reviews: List<Review>

        movie = null

        viewModelScope.launch {
            movieDetailsUiState = try {
                movie = localRepository.getFavorite(movieId)?.toMovie() ?:
                        remoteRepository.getMovie(movieId)
                reviews = remoteRepository.getReviews(movieId)
                MovieDetailsUiState.Success(movie = movie, reviews= reviews)
            }
            catch(e: IOException) {
                try {
                    movie = localRepository.getFavorite(movieId)?.toMovie()
                        ?: remoteRepository.getMovie(movieId)
                    MovieDetailsUiState.SuccessButNoReviews(movie = movie, reviews = null)

                } catch (e: IOException) {
                    MovieDetailsUiState.Error
                }
            }
        }
    }
}
MovieDetailsViewModel.kt
package com.example.moviesapp.ui.moviesapp

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.moviesapp.AppContextHolder
import com.example.moviesapp.data.LocalFavoriteMoviesRepositoryProvider
import com.example.moviesapp.data.RemoteMoviesRepositoryProvider
import com.example.moviesapp.model.Movie
import com.example.moviesapp.model.Review
import com.example.moviesapp.utils.toMovie
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import okio.IOException

sealed interface MovieDetailsUiState {
    class Success(val movie: Movie?, val reviews: List<Review>): MovieDetailsUiState
    class SuccessButNoReviews(val movie: Movie?, val reviews: List<Review>?): MovieDetailsUiState
    object Error: MovieDetailsUiState
    object Loading: MovieDetailsUiState
}

class MovieDetailsViewModel(): ViewModel() {
    private val remoteRepository = RemoteMoviesRepositoryProvider.repository
    private val localRepository = LocalFavoriteMoviesRepositoryProvider.getRepository(AppContextHolder.appContext)
    var movieDetailsUiState: MovieDetailsUiState by mutableStateOf(MovieDetailsUiState.Loading)

    fun getMovie(movieId:Int){
        var movie: Movie?
        var reviews: List<Review>

        movie = null

        viewModelScope.launch {
            movieDetailsUiState = try {
                movie = localRepository.getFavorite(movieId)?.toMovie() ?:
                        remoteRepository.getMovie(movieId)
                reviews = remoteRepository.getReviews(movieId)
                MovieDetailsUiState.Success(movie = movie, reviews= reviews)
            }
            catch(e: IOException) {
                try {
                    movie = localRepository.getFavorite(movieId)?.toMovie()
                        ?: remoteRepository.getMovie(movieId)
                    MovieDetailsUiState.SuccessButNoReviews(movie = movie, reviews = null)

                } catch (e: IOException) {
                    MovieDetailsUiState.Error
                }
            }
        }
    }
}

Demais arquivos de interface

Vale mencionar que a tela MovieDetailsScreen faz uso da função rememberLazyListState para implementar o efeito de rolagem conhecido como Collapsing Toolbar, fazendo com que a imagem diminua de tamanha a medida que o usuário rola a tela.

O código abaixo faz o calcula da altura atual que a foto deve ter.

scrollState terá várias informações sobre o estado da rolagem da LazyColumn.

important

A implementação abaixo não é ideal. Ela favorece a simplicidade em detrimento do desempenho. O código apresentado realiza muitas recomposições, o que pode impactar o desempenho da aplicação.

    val scrollState = rememberLazyListState()
    val density = LocalDensity.current

    val maxHeight = 500.dp
    val minHeight = 150.dp

    val maxHeightPx = with(density) { maxHeight.toPx() }
    val minHeightPx = with(density) { minHeight.toPx() }
    
    val scroll: Float =
        (scrollState.firstVisibleItemIndex * maxHeightPx + scrollState.firstVisibleItemScrollOffset)

    val currentHeightPx = (maxHeightPx - scroll).coerceIn(minHeightPx, maxHeightPx)
    val currentHeight = with(density) { currentHeightPx.toDp() }

MovieDetailsScreen.kt
package com.example.moviesapp.ui.moviesapp

import android.R
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.ColorImage
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePreviewHandler
import coil3.compose.LocalAsyncImagePreviewHandler
import com.example.moviesapp.model.Movie
import com.example.moviesapp.model.Review
import com.example.moviesapp.model.fourMovies
import com.example.moviesapp.model.fourReviews
import com.example.moviesapp.ui.theme.MoviesAppTheme

@Composable
fun MovieDetailsScreen(
    movieId: Int,
    movieDetailsViewModel: MovieDetailsViewModel = viewModel(),
    onGoBackClick: () -> Unit = {},
){
    val movieDetailsUiState = movieDetailsViewModel.movieDetailsUiState

    LaunchedEffect(movieId) {
        movieDetailsViewModel.getMovie(movieId)
    }

    when(movieDetailsUiState){
        is MovieDetailsUiState.Success ->{
            MovieDetailsScreen(movie = movieDetailsUiState.movie!!, reviews = movieDetailsUiState.reviews)
        }
        is MovieDetailsUiState.SuccessButNoReviews ->{
            MovieDetailsScreen(movie = movieDetailsUiState.movie!!, reviews = null)
        }
        is MovieDetailsUiState.Loading ->{
            LoadingScreen(modifier = Modifier.fillMaxSize())
        }
        is MovieDetailsUiState.Error ->{
            ErrorScreen(modifier = Modifier.fillMaxSize())
        }
    }

}

@OptIn(ExperimentalCoilApi::class)
@Composable
fun MovieDetailsScreen(
    movie: Movie = fourMovies[0],
    reviews: List<Review>? = fourReviews,
    onGoBackClick: () -> Unit = {},
){
    val scrollState = rememberLazyListState()
    val density = LocalDensity.current

    val maxHeight = 500.dp
    val minHeight = 150.dp

    val maxHeightPx = with(density) { maxHeight.toPx() }
    val minHeightPx = with(density) { minHeight.toPx() }
    
    val scroll: Float =
        (scrollState.firstVisibleItemIndex * maxHeightPx + scrollState.firstVisibleItemScrollOffset)

    val currentHeightPx = (maxHeightPx - scroll).coerceIn(minHeightPx, maxHeightPx)
    val currentHeight = with(density) { currentHeightPx.toDp() }

    Box {
        LazyColumn(
            state = scrollState,
            contentPadding = PaddingValues(top = maxHeight),
        ) {
            item() {
                Column(modifier = Modifier
                    .padding(horizontal = 20.dp)
                ) {
                    Spacer(modifier = Modifier.size(20.dp))
                    Text(text="Sinopse", style = MaterialTheme.typography.titleLarge)
                    Text(text = movie.synopsis)
                    Spacer(modifier = Modifier.size(20.dp))
                    Text(text="Diretor", style = MaterialTheme.typography.titleLarge)
                    Text(text = movie.director)
                    Spacer(modifier = Modifier.size(20.dp))
                    Text(text="Elenco", style = MaterialTheme.typography.titleLarge)
                    Text(text = movie.cast.toString())
                    HorizontalDivider(modifier = Modifier.padding(horizontal = 10.dp, vertical = 25.dp))
                    Text(text = "Reviews", style = MaterialTheme.typography.titleLarge)
                    Spacer(modifier = Modifier.height(25.dp))
                }
            }
            item() {
                Column(modifier = Modifier
                    .padding(horizontal = 20.dp)
                ) {
                    if(reviews == null){
                        ErrorScreen(modifier = Modifier.fillMaxSize())
                    }
                    else if(reviews.isEmpty()){
                        Text(text= "Nenhum review :(")
                    }
                    else {
                        reviews.forEach { review ->
                            Row(verticalAlignment = Alignment.CenterVertically) {
                                Text(text = review.author, modifier = Modifier.weight(1f), style = MaterialTheme.typography.titleMedium)
                                Text(text = "${review.rating} / 10")

                            }
                            Text(text = review.reviewText)
                            Spacer(modifier = Modifier.height(25.dp))
                        }
                        
                    }
                }
            }
        }

        Box {
            CompositionLocalProvider(LocalAsyncImagePreviewHandler provides previewHandler) {
                AsyncImage(
                    model = movie.posterUrl,
                    contentDescription = "Poster de ${movie.title}",
                    contentScale = ContentScale.Crop,
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(currentHeight)
                        .align(Alignment.TopCenter)
                )
            }
            Text(
                text = movie.title,
                style = MaterialTheme.typography.displaySmall.copy(
                    shadow = Shadow(
                        color = Color.Black, offset = Offset(x = 0f,y = 0f), blurRadius = 10f
                    )
                ),
                color = MaterialTheme.colorScheme.onPrimary,
                modifier = Modifier
                    .padding(10.dp)
                    .align(Alignment.BottomStart)
            )
        }
    }
}

val previewHandler = AsyncImagePreviewHandler {
    ColorImage(Color.Red.toArgb())
}

@Preview
@Composable
fun MovieDetailsScreenPreview() {
    MoviesAppTheme {
        MovieDetailsScreen(
            movie = fourMovies[0]
        )
    }
}
MoviesScreen.kt
package com.example.moviesapp.ui.moviesapp

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults.topAppBarColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.moviesapp.model.fourMovies
import com.example.moviesapp.ui.theme.MoviesAppTheme

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MoviesScreen(
    moviesListViewModel: MoviesListViewModel = viewModel (),
    onGoToMovieDetailsClick: (movieId:Int) -> Unit = {},
) {
    val moviesScreenUiState = moviesListViewModel.moviesScreenUiState

    Column(){
        when(moviesScreenUiState){
            is MoviesScreenUiState.Success -> {
                MoviesList(
                    movies = moviesScreenUiState.movies,
                    favorites = moviesScreenUiState.favorites,
                    onFavoriteClick = { movieId -> moviesListViewModel.toggleFavorite(movieId) },
                    onMovieClick = {movieId -> onGoToMovieDetailsClick(movieId) },
                )
            }
            is MoviesScreenUiState.Loading -> LoadingScreen(modifier = Modifier.fillMaxSize())
            is MoviesScreenUiState.Error -> ErrorScreen( modifier = Modifier.fillMaxSize())
        }
    }
}
MoviesList.kt
MovieItem.kt
package com.example.moviesapp.ui.moviesapp

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil3.ColorImage
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePreviewHandler
import coil3.compose.LocalAsyncImagePreviewHandler
import com.example.moviesapp.model.Movie
import com.example.moviesapp.model.fourMovies
import com.example.moviesapp.ui.theme.MoviesAppTheme

@OptIn(ExperimentalCoilApi::class)
@Composable
fun MovieItem(
    modifier: Modifier = Modifier,
    movie: Movie,
    isFavorite: Boolean = false,
    onTitleClick: () -> Unit = {},
    onFavoriteClick: () -> Unit = {}
) {
    val previewHandler = AsyncImagePreviewHandler{
        ColorImage(Color.Red.toArgb())
    }

    Row(
        verticalAlignment = Alignment.CenterVertically,
        modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp)
    ) {
        CompositionLocalProvider(LocalAsyncImagePreviewHandler provides com.example.moviesapp.ui.moviesapp.previewHandler) {
            AsyncImage(
                model = movie.posterUrl,
                contentDescription = "Poster de ${movie.title}",
                contentScale = ContentScale.Crop,
                modifier = Modifier
                    .height(50.dp)
                    .width(50.dp)
                    .align(Alignment.CenterVertically)
                    .padding(end = 10.dp)
            )
        }
        Text(
            modifier = modifier
                .clickable(
                onClick = onTitleClick,
            )
                .weight(1f),
            text = movie.title,
        )
        IconButton(
            onClick = onFavoriteClick,
        ) {
            if(isFavorite) {
                Icon(Icons.Filled.Favorite, contentDescription = "Toogle Favorite")
            }
            else{
                Icon(Icons.Filled.FavoriteBorder, contentDescription = "Toogle Favorite")
            }
        }
    }
}

@Preview
@Composable
fun MovieItemPreview(){
    MoviesAppTheme {
        MovieItem(movie = fourMovies[0])
    }
}

ApiComposables.kt
package com.example.moviesapp.ui.moviesapp

import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.example.moviesapp.R
import androidx.compose.ui.res.stringResource


@Composable
fun LoadingScreen(modifier: Modifier = Modifier) {
    Image(
        modifier = modifier.size(200.dp),
        painter = painterResource(R.drawable.loading_img),
        contentDescription = "Carregando"
    )
}

@Composable
fun ErrorScreen(modifier: Modifier = Modifier) {
    Column(
        modifier = modifier,
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Image(
            painter = painterResource(id = R.drawable.ic_connection_error), contentDescription = ""
        )
        Text(text = "Falha ao Carregar", modifier = Modifier.padding(16.dp))
    }
}

App

Como informado anteriormente, os arquivos abaixo tem pequenas alterações para permitir uma instância de context: Context disponível em todo o código da aplicação. O context é necessário para que os viewModels inicializem os repositórios.

Application.kt
package com.example.moviesapp

import android.app.Application

class MoviesApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        AppContextHolder.init(this)
    }
}
AppContextHolder.kt
package com.example.moviesapp

import android.annotation.SuppressLint
import android.app.Application
import android.content.Context

object AppContextHolder {
    @SuppressLint("StaticFieldLeak")
    lateinit var appContext: Context
        private set

    fun init(app: Application) {
        appContext = app.applicationContext
    }
}

Para que tudo isso funcione, precisamos informar no arquivo AndroidManifest.xml que o entry point da aplicação agora deve ser a classe MoviesApplication e não mais a MainActivity. Para isso, alteramos o atributo name do item Application.

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.INTERNET" />
    <application
        android:name=".MoviesApplication"
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.INTERNET" />
    <application
        android:name=".MoviesApplication"
        android:allowBackup="true"
        android:usesCleartextTraffic="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.MoviesApp"
        tools:targetApi="31">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:label="@string/app_name"
            android:theme="@style/Theme.MoviesApp">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

10 - Arquitetura de Aplicações Android e Outros Conceitos Importantes

Considerações Iniciais

  • Limitações de Recursos: Dispositivos móveis podem encerrar processos de aplicativos para liberar recursos.
  • Arquitetura de Aplicativos: Define os limites e responsabilidades de cada parte do aplicativo.

Princípios Fundamentais

Separação de Responsabilidades

  • Evitar concentrar todo o código em uma Activity ou Fragment.

Interface Guiada por Modelos de Dados

  • A UI deve ser baseada em modelos de dados, preferencialmente persistentes.
  • Benefícios: Aplicativos mais testáveis e robustos.

Fonte Única de Verdade

  • Designar uma única fonte de verdade (SSOT) para cada tipo de dado.
  • Centraliza mudanças, protege dados contra alterações indevidas e facilita o rastreamento de bugs.

Fluxo de Dados Unidirecional

  • Utiliza-se o padrão de Fluxo de Dados Unidirecional (UDF) onde o estado flui em uma direção e eventos na direção oposta.

Arquitetura Recomendada

Camadas do Aplicativo

  1. Camada de UI: Exibe os dados na tela.
  2. Camada de Dados: Contém a lógica de negócios e expõe os dados.

Arquitetura Típica

Técnicas Modernas

  • Arquitetura reativa e em camadas.
  • Fluxo de Dados Unidirecional (UDF).
  • Uso de Coroutines e Flows.
  • Injeção de dependência.

Camadas Detalhadas

Camada de UI

  • Elementos de UI: Renderizam os dados.
  • Portadores de Estado: Mantêm e gerenciam os dados do UI (exemplo: ViewModel).

Camada de UI

Camada de Dados

  • Repositórios: Expõem e centralizam dados, resolvem conflitos entre fontes e contêm lógica de negócios.

Camada de Dados

Camada de Domínio (Opcional)

  • Função: Encapsula lógica de negócios complexa que pode, por exemplo, ser reutilizada por vários ViewModels.

Camada de Domínio

Injeção de Dependência

  • Injeção de Dependência (DI): Permite definir dependências sem construi-las diretamente. Durante a execução, outra classe é responsável por prover essas dependências.
  • Service Locator: Padrão que fornece um registro para obter dependências. Ou seja, durante a execução, classes podem consultar o registro para obter suas dependências.

A biblioteca Hilt é indicada para o uso de DI em projetos Android:

Melhores Práticas Gerais

  • Não armazene dados em componentes de aplicativos.
  • Reduza dependências em classes Android (seus componentes devem ser os únicos a dependerem de Apis do Android, como o Context).
  • Crie limites bem definidos entre módulos.
  • Minimize a exposição de cada módulo.
  • Testabilidade e segurança de tipos.

Benefícios da Arquitetura

  • Manutenção e Qualidade: Melhora a robustez geral do aplicativo.
  • Escalabilidade: Facilita a contribuição de múltiplas pessoas e equipes.
  • Onboarding: Consistência simplifica a adaptação de novos integrantes.
  • Testabilidade: Arquitetura favorece tipos simples e testáveis.

Recomendações para Arquitetura de Aplicações Android

https://developer.android.com/topic/architecture/recommendations


Outros conteúdos

Distribuição de Aplicativos

  • APKs Otimizados: No Google Play, servidores geram APKs otimizados contendo apenas recursos necessários para o dispositivo que solicita a instalação.

Sistema Operacional Android

  • Sistema Multiusuário: Android é um sistema multiusuário baseado em Linux; cada app é um usuário diferente.
  • Permissões de Arquivos: Cada app tem permissões para acessar apenas seus próprios arquivos.
  • Processo Isolado: Cada app executa em seu próprio processo Linux e máquina virtual, garantindo isolamento.
  • Princípio do Menor Privilégio: Apps acessam apenas componentes necessários para sua execução.

Permissões

  • Solicitação de Permissões: Apps podem solicitar acesso a dados do dispositivo como localização e câmera, mas o usuário deve conceder explicitamente essas permissões. Mais informações sobre permissões.

Fluxo de alto nível para utilização de permissões no Android.

Tipos de Componentes de Aplicativos

  1. Activities

    • Ponto de entrada para interação com o usuário, representando uma tela.
    • Diferentes apps podem iniciar activities se permitido.
  2. Services

    • Executa operações em segundo plano sem interface de usuário.
    • Ex.: Tocar música, buscar dados na rede.
  3. Broadcast Receivers

    • Permite receber eventos do sistema e reagir a eles.
    • Ex.: Notificação de eventos futuros.
  4. Content Providers

    • Gerencia dados entre aplicativos.
    • Por exemplo o sistema Android expõe um Content Provider que gerencia informações de contato do usuário. Qualquer Aplicação com permissões suficientes pode gerenciar essas informações;
    • Acesso permitido a apps com as permissões corretas.

Interação entre Aplicativos

  • Início de Componentes de Outros Apps: Apps podem iniciar componentes de outros apps sem incorporar código de terceiros.
  • Intents: Mensagens assíncronas que ativam três dos quatro tipos de componentes (activities, services, receivers):
    • Por exemplo, uma aplicação pode enviar uma Intent para o aplicativo nativo de câmera para capturar uma foto.
    • Nesse caso, não é necessário implementar um acesso à câmera. Basta utilizar a tela do aplicativo nativo.
    • Esse relacionamento pode ser obtido com qualquer aplicativo que exponha Intents para acesso aos seus recursos.
  • Manifesto do Aplicativo: Arquivo AndroidManifest.xml declara componentes e permissões de uso.

Exemplo de Intent para compartilhar um filme na Aplicação Movies App.

Declaração de Componentes

  • Elementos no Manifesto:

  • Declaração de Câmera:

    • Apps declaram recursos de hardware, como câmera, no arquivo AndroidManifest.xml.
    • Permite especificar se o recurso é necessário ou opcional.

Especificação Trabalho Prático

Tema: Desenvolvimento de uma Aplicação Android para Gestão de Tarefas

Objetivo: Criar uma aplicação Android que permita aos usuários gerenciar suas tarefas diárias, integrando conhecimentos de arquitetura e testes de aplicações Android.

Descrição do Projeto: Os alunos devem desenvolver uma aplicação Android com as seguintes funcionalidades e requisitos:

Este trabalho poderá ser realizado em grupos de até 4 estudantes.

Funcionalidades Básicas

  1. Autenticação:

    • Autenticação com Firebase Authentication;
  2. Persistência de dados:

    • Persistência de dados com Firebase Firestore ou Room Database;
  3. Modelo de dados:

    • Tarefas devem ter, ao menos, as seguintes informações:
      • Título;
      • Descrição;
      • Data limite;
      • Prioridade;
      • Status;
    • Tarefas podem ter subtarefas;
    • Uma tarefa pode ser iniciada, pausada e concluída;
    • Uma tarefa pode ser concluída diretamente, sem precisar sem iniciada;
  4. Funcionalidades gerais:

    • Permitir criação, edição e remoção de tarefas;
    • Notificar usuários sobre tarefas com data limite próxima;
    • Apresentar tempo utilizado para concluir uma tarefa;
  5. Requisito de interface:

    • Utilizar Jetpack Compose para criação das telas.
    • O layout da aplicação e a quantidade de telas é livre.
  6. Filtro e Ordenação:

    • Permitir filtragem por prioridade e status.
    • Ordenação por data de conclusão.

Funcionalidades Adicionais (desejáveis, porém, opcionais)

  1. Tema Escuro/Claro:

    • Configurar alternância entre temas escuro e claro.
  2. Pesquisa de Tarefas:

    • Implementar barra de pesquisa para encontrar tarefas rapidamente.

Sugestões para Telas da Aplicação

  1. Tela de Autenticação:

    • Login e Registro: Usar Firebase Authentication.
  2. Tela de Dashboard:

    • Resumo de Tarefas: Exibir contagem de tarefas pendentes, concluídas e totais.
    • Listagem de Tarefas: Mostrar as tarefas em formato de lista.
  3. Tela de Detalhes da Tarefa:

    • Visualizar Detalhes: Título, descrição, data, prioridade e status.
    • Botões de Ação: Editar ou excluir a tarefa.
  4. Tela de Criação/Edição de Tarefa:

    • Entrada de Dados: Título, descrição, data de conclusão, prioridade.
    • Salvar/Atualizar Tarefa: Persistir dados no Firebase Firestore ou Room Database.
  5. Tela de Notificações:

    • Gerenciar Notificações: Ativar/desativar lembretes para tarefas específicas.
  6. Tela de Perfil do Usuário:

    • Editar Perfil: Nome do usuário, e-mail e outros dados relevantes.
    • Sair da Conta: Logout da aplicação.

Estrutura da Aplicação

  • Arquitetura: Utilizar MVVM para uma separação clara de responsabilidades.
  • Banco de Dados: Firebase Firestore ou Room Database para persistência.

Entregáveis

  1. Código-Fonte:

    • Repositório do GitLab com código bem documentado.
  2. Documentação Técnica: (no próprio repositório)

    • Instruções para instalação, execução e detalhes sobre arquitetura utilizada.
  3. Vídeo de funcionamento:

    • Video apresentando a aplicação em funcionamento.
    • Este vídeo ficará disponível para que todos da turma conheçam os aplicativos de outros grupos.

Defesa do trabalho

  • Apresentação final destacando funcionalidades e processo de desenvolvimento.
  • Apenas para o professor.
  • Aplicação deve funcionar em um dispositivo físico.

Critérios de Avaliação

A avaliação será em duas partes.

  1. Avaliação final do projeto e funcionalidade (70% da nota):

    • Funcionalidade Completa: Implementação de todos os requisitos.
    • Qualidade do Código: Uso correto da arquitetura MVVM e boas práticas.
    • Interface do Usuário: Design coerente com Material Design.
    • Documentação e código fonte: Clareza e cobertura ampla das informações técnicas e bom gerenciamento do versionamento do código.
  2. Avaliação do processo de desenvolvimento (30% da nota):

    • 4x checkpoints nas aulas dos dias 21/05, 28/05, 04/06 e 11/06.

Os checkpoints poderão ser realizados de duas formas:

  • Commits regulares antes de cada data de checkpoint (professor irá avaliar commits realizados no repositório).
  • Reuniões presenciais no horário da aula;

Cada equipe poderá decidir, semanalmente, a forma de checkpoint que deseja.

Repositório para o projeto

O repositório base para o projeto final é o seguinte:

https://gitlab.com/ds151-alexkutzke/ds151-project-2025-1

Cada equipe deve fazer apenas 1 fork do projeto acima.

Os membros da equipe devem ser adicionados pela interface do Gitlab como desenvolvedores no projeto. O arquivo README deverá conter o nome dos integrantes do grupo.

O fork de cada equipe deverá ser realizado antes da primeira data de checkpoint.