Como reduzir tempo de build no Flutter com build_runner em até 70%
Quem trabalha em desenvolvimento de software com linguagens compiladas como Java, Dart ou Kotlin sabe: a demora para fazermos um build das nossas apps pode se tornar uma coisa bem frustrante de acordo com o tamanho do projeto. Às vezes esperar muito tempo de build para vermos a alteração de uma linha de código pode ser o suficiente para nos desconcentrar da tarefa que estamos fazendo. Isso pode ser ainda pior quando estamos tentando resolver algum bug e precisamos repetir esse processo muitas vezes no dia.
Nesse texto, veja como reduzimos o tempo de build local em 72% e como você pode fazer o mesmo no seu projeto Flutter.
Tempo de build completo:
Tempo de build otimizado:
Vamos nessa?
Contexto
No time de mobile da Revelo, como você já deve ter notado, utilizamos Flutter. Nosso aplicativo tem algumas complexidades que exigem uma gestão de estado um pouco mais sólida, então optamos pela arquitetura MVVM com camada de repositório. Por isso, precisamos utilizar algumas bibliotecas que dependem da utilização do build_runner para geração de código.
No nosso projeto, as principais bibliotecas que entram nesse caso são
Agora imagine que cada classe que utiliza pelo menos uma dessas bibliotecas precisa passar pelo processo do build_runner quando houver alterações. Além disso, cada novo build com uma alteração em uma dessas classes fará com que todas as classes do projeto com essas dependências passe pelo processo do build_runner.
Assim, toda vez que fazemos uma alteração em um desses arquivos, executamos o comando:
flutter pub run build_runner build –delete-conflicting-outputs
No caso do nosso projeto Flutter esse tempo de build varia, dependendo da máquina, de 1 minuto e 30 segundos até 4 minutos. É um tempo pequeno comparado a builds de grandes projetos, mas quando estamos testando coisas novas ele começa a atrapalhar nosso fluxo de trabalho.
É por causa dessa percepção que começamos a procurar alternativas para reduzirmos o tempo de build local, chegando a um resultado interessante com pouco investimento de tempo de desenvolvimento.
Vou levá-los através do nosso fluxo de pensamento e estratégia de ataque a esse problema, assim como a solução que chegamos!
Mapeando o problema
Primeiramente, estudamos quais eram os casos onde o tempo de build mais nos atrapalhava diariamente e onde teríamos maior impacto com o menor esforço.
Geralmente, precisamos fazer um build quando trocamos de branch para fazer um teste ou começar uma nova tarefa, algo que apesar de ser frequente não incomoda muito pois não estamos no meio de uma tarefa e temos que esperar o build rodar após qualquer mudança.
Isso nos leva ao segundo caso, que ocorre quando estamos no meio de uma tarefa e precisamos rodar o build_runner por conta de alguma atualização em uma classe que utilize uma das bibliotecas citadas acima.
Chegamos à conclusão de que realizamos mais builds durante o desenvolvimento de uma tarefa e que talvez não fosse interessante pensarmos em um caso muito generalista naquele momento.
Então tínhamos o problema a ser resolvido: precisamos reduzir o tempo de build local nos arquivos modificados ou novos da branch atual.
Como saber os quais são arquivos modificados ou adicionados de maneira otimizada
Nosso próximo desafio era saber quais arquivos precisarão passar pelo build na branch atual. O git nos dá uma funcionalidade muito útil para isso:
git status --porcelain
Esse comando do git status nos dá o resultado de uma forma fácil de utilizar em scripts. Segundo a documentação, o –porcelain é similar ao git status –short e não mudará em versões posteriores do Git. Por isso, essa se tornou a opção mais viável para fazermos o nosso script.
Como filtrar o build_runner?
Agora vamos para a parte mais interessante: como fazer um script que coloque filtros no build_runner?
Descobrimos que o próprio build_runner tem a funcionalidade de filtrar o que você quer buildar! Essa funcionalidade permite que você adicione parâmetros –build-filter com o nome do arquivo ou pasta que precisa passar pelo processo.
Como exemplo, uma execução possível do build_runner é:
flutter pub run build_runner build --delete-conflicting-outputs --build-filter="lib/di/injection_initializer.config.dart"
Caso queira saber mais, encontramos essa informação neste link.
Onde a mágica acontece
Decidimos utilizar shell script para essa tarefa, por termos conhecimento prévio com esse tipo de script e por já termos o shell como padrão de scripts locais para o nosso projeto.
Criamos um arquivo chamado runBuild.sh, que pode ser executado com uma opção -f (de filter, intuitivo não?).
O que ele faz é rodar o build_runner de acordo com a presença ou ausência dessa opção. Se rodarmos com o -f, ele usará o git status porcelain e fará algumas operações para executar o build de maneira correta. Se rodarmos sem o -f, ele rodará o build_runner normalmente, sem nenhum filtro. Aqui temos o primeiro trecho do script:
while getopts "f" opt; do
case ${opt} in
f) arg_filter="1" ;;
/?) echo "Invalid option -$OPTARG" >&2; exit ;;
esac
done
if [ -z "${arg_filter}" ]; then
flutter pub run build_runner build --delete-conflicting-outputs
else
Na parte do getopts, passamos as opções possíveis (nesse caso, "f"). Com base na presença da opção -f, ele atribui um valor à variável arg_filter. Se passarmos qualquer opção que não esteja cadastrada, ele retornará que a opção é inválida e vai parar a execução do código.
Na condicional [ -z "${arg_filter}" ] o script verifica se arg_filter é nulo e, em caso positivo, ele executará o build_runner normalmente.
Para estruturarmos melhor nosso raciocínio, compartilho aqui a segunda parte do script. Fique tranquilo(a), passaremos juntos por cada etapa da variável filters para que você possa entender o que está sendo executado.
else
echo "Fetching files that need building"
filters=`git status --porcelain | grep -E "\.dart" | awk '{print $2}' | sed 's/\(\(.*\)\.dart$\)/--build-filter="\2.dart"\n--build-filter="\2.g.dart"/g' | xargs`
echo "Running build with ${filters}"
flutter pub run build_runner build --delete-conflicting-outputs ${filters} --build-filter="lib/di/injection_initializer.config.dart"
fi
Operações para obter os arquivos em filters
Nosso foco agora é essa linha de código:
filters=`git status --porcelain | grep -E "\.dart" | awk '{print $2}' | sed 's/\(\(.*\)\.dart$\)/--build-filter="\2.dart"\n--build-filter="\2.g.dart"/g' | xargs`
Aqui temos uma série de pequenas ações em um pipeline (primeiro execute isso, depois aquilo, etc.) e vamos analisar cada parte dela.
A primeira parte, como já vimos anteriormente, é responsável por nos trazer os arquivos novos ou modificados na branch atual. Quando executamos somente ela, nosso resultado será algo como:
Agora vamos selecionar somente os arquivos .dart retornados com o método grep -E "\.dart".
O grep é um comando que realiza uma busca em todo o input, selecionando linhas que respeitam uma função regular. O -E faz com que ele interprete um padrão como uma expressão regular estendida.
Caso queira saber mais sobre grep, clique aqui.
Ok, com isso selecionamos somente as linhas retornadas do git que possuem .dart mas ainda temos as indicações do Git como M, U, C e ??. Para removermos essas indicações utilizaremos o comando awk, que nos permite quebrar um texto em partes.
Com o método awk '{print $2}' conseguimos excluir a primeira parte da linha e retornar somente a segunda (daí o $2).
Caso queira saber mais sobre awk, clique aqui.
Mas ter uma variável somente com os nomes dos arquivos não é suficiente para o nosso caso. Precisamos inserir o parâmetro –build-filter= antes de todos os arquivos e também adicionar os arquivos gerados .g.dart necessários nos filtros. É aí que entra o sed.
O sed é um comando que nos permite fazer transformações de texto em inputs como os que temos na nossa função.
Caso queira saber mais sobre sed, clique aqui.
sed 's/\(\(.*\)\.dart$\)/--build-filter="\2.dart"\n--build-filter="\2.g.dart"/g'
Nesta expressão, utilizamos o comando s/regexp/replacement/flags. Com este comando, conseguimos substituir as linhas que contêm {texto}.dart por uma que contenha:
–build-filter="{texto}.dart" –build-filter="{texto}.g.dart"
Também, a flag "g" logo no final garante que o texto não será mantido a cada iteração.
Por último utilizamos o xargs para unir todas as linhas em uma só! Caso queira saber mais sobre xargs, clique aqui.
Agora temos uma lista filters com todos os arquivos modificados e sua variação de arquivos gerados e vamos adicioná-la ao build_runner. Ele é inteligente para entender quais arquivos não precisarão gerar novos arquivos .g.dart então não teremos problemas com arquivos extras sendo gerados.
Por último, vamos inserir o arquivo de configuração de injeção de dependências para atualizarmos possíveis mudanças nas injeções.
flutter pub run build_runner build --delete-conflicting-outputs ${filters} --build-filter="lib/di/injection_initializer.config.dart"
Script completo
Pronto! Agora temos o script completo que conseguiu otimizar o tempo de build local do app da Revelo de 1 minuto e 34 segundos para 26,4 segundos, uma redução de 72% no tempo de build local.
while getopts "f" opt; do
case ${opt} in
f) arg_filter="1" ;;
/?) echo "Invalid option -$OPTARG" >&2; exit ;;
esac
done
if [ -z "${arg_filter}" ]; then
flutter pub run build_runner build --delete-conflicting-outputs
else
echo "Fetching files that need building"
filters=`git status --porcelain | grep -E "\.dart" | awk '{print $2}' | sed 's/\(\(.*\)\.dart$\)/--build-filter="\2.dart"\n--build-filter="\2.g.dart"/g' | xargs`
echo "Running build with ${filters}"
flutter pub run build_runner build --delete-conflicting-outputs ${filters} --build-filter="lib/di/injection_initializer.config.dart"
fi
Aqui ficam meus agradecimentos mais que especiais ao Cesar Castro, Douglas Iacovelli e à Rafaela Martins, que ajudaram em todo esse processo!
Espero que você também consiga diminuir seu tempo de build local com esse script e, se tiver sugestões de melhorias, pode me chamar e vamos conversar, combinado? :)