top of page

Técnicas para trabalhar offline com Flutter: Implementando cache local

No mundo atual, esperamos que nossas aplicações estejam sempre conectadas à internet. No entanto, há momentos em que isso não é possível. Para proporcionar uma boa experiência ao usuário, mesmo sem conexão, é essencial implementar técnicas de cache offline. Neste artigo, vou mostrar como fiz isso em uma aplicação Flutter que exibe filmes em destaque obtidos de uma API.

Por que trabalhar offline?

Trabalhar offline oferece diversos benefícios:

  • Melhora a experiência do usuário: O aplicativo continua funcional mesmo sem internet.

  • Desempenho aprimorado: Reduz o tempo de carregamento, utilizando dados armazenados localmente.

  • Economia de dados móveis: Minimiza o uso de dados, carregando informações somente quando necessário.

Estrutura da Aplicação

Descrição do Projeto

Nossa aplicação se comunica com uma API de filmes para exibir um grid com os filmes em destaque. Para permitir o funcionamento offline, implementamos um banco de dados local para cache dos dados.

Principais Tecnologias Utilizadas

  • Flutter: Framework para desenvolvimento multiplataforma.

  • dio: Pacote http para fazer as requisições e obter os filmes.

  • get_it: Pacote usado para injeção das dependências.

  • drift: ORM SQLite para Flutter, usado para armazenamento local.

  • cached_network_image e flutter_cache_manager: Usado para fazer cache das imagens dos filmes.

Implementação Passo a Passo

1. Configurando o Projeto

Primeiro, adicione as dependências no pubspec.yaml:

name: movies_app
description: A new Flutter project.

publish_to: "none"

version: 1.0.0+1

environment:
  sdk: '>=3.4.0 <4.0.0'

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.8
  dio: ^5.4.3+1
  cached_network_image: ^3.3.1
  flutter_cache_manager: ^3.3.2
  drift: ^2.18.0
  sqlite3_flutter_libs: ^0.5.21
  path_provider: ^2.1.3
  path: ^1.9.0
  sqlite3: ^2.4.3
  get_it: ^7.7.0

dev_dependencies:
  flutter_test:
    sdk: flutter

  flutter_lints: ^4.0.0
  drift_dev: ^2.18.0
  build_runner: ^2.4.10

flutter:
  uses-material-design: true

2. Criando o Banco de Dados Local

Vamos criar uma classe para gerenciar nosso banco de dados local:

import 'dart:io';

import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import 'package:sqlite3/sqlite3.dart';
import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart';

part 'database.g.dart';

@DriftDatabase()
class AppDatabase extends _$AppDatabase {
  AppDatabase._internal() : super(_openConnection());

  static AppDatabase instance = AppDatabase._internal();

  @override
  int get schemaVersion => 1;
}

LazyDatabase _openConnection() {
  // the LazyDatabase util lets us find the right location for the file async.
  return LazyDatabase(() async {
    // put the database file, called db.sqlite here, into the documents folder
    // for your app.
    final dbFolder = await getApplicationDocumentsDirectory();
    final file = File(path.join(dbFolder.path, 'db.sqlite'));

    // Also work around limitations on old Android versions
    if (Platform.isAndroid) {
      await applyWorkaroundToOpenSqlite3OnOldAndroidVersions();
    }

    // Make sqlite3 pick a more suitable location for temporary files - the
    // one from the system may be inaccessible due to sandboxing.
    final cachebase = (await getTemporaryDirectory()).path;
    // We can't access /tmp on Android, which sqlite3 would try by default.
    // Explicitly tell it about the correct temporary directory.
    sqlite3.tempDirectory = cachebase;

    return NativeDatabase.createInBackground(file);
  });
}

Criando a tabela de filmes

Com o banco de dados criado, precisamos criar nossa primeira tabela para representar o objeto Movie, com drift é extremamente simples:

import 'package:drift/drift.dart';

@DataClassName('Movie')
class Movies extends Table {
  IntColumn get id => integer().nullable()();
  TextColumn get title => text().nullable()();
  TextColumn get overview => text().nullable()();
  @JsonKey('backdrop_path')
  TextColumn get backdropPath => text().nullable()();
  @JsonKey('release_date')
  TextColumn get releaseDate => text().nullable()();
  @JsonKey('original_title')
  TextColumn get originalTitle => text().nullable()();
  @JsonKey('poster_path')
  TextColumn get posterPath => text().nullable()();
  @override
  Set<Column> get primaryKey => {id};
}

O drift gera uma entidade Movie, já com os métodos para a serialização (fromJson e toJson), perceba que podemos customizar os nomes desses parâmetros usando a notation JsonKey.

Para facilitar a manipulação dos dados, também criaremos um DAO com os principais métodos referentes a essa tabela:

import 'package:drift/drift.dart';

import '../database.dart';
import '../tables/movies_table.dart';

part 'movies_dao.g.dart';

@DriftAccessor(tables: [Movies])
class MoviesDao extends DatabaseAccessor<AppDatabase> with _$MoviesDaoMixin {
  MoviesDao(super.db);
  Future<int> add(entity) {
    return into(movies).insert(entity);
  }
  Future addAll(List<Movie> entities) {
    return batch((batch) => batch.insertAll(movies, entities));
  }
  Future<Movie?> findById(int id) {
    return (select(movies)
          ..where((tbl) => tbl.id.equals(id))
          ..limit(1))
        .getSingleOrNull();
  }
  Stream<List<Movie>> getAll() {
    return select(movies).watch();
  }
  Future<List<Movie>> getAllAsFuture({String? filter}) {
    return select(movies).get();
  }
  Future remove(int id) {
    return (delete(movies)..where((tbl) => tbl.id.equals(id))).go();
  }
  Future removeAll() {
    return delete(movies).go();
  }
  Future updateMovie(entity) {
    return (update(movies).replace(entity));
  }
}

Com nossa tabela e DAO criado, basta adicionar elas ao nosso Banco de dados e rodar o build_runner para que a mágica aconteça:

@DriftDatabase(
  tables: [Movies],
  daos: [MoviesDao],
)
class AppDatabase extends _$AppDatabase {
  AppDatabase._internal() : super(_openConnection());

  static AppDatabase instance = AppDatabase._internal();

  @override
  int get schemaVersion => 1;
}

3. Fetching e Cache dos Dados

Nesse passo criaremos duas classes, um repository para fazer o Fetching dos dados remotos, e uma controller que ficara responsável por capturar esses dados e armazenar localmente

Nosso repository:

import 'package:dio/dio.dart';
import '../../core/database/database.dart';

class MovieRepository {
  final Dio _httpClient = Dio();

  MovieRepository() {
    _httpClient.options.baseUrl = 'https://api.themoviedb.org/3/';
    _httpClient.options.queryParameters = {
      'api_key': const String.fromEnvironment('MOVIE_API_KEY'),
      'language': 'pt-BR',
    };
  }

  Future<List<Movie>?> getMovies() async {
    try {
      final response = await _httpClient.get('movie/popular');

      final movies = <Movie>[];

      for (final json in (response.data['results'] as List)) {
        movies.add(Movie.fromJson(json));
      }

      return movies;
    } catch (_) {
      return null;
    }
  }
}

Perceba que nosso repository contém apenas um método, que busca os filmes na themoviedb e em caso de sucesso, ira retornar uma lista com os filmes, do contrário ira retornar null, poderíamos também retornar uma Exception ou um Either, porém o foco aqui está na busca em si. É muito importante que não retornemos uma lista vazia em casos de falha, pois o app pode entender que os registros foram zerados, por tanto ele iria limpar também os registros locais.

Nossa controller:

import '../../core/database/daos/movies_dao.dart';
import '../../core/database/database.dart';
import '../repository/movie_repository.dart';

class MovieController {
  final MovieRepository _moviesRepository;

  final MoviesDao _moviesDao;

  MovieController({
    required MovieRepository moviesRepository,
    required MoviesDao moviesDao,
  })  : _moviesRepository = moviesRepository,
        _moviesDao = moviesDao;

  Future<void> getMoviesFromRemote() async {
    final movies = await _moviesRepository.getMovies();

    if (movies == null) return;

    await _moviesDao.removeAll();

    await _moviesDao.addAll(movies);
  }

  Stream<List<Movie>> getMoviesFromLocal() {
    return _moviesDao.getAll();
  }
}

Nossa controller agora tem duas funções, capturar os dados remotos e resgatar os dados locais.

  • getMoviesFromRemote: Busca os dados na nossa API, perceba que caso movies seja null, ou seja, ocorreu alguma falha na busca, a função é abortada, caso não, ela limpa os dados locais e insere os novos. Por uma questão de desempenho, é mais barato remover os registros e inserir novos do que ir atualizando um por um, mas caso em sua base local tenham registros que não podem ser deletados, é importante que você implemente um mecanismo para manter esses registros.

  • getMoviesFromLocal: Retorna uma stream com todos os registros de movies na nossa base local.

4. Exibindo os Dados no Grid

Primeiro para que possamos trabalhar com a nossa controller, precisamos injetar as dependências da mesma, segue o arquivo main.dart com a inicialização da controller e da aplicação:

import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:get_it/get_it.dart';
import 'core/database/database.dart';
import 'home/controller/movie_controller.dart';
import 'home/pages/home_page.dart';
import 'home/repository/movie_repository.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();

  final getIt = GetIt.instance;

  getIt.registerLazySingleton(
    () => MovieController(
      cacheManager: DefaultCacheManager(),
      moviesDao: AppDatabase.instance.moviesDao,
      moviesRepository: MovieRepository(),
    ),
  );

  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Movies app',
      theme: ThemeData(
        primarySwatch: Colors.blueGrey,
        brightness: Brightness.dark,
      ),
      home: const HomePage(),
    );
  }
}

Finalmente, vamos criar a interface do usuário para exibir os filmes em um grid:

import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import '../controller/movie_controller.dart';
import '../widgets/movie_card.dart';

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  final controller = GetIt.I.get<MovieController>();

  @override
  void initState() {
    super.initState();

    controller.getMoviesFromRemote();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Movies App'),
      ),
      body: StreamBuilder(
        stream: controller.getMoviesFromLocal(),
        builder: ((context, snapshot) {
          switch (snapshot.connectionState) {
            case ConnectionState.waiting:
              return const Center(
                child: CircularProgressIndicator(),
              );
            case ConnectionState.none:
              return const Center(
                child: Text('No data'),
              );
            case ConnectionState.active:
            case ConnectionState.done:
              break;
          }

          return GridView.builder(
            itemCount: snapshot.data?.length ?? 0,
            gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
              maxCrossAxisExtent: 200,
              childAspectRatio: 0.6,
              mainAxisSpacing: 10,
              crossAxisSpacing: 10,
            ),
            itemBuilder: ((context, index) {
              return MovieCard(
                movie: snapshot.data![index],
              );
            }),
          );
        }),
      ),
    );
  }
}          

Criamos uma tela (HomePage), que ao abrir invoca o método getMoviesFromRemote, e retorna um streamBuilder com o getMoviesFromLocal, esse StreamBuilder renderiza um GridView com os filmes, para isso criamos um componente MovieCard:

import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import '../../core/database/database.dart';
import '../../core/utils/get_image_url.dart';
import '../controller/movie_controller.dart';

class MovieCard extends StatelessWidget {
  MovieCard({super.key, required this.movie, this.onTap});
  final Movie movie;
  final Function()? onTap;

  final controller = GetIt.I.get<MovieController>();

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Column(
        children: [
          Expanded(
            child: Container(
              decoration: BoxDecoration(
                image: DecorationImage(
                  image: CachedNetworkImageProvider(
                    getImageUrl(
                      movie.posterPath ?? '',
                    ),
                  ),
                  fit: BoxFit.cover,
                ),
              ),
              child: Align(
                alignment: Alignment.bottomCenter,
                child: Text(
                  movie.releaseDate ?? '',
                  style: const TextStyle(
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
            ),
          ),
          Text(
            '${movie.title}\n',
            maxLines: 2,
            textAlign: TextAlign.center,
          ),
        ],
      ),
    );
  }
}

Nesse componente podemos destacar o uso do CachedNetworkImage, com isso garantimos que as imagens sejam cacheadas na aplicação, também temos um método novo aqui, o getImageUrl, esse método apenas existe pois nossa API não retorna o endereço completo da imagem, então precisamos montar essa url:

String getImageUrl(String imagePath) {
  return 'https://image.tmdb.org/t/p/original$imagePath';
}

E 'voilà'! Temos o nosso app


Bonus:

Já temos o nosso aplicativo funcionando tanto online quanto offline e sincronizando nossos filmes, além de fazer o cache também das imagens, a pergunta é, podemos melhorar em algo?

A resposta é sim. Imagine que você queira sincronizar uma lista com milhares de filmes, e queira que o usuário possa conferir a listagem mesmo offline. Para os dados de titulo, data, isso não é um problema, pois esses dados são salvos no nosso banco, porém para as imagens temos um problema…

Da forma como implementamos, as imagens apenas são baixadas quando o usuário visualiza a mesma, caso ele esteja sem conexão a venha a visualizar um filme novo, ele não conseguira ver a imagem do mesmo e isso não é legal…

Para resolver esse problema, podemos implementar um método que faça o download dessa imagem no momento da sincronização dos dados, assim garantimos que todos os dados necessários estão sendo sincronizados. Bora la!

Segue a nossa controller atualizada:

import 'package:flutter_cache_manager/flutter_cache_manager.dart';

import '../../core/database/daos/movies_dao.dart';
import '../../core/database/database.dart';
import '../../core/utils/get_image_url.dart';
import '../models/movie_details.dart';
import '../repository/movie_repository.dart';

class MovieController {
  final MovieRepository _moviesRepository;

  final DefaultCacheManager _cacheManager;

  final MoviesDao _moviesDao;

  MovieController({
    required MovieRepository moviesRepository,
    required MoviesDao moviesDao,
    required DefaultCacheManager cacheManager,
  })  : _moviesRepository = moviesRepository,
        _moviesDao = moviesDao,
        _cacheManager = cacheManager;

  Future downloadImage(String url) async {
    final localFile = await _cacheManager.getFileFromCache(url);

    if (localFile != null) return;

    await _cacheManager.downloadFile(url);
  }

  Future<void> getMoviesFromRemote() async {
    final movies = await _moviesRepository.getMovies();

    if (movies == null) return;

    await _moviesDao.removeAll();

    for (var movie in movies) {
      downloadImage(getImageUrl(movie.posterPath ?? ''));
    }

    await _moviesDao.addAll(movies);
  }

  Stream<List<Movie>> getMoviesFromLocal() {
    return _moviesDao.getAll();
  }
}

Agora nossa controller tem um novo método, o downloadImage, esse método ira receber a url da imagem, irá verificar se a mesma já foi baixada, se sim ira abortar o método (afinal não queremos abusar dos dados do nosso usuário né!), e caso seja uma imagem nova, faz o download da mesma em background.

Nesse caso não demos await, pois o objetivo não é travar o aplicativo nessa etapa, mas caso você precise garantir que todas as imagens foram salvas antes de continuar, sinta-se a vontade para usar do await.

Conclusão

Implementar cache local em uma aplicação Flutter é uma maneira eficaz de melhorar a experiência do usuário, garantindo que a aplicação funcione mesmo sem conexão à internet. Com o uso de pacotes como Sqflite, drift e Flutter Cache Manager, essa implementação se torna bastante simplificada.

Nesse artigo abordamos um cenário mais complexo, onde podemos gerenciar os dados offline, porém se o seu objetivo é apenas exibir registros e cachear as suas requisições, pode se utilizar de outras abordagens mais simples como o uso do dio_cache_interceptor, que já abstrai todo o gerenciamento dos dados.

Espero que este guia tenha sido útil para você. Fique à vontade para deixar comentários ou dúvidas abaixo. Até a próxima! Escrito por: Rodrigo Rafael | LinkedIn

Link para o repositório completo: nscreen-labs/movies_app

15 visualizações
bottom of page