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:
flutter run --profileO 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.
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:
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:
final totalItens = context.select<Carrinho, int>(
(carrinho) => carrinho.itens.length,
);No Riverpod, a ideia é a mesma:
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):
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(ouprototypeItem) poupa o Flutter de medir cada item durante a rolagem. - Evite
shrinkWrap: trueem listas longas dentro de outro scroll: isso obriga a lista a calcular o tamanho de todos os itens. Prefira umCustomScrollViewcom slivers, comoSliverList.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:
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:
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.networkmantém um cache em memória, mas não salva em disco. Para isso, existem pacotes como ocached_network_image. - Pré-carregamento:
precacheImagepermite 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:
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:
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:
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.
constem tudo o que for constante.- Widgets pequenos e estado observado com precisão.
- Listas longas com
.builder. RepaintBoundarysó 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.