Como encontrar vazamentos de memória em aplicativos Flutter?

Tempo de leitura: 7 minutes

Em uma grande base de código, a identificação de problemas relacionados à memória pode, ocasionalmente, ser um desafio. Para resolver isso, podemos aproveitar o DevTools oferecido pelo Flutter. Neste artigo, exploraremos diferentes conjuntos de ferramentas para encontrar vazamentos de memória em um aplicativo Flutter usando o DevTools.

 

Noções básicas de memória no Dart

Quando um objeto é criado usando um construtor, sua memória é alocada no heap pela VM (Máquina Virtual) do Dart. A Dart VM se encarrega de alocar a memória para os objetos quando eles são criados e de desalocar a memória quando eles não estão mais sendo usados.

Um aplicativo Dart cria um objeto raiz, que faz referência a todos os outros objetos que o aplicativo cria, direta ou indiretamente.

Podemos pensar nisso como uma cadeia entre os objetos, chegando, por fim, ao objeto raiz. Se um elo da cadeia for quebrado, o coletor de lixo (GC) será avisado para desalocar a memória do objeto.

root -> A -> B -> C
root -> A -> B -/- C (Sinaliza ao GC para desalocar a memória de C)

 

Uso do Memory View no DevTools para encontrar vazamentos de memória

Usaremos o código abaixo para verificar vazamentos de memória no DevTools.

Etapa – 1 Executar o aplicativo no modo de perfil

Primeiro, para executar o aplicativo no modo de perfil, use o sinalizador –profile; isso obterá as alocações de memória mais precisas para seu aplicativo no DevTools.

flutter run --profile

Etapa – 2 Abrir o DevTools

Você pode abrir o DevTools de duas maneiras:

Clicando em Open DevTools (Abrir DevTools) na guia Flutter

 

Etapa – 3 Abra a guia de memória

Clique na guia Memória no DevTools e ative a opção Atualizar no GC. A ativação da atualização no coletor de lixo atualizará a lista de objetos referenciados à medida que eles forem referenciados ou desreferenciados.

Etapa – 4 Navegar para a tela de tarefas pesadas

Ao navegar pela HeavyTaskScreen, o HeavyObj é registrado na memória.

Aqui está o trecho de código:

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

  @override
  State<HeavyTaskScreen> createState() => _HeavyTaskScreenState();
}

class _HeavyTaskScreenState extends State<HeavyTaskScreen> {
  @override
  void initState() {
    super.initState();
    Future.delayed(const Duration(seconds: 2), () {
      for (var i = 0; i < 10000; i++) {
        Singleton.instance.objectList.add(HeavyObj());
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return const Scaffold();
  }
}

Em HeavyTaskScreen, estamos adicionando 10.000 elementos à lista, que está em um Singleton. Podemos pensar nisso como uma chamada de API em que ele desserializa um objeto JSON grande ou executa alguma outra tarefa pesada.

Agora, se você notar no GIF, temos 10.000 objetos de HeavyObj que não estão sendo desreferenciados mesmo depois de fechar a tela ou se navegarmos para outra tela. Podemos chamar isso de vazamento de memória porque o HeavyTaskScreen não está mais na tela, mas as operações feitas nessa tela ainda estão consumindo memória.

 

 

Exte exemplo rodando no Android Studio, o acima no VsCode
Exte exemplo rodando no Android Studio, o acima no VsCode

Em HeavyTaskScreen, estamos adicionando 10.000 elementos à lista, que está em um Singleton. Podemos pensar nisso como uma chamada de API em que ele desserializa um objeto JSON grande ou executa alguma outra tarefa pesada.

Agora, se você notar no GIF, temos 10.000 objetos de HeavyObj que não estão sendo desreferenciados mesmo depois de fechar a tela ou se navegarmos para outra tela. Podemos chamar isso de vazamento de memória porque o HeavyTaskScreen não está mais na tela, mas as operações feitas nessa tela ainda estão consumindo memória.

Agora, você pode se perguntar: por que isso acontece?

Como discutimos acima, até que a cadeia de referência entre o objeto raiz e o outro objeto não seja quebrada, o GC não será sinalizado para remover o objeto da memória. Como os HeavyObjs ainda podem ser acessados a partir do Singleton, a referência deles é mantida para o objeto raiz, de modo que o GC não será sinalizado para remover o objeto mesmo depois que o HeavyTaskScreen for fechado.

Como podemos corrigir isso?

Podemos corrigir isso de duas maneiras:

  1. Tornar o Singleton anulável

Ao tornar o Singleton anulável, podemos desreferenciar todo o Singleton usando um método de descarte quando não precisarmos dele.

Assim:

class Singleton {
  Singleton._();

  static Singleton instance;

  factory Singleton() => instance ??= Singleton._();

  List<HeavyObj> objectList = [];

  void dispose() {
    instance = null;
  }
}
// Chamada no método dispose do widget com estado quando não precisamos dele.
@override
  void dispose() {
    Singleton.instance.dispose();
    super.dispose();
  }

2. Use pacotes como flutter_modular ou get_it

Usando pacotes como esses, podemos criar singletons ou dependências, em termos de módulo ou escopo. Quando um módulo/escopo é removido da memória, todas as classes registradas são automaticamente descartadas, de modo que não precisamos cuidar disso manualmente, como fizemos acima.

 

Comparar diferentes períodos de tempo

Podemos usar o DevTools para comparar diretamente os dois períodos de tempo do aplicativo. Por exemplo, primeiro estamos na HomeScreen e capturamos um instantâneo, depois navegamos para a Page1 e capturamos outro instantâneo, e agora podemos comparar esses instantâneos.

HomeScreen:

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

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Center(
            child: ElevatedButton(
              onPressed: () {
                Navigator.push(context, MaterialPageRoute(
                  builder: (context) {
                    return const Page1();
                  },
                ));
              },
              child: const Text('Page1'),
            ),
          ),
          Center(
            child: ElevatedButton(
              onPressed: () {
                Navigator.push(context, MaterialPageRoute(
                  builder: (context) {
                    return const HeavyTaskScreen();
                  },
                ));
              },
              child: const Text('Heavy task screen'),
            ),
          ),
        ],
      ),
    );
  }
}

Agora, no DevTools, vá até a guia Diff snapshot e clique no botão record snapshot.

Quando o carregamento dos dados for concluído, navegaremos para a Page1.

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

  @override
  State<Page1> createState() => _Page1State();
}

class _Page1State extends State<Page1> {
  late ScrollController controller;

  @override
  void initState() {
    super.initState();
    controller = ScrollController()..addListener(fn);
  }

  void fn() {}

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          Navigator.push(
            context,
            MaterialPageRoute(
              builder: (context) => const Page2(),
            ),
          );
        },
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

Agora, capture o instantâneo novamente. Agora, você pode comparar os dois períodos de tempo.

Aqui, podemos verificar quantas instâncias de classes existem ou quanto tamanho elas estão consumindo ao clicar em cada método principal.

Agora, podemos comparar diretamente esses dois snapshots clicando no botão Diff (aqui, a comparação é entre main-1 e main-2).

Ele mostrará a diferença entre esses dois instantâneos. Por exemplo, quantas instâncias novas existem, quantas instâncias foram liberadas e as diferenças na memória ocupada.

Depois de voltar à HomeScreen, tirei outro instantâneo e o comparei com o segundo instantâneo usando o botão suspenso.

Como você pode ver, ele mostra o número de instâncias liberadas e a quantidade de memória liberada.

 

Um exemplo comum

Agora, vamos dar um exemplo em que o vazamento de memória é muito comum.

Etapa – 1 Criar código propenso a vazamentos

Criaremos duas telas, a Page 1 e a Page 2. Em ambas as telas, inicializaremos um ScrollController e anexaremos um ouvinte a ele. Em ambas as telas, haverá um botão que permitirá aos usuários navegar para a outra tela.

late ScrollController controller;
@override
void initState() {
  super.initState();
   controller = ScrollController()..addListener(fn);
}
...
 onPressed: () {
    // Page1 -> Page2 & Page2 -> Page1
          Navigator.push(
            context,
            MaterialPageRoute(
              builder: (context) => const Page2(), 
          );
        }

Etapa – 2 Pressione a tela várias vezes

Vamos pressionar essas telas 30 vezes e verificar o rastreamento de memória no DevTools.

Como você pode ver, há 30 instâncias de cada página e elas ocupam 11,7 MB de memória.

Etapa – 3 Remover listener

Agora, faremos apenas uma pequena alteração. Removeremos o listener antes de empurrar a tela.

onPressed: () {
          controller.removeListener(fn);
          Navigator.push(
            context,
            MaterialPageRoute(
              builder: (context) => const Page2(),
            ),
          );
        },

Agora, vamos verificar o rastreamento da memória.

Como podemos ver, ainda há 30 instâncias de cada página, mas a memória ocupada é de 11,0 MB, o que é um pouco menos do que antes.

Por exemplo, se enviarmos uma tela com muitos recursos sendo usados e, em seguida, enviarmos outra tela, os recursos da tela anterior ainda estarão ocupados e não serão liberados (o que não deveria acontecer). Em vez disso, podemos liberá-los da memória e começar a realocá-los novamente como e quando necessário.

Se não lidarmos com esse cenário adequadamente, isso pode causar o inchaço da memória. Isso significa que está sendo usada mais memória do que o necessário e, se esse problema aumentar e se acumular, o aplicativo pode travar devido a um problema de falta de memória.

O cenário acima pode ser causado por um aplicativo que permite uma navegação aninhada muito profunda.

 

Conclusão

Neste artigo, exploramos várias estratégias e metodologias para detectar vazamentos de memória, incluindo o monitoramento do uso da memória, quando um objeto é retido e a utilização da visualização de memória. Munido desse conhecimento, você pode abordar proativamente os problemas relacionados à memória, resultando em aplicativos Flutter mais robustos e confiáveis.