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

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

Compartilhe

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 promocional da Alura, com chamada para um evento ao vivo no dia 12 de fevereiro às 18h30, com os dizeres

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!

Mikael Diniz
Mikael Diniz

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

Veja outros artigos sobre Mobile