Aula 30 · Semana 12 · Quarta, 25 de novembro de 2026 · 16:00 às 18:00
Tamanho, arranque e atualizações, e sprint 3
Teoria (30 minutos): tamanho da app, tempo de arranque, atualizações OTA e code push, forced update e compatibilidade com versões antigas. Prática: sprint 3, com medição do tamanho da app de cada grupo. A sprint fecha na quarta-feira seguinte.
Sprint 3 fecha quarta 02/12 às 16:30
No fim desta aula consegues
Explicar porque o tamanho de uma app custa instalações e que limites as lojas impõem.
Reduzir o tamanho com app bundle, split por ABI, tree shaking e imagens e fontes certas, e medir com --analyze-size.
Descrever o que acontece entre o toque no ícone e o primeiro frame, e adiar o trabalho que não precisa de estar nesse caminho.
Dizer o que o code push pode e não pode mudar, e que regras a Apple impõe.
Desenhar um forced update com versão mínima em remote config e um ecrã de bloqueio que falha aberto.
Porque o tamanho importa
Um estudo da Google com dados da Play (2017) mediu: por cada 6 MB a mais no APK, menos 1 % de instalações concluídas. Em mercados com dados caros e telemóveis de 32 GB cheios, a primeira app a ser apagada é a maior.
Limites das lojas: a Play aceita AABs até 200 MB de download comprimido (mais Play Asset Delivery para jogos); a App Store aceita até 4 GB, mas acima de 200 MB o iOS pede confirmação para descarregar por dados móveis. Muita gente nunca confirma.
Atualizações: a Play envia deltas, mas cada release é um download para todos. Uma app de 100 MB com release semanal gasta 400 MB por mês a cada utilizador, e há quem desligue as atualizações automáticas por causa disso.
Flutter: uma app vazia em release pesa 4 a 5 MB por arquitetura, dos quais o engine é a maior parte. O resto cresce com packages, código nativo dos plugins, imagens e fontes. O Tempo Açores com mapa, fontes e imagens de fundo chega aos 20 a 30 MB se ninguém olhar.
Duas medidas: tamanho de download (comprimido, o que a loja mostra) e tamanho instalado (descomprimido, o que o utilizador vê nas definições). O instalado é quase sempre o dobro.
Para os testes: o tamanho custa instalações e atualizações; a Play limita o AAB a 200 MB de download e o iOS pede confirmação em dados móveis acima de 200 MB.
AAB e split por ABI
O APK universal (flutter build apk) leva o engine e o código Dart compilado para as três arquiteturas Android: armeabi-v7a, arm64-v8a e x86_64. Vai tudo para o telemóvel, que só usa uma.
flutter build apk --split-per-abi gera três APKs, um por arquitetura, cada um com cerca de um terço. É o formato certo para o Firebase App Distribution (aula 27) e para instalar com adb.
flutter build appbundle gera um AAB: a Play recebe o bundle e gera, assina e entrega o APK certo para cada telemóvel (arquitetura, densidade de ecrã, idioma). Obrigatório para apps novas na Play desde 2021. Download típico: metade do APK universal.
No iOS, o App Store Connect faz o equivalente com App Thinning: cada iPhone recebe só a sua arquitetura e os assets @2x ou @3x que usa. Não há nada a configurar.
Deferred components (só Android, via Play Feature Delivery): partes da app em Dart e assets descarregadas a pedido. Compensa em apps grandes com módulos raros (realidade aumentada, um editor); para o Tempo Açores é complexidade a mais.
Para os testes: o AAB deixa a Play entregar só o que cada telemóvel precisa; o APK universal leva as três arquiteturas; o split por ABI é o meio-termo para distribuir fora da loja.
Tree shaking, imagens e fontes
Tree shaking de código: o compilador AOT em release deixa de fora o código Dart que nunca é chamado. Um package grande de que usas uma função pesa pouco; um package com código nativo (Kotlin, Swift) pesa sempre, porque o nativo não passa pelo compilador Dart.
Tree shaking de ícones: em release o Flutter reconstrói o MaterialIcons só com os ícones usados como const. Icons.wb_sunny custa bytes; a fonte inteira custaria 1,6 MB. Se construíres ícones dinamicamente com IconData(codePoint) o build falha e pede --no-tree-shake-icons, que desliga a otimização.
Imagens são o maior contribuinte na maioria das apps. Um PNG de 4000 px para um cartão de 300 px é desperdício; WebP com qualidade 80 fica num terço; variantes 2.0x e 3.0x só quando o ecrã as mostra; SVG para ícones e logótipos; fotos de fundo servidas por rede com cache. O flutter build não comprime imagens por ti.
Fontes: cada peso de uma família são 100 a 300 KB. Inclui só os pesos que usas (Regular e Bold, não os nove). O google_fonts descarrega em runtime por defeito: bom para o tamanho, mau offline e no arranque; para produção empacota as fontes no pubspec.yaml.
Assets: tudo o que está em flutter: assets: vai para o bundle, referenciado ou não. JSON de testes, ficheiros de design, a pasta assets/ inteira declarada por preguiça.
Para os testes: o código Dart não usado e os ícones do Material saem sozinhos em release por tree shaking; imagens, fontes e assets não: esses cortas tu.
Arranque: até ao primeiro frame
A frio, por ordem: o SO cria o processo → mostra o splash nativo (LaunchScreen.storyboard no iOS, o tema de arranque no Android) → o embedder inicializa o engine e a Dart VM (100 a 300 ms em release) → main() → runApp → primeiro build → primeiro frame → dados. A quente, o processo já existe e o SO só traz a app para a frente.
Referências: o Android Vitals marca como excessivo um arranque a frio acima de 5 s; o iOS mata a app se o arranque passar de cerca de 20 s (watchdog). A meta da aula 26: abaixo de 2 s a frio, medidos num telemóvel médio, não no teu.
O splash nativo (flutter_native_splash, aula 27) tapa o intervalo entre o toque e o primeiro frame do Flutter; sem ele há um ecrã branco. Não o prolongues com um segundo splash animado de 3 s dentro do Flutter: dobras o tempo percebido.
Trabalho adiado: em main() antes de runApp só o indispensável (WidgetsFlutterBinding.ensureInitialized(), ler preferências pequenas). Firebase, base de dados, Remote Config e pedidos HTTP arrancam depois do primeiro frame, em paralelo, com a UI a mostrar a cache ou um skeleton. Um await http.get antes do runApp é o erro clássico.
Medir:flutter run --profile --trace-startup escreve build/start_up_info.json com timeToFirstFrameMicros; o separador Performance do DevTools mostra a timeline do arranque; adb shell am start -W dá o tempo total no Android; no iOS, o Instruments com o template App Launch. Nunca em debug.
Para os testes: o arranque a frio inclui o engine e a Dart VM antes do main; o splash nativo tapa esse intervalo; o trabalho pesado vai para depois do primeiro frame.
OTA e code push: Shorebird
Pela loja: build, upload, revisão (horas a dias), rollout, e o utilizador tem de atualizar; a maioria demora semanas. Um bug crítico lançado numa terça pode estar no ar até ao fim da semana seguinte.
Code push: a app descarrega código Dart novo e aplica-o no arranque seguinte, sem passar pela loja. Shorebird é a implementação para Flutter, criada por antigos membros da equipa do Flutter: shorebird release android publica o build base, shorebird patch android envia a correção para esse release. O Expo Updates faz o mesmo em React Native; o CodePush da Microsoft fechou em 2025.
O que pode mudar: código Dart, ou seja, lógica, parsers, textos, layout. O que não pode: código nativo (Kotlin, Swift, plugins com parte nativa), permissões, ícone, versão do Flutter. Para isso, release novo pela loja. Um patch pertence sempre a um release específico.
Regras da Apple: a guideline 2.5.2 proíbe descarregar código que mude as funcionalidades da app; o acordo de programador permite código interpretado desde que não mude o propósito principal. O Shorebird cumpre porque no iOS o patch corre num interpretador Dart; tu cumpres se usares patches para correções, não para funcionalidades escondidas da revisão. A Google tem uma regra equivalente.
Limites: o patch chega só a quem abre a app com rede; reverte-se no painel, mas já correu em quem o recebeu; no iOS o código interpretado é mais lento do que AOT. Não substitui testes nem o forced update.
Para os testes: code push só muda código Dart, nunca código nativo nem permissões; a Apple permite-o desde que não mude o propósito da app.
Analogia Code push é trocar o conteúdo do jornal sem reimprimir a capa. Só o Dart (o texto das páginas) muda; o "papel" nativo, as permissões e o ícone continuam os mesmos, e por isso não passa por nova revisão. A Apple só aceita a troca se as regras do jornal, o seu propósito declarado, não mudarem.
Forced update e versões mínimas
Porquê: a v1 da API desliga a 09/12 (aula 28) e quem ficar numa versão da app que só fala v1 passa a ver erros. Os bancos e a Revolut forçam atualizações; o WhatsApp expira versões antigas com data marcada.
Mecanismo: um valor em remote config (Firebase Remote Config, aula 26, ou um endpoint /config teu) com min_version, latest_version e uma mensagem. No arranque a app lê a sua versão com package_info_plus e compara com semântica de versões (pub_semver), nunca como strings: em strings, "1.10.0" é menor do que "1.9.0".
Dois níveis: abaixo de latest_version, um aviso dispensável ("atualiza quando puderes"); abaixo de min_version, um ecrã de bloqueio que não fecha, com um botão para a loja (url_launcher para market://details?id=... ou o link da App Store). No Android, o in_app_update usa a Play para atualizar sem sair da app; no iOS não há API, só o link.
Falha aberto: se a config não carregar (sem rede, servidor em baixo), a app entra. Um utilizador em modo avião não pode ficar bloqueado por um bug teu. E nunca definas min_version acima da versão que já está aprovada na loja: bloqueias todos os utilizadores sem alternativa.
Suportar quem não atualiza: o backend mantém a API antiga durante a janela de deprecação, a app ignora campos desconhecidos, e o header X-App-Version (aula 12) diz ao backend quem ainda está atrás. Forçar é o último recurso, uma ou duas vezes por ano; usado em excesso, leva o utilizador a desinstalar.
Para os testes: forced update é uma versão mínima em remote config com um ecrã de bloqueio; falha aberto sem rede e nunca aponta para uma versão que ainda não está na loja.
Sprint 3: ponto de situação
Cada grupo, um minuto: o que está em main, o que está em PR, o que está bloqueado. O motivo de rejeição de ontem já tem PR aberto?
A sprint 3 fecha na próxima quarta, 02/12, às 16:30, com a review na aula 32. Antes disso, segunda é build e assinatura: tragam o projeto a compilar em release.
Pronto, sem novidades: instalável por um tester fora do grupo, README com screenshots, CI verde, API v2 com testes verdes.
Prioridade: fechar issues abertas. Extras só entram se os mínimos já estiverem em main.
Hoje, além de circular pelos grupos: exercício de 15 minutos sobre o tamanho da app, num só portátil por grupo.
Última semana da sprint Congelem o âmbito hoje: o que não estiver numa issue com responsável não se faz. Cada dia até quarta serve para fechar, testar no telemóvel de outra pessoa e escrever o README.
✓ Built build/app/outputs/flutter-apk/app-release.apk (18.4MB)
app-release.apk (total compressed) 18 MB
lib/
arm64-v8a/
libapp.so 3 MB
libflutter.so 4 MB
assets/
flutter_assets/
assets/imagens/fundo_acores.png 5 MB
assets/imagens/ilhas/ 2 MB
packages/google_fonts/Inter-*.ttf 1 MB
fonts/MaterialIcons-Regular.otf 6 KB
classes.dex 2 MB
res/ 1 MB
Dart AOT symbols accounted decompressed size 9 MB
package:flutter 4 MB
package:flutter_map 1 MB
package:tempo_acores 487 KB
A summary of your APK analysis can be found at: build/apk-code-size-analysis_01.json
To analyze your app size in Dart DevTools, run the following command:
dart devtools --appSizeBase=apk-code-size-analysis_01.json
--analyze-size exige uma só arquitetura, daí o --target-platform android-arm64; não se combina com --split-per-abi.
libflutter.so é o engine, fixo; libapp.so é o teu Dart compilado com os packages; classes.dex é o Kotlin e Java dos plugins; flutter_assets/ é o que declaraste no pubspec.yaml. A secção "Dart AOT symbols" diz que packages pesam dentro do libapp.so.
Neste exemplo, uma imagem de fundo de 5 MB é 27 % da app. Em WebP a 1080 px fica em 300 KB. O google_fonts a empacotar nove pesos do Inter é o segundo alvo.
Exercício: o que fazer com o número
Comando corrido no projeto do grupo, em release, para android-arm64, sem erros.
Identificado o maior contribuinte que não é o engine: um ficheiro ou um package, com o tamanho e a percentagem do total.
Decisão tomada: comprimir, remover, substituir por rede com cache, ou aceitar com justificação escrita.
Se for imagem ou fonte: corrigido agora, num PR de 10 minutos, e medido outra vez.
Se for um package: decidido se vale o que pesa, com a tabela da aula 28.
README com uma secção "Tamanho da app": tamanho antes, maior contribuinte, o que mudaram, tamanho depois.
Exercício Quinze minutos. Corram flutter build apk --analyze-size --target-platform android-arm64 no projeto do grupo e encontrem o maior contribuinte que não seja o libflutter.so. Digam em uma frase o que fariam com ele. Se for uma imagem ou uma fonte, corrijam já e voltem a medir: os dois números vão para o README. Quem quiser ver a treemap: dart devtools --appSizeBase=build/apk-code-size-analysis_01.json e o separador App Size.
Até segunda
Para fazer
A sprint 3 fecha na próxima quarta, 02/12, às 16:30: tudo em main até terça à noite, demo de 5 minutos ensaiada, instalável por um tester fora do grupo, README com screenshots, CI verde.
O PR do tamanho merged, com os dois números na secção "Tamanho da app" do README.
Para segunda (build e assinatura): o projeto compila com flutter build appbundle sem erros e um membro do grupo traz o portátil com o Android Studio atualizado.
Para ler
Measuring your app's size (docs.flutter.dev): o que conta para o tamanho e como ler o --analyze-size no DevTools.
App startup time (developer.android.com): arranque a frio, a quente e os limiares do Android Vitals.
Firebase Remote Config: o mecanismo por trás do forced update e das feature flags, com valores por defeito para quando a rede falha.
Shorebird docs: o que um patch pode mudar e como fica na revisão das lojas.