Couchbase: o que é e como usá-lo no Flutter para criar um app offline first

Mikael Diniz
Mikael Diniz

Compartilhe

Avalie este artigo

Fala, galera dev! Tudo certo?

Você já criou alguma aplicação offline first que faz persistência de dados localmente? E uma que se comunica com APIs externas?

Cada uma dessas tarefas já é um desafio por si só; agora imagine a complexidade ao lidar com ambas ao mesmo tempo.

Neste artigo, vamos explorar como gerenciar dados tanto localmente quanto remotamente em uma aplicação e mostrar como o Couchbase simplifica esse processo.

Conhecendo o app Mood

Para entender o gerenciamento de dados locais e remotos, vamos trabalhar com um aplicativo Flutter de humor, em que a pessoa usuária pode escrever como está se sentindo.

Começaremos analisando a classe MyMood, que representa o humor:

class MyMood {
  final String message;
  MyMood({required this.message});
  factory MyMood.fromJson(Map<String, dynamic> json) {
    return MyMood(
      message: json['message'],
    );
  }
  Map<String, dynamic> toMap() {
    return <String, dynamic>{
      'message': message,
    };
  }
}

Essa classe MyMood representa um modelo de dados com uma única propriedade message.

Ela inclui métodos para criar uma instância a partir de um JSON e para converter a instância de volta para um mapa (Map) de dados.

A tela principal da aplicação está implementada da seguinte forma:

import 'package:flutter/material.dart';
void main() {
  runApp(MyApp());
}
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'App de Humor',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MoodScreen(),
    );
  }
}
class MoodScreen extends StatefulWidget {
  @override
  _MoodScreenState createState() => _MoodScreenState();
}
class _MoodScreenState extends State<MoodScreen> {
  String _moodMessage = "Estou feliz"; 
  final TextEditingController _controller = TextEditingController();
  void _saveMood() {
    if (_controller.text.isNotEmpty) {
      setState(() {
        _moodMessage = _controller.text; 
        _controller.clear();
      });
    }
  }
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Seu Humor do Dia'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text(
              'Seu Humor:',
              style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
            ),
            SizedBox(height: 8),
            Text(
              _moodMessage,
              style: TextStyle(fontSize: 20, fontStyle: FontStyle.italic),
            ),
            SizedBox(height: 20),
            TextField(
              controller: _controller,
              decoration: InputDecoration(
                labelText: 'Digite uma frase sobre o seu humor',
                border: OutlineInputBorder(),
              ),
            ),
            SizedBox(height: 16),
            ElevatedButton(
              onPressed: _saveMood,
              child: Text('Salvar Humor'),
            ),
          ],
        ),
      ),
    );
  }
}

O código cria um app Flutter com uma tela em que você registra o seu humor. Ao clicar no botão, o humor digitado é exibido na tela.

No entanto, isso acontece enquanto o aplicativo está aberto. Ou seja, se fechamos, todo o progresso é perdido e o humor da pessoa usuária não é registrado.

Nosso objetivo é fazer com que o app armazene o humor digitado pela pessoa usuária, para que o progresso se mantenha mesmo se ela fechar, desinstalar ou usar o aplicativo em outro dispositivo.

Banner da Alura com desenvolvedor programando no computador e convite para aproveitar o feriado para entrar no mundo da tecnologia. A oferta destaca até 40% de desconto em cursos online de programação, mais um mês grátis, com botão “Comece agora” para iniciar a carreira tech.

Qual o problema de usar apenas dados remotos em aplicativos Flutter?

Talvez, ao pensar na solução do problema dos dados que somem, tenha vindo à sua mente a ideia de usar um servidor remoto para armazenar o humor da pessoa usuária e recuperá-lo sempre que necessário. Algo como:

dynamic getMood(url) async {
  dynamic mood = // faz requisição GET na URL
  return mood;
}

E, de fato, essa solução não estaria de todo equivocada, mas traz alguns problemas. Primeiro, vamos analisar a imagem a seguir:

Fluxo de funcionamento de uma API com as etapas: cliente faz uma requisição para a API, que realiza o processamento no servidor, devolve o resultado para a API, e esta retorna uma resposta ao cliente. No canto inferior esquerdo, há o logo da Alura.

A abordagem de armazenar os dados exclusivamente no servidor nos deixa totalmente dependentes da resposta dele (como ilustrado pela imagem). E tem mais.

O grande problema é que a comunicação entre o cliente e o servidor pode falhar por diversos motivos, o que inutiliza o aplicativo.

Outro contra é que, ao abrir o app, o sistema acessa o servidor para buscar os dados, embora esses dados sejam os mesmos de um acesso anterior.

Por último, ao depender de dados remotos, as consultas constantes ao servidor podem sobrecarregar a infraestrutura, o que aumenta custos e impacta negativamente a performance do app.

Com essas limitações em mente, é hora de conhecermos um novo conceito: a persistência de dados.

O que é persistência de dados no Flutter?

A persistência de dados no Flutter é uma técnica de desenvolvimento em que salvamos os dados do aplicativo localmente.

É como se, dentro do dispositivo móvel, houvesse uma caixa onde guardamos os dados.

Esses dados podem ser itens de uma lista, informações de login, configurações e preferências da pessoa usuária (dark mode, por exemplo).

Ao armazenar dados diretamente no dispositivo, você elimina as requisições constantes ao servidor, reduzindo a dependência da conexão de internet e permitindo que o app continue funcionando sem rede.

E as consultas locais são muito mais rápidas que aquelas feitas a servidores remotos. Isso melhora a performance do app, principalmente em regiões com conexões lentas ou instáveis, onde a latência pode ser um fator limitante.

Outra vantagem é que o armazenamento local reduz a carga no servidor, pois muitas consultas, antes remotas, são processadas diretamente no dispositivo.

Entendendo nosso problema, chegou o momento de criar nosso banco local e, para fazê-lo, usaremos o Couchbase.

Para começar, adicione as seguintes dependências no arquivo pubspec.yaml:

dependencies:
  cbl_flutter:
  cbl_flutter_ce:
  cbl:
## Lembrando que você deve utilizar as versões mais recentes

Depois, execute:

flutter pub get

Com as dependências instaladas, vamos começar configurando o banco de dados local:

Em seguida, crie a classe que será responsável por armazenar os dados da nossa coleção do Couchbase:

class CouchbaseContants {
  static const String channel = 'moodcollection';
  static const String collection = 'moodcollection';
  static const String scope = 'moodscope';
}

Com as constantes criadas, vamos partir para a criação do nosso serviço:

class DatabaseService {
  AsyncDatabase? database;  
  Future<void> init() async {
    database ??= await Database.openAsync('database');  
  }
}

Neste trecho de código, criamos a classe que será responsável pelo armazenamento local da nossa aplicação e criamos um método de inicialização.

Como salvar dados no banco local com persistência no Flutter?

Agora que a estrutura do banco está pronta, vamos criar a função add para salvar o humor no banco:

class DatabaseService {
  AsyncDatabase? database;  
//Outros métodos
   Future<bool> addMood(MyMood mood) async {
    final collection = await database?.createCollection(
      CouchbaseContants.collection,
      CouchbaseContants.scope,
    );
    if (collection != null) {
      final document = MutableDocument(mood.toMap());
      final resultSave = await collection.saveDocument(
        document,
        ConcurrencyControl.lastWriteWins,
      );
      return resultSave;
    }
    return false;
  }
//Outros métodos
}

Essa função cria um MutableDocument com os dados do humor e o salva no banco de dados. O campo type é usado para identificar o tipo de documento, o que facilita consultas posteriores.

Consultando os dados armazenados no dispositivo

Agora, vamos buscar o humor salvo no banco com a função abaixo:

class DatabaseService {
  AsyncDatabase? database;
  //Outros métodos
  Future<List<MyMood>?> fetch({
    required String collectionName,
  }) async {
    await init();
    await database?.createCollection(
      collectionName,
      CouchbaseContants.scope,
    );
    final query = await database?.createQuery(
      'SELECT META().id, * FROM ${CouchbaseContants.scope}.$collectionName''}',
    );
    final result = await query?.execute();
    final results = await result?.allResults();
    final data = results
        ?.map((e) => {
              'id': e.string('id'),
              ...(e.toPlainMap()[collectionName] as Map<String, dynamic>)
            })
        .toList();
    final moodsFromData = data?.map((e) => MyMood.fromJson(e)).toList();
    return moodsFromData ?? [];
  }
  
  //Outros métodos
  
}

Neste código, através do método fetch, fazemos a busca do humor armazenado e fazemos o retorno dele.

O foco do artigo não será fazer operações com um banco de dados local, mas se você quiser explorar um pouquinho mais sobre persistência, acesse este artigo: Persistência de Dados no Flutter: o que é? Qual ferramenta usar?

Com as funções prontas, precisamos modificar o nosso app Flutter e implementar a nova funcionalidade de persistência de dados.

Nossa aplicação vai ficar da seguinte forma:

import 'package:flutter/material.dart';
import 'package:cbl_flutter/cbl_flutter.dart';
import 'package:cbl/cbl.dart';
//Outras classes
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await CouchbaseLiteFlutter.init();
  runApp(MyApp());
}
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'App de Humor',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MoodScreen(),
    );
  }
}
class MoodScreen extends StatefulWidget {
  @override
  _MoodScreenState createState() => _MoodScreenState();
}
class _MoodScreenState extends State<MoodScreen> {
  String _moodMessage = "Estou feliz";
  final TextEditingController _controller = TextEditingController();
  final DatabaseService _dbService = DatabaseService();
  @override
  void initState() {
    super.initState();
    _initializeDatabase();
  }
  Future<void> _initializeDatabase() async {
    await _dbService.init();
    _loadMood();
  }
  Future<void> _loadMood() async {
    try {
      final storedMood =
          await _dbService.fetch(collectionName: 'moodCollection');
      if (storedMood != null) {
        setState(() {
          _moodMessage = storedMood.last.message;
        });
      } else {
        setState(() {
          _moodMessage = "Estou feliz";
        });
      }
    } catch (e) {
      setState(() {
        _moodMessage = "Estou feliz";
      });
    }
  }
  void _saveMood() async {
    if (_controller.text.isNotEmpty) {
      final newMood = MyMood(message: _controller.text);
      bool isSaved = await _dbService.addMood(newMood);
      if (isSaved) {
        setState(() {
          _moodMessage = _controller.text;
        });
        _controller.clear();
      }
    }
  }
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Seu Humor do Dia'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text(
              'Seu Humor:',
              style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
            ),
            SizedBox(height: 8),
            Text(
              _moodMessage,
              style: TextStyle(fontSize: 20, fontStyle: FontStyle.italic),
            ),
            SizedBox(height: 20),
            TextField(
              controller: _controller,
              decoration: InputDecoration(
                labelText: 'Digite uma frase sobre o seu humor',
                border: OutlineInputBorder(),
              ),
            ),
            SizedBox(height: 16),
            ElevatedButton(
              onPressed: _saveMood,
              child: Text('Salvar Humor'),
            ),
          ],
        ),
      ),
    );
  }
}

Pronto, usamos como base a mesma aplicação que havíamos criado anteriormente e adicionamos o armazenamento local dos dados.

Como sincronizar dados locais e remotos em apps Flutter?

Já entendemos os problemas de usar apenas dados remotos, vimos como o armazenamento local auxilia e até criamos um banco de dados local. Mas ainda falta algo, certo?

Se um aplicativo utiliza o Coucbase (armazenamento local), mas em outras funcionalidades utiliza o banco de dados na nuvem, é preciso programar o aplicativo de forma que os dois bancos se comuniquem, evitando erros nos dados.

Assim, vamos ver como conectar o banco local com o servidor.

Felizmente, o Couchbase facilita esse processo.

O que é Couchbase?

O Couchbase é uma plataforma de banco de dados que combina o melhor do NoSQL e SQL.

Se você ainda não está familiarizado com esses conceitos, aproveite o artigo: SQL e NoSQL: trabalhando com bancos relacionais e não relacionais.

O Couchbase oferece uma infinidade de funcionalidades, e entre elas está a sincronização entre banco local e remoto, que iremos usar neste artigo. Para começar, vamos configurar a sincronização usando o Couchbase Capella e o Sync Gateway.

Vamos começar!

Como utilizar o Couchbase no Flutter?

Aqui vai um passo a passo de como usar o Couchbase no Flutter:

Passo 1: Criando uma conta no Couchbase

  • Acesse a plataforma Couchbase;
  • Crie uma conta gratuita (ou entre com a sua);
  • Crie um projeto com o nome da sua preferência;
  • No painel, crie um novo cluster e defina um nome.

Com a conta e o cluster criados, siga para o próximo passo.

Passo 2: Bucket e Collection

Para armazenar nossos dados, precisamos criar um bucket e uma collection. O bucket é como uma grande caixa, e dentro dele estão as caixas menores, as collections, que são as responsáveis por armazenar, de fato, os dados. Siga os passos abaixo:

  • Acesse o data tools (ferramentas de dados);
  • Dentro do data tools, crie um bucket chamado moodbucket;
  • Dentro do moodbucket, crie o scope moodscope;
  • No escopo, adicione a collection moodcollection.

Passo 3: Configuração de serviço de app e endpoint:

  • Vá para App Services e crie um serviço chamado moodservice;
  • Abra o serviço e adicione um endpoint e conecte à collection moodcollection;
  • Dentro do endpoint, acesse App Users (se necessário clique em “resume endpoint” para ativar o endpoint);
  • Configure um novo user e atribua o canal moodcollection.

Passo 4: Configurando o projeto

Agora, dentro do nosso projeto Flutter, modificaremos a nossa classe de constantes do Couchbase, e adicionaremos alguns atributos que irão nos auxiliar a fazer a sincronização com o servidor remoto:

class CouchbaseContants {
  static String userName = 'teste'; //nome do seu user
  static String password = 'teste'; //senha do user
  static String publicConnectionUrl ='endpoint'; //seu endpoint
  static const String channel = 'moodcollection';
  static const String collection = 'moodcollection';
  static const String scope = 'moodscope';
}

Com todos os atributos adicionados, modifique a classe do nosso banco de dados para adicionar as funções e atributos que serão responsáveis pela sincronização:

class DatabaseService {
  AsyncDatabase? database;
  AsyncReplicator? replicator;
  //outros métodos
  Future<bool> addMood(MyMood mood) async {
    final collection = await database?.createCollection(
      CouchbaseContants.collection,
      CouchbaseContants.scope,
    );
    if (collection != null) {
      final document = MutableDocument(mood.toMap());
      final resultSave = await collection.saveDocument(
        document,
        ConcurrencyControl.lastWriteWins,
      );
      if (resultSave) {
        startReplication(
          collectionName: CouchbaseContants.collection,
          onSynced: () {
            print('Sincronizado');
          },
        );
      }
      return resultSave;
    }
    return false;
  }
  Future<void> startReplication({
    required String collectionName,
    required Function() onSynced,
  }) async {
    final collection = await database?.createCollection(
      collectionName,
      CouchbaseContants.scope,
    );
    if (collection != null) {
      final replicatorConfig = ReplicatorConfiguration(
        target: UrlEndpoint(
          Uri.parse(CouchbaseContants.publicConnectionUrl),
        ),
        authenticator: BasicAuthenticator(
          username: CouchbaseContants.userName,
          password: CouchbaseContants.password,
        ),
        continuous: true,
        replicatorType: ReplicatorType.pushAndPull,
        enableAutoPurge: true,
      );
      replicatorConfig.addCollection(
        collection,
        CollectionConfiguration(
          channels: [CouchbaseContants.channel],
          conflictResolver: ConflictResolver.from(
            (conflict) {
              return conflict.remoteDocument ?? conflict.localDocument;
            },
          ),
        ),
      );
      replicator = await Replicator.createAsync(replicatorConfig);
      replicator?.addChangeListener(
        (change) {
          if (change.status.error != null) {
            print('Ocorreu um erro na replicação');
          }
          if (change.status.activity == ReplicatorActivityLevel.idle) {
            print('ocorreu uma sincronização');
            onSynced();
          }
        },
      );
      await replicator?.start();
    }
  }

Além de adicionar o atributo replicator, criamos também a função startReplication, que é responsável por sincronizar os nossos dados. O método de carregar humor foi modificado e, agora, usa a nossa função de sincronizar dados.

Pronto, finalizamos o app! Agora é só abrir a aplicação e testar.

Offline first no Flutter e exemplos

Nossa aplicação foi desenvolvida para sincronizar o banco de dados local com o remoto. O mais interessante é que ela funciona sem conexão com a internet, graças ao armazenamento local.

Na prática, sem perceber, adotamos o conceito de Offline First. Você provavelmente já encontrou essa abordagem em aplicativos populares.

No WhatsApp, por exemplo, quando você envia uma mensagem e está offline, ela fica pendente e é enviada automaticamente assim que a conexão volta.

No Google Maps, você pode navegar por rotas salvas mesmo sem internet. E esses são só alguns exemplos, já que essa abordagem está presente em muitos outros aplicativos.

O que é offline first?

Offline First é quando um aplicativo Flutter continua funcionando sem conexão com a internet. Assim, a pessoa usuária tem uma experiência mais fluida e sem interrupções, mesmo quando estiver sem internet, e neste artigo, fizemos tudo isso na prática.

Conclusão

Você mergulhou no Offline First, utilizando o Couchbase.

Mas não paramos por aqui!

Se você quer aprender ainda mais sobre os princípios do Offline First, a Alura preparou uma lista de conteúdos especiais:

Até a próxima!

Avalie este artigo

Mikael Diniz
Mikael Diniz

Atualmente estou cursando Ciência da Computação na UFMA, sou apaixonado por programação, games, matemática, basquete, programação competitiva e LeetCode.

Veja outros artigos sobre Mobile