Android Room com View: Kotlin

A coleção Componentes da arquitetura do Android oferece orientações sobre a arquitetura de apps, com bibliotecas para tarefas comuns, como o gerenciamento do ciclo de vida e a persistência de dados. Os Componentes da arquitetura ajudam você a estruturar o app de maneira robusta, testável e de fácil manutenção com menos código boilerplate.

As bibliotecas de Componentes da Arquitetura fazem parte do Android Jetpack.

Esta é a versão em Kotlin do codelab. A versão na linguagem de programação Java pode ser encontrada neste link.

Se você encontrar algum problema nesse codelab, como bugs no código, erros gramaticais ou simplesmente conteúdo confuso, informe o problema use o link Informar um erro no canto inferior esquerdo do codelab.

Pré-requisitos

É necessário conhecer o Kotlin, os conceitos de projeto orientados a objetos e os princípios básicos de desenvolvimento do Android, principalmente:

  • RecyclerView e adaptadores.
  • Banco de dados SQLite e a linguagem de consulta SQLite.
  • Corrotinas básicas. Se você não for familiarizado com as corrotinas, consulte Como usar corrotinas Kotlin no app Android.

Também é importante conhecer os padrões de arquitetura de software que separam os dados da interface do usuário, como o Model-View-Presenter (MVP) ou o Model-View-Controller (MVC). Este codelab implementa a arquitetura definida no Guia para a arquitetura do app da documentação do desenvolvedor Android.

Este codelab é focado nos Componentes da arquitetura do Android. Conceitos e códigos não relacionados a este tópico são fornecidos para que você os copie e cole.

O que você aprenderá

Você aprenderá a projetar e construir um app usando os Componentes da arquitetura Room, ViewModel e LiveData. Seu app irá:

  • implementar a arquitetura recomendada usando os Componentes da Arquitetura do Android;
  • trabalhar com um banco de dados para receber e salvar dados e pré-preencher esse banco de dados com exemplos de palavras;
  • exibir todas as palavras em uma RecyclerView na classe MainActivity;
  • abrir uma segunda atividade quando o usuário tocar no botão +. Quando o usuário insere uma palavra, ela é adicionada ao banco de dados e exibida na lista da RecyclerView.

O app é simples, mas suficientemente complexo para ser usado como modelo. Veja alguns exemplos:

Pré-requisitos

  • Android Studio 4.0 ou mais recente e conhecimento sobre como usá-lo. Verifique se o Android Studio está atualizado, assim como o SDK e o Gradle.
  • Um dispositivo ou emulador Android.

Este codelab disponibiliza todo o código necessário para que você crie o app completo.

Veja um diagrama simples que apresenta os Componentes da arquitetura e como eles funcionam juntos. Este codelab se concentra em um subconjunto dos componentes, nesse caso, LiveData, ViewModel e Room. Cada componente será explicado detalhadamente quando você usá-lo no seu app.

8e4b761713e3a76b.png

LiveData: uma classe armazenadora de dados que pode ser observada (link em inglês). Ela sempre mantém/armazena em cache a versão mais recente dos dados e notifica os observadores quando os dados mudam. LiveData é compatível com o ciclo de vida. Os componentes da IU apenas observam dados relevantes e não interrompem nem retomam a observação. O LiveData gerencia tudo isso automaticamente, já que conta com reconhecimento das mudanças relevantes do status do ciclo de vida durante a observação.

ViewModel: atua como um centro de comunicação entre o repositório (dados) e a IU. A IU não precisa mais se preocupar com a origem dos dados. As instâncias do ViewModel sobrevivem à recriação de atividade/fragmento.

Repositório: uma classe que você cria e que é usada principalmente para gerenciar várias fontes de dados.

Entidade: classe com anotação que descreve uma tabela de banco de dados ao trabalhar com o Room.

Banco de dados do Room: simplifica o trabalho com o banco de dados e serve como ponto de acesso para o banco de dados SQLite (oculta SQLiteOpenHelper). O banco de dados do Room usa o DAO para realizar consultas ao banco de dados SQLite.

Banco de dados SQLite: armazenamento no dispositivo. A biblioteca de persistência Room cria e mantém esse banco de dados para você.

DAO: objeto de acesso a dados. Um mapeamento de consultas SQL para funções. Ao usar um DAO, você chama os métodos e o Room faz o resto.

Visão geral da arquitetura do RoomWordSample

O diagrama a seguir mostra como todas as partes do app interagem. Cada uma das caixas retangulares (exceto a do banco de dados SQLite) representa uma classe que você criará neste codelab.

a70aca8d4b737712.png

  1. Abra o Android Studio e clique em Start a new Android Studio project.
  2. Na janela Create New Project, escolha Empty Activity e clique em Next.
  3. Na tela seguinte, dê o nome RoomWordSample ao app e clique em Finish.

9b6cbaec81794071.png

Em seguida, será necessário adicionar as bibliotecas de componentes aos arquivos do Gradle.

  1. No Android Studio, clique na guia "Projects" e expanda a pasta "Gradle Scripts".

Abra build.gradle (Module: app).

  1. Aplique o plug-in do Kotlin para kapt processar anotações (link em inglês) adicionando-o após a seção de plug-ins definida na parte superior do arquivo build.gradle (Module: app).
apply plugin: 'kotlin-kapt'
  1. Adicione o bloco packagingOptions dentro do bloco android para excluir o módulo de funções atômicas (links em inglês) do pacote e evitar avisos.
  2. Algumas das APIs que usaremos exigem a versão 1.8 do jvmTarget. Portanto, adicione-a ao bloco android.
android {
    // other configuration (buildTypes, defaultConfig, etc.)

    packagingOptions {
        exclude 'META-INF/atomicfu.kotlin_module'
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }

}
  1. Substitua o bloco dependencies por:
dependencies {
    implementation "androidx.appcompat:appcompat:$rootProject.appCompatVersion"
    implementation "androidx.activity:activity-ktx:$rootProject.activityVersion"

    // Dependencies for working with Architecture components
    // You'll probably have to update the version numbers in build.gradle (Project)

    // Room components
    implementation "androidx.room:room-ktx:$rootProject.roomVersion"
    kapt "androidx.room:room-compiler:$rootProject.roomVersion"
    androidTestImplementation "androidx.room:room-testing:$rootProject.roomVersion"

    // Lifecycle components
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$rootProject.lifecycleVersion"
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:$rootProject.lifecycleVersion"
    implementation "androidx.lifecycle:lifecycle-common-java8:$rootProject.lifecycleVersion"

    // Kotlin components
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    api "org.jetbrains.kotlinx:kotlinx-coroutines-core:$rootProject.coroutines"
    api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$rootProject.coroutines"

    // UI
    implementation "androidx.constraintlayout:constraintlayout:$rootProject.constraintLayoutVersion"
    implementation "com.google.android.material:material:$rootProject.materialVersion"

    // Testing
    testImplementation "junit:junit:$rootProject.junitVersion"
    androidTestImplementation "androidx.arch.core:core-testing:$rootProject.coreTestingVersion"
    androidTestImplementation ("androidx.test.espresso:espresso-core:$rootProject.espressoVersion", {
        exclude group: 'com.android.support', module: 'support-annotations'
    })
    androidTestImplementation "androidx.test.ext:junit:$rootProject.androidxJunitVersion"
}

O Gradle poderá gerar avisos sobre versões ausentes ou não definidas neste momento. Isso será corrigido na próxima etapa.

  1. No seu arquivo build.gradle (Project: RoomWordsSample), adicione os números de versão ao final do arquivo, conforme mostrado no código abaixo.
ext {
    activityVersion = '1.1.0'
    appCompatVersion = '1.2.0'
    constraintLayoutVersion = '2.0.2'
    coreTestingVersion = '2.1.0'
    coroutines = '1.3.9'
    lifecycleVersion = '2.2.0'
    materialVersion = '1.2.1'
    roomVersion = '2.2.5'
    // testing
    junitVersion = '4.13.1'
    espressoVersion = '3.1.0'
    androidxJunitVersion = '1.1.2'
}

Os dados desse app são palavras. Você precisará de uma tabela simples para conter esses valores:

3821ac1a6cb01278.png

O Room permite criar tabelas usando uma Entidade. Faça isso agora.

  1. Crie um novo arquivo de classe do Kotlin com o nome Word contendo a classe de dados Word (link em inglês). Esta classe descreve a entidade (que representa a tabela SQLite) das palavras. Cada propriedade na classe representa uma coluna na tabela. A Room usará estas propriedades para criar a tabela e instanciar objetos de linhas no banco de dados.

Veja o código:

data class Word(val word: String)

Para tornar a classe Word significativa para um banco de dados do Room, é necessário criar uma associação entre a classe e o banco de dados usando anotações (link em inglês) do Kotlin. Você usará anotações específicas para identificar como cada parte desta classe se relaciona a uma entrada no banco de dados. O Room usa essas informações extras para gerar o código.

Se você mesmo digitar as anotações (em vez de colá-las), o Android Studio importará automaticamente as classes de anotação.

  1. Atualize sua classe Word com anotações conforme mostrado neste código:
@Entity(tableName = "word_table")
class Word(@PrimaryKey @ColumnInfo(name = "word") val word: String)

Vejamos o que essas anotações fazem:

  • @Entity(tableName = "word_table") Cada classe @Entity representa uma tabela SQLite. Anote a declaração da classe para indicar que é uma entidade. Você pode especificar o nome da tabela se quiser que seja diferente do nome da classe. Dessa forma, a tabela terá o nome "word_table".
  • @PrimaryKey Toda entidade precisa de uma chave primária. Para simplificar, cada palavra funciona como a própria chave primária.
  • @ColumnInfo(name = "word") Especifica o nome da coluna na tabela se você quiser que seja diferente do nome da variável de membro. Dessa forma, a coluna terá o nome "word".
  • Todas as propriedades armazenadas no banco de dados precisam ter visibilidade pública, que é o padrão do Kotlin.

Você pode ver uma lista completa de anotações na referência do resumo do pacote da Room.

O que é o DAO?

No DAO (objeto de acesso a dados), você especifica consultas SQL e as associa a chamadas de método. O compilador verifica o SQL e gera consultas usando as anotações de conveniência para consultas comuns, como @Insert. A Room usa o DAO para criar uma API limpa para seu código.

O DAO precisa ser uma interface ou uma classe abstrata.

Por padrão, todas as consultas precisam ser executadas em uma linha de execução separada.

A Room é compatível com corrotinas Kotlin (link em inglês). Isso permite anotar as consultas com o modificador suspend e depois chamá-las em uma corrotina ou em outra função de suspensão.

Implementar o DAO

Vamos criar um DAO que fornece consultas para:

  • ordenar todas as palavras em ordem alfabética;
  • inserir uma palavra;
  • excluir todas as palavras.
  1. Crie um novo arquivo de classe do Kotlin com o nome WordDao.
  2. Copie e cole o código a seguir no WordDao e corrija as importações conforme necessário para que ele seja compilado.
@Dao
interface WordDao {

    @Query("SELECT * FROM word_table ORDER BY word ASC")
    fun getAlphabetizedWords(): List<Word>

    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insert(word: Word)

    @Query("DELETE FROM word_table")
    suspend fun deleteAll()
}

Veja como fazer isso:

  • WordDao é uma interface. Os DAOs precisam ser interfaces ou classes abstratas.
  • A anotação @Dao a identifica como uma classe DAO para a Room.
  • suspend fun insert(word: Word): declara uma função de suspensão para inserir uma palavra.
  • A anotação @Insert é um método especial de DAO em que você não precisa fornecer nenhum SQL. Há também anotações @Delete e @Update para excluir e atualizar linhas, mas elas não serão usadas neste app.
  • onConflict = OnConflictStrategy.IGNORE: a estratégia onConflict selecionada ignora uma nova palavra se ela for exatamente igual à que já está na lista. Para saber mais sobre as estratégias de conflito disponíveis, confira a documentação.
  • suspend fun deleteAll(): declara uma função de suspensão para excluir todas as palavras.
  • Não há uma anotação de conveniência para excluir várias entidades. Por isso, a anotação genérica @Query é usada.
  • @Query("DELETE FROM word_table"): @Query requer que você forneça uma consulta SQL como um parâmetro de string à anotação, permitindo consultas de leitura complexas e outras operações.
  • fun getAlphabetizedWords(): List<Word>: um método para acessar todas as palavras e retornar uma List de Words.
  • @Query("SELECT * FROM word_table ORDER BY word ASC"): consulta que retorna uma lista de palavras classificadas em ordem crescente.

Quando os dados são modificados, convém realizar alguma ação, como exibir a atualização na IU. Isso significa que você precisa observar os dados para que possa reagir quando eles mudarem.

Para observar as mudanças nos dados, usaremos o fluxo (link em inglês) de kotlinx-coroutines. Use um valor de retorno do tipo Flow na descrição do método e o Room gerará todo o código necessário para atualizar o Flow quando o banco de dados for atualizado.

Em WordDao, mude a assinatura do método getAlphabetizedWords() para que o List<Word> retornado seja agrupado com Flow.

   @Query("SELECT * FROM word_table ORDER BY word ASC")
   fun getAlphabetizedWords(): Flow<List<Word>>

Mais adiante neste codelab, transformaremos o fluxo para LiveData no ViewModel. Nos aprofundaremos mais nesses componentes assim que começarmos a implementá-los.

O que é um banco de dados da Room**?**

  • A Room é uma camada do banco de dados de um banco de dados SQLite.
  • A Room cuida das tarefas de rotina que você costumava realizar com um SQLiteOpenHelper.
  • A Room usa o DAO para emitir consultas ao banco de dados.
  • Por padrão, para evitar mau desempenho da IU, a Room não permite que você faça consultas na linha de execução principal. Quando as consultas da Room retornam um Flow, elas são executadas automaticamente de maneira assíncrona em uma linha de execução em segundo plano.
  • A Room oferece verificações das instruções do SQLite durante o tempo de compilação.

Implementar o banco de dados da Room

A classe do banco de dados da Room precisa ser abstrata e estender RoomDatabase. Normalmente, você só precisa de uma instância de um banco de dados da Room para todo o app.

Vamos criar uma agora.

  1. Crie um arquivo de classe do Kotlin com o nome WordRoomDatabase e adicione este código a ele:
// Annotates class to be a Room Database with a table (entity) of the Word class
@Database(entities = arrayOf(Word::class), version = 1, exportSchema = false)
public abstract class WordRoomDatabase : RoomDatabase() {

   abstract fun wordDao(): WordDao

   companion object {
        // Singleton prevents multiple instances of database opening at the
        // same time.
        @Volatile
        private var INSTANCE: WordRoomDatabase? = null

        fun getDatabase(context: Context): WordRoomDatabase {
            // if the INSTANCE is not null, then return it,
            // if it is, then create the database
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                        context.applicationContext,
                        WordRoomDatabase::class.java,
                        "word_database"
                    ).build()
                INSTANCE = instance
                // return instance
                instance
            }
        }
   }
}

Vamos analisar o código:

  • A classe de banco de dados da Room precisa ser abstract e estender RoomDatabase..
  • Use @Database para anotar a classe como um banco de dados da Room e usar os parâmetros de anotação para declarar as entidades que pertencem ao banco de dados e definir o número da versão. Cada entidade corresponde a uma tabela que será criada no banco de dados. As migrações de banco de dados estão além do escopo deste codelab, portanto, exportSchema foi definido como falso para evitar um aviso de compilação. Em um app real, considere configurar um diretório para o Room usar e exportar o esquema para que você possa verificar o esquema atual no seu sistema de controle de versões.
  • O banco de dados expõe DAOs usando um método "getter" abstrato para cada @Dao.
  • Você definiu um singleton (link em inglês), WordRoomDatabase, para evitar que várias instâncias do banco de dados sejam abertas ao mesmo tempo.
  • getDatabase retorna o Singleton. Ele criará o banco de dados na primeira vez que for acessado, usando o builder do banco de dados da Room para criar um objeto RoomDatabase no contexto do aplicativo da classe WordRoomDatabase e o nomeará como "word_database".

O que é um repositório?

Uma classe de repositório abstrai o acesso a várias fontes de dados. O repositório não faz parte das bibliotecas dos Componentes da arquitetura, mas é uma prática recomendada para a separação e arquitetura do código. Uma classe de repositório fornece uma API limpa para acesso aos dados no restante do aplicativo.

cdfae5b9b10da57f.png

Por que usar um repositório?

Um repositório gerencia consultas e permite usar vários back-ends. No exemplo mais comum, o repositório implementa a lógica para decidir se precisa buscar dados de uma rede ou usar resultados armazenados em cache em um banco de dados local.

Como implementar o repositório

Crie um arquivo de classe do Kotlin chamado WordRepository e cole o código a seguir nele:

// Declares the DAO as a private property in the constructor. Pass in the DAO
// instead of the whole database, because you only need access to the DAO
class WordRepository(private val wordDao: WordDao) {

    // Room executes all queries on a separate thread.
    // Observed Flow will notify the observer when the data has changed.
    val allWords: Flow<List<Word>> = wordDao.getAlphabetizedWords()

    // By default Room runs suspend queries off the main thread, therefore, we don't need to
    // implement anything else to ensure we're not doing long running database work
    // off the main thread.
    @Suppress("RedundantSuspendModifier")
    @WorkerThread
    suspend fun insert(word: Word) {
        wordDao.insert(word)
    }
}

Veja as principais vantagens:

  • O DAO é transmitido ao construtor do repositório, e não ao banco de dados inteiro. Isso ocorre porque só é necessário acessar o DAO, já que ele contém todos os métodos de leitura/gravação do banco de dados. Não é necessário expor todo o banco de dados ao repositório.
  • A lista de palavras é uma propriedade pública. Ela é inicializada com a lista de palavras do Flow do Room. É possível fazer isso devido à forma como você definiu o método getAlphabetizedWords para retornar um Flow na etapa "Como observar mudanças no banco de dados". O Room executa todas as consultas em uma linha de execução separada.
  • O modificador suspend informa ao compilador que ele precisa ser chamado por uma corrotina ou outra função de suspensão.
  • A Room executa consultas de suspensão fora da linha de execução principal.

O que é um ViewModel?

O papel de um ViewModel é fornecer dados à IU e sobreviver às mudanças de configuração. Um ViewModel é um centro de comunicação entre o repositório e a IU. Também é possível usar um ViewModel para compartilhar dados entre fragmentos. O ViewModel faz parte da biblioteca do ciclo de vida.

72848dfccfe5777b.png

Para ver um guia introdutório sobre esse assunto, consulte ViewModel Overview ou a postagem do blog ViewModels: um exemplo simples (em inglês).

Por que usar um ViewModel?

Um ViewModel armazena os dados da IU do app de uma maneira que considera o ciclo de vida para sobreviver a mudanças na configuração. Separar os dados da IU do seu app das classes Activity e Fragment permite que você siga melhor o princípio de responsabilidade exclusiva: suas atividades e fragmentos são responsáveis por desenhar dados na tela, enquanto o ViewModel pode cuidar do armazenamento e processamento de todos os dados necessários para a IU.

LiveData e ViewModel

LiveData é um armazenador de dados observáveis. Você pode receber notificações sempre que os dados mudarem. Diferente do fluxo, o LiveData é compatível com o ciclo de vida, o que significa que ele respeitará o ciclo de vida de outros componentes, como atividades ou fragmentos. O LiveData interrompe ou retoma a observação automaticamente, dependendo do ciclo de vida do componente que detecta mudanças. Isso torna o LiveData o componente perfeito para ser usado em dados sujeitos a mudanças que a IU usará ou exibirá.

O ViewModel transformará os dados do repositório, de fluxo para LiveData, expondo a lista de palavras como LiveData na IU. Isso garante que a IU será atualizada automaticamente sempre que os dados forem alterados no banco de dados.

viewModelScope

Em Kotlin, todas as corrotinas são executadas em um CoroutineScope (link em inglês). Um escopo controla o ciclo de vida das corrotinas com o job. Quando você cancelar o job de um escopo, todas as corrotinas iniciadas nesse escopo serão canceladas.

A biblioteca lifecycle-viewmodel-ktx do AndroidX adiciona uma viewModelScope como uma função de extensão da classe ViewModel, permitindo usar escopos.

Para saber mais sobre como trabalhar com corrotinas no ViewModel, confira a Etapa 5 do codelab Como usar corrotinas Kotlin no seu app Android ou a postagem do blog Corrotinas fáceis no Android: viewModelScope (em inglês).

Implementar o ViewModel

Crie um arquivo de classe do Kotlin para o WordViewModel e adicione este código a ele:

class WordViewModel(private val repository: WordRepository) : ViewModel() {

    // Using LiveData and caching what allWords returns has several benefits:
    // - We can put an observer on the data (instead of polling for changes) and only update the
    //   the UI when the data actually changes.
    // - Repository is completely separated from the UI through the ViewModel.
    val allWords: LiveData<List<Word>> = repository.allWords.asLiveData()

    /**
     * Launching a new coroutine to insert the data in a non-blocking way
     */
    fun insert(word: Word) = viewModelScope.launch {
        repository.insert(word)
    }
}

class WordViewModelFactory(private val repository: WordRepository) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(WordViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return WordViewModel(repository) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

Vejamos o que esse código faz. Ele:

  • criou uma classe com o nome WordViewModel que recebe o WordRepository como parâmetro e estende ViewModel. O repositório é a única dependência necessária para o ViewModel. Se outras classes fossem necessárias, elas também seriam transmitidas no construtor;
  • adicionou uma variável de membro LiveData pública para armazenar a lista de palavras em cache;
  • inicializou o LiveData com o fluxo allWords do repositório. Em seguida, converteu o fluxo para LiveData, chamando asLiveData().;
  • criou um método wrapper insert() que chama o método insert() do repositório. Dessa forma, a implementação de insert() é encapsulada na IU. Estamos inicializando uma nova corrotina e chamando a inserção do repositório, que é uma função de suspensão. Como mencionado, os ViewModels têm um escopo de corrotina baseado no ciclo de vida conhecido como viewModelScope, que você usará aqui;
  • criou o ViewModel e implementou uma ViewModelProvider.Factory que tem como um parâmetro as dependências necessárias para criar WordViewModel: o WordRepository.

Ao usar viewModels e ViewModelProvider.Factory, o framework cuidará do ciclo de vida do ViewModel. Ele sobreviverá a mudanças de configuração e, mesmo que a atividade seja recriada, você sempre receberá a instância correta da classe WordViewModel.

Em seguida, você precisará adicionar o layout XML à lista e aos itens.

Este codelab presume que você está familiarizado com a criação de layouts em XML, então estamos apenas fornecendo o código.

Faça com que o material do tema do seu aplicativo defina o AppTheme pai como Theme.MaterialComponents.Light.DarkActionBar. Adicione um estilo aos itens da lista em values/styles.xml:

<resources>

    <!-- Base application theme. -->
    <style name="AppTheme" parent="Theme.MaterialComponents.Light.DarkActionBar">
        <!-- Customize your theme here. -->
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
    </style>

    <!-- The default font for RecyclerView items is too small.
    The margin is a simple delimiter between the words. -->
    <style name="word_title">
        <item name="android:layout_marginBottom">8dp</item>
        <item name="android:paddingLeft">8dp</item>
        <item name="android:background">@android:color/holo_orange_light</item>
        <item name="android:textAppearance">@android:style/TextAppearance.Large</item>
    </style>
</resources>

Crie um novo arquivo de recursos de dimensão:

  1. Clique no módulo de app na janela Project.
  2. Selecione File > New > Android Resource File.
  3. Nos qualificadores disponíveis, selecione Dimension.
  4. Dê o nome "dimens" ao arquivo.

aa5895240838057.png

Adicione estes recursos de dimensão a values/dimens.xml:

<dimen name="big_padding">16dp</dimen>

Adicione um layout layout/recyclerview_item.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
        android:id="@+id/textView"
        style="@style/word_title"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@android:color/holo_orange_light" />
</LinearLayout>

Em layout/activity_main.xml, substitua a TextView por uma RecyclerView e adicione um botão de ação flutuante (FAB). Agora, seu layout ficará assim:

<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">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerview"
        android:layout_width="0dp"
        android:layout_height="0dp"
        tools:listitem="@layout/recyclerview_item"
        android:padding="@dimen/big_padding"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fab"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        android:contentDescription="@string/add_word"/>

</androidx.constraintlayout.widget.ConstraintLayout>

A aparência do FAB corresponderá à ação disponível. Por isso, substitua o ícone por um símbolo de "+".

Primeiro, precisamos adicionar um novo recurso de vetor:

  1. Selecione File > New > Vector Asset.
  2. Clique no ícone do robô do Android no campo Clip Art:. 8d935457de8e7a46.png
  3. Pesquise "add" e selecione o recurso "+". Clique em OK. 758befc99c8cc794.png
  4. Na janela do Asset Studio, clique em Next. 672248bada3cfb25.png
  5. Confirme se o caminho do ícone é main > drawable e clique em Finish para adicionar o recurso. ef118084f96c6176.png
  6. Ainda em layout/activity_main.xml, atualize o FAB para incluir o novo drawable:
<com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fab"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="16dp"
        android:contentDescription="@string/add_word"
        android:src="@drawable/ic_add_black_24dp"/>

Você exibirá os dados em uma RecyclerView, que é um pouco melhor do que simplesmente gerar os dados em uma TextView. Este codelab considera que você sabe como RecyclerView, RecyclerView.ViewHolder e ListAdapter funcionam.

Você precisará criar:

  • a classe WordListAdapter que estende o ListAdapter;
  • uma classe DiffUtil.ItemCallback aninhada como parte do WordListAdapter.;
  • o ViewHolder que exibirá cada palavra na nossa lista.

Veja o código:

class WordListAdapter : ListAdapter<Word, WordViewHolder>(WordsComparator()) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WordViewHolder {
        return WordViewHolder.create(parent)
    }

    override fun onBindViewHolder(holder: WordViewHolder, position: Int) {
        val current = getItem(position)
        holder.bind(current.word)
    }

    class WordViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        private val wordItemView: TextView = itemView.findViewById(R.id.textView)

        fun bind(text: String?) {
            wordItemView.text = text
        }

        companion object {
            fun create(parent: ViewGroup): WordViewHolder {
                val view: View = LayoutInflater.from(parent.context)
                    .inflate(R.layout.recyclerview_item, parent, false)
                return WordViewHolder(view)
            }
        }
    }

    class WordsComparator : DiffUtil.ItemCallback<Word>() {
        override fun areItemsTheSame(oldItem: Word, newItem: Word): Boolean {
            return oldItem === newItem
        }

        override fun areContentsTheSame(oldItem: Word, newItem: Word): Boolean {
            return oldItem.word == newItem.word
        }
    }
}

Temos aqui:

  • A classe WordViewHolder, que nos permite vincular um texto a uma TextView. A classe expõe uma função estática create() para inflar o layout.
  • O WordsComparator, que define como calcular se duas palavras são iguais ou se o conteúdo é o mesmo.
  • O WordListAdapter, que cria o WordViewHolder em onCreateViewHolder e o vincula a onBindViewHolder.

Adicione a RecyclerView ao método onCreate() da MainActivity.

No método onCreate() após setContentView:

   val recyclerView = findViewById<RecyclerView>(R.id.recyclerview)
   val adapter = WordListAdapter()
   recyclerView.adapter = adapter
   recyclerView.layoutManager = LinearLayoutManager(this)

Execute o app para verificar se tudo está funcionando. Não haverá itens, porque os dados ainda não foram coletados.

79cb875d4296afce.png

Você quer ter apenas uma instância do banco de dados e do repositório no seu app. Uma maneira fácil de fazer isso é criá-los como membros da classe Application. Depois, eles serão acessados pelo aplicativo sempre que necessário, em vez de serem criados toda vez.

Crie uma nova classe com o nome WordsApplication que estenda Application. O código fica assim:

class WordsApplication : Application() {
    // Using by lazy so the database and the repository are only created when they're needed
    // rather than when the application starts
    val database by lazy { WordRoomDatabase.getDatabase(this) }
    val repository by lazy { WordRepository(database.wordDao()) }
}

Veja o que você fez até aqui:

  • Criou uma instância de banco de dados.
  • Criou uma instância de repositório com base no DAO do banco de dados.
  • Como esses objetos só serão criados na primeira vez que forem necessários, e não durante a inicialização do app, estamos usando a delegação de propriedades do Kotlin: by lazy. (link em inglês).

Agora que você criou a classe do aplicativo, atualize o arquivo AndroidManifest e defina WordsApplication como application android:name.

Veja como ficará a tag do aplicativo:

<application
        android:name=".WordsApplication"
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">
...

No momento, o banco de dados está vazio. Você adicionará os dados de duas maneiras: alguns quando o banco de dados for criado e outros com uma Activity para adicionar palavras.

Para excluir todo o conteúdo e preencher novamente o banco de dados sempre que o app for criado, crie um RoomDatabase.Callback e substitua o método onCreate(). Como não é possível realizar operações de banco de dados do Room na linha de execução de IU, onCreate() inicia uma corrotina no agente de E/S.

Para iniciar uma corrotina, um CoroutineScope é necessário. Atualize o método getDatabase da classe WordRoomDatabase para receber também um escopo de corrotina como parâmetro:

fun getDatabase(
       context: Context,
       scope: CoroutineScope
  ): WordRoomDatabase {
...
}

O preenchimento do banco de dados não está relacionado ao ciclo de vida da IU. Portanto, não use um CoroutineScope como o viewModelScope, porque ele está relacionado ao ciclo de vida do app. Você atualizará o WordsApplication para conter um applicationScope e, depois, transmitir esse valor para o WordRoomDatabase.getDatabase.

class WordsApplication : Application() {
    // No need to cancel this scope as it'll be torn down with the process
    val applicationScope = CoroutineScope(SupervisorJob())

    // Using by lazy so the database and the repository are only created when they're needed
    // rather than when the application starts
    val database by lazy { WordRoomDatabase.getDatabase(this, applicationScope) }
    val repository by lazy { WordRepository(database.wordDao()) }
}

No WordRoomDatabase, você criará uma implementação personalizada de RoomDatabase.Callback(), que também recebe um CoroutineScope como um parâmetro construtor. Em seguida, substitua o método onOpen para preencher o banco de dados.

Veja o código para criar o callback na classe WordRoomDatabase:

private class WordDatabaseCallback(
    private val scope: CoroutineScope
) : RoomDatabase.Callback() {

    override fun onCreate(db: SupportSQLiteDatabase) {
        super.onCreate(db)
        INSTANCE?.let { database ->
            scope.launch {
                populateDatabase(database.wordDao())
            }
        }
    }

    suspend fun populateDatabase(wordDao: WordDao) {
        // Delete all content here.
        wordDao.deleteAll()

        // Add sample words.
        var word = Word("Hello")
        wordDao.insert(word)
        word = Word("World!")
        wordDao.insert(word)

        // TODO: Add your own words!
    }
}

Por fim, adicione o callback à sequência de compilação do banco de dados antes de chamar .build() no Room.databaseBuilder():

.addCallback(WordDatabaseCallback(scope))

O código final ficará assim:

@Database(entities = arrayOf(Word::class), version = 1, exportSchema = false)
abstract class WordRoomDatabase : RoomDatabase() {

   abstract fun wordDao(): WordDao

   private class WordDatabaseCallback(
       private val scope: CoroutineScope
   ) : RoomDatabase.Callback() {

       override fun onCreate(db: SupportSQLiteDatabase) {
           super.onCreate(db)
           INSTANCE?.let { database ->
               scope.launch {
                   var wordDao = database.wordDao()

                   // Delete all content here.
                   wordDao.deleteAll()

                   // Add sample words.
                   var word = Word("Hello")
                   wordDao.insert(word)
                   word = Word("World!")
                   wordDao.insert(word)

                   // TODO: Add your own words!
                   word = Word("TODO!")
                   wordDao.insert(word)
               }
           }
       }
   }

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

       fun getDatabase(
           context: Context,
           scope: CoroutineScope
       ): WordRoomDatabase {
            // if the INSTANCE is not null, then return it,
            // if it is, then create the database
            return INSTANCE ?: synchronized(this) {
                val instance = Room.databaseBuilder(
                        context.applicationContext,
                        WordRoomDatabase::class.java,
                        "word_database"
                )
                 .addCallback(WordDatabaseCallback(scope))
                 .build()
                INSTANCE = instance
                // return instance
                instance
        }
     }
   }
}

Adicione estes recursos de string em values/strings.xml:

<string name="hint_word">Word...</string>
<string name="button_save">Save</string>
<string name="empty_not_saved">Word not saved because it is empty.</string>
<string name="add_word">Add word</string>

Adicione este recurso de cor em value/colors.xml:

<color name="buttonLabel">#FFFFFF</color>

Adicione um recurso de dimensão min_height em values/dimens.xml:

<dimen name="min_height">48dp</dimen>

Crie uma nova Activity vazia do Android usando o modelo Empty Activity:

  1. Selecione File > New > Activity > Empty Activity.
  2. Insira NewWordActivity como o nome da atividade.
  3. Verifique se a nova atividade foi adicionada ao manifesto do Android.
<activity android:name=".NewWordActivity"></activity>

Atualize o arquivo activity_new_word.xml na pasta do layout com o seguinte código:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <EditText
        android:id="@+id/edit_word"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:minHeight="@dimen/min_height"
        android:fontFamily="sans-serif-light"
        android:hint="@string/hint_word"
        android:inputType="textAutoComplete"
        android:layout_margin="@dimen/big_padding"
        android:textSize="18sp" />

    <Button
        android:id="@+id/button_save"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/colorPrimary"
        android:text="@string/button_save"
        android:layout_margin="@dimen/big_padding"
        android:textColor="@color/buttonLabel" />

</LinearLayout>

Atualize o código da atividade:

class NewWordActivity : AppCompatActivity() {

    private lateinit var editWordView: EditText

    public override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_new_word)
        editWordView = findViewById(R.id.edit_word)

        val button = findViewById<Button>(R.id.button_save)
        button.setOnClickListener {
            val replyIntent = Intent()
            if (TextUtils.isEmpty(editWordView.text)) {
                setResult(Activity.RESULT_CANCELED, replyIntent)
            } else {
                val word = editWordView.text.toString()
                replyIntent.putExtra(EXTRA_REPLY, word)
                setResult(Activity.RESULT_OK, replyIntent)
            }
            finish()
        }
    }

    companion object {
        const val EXTRA_REPLY = "com.example.android.wordlistsql.REPLY"
    }
}

A etapa final é conectar a IU ao banco de dados salvando novas palavras inseridas pelo usuário e exibindo o conteúdo atual do banco de dados de palavras na RecyclerView.

Para exibir o conteúdo atual do banco de dados, adicione um observador para observar LiveData no ViewModel.

Sempre que os dados são modificados, o callback onChanged() é invocado e chama o método setWords() do adaptador para atualizar os dados em cache e a lista exibida.

Crie o ViewModel na MainActivity:

private val wordViewModel: WordViewModel by viewModels {
    WordViewModelFactory((application as WordsApplication).repository)
}

Para criar o ViewModel usamos o delegado viewModels, transmitindo uma instância de WordViewModelFactory. Ela é construída com base no repositório recebido do WordsApplication.

Também em onCreate(), adicione um observador para a propriedade LiveData allWords do WordViewModel.

O método onChanged(), padrão da nossa Lambda, é acionado quando os dados observados mudam e a atividade está em primeiro plano:

wordViewModel.allWords.observe(this, Observer { words ->
            // Update the cached copy of the words in the adapter.
            words?.let { adapter.submitList(it) }
})

Queremos abrir a NewWordActivity ao tocar no FAB e, quando voltarmos à MainActivity, para inserir a nova palavra no banco de dados ou exibir um Toast.

Para fazer isso, comece definindo um código de solicitação:

private val newWordActivityRequestCode = 1

Na MainActivity, adicione o código onActivityResult() para a NewWordActivity.

Se a atividade retornar com RESULT_OK, insira a palavra retornada no banco de dados chamando o método insert() do WordViewModel:

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)

    if (requestCode == newWordActivityRequestCode && resultCode == Activity.RESULT_OK) {
        data?.getStringExtra(NewWordActivity.EXTRA_REPLY)?.let {
            val word = Word(it)
            wordViewModel.insert(word)
        }
    } else {
        Toast.makeText(
            applicationContext,
            R.string.empty_not_saved,
            Toast.LENGTH_LONG).show()
    }
}

Na MainActivity, inicie NewWordActivity quando o usuário tocar no FAB. No onCreate da MainActivity, localize o FAB e adicione um onClickListener com este código:

val fab = findViewById<FloatingActionButton>(R.id.fab)
fab.setOnClickListener {
  val intent = Intent(this@MainActivity, NewWordActivity::class.java)
  startActivityForResult(intent, newWordActivityRequestCode)
}

O código finalizado ficará assim:

class MainActivity : AppCompatActivity() {

    private val newWordActivityRequestCode = 1
    private val wordViewModel: WordViewModel by viewModels {
        WordViewModelFactory((application as WordsApplication).repository)
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val recyclerView = findViewById<RecyclerView>(R.id.recyclerview)
        val adapter = WordListAdapter()
        recyclerView.adapter = adapter
        recyclerView.layoutManager = LinearLayoutManager(this)

        // Add an observer on the LiveData returned by getAlphabetizedWords.
        // The onChanged() method fires when the observed data changes and the activity is
        // in the foreground.
        wordViewModel.allWords.observe(owner = this) { words ->
            // Update the cached copy of the words in the adapter.
            words.let { adapter.submitList(it) }
        }

        val fab = findViewById<FloatingActionButton>(R.id.fab)
        fab.setOnClickListener {
            val intent = Intent(this@MainActivity, NewWordActivity::class.java)
            startActivityForResult(intent, newWordActivityRequestCode)
        }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, intentData: Intent?) {
        super.onActivityResult(requestCode, resultCode, intentData)

        if (requestCode == newWordActivityRequestCode && resultCode == Activity.RESULT_OK) {
            intentData?.getStringExtra(NewWordActivity.EXTRA_REPLY)?.let { reply ->
                val word = Word(reply)
                wordViewModel.insert(word)
            }
        } else {
            Toast.makeText(
                applicationContext,
                R.string.empty_not_saved,
                Toast.LENGTH_LONG
            ).show()
        }
    }
}

Agora, execute seu app. Quando você adicionar uma palavra ao banco de dados em NewWordActivity, a IU será atualizada automaticamente.

Agora que temos um app em funcionamento, vamos recapitular o que foi criado. Veja a estrutura do app novamente:

a70aca8d4b737712.png

Os componentes do app são os seguintes:

  • MainActivity: exibe palavras em uma lista usando uma RecyclerView e o WordListAdapter. Na MainActivity, há um Observer que observa as palavras do banco de dados e é notificado quando elas mudam.
  • NewWordActivity: adiciona uma nova palavra à lista.
  • WordViewModel: fornece métodos para acessar a camada de dados e retorna LiveData para que a MainActivity possa configurar o relacionamento do observador.*
  • LiveData<List<Word>>: possibilita atualizações automáticas nos componentes da IU. É possível converter o Flow para LiveData chamando o método flow.toLiveData().
  • Repository: gerencia uma ou mais fontes de dados. O Repository expõe métodos para que o ViewModel interaja com o provedor de dados. Neste app, esse back-end é um banco de dados da Room.
  • Room: é um wrapper e implementa um banco de dados SQLite. A Room realiza muitas tarefas por você.
  • DAO: mapeia chamadas de método para consultas ao banco de dados, de modo que, quando o repositório chamar um método, como getAlphabetizedWords(), o Room poderá executar SELECT * FROM word_table ORDER BY word ASC.
  • O DAO pode expor consultas suspend para solicitações únicas e consultas do Flow quando quiser receber notificações de mudanças no banco de dados.
  • Word: é a classe de entidade que contém uma única palavra.
  • Views e Activities (e Fragments) somente interagem com os dados usando o ViewModel. Dessa forma, não importa de onde vêm os dados.

Fluxo de dados para atualizações automáticas da IU (IU reativa)

A atualização automática é possível porque você está usando o LiveData. Na MainActivity, há um Observer que observa o LiveData das palavras do banco de dados e é notificado quando elas mudam. Quando há uma mudança, o método onChange() do observador é executado e atualiza mWords no WordListAdapter.

Os dados podem ser observados porque são LiveData. LiveData<List<Word>> é o resultado observado retornado pela propriedade allWords do WordViewModel.

O WordViewModel oculta tudo sobre o back-end da camada de IU. Ele fornece métodos para acessar a camada de dados e retorna LiveData para que MainActivity possa configurar a relação do observador. Views e Activities (e Fragments) somente interagem com os dados usando o ViewModel. Dessa forma, não importa de onde vêm os dados.

Nesse caso, eles são provenientes de um Repository. O ViewModel não precisa saber com o que o repositório interage. Ele só precisa saber como interagir com o Repository, usando os métodos expostos pelo Repository.

O repositório gerencia uma ou mais fontes de dados. No app WordListSample, esse back-end é um banco de dados da Room. A Room é um wrapper e implementa um banco de dados SQLite. A Room realiza muitas tarefas por você. Por exemplo, o Room faz tudo o que você precisava fazer com uma classe SQLiteOpenHelper.

O DAO mapeia chamadas de método para consultas do banco de dados, de modo que, quando o repositório chamar um método como getAllWords(), o Room poderá executar SELECT * FROM word_table ORDER BY word ASC

.

Como o resultado retornado da consulta é observado em LiveData, toda vez que os dados no Room mudam, o método onChanged() da interface Observer é executado e a IU é atualizada.

Opcional: fazer o download do código da solução

Consulte o código da solução deste codelab, caso ainda não tenha feito isso. Você pode analisar o repositório do GitHub (link em inglês) ou fazer o download do código aqui:

Faça o download do código-fonte

Descompacte o arquivo ZIP transferido por download. Isso descompactará uma pasta raiz, android-room-with-a-view-kotlin, que contém o app completo.