Photo by Marc-Olivier Jodoin on Unsplash

⚡Como Baseline Profile pode fazer seu app voar!

Daniel J Rodrigues
ioasys-voices
Published in
8 min readJul 3, 2023

--

Em um mercado de aplicativos cada vez mais competitivo, a performance é um fator que deve ser levado a sério durante o desenvolvimento de um app. Se o seu aplicativo é muito lento, causando uma experiência ruim para o usuário, além de frustrado, ele irá abandonar o app rapidamente. Na documentação oficial do Android podemos encontrar diversos exemplos e guias de como podemos melhorar a performance do nosso app. Nesse artigo vamos explorar os Baseline profiles, e como podemos implementá-los.

O que são, e como funcionam?

Para entender o que os Baseline são, e como otimizam o nosso app, primeiro precisamos entender como os Apps ou bibliotecas Android são compilados. Na grande maioria da vezes são compilados utilizando JIT Mode (Just In Time), ou seja, são compiladas na medida em que precisamos delas. O que os Baselines fazem basicamente é mapear fluxos/ações comuns da jornada do usuário ao longo do app e gerar um arquivo que contém um conjunto de regras (profile data) que dizem para o Android quais partes do código ele precisa ~pré-compilar~. E à medida que o usuário utiliza o nosso app, por debaixo dos panos essas partes que foram pré-compiladas serão usadas, evitando o gasto de recursos, e o tempo que o Android usaria para compilar essas partes em memória dinamicamente.

Benchmark

Agora que entendemos como o Baseline pode ajudar o nosso app a ser mais performático, é ideal que tenhamos uma maneira de mensurar a performance, e ter noção de qual foi o real impacto ao implementar o baseline. Uma das formas de fazer isso é através de Benchmarking, que é uma maneira de inspecionarmos e monitorarmos a performance da nossa aplicação. Quando executamos benchmarks com recorrência, ao adicionar uma nova funcionalidade por exemplo, podemos comparar os resultados das execuções para entender se aquilo afetou o nosso app de maneira positiva, ou negativa.

Atualmente o android oferece 2 abordagens de benchmark para podermos analisar diferentes partes e contextos do app que são respectivamente:

  • Microbenchmark: que nos permite fazer benchmark diretamente em partes específicas do nosso código. Uma ótima opção caso o nosso app faça cálculos, processamentos de dados, ou outras coisas que fazem uso intenso da CPU;
  • Macrobenchmark: diferente do micro onde implementamos o benchmark diretamente no código da aplicação, no macro monitoramos os resultados de maneira externa, que é construída a partir dos testes de UI que escrevemos. Dessa maneira, podemos navegar pela aplicação em fluxos comuns, semelhante ao usuário final, permitindo monitorarmos interações com a UI, animações, startup, etc. Tornando assim o candidato perfeito para atuar com o baseline profile, que visa otimizar os fluxos e operações recorrentes dos usuários.

Setup inicial

Pra termos dimensão de como o nosso app performa atualmente sem o baseline profile, vamos começar criando os nossos primeiros benchmarks e, pra isso, vamos criar um novo módulo de Benchmark no projeto:

⚠️ Lembre-se de marcar a opção Benchmark module type como Macrobenchmark pois como vimos, é opção que mais se adequa para termos o máximo de proveito com o baseline profile.

Com o módulo criado, lembre de adicionar a dependência do baseline no seu build.gradle(:app):

implementation "androidx.profileinstaller:profileinstaller:x.x.x"

Com a criação do módulo, automaticamente será gerado um arquivo com o seguinte teste:

@RunWith(AndroidJUnit4::class)
class ExampleStartupBenchmark {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()

@Test
fun startup() = benchmarkRule.measureRepeated(
packageName = "com.example.baseline", // <-- PackageName do seu app
metrics = listOf(StartupTimingMetric()),
iterations = 5,
startupMode = StartupMode.COLD
) {
pressHome()
startActivityAndWait()
}
}

Mas o que esse código faz?

  • benchmarkRule: Define qual abordagem de benchmark vai ser usada a partir da opção selecionada na criação do módulo.
  • metrics: Uma lista de quais métricas queremos medir no teste. Nesse caso, como queremos medir o tempo de inicialização do app, usamos o StartupTimingMetric.
  • iteractions: Um número inteiro que representa quantas vezes as instruções vão se repetir durante a execução do teste, permitindo métricas mais consistentes.
  • startupMode: Força a maneira que o app vai ser executado. Com o valor default COLD, forçamos que quando o teste for iniciado o app seja executado como se o app acabasse de ter sido instalado e pronto pra primeira inicialização.

Agora que entendemos como o nosso teste vai medir o tempo de inicialização do app, estamos prontos pra executar o nosso primeiro benchmark. Porém como a ideia é estar o mais próximo possível da experiência do usuário final, é recomendado que os testes de benchmark sejam executados em um device físico ao invés de um emulador.

Ao executar o teste clicando em Run "ExampleStartupBenchmark", o teste vai começar a abrir e fechar o aplicativo 5 vezes (número definido no parâmetro iteractions). E nos logs de execução do android studio podemos ver o resultado do nosso primeiro teste de benchmark:

ExampleStartupBenchmark_startup
timeToInitialDisplayMs min 183.1, median 192.5, max 747.5
Traces: Iteration 0 1 2 3 4

Esse log mostra quanto tempo (em milissegundos) o nosso app demorou pra iniciar e de todas as 5 interações, quanto durou a inicialização mais rápida (min 183.1 ms) e mais demorada (max 747.5 ms), e também a média de tempo de todas interações (median 192.5 ms).

Interagindo com a interface

A maneira como Macrobenchmark controla o app é através da biblioteca UiAutomator. Ou seja, é semelhante a um teste de UI comum, o que nos permite ir além da inicialização do aplicativo e poder interagir com a interface e/ou navegar pelos fluxos do app.

E pra testar isso, criei uma simples interface no app onde vamos simular o seguinte fluxo:

  • Usuário abre o app
  • Clica no botão de curtir a foto
  • O texto é atualizado de “99 curtidas” para “100 curtidas”
  • Fim do fluxo

Pra isso, vou criar um novo teste chamado likePost,e ele vai seguir o mesmo padrão do nosso primeiro teste startup:

@Test
fun likePost() = benchmarkRule.measureRepeated(
packageName = "com.example.baseline",
metrics = listOf(FrameTimingMetric()),
iterations = 2,
startupMode = StartupMode.COLD
) {
pressHome()
startActivityAndWait()
}

Como o foco desse teste é medir uma interação direta no app e não a inicialização dele, ao invés de StartupTimingMetric vamos utilizar o FrameTimingMetric, que vai resultar em algumas métricas diferentes como o frameCpuTimeMs, que mostra quanto tempo um frame demorou pra ser produzido na CPU.

Agora que já informamos para o teste iniciar o nosso app através do startActivityAndWait, basta localizarmos os elementos na tela com o UI Automator para poder interagir com eles.

fun likePost() = benchmarkRule.measureRepeated(...) {
// ...
device.wait(Until.hasObject(By.text("99 curtidas")), 5000)
val likeBtn = device.findObject(UiSelector().resourceId("likeBtnId"))
}

Com o device.wait nós definimos que vamos esperar um máximo de 5 segundos (timeout) até encontrarmos na tela um objeto que contenha o texto “99 curtidas”. Em seguida, através do device.findObject buscamos o botão de curtida através do id do elemento.

Com os elementos referenciados, podemos efetuar o clique do botão e atualizar o texto com o número de curtidas.

fun likePost() = benchmarkRule.measureRepeated(...) {
//...
likeBtn.click()
device.wait(Until.hasObject(By.text("100 curtidass")), 5000)
}

Ao executar esse teste, o resultado gerado pelo FrameTimingMetric é um pouco diferente:

ExampleStartupBenchmark_likePost
frameDurationCpuMs P50 17.4, P90 34.3, P95 50.2, P99 87.2
frameOverrunMs P50 -11.5, P90 41.3, P95 78.0, P99 98.9
Traces: Iteration 0 1

O que esses valores significam?

  • frameOverrunMs: O android gera uma estimativa de tempo para execução dos frames, e essa métrica diz pra gente se o frame excedeu o prazo, ou terminou antes do esperado. Ou seja, números negativos indicam rapidez de um frame em relação ao prazo, e valores muito acima de 0 indicam lentidão na renderização de frames.
  • frameDurationCpuMs: Quanto tempo o frame demorou pra ser produzido na CPU.

E todos esses valores são coletados na distruibuição: porcentagens 50, 90, 95 e 99.

Aplicando o baseline

Só que não adianta aprender a identificar a lentidão no nosso app se não pudermos resolvê-la. E é aqui que o baseline entra. Como vimos no começo, o baseline profile não é nada mais do que um arquivo (.txt) que contém um conjunto de regras de compilação. E é no módulo de benchmark que vamos gerar esse arquivo.

A fim de organizar melhor o código, vou separar esse setup em um novo arquivo chamado BaselineProfileGenerator.kt. E o setup é bem simples:

@ExperimentalBaselineProfilesApi
@RunWith(AndroidJUnit4ClassRunner::class)
class BaselineProfileGenerator {
@get:Rule
val baselineProfileRule = BaselineProfileRule()

@Test
fun generate() {
return baselineProfileRule.collectBaselineProfile(
"seu.package.name"
) {}
}
}

Bem semelhante ao setup do Macrobenchmark, não? Definimos as regras de execução dos testes, e temos um função generate, que vai ser responsável por gerar o conjunto de regras de compilação do baseline.

Porém, pré-compilar todo o app seria um desperdício de recursos em partes do app que o usuário raramente acessa, concorda? Por isso devemos dar ênfase nos fluxos mais comuns na jornada do usuário, e definir no escopo do baseline quais partes do app ele deve pré-compilar. E como fazemos isso? Simples! Como ainda estamos em um escopo de testes, podemos utilizar do UI Automator para interagir com a interface da mesma maneira que fizemos nos nossos testes de benchmark.

💡 Dica: para evitar a duplicação de código da interação com a interface, você pode criar extensions a partir de fun MacrobenchmarkScope.nomeDaExt(){...}!

@Test
fun generate() {
return baselineProfileRule.collectBaselineProfile(...) {
pressHome()
startActivityAndWait()
device.wait(
Until.hasObject(By.text("99 curtidas")), 5000
)
val likeBtn = device.findObject(
UiSelector().resourceId("likeBtnId")
)
likeBtn.click()
device.wait(Until.hasObject(By.text("100 curtidas")), 5000)
device.waitForIdle()
}
}

E estamos prontos para gerar o nosso arquivo de regras. Para executar, precisamos de um device com acesso root. Caso esteja utilizando um emulador, você pode digitar no terminal o comando adb root, ou (recomendável) criar um Managed Device para usar durante os testes.

Onde encontro o arquivo?

Após executar o teste com sucesso, o arquivo do baseline profile pode ser encontrado na pasta build do módulo de benchmark (na visualização de projeto) no caminho:
.../build/outputs/managed_device_android_test_additional_output/[device]/BaselineProfileGenerator-baseline-prof.txt.

E o que fazer com esse conjunto de regras gerado? Basta copiarmos o arquivo gerado pra dentro de app/src/main, renomear o arquivo para baseline-prof.txt e pronto! Simples assim :)

Como medir o impacto na performance?

No escopo do measureRepeated onde definimos as características de execução do teste, podemos acrescentar um parâmetro chamado compilationMode. Aprendemos que a maneira que o baseline profile optimiza os fluxos comuns é pré-compilando partes do código, então a maneira como vamos diferenciar o fluxo ~com baseline~ do fluxo ~sem baseline~ é através do módulo de compilação.

Para evitar trocar esse parâmetro toda vez que quisermos comparar, podemos dividir o nosso teste em 2 da seguinte maneira:

@Test
fun likePostNone() = likePost(CompilationMode.None())//<- Sem baseline

@Test
fun likePostPartial() = likePost(CompilationMode.Partial())//<- Com baseline

private fun likePost(mode: CompilationMode) = benchmarkRule.measureRepeated(
//...
compilationMode = mode, // <-- dinâmico
startupMode = StartupMode.COLD
) {...}

E ao executar o cada um dos testes, olha a diferença entre as duas:

Sem baseline:
ExampleStartupBenchmark_likePostNone
frameDurationCpuMs P50 18.8, P90 45.4, P95 70.8, P99 176.1
frameOverrunMs P50 -10.9, P90 72.4, P95 141.0, P99 236.8
Traces: Iteration 0 1
-------------------------
Com baseline:
ExampleStartupBenchmark_likePostPartial
frameDurationCpuMs P50 10.7, P90 19.8, P95 38.1, P99 79.4
frameOverrunMs P50 -12.0, P90 42.8, P95 75.8, P99 88.9
Traces: Iteration 0 1

Mesmo em um aplicativo bem simples, o tempo caiu quase pela metade!

Agora que você compreende como funciona, e como implementamos o baseline, é hora de aplicar esse conhecimento e colher os benefícios que essa técnica pode proporcionar. Espero que este artigo tenha fornecido insights valiosos e tenha inspirado você a implementar essa técnica em seus projetos.

Com o baseline profile, você estará no caminho para desenvolver aplicativos excepcionais. Boa sorte em suas jornadas de desenvolvimento, e que seus apps brilhem com um desempenho superior!

--

--