Persistência de dados no Android com SQLite

Em muitos aplicativos no Android, existe a necessidade de salvar certos dados estruturados como o progresso do usuário em uma determinada ação quando o aplicativo é pausado.

Neste post, irei abordar o uso do banco de dados SQLite no Android.

O SQLite

O SQLite é uma biblioteca que fornece um banco de dados relacional e leve em termos de configuração, administração e espaço. Além de não ter dependências externas e ser multiplataforma, o SQLite armazena todo o banco de dados em um único arquivo. Isto o torna perfeito para ser usado em dispositivos como um celular.

Não vou falar sobre os detalhes do SQLite, então recomendo a leitura da documentação oficial.

SQLite no Android

O framework do Android disponibiliza um conjunto de APIs prontas para realizarmos o acesso a um banco de dados SQLite para nossos aplicativos. Estas APIs se encontram no pacote android.database.sqlite.

O principal utilitário que iremos usar é o SQLiteOpenHelper, que é encarregado de abrir, criar ou atualizar o banco de dados caso necessário.

Criando o DbHelper

Como a classe SQLiteOpenHelper é uma classe abstrata, devemos herdá-la para poder usar todos os recursos que ela disponibiliza.

Com esta herança, devemos implementar dois métodos: onCreate() e onUpgrade().

Nossa classe que herda SQLiteOpenHelper será chamada de DbHelper. Para o construtor, devemos informar o Context, o nome do banco de dados e a versão.

public class DbHelper extends SQLiteOpenHelper {

    public DbHelper(Context context) {
        super(context, "vendas.db", null, 1);
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        // Implementar a criação das tabelas
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        // Implementar a atualização do banco baseado nas versões (oldVersion e newVersion)
    }
}

A inicialização do DbHelper se resume a um simples new.

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        DbHelper dbHelper = new DbHelper(this);
    }
}

Meios de acesso

Existem duas formas de acessar nosso banco de dados: obter uma instância de leitura ou uma instância de escrita. Conseguimos isso através dos métodos getReadableDatabase() e getWritableDatabase() respectivamente.

Usando o getReadableDatabase(), obtemos uma instâncida de SQLiteDatabase para realizarmos operações de leitura. Já o getWritableDatabase(), obtemos uma instância de SQLiteDatabase para operações de escrita.

Implementando o método onCreate()

Ao obtermos uma instância de SQLiteDatabase, o SQLiteOpenHelper cuida de criar o banco caso este não exista. Assim o método onCreate() é executado para criar as tabelas do banco.

Sobrescrevendo o método onCreate(), recebemos uma instância de SQLiteDatabase por parâmetro para executarmos as ações desejadas. Lembrando que esta instância de SQLiteDatabase é uma instância que permite escrita.

No caso iremos usar apenas o execSQL(), que nos permite executar um comando SQL passado por parâmetro.

Como no nome do meu banco de dados deste exemplo é vendas.db, irei criar uma tabela chamada clientes.

@Override
public void onCreate(SQLiteDatabase db) {
    db.execSQL("CREATE TABLE clientes (" +
            "_ID INT PRIMARY KEY," +
            "nome TEXT NOT NULL," +
            "cpf TEXT NOT NULL);");
}

Então quando o banco de dados for criado pela primeira vez teremos nossa tabela clientes criada também.

Inserindo dados

Como já citado anteriormente, usamos o método getWritableDatabase() para executarmos operações de escrita no banco.

DbHelper dbHelper = new DbHelper(this);

SQLiteDatabase db = dbHelper.getWritableDatabase();

O método adequado para inserção de dados é o insert() da classe SQLiteDatabase.

Duas informações são necessárias para o método insert(): a tabela e os valores das colunas. A tabela é uma simples String e os valores são passados através de um ContentValues.

A classe ContentValues armazena os dados no formato chave-valor, que seria equivalente a coluna-valor.

DbHelper dbHelper = new DbHelper(this);

SQLiteDatabase db = dbHelper.getWritableDatabase();

ContentValues novoCliente = new ContentValues();
novoCliente.put("nome", "João Vitor");
novoCliente.put("cpf", "111.222.333-44");

db.insert("clientes", null, novoCliente);

O método insert() retorna um long que representa o id do novo registro, ou -1 caso ocorra um problema durante a inserção.

Atualizando dados

O método update() é bem parecido com o método insert(), a diferença é que podemos definir a condição de atualização, através de dois parâmetros: whereClause e whereArgs.

Para o whereClause definimos os campos que fazem parte da condição, como o whereClause é uma String e não um array, devemos definir os campos da seguinte maneira: “campo1 = ? AND campo2 = ?”. Exatamente como uma cláusula WHERE do SQL, porém ao invés de informar os valores, informamos interrogações.

Estas interrogações serão substituidas pelos valores do parâmetro whereArgs.

DbHelper dbHelper = new DbHelper(this);

SQLiteDatabase db = dbHelper.getWritableDatabase();

ContentValues novosValores = new ContentValues();
novoCliente.put("nome", "João");

db.update("clientes", novosValores, "cpf = ?", new String[]{"111.222.333-44"});

Esta técnica previne possíveis ataques de SQL Injection.

Removendo dados

Parecido com os métodos anteriores, no método delete() devemos informar apenas o nome da tabela, a condição e seus valores.

DbHelper dbHelper = new DbHelper(this);

SQLiteDatabase db = dbHelper.getWritableDatabase();

db.delete("clientes", "nome = ?", new String[]{"João"});

Consultando dados

O método query() é o mais indicado para recuperar dados do banco.

Com ele podemos informar a tabela, as colunas, a condição (WHERE), agrupamento (GROUP BY), condição pós-agrupamento (HAVING), ordenação (ORDER BY) e até definir limites (LIMIT).

Apesar de ter vários parâmetros, o mínimo necessário para o método query() funcionar, é a tabela e as colunas, as outras informações são opcionais e podemos informar null.

O retorno deste método é uma instância da classe Cursor. Esta classe representa o resultado de uma consulta.

DbHelper dbHelper = new DbHelper(this);

SQLiteDatabase db = dbHelper.getReadableDatabase();

String[] colunas = new String[]{"nome", "cpf"};

Cursor cursor = db.query("clientes", colunas, null, null, null, null, "nome");

A classe Cursor disponibiliza métodos de navegação como moveToFirst(), moveToLast(), moveToNext() e moveToPrevious(). Todos estes métodos retornam um Boolean, assim podemos saber se houve sucesso ou não.

Também podemos usar métodos auxiliares como getCount(), getPosition(), isAfterLast(), isBeforeFirst(), isFirst() e isLast().

Para acessar um dado da consulta, usamos o índice (iniciado em zero) da coluna. Um facilitador é o getColumnIndex() onde este método retorna o índice do nome da coluna passada por parâmetro.

DbHelper dbHelper = new DbHelper(this);

SQLiteDatabase db = dbHelper.getReadableDatabase();

String[] colunas = new String[]{"nome", "cpf"};

Cursor cursor = db.query("clientes", colunas, null, null, null, null, "nome");

if (cursor.moveToFirst()) {
    final int INDICE_NOME = 0;
    final int INDICE_CPF = 1;

    String nomeCliente;
    String cpfCliente;

    do {
        nomeCliente = cursor.getString(INDICE_NOME);
        cpfCliente = cursor.getString(INDICE_CPF);
        
        // Aqui você já em os valores desejados e pode executar
        // qualquer coisa que precisar com os valores
    } while (cursor.moveToNext());
}

Dicas

Cuidado com o getColumnIndex() dentro de loops

O método getColumnIndex() é bem conveniente, informamos o nome da coluna que desejamos e recebemos o índice dela. Mas isso pode ser perigoso dentro de um loop.

if (cursor.moveToFirst()) {
    String nomeCliente;
    String cpfCliente;

    do {
        nomeCliente = cursor.getString(cursor.getColumnIndex("nome"));
        cpfCliente = cursor.getString(cursor.getColumnIndex("cpf"));
    } while (cursor.moveToNext());
}

A execução de getColumnIndex() é necessário apenas uma vez: antes do loop.

if (cursor.moveToFirst()) {
    int indiceNome = cursor.getColumnIndex("nome");
    int indiceCpf = cursor.getColumnIndex("cpf");

    String nomeCliente;
    String cpfCliente;

    do {
        nomeCliente = cursor.getString(indiceNome);
        cpfCliente = cursor.getString(indiceCpf);
    } while (cursor.moveToNext());
}

Muito menos processamento e mais performático, não?

Acesse uma coluna pelo índice quando possível

Na maioria das consultas através do método query() as colunas são fixas, ou seja, não dependem de alguma condição para aparecer ou não.

Nestes casos o uso do getColumnIndex() é desnecessário e só aumenta o processamento.

Acesse as colunas diretamente pelo seu índice.

if (cursor.moveToFirst()) {
    final int INDICE_NOME = 0;
    final int INDICE_CPF = 1;

    String nomeCliente;
    String cpfCliente;

    do {
        nomeCliente = cursor.getString(INDICE_NOME);
        cpfCliente = cursor.getString(INDICE_CPF);
    } while (cursor.moveToNext());
}

Os métodos getWritableDatabase() e getReadableDatabase() podem ser perigosos

Devido ao fato do SQLiteOpenHelper se encarregar de criar ou atualizar o banco de dados para nós, executar o método getReadableDatabase() ou getWritableDatabase() pode demorar um pouco dependendo da quantidade de operações que você realiza para criação ou atualização do banco de dados.

Portanto recomendo mover as chamadas destes métodos para uma thread diferente da thread principal.

Para operações em massa, o beginTransaction() te ajudará

Quando temos muito INSERT e desejamos que todos sejam inseridos corretamente, podemos iniciar uma transação para esta operação.

O método que inicia uma transação é o beginTransaction(), e ao final de qualquer transação (com sucesso ou não) devemos executar o método endTransaction().

db.beginTransaction();
try {
    for (int i = 0; i < dadosClientes.length; i++)
        db.insert("clientes", null, dadosClientes[i]);

    db.setTransactionSuccessful();
} finally {
    db.endTransaction();
}

O sucesso da operação é marcado pelo método setTransactionSuccessful().

Quando executamos endTransaction() sem executar setTransactionSuccessful(), é entendido que a transação não foi bem sucedida, portando as alteraçõe são revertidas.

Singleton ao resgate

Provavelmente instanciaremos o DbHelper várias vezes em nossas aplicações, e para evitarmos despejos de memória e alocações desnecessárias, podemos criar apenas uma instância do DbHelper e usar a mesma instância por toda a aplicação usando o padrão Singleton.

public class DbHelper extends SQLiteOpenHelper {

    private static DbHelper instance;

    private DbHelper(Context context) {
        super(context, "vendas.db", null, 1);
    }

    public static synchronized DbHelper getInstance(Context context) {
        if (instance == null) {
            instance = new DbHelper(context.getApplicationContext());
        }
        return instance;
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        db.execSQL("CREATE TABLE clientes (" +
                "_ID INT PRIMARY KEY," +
                "nome TEXT NOT NULL," +
                "cpf TEXT NOT NULL);");
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
    }
}

Ao invés de usarmos o new, usaremos o método getInstance().

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        DbHelper dbHelper = DbHelper.getInstance(this);
    }
}

Conclusão

Neste post, abordei apenas o necessário, mesmo não existindo conteúdo muito avançado sobre o SQLiteOpenHelper.

Como estudo complementar, recomendo ver o método rawQuery().

Até mais.