Image source: https://developers-br.googleblog.com/2021/08/o-jetpack-compose-esta-na-versao-10.html

Lições aprendidas em um projeto real com Jetpack Compose

Nelson Glauber
10 min readMar 30, 2022

Em fevereiro de 2021, fui chamado para trabalhar em um projeto de uma nova aplicação Android para um cliente da Nagarro e como vinha estudando Jetpack Compose desde 2019, sugeri que usássemos esse novo framework. Entretanto, o Compose ainda estava em versão beta e a equipe não conhecia o Compose e/ou o paradigma de UI declarativas.

Mas por meio de algumas apresentações e um mini-curso rápido consegui convencer a equipe a adotá-lo no projeto. 🎉 Posteriormente, em maio, o Google anunciou a versão 1.0 no Google I/O.

Nesse post, quero compartilhar algumas experiências e lições aprendidas durante esse projeto. Espero que sejam úteis para mais pessoas.

1. Crie componentes reusáveis e personalizáveis

Uma das grandes vantagens do Compose é a facilidade de criar componentes, tendo em vista que um componente é simplesmente uma função Kotlin com a anotação @Composable.

Desta forma, ao criar um elemento de UI, pense sempre em como ele poderia ser reusado em outras partes do seu projeto (ou em outros projetos) e quão fácil seria mudar cores, tamanho, conteúdo, etc.

Uma forma de deixar o componente extensível é utilizar o padrão Slot, que basicamente recomenda deixar um espaço vazio na UI para que o desenvolvedor possa preencher da maneira que desejar.

@Composable
fun CustomButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
val shape = RoundedCornerShape(16.dp)
Box(
modifier
.background(Color.Red, shape)
.clip(shape)
.clickable {
onClick()
}
.padding(16.dp)
) {
content()
}
}

Como podemos observar nesse exemplo, o conteúdo do CustomButton pode ser preenchido com qualquer conteúdo. Isso é feito por meio do parâmetro content (esse nome é um padrão do Compose) que é uma composable function, fazendo com que o componente seja altamente reutilizável.

Outro parâmetro que ajuda na personalização do componente é o parâmetro do tipo Modifier que permite modificar diversas características do componente.

Por fim, normalmente eu recomendo declarar esses componentes em um módulo do projeto que possa ser acessado pelos demais módulos.

2. Sempre defina um preview para seu Composable

O Android Studio possui a área de pré-visualização de um composable, seja ele um simples componente ou até mesmo uma tela completa. Em um projeto de grande porte, é essencial definir uma ou várias funções de preview de modo a ter uma fácil visualização do componente.

Essas funções nada mais são do que composable functions (anotadas com @Composable) com a anotação @Preview. Normalmente eu declaro essas funções como privadas de modo a não confundir com a tela/componente real e sempre começando com "Preview".

Inclusive essa anotação tem diversos parâmetros como o uiMode que permite visualizar o componente em dark mode.

Pré-visualização de um componente no Android Studio.

Um outro detalhe é que, algumas vezes você vai precisar de dados fake para preencher o preview do seu composable. Por exemplo: a tela de detalhes de produto provavelmente vai precisar de um objeto da classe Produto. Para esses casos, crie um arquivo que vai fornecer esses dados (PreviewFakeData, por exemplo).

Caso seja difícil de declarar uma função de preview para seu componente, provavelmente é porque ele precisa de um refactor.

3. Mantenha as bibliotecas do Compose atualizadas

O Compose é um framework novo e está repleto de APIs experimentais (anotadas com @Experimental*Api) e bugs. Por isso, é importante ficar atento para manter as bibliotecas atualizadas, principalmente aquelas desenvolvidas por terceiros e as que fazem parte do pacote accompanist, pois é comum ainda termos algumas mudanças de API em alguns componentes.

Durante o projeto que trabalhei, tive que fazer diversos refactors por conta de mudanças de API. Portanto, atualizar essas dependências com frequência, ajuda a fracionar esse problema.

Você pode acompanhar as releases das bibliotecas do Jetpack aqui.

4. Single activity model funcionou bem

A proposta de ter apenas uma activity no projeto não é algo novo, mas agora é praticamente uma regra em uma aplicação 100% Compose. Um fato curioso é que o projeto no qual eu estava trabalhando era apenas para smartphones em orientação de tela retrato (portrait) 🤦🏻‍♂️. Por isso, a rotação da activity era bloqueada, entretanto, apenas uma tela da aplicação poderia estar em ambas as orientações 😩. Felizmente, esse cenário foi simples de resolver com o Compose.

val activity = LocalContext.current as? Activity
DisposableEffect(Unit) {
activity?.requestedOrientation =
ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR
onDispose {
activity?.requestedOrientation =
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
}

O código acima utiliza o DisposableEffect para definir a orientação como "full sensor" e quando o composable era removido da tela (por qualquer razão), no onDispose o modo retrato é atribuído novamente.

5. Qual o ciclo de vida de um Composable?

Diferentemente de uma activity ou fragment, um composable não possui métodos de ciclo de vida como: onCreate, onStart, onResume, onPause, onStop e onDestroy. Mas isso não chega a ser um problema, sendo que você pode realizar esse tratamento tanto no próprio composable ou no ViewModel.

  • Para tratar no composable, você poderia usar o código a seguir:
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_CREATE -> /* onCreate */
Lifecycle.Event.ON_START -> /* onStart */
Lifecycle.Event.ON_RESUME -> /* onResume */
Lifecycle.Event.ON_PAUSE -> /* onPause */
Lifecycle.Event.ON_STOP -> /* onStop */
Lifecycle.Event.ON_DESTROY -> /* onDestroy */
Lifecycle.Event.ON_ANY -> /* Em qualquer evento */
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
  • Para detectar os eventos de ciclo de vida no ViewModel, poderia usar:
class YourViewModel : ViewModel(), DefaultLifecycleObserver {
override fun onPause(owner: LifecycleOwner) {
super.onPause(owner)
/* onPause */
}

override fun onResume(owner: LifecycleOwner) {
super.onResume(owner)
/* onResume */
}
}

Por fim, no Composable é preciso apenas adicionar/remover o observer…

val viewModel: YourViewModel = viewModel()//...val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
lifecycleOwner.lifecycle.addObserver(viewModel)
onDispose {
lifecycleOwner.lifecycle.removeObserver(viewModel)
}
}

6. O uso do LaunchedEffect pode ter uma alternativa

Os effects do Compose são muito poderosos e é muito importante conhecê-los bem. Mas em alguns cenários, eu creio que principalmente o LaunchedEffect, poderia ser substituído por outra abordagem.

Por exemplo, uma coisa que eu costumeiramente fazia é usar o LaunchEffect para chamar um método do view model que deveria ser chamado apenas uma vez quando a tela era aberta, mas que precisavam de um parâmetro vindo da tela anterior.

val productId = backStackEntry.arguments?.getString("productId")
val productsDetailsViewModel: ProductDetailsViewModel = ...
LaunchedEffect(bookId) {
productsDetailsViewModel.loadProduct(productId ?: "")
}

O exemplo acima obtém o parâmetro productId por meio da API de navegação. Em seguida, dentro do bloco LaunchedEffect, o método loadProduct é chamado usando o id obtido. O problema de seguir essa abordagem é que esse LaunchedEffect poderá ser chamado em outros casos, como por exemplo, quando essa tela (A) chamar outra tela (B), e em seguida, a tela anterior (A) seja exibida novamente.

Uma alternativa à essa abordagem é receber esse parâmetro no construtor do ViewModel e chamar o loadProduct no bloco init do ViewModel. Caso esteja usando injeção de dependência como Hilt, você pode usar a injeção assistida (usando @AssistedInject). Esse artigo do

me ajudou nessa abordagem.

Óbvio que você pode contornar esse problema mantendo o último ID carregado e não fazer nada caso o novo ID seja igual ao atual. Mas é um workaround que você pode contornar de uma forma mais elegante via injeção de dependência.

7. Não tenha NavHost aninhados

Uma abordagem muito comum no desenvolvimento de aplicações Android é criar um módulo para cada feature. Isso além de deixar o projeto mais organizado permite implementar módulos de dinâmicos mais facilmente.

Ao utilizar a biblioteca de navegação do Compose, você NÃO deve ter NavHost (ou AnimatedNavHost ) aninhados. Pois isso dificulta bastante a navegação para telas internas do módulo (com deep links, por exemplo).

A minha recomendação é utilizar apenas um NavHost com vários gráficos (graphs) de navegação aninhados, um para cada feature. Por exemplo:

val initialRoute = if (isLoggedIn()) "books" else "login"
val navController = rememberAnimatedNavController()
NavHost(
navController = navController,
startDestination = initialRoute
) {
loginGraph()
booksGraph()
settingsGraph()
}

No exemplo acima, temos um NavHost, e dentro dele, funções que representam o gráfico de navegação de cada feature (login, books e settings) da aplicação. Desta forma, cada módulo teria algo como:

fun NavGraphBuilder.booksGraph() {
navigation(
route = "books",
startDestination = "booksList",
) {
composable("booksList") {
BookListDestination()
}
// Outros destinations aqui...
}
}

Perceba que booksGraph é uma extension function da classe NavGraphBuilder que permite chamar a função navigation e declarar cada rota de navegação usando a função composable. Mais detalhes sobre a navegação com o Compose você encontra aqui.

8. Organize a navegação do app

Perceba que no exemplo anterior, foram utilizadas strings hardcoded para representar as rotas de navegação da aplicação. No entanto, isso foi apenas facilitar a ilustração do exemplo. Para organizar melhor a parte de navegação da aplicação eu defini dois padrões:

  • Routes: são objetos que representam cada rota da aplicação e sabem como criá-las baseado em parâmetros recebidos.
  • Destination: é um composable que tem o papel de instanciar o ViewModel, fazer o parser de parâmetros de navegação e chamar a tela propriamente dita. Um destination também pode retornar dados da navegação, como por exemplo, uma tela de seleção de país.
Arquitetura da navegação de uma aplicação Compose

Como mostra a imagem acima, teríamos uma (single) activity, um NavHost e uma série de destinations. E cada destination instancia o ViewModel e o passa como parâmetro para a tela (screen).

Um exemplo de um objeto de route seria:

object BookDetails {
private const val baseRoute = "BookDetails"
private const val paramBookId = "bookId"
val route: String = "$baseRoute/{$paramBookId}" val navArguments = listOf(
navArgument(paramBookId) {
type = NavType.StringType
}
)
fun getBookId(backStackEntry: NavBackStackEntry): String? =
backStackEntry.arguments?.getString(paramBookId)
fun buildBookDetailsRoute(id: String) = "$baseRoute/$id"
}

Esse objeto define as características da rota para a tela de detalhes de um livro. Essa rota requer o parâmetro bookId e para criar uma rota válida foi definida a função buildBookDetailsRoute. Então na definição do gráfico de navegação teríamos:

composable(
BookDetails.route,
arguments = BookDetails.navArguments
) { backStackEntry ->
BookDetailsDestination(backStackEntry)
}

Qualquer parte do código que precisar chamar essa rota só precisará usar o seguinte código:

navigationController.navigate(
BookDetails.buildBookDetailsRoute("someBookId")
)

A destination que tratará essa chamada seria algo como a seguir:

@Composable
fun BookDetailsDestination(
backStackEntry: NavBackStackEntry
) {
val bookId = BookDetails.getBookId(backStackEntry)
val bookDetailsViewModel = bookDetailsViewModel(bookId ?: "")
BookDetailsScreen(bookDetailsViewModel)
}

Perceba que cada um possui uma função definida. A rota define e faz o parser dos parâmetros enquanto que a destination instancia o ViewModel e passa os parâmetro para ele. Finalmente a screen é onde haverá a interação com o usuário.

9. StateFlow para manter estado no ViewModel

Em muitos exemplos do Google, eu vejo que a representação do estado da tela é feito por meio de um atributo do tipo State<T> (que é alterado internamente por meio de um MutableState<T>) do próprio compose. Particularmente eu prefiro deixar o ViewModel independente das classes do compose. Por essa razão, eu utilizo um MutableStateFlow<T>

class BookFormViewModel: ViewModel() {
...
private val _uiState = MutableStateFlow(BookFormUiState())
val uiState = _uiState.asStateFlow()
...

… e observo esse Flow utilizando a função collectAsState() no composable.

@Composable
fun BookFormScreen(
viewModel: BookFormViewModel,
...
) {
val bookFormUiState by viewModel.uiState.collectAsState()

10. Tenha UM (e apenas UM) estado para sua tela

Um dos conceitos principais do compose é que a UI será atualizada quando um estado mudar. Por isso é importante ter um estado único para a tela, ou simplesmente uma única fonte de verdade (single source of truth).

Minha sugestão é definir uma data class contendo todo o estado da tela, e disponibilizar métodos no ViewModel para atualizar esse estado.

data class BookFormUiState(
val bookTitle: String = "",
// Outros campos da UI aqui...
)

Então, basicamente no ViewModel teríamos:

class BookFormViewModel: ViewModel() {
...
private val _uiState = MutableStateFlow(BookFormUiState())
val uiState = _uiState.asStateFlow()
...
fun setTitle(title: String) {
_uiState.update {
it.copy(bookTitle = title)
}
}

11. Mantenha a referência dos Jobs do seus Flows

Uma coisa muito bacana que o pessoal do Google fez foi o viewModelScope nos ViewModels, pois eles cancelam automaticamente os jobs criados ao invocar o viewModelScope.launch. Entretanto, se você chamar várias vezes uma função que inicia um Job com o objetivo de coletar o resultado de um Flow, o Job anterior não será cancelado automaticamente enquanto o ViewModel estiver ativo.

Por exemplo: imagine que você possui uma tela de listagem de livros e que no ViewModel você tem a seguinte declaração:

fun loadBooks() {
viewModelScope.launch {
bookUseCase.listBooks().collect { list ->
// ...
}
}
}

Ao invocar o bookUseCase.listBooks() um novo Job será criado. Aqui não haveria problema se essa função não fosse chamada novamente durante o ciclo de vida do ViewModel. Mas, imagine que você adiciona o recurso de pull-to-refresh na tela de listagem. Ao realizar essa ação, a função loadBooks será chamada novamente e um novo Job será criado, mas o Job anterior não será cancelado.

Para evitar esse problema, faça o controle dos jobs do ViewModel e cancele-os assim que um novo Job para um mesmo propósito for lançado.

private var loadBooksJob: Job? = nullfun loadBooks() {
loadBooksJob?.cancel()
loadBooksJob = viewModelScope.launch {
bookUseCase.listBooks().collect { list ->
...
}
}
}

E se eu tiver muitos jobs? Bem, você pode criar o seu mecanismo de cancelamento. O importante é não desperdiçar recursos mantendo jobs abertos desnecessariamente.

12. Hilt + ViewModel + Navigation = ❤️

Eu sempre gostei do Koin para injeção de dependência, mas no momento que estava implementando a parte de D.I. no projeto, apenas o Hilt dava suporte para o Compose (hoje em dia o Koin já provê esse suporte).

O Hilt me surpreendeu positivamente. Ele é bem simples (ao contrário do Dagger) e funciona muito bem com ViewModel do Jetpack provendo um escopo de rota compatível com a biblioteca de navegação.

13. Customizar tema é muito simples

Customizar temas no Compose é muito simples. Além de você pode utilizar cores, fontes e shapes já disponíveis no tema padrão, mas você pode adicionar outras coisas como dimensões. Isso é facilmente alcançado por meio de CompositionLocalProvider (saiba mais aqui) e esse post do Matías Irland me ajudou bastante na implementação.

Todos esses tópicos que eu mencionei aqui encontram-se aplicados na prática no repositório de exemplo que eu disponibilizei no meu github. 👇🏼

Fiquem à vontade para testar, reportar bugs e abrir aquele bom e velho pull-request 😎 (ou merge-request 🤔 que faz mais sentido pra mim).

Qualquer dúvida ou sugestão, deixem seus comentários. 😉

4br4ç05
nglauber

--

--

Nelson Glauber

Android Developer. Google Developer Expert for Android/Kotlin. Author of “Dominando o Android”.