Android Room with a View - Kotlin

Lo scopo di Architecture Components è fornire indicazioni sull'architettura delle app, con librerie per attività comuni come la gestione del ciclo di vita e la persistenza dei dati. I componenti dell'architettura ti aiutano a strutturare l'app in modo robusto, testabile e gestibile con meno codice boilerplate. Le librerie dei componenti dell'architettura fanno parte di Android Jetpack.

Questa è la versione Kotlin del codelab. La versione nel linguaggio di programmazione Java è disponibile qui.

Se riscontri problemi (bug del codice, errori grammaticali, formulazione poco chiara e così via) mentre segui questo codelab, segnalali tramite il link Segnala un errore nell'angolo in basso a sinistra del codelab.

Prerequisiti

Devi avere familiarità con Kotlin, i concetti di progettazione orientata agli oggetti e i fondamenti dello sviluppo per Android, in particolare:

Inoltre, è utile conoscere i pattern architetturali del software che separano i dati dall'interfaccia utente, come MVP o MVC. Questo codelab implementa l'architettura definita nella Guida all'architettura dell'app.

Questo codelab è incentrato sui componenti dell'architettura Android. Concetti e codice non pertinenti sono forniti solo per operazioni di copia e incolla.

Se non hai familiarità con Kotlin, una versione di questo codelab è disponibile nel linguaggio di programmazione Java qui.

Attività previste

In questo codelab imparerai a progettare e creare un'app utilizzando i componenti dell'architettura Room, ViewModel e LiveData e a creare un'app che esegue le seguenti operazioni:

  • Implementa la nostra architettura consigliata utilizzando i componenti dell'architettura Android.
  • Funziona con un database per ottenere e salvare i dati e precompila il database con alcune parole.
  • Mostra tutte le parole in un RecyclerView in MainActivity.
  • Apre una seconda attività quando l'utente tocca il pulsante +. Quando l'utente inserisce una parola, la aggiunge al database e all'elenco.

L'app è semplice, ma sufficientemente complessa da poter essere utilizzata come modello su cui basarsi. Ecco un'anteprima:

Che cosa ti serve

Questo codelab fornisce tutto il codice necessario per creare l'app completa.

L'utilizzo dei componenti dell'architettura e l'implementazione dell'architettura consigliata richiedono molti passaggi. La cosa più importante è creare un modello mentale di ciò che sta succedendo, capire come si incastrano i pezzi e come fluiscono i dati. Mentre segui questo codelab, non limitarti a copiare e incollare il codice, ma cerca di sviluppare una comprensione più approfondita.

Per introdurre la terminologia, ecco una breve introduzione ai componenti dell'architettura e al loro funzionamento congiunto. Tieni presente che questo codelab si concentra su un sottoinsieme dei componenti, ovvero LiveData, ViewModel e Room. Ogni componente viene spiegato man mano che lo utilizzi.

Questo diagramma mostra una forma base dell'architettura:

Entità: classe annotata che descrive una tabella di database quando utilizzi Room.

Database SQLite: spazio di archiviazione sul dispositivo. La libreria di persistenza Room crea e gestisce questo database per te.

DAO: Data Access Object. Una mappatura delle query SQL alle funzioni. Quando utilizzi un DAO, chiami i metodi e Room si occupa del resto.

Database Room: semplifica il lavoro con i database e funge da punto di accesso al database SQLite sottostante (nasconde SQLiteOpenHelper). Il database Room utilizza il DAO per inviare query al database SQLite.

Repository:una classe che crei e che viene utilizzata principalmente per gestire più origini dati.

ViewModel: funge da centro di comunicazione tra il repository (dati) e la UI. L'interfaccia utente non deve più preoccuparsi dell'origine dei dati. Le istanze ViewModel sopravvivono alla ricreazione di Activity/Fragment.

LiveData: una classe di contenitore di dati che può essere osservata. Contiene/memorizza sempre nella cache l'ultima versione dei dati e invia una notifica ai suoi osservatori quando i dati cambiano. LiveData è consapevole del ciclo di vita. I componenti dell'interfaccia utente osservano solo i dati pertinenti e non interrompono o riprendono l'osservazione. LiveData gestisce automaticamente tutto questo perché è a conoscenza delle modifiche dello stato del ciclo di vita pertinenti durante l'osservazione.

Panoramica dell'architettura di RoomWordSample

Il seguente diagramma mostra tutti i componenti dell'app. Ogni riquadro contenitore (ad eccezione del database SQLite) rappresenta una classe che creerai.

  1. Apri Android Studio e fai clic su Avvia un nuovo progetto Android Studio.
  2. Nella finestra Crea nuovo progetto, scegli Attività vuota e fai clic su Avanti.
  3. Nella schermata successiva, assegna all'app il nome RoomWordSample e fai clic su Fine.

Successivamente, dovrai aggiungere le librerie dei componenti ai file Gradle.

  1. In Android Studio, fai clic sulla scheda Progetti ed espandi la cartella Gradle Scripts.

Apri build.gradle (Modulo: app).

  1. Applica il plug-in Kotlin Annotation Processor kapt aggiungendolo dopo gli altri plug-in definiti nella parte superiore del file build.gradle (Modulo: app).
apply plugin: 'kotlin-kapt'
  1. Aggiungi il blocco packagingOptions all'interno del blocco android per escludere il modulo delle funzioni atomiche dal pacchetto ed evitare avvisi.
android {
    // other configuration (buildTypes, defaultConfig, etc.)

    packagingOptions {
        exclude 'META-INF/atomicfu.kotlin_module'
    }
}
  1. Aggiungi il seguente codice alla fine del blocco dependencies.
// Room components
implementation "androidx.room:room-runtime:$rootProject.roomVersion"
kapt "androidx.room:room-compiler:$rootProject.roomVersion"
androidTestImplementation "androidx.room:room-testing:$rootProject.roomVersion"

// Lifecycle components
implementation "androidx.lifecycle:lifecycle-extensions:$rootProject.archLifecycleVersion"
kapt "androidx.lifecycle:lifecycle-compiler:$rootProject.archLifecycleVersion"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$rootProject.archLifecycleVersion"

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

// Material design
implementation "com.google.android.material:material:$rootProject.materialVersion"

// Testing
testImplementation 'junit:junit:4.12'
androidTestImplementation "androidx.arch.core:core-testing:$rootProject.coreTestingVersion"
  1. Nel file build.gradle (Project: RoomWordsSample), aggiungi i numeri di versione alla fine del file, come indicato nel codice riportato di seguito.
ext {
    roomVersion = '2.2.5'
    archLifecycleVersion = '2.2.0'
    coreTestingVersion = '2.1.0'
    materialVersion = '1.1.0'
    coroutines = '1.3.4'
}

I dati per questa app sono parole e avrai bisogno di una semplice tabella per contenere questi valori:

Room ti consente di creare tabelle tramite un'entità. Facciamolo subito.

  1. Crea un nuovo file di classe Kotlin denominato Word contenente la classe di dati Word.
    Questa classe descriverà l'entità (che rappresenta la tabella SQLite) per le tue parole. Ogni proprietà della classe rappresenta una colonna della tabella. Room utilizzerà queste proprietà per creare la tabella e istanziare gli oggetti dalle righe del database.

Ecco il codice:

data class Word(val word: String)

Per rendere significativa la classe Word per un database Room, devi annotarla. Le annotazioni identificano la relazione tra ogni parte di questa classe e una voce nel database. Room utilizza queste informazioni per generare il codice.

Se digiti le annotazioni manualmente (anziché incollarle), Android Studio importerà automaticamente le classi di annotazione.

  1. Aggiorna la classe Word con le annotazioni mostrate in questo codice:
@Entity(tableName = "word_table")
class Word(@PrimaryKey @ColumnInfo(name = "word") val word: String)

Vediamo cosa fanno queste annotazioni:

  • @Entity(tableName = "word_table")
    Ogni classe @Entity rappresenta una tabella SQLite. Annota la dichiarazione della classe per indicare che si tratta di un'entità. Puoi specificare il nome della tabella se vuoi che sia diverso dal nome della classe. In questo modo la tabella viene denominata "word_table".
  • @PrimaryKey
    Ogni entità deve avere una chiave primaria. Per semplicità, ogni parola funge da chiave primaria.
  • @ColumnInfo(name = "word")
    Specifica il nome della colonna nella tabella se vuoi che sia diverso dal nome della variabile membro. In questo modo, la colonna viene denominata "word".
  • Ogni proprietà archiviata nel database deve avere visibilità pubblica, che è l'impostazione predefinita di Kotlin.

Puoi trovare un elenco completo delle annotazioni nel Riferimento al riepilogo del pacchetto della stanza.

Che cos'è la DAO?

Nell'DAO, specifica le query SQL e le associa alle chiamate di metodi. Il compilatore controlla l'SQL e genera query dalle annotazioni di convenienza per le query comuni, ad esempio @Insert. Room utilizza il DAO per creare un'API pulita per il tuo codice.

Il DAO deve essere un'interfaccia o una classe astratta.

Per impostazione predefinita, tutte le query devono essere eseguite su un thread separato.

Room supporta le coroutine, consentendo di annotare le query con il modificatore suspend e di chiamarle da una coroutine o da un'altra funzione di sospensione.

Implementare l'oggetto di accesso ai dati

Scriviamo un DAO che fornisca query per:

  • Ottenere tutte le parole in ordine alfabetico
  • Inserire una parola
  • Eliminazione di tutte le parole
  1. Crea un nuovo file di classe Kotlin denominato WordDao.
  2. Copia e incolla il seguente codice in WordDao e correggi le importazioni in base alle necessità per compilarlo.
@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()
}

Vediamo come fare:

  • WordDao è un'interfaccia; i DAO devono essere interfacce o classi astratte.
  • L'annotazione @Dao lo identifica come classe DAO per Room.
  • suspend fun insert(word: Word) : dichiara una funzione di sospensione per inserire una parola.
  • L'annotazione @Insert è un'annotazione speciale del metodo DAO in cui non devi fornire alcun SQL. Esistono anche annotazioni @Delete e @Update per l'eliminazione e l'aggiornamento delle righe, ma non le utilizzi in questa app.
  • onConflict = OnConflictStrategy.IGNORE: la strategia onConflict selezionata ignora una nuova parola se è esattamente uguale a una già presente nell'elenco. Per saperne di più sulle strategie di conflitto disponibili, consulta la documentazione.
  • suspend fun deleteAll(): dichiara una funzione di sospensione per eliminare tutte le parole.
  • Non esiste un'annotazione di convenienza per l'eliminazione di più entità, quindi viene annotata con il generico @Query.
  • @Query("DELETE FROM word_table"): @Query richiede di fornire una query SQL come parametro stringa all'annotazione, consentendo query di lettura complesse e altre operazioni.
  • fun getAlphabetizedWords(): List<Word>: un metodo per ottenere tutte le parole e restituire un List di Words.
  • @Query("SELECT * from word_table ORDER BY word ASC"): query che restituisce un elenco di parole ordinate in ordine crescente.

Quando i dati cambiano, in genere vuoi intraprendere un'azione, ad esempio visualizzare i dati aggiornati nell'interfaccia utente. Ciò significa che devi osservare i dati in modo da poter reagire quando cambiano.

A seconda di come vengono archiviati i dati, questa operazione può essere complicata. L'osservazione delle modifiche ai dati in più componenti dell'app può creare percorsi di dipendenza espliciti e rigidi tra i componenti. Ciò rende difficile il test e il debug, tra le altre cose.

LiveData, una classe lifecycle library per l'osservazione dei dati, risolve questo problema. Utilizza un valore restituito di tipo LiveData nella descrizione del metodo e Room genera tutto il codice necessario per aggiornare LiveData quando il database viene aggiornato.

In WordDao, modifica la firma del metodo getAlphabetizedWords() in modo che List<Word> restituito sia racchiuso tra LiveData.

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

Più avanti in questo codelab, monitorerai le modifiche ai dati tramite un Observer in MainActivity.

Che cos'è un database Room?

  • Room è un livello di database sopra un database SQLite.
  • Room si occupa delle attività banali che prima gestivi con un SQLiteOpenHelper.
  • Room utilizza DAO per inviare query al database.
  • Per impostazione predefinita, per evitare prestazioni scadenti dell'interfaccia utente, Room non consente di eseguire query sul thread principale. Quando le query Room restituiscono LiveData, vengono eseguite automaticamente in modo asincrono su un thread in background.
  • Room fornisce controlli in fase di compilazione delle istruzioni SQLite.

Implementare il database Room

La classe del database Room deve essere astratta ed estendere RoomDatabase. In genere, è necessaria una sola istanza di un database Room per l'intera app.

Creiamone uno ora.

  1. Crea un file di classe Kotlin denominato WordRoomDatabase e aggiungi questo codice:
// 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 {
            val tempInstance = INSTANCE
            if (tempInstance != null) {
                return tempInstance
            }
            synchronized(this) {
                val instance = Room.databaseBuilder(
                        context.applicationContext,
                        WordRoomDatabase::class.java, 
                        "word_database"
                    ).build()
                INSTANCE = instance
                return instance
            }
        }
   }
}

Esaminiamo il codice:

  • La classe di database per Room deve essere abstract ed estendere RoomDatabase
  • Annoti la classe in modo che sia un database Room con @Database e utilizzi i parametri di annotazione per dichiarare le entità che appartengono al database e impostare il numero di versione. Ogni entità corrisponde a una tabella che verrà creata nel database. Le migrazioni del database non rientrano nell'ambito di questo codelab, quindi abbiamo impostato exportSchema su false per evitare un avviso di build. In un'app reale, devi prendere in considerazione l'impostazione di una directory che Room utilizzerà per esportare lo schema, in modo da poterlo controllare nel sistema di controllo della versione.
  • Il database espone i DAO tramite un metodo "getter" astratto per ogni @Dao.
  • Abbiamo definito un singleton, WordRoomDatabase,, per evitare che vengano aperte più istanze del database contemporaneamente.
  • getDatabase restituisce il singleton. La prima volta che viene eseguito l'accesso, viene creato il database utilizzando il builder di database di Room per creare un oggetto RoomDatabase nel contesto dell'applicazione dalla classe WordRoomDatabase e viene assegnato il nome "word_database".

Che cos'è un repository?

Una classe di repository astrae l'accesso a più origini dati. Il repository non fa parte delle librerie dei componenti dell'architettura, ma è una best practice suggerita per la separazione del codice e dell'architettura. Una classe Repository fornisce un'API pulita per l'accesso ai dati al resto dell'applicazione.

Perché utilizzare un repository?

Un repository gestisce le query e ti consente di utilizzare più backend. Nell'esempio più comune, il repository implementa la logica per decidere se recuperare i dati da una rete o utilizzare i risultati memorizzati nella cache in un database locale.

Implementazione del repository

Crea un file di classe Kotlin denominato WordRepository e incolla il seguente codice:

// 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 LiveData will notify the observer when the data has changed.
    val allWords: LiveData<List<Word>> = wordDao.getAlphabetizedWords()
 
    suspend fun insert(word: Word) {
        wordDao.insert(word)
    }
}

I principali concetti chiave:

  • L'oggetto DAO viene passato al costruttore del repository anziché all'intero database. Questo perché ha bisogno solo dell'accesso al DAO, in quanto contiene tutti i metodi di lettura/scrittura per il database. Non è necessario esporre l'intero database al repository.
  • L'elenco di parole è di proprietà pubblica. Viene inizializzato ottenendo l'elenco di parole LiveData da Room. Possiamo farlo grazie al modo in cui abbiamo definito il metodo getAlphabetizedWords per restituire LiveData nel passaggio "La classe LiveData". Room esegue tutte le query su un thread separato. L'LiveData osservato invierà una notifica all'osservatore nel thread principale quando i dati saranno cambiati.
  • Il modificatore suspend indica al compilatore che questa funzione deve essere chiamata da una coroutine o da un'altra funzione di sospensione.

Che cos'è un ViewModel?

Il ruolo di ViewModel è fornire dati alla UI e sopravvivere alle modifiche alla configurazione. Un ViewModel funge da centro di comunicazione tra il repository e la UI. Puoi anche utilizzare un ViewModel per condividere i dati tra i fragment. ViewModel fa parte della libreria del ciclo di vita.

Per una guida introduttiva a questo argomento, consulta ViewModel Overview o il post del blog ViewModels: A Simple Example.

Perché utilizzare un ViewModel?

Un ViewModel contiene i dati dell'interfaccia utente della tua app in modo consapevole del ciclo di vita che sopravvive alle modifiche alla configurazione. Separando i dati dell'interfaccia utente dell'app dalle classi Activity e Fragment, puoi seguire meglio il principio di responsabilità singola: le attività e i fragment sono responsabili del disegno dei dati sullo schermo, mentre ViewModel può occuparsi di contenere ed elaborare tutti i dati necessari per l'interfaccia utente.

In ViewModel, utilizza LiveData per i dati modificabili che l'interfaccia utente utilizzerà o visualizzerà. L'utilizzo di LiveData offre diversi vantaggi:

  • Puoi impostare un osservatore sui dati (anziché eseguire il polling per rilevare le modifiche) e aggiornare l'interfaccia utente solo quando i dati cambiano effettivamente.
  • Il repository e la UI sono completamente separati da ViewModel.
  • Non ci sono chiamate al database da ViewModel (tutto viene gestito nel repository), il che rende il codice più testabile.

viewModelScope

In Kotlin, tutte le coroutine vengono eseguite all'interno di un CoroutineScope. Un ambito controlla la durata delle coroutine tramite il relativo job. Quando annulli il job di un ambito, vengono annullate tutte le coroutine avviate in quell'ambito.

La libreria AndroidX lifecycle-viewmodel-ktx aggiunge un viewModelScope come funzione di estensione della classe ViewModel, consentendoti di lavorare con gli ambiti.

Per scoprire di più su come utilizzare le coroutine in ViewModel, consulta il passaggio 5 del codelab Utilizzo delle coroutine Kotlin nell'app Android o il post del blog Easy Coroutines in Android: viewModelScope.

Implementare il ViewModel

Crea un file di classe Kotlin per WordViewModel e aggiungi questo codice:

class WordViewModel(application: Application) : AndroidViewModel(application) {

    private val repository: WordRepository
    // Using LiveData and caching what getAlphabetizedWords 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>>

    init {
        val wordsDao = WordRoomDatabase.getDatabase(application).wordDao()
        repository = WordRepository(wordsDao)
        allWords = repository.allWords
    }

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

Ecco cosa abbiamo fatto:

  • È stata creata una classe denominata WordViewModel che riceve Application come parametro ed estende AndroidViewModel.
  • È stata aggiunta una variabile membro privata per contenere un riferimento al repository.
  • È stata aggiunta una variabile membro pubblica LiveData per memorizzare nella cache l'elenco delle parole.
  • È stato creato un blocco init che fa riferimento a WordDao da WordRoomDatabase.
  • Nel blocco init, è stato creato WordRepository in base a WordRoomDatabase.
  • Nel blocco init, inizializza allWords LiveData utilizzando il repository.
  • È stato creato un metodo wrapper insert() che chiama il metodo insert() del repository. In questo modo, l'implementazione di insert() è incapsulata dalla UI. Non vogliamo che l'inserimento blocchi il thread principale, quindi avviamo una nuova coroutine e chiamiamo l'inserimento del repository, che è una funzione di sospensione. Come accennato, i ViewModel hanno un ambito di coroutine basato sul loro ciclo di vita chiamato viewModelScope, che utilizziamo qui.

Successivamente, devi aggiungere il layout XML per l'elenco e gli elementi.

Questo codelab presuppone che tu abbia familiarità con la creazione di layout in XML, quindi ti forniamo solo il codice.

Rendi il tema dell'applicazione materiale impostando il genitore AppTheme su Theme.MaterialComponents.Light.DarkActionBar. Aggiungi uno stile per gli elementi di elenco in 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_width">match_parent</item>
        <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>

Aggiungere un 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>

In layout/activity_main.xml, sostituisci TextView con RecyclerView e aggiungi un pulsante di azione sovrapposto (FAB). Il layout dovrebbe ora avere il seguente aspetto:

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

L'aspetto del pulsante Azione rapida deve corrispondere all'azione disponibile, quindi sostituiremo l'icona con un simbolo "+".

Innanzitutto, dobbiamo aggiungere un nuovo asset vettoriale:

  1. Seleziona File > Nuovo > Risorsa vettoriale.
  2. Fai clic sull'icona del robot Android nel campo Clip Art: .
  3. Cerca "aggiungi" e seleziona l'asset "+". Fai clic su Ok
    .
  4. Dopodiché, fai clic su Avanti.
  5. Conferma il percorso dell'icona come main > drawable e fai clic su Fine per aggiungere l'asset.
  6. Sempre in layout/activity_main.xml, aggiorna il FAB in modo che includa il nuovo elemento disegnabile:
<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"/>

Visualizzerai i dati in un RecyclerView, che è un po' più bello che inserirli semplicemente in un TextView. Questo codelab presuppone che tu sappia come funzionano RecyclerView, RecyclerView.LayoutManager, RecyclerView.ViewHolder e RecyclerView.Adapter.

Tieni presente che la variabile words nell'adattatore memorizza i dati nella cache. Nell'attività successiva, aggiungi il codice che aggiorna automaticamente i dati.

Crea un file di classe Kotlin per WordListAdapter che estende RecyclerView.Adapter. Ecco il codice:

class WordListAdapter internal constructor(
        context: Context
) : RecyclerView.Adapter<WordListAdapter.WordViewHolder>() {

    private val inflater: LayoutInflater = LayoutInflater.from(context)
    private var words = emptyList<Word>() // Cached copy of words

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

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): WordViewHolder {
        val itemView = inflater.inflate(R.layout.recyclerview_item, parent, false)
        return WordViewHolder(itemView)
    }

    override fun onBindViewHolder(holder: WordViewHolder, position: Int) {
        val current = words[position]
        holder.wordItemView.text = current.word
    }

    internal fun setWords(words: List<Word>) {
        this.words = words
        notifyDataSetChanged()
    }

    override fun getItemCount() = words.size
}

Aggiungi RecyclerView nel metodo onCreate() di MainActivity.

Nel metodo onCreate() dopo setContentView:

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

Esegui l'app per assicurarti che tutto funzioni. Non sono presenti elementi perché non hai ancora collegato i dati.

Non ci sono dati nel database. Aggiungerai i dati in due modi: aggiungendo alcuni dati all'apertura del database e aggiungendo un Activity per aggiungere parole.

Per eliminare tutti i contenuti e ripopolare il database ogni volta che viene avviata l'app, crea un RoomDatabase.Callback e sostituisci onOpen(). Poiché non puoi eseguire operazioni sul database Room nel thread UI, onOpen() avvia una coroutine sul dispatcher I/O.

Per avviare una coroutine abbiamo bisogno di un CoroutineScope. Aggiorna il metodo getDatabase della classe WordRoomDatabase per ottenere anche un ambito di coroutine come parametro:

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

Aggiorna l'inizializzatore di recupero del database nel blocco init di WordViewModel per passare anche l'ambito:

val wordsDao = WordRoomDatabase.getDatabase(application, viewModelScope).wordDao()

In WordRoomDatabase, creiamo un'implementazione personalizzata di RoomDatabase.Callback(), che riceve anche CoroutineScope come parametro del costruttore. Poi, eseguiamo l'override del metodo onOpen per popolare il database.

Ecco il codice per creare il callback all'interno della classe WordRoomDatabase:

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

    override fun onOpen(db: SupportSQLiteDatabase) {
        super.onOpen(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!
    }
}

Infine, aggiungi il callback alla sequenza di creazione del database subito prima di chiamare .build() su Room.databaseBuilder():

.addCallback(WordDatabaseCallback(scope))

Ecco come dovrebbe apparire il codice finale:

@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 onOpen(db: SupportSQLiteDatabase) {
           super.onOpen(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
        }
     }
   }
}

Aggiungi queste risorse stringa in 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>

Aggiungi questa risorsa di colore in value/colors.xml:

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

Crea un nuovo file di risorse delle dimensioni:

  1. Fai clic sul modulo dell'app nella finestra Progetto.
  2. Seleziona File > Nuovo > File di risorse Android.
  3. In Qualificatori disponibili, seleziona Dimensione .
  4. Imposta il nome del file: dimens

Aggiungi queste risorse delle dimensioni in values/dimens.xml:

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

Crea un nuovo progetto Android Activity vuoto con il modello Empty Activity:

  1. Seleziona File > Nuovo > Attività > Attività vuota.
  2. Inserisci NewWordActivity per il nome dell'attività.
  3. Verifica che la nuova attività sia stata aggiunta al file manifest di Android.
<activity android:name=".NewWordActivity"></activity>

Aggiorna il file activity_new_word.xml nella cartella del layout con il seguente codice:

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

Aggiorna il codice dell'attività:

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

Il passaggio finale consiste nel connettere la UI al database salvando le nuove parole inserite dall'utente e visualizzando i contenuti attuali del database di parole in RecyclerView.

Per visualizzare i contenuti attuali del database, aggiungi un osservatore che osservi LiveData in ViewModel.

Ogni volta che i dati cambiano, viene richiamato il callback onChanged(), che chiama il metodo setWords() dell'adattatore per aggiornare i dati memorizzati nella cache dell'adattatore e aggiornare l'elenco visualizzato.

In MainActivity, crea una variabile membro per ViewModel:

private lateinit var wordViewModel: WordViewModel

Utilizza ViewModelProvider per associare il tuo ViewModel al tuo Activity.

Quando Activity viene avviato per la prima volta, ViewModelProviders crea ViewModel. Quando l'attività viene eliminata, ad esempio a causa di una modifica alla configurazione, ViewModel persiste. Quando l'attività viene ricreata, ViewModelProviders restituisce ViewModel esistente. Per ulteriori informazioni, vedi ViewModel.

In onCreate() sotto il blocco di codice RecyclerView, ottieni un ViewModel da ViewModelProvider:

wordViewModel = ViewModelProvider(this).get(WordViewModel::class.java)

In onCreate(), aggiungi anche un osservatore per la proprietà allWords LiveData da WordViewModel.

Il metodo onChanged() (il metodo predefinito per la nostra Lambda) viene attivato quando i dati osservati cambiano e l'attività è in primo piano:

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

Vogliamo aprire NewWordActivity quando tocchiamo il pulsante Azione rapida e, una volta tornati in MainActivity, inserire la nuova parola nel database o mostrare un Toast. Per raggiungere questo obiettivo, iniziamo definendo un codice di richiesta:

private val newWordActivityRequestCode = 1

In MainActivity, aggiungi il codice onActivityResult() per NewWordActivity.

Se l'attività restituisce RESULT_OK, inserisci la parola restituita nel database chiamando il metodo insert() di 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()
    }
}

In MainActivity,start NewWordActivity quando l'utente tocca il pulsante di azione rapida. In MainActivity onCreate, trova il FAB e aggiungi un onClickListener con questo codice:

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

Il codice finale dovrebbe avere questo aspetto:

class MainActivity : AppCompatActivity() {

   private const val newWordActivityRequestCode = 1
   private lateinit var wordViewModel: WordViewModel

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

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

       wordViewModel = ViewModelProvider(this).get(WordViewModel::class.java)
       wordViewModel.allWords.observe(this, Observer { words ->
           // Update the cached copy of the words in the adapter.
           words?.let { adapter.setWords(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, 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()
       }
   }
}

Ora esegui l'app. Quando aggiungi una parola al database in NewWordActivity, l'interfaccia utente viene aggiornata automaticamente.

Ora che hai un'app funzionante, riepiloghiamo ciò che hai creato. Ecco di nuovo la struttura dell'app:

I componenti dell'app sono:

  • MainActivity: mostra le parole in un elenco utilizzando un RecyclerView e il WordListAdapter. In MainActivity, è presente un Observer che osserva le parole LiveData del database e riceve una notifica quando cambiano.
  • NewWordActivity: aggiunge una nuova parola all'elenco.
  • WordViewModel: fornisce metodi per accedere al livello dati e restituisce LiveData in modo che MainActivity possa impostare la relazione di osservazione.*
  • LiveData<List<Word>>: consente gli aggiornamenti automatici nei componenti dell'interfaccia utente. In MainActivity, è presente un Observer che osserva le parole LiveData del database e riceve una notifica quando cambiano.
  • Repository: gestisce una o più origini dati. Repository espone i metodi per consentire alla ViewModel di interagire con il provider di dati sottostante. In questa app, il backend è un database Room.
  • Room: è un wrapper intorno a un database SQLite e lo implementa. Room fa molto lavoro per te che prima dovevi fare da solo.
  • DAO: mappa le chiamate ai metodi alle query del database, in modo che quando il repository chiama un metodo come getAlphabetizedWords(), Room possa eseguire SELECT * from word_table ORDER BY word ASC.
  • Word: è la classe di entità che contiene una sola parola.

* Views e Activities (e Fragments) interagiscono con i dati solo tramite ViewModel. Pertanto, la provenienza dei dati non è importante.

Flusso di dati per gli aggiornamenti automatici della UI (UI reattiva)

L'aggiornamento automatico è possibile perché utilizziamo LiveData. In MainActivity, è presente un Observer che osserva le parole LiveData del database e riceve una notifica quando cambiano. Quando si verifica una modifica, viene eseguito il metodo onChange() dell'osservatore e viene aggiornato mWords in WordListAdapter.

I dati possono essere osservati perché sono LiveData. Ciò che viene osservato è LiveData<List<Word>> restituito dalla proprietà WordViewModel allWords.

WordViewModel nasconde tutto ciò che riguarda il backend dal livello UI. Fornisce metodi per accedere al data layer e restituisce LiveData in modo che MainActivity possa configurare la relazione di osservazione. Views e Activities (e Fragments) interagiscono con i dati solo tramite ViewModel. Pertanto, la provenienza dei dati non è importante.

In questo caso, i dati provengono da un Repository. ViewModel non ha bisogno di sapere con cosa interagisce il repository. Deve solo sapere come interagire con Repository, ovvero tramite i metodi esposti da Repository.

Il repository gestisce una o più origini dati. Nell'app WordListSample, il backend è un database Room. Room è un wrapper che implementa un database SQLite. Room fa molto lavoro per te che prima dovevi fare da solo. Ad esempio, Room fa tutto ciò che facevi con un corso SQLiteOpenHelper.

Il metodo DAO mappa le chiamate ai metodi alle query del database, in modo che quando il repository chiama un metodo come getAllWords(), Room possa eseguire SELECT * from word_table ORDER BY word ASC.

Poiché il risultato restituito dalla query è osservato LiveData, ogni volta che i dati in Room cambiano, viene eseguito il metodo onChanged() dell'interfaccia Observer e l'interfaccia utente viene aggiornata.

[Facoltativo] Scarica il codice della soluzione

Se non l'hai ancora fatto, puoi dare un'occhiata al codice della soluzione per il codelab. Puoi consultare il repository GitHub o scaricare il codice qui:

Scarica il codice sorgente

Decomprimi il file ZIP scaricato. Verrà estratta una cartella principale, android-room-with-a-view-kotlin, che contiene l'app completa.