Flutter SQLite

Flutter SQLite é a combinação mais comum para guardar dados localmente em apps Android e iOS, e neste artigo você vai ver como implementar isso do zero com o pacote sqflite. Todo o conteúdo se baseia no curso Flutter: App Android e iOS nativo, SQLite, Biometria, Câmera, GPS e Google Maps, onde implementamos o SQLite na prática em um app nativo completo, com o mesmo codebase para as duas plataformas.

Ao longo do texto, vamos criar o banco, definir o script da tabela, preparar o modelo de dados e escrever as quatro operações básicas. Além disso, você vai entender por que o pacote já protege seu app contra SQL Injection quando usado do jeito certo.

Flutter SQLite e banco de dados local

O SQLite com certeza é um dos bancos locais mais populares do mercado. Por isso, as chances de você precisar trabalhar com ele em algum projeto mobile são grandes.

Diferente de um servidor de banco de dados, o SQLite grava tudo em um único arquivo dentro do próprio dispositivo. Dessa forma, os dados continuam lá mesmo depois que o usuário fecha o app ou reinicia o aparelho.

Além disso, o uso do Flutter SQLite é relativamente simples. O pacote sqflite já oferece métodos auxiliares que dispensam a escrita de boa parte do código SQL e, ao mesmo tempo, trabalham com parâmetros que evitam SQL Injection.

Vou deixar aqui também um vídeo onde falo um pouco mais sobre SQL Injection. É um assunto batido há muito tempo, mas que ainda traz dor de cabeça em diversos cenários.

Miniatura do vídeo do balta.io sobre SQL Injection, tema relacionado à segurança no uso de Flutter SQLite
Vídeo sobre SQL Injection, disponível no YouTube.

Você pode assistir ao vídeo sobre SQL Injection no YouTube antes de seguir, porque ele ajuda a entender o motivo de usarmos parâmetros nas consultas.

E o Hive?

Além do Flutter SQLite, havia também o Hive como opção. Ele é um banco de dados local, NoSQL e escrito em Dart, o que parece o casamento perfeito.

Porém, existem alguns riscos que não gostaria de correr naquele momento. Além disso, o Hive é menos popular que o SQLite, então a comunidade e o material de apoio são menores.

Se você ainda está decidindo entre as opções de gerenciamento de estado e arquitetura do app, vale ler também o artigo Flutter: por onde começar.

Dependências do Flutter SQLite

Para trabalhar com Flutter SQLite, precisamos do pacote sqflite, que vai prover tudo de que precisamos no nível do banco de dados. Ele é o plugin mais usado para SQLite no Flutter e roda no Android, no iOS e no macOS.

Como o SQLite é um banco local, vamos precisar salvar o arquivo dele no dispositivo. Por isso, entram em cena dois pacotes adicionais, o path e o path_provider.

O path serve para montar caminhos de arquivo de forma segura em qualquer sistema. Já o path_provider ajuda a localizar pastas padrão do dispositivo, como a pasta de documentos do app.

Dessa forma, crie uma nova aplicação com Flutter e adicione os seguintes pacotes no pubspec.yaml, logo abaixo da seção dependencies.

dependencies:
  flutter:
    sdk: flutter
  path:
  path_provider:
  sqflite:

Em seguida, rode o comando de restauração de pacotes da sua versão do Flutter para baixar as dependências. Deixar a versão em branco faz o gerenciador escolher a mais recente compatível com o seu projeto.

Script de criação do banco no Flutter SQLite

O primeiro passo para começar com Flutter SQLite é criar o banco de dados. Nesse momento, não temos como fugir da escrita de código SQL.

Como o foco deste artigo não é a linguagem SQL, vou deixar o código que utilizei para criar o banco de dados no curso 7201. Ele cria uma tabela de contatos com nome, e-mail, telefone, imagem, endereço e coordenadas.

CREATE TABLE contacts(id INTEGER PRIMARY KEY, name TEXT, email TEXT, phone TEXT, image TEXT, addressLine1 TEXT, addressLine2 TEXT, latLng TEXT)

Repare que a coluna id é do tipo INTEGER PRIMARY KEY. No SQLite, isso faz o próprio banco gerar o valor automaticamente quando você não informa nenhum na inserção.

Além disso, o SQLite trabalha com poucos tipos de dado. O sqflite mapeia INTEGER para int, REAL para num, TEXT para String e BLOB para Uint8List.

Valores booleanos e datas não têm tipo próprio. Na prática, você grava booleanos como 0 ou 1 e datas como texto no formato ISO 8601 ou como milissegundos desde 1970.

Guardando o script em constantes

Eu armazeno sempre esses códigos SQL em constantes no código. Assim, evito mudanças em tempo de execução e possíveis erros de digitação espalhados pelo projeto.

Para este caso específico, criei uma constante chamada CREATE_CONTACTS_TABLE_SCRIPT com o conteúdo como String. Também recomendo guardar o nome do banco e da tabela em constantes, como DATABASE_NAME e TABLE_NAME.

const String DATABASE_NAME = "contacts.db";
const String TABLE_NAME = "contacts";

// Script executado apenas na criação do banco
const String CREATE_CONTACTS_TABLE_SCRIPT =
    "CREATE TABLE contacts(id INTEGER PRIMARY KEY, name TEXT, email TEXT, phone TEXT, image TEXT, addressLine1 TEXT, addressLine2 TEXT, latLng TEXT)";

O nome contacts.db é apenas um exemplo. O importante é que o mesmo valor seja usado em todos os pontos que abrem o banco.

Como o Flutter SQLite funciona?

No SQLite, não existe um servidor esperando conexões o tempo todo. Em vez disso, abrimos o arquivo do banco, executamos as consultas e trabalhamos com os resultados.

Dessa forma, sempre que formos executar algo no Flutter SQLite, precisamos da função openDatabase para abrir o banco antes. Ela devolve um objeto Database, que expõe todos os métodos de leitura e escrita.

Para facilitar, minha sugestão é criar um método que retorna o banco de dados. Se possível, aplique também o Repository Pattern para abstrair esse acesso a dados das telas do app.

O conceito é o mesmo que usamos no back-end. Se quiser se aprofundar no padrão, leia o artigo sobre Repository Pattern em aplicações .NET, que explica a ideia com outra stack.

Abrindo o banco com openDatabase

Neste caso, vou criar um método privado e assíncrono chamado _getDatabase(), que nos retorna o banco de dados pronto para uso. Caso o banco ainda não exista, vamos pedir ao SQLite que o crie usando o script que definimos anteriormente.

Note que a criação ou abertura do banco é um processo que pode demorar. Por isso, o retorno é um Future.

import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';

Future<Database> _getDatabase() async {
  return openDatabase(
    // Monta o caminho completo do arquivo do banco
    join(await getDatabasesPath(), DATABASE_NAME),
    onCreate: (db, version) {
      // Executado apenas quando o arquivo ainda não existe
      return db.execute(CREATE_CONTACTS_TABLE_SCRIPT);
    },
    version: 1,
  );
}

O método getDatabasesPath recupera o caminho padrão para bancos de dados no dispositivo. Em seguida, o join do pacote path une esse caminho ao nome do arquivo.

Caso o arquivo não seja encontrado, o sqflite dispara o callback onCreate. Dentro dele, executamos o script SQL guardado na constante CREATE_CONTACTS_TABLE_SCRIPT.

Versão do banco e migrações

O parâmetro version é o que permite evoluir o esquema com o tempo. Quando você aumenta esse número em uma nova versão do app, o sqflite chama o callback onUpgrade, onde você pode adicionar colunas ou criar tabelas novas.

Segundo a documentação do pacote, tanto o onCreate quanto o onUpgrade rodam dentro de uma transação. Ou seja, se algo falhar no meio do script, nada fica pela metade.

A documentação também recomenda manter uma única instância do banco aberta durante a vida do app. Com isso, você evita o custo de abrir o arquivo repetidas vezes, então vale guardar o Database em uma variável depois da primeira chamada.

Operações básicas com Flutter SQLite

Com o banco de dados aberto, podemos executar as operações básicas que chamamos de CRUD. A sigla vem de Create (criar), Read (ler), Update (atualizar) e Delete (excluir).

O sqflite oferece dois caminhos para isso. Você pode escrever SQL puro com métodos como rawQuery e rawInsert, ou então usar os métodos auxiliares insert, query, update e delete, que montam o SQL por você.

Neste artigo, vamos usar os métodos auxiliares. Eles reduzem a quantidade de código e já trabalham com parâmetros, o que mantém o Flutter SQLite protegido contra SQL Injection.

Preparando o modelo

Antes de prosseguir, precisamos de um modelo de dados. Isso pode, e deve, ser feito pelos models da aplicação.

O sqflite sempre recebe e retorna um Map<String, dynamic>. Por isso, é uma boa ideia deixar o modelo preparado para ser convertido de e para essa estrutura, com um método toMap e um construtor fromMap.

class ContactModel {
  int id = 0;
  String name = "";
  String email = "";
  String phone = "";
  String image = "assets/images/profile-picture.png";
  String addressLine1 = "";
  String addressLine2 = "";
  String latLng = "";

  ContactModel({
    this.id,
    this.name,
    this.email,
    this.phone,
    this.image,
    this.addressLine1,
    this.addressLine2,
    this.latLng,
  });

  // Converte um registro do banco em modelo
  ContactModel.fromMap(Map<String, dynamic> map) {
    id = map['id'];
    name = map['name'];
    email = map['email'];
    phone = map['phone'];
    image = map['image'];
    addressLine1 = map['addressLine1'];
    addressLine2 = map['addressLine2'];
    latLng = map['latLng'];
  }

  // Converte o modelo no formato esperado pelo sqflite
  Map<String, dynamic> toMap() {
    return {
      'id': id,
      'name': name,
      'phone': phone,
      'email': email,
      'image': image,
      'addressLine1': addressLine1,
      'addressLine2': addressLine2,
      'latLng': latLng,
    };
  }
}

As chaves do mapa precisam ter exatamente os mesmos nomes das colunas da tabela. Caso contrário, o sqflite tenta gravar em uma coluna que não existe e lança uma exceção.

Esse código segue a sintaxe do Dart da época do curso, antes do null safety. Em projetos com Dart 2.12 ou superior, os parâmetros opcionais precisam de valores padrão ou de tipos anuláveis, como int?.

Salvando um registro no Flutter SQLite

No sqflite, o método db.insert faz a inserção de um novo registro. Nele, precisamos informar a tabela e o item que desejamos incluir.

Lembre-se de que esse item precisa estar no formato Map<String, dynamic>. Então, convertemos o modelo usando o método toMap() criado anteriormente.

Para exemplificar, criei um método chamado create, que recebe um modelo e o insere no banco de dados.

Future create(ContactModel model) async {
  try {
    final Database db = await _getDatabase();

    await db.insert(
      TABLE_NAME,
      model.toMap(),
    );
  } catch (ex) {
    // Registra o erro sem derrubar o app
    print(ex);
    return;
  }
}

Note que executamos tudo dentro de um try/catch, pois podem ocorrer erros de execução ou de disco aqui. É bom sempre ficar prevenido.

Primeiro, recuperamos o banco com await _getDatabase(). Em seguida, o db.insert persiste o modelo na tabela definida pela constante TABLE_NAME.

O insert também aceita o parâmetro conflictAlgorithm. Com ConflictAlgorithm.replace, por exemplo, um registro com o mesmo id é substituído em vez de gerar erro de chave duplicada.

Lendo os registros

Para a leitura no Flutter SQLite, usamos o método query, enviando como parâmetro o nome da tabela de onde queremos buscar os resultados. Ele também aceita filtros, ordenação e limite, como where, orderBy e limit.

Esse método retorna um List<Map<String, dynamic>>. Embora você possa devolver essa mesma lista para a interface, é comum retornarmos uma lista tipada, ou seja, um List<ContactModel>.

Dessa forma, temos o método getContacts(), que busca os contatos, converte o resultado para uma lista tipada e o devolve.

Future<List<ContactModel>> getContacts() async {
  try {
    final Database db = await _getDatabase();
    final List<Map<String, dynamic>> maps = await db.query(TABLE_NAME);

    // Converte cada mapa em um ContactModel
    return List.generate(
      maps.length,
      (i) {
        return ContactModel.fromMap(maps[i]);
      },
    );
  } catch (ex) {
    print(ex);
    return new List<ContactModel>();
  }
}

Note que usamos o List.generate para percorrer os mapas e gerar uma nova lista do tipo List<ContactModel>. Em caso de erro, devolvemos uma lista vazia, assim a tela não quebra por falta de dados.

Como o retorno é um Future, a interface costuma consumir esse método com um FutureBuilder. Dessa forma, o widget mostra um indicador de carregamento enquanto a consulta não termina.

Update e delete no Flutter SQLite

Neste momento, você já deve ter pego a ideia de como o Flutter SQLite trabalha. Basicamente, abrimos o banco e pedimos para uma instrução ser executada com base no nosso modelo.

Para o update e o delete, precisamos de um parâmetro adicional, que é o id do contato. Isso é feito com as propriedades where e whereArgs.

await db.update(
  TABLE_NAME,
  model.toMap(),
  // O ? será substituído pelo valor de whereArgs
  where: "id = ?",
  whereArgs: [model.id],
);

Na propriedade where, escrevemos o filtro em SQL, usando um ? onde queremos um parâmetro. Em seguida, o whereArgs substitui cada ? pelos valores informados.

É importante lembrar que essa substituição ocorre na ordem. Ou seja, o primeiro ? recebe o primeiro valor da lista, e assim por diante.

O último método, o delete, chama a função db.delete() da mesma forma que fizemos no update, inclusive informando o id.

await db.delete(
  TABLE_NAME,
  where: "id = ?",
  whereArgs: [id],
);

Tanto o update quanto o delete retornam a quantidade de linhas afetadas. Com isso, você consegue saber se o registro realmente existia antes da operação.

Segurança com whereArgs

A documentação oficial do Flutter, na receita Persist data with SQLite, é direta sobre esse ponto. Ela pede para usar sempre whereArgs ao passar argumentos para um where, porque isso ajuda a proteger o app contra ataques de SQL Injection.

Na prática, isso significa nunca usar interpolação de string, como where: "id = ${model.id}". Embora pareça inofensivo com números, o mesmo hábito em um campo de texto abre espaço para que um valor malicioso altere a consulta.

Com o ? e o whereArgs, o valor viaja separado do comando SQL. Assim, o banco trata o conteúdo sempre como dado, e nunca como parte da instrução.

Transações e operações em lote

Em cenários com várias escritas seguidas, o Flutter SQLite com sqflite oferece transações. Dentro de db.transaction, todas as operações são confirmadas juntas e, se uma delas falhar, o pacote desfaz as outras automaticamente.

Além disso, existe o recurso de batch, que agrupa várias instruções em uma única ida ao código nativo. Segundo a documentação do pacote, isso reduz a comunicação entre o Dart e a plataforma e melhora o desempenho em importações grandes.

Future importContacts(List<ContactModel> contacts) async {
  final Database db = await _getDatabase();
  final Batch batch = db.batch();

  // Enfileira todas as inserções
  for (final contact in contacts) {
    batch.insert(TABLE_NAME, contact.toMap());
  }

  // Envia tudo de uma vez para o banco
  await batch.commit(noResult: true);
}

O parâmetro noResult: true evita que o pacote monte a lista de resultados de cada instrução. Por isso, ele é útil quando você não precisa dos ids gerados.

Código fonte

O código deste artigo se baseia no projeto que realizamos no curso Flutter: App Android e iOS nativo, SQLite, Biometria, Câmera, GPS e Google Maps. O projeto completo está disponível neste repositório do GitHub.

No repositório, você encontra o Flutter SQLite integrado ao restante do app, junto com biometria, câmera, GPS e mapas. Vale clonar o projeto e rodar no emulador para ver o fluxo completo.

Conteúdo adicional

Se você se interessou por este conteúdo, talvez valha a pena dar uma olhada neste bate papo que fizemos sobre o Flutter. Nele, falamos de muitos pontos interessantes sobre o framework e o mercado.

Miniatura do bate papo do balta.io sobre Flutter, com participantes conversando sobre o framework
Bate papo sobre Flutter, disponível no YouTube.

Você pode assistir ao bate papo sobre Flutter no YouTube quando quiser. Além disso, o artigo Flutter setState, BLoC e MobX ajuda a escolher como levar os dados do banco até a tela.

Conclusão

O Flutter SQLite resolve de forma simples a persistência local em apps Android e iOS. Com o sqflite, o path e o path_provider, você cria o banco, define o esquema e executa o CRUD com poucas linhas de código.

Em resumo, guarde scripts e nomes em constantes, centralize a abertura do banco em um único método e prepare seus modelos com toMap e fromMap. Por fim, use sempre whereArgs nos filtros, porque é ele que mantém suas consultas protegidas.

FAQ

Qual pacote usar para SQLite no Flutter?

O pacote mais usado é o sqflite. Ele funciona no Android, no iOS e no macOS e oferece métodos auxiliares para o CRUD.

Preciso escrever SQL para usar o sqflite?

Apenas no script de criação das tabelas. Para inserir, consultar, atualizar e excluir, os métodos auxiliares montam o SQL por você.

Onde o banco SQLite fica salvo no dispositivo?

Na pasta padrão de bancos de dados do app, retornada por getDatabasesPath. O arquivo fica no armazenamento interno e persiste entre as execuções.

Como evitar SQL Injection no sqflite?

Use sempre ? no where e passe os valores em whereArgs. Nunca monte filtros com interpolação de string.

Como alterar a estrutura do banco depois de publicado?

Aumente o número da version no openDatabase e implemente o callback onUpgrade com os comandos de alteração.

Glossário

Termo Significado
SQLite Banco de dados relacional embutido, que grava tudo em um único arquivo local.
sqflite Plugin do Flutter que dá acesso ao SQLite no Android, iOS e macOS.
CRUD Sigla para as operações de criar, ler, atualizar e excluir registros.
Future Tipo do Dart que representa um valor disponível no futuro, após uma operação assíncrona.
whereArgs Lista de valores que substituem os marcadores ? de um filtro no sqflite.
SQL Injection Ataque que insere comandos SQL maliciosos por meio de dados de entrada.
Batch Agrupamento de várias instruções enviadas ao banco de uma só vez.
Repository Pattern Padrão que isola o acesso a dados atrás de uma classe dedicada.

Fontes