• Flutter
  • Dart
  • Performance

Performance em Flutter — boas práticas para apps fluidos

Boas práticas de performance em Flutter com exemplos em Dart - const, rebuilds, listas, imagens, isolates, DevTools, Impeller e tamanho do bundle web.

O Flutter é rápido por padrão, mas não é imune a decisões ruins. Uma lista montada de uma vez, uma imagem decodificada em resolução muito maior do que a necessária ou um JSON grande processado na thread de interface bastam para o app engasgar. Neste artigo, reúno as práticas que aplico no dia a dia para manter apps Flutter fluidos, com exemplos curtos em Dart. A ideia não é otimizar tudo, e sim saber onde olhar quando algo não estiver bem.

Primeiro, meça (do jeito certo)

Antes de qualquer otimização, uma regra: nunca avalie performance em modo debug. Em debug, o código roda com compilação JIT e uma série de verificações extras, o que deixa o app visivelmente mais lento do que ele realmente é.

Para medir, use o modo profile em um aparelho físico:

bash
flutter run --profile

O modo profile mantém a performance próxima à do release, mas ainda permite conectar o Flutter DevTools. Emuladores e simuladores não representam bem o desempenho real, por isso o modo profile não é suportado neles.

No DevTools, as ferramentas que mais uso são:

  • Performance: mostra cada frame, separando o tempo da thread de UI e da thread de rasterização. Frames que passam do orçamento (cerca de 16 ms em telas de 60 Hz) aparecem destacados.
  • Rastreamento de rebuilds: ajuda a descobrir quais widgets estão sendo reconstruídos com mais frequência do que deveriam.
  • CPU Profiler: indica quais funções estão consumindo mais tempo.
  • Memory: útil para investigar vazamentos e consumo excessivo, especialmente com imagens.

Com isso em mãos, as práticas a seguir deixam de ser palpite e passam a ser correções direcionadas.

Use construtores const sempre que possível

Um widget const é criado uma única vez, em tempo de compilação. Quando o pai é reconstruído, o Flutter reconhece que aquela instância não mudou e pode pular o rebuild dessa parte da árvore.

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

  @override
  Widget build(BuildContext context) {
    return const Padding(
      padding: EdgeInsets.all(16),
      child: Text('Meu perfil'),
    );
  }
}

// Uso:
const CabecalhoPerfil();

Para não depender de memória, ative a regra prefer_const_constructors no analysis_options.yaml. O analisador passa a apontar cada lugar onde const poderia ser usado.

Evite rebuilds desnecessários

Divida widgets grandes

Quando uma tela inteira vive em um único build, qualquer setState reconstrói tudo. Extrair partes em widgets próprios (classes, e não métodos auxiliares que retornam Widget) limita o alcance de cada reconstrução e permite que as partes estáticas sejam const.

Reconstrua apenas o que muda

O ValueListenableBuilder é uma forma simples de reconstruir só o trecho que depende de um valor. O parâmetro child permite passar uma parte que nunca muda e, portanto, não precisa ser recriada:

dart
final quantidade = ValueNotifier<int>(0);

ValueListenableBuilder<int>(
  valueListenable: quantidade,
  builder: (context, valor, child) {
    return Row(
      children: [
        child!,
        Text('$valor'),
      ],
    );
  },
  child: const Icon(Icons.shopping_cart),
);

O mesmo padrão de child existe no AnimatedBuilder, o que é especialmente importante em animações, que rodam a cada frame.

Selecione o estado com precisão

Com gerenciadores de estado, o problema mais comum é observar o objeto inteiro quando só um campo interessa. No provider, use select:

dart
final totalItens = context.select<Carrinho, int>(
  (carrinho) => carrinho.itens.length,
);

No Riverpod, a ideia é a mesma:

dart
final totalItens = ref.watch(
  carrinhoProvider.select((carrinho) => carrinho.itens.length),
);

Assim, o widget só é reconstruído quando a quantidade de itens muda, e não a cada alteração qualquer no carrinho.

Listas longas: construa sob demanda

ListView(children: [...]) cria todos os itens de uma vez, mesmo os que estão fora da tela. Para listas longas ou de tamanho desconhecido, use os construtores preguiçosos, que só montam o que está visível (mais uma pequena margem):

dart
ListView.builder(
  itemCount: filmes.length,
  itemExtent: 120,
  itemBuilder: (context, index) {
    return FilmeTile(filme: filmes[index]);
  },
);

Alguns cuidados:

  • Quando todos os itens têm a mesma altura, informar itemExtent (ou prototypeItem) poupa o Flutter de medir cada item durante a rolagem.
  • Evite shrinkWrap: true em listas longas dentro de outro scroll: isso obriga a lista a calcular o tamanho de todos os itens. Prefira um CustomScrollView com slivers, como SliverList.builder.
  • Em grades, o equivalente é GridView.builder.

RepaintBoundary: isole o que se repinta com frequência

Quando uma parte pequena da tela é animada continuamente — um indicador de carregamento, um gráfico em tempo real — ela pode forçar a repintura de áreas vizinhas que não mudaram. O RepaintBoundary cria uma camada separada para essa subárvore:

dart
Stack(
  children: [
    const ConteudoDaTela(),
    RepaintBoundary(
      child: GraficoAoVivo(dados: dados),
    ),
  ],
);

Não saia espalhando RepaintBoundary por todo lado: cada camada tem custo de memória. Os itens de ListView já recebem esse tratamento por padrão. Para verificar se faz diferença, ative debugRepaintRainbowEnabled = true (de package:flutter/rendering.dart) e observe quais áreas mudam de cor a cada repintura.

Imagens: decodifique no tamanho em que serão exibidas

Imagens são uma das maiores fontes de consumo de memória e de travamentos. Uma foto de alta resolução exibida em uma miniatura ainda é decodificada no tamanho original, a menos que você diga o contrário. Os parâmetros cacheWidth e cacheHeight resolvem isso:

dart
final dpr = MediaQuery.devicePixelRatioOf(context);

Image.network(
  urlDoPoster,
  width: 100,
  height: 150,
  fit: BoxFit.cover,
  cacheWidth: (100 * dpr).round(),
);

Informando só uma das dimensões, a proporção da imagem é preservada. Em telas com muitas imagens, como catálogos — pense em algo como o Movie DB, um catálogo de filmes —, essa prática faz diferença perceptível na memória e na fluidez da rolagem.

Outras dicas:

  • Formato: para fotos, WebP costuma oferecer arquivos menores do que JPEG e PNG com qualidade semelhante. Para ícones e ilustrações simples, considere vetores.
  • Tamanho na origem: se você controla o servidor, entregue imagens já redimensionadas em vez de depender só do redimensionamento no app.
  • Cache em disco: o Image.network mantém um cache em memória, mas não salva em disco. Para isso, existem pacotes como o cached_network_image.
  • Pré-carregamento: precacheImage permite carregar uma imagem antes de ela aparecer, evitando o "pisca" na primeira exibição.

Tire o trabalho pesado da thread de UI

O Dart roda o código do app em um único isolate principal. Se uma tarefa pesada — decodificar um JSON grande, processar uma imagem, fazer cálculos extensos — rodar ali, a interface congela até ela terminar.

A forma mais simples de mover esse trabalho para outro isolate é o Isolate.run:

dart
import 'dart:convert';
import 'dart:isolate';

Future<List<Filme>> parseFilmes(String corpo) {
  return Isolate.run(() {
    final dados = jsonDecode(corpo) as Map<String, dynamic>;
    final resultados = dados['results'] as List<dynamic>;
    return resultados
        .map((e) => Filme.fromJson(e as Map<String, dynamic>))
        .toList();
  });
}

Se o código também precisa rodar na web, use o compute, do pacote foundation. O dart:isolate não está disponível na web; lá, o compute executa a função na mesma thread, mas o código continua funcionando em todas as plataformas:

dart
import 'dart:convert';

import 'package:flutter/foundation.dart';

List<Filme> _parseFilmes(String corpo) {
  final dados = jsonDecode(corpo) as Map<String, dynamic>;
  final resultados = dados['results'] as List<dynamic>;
  return resultados
      .map((e) => Filme.fromJson(e as Map<String, dynamic>))
      .toList();
}

final filmes = await compute(_parseFilmes, resposta.body);

Criar um isolate tem custo. Para respostas pequenas, o ganho não compensa — e é aí que medir no DevTools ajuda a decidir.

Impeller: o motor de renderização atual

O Impeller é o motor de renderização que substitui o Skia no Flutter para Android e iOS. Ele é o padrão no iOS e, nas versões recentes, também no Android, em aparelhos compatíveis.

A principal vantagem é que os shaders já vêm pré-compilados com o engine, em vez de serem compilados no aparelho na primeira vez que uma animação é executada. Isso elimina uma causa clássica de travamentos na primeira execução de animações, que antes exigia soluções de contorno.

Na prática, para a maioria dos apps não há nada a configurar: basta manter o Flutter atualizado. Ainda assim, efeitos caros como saveLayer — usado implicitamente por Opacity em animações, por exemplo — continuam tendo custo. Para animar opacidade, prefira FadeTransition ou AnimatedOpacity.

Flutter Web: carregamento e tamanho do bundle

Na web, o primeiro carregamento é o ponto mais sensível, porque o navegador precisa baixar e iniciar o app antes de exibir qualquer coisa.

Carregue partes do app sob demanda

Com deferred imports, uma parte do código só é baixada quando for usada. É ideal para áreas pouco acessadas, como relatórios ou configurações avançadas:

dart
import 'relatorios_page.dart' deferred as relatorios;

Future<void> abrirRelatorios(BuildContext context) async {
  await relatorios.loadLibrary();
  if (!context.mounted) return;
  Navigator.of(context).push(
    MaterialPageRoute(builder: (_) => relatorios.RelatoriosPage()),
  );
}

Repare que RelatoriosPage() não é const: construtores de bibliotecas adiadas não podem ser usados em contexto constante.

Mantenha o bundle enxuto

  • Sempre publique o build de release (flutter build web). O tree shaking de ícones já remove os glifos não utilizados das fontes de ícones.
  • Revise as dependências: cada pacote adicionado pode aumentar o código final.
  • Comprima e redimensione imagens e fontes incluídas nos assets.
  • Configure o servidor para compressão (gzip ou Brotli) e cache dos arquivos estáticos.
  • Avalie o build em WebAssembly (flutter build web --wasm), que pode trazer ganhos de desempenho em navegadores compatíveis.

Checklist rápido

  • Medir em modo profile, em aparelho físico, com o DevTools.
  • const em tudo o que for constante.
  • Widgets pequenos e estado observado com precisão.
  • Listas longas com .builder.
  • RepaintBoundary só onde a medição indicar.
  • Imagens decodificadas no tamanho de exibição.
  • Trabalho pesado fora da thread de UI.
  • Na web, deferred imports e bundle enxuto.

Vamos conversar?

Se o seu app Flutter está travando, consumindo memória demais ou demorando para carregar, posso ajudar a investigar e corrigir. Entre em contato e me conte o que está acontecendo.

Tem um projeto em mente?

Vamos conversar sobre como transformar sua ideia em um app de alta qualidade.

Conversar no WhatsApp

Contato

Vamos conversar?

(11) 99711-9154
nsouza10@outlook.com