Módulo 07

En este módulo trataremos:

  • La Persistencia de datos con Room y SQLite en JetPack Compose.
  • Creación de un patrón de diseño con los elementos importantes de la arquitectura, como Application + View + ViewModel + Repositorio + DB Room SQLite
  • Inyección de dependencias manual (sin librerías adicionales)

Para poder entender los elementos incluidos en el siguiente punto, seguramente tendréis que revisar los enlaces apropiados más abajo.

Elementos importantes de la arquitectura

Application

Android crea el objeto Application de manera transparente para nosotros y no es necesario instanciar un objeto Application por defecto.

El objeto Application es persistente a lo largo de toda la ejecución, no como las Activities que tienen un ciclo de vida durante el cual pueden ser destruidas.
El objeto Application es un objeto singleton por defecto, es decir, sólo existe una instancia del mismo.

Es recomendable instanciar el objeto Application en nuestra aplicación cuando vamos a utilizarlo como contenedor de elementos, objetos, clases, etc.. que queremos que pervivan durante toda la aplicación. Es por tanto el sitio apropiado donde crear un Contenedor que mantenga los elementos persistentes como los repositorios y las bases de datos.

Desde cualquier activity o fragment de una aplicación podremos acceder al objeto application.

Para instanciar un objeto Application primero tenemos que crear una clase que herede de Application.
Aquí tenéis el código de una clase Application.

class MyApplication: Application() {

    //La aplicación declara una clase contenedor para almacenar los objetos persistentes para toda la app
    //como repositorios
    lateinit var dataContainer: AppDataContainer

    //En el constructor de la aplicación se inicializará el container de la aplicación.
    override fun onCreate() {
        super.onCreate()
        //Hemos declarado una clase AppDataContainer
        dataContainer = AppDataContainer(this)
    }
}

El Contenedor

Si nos fijamos en el código de la clase Application mostrado, vemos que en esta clase se declara un objeto que llamamos dataContainer y que es de una clase que llamamos AppDataContainer.

Lo que estamos haciendo es declarar un contenedor para datos. El contenedor de datos no es más que un objeto que contiene todos los repositorios y bases de datos que va a utilizar nuestra aplicación, de forma que no los tengamos que declarar a nivel de objeto application, pues es posible que haya más adelante otros contenedores para otros tipos de elementos como WorkManagers, ContentProviders, etc…

De esta manera, como podemos acceder al objeto aplication, simplemente accediendo a su variable dataContainer estaremos en disposición de acceder a Repositorios y basesd de datos. Lo iremos viendo, de momento aquí tenéis un código de ejemplo de nuestra clase AppDataContainer.

/**
 * [AppContainer] implementation that provides instance of [WordRepository]
 * [AppContainer] implementation that provides instance of [WordDatabase]
 */
class AppDataContainer(private val context: Context) {
    val gameDatabase = WordDatabase.getInstance(context)
    val wordRepository: WordRepository
        get() = WordRepository(gameDatabase!!)
}

Los DAO (Data Access Objects) y el Modelo conceptual de los elementos de base de datos

La base de datos es un elemento de la arquitectura de aplicaciones que implementa las entidades y las relaciones entre ellas en forma de tablas y relaciones entre las tablas. Revisar los conceptos en las referencias. En nuestro caso, para trabajar con nuestras aplicaciones Android utilizaremos la base de datos SQLite especialmente diseñada para dispositivos con pocos recursos y que se implementa físicamente como un conjunto de ficheros donde se guardan los datos. Revisar estos conceptos en el primer codelab más abajo.

Desde el punto de vista nuestra aplicación Android podremos acceder a las tablas de la base de datos utilizando objetos kotlin. Estos objetos tendrán métodos para acceder a los datos. Estos métodos estarán anotados con una sintaxis @Algo que hacen que el compilador de Android Studio (teniendo las librerías apropiadas) genere código por nosotros para acceder realmente a los datos en la base de datos, tanto para insertar, como para consultar, actualizar o borrar.

Aquí tenéis ejemplo de los DAO para acceder a una base de datos de palabras:

@Dao
interface WordDao {
    @Query("SELECT * FROM words")
    fun getAll(): Flow<List<Word>>

    //Metodo insertAll para insertar multiples palabras de una vez
    @Insert
    fun insertAll(vararg words: Word)

    //Metodo insert para insertar una palabra a la vez
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    fun insert(word: Word)

    //Metodo delete para borrar una palabra a la vez
    @Delete
    fun delete(vararg word: Word)

    //Medoto update para actualizar varias palabaras a la vez
    //cada Word tiene el id que no cambia y actualiza la columna word con la Word.word)
    @Update
    fun update(vararg word: Word)
}

Vemos que realmente estamos definiendo un Interface que propone los métodos que tendrá que implementar “quien quiera ser” un WordDao. Lo vemos en la base de datos.

Si nos fijamos el método getAll(), anotado como @Query, y que nos permite recuperar todos los registros de la tabla words, devuelve un Flow. En este caso es un Flow de List<Word>

Y, ¿Word, qué es? pues Word en nuestro caso es una clase de define cómo es una entidad de la base datos y que está implementada a en la tabla words.
Word lo que hace es decir, desde Android se le llama Modelo (de datos), cómo tiene que ser la tabla words de nuestra base de datos.
El código del modelo (clase kotlin) Word que define la entidad word y que se guarda en la tabla SQLite words es:

@Entity(tableName = "words")
data class Word (
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,
    @NotNull
    val word: String
)

En este caso es muy sencilla, sólo tenemos dos columnas en nuestra tabla, un identificador único autoincremental y un campo string para la palabra.

La base de datos Room

Como vemos uno de los elementos que contiene el container definido en AppDatacontainer es la base de datos y el otro es el repositorio.

Bases de datos podemos tener las que queramos y repositorios también. En este ejemplo veremos una única base de datos y un único repositorio que la utiliza.

En el contenedor gameContainer de la clase AppDataContainer vemos que la variable gameDatabase se inicializa llamando al método .getInstance(context) de la clase WordDatabase.

Tenemos que crear una clase para nuestra base de datos y llamar a su método getInstance que se encargará de darnos un objeto singleton para acceder a la base de datos. De esta manera la base de datos siempre es la misma, la que devuelve el método getInstance() que comprobará si ya se ha creado. Si ya la tenía creada, devuelve la instancia ya existente, si no la crea y devuelve la recién creada.

Así, desde cualquier punto de la aplicación podremos acceder a la base de datos con la certeza de que es la misma base de datos.
Aquí tenéis el código de la clase WordDatabase que crea una base de datos para el juego de las palabras, unscramble (en módulos anteriores).

//Nuestra Room Database
@Database(entities = [Word::class], version = 1)
abstract class WordDatabase : RoomDatabase() {
    abstract fun wordDao(): WordDao

    companion object {
        private const val DATABASE_NAME = "words_database"

        @Volatile
        private var INSTANCE: WordDatabase? = null

        fun getInstance(context: Context): WordDatabase? {
            INSTANCE ?: synchronized(this) {
                INSTANCE = Room.databaseBuilder(
                    context.applicationContext,
                    WordDatabase::class.java,
                    DATABASE_NAME
                    )
                    .build()
            }
            return INSTANCE
        }
    }

Se anota con @Database y se lista en la anotación todas las entidades de la base de datos, en nuestro caso sólo tenemos una.

En la clase se crean tantas funciones DAO como tengamos, en nuestro caso sólo una función wordDao que devuelve un objeto de la clase WordDao, mediante el que podremos acceder a los métodos que tenemos que implementa la interfaz. ¿donde está el código de cada función de la clase WordDao que se supone que tenemos que implementar? Pues lo ha generado el compilador de Android Studio mediante la librerías de anotación que hemos incluido en el proyecto. Es decir, gracias a haber anotado los métodos del interfaz como @Query, @Delete, @Insert y @Update, éste código de “gestión SQLite” se implementa automáticamente y de manera transparente para nosotros.

Vemos también que tenemos un objeto companion que tiene una constante para definir el nombre de la base de datos, una variable privada INSTANCE que representa el objeto Kotlin en memoria para acceder a la base de datos (y que tenemos que garantizar que siempre se usa el mismo) y una función getInstance() para devolver esta instancia única a quien la pida.

Como es posible que se pida desde varios sitios la instancia de la base de datos y puede ser que incluso de manera concurrente desde distintos threads, tenemos que garantizar que sólo un thread a la vez acceda al código que crea la instancia. Claro, si por un casual se pide la instancia desde dos threads y ésta no está creada, cada thread creará su base de datos y la variable INSTANCE quedará actualizada con la última de las dos. Por esto se utiliza la el lamda synchronized que impide que dos threads accedan simultáneamente a este método de la clase, lo harán uno detrás de otro. Así el segundo verá ya cargada la viarable INSTANCE y se limitará a devolverla. Es el primero que acceda, el que encuentra INSTANCE a null el que llama a Room.databaseBuilder() para crear físicamente en SSD del dispositivo el fichero de la base de datos SQLite.

La base de datos, tal como hemos puesto en el ejemplo se crea con una tabla sólo, la tabla words. Además esta tabla está vacía, no tiene registros. Podremos utilizar los métodos DAO y el repositorio y demás cosas (como se trata en los enlaces) para insertar registros en la tabla words.

Pero, ¿y si quiero que la base de datos cuando se cree se cargue con datos que vengan de otro sitio, de una lista de palabras, de una conexión a internet, etc.?

Lo que podemos hacer es declarar un callback() para ejecutar su código en respuesta al evento de creación de la base de datos. Cuando Android crea la base de datos genera un evento diciendo que la base de datos ha sido creada. Si nosotros hemos definido un código para ser ejecutado cuando suceda el evento, es decir, un callback para ese evento, entonces Android pondrá en ejecución el código que nosotros hemos programado.

Suponer que tenemos una función que llamamos InitalLoader() con el siguiente código: (ponerlo en el mismo fichero .kt donde se declara la base de datos, así está a mano)

suspend fun initialLoader(context: Context){
    val wordDao = WordDatabase.getInstance(context = context)?.wordDao()

    allWords.forEach(){
        wordDao!!.insert(Word(word = it))
    }
}

En este código vemos que le pedimos a la clase WordDatabase que nos de la instancia de la base de datos, sabemos que será la misma que le de a quien se la pida en otro momento. A partir de la instancia que nos da getInstance() accedemos al wordDao() que nos ofrece las funciones de gestión de datos SQLite.

Vemos, en este ejemplo que tenemos una variable llamada allWords, que no es más que una Lista de palabras (List<Word>) picada a mano y que podemos declarar en el fichero WordData.kt siguiente:

// Set with all the words for the Game
val allWords: Set<String> =
    setOf(
        "animal",
        "auto",
        "anecdote",
        "alphabet",
        "all",
   ...
        "zigzag",
        "zoology",
        "zone",
        "zeal"
    )

De esta forma en nuestra función InitialLoader() recorreremos (forEach) esta lista de palabras y llamaremos al Dao insert() pasándole un objeto que construimos con el constructor de nuestro modelo Word en cada insert.

Bien, con esto tenemos el código que queremos que se ejecute cuando se cree la base de datos. El evento de creación de la base de datos ocurre una única vez, como es de esperar. Pero ¿cómo hacemos para decirle a Android que vincule nuestro código con el evento?.
En el siguiente código vemos redefinida la clase WordDatabase incluyendo el callback y su vinculación con nuestra función InitialLoader()

suspend fun initialLoader(context: Context){
    val wordDao = WordDatabase.getInstance(context = context)?.wordDao()

    allWords.forEach(){
        wordDao!!.insert(Word(word = it))
    }
}

//Nuestra Room Database
@Database(entities = [Word::class], version = 1)
abstract class WordDatabase : RoomDatabase() {
    abstract fun wordDao(): WordDao

    companion object {
        private const val DATABASE_NAME = "words_database"

        @Volatile
        private var INSTANCE: WordDatabase? = null

        fun getInstance(context: Context): WordDatabase? {
            INSTANCE ?: synchronized(this) {
                INSTANCE = Room.databaseBuilder(
                    context.applicationContext,
                    WordDatabase::class.java,
                    DATABASE_NAME
                    )
                    .addCallback(object : RoomDatabase.Callback(){
                        override fun onCreate(db: SupportSQLiteDatabase){
                            super.onCreate(db)

                            CoroutineScope(Dispatchers.IO).launch {
                                initialLoader(context)
                            }

                        }
                    })
                    .build()
            }
            return INSTANCE
        }
    }

}

Vemos que la función .addCallback() del databaseBuilder nos permite sobrecargar la función onCreate, que es donde ponemos nuestra llamada a initialLoader().
Pero claro, esto lo tenemos que hacer en una corrutina puesto que sino nuestro mainThread se pararía hasta que se terminara de cargar.
Por esto también hemos definido InitialLoader como una función suspend.

El repositorio

Ya tenemos los DAO, los Modelos y la base de datos Room que además se carga al inicio.

Ahora hay que definir el repositorio. Realmente no estamos obligados a montar un Repositorio (o varios, como veremos) pero es aconsejable.

El repositorio, para nosotros, es el intermediario entre el sistema de almacenamiento y nuestros ViewModels. Ese intermediario es el que sabe de la base de datos, de si hay una o varias, de si es local o remota, y demás cosas. Eso, desde el ViewModel nos da igual, sólo queremos que el repositorio nos ofrezca las funciones que nos servirán para obtener datos, insertarlos, actualizarlos y borrarlos. Si tiene que actualizar en varios sitos, o si tiene que obtener los datos primero on-line y si no hay conexión nos sirve los datos locales, eso es problema del repositorio, no de nosotros ( los ViewModels), simplemente queremos funciones que hagan las cosas, cómo las haga nos da igual.

Otra consideración a tener en cuenta, o mejor dicho otro punto de vista, es que podemos tener varios repositorios o uno solo. Por ejemplo, un repositorio para todo lo que tenga que ver con usuarios, otro para todo lo que tenga que ver con pedidos, otro para todo lo de proveedores, etc.. (distintos casos de uso de nuestra aplicación), o bien si queremos montar uno solo que nos oferte funciones para tratar con todo. Incluso podemos tener, como en alguno de los tutoriales de la sección de enlaces, un repositorio para cada entidad de la base de datos. Cuantos tener y cómo lo organicemos dependerá de la complejidad de nuestra aplicación y de cómo queramos estructurarla. Aunque en los ejemplos que estamos poniendo sólo tenemos uno (porque son muy sencillos o porque sólo queremos definir uno) tener en cuenta que cuando hablamos de “El repositorio” no es “un único repositorio”.

Os dejo el ejemplo de un repositorio para interactuar con las palabras (words) de nuestro ejemplo:

class WordRepository(private val wordDatabase: WordDatabase) {

    fun insertWord(word: Word) {
        wordDatabase.wordDao().insert(word)
    }

    fun getAllWords(): Flow<List<Word>> {
        return wordDatabase.wordDao().getAll()
    }


}

En este caso sólo nos ofrece dos funcionalidades, añadir una palabra a la base de datos (que ya hemos usado en el callback de carga) y una funcionalidad para obtener una lista de todas las palabras.

Como vemos, en este caso sencillo, donde no hay más lógica que determine de donde obtener los datos, nos limitamos simplemente a devolver o a llamar a los métodos del Dao apropiado.

Vemos que el repositorio de palabras, obtiene una referencia a la base de datos de palabras, con ella es con la que trabaja y a través de ella llega a sus DAOs.

El ViewModel con parámetros y los ViewModel Factories

En los ViewModels que hemos usado en los códigos de módulos anteriores no necesitabamos parámetros en el ViewModel, y por eso se inicializaban tan simple como poner =viewModel(). Eso lo que hace es llamar a un constructor por defecto de ViewModels (sin parámetros) y lo usamos como hemos visto. Además recordar que aunque llamaramos de varios sitios al constructor con =viewModel(), si ya había construido el ViewModel para ese contexto, nos devolverá la instancia que ya había creado. Si es la primera vez, pues lo crea y nos devuelve esa instancia. Eso garantiza que distintas “pantallas” (composables) que forman un caso de uso (o una navegación) obtengan siempre el mismo ViewModel y por tanto mantener el estado apropiadamente.

El código del ViewModel para nuestro ejemplo de palabras sería:

class GameViewModel(val wordRepository: WordRepository) : ViewModel() { //Ahora tenemos un parámetro.

    // Game UI state (no confundir con el estado de un composable)
    private val _uiState = MutableStateFlow(GameUiState())

    // Backing property to avoid state updates from other classes
    val uiState: StateFlow<GameUiState> = _uiState.asStateFlow()

    private suspend fun getAllWords()  { //Como ejemplo, esta función nos traerá todas las palabras de la base de datos al estado
        val gameWords = wordRepository.getAllWords().first()
        if (!gameWords.isEmpty()) {
            _uiState.update { currentState ->
                currentState.copy(
                    wordsLoaded = true,   //esto depende de nuestro estado
                    gameWords = gameWords //esto depende de nuestro estado
                )
            }
        }
    }


    //.... resto de funciones que ofrece el viewmodel, esto ya lo conocemos de módulos anteriores

}

Pero ahora, en nuestros ViewModels, y en sus estados, tenemos que almacenar datos que vienen de la base de datos. Para ello necesitamos decirle al ViewModel qué repositorio va a usar. En este caso, ya tenemos un parámetro, el repositorio. Por tanto, como podemos unas veces usar un repositorio y otras veces otro, el constructor por defecto llamando con =viewModel() no nos vale.

Cuando queremos instanciar una variable con el ViewModel que tiene un parámetro (o varios), tenemos que tener un constructor específico para ese tipo de ViewModel. Eso lo vamos a hacer con lo que Android llama una Factory, es un componente de Android que crea una instancia específica de un ViewModel con parámetros y siempre que se le pida con el mismo contexto nos dará la instancia creada, si es la primera vez, pues crea el ViewModel.

Por tanto tenemos que usar una ViewModel Factory para nuestro ViewModel del ejemplo de las palabras.

Os dejo el código de ejemplo de éste:

class GameViewModelFactory(private val wordRepository: WordRepository): ViewModelProvider.NewInstanceFactory() {
    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel> create(
        modelClass: Class<T>,
        extras: CreationExtras
    ): T {

        // Get the Application object from extras
        val application = checkNotNull(extras[APPLICATION_KEY])

        if (modelClass.isAssignableFrom(GameViewModel::class.java)) {
            return GameViewModel((application as MyApplication).container.wordRepository) as T
        }
    throw java.lang.IllegalArgumentException("Unknown ViewModel Class")
    }
}

Revisar los enlaces acerca del ViewModelProvider y las factories.

En la sobrecarga de la función create, vemos que añadimos un parámetro llamado extras, de la clase CreationExtras. Eso lo crea automáticamente Android, es decir, no tenemos que definir la clase CreationExtras. A través de este parámetro podemos obtener una referencia al objeto Application de nuestra aplicación, esto se hace con la línea:

// Get the Application object from extras
        val application = checkNotNull(extras[APPLICATION_KEY])

que vemos que además chequeamos que no sea nula. Esta línea nos instancia una variable con una referencia al objeto Aplication.

Recordar que nuestro objeto aplicación tenía un contenedor y que éste almacenaba el o los repositorios a usar en la aplicación.
Ese es el que usamos en la línea del return. Es decir nuestra factory, (por ser precisos, nuestro ViewModelProvieder.NewInstanceFactory() ), devolverá un GameViewModel(<parametro>) cuyo parámetro lo obtiene del container de nuestra aplicación, el wordRepository.

Lecturas y enlaces relacionados: SQL

Lecturas y enlaces relacionados: Room

Lecturas y enlaces relacionados: Repository

Lecturas y enlaces relacionados: ViewModels & ViewModels Factories

Codelabs:

SQLBasics: Cómo usar SQL para leer y escribir en una base de datos

Página principal del curso

Unidad 6 : Ruta 1 Introducción a SQL -> TRAINING ->ANDROID BASICS WITH COMPOSE -> PERSISTENCIA DE DATOS -> SQL

TODO: Realizar el codelab en Cómo usar SQL para leer y escribir en una base de datos –> Codelab
En GitHub : SQLDemo

Nota: Al igual que con las clases de Kotlin, la convención es usar la forma singular para el nombre de las tablas de la base de datos. En el ejemplo, eso significa que nombras las tablas teacher, student y course, NO LAS FORMAS PLURALES teachers, students y courses que sin embargo en otros ambientes son las preferidas.

Hemos tratado:

  • Resumen de SQL.
  • SQL tipos básicos
  • Android Studio Database Inspector, View > Tool Windows > App Inspection Nota: Para poder acceder a la Base de datos la App tiene que estar en ejecución.
  • Ejecución de consultas en el Query Tab del Inspector.
  • Instrucciones SELECT
  • Instrucciones SELECT con consultas agregadas (COUNT, SUM, AVG, MIN, MAX)
  • Filtrar duplicados con SELECT DISTINCT
  • Filtrado con la Cláusula WHERE
  • Búsqueda de texto con LIKE
  • Agrupar resultados con GROUP BY
  • Ordenar resultados con ORDER BY
  • Restringir el número de resultados con LIMIT
  • Insertar datos en una base de datos con INSERT
  • Actualizar los datos existentes en una base de datos con UPDATE
  • Borrar una fila de una base de datos con DELETE

Inventory: Cómo conservar datos con Room

Página principal del curso

Unidad 6 : Ruta 1 Introducción a SQL -> TRAINING ->ANDROID BASICS WITH COMPOSE -> PERSISTENCIA DE DATOS -> SQL

TODO: Realizar el codelab en Cómo conservar datos con Room –> Codelab
En GitHub :
Inventory (Starter branch)
Inventory (Codelab branch)

TODO: Realizar el codelab en Cómo leer y actualizar datos con Room –> Codelab (paso 5 Test opcional)
En GitHub :
Inventory (Codelab 2 branch)

Análisis Previo de la Starter App:

La starter App introduce el uso del Objeto Application.

Todas las Apps en Android contienen este objeto, pero hasta ahora no la hemos tratado porque implícitamente está siendo usada en el background y no hemos modificado su comportamiento inicial ni colocado elementos de datos ni código a nivel de App.

Ahora tenemos que declararla explícitamente para que podamos añadir el componente base de datos a nivel de Application. Vemos que a nivel del fichero manifest.xml se ha añadido la línea:

    android:name=".InventoryApplication"

Que define la clase kotlin que implementa la Aplicación.
Referencias:

Acerca de los ViewModels de la Starter App:

Como sabemos para lanzar un viewmodel asociado al ciclo de vida de una Activity basta con llamar a la función ViewModel() como en el siguiente código (ver artículo):

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            MainScreen(viewModel())
        }
    }
}

En este caso nuestra llamada a viewModel() llevará un parámetro que es el repositorio.
Cuando un ViewModel tiene parámetros hay que usar una Factory de ViewModels para que defina el parámetro.

Nota:  Si se muestra un Flow, solo debes llamar explícitamente a los métodos desde el DAO una vez para un ciclo de vida determinado. Room controla las actualizaciones de los datos subyacentes de manera asíncrona. (del codelab2)

La forma recomendada de exponer un Flow desde un ViewModel es con un StateFlow. El uso de un StateFlow permite guardar y observar los datos, sin importar el ciclo de vida de la IU. Para convertir un Flow en un StateFlow, usa el operador stateIn (Things to know about Flow’s shareIn and stateIn operators)