Imprimir o livro todoImprimir o livro todo

Livro 3 - Projeto Agenda de Contatos - Parte 3

Site: Moodle - IFSC
Curso: 2022-1 - FIC - Programação para Dispositivos Móveis - Prof. Bruno Calegaro
Livro: Livro 3 - Projeto Agenda de Contatos - Parte 3
Impresso por: Usuário visitante
Data: segunda, 25 Nov 2024, 07:11

1. Apresentação

Neste livro 3 conheceremos novos componentes avançados para a construção de aplicativos usando o Android Jetpack. Vamos exemplificar o uso dos componentes de arquitetura (Android Arch Components) do Android Jetpack e o uso da biblioteca Room para a manipulação do banco de dados SQLite através do aplicativo de Agenda de Contatos.

O aplicativo Agenda de Contatos fornece acesso a informações de contato armazenadas em um banco de dados SQLite no dispositivo. No aplicativo podemos:

  • ver uma lista em ordem alfabética dos contatos
  • ver os detalhes de um contato tocando no nome dele na lista de contatos
  • adicionar novos contatos
  • editar ou excluir contatos existentes

Figura 1 Aplicativo Agenda de Contatos com múltiplas telas

Na primeira parte do projeto foram construídas as telas do aplicativo e as classes referentes a Atividade, Fragmentos, ViewModels, Adapters e classes necessárias para a configuração e execução do banco de dados. Devido ao tamanho do aplicativo, vamos revisar a finalidade de cada classe:

  • Pacote data
    • ContactsDatabase: classe padrão da biblioteca Room para conter a descrição do banco de dado
    • ContactsDAO: classe padrão da biblioteca Room para conter as operações a ser manipuladas pelo banco de dados
    • Contact: classe para conter a descrição de um objeto a ser manipulado pelo banco de dados.
    • ContactsRepository: está classe não é exigida pela biblioteca Room mas é usado devido boas práticas de programação pois ela irá oferecer a aplicação uma interface (API) para o restante do aplicativo.
  • Pacote principal
    • MainActivity: classe principal que gerencia os fragmentos do aplicativo e implementa seus métodos de interface de call-back para responder quando um contato é selecionado, um novo contato é adicionado ou um já existente é atualizado ou excluído
  • Pacote ui.contacts
    • MainFragment: essa classe gerencia a RecyclerView da lista de contatos e o FloatingActionButton para adicionar contatos. 
    • MainViewModel: ViewModel para vincular os dados do banco a uma lista de contatos
    • ContactsAdapter:  subclasse de RecylerView.Adapter usada pelo MainFragment para vincular a lista ordenada de nomes de contato a RecyclerView.
    • ItemDivider: essa classe define o divisor exibido entre os itens da RecyclerView.
    • AddEditFragment:  essa classe gerencia os TextInputLayouts e um FloatingActionButton para adicionar um novo contato ou editar um já existente.
    • AddEditViewModel: ViewModel para armazenar os dados de um novo contato ou um já existente
    • DetailFragment: essa classe gerencia os componentes TextView estilizados que exibem os detalhes de um contato selecionado e os itens da barra de aplicativo que permitem ao usuário editar ou excluir o contato que está sendo exibi
    • DetailViewModel: ViewModel para vincular os dados de um contato específico

Na segunda parte do projeto, fizemos a configuração das transições entre os fragmentos com o componente Navigation e apresentação de dados fictícios.

Por fim, nesta terceira e última etapa do projeto vamos aplicar a persistência de dados da agenda de contatos com a biblioteca Room manipulando os dados salvos em um banco de dados SQLite. Também iremos implementar o padrão ViewModel e usar a classe LiveData, ambas presentes nos componentes de ciclo de vida do Android Jetpack.

Bons estudos!

2. Recursos Envolvidos (Parte 3)

No desenvolvimento da terceira parte do aplicativo, vamos usar as seguintes tecnologias presentes no Android Jetpack: biblioteca Room para manipulação de banco de dados SQLite, padrão ViewModel e LiveData para fazer a vinculação de dados aos elementos da interface gráfica.

Componentes de Arquitetura

  • Foi introduzido pelo Android Jetpack o pacote de componentes de Arquitetura (Architecture Components) para servir como um guia para construção de aplicativos. Nesse pacote está presentes diversas bibliotecas para tarefas comuns como gerenciamento dos ciclos de vida e persistência de dados.
  • Os componentes de arquitetura ajudam você a construir um aplicativo robusto, testável e de fácil manutenção.
  • Os componentes de arquitetura oferecem uma abordagem simples, flexível e prática nos livrando de ter que lidar com problemas comuns e nos focar nas funcionalidades do aplicativo.

Projeto de Arquitetura

  • O projeto de Agenda de Contatos faz o uso dos componentes de arquitetura do Android e exemplifica a estrutura padrão recomendado pelo Android Jetpack:

  • Na primeira e segunda parte do aplicativo fizemos a implementação da primeira camada da arquitetura. Nela usamos os fragmentos para construir as telas do aplicativo e configuramos as classes junto com a atividade (activity) principal para fazer a transição entre as telas.
  • Nesta terceira parte do aplicativo vamos implementar as demais camadas e introduzir novos conceitos adotados na arquitetura proposta.
    • Entity: Essa é uma classe de anotação utilizada para descrever uma tabela do banco de dados. Neste projeto específico, a classe Contact será utilizada para descrever as informações que devem ser armazenadas no banco de dados.
    • SQLite: banco de dados nativo do Android. Cada aplicativo pode criar e gerenciar as suas tabelas em um banco de dados sandbox (isto é, apenas o aplicativo pode ter acesso ao banco criado, você não pode acessar um banco de dados de outro aplicativo do seu celular).
    • DAO: Data Acess Object, padrão de projeto onde se cria uma classe para conter todas as operações (SQL) de um dado objeto. Nesse projeto específico, a classe ContactsDAO será empregada para definir as operações sobre a tabela de contatos.
    • RoomDatabase: camada da biblioteca Room para abstrair as operações sobre um banco de dados SQLite. Esta classe usa os DAOs criados para fazer a manipulação dos dados salvos no banco de dados. Nesse projeto específico, a classe ContactsDatabase será empregada para fazer a criação da tabela no banco de dados.
    • Repository: a classe repositório é um padrão de projeto empregado para que seja uma interface de comunicação entre a interface do aplicativo e o banco de dados. A vantagem de usar esse tipo de estratégia é que o repositório pode ser programado para gerenciar dados de múltiplas fontes por exemplo, sincronizar uma banco de dados local com um Web Service. Na Agenda de Contatos, vamos implementar a classe ContactsRepository.
    • ViewModel: uma ViewModel é uma classe cujo propósito é servir dados a uma interface gráfica (fragmento). A ViewModel fará o meio termo entre a comunicação dos dados apresentados na interface gráfica e o repositório. No aplicativo Agenda de Contatos vamos ter três ViewModels uma para cada fragmento: MainViewModel, AddEditViewModel e DetailViewModel.
    • LiveData: classe especial armazenar informações e notificar alterações em seu conteúdo. Esse tipo de classe pode ser "observado" por outro objeto e receber notificações quando seu valor é alterado garantindo que o objeto que está observando sempre tenha a última versão do conteúdo. Usamos essa classe para manipular a lista de contatos, pois a cada contato adicionado, editado ou removido, o fragmento será notificado e mostrará os dados sempre atualizados.

3. Biblioteca Room e persistência de dados

Para a finalização do aplicativo Agenda de Contatos vamos primeiramente fazer as configurações das classes utilizadas pela biblioteca Room. Ao configurar a persistência de dados para o lista de contatos poderemos realizar as operações de criação, edição e remoção de contatos em um banco de dados SQLite.

Para tanto, vamos implementar as seguintes classes:

  • Contact
  • ContactsDAO
  • ContactsDatabase
  • ContactsRepository

3.1. Adicionando a biblioteca Room ao projeto

Antes de começar a usa a biblioteca Room no projeto, primeiro precisamos adicionar suas dependências ao projeto. Para tanto, localize na pasta "Gradle Scripts" o arquivo build.gradle (Module ...)

Dentro desse arquivo estão presentes diversas configurações para o nosso projeto como a versão Android mínima, a versão do compilador e as dependências do projeto. O que nós precisamos fazer é adicionar os componentes da biblioteca Room ao campo dependencies. Se você já realizei esse passo nas configurações do projeto do Livro 1 ignore essa etapa, senão ceritique se de configurar o arquivo como: 

dependencies {

implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'com.google.android.material:material:1.2.1'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.2.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.gridlayout:gridlayout:1.0.0'
implementation 'androidx.navigation:navigation-fragment:2.2.2'
implementation 'androidx.navigation:navigation-ui:2.2.2'
testImplementation 'junit:junit:4.+'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'

implementation "androidx.room:room-runtime:2.2.6"
annotationProcessor "androidx.room:room-compiler:2.2.6"
testImplementation
"androidx.room:room-testing:2.2.6"
}

Após adicionar as novas linhas em destaque ao arquivo, clique no botão "Sync now" para sinalizar ao Android Studio fazer o download e adicionar as dependências ao projeto. Ao final do processo, seu projeto estará pronto e configurado para usar a biblioteca Room.

3.2. Classe Contact (Entity)

A agenda de contatos proposta por este aplicativo deve armazenar informações de um contato contendo seu nome, telefone e e-mail. Para a biblioteca Room, cada contato deve representar uma entidade (Entity) e, assim, ser armazenado no banco de dados. Para conseguir fazer a persistência desses dados em um banco precisamos criar uma classe chamada Contato com seus métodos get e construtor pois é assim que a biblioteca entende como instanciar esses objetos.

Observe, no entanto, que apenas a definição clássica de uma classe não é o suficiente para a biblioteca "entender" a classe. Usamos assim um conjunto de anotações (annotations) para escrever essa classe de modo que a biblioteca Room entenda exatamente como queremos que a tabela seja armazenada no banco de dados.

Dessa forma, abra o arquivo Contact.java e implemente o código a seguir:

package ...

import androidx.annotation.NonNull;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.Ignore;
import androidx.room.PrimaryKey;

@Entity(tableName = "contacts_table")
public class Contact {

@PrimaryKey(autoGenerate = true)
@NonNull
@ColumnInfo(name = "id")
private int id;

@NonNull
@ColumnInfo(name = "name")
private String name;

@ColumnInfo(name = "phone")
private String phone;

@ColumnInfo(name = "email")
private String email;

public Contact(int id, String name, String phone, String email) {
this.id = id;
this.name = name;
this.phone = phone;
this.email = email;
}

@Ignore
public Contact(String name, String phone, String email) {
this.name = name;
this.phone = phone;
this.email = email;
}

public int getId() {return this.id;}

public String getName(){return this.name;}

public String getPhone() {return this.phone;}

public String getEmail() {return this.email;}
}
  • @Entity(tableName = "contacts_table")
    • Cada classe com a anotação entity representa uma tabela no banco de dados. Por padrão, o próprio nome da classe identifica o nome da tabela mas você pode especificar manualmente como no caso acima.
  • @PrimaryKey
    • Toda entidade precisa de uma chave primária. Para a tabela de contatos será adotado como chave primária o campo id
  • @NonNull
    • Identifica que o parâmetro, campo ou o valor de retorno do método nunca seja nulo
  • @ColumnInfo(name = "phone")
    • Especifica o nome da coluna na tabela. Por padrão, se adota o próprio nome do campo mas você pode definir manualmente.
  • Observe também que todos os campos devem possuir um método get e deve existir ao menos um construtor padrão com todos os campos para se poder instanciar os objetos. Nesta classe declaramos um construtor padrão e outro com a omissão do campo id. O construtor padrão vai ser invocado quando usarmos comandos como "select *", ou seja, queremos retornar uma lista de contatos do banco de dados. E o outro será usado quando formos adicionar um novo contato, pois ao omitir o campo id a biblioteca Room entende e fica automaticamente carregada de criar um valor id válido. Ademais, para a biblioteca Room entender qual é o construtor padrão devemos usar a anotação @Ignore nos demais construtores.

Você pode encontrar a lista completa de anotações nesta documentação.

3.3. Classe ContactsDAO (DAO)

O que é DAO?

Em um objeto DAO (Data Acess Object) vão ser declaradas as consultas SQL bem como sua associação a métodos específicos. O compilador verifica os comandos SQL e gera as consultas por conveniência através de anotações como @Insert, por exemplo.

Um objeto DAO deve ser implementado como uma interface ou uma classe abstrata. Assim, a biblioteca Room cria automaticamente uma API sem você precisar escrever muito código. Como resultado temos um código mais limpo.

Por padrão, todas as consultas são executadas em uma thread separada. Dessa forma, o programador não precisa de preocupar em implementar essa tarefa tão comum para cada consulta criada, tornando o processo do desenvolvimento do aplicativo mais rápido e produtivo!

Implementação da classe ContactsDAO

Na Agenda de Contatos vamos usar o banco de dados SQLite para armazenar contatos com nome, telefone e e-mail. Nesse aplicativo, vamos usar três telas: uma para exibir a lista de contatos, uma para adicionar ou editar um contato já existente e uma para exibir os detalhes de um contato selecionado. Portanto, precisamos implementar o DAO para realizar as seguintes operações:

  • Pesquisar todos os contatos salvos no banco de dados
    • e também pesquisar todas as informações de um contato específico
  • Inserir um novo contato
  • Editar um contato já existente
  • Apagar um contato específico
    • e também apagar todos os contatos da agenda. Usaremos essa consulta na fase de testes do aplicativo.

O código completo da classe ContactsDAO fica como:

package ...

import androidx.lifecycle.LiveData;
import androidx.room.Dao;
import androidx.room.Delete;
import androidx.room.Insert;
import androidx.room.Query;
import androidx.room.Update;

import java.util.List;

@Dao
public interface ContactsDAO {
@Query("SELECT * from contacts_table ORDER BY name ASC")
LiveData<List<Contact>> getAllContacts();

@Query("SELECT * from contacts_table WHERE id=:id")
LiveData<Contact> getContactById(int id);

@Insert
void insert(Contact contact);

@Update
void update(Contact contact);

@Delete
void delete(Contact contact);

@Query("DELETE FROM contacts_table")
void deleteAll();
}

  • @DAO
    • Anotação usada para identificar a classe como um DAO
  • @Query
    • Anotação usada para declarar uma consulta manualmente ao banco de dados. Declare entre os parênteses a consulta desejada. Logo abaixo da anotação deve ser implementado um método para executar a consulta.
    • Neste projeto duas consultas são declaradas, uma para buscar todos os contatos e outra para buscar apenas o contato com o id selecionado.
    • Observe que em ambos os casos o objeto de retorno é um dado do tipo LiveData<Tipo>. Esse é o retorno padrão dos métodos da biblioteca Room e é um dos componentes de arquitetura do Android Jetpack. Para mais informações consulte a documentação
  • @Insert, @Update, @Delete
    • Anotação para uma consulta do do tipo insert (inserção), update (atualização) e delete (remoção). Esse tipo de operação é extremamente comum logo não é necessário especificar nenhum comando SQL.
  • Método deleteAll
    • Não existe uma anotação para deletar todos os dados de uma tabela, logo devemos usar @Query para declarar manualmente o comando SQL para esse fim. De fato, para todos os casos em que não exista uma anotação conveniente, o comando SQL deve ser declarado explicitamente com a anotação @Query.

A classe LiveData

Quando os dados mudam você provavelmente deseja executar alguma ação, como exibir os dados atualizados na tela. Isso significa que você deve observador esse dados para perceber essas mudanças e ter uma reação. Dependendo de como os dados são armazenados, isso pode ser complicado. Observar mudanças nos dados através de múltiplos componentes dentro do aplicativo pode criar, explicitamente, dependências rígidas entre os componentes. Isso torna a tarefa de depurar e testar o código mais difícil, entre outras coisas.

LiveData, classe para observar dados, presente na biblioteca de componentes de ciclo de vida (lifecycle) dentro do Android Jetpack, representa uma solução para esse problema. A biblioteca Room usa esse tipo de dado para ser o valor de retorno das consultas ao banco e gera automaticamente todo o código necessário para atualizar a LiveData quando o banco de dados é atualizado.

3.4. Classe ContactsDatabase (Room database)

O que é uma Room database?

Room database é uma camada acima do banco de dados SQLite. A biblioteca Room simplifica a manipulação de banco de dados e se encarrega das tarefas comuns acessar ou criar um banco de dados SQLite.

  • Room usa os objetos DAO para fazer consultas a seu banco de dados.
  • Por padrão, não se deve fazer operações demoradas na thread principal do aplicativo, tais como as consultas SQL a um banco de dados, pois isso fará com que a interface gráfica trave. Você já deve ter visto a mensagem do Android: "O aplicativo está demorando e responder deseja encerrar?", não queremos isso em nossos aplicativos! .Portanto, para evitar essa situação devemos SEMPRE realizar operações demoradas em segundo plano. Felizmente, o casamento entre a biblioteca Room e classe LiveData
    aplica essa regra automaticamente e executa todas as consultas em uma thread em segundo plano.
  • Room oferece verificações em tempo de compilação para os comandos SQL. Sem mais erros de sintaxe em tempo de execução!
  • Sua classe Room deve ser declarada como abstrata e extender a classe RoomDatabase.
  • Normalmente, você precisará apenas de uma única instância da Room database em todo aplicativo. Assim, é comum empregar o padrão de projeto Singletonpara garantir a existência de apenas um objeto acessando o banco de dados.

Implementação da classe ContactsDatabase

A criação da classe ContactsDatabase usa anotações para declarar as tabelas do banco de dados e o padrão Singleton para garantir a existência de apenas uma instância da classe em todo o aplicativo. Além disso, nesta fase de construção do aplicativo vamos criar um callback, que nada mais é que um método para ser executado junto a criação do banco de dados, para gerar automaticamente alguns valores iniciais para o nosso banco.

package ...

import android.content.Context;
import android.os.AsyncTask;

import androidx.annotation.NonNull;
import androidx.room.Database;
import androidx.room.Room;
import androidx.room.RoomDatabase;
import androidx.sqlite.db.SupportSQLiteDatabase;

@Database(entities = {Contact.class}, version = 1, exportSchema = false)
public abstract class ContactsDatabase extends RoomDatabase {
public abstract ContactsDAO contactsDao();

private static volatile ContactsDatabase INSTANCE;

static ContactsDatabase getDatabase(final Context context) {
if (INSTANCE == null) {
synchronized (ContactsDatabase.class) {
if (INSTANCE == null) {
INSTANCE = Room.databaseBuilder(context.getApplicationContext(),
ContactsDatabase.class, "contacts_database")
// remover essa linha na versão final
.addCallback(sRoomDatabaseCallback)
.build();
}
}
}
return INSTANCE;
}

private static RoomDatabase.Callback sRoomDatabaseCallback =
new RoomDatabase.Callback(){

@Override
public void onOpen (@NonNull SupportSQLiteDatabase db){
super.onOpen(db);
new PopulateDbAsync(INSTANCE).execute();
}
};
private static class PopulateDbAsync extends AsyncTask<Void, Void, Void> {
private final ContactsDAO mDao;

PopulateDbAsync(ContactsDatabase db) {
mDao = db.contactsDao();
}

@Override
protected Void doInBackground(final Void... params) {
mDao.deleteAll();
Contact word = new Contact("Hello", "", "");
mDao.insert(word);
word =
new Contact("World", "", "");
mDao.insert(word);
return null;
}
}
}
  • Todo banco de dados deve usar a herança da classe RoomDatabase
  • @Database
    • Anotação para identificar a criação de uma Room database. Você deve declarar todas as entidades para usar no banco e também o número de versão. Cada entidade vai ser usada para criar as tabelas no banco de dados.
  • Declaramos todos os objetos DAO para a manipulação das consultas ao banco de dados provendo um método abstrato para cada @DAO
  • O padrão Singleton exige que a classe possua uma referência a uma instância dela mesma e um método getDatabase. Dessa forma, para obter um novo objeto ContactDatabase deve ser solicitado esse método get onde ele verifica a existência de uma instância existente e retorna esse valor ou, se é a primeira vez que o método é invocado (logo a classe ainda não foi instânciada) então ele cria uma instância do objeto
  • O método databaseBuilder é o responsável pela efetiva criação do banco de dados. Ele cria para o contexto da aplicação um banco de dados a partir da definição da classe ContactsDatabase e nomeia-o para "contacts_database"
  • Após a criação do banco é configurado uma ação para executar através do método addCallBack. A ação a ser executada irá através de uma AsyncTask adicionar alguns valores iniciais para o banco de dados.

3.5. Classe ContactsRepository (Repository)

O que é um Repositório?

A classe Repository é uma classe que abstrai o acesso a múltiplas fontes de dados. O repositório não é um componente de arquitetura do Android JetPack mas é uma prática recomendada. Um repositório manipula operações sobre as fontes de dados e oferece uma API limpa para ser usada pelo resto da aplicação. A figura abaixo ilustra como o camada repositório se encaixa na arquitetura do projeto. Note que UI se refere a User Interface, ou seja, a interface gráfica.

Porque usar um Repositório?

Um repositório gerencia as threads das consultas e permite o uso de múltiplas fontes de dados. Em um caso de uso mais comum, o repositório implementa a lógica para decidir de os dados devem ser obtidos através de um Web Service ou de uma cache armazenada em um banco de dados local.

Implementando a classe ContactsRepository

A classe ContactsRepository implementará todas as operações necessárias para o aplicativo Agenda de Contatos. Para cada consulta ao banco será usada uma AsyncTask para executar a operação em plano de fundo.

package ...

import android.app.Application;
import android.os.AsyncTask;

import androidx.lifecycle.LiveData;

import java.util.List;

public class ContactsRepository {
private ContactsDAO mContactsDao;
private LiveData<List<Contact>> mAllContacts; // create a cached data


public ContactsRepository(Application application) {
ContactsDatabase db = ContactsDatabase.getDatabase(application);
mContactsDao = db.contactsDao();
mAllContacts = mContactsDao.getAllContacts();
}

/***********************************************
GET ALL CONTACTS
***********************************************/
public LiveData<List<Contact>> getAllContacts() {
return mAllContacts;
}

/***********************************************
GET CONTACT BY ID
***********************************************/
public LiveData<Contact> getContactById(int id) {
return mContactsDao.getContactById(id);
}

/***********************************************
INSERT CONTACT TASKS
***********************************************/
public void insert (Contact contact) {
new insertAsyncTask(mContactsDao).execute(contact);
}

private static class insertAsyncTask extends AsyncTask<Contact, Void, Void> {
private ContactsDAO mAsyncTaskDao;

insertAsyncTask(ContactsDAO dao) {
mAsyncTaskDao = dao;
}

@Override
protected Void doInBackground(final Contact... params) {
mAsyncTaskDao.insert(params[0]);
return null;
}
}

/***********************************************
UPDATE CONTACT TASKS
***********************************************/
public void update (Contact contact) {
new updateAsyncTask(mContactsDao).execute(contact);
}

private static class updateAsyncTask extends AsyncTask<Contact, Void, Void> {
private ContactsDAO mAsyncTaskDao;

updateAsyncTask(ContactsDAO dao) {
mAsyncTaskDao = dao;
}

@Override
protected Void doInBackground(final Contact... params) {
mAsyncTaskDao.update(params[0]);
return null;
}
}

/***********************************************
DELETE CONTACT TASKS
***********************************************/
public void delete (Contact contact) {
new deleteAsyncTask(mContactsDao).execute(contact);
}

private static class deleteAsyncTask extends AsyncTask<Contact, Void, Void> {
private ContactsDAO mAsyncTaskDao;

deleteAsyncTask(ContactsDAO dao) {
mAsyncTaskDao = dao;
}

@Override
protected Void doInBackground(final Contact... params) {
mAsyncTaskDao.delete(params[0]);
return null;
}
}
}
  • O repositório cria dois objetos em seu construtor para serem usados ao longo do aplicativo. mContactsDAO representa o DAO para executar as operações no banco de dados e mAllContacts representa a lista de contatos.
  • Observe que não foi implementando um método para buscar todos os contatos. Isso ocorre durante o construtor e será invocado logo no inicio do aplicativo apenas uma vez. Quando um contato for adicionado, alterado ou removido, você deve imaginar que a lista de contatos deveria ser atualizada e um nova consulta ao banco de dados solicitado para exibir na tela essas novas informações. No entanto, estamos usando LiveData e assim não precisamos fazer uma nova solicitação pois qualquer mudança a lista de contatos será automaticamente processada! Assim, ao ser chamado o método getAllContacts podemos simplesmente retornar a lista de objetos LiveData sem ter que fazer explicitamente uma nova solicitação ao banco de dados com o DAO.
  • Ademais, no caso de ser buscado as informações de um contato específico precisamos apenas fazer a solicitação ao DAO pois ele irá automaticamente buscar o valor salvo na cache da consulta já feita logo não será uma tarefa demorada.
  • Para as demais operações, usamos uma AsyncTask para configurar uma ação em segunda plano e executar o método apropriado do DAO.

Classe AsyncTask

A classe AsyncTask é um classe que permite facilmente a declaração de ações para serem executadas em segundo plano sem você ter que manipular explicitamente threads. Essa classe é criada com a declaração de três tipos respectivamente um tipo associado aos parâmetros, progresso e resultado. Esses tipos serão usados nos métodos onPreExecute, doInBackground, onProgressUpdate e onPostExecute. Todo código declarado dentro do método doInBackground será executado em uma thread paralela. Você pode então fazer uma solicitação a um Web Service em paralela e após receber os dados executar uma ação, como atualizar os valores na tela, com o método onPostExecute.

No caso do aplicativo da Agenda de Contatos não precisamos de uma ação de retorno pois apenas iremos fazer solicitações para o banco de dados. Logo usar apenas o método doInBackground é o suficiente. Você poderia no entanto, mostrar uma mensagem de sucesso após uma consulta o banco usando o método onPostExecute.

4. ViewModel e vinculação de dados

O que é uma ViewModel?

O papel de uma ViewModel, é prover dados para a interface gráfica do usuário e sobreviver a mudanças nas configuração (Ciclo de Vida). Uma ViewModel atua como uma comunicação entre o repositório e a interface gráfica (atividade/fragmento). Você também pode usar uma ViewModel para compartilhas dados entre fragmentos diferentes. A classe ViewModel é um dos componentes da biblioteca LifeCycle do Android Jetpack.

Para uma introdução mas completa acessa a documentação.

Porque usar uma ViewModel?

Uma ViewModel guarda os dados de um aplicativo e observa os seus ciclos de vida de forma para garantir que eles sobrevivem a mudanças nas configurações. Uma simples mudança como girar a tela do dispositivo faz com que uma atividade/fragmento seja reconstruído o que pode acarretar perda das informações. ViewModel resolvem esse problema pois separa os dados da Activity/Fragment. 

Separar os dados da interface gráfica é uma maneira elegante de seguir o princípio da responsabilidade única: a atividade e o fragmento fica responsáveis em cuidar da apresentação dos dados na tela, enquanto a ViewModel se encarrega de armazenar e processar os dados necessários na interface gráfica.

Em uma ViewModel, se usa a classe LiveData para armazenar e observar os valores para serem usados ou mostrados pela interface gráfica. Usar LiveData trás alguns benefícios:

  • Você pode observar os dados e configurar para que mudanças sejam automaticamente processadas e mostradas na interface gráfica
  • O repositório e a interface gráfica estão separados da ViewModel. Não existem chamadas diretas ao banco de dados pela ViewModel, o que facilita a criação de testes unitários.

4.1. MainViewModel

Implementação da MainViewModel

A MainViewModel deve ser implementada para armazenar uma lista de contatos. Para tanto, usamos um elemento LiveData<List<Contact>> de forma que seja armazenada uma lista de contatos usando a classe LiveData. Durante a criação da ViewModel, é solicitado ao repositório da aplicação a lista de contatos. Como esses dados são representados por uma LiveData o fragmento pode solicitar esses dados com o método getAllContacts e configurar a observação desses dados para que mudanças no banco de dados reflitam nos dados apresentados na interface gráfica.

package ...

import android.app.Application;

import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;

import java.util.List;

import br.com.bcalegaro.agendacontatos.data.Contact;
import br.com.bcalegaro.agendacontatos.data.ContactsRepository;

public class MainViewModel extends AndroidViewModel {
private LiveData<List<Contact>> mAllContacts;
private ContactsRepository mRepository;

public MainViewModel (Application application) {
super(application);
mRepository = new ContactsRepository(application);
mAllContacts = mRepository.getAllContacts();
}

public LiveData<List<Contact>> getAllContacts() { return mAllContacts; }
}

Vinculação dos dados no MainFragment

Para fazer a vinculação dos dados da ViewModel ao fragmento vamos modificar o método onActivityCreated de MainFragment da seguinte forma:

@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
mainViewModel = new ViewModelProvider(this).get(MainViewModel.class);
// configura a observação da lista de contatos para atualizar a lista
// quando detectada uma mudança
mainViewModel.getAllContacts().observe(getViewLifecycleOwner(), new Observer<List<Contact>>() {
@Override
public void onChanged(@Nullable final List<Contact> contacts) {
// Atualiza a lista de contatos do adaptador
contactsAdapter.setContacts(contacts);
}
});
}

Ao fazer essa configuração, configuramos que mudanças na lista de contato disparem automaticamente a atualização dos dados no adaptador e, por tanto, nos dados exibidos na interface gráfica.

4.2. AddEditViewModel

Implementação da AddEditViewModel

A AddEditViewModel deve ser implementada para armazenar um contato específico e abstrair as solicitação ao repositório para adicionar um novo contato ou editar um já existente. Criamos o método getContactById para solicitar ao repositório um contato a partir de seu id e os métodos insert e update para o encaminhamento das operações ao repositório.

package ...

import android.app.Application;

import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;

import br.com.bcalegaro.agendacontatos.data.Contact;
import br.com.bcalegaro.agendacontatos.data.ContactsRepository;

public class AddEditViewModel extends AndroidViewModel {
private LiveData<Contact> contact;
private ContactsRepository mRepository;

public AddEditViewModel (Application application) {
super(application);
mRepository = new ContactsRepository(application);
}

public LiveData<Contact> getContactById(int id) {
contact = mRepository.getContactById(id);
return contact;
}

public void insert(Contact contact) { mRepository.insert(contact); }

public void update(Contact contact) { mRepository.update(contact); }
}

Vinculação dos dados na AddEditFragment

Para fazer a vinculação dos dados da ViewModel ao fragmento vamos modificar o método onActivityCreated de AddEditFragment. Nesse fragmento devemos, ou criar um novo contato e assim apresentar dados em branco nas caixas de texto, ou buscar as informações de um contato específico e apresentá-las na tela. Fazemos isso através da solicitação a ViewModel com o método getContactById e configurando que ao ser retornado os dados as informações na tela sejam atualizada.

@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
// cria uma ViewModel para o fragmento
addEditViewModel = new ViewModelProvider(this).get(AddEditViewModel.class);
// se estiver editando um contato existente atualiza a tela com os valores
if (addingNewContact == false) {
// usa a ViewModel para solicitar a busca pelo novo contato
addEditViewModel.getContactById(contactID).observe(getViewLifecycleOwner(), new Observer<Contact>() {
@Override
public void onChanged(@Nullable final Contact contact) {
// atualiza as informações da tela com os dados do contato lido
nameTextInputLayout.getEditText().setText(contact.getName());
phoneTextInputLayout.getEditText().setText(contact.getPhone());
emailTextInputLayout.getEditText().setText(contact.getEmail());
}
});
}
}

Ademais, precisamos modificar o método saveContact para encaminhar as solicitações corretas ao repositório. Ou seja, as solicitação de inserção de um novo contato ou edição de um contato já existente.

Primeiramente adicione os seguintes campos a classe:

// componentes EditText para informações de contato
private TextInputLayout nameTextInputLayout;
private TextInputLayout phoneTextInputLayout;
private TextInputLayout emailTextInputLayout;

Inicialize os campos corretamente na criação do fragmento :

@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
super.onCreateView(inflater, container, savedInstanceState);
// cria o fragmento com o layout do arquivo add_edit_fragment.xml
View view = inflater.inflate(R.layout.add_edit_fragment, container, false);
// obtem as referências dos componentes
nameTextInputLayout = view.findViewById(R.id.nameTextInputLayout);
phoneTextInputLayout = view.findViewById(R.id.phoneTextInputLayout);
emailTextInputLayout = view.findViewById(R.id.emailTextInputLayout);
// configura o receptor de eventos do FAB
saveContactFAB = view.findViewById(R.id.saveButton);
saveContactFAB.setOnClickListener(saveContactButtonClicked);
// acessa a lista de argumentos enviada ao fragmento em busca do ID do contato
Bundle arguments = getArguments();
contactID = arguments.getInt(CONTACT_ID);
// verifica se o fragmento deve criar um novo contato ou editar um já existente
if (contactID == NEW_CONTACT) {
// usa a flag para sinalizar que é um novo contato
addingNewContact = true;
} else {
// usa a flag para sinalizar que é uma edição
addingNewContact = false;
}
return view;
}

Por fim, modifquei o método saveContact.

// salva informações de um contato no banco de dados
private void saveContact() {
// faz a leitura dos dados inseridos
String name = nameTextInputLayout.getEditText().getText().toString();
String phone = phoneTextInputLayout.getEditText().getText().toString();
String email = emailTextInputLayout.getEditText().getText().toString();
// caso for adição de uma novo contato
if (addingNewContact) {
// cria um contato sem um ID pois ele será adicionado automaticamente no banco de dados
Contact contact = new Contact(name, phone, email);
// solicita a ViewModel a inserção do novo contato
addEditViewModel.insert(contact);
} else {
// cria um contato com o mesmo ID e atualiza o seus valores
Contact contact = new Contact(contactID, name, phone, email);
// solicita a ViewModel a atualização do contato
addEditViewModel.update(contact);
}
//Solicita a naveção voltar uma tela
Navigation.findNavController(getView()).popBackStack();
}

4.3. DetailViewModel

Implementação da DetailViewModel

A DetailViewModel deve ser implementada para armazenar um contato específico e abstrair a solicitação ao repositório de apagar um contato já existente. Criamos o método getContactById para solicitar ao repositório um contato a partir de seu id e o método delete para o encaminhamento da operação ao repositório.

package ...

import android.app.Application;

import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;

import br.com.bcalegaro.agendacontatos.data.Contact;
import br.com.bcalegaro.agendacontatos.data.ContactsRepository;

public class DetailViewModel extends AndroidViewModel {
private LiveData<Contact> mContact;
private ContactsRepository mRepository;

public DetailViewModel (Application application) {
super(application);
mRepository = new ContactsRepository(application);
}

public LiveData<Contact> getContactById(int id) {
mContact = mRepository.getContactById(id);
return mContact;
}

public void delete() { mRepository.delete(mContact.getValue()); }
}

Vinculação dos dados na DetailFragment

Para fazer a vinculação dos dados da ViewModel ao fragmento vamos modificar o método onActivityCreated de DetailFragment. Nesse fragmento devemos, ou criar um novo contato e assim apresentar dados em branco nas caixas de texto, ou buscar as informações de um contato específico e apresentá-las na tela. Fazemos isso através da solicitação a ViewModel com o método getContactById e configurando que ao ser retornado os dados as informações na tela sejam atualizadas.

Primeiramente, adicione os seguintes campos na classe:

// componentes TextView para informações de contato
private TextView nameTextView;
private TextView phoneTextView;
private TextView emailTextView;

Configure a inicialização dos campos:

@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
super.onCreateView(inflater, container, savedInstanceState);
// cria o fragmento com o layout do arquivo details_fragment.xml
View view = inflater.inflate(R.layout.detail_fragment, container, false);
// configura o fragmento para exibir itens de menu
setHasOptionsMenu(true);
// obtem as referências dos componentes
nameTextView = (TextView) view.findViewById(R.id.nameTextView);
phoneTextView = (TextView) view.findViewById(R.id.phoneTextView);
emailTextView = (TextView) view.findViewById(R.id.emailTextView);
// acessa a lista de argumentos enviada ao fragmento em busca do ID do contato
Bundle arguments = getArguments();
if (arguments != null)
contactID = arguments.getInt(CONTACT_ID);
return view;
}

Por fim, configure a ViewModel para buscar as informações de um contato e atualizar os dados na tela.

@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
detailViewModel = new ViewModelProvider(this).get(DetailViewModel.class);
detailViewModel.getContactById(contactID).observe(getViewLifecycleOwner(), new Observer<Contact>() {
@Override
public void onChanged(@Nullable final Contact contact) {
// atualiza as informações da tela com os dados do contato lido
nameTextView.setText(contact.getName());
phoneTextView.setText(contact.getPhone());
emailTextView.setText(contact.getEmail());
}
});
}

Por fim, modifique o método deleteContact para fazer o uso da ViewModel:

// exclui um contato
private void deleteContact() {
// usa a ViewModel para apagar o contato aberto
detailViewModel.delete();
Navigation.findNavController(getView()).popBackStack();
}

5. Conclusão

Neste livro, você finalizou a criação do aplicativo Agenda de Contatos para adicionar, ver, editar e excluir informações de contato armazenadas em um banco de dados SQLite.

Você usou uma única atividade par armazenar todos os fragmentos do aplicativo. Usou Navigation para exibir fragmentos dinamicamente. Usou a pilha de retrocesso (BackStack) para fornecer suporte automático ao botão de voltar do Android. 

Você usou a biblioteca Room para abstrair o uso de banco de dados SQLite no dispositivo. Também criou um objeto DAO para manipular as operações SQL de consulta, inserção, atualização e remoção de contatos no banco de dados. Para acessar o banco de dados de forma assíncrona, fora da thread da interface gráfica do usuário, você fez o uso da classe AsyncTask. Você configurou a vinculação dos dados proveniente do repositório da aplicação com o uso de ViewModels e a classe LiveData.

No final, temos um aplicativo robusto fazendo o uso dos melhores componentes de arquitetura disponibilizados no Android Jetpack. Seguindo o princípio da responsabilidade única temos uma arquitetura onde os fragmentos estão responsáveis em como apresentar os dados ao usuário, as ViewModels se encarregam de armazenar as informações e fazer solicitações ao repositório, o repositório se encarrega de se comunicar com a biblioteca Room e encaminhar solicitação ao objeto DAO, e a os componentes da biblioteca Room se encarregam de se comunicar com o banco de dados SQLite em si. Dessa forma, construímos um aplicativo robusto, testável e eficiente.