1. Antes de começar
Introdução
Nos codelabs anteriores, você aprendeu a receber dados de um serviço da Web usando um padrão do repositório e analisar a resposta em um objeto Kotlin. Neste codelab, você vai aproveitar esse conhecimento para carregar e mostrar fotos de um URL da Web. Também vamos lembrar como criar uma LazyVerticalGrid
e usá-la para mostrar uma grade de imagens na página de visão geral.
Pré-requisitos
- Saber extrair o JSON de um serviço REST da Web e analisar esses dados em objetos Kotlin usando as bibliotecas Retrofit e Gson (links em inglês).
- Conhecimento sobre um serviço da Web REST (link em inglês).
- Conhecimento sobre os componentes da arquitetura do Android, como camadas de dados e repositórios.
- Conhecimento sobre a injeção de dependência.
- Conhecimento sobre
ViewModel
eViewModelProvider.Factory
. - Saber implementar corrotinas para o app.
- Conhecimento sobre o padrão do repositório.
O que você vai aprender
- Como usar a biblioteca Coil (link em inglês) para carregar e mostrar uma imagem de um URL da Web.
- Como usar um
LazyVerticalGrid
para mostrar uma grade de imagens. - Como processar erros possíveis durante o download e a exibição das imagens.
O que você vai criar
- Você vai modificar o app Mars Photos para acessar o URL dos dados de imagens de Marte e usar a Coil para carregar e mostrar essas imagens.
- Adicionar uma animação e um ícone de erro de carregamento ao app.
- Adicionar o status e o tratamento de erros ao app.
O que é necessário
- Um computador com um navegador da Web moderno, como a versão mais recente do Chrome.
- Código inicial do app Mars Photos com serviços REST da Web.
2. Visão geral do app
Neste codelab, você vai continuar trabalhando com o app Mars Photos de um codelab anterior. O app Mars Photos se conecta a um serviço da Web para extrair e mostrar o número de objetos Kotlin acessados usando Gson. Estes objetos Kotlin contêm os URLs das fotos reais da superfície de Marte capturadas pelos rovers da NASA.
A versão do app que você criar neste codelab vai mostrar fotos de Marte em uma grade de imagens. As imagens fazem parte dos dados que o app extrai do serviço da Web. Seu app vai usar a biblioteca Coil (link em inglês) para carregar e mostrar as imagens, e uma LazyVerticalGrid
para criar o layout de grade para elas. O app também processará corretamente os erros de rede, mostrando uma mensagem de erro.
Acessar o código inicial
Para começar, faça o download do código inicial:
Outra opção é clonar o repositório do GitHub:
$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-mars-photos.git $ cd basic-android-kotlin-compose-training-mars-photos $ git checkout coil-starter
Procure o código no repositório do GitHub do Mars Photos
(link em inglês).
3. Mostrar uma imagem transferida por download
Mostrar uma foto de um URL da Web pode parecer simples, mas requer muito trabalho técnico para que isso funcione bem. A imagem precisa ser transferida por download, armazenada internamente (em cache) e decodificada do formato compactado para uma imagem que o Android possa usar. É possível armazenar a imagem em um cache na memória, no cache baseado em armazenamento ou em ambos. Tudo isso precisa acontecer em segmentos de baixa prioridade em segundo plano para que a IU permaneça responsiva. Além disso, para conseguir a melhor performance de rede e CPU, convém buscar e decodificar mais de uma imagem ao mesmo tempo.
Felizmente, é possível usar uma biblioteca criada pela comunidade, a Coil (link em inglês), para fazer o download, armazenar em um buffer, decodificar e armazenar as imagens em cache. Sem a Coil, você teria muito trabalho a fazer.
Resumidamente, a Coil precisa de duas coisas:
- O URL da imagem que você quer carregar e mostrar.
- Um objeto
AsyncImage
para mostrar essa imagem.
Nesta tarefa, vamos aprender a usar a Coil para mostrar uma única imagem de Marte recebida do serviço da Web. Você vai mostrar a imagem da primeira foto de Marte na lista de fotos que o serviço da Web retorna. As imagens abaixo mostram as capturas de tela "antes e depois":
Adicionar uma dependência da Coil
- Abra o app da solução do Mars Photos (em inglês) do codelab Adicionar repositório e injeção de dependência manual.
- Execute o app para confirmar que ele mostra a contagem de fotos de Marte extraídas.
- Abra build.gradle.kts (Module :app).
- Na seção
dependencies
, adicione esta linha para a biblioteca Coil:
// Coil
implementation("io.coil-kt:coil-compose:2.4.0")
Verifique e atualize a versão mais recente da biblioteca na página de documentação da Coil (link em inglês).
- Clique em Sync Now para recriar o projeto com a nova dependência.
Mostrar o URL da imagem
Nesta etapa, você vai extrair e mostrar o URL da primeira foto de Marte.
- Em
ui/screens/MarsViewModel.kt
, no métodogetMarsPhotos()
, no blocotry
, encontre a linha que define os dados extraídos do serviço da Web paralistResult
.
// No need to copy, code is already present
try {
val listResult = marsPhotosRepository.getMarsPhotos()
//...
}
- Atualize essa linha mudando
listResult
pararesult
e atribuindo a primeira foto de Marte extraída à nova variávelresult
. Atribua o primeiro objeto de foto no índice0
.
try {
val result = marsPhotosRepository.getMarsPhotos()[0]
//...
}
- Na próxima linha, atualize o parâmetro transmitido à chamada de função
MarsUiState.Success()
para a string no código abaixo. Use os dados da nova propriedade em vez delistResult
. Mostre o URL da primeira imagem da fotoresult
.
try {
...
MarsUiState.Success("First Mars image URL: ${result.imgSrc}")
}
O bloco try
completo agora tem esta aparência:
marsUiState = try {
val result = marsPhotosRepository.getMarsPhotos()[0]
MarsUiState.Success(
" First Mars image URL : ${result.imgSrc}"
)
}
- Execute o app. O
Text
de composição agora mostra o URL da primeira foto de Marte. A próxima seção descreve como fazer com que o app mostre a imagem nesse URL.
Adicionar o elemento combinável AsyncImage
Nesta etapa, você vai adicionar uma função de composição AsyncImage
para carregar e mostrar uma única foto de Marte. AsyncImage
é uma função que executa uma solicitação de imagem de forma assíncrona e renderiza o resultado.
// Example code, no need to copy over
AsyncImage(
model = "https://android.com/sample_image.jpg",
contentDescription = null
)
O argumento model
pode ser o valor ImageRequest.data
ou o próprio ImageRequest
. No exemplo anterior, você atribui o valor ImageRequest.data
, ou seja, o URL da imagem, que é "https://android.com/sample_image.jpg"
. O código de exemplo abaixo mostra como atribuir a própria ImageRequest
ao model
.
// Example code, no need to copy over
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("https://example.com/image.jpg")
.crossfade(true)
.build(),
placeholder = painterResource(R.drawable.placeholder),
contentDescription = stringResource(R.string.description),
contentScale = ContentScale.Crop,
modifier = Modifier.clip(CircleShape)
)
AsyncImage
oferece suporte aos mesmos argumentos que o elemento combinável de imagem padrão. Além disso, ele oferece suporte à configuração de pintores placeholder
/error
/fallback
e callbacks onLoading
/onSuccess
/onError
. O código de exemplo anterior carrega a imagem com um corte circular e um crossfade, e define um marcador de posição.
contentDescription
define o texto usado pelos serviços de acessibilidade para descrever o que essa imagem representa.
Adicione uma função de composição AsyncImage
ao seu código para mostrar a primeira foto de Marte extraída.
- Em
ui/screens/HomeScreen.kt
, adicione uma nova função de composição com o nomeMarsPhotoCard()
, que usaMarsPhoto
eModifier
.
@Composable
fun MarsPhotoCard(photo: MarsPhoto, modifier: Modifier = Modifier) {
}
- Na função combinável
MarsPhotoCard()
, adicione a funçãoAsyncImage()
desta maneira:
import coil.compose.AsyncImage
import coil.request.ImageRequest
import androidx.compose.ui.platform.LocalContext
@Composable
fun MarsPhotoCard(photo: MarsPhoto, modifier: Modifier = Modifier) {
AsyncImage(
model = ImageRequest.Builder(context = LocalContext.current)
.data(photo.imgSrc)
.build(),
contentDescription = stringResource(R.string.mars_photo),
modifier = Modifier.fillMaxWidth()
)
}
No código anterior, você cria uma ImageRequest
usando o URL da imagem (photo.imgSrc
) e o transmite ao argumento model
. Use contentDescription
para definir o texto para leitores de acessibilidade.
- Adicione
crossfade(true)
àImageRequest
para ativar uma animação de crossfade quando a solicitação for concluída com êxito.
@Composable
fun MarsPhotoCard(photo: MarsPhoto, modifier: Modifier = Modifier) {
AsyncImage(
model = ImageRequest.Builder(context = LocalContext.current)
.data(photo.imgSrc)
.crossfade(true)
.build(),
contentDescription = stringResource(R.string.mars_photo),
modifier = Modifier.fillMaxWidth()
)
}
- Atualize o elemento combinável
HomeScreen
para mostrar o elementoMarsPhotoCard
em vez deResultScreen
quando a solicitação for concluída. O erro de correspondência de tipo vai ser corrigido na próxima etapa.
@Composable
fun HomeScreen(
marsUiState: MarsUiState,
modifier: Modifier = Modifier
) {
when (marsUiState) {
is MarsUiState.Loading -> LoadingScreen(modifier = modifier.fillMaxSize())
is MarsUiState.Success -> MarsPhotoCard(photo = marsUiState.photos, modifier = modifier.fillMaxSize())
else -> ErrorScreen(modifier = modifier.fillMaxSize())
}
}
- No arquivo
MarsViewModel.kt
, atualize a interfaceMarsUiState
para aceitar um objetoMarsPhoto
em vez de umaString
.
sealed interface MarsUiState {
data class Success(val photos: MarsPhoto) : MarsUiState
//...
}
- Atualize a função
getMarsPhotos()
para transmitir o primeiro objeto de foto de Marte ao métodoMarsUiState.Success()
. Exclua a variávelresult
.
marsUiState = try {
MarsUiState.Success(marsPhotosRepository.getMarsPhotos()[0])
}
- Execute o app e confirme se ele mostra uma única imagem de Marte.
- A foto de Marte não está preenchendo toda a tela. Para preencher o espaço disponível na tela, em
HomeScreen.kt
, emAsyncImage
definacontentScale
comoContentScale.Crop
.
import androidx.compose.ui.layout.ContentScale
@Composable
fun MarsPhotoCard(photo: MarsPhoto, modifier: Modifier = Modifier) {
AsyncImage(
model = ImageRequest.Builder(context = LocalContext.current)
.data(photo.imgSrc)
.crossfade(true)
.build(),
contentDescription = stringResource(R.string.mars_photo),
contentScale = ContentScale.Crop,
modifier = modifier,
)
}
- Execute o app e confirme se a imagem preenche a tela horizontal e verticalmente.
Adicionar imagens de erro e carregamento
Para melhorar a experiência do usuário no seu app, mostre uma imagem de marcador ao carregar a imagem. Você também pode mostrar uma imagem de erro se o carregamento falhar devido a um problema, como um arquivo de imagem corrompido ou ausente. Nesta seção, você adiciona imagens de erro e de marcador usando AsyncImage
.
- Abra
res/drawable/ic_broken_image.xml
e clique na guia Design ou Split à direita. Para a imagem de erro, use o ícone de imagem corrompida disponível na biblioteca de ícones integrada. Esse drawable vetorial usa o atributoandroid:tint
para colorir o ícone em cinza.
- Abra
res/drawable/loading_img.xml
. Esse drawable é uma animação que gira um drawable de imagem,loading_img.xml
, ao redor do ponto central. Essa animação não vai ser mostrada na visualização.
- Retorne ao arquivo
HomeScreen.kt
. No elemento combinávelMarsPhotoCard
, atualize a chamada paraAsyncImage()
e adicione os atributoserror
eplaceholder
, como mostrado no código abaixo:
import androidx.compose.ui.res.painterResource
@Composable
fun MarsPhotoCard(photo: MarsPhoto, modifier: Modifier = Modifier) {
AsyncImage(
// ...
error = painterResource(R.drawable.ic_broken_image),
placeholder = painterResource(R.drawable.loading_img),
// ...
)
}
Esse código define a imagem de carregamento de marcador a ser usada durante o carregamento (o drawable loading_img
). Também define a imagem a ser usada se o carregamento falhar (o drawable ic_broken_image
).
O elemento combinável MarsPhotoCard
completo agora tem esta aparência:
@Composable
fun MarsPhotoCard(photo: MarsPhoto, modifier: Modifier = Modifier) {
AsyncImage(
model = ImageRequest.Builder(context = LocalContext.current)
.data(photo.imgSrc)
.crossfade(true)
.build(),
error = painterResource(R.drawable.ic_broken_image),
placeholder = painterResource(R.drawable.loading_img),
contentDescription = stringResource(R.string.mars_photo),
contentScale = ContentScale.Crop
)
}
- Execute o app. Dependendo da velocidade da sua conexão de rede, você poderá notar o carregamento brevemente, à medida que a Coil faz o download e mostra a imagem. No entanto, o ícone de imagem corrompida ainda não será mostrado, mesmo que você desative a rede. Isso será corrigido na última tarefa do codelab.
4. Mostrar uma grade de imagens com uma LazyVerticalGrid
Agora, seu app carrega uma foto de Marte recebida da Internet, o primeiro item da lista de MarsPhoto
. Você usou o URL da imagem desses dados de fotos de Marte para preencher uma AsyncImage
. No entanto, o objetivo é que o app mostre uma grade de imagens. Nesta tarefa, você usa uma LazyVerticalGrid
com um gerenciador de layout de grade para mostrar uma grade de imagens.
Grades lentas
Os elementos combináveis LazyVerticalGrid e LazyHorizontalGrid oferecem suporte para a exibição de itens em uma grade. Uma grade vertical lenta mostra os itens em um contêiner de rolagem vertical, dividido em várias colunas, enquanto uma grade horizontal lenta tem o mesmo comportamento no eixo horizontal.
Do ponto de vista do design, o layout de grade é melhor para mostrar fotos de Marte como ícones ou imagens.
O parâmetro columns
na LazyVerticalGrid
e o parâmetro rows
na LazyHorizontalGrid
controlam como as células são formadas em colunas ou linhas. O exemplo abaixo mostra itens em uma grade, usando GridCells.Adaptive
para definir cada coluna com pelo menos 128.dp
de largura:
// Sample code - No need to copy over
@Composable
fun PhotoGrid(photos: List<Photo>) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 150.dp)
) {
items(photos) { photo ->
PhotoItem(photo)
}
}
}
LazyVerticalGrid
permite especificar uma largura para os itens. A grade se ajusta ao máximo de colunas possível. Depois de calcular o número de colunas, a grade distribui qualquer largura restante igualmente entre as colunas. Essa maneira adaptável de dimensionamento é útil principalmente para mostrar conjuntos de itens em diferentes tamanhos de tela.
Neste codelab, para mostrar fotos de Marte, você usa o elemento combinável LazyVerticalGrid
com GridCells.Adaptive
, com cada coluna definida como 150.dp
de largura.
Chaves de itens
Quando o usuário rola a grade (uma LazyRow
em uma LazyColumn
), a posição do item da lista muda. No entanto, devido a uma mudança de orientação ou se os itens forem adicionados ou removidos, o usuário pode perder a posição de rolagem na linha. As chaves de itens mantêm a posição de rolagem com base na chave.
Ao fornecer chaves, você ajuda o Compose a processar as reordenações corretamente. Por exemplo, se o item tiver um estado memorizado, a definição das chaves vai permitir que o Compose mova esse estado junto ao item quando a posição mudar.
Adicionar uma LazyVerticalGrid
Adicione uma função de composição para mostrar uma lista de fotos de Marte em uma grade vertical.
- No arquivo
HomeScreen.kt
, crie uma nova função de composição com o nomePhotosGridScreen()
, que usa uma lista deMarsPhoto
e ummodifier
como argumentos.
@Composable
fun PhotosGridScreen(
photos: List<MarsPhoto>,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
) {
}
- Dentro do combinável
PhotosGridScreen
, adicione umLazyVerticalGrid
com os parâmetros abaixo.
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.unit.dp
@Composable
fun PhotosGridScreen(
photos: List<MarsPhoto>,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
) {
LazyVerticalGrid(
columns = GridCells.Adaptive(150.dp),
modifier = modifier.padding(horizontal = 4.dp),
contentPadding = contentPadding,
) {
}
}
- Para adicionar uma lista de itens, dentro da lambda
LazyVerticalGrid
, chame a funçãoitems()
, transmitindo a lista deMarsPhoto
e uma chave de item comophoto.id
.
import androidx.compose.foundation.lazy.grid.items
@Composable
fun PhotosGridScreen(
photos: List<MarsPhoto>,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
) {
LazyVerticalGrid(
// ...
) {
items(items = photos, key = { photo -> photo.id }) {
}
}
}
- Para adicionar o conteúdo mostrado por um único item da lista, defina a expressão lambda
items
. ChameMarsPhotoCard
, transmitindo aphoto
.
items(items = photos, key = { photo -> photo.id }) {
photo -> MarsPhotoCard(photo)
}
- Atualize o elemento combinável
HomeScreen
para mostrar a funçãoPhotosGridScreen
em vez doMarsPhotoCard
para concluir a solicitação.
when (marsUiState) {
// ...
is MarsUiState.Success -> PhotosGridScreen(marsUiState.photos, modifier)
// ...
}
- No arquivo
MarsViewModel.kt
, atualize a interfaceMarsUiState
para aceitar uma lista de objetosMarsPhoto
em vez de uma únicaMarsPhoto
. O combinávelPhotosGridScreen
aceita uma lista de objetosMarsPhoto
.
sealed interface MarsUiState {
data class Success(val photos: List<MarsPhoto>) : MarsUiState
//...
}
- No arquivo
MarsViewModel.kt
, atualize a funçãogetMarsPhotos()
para transmitir uma lista de objetos de fotos de Marte ao métodoMarsUiState.Success()
.
marsUiState = try {
MarsUiState.Success(marsPhotosRepository.getMarsPhotos())
}
- Execute o app.
Não há padding ao redor de cada foto, e a proporção é diferente para fotos distintas. Você pode adicionar um elemento combinável Card
para corrigir esses problemas.
Adicionar um card combinável
- No arquivo
HomeScreen.kt
, no elemento combinávelMarsPhotoCard
, adicione umCard
com elevação de8.dp
ao redor daAsyncImage
. Atribua o argumentomodifier
ao elemento combinávelCard
.
import androidx.compose.material.Card
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.padding
@Composable
fun MarsPhotoCard(photo: MarsPhoto, modifier: Modifier = Modifier) {
Card(
modifier = modifier,
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
AsyncImage(
model = ImageRequest.Builder(context = LocalContext.current)
.data(photo.imgSrc)
.crossfade(true)
.build(),
error = painterResource(R.drawable.ic_broken_image),
placeholder = painterResource(R.drawable.loading_img),
contentDescription = stringResource(R.string.mars_photo),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth()
)
}
}
- Para corrigir a proporção, em
PhotosGridScreen()
, atualize o modificador doMarsPhotoCard()
.
@Composable
fun PhotosGridScreen(photos: List<MarsPhoto>, modifier: Modifier = Modifier) {
LazyVerticalGrid(
//...
) {
items(items = photos, key = { photo -> photo.id }) { photo ->
MarsPhotoCard(
photo,
modifier = modifier
.padding(4.dp)
.fillMaxWidth()
.aspectRatio(1.5f)
)
}
}
}
- Atualize a visualização da tela de resultados para visualizar
PhotosGridScreen()
. Simulação de dados com URLs de imagem vazios.
@Preview(showBackground = true) @Composable fun PhotosGridScreenPreview() { MarsPhotosTheme { val mockData = List(10) { MarsPhoto("$it", "") } PhotosGridScreen(mockData) } }
Como os dados simulados têm URLs vazios, você vai notar o carregamento de imagens na visualização da grade de fotos.
- Execute o app.
- Enquanto o app estiver em execução, ative o modo avião.
- Role as imagens no emulador. As imagens que ainda não foram carregadas aparecem como ícones de imagem corrompida. Este é o drawable de imagem que você transmitiu para a biblioteca de imagens Coil mostrar no caso de qualquer erro de rede ou imagem.
Bom trabalho! Você simulou um erro de conexão de rede ativando o modo avião no emulador ou dispositivo.
5. Adicionar uma ação de repetição
Nesta seção, você vai adicionar um botão de ação de nova tentativa e recuperar as fotos quando o botão for clicado.
- Adicione um botão à tela de erro. No arquivo
HomeScreen.kt
, atualize o combinávelErrorScreen()
para incluir um parâmetro lambdaretryAction
e um botão.
@Composable
fun ErrorScreen(retryAction: () -> Unit, modifier: Modifier = Modifier) {
Column(
// ...
) {
Image(
// ...
)
Text(//...)
Button(onClick = retryAction) {
Text(stringResource(R.string.retry))
}
}
}
Confira a prévia.
- Atualize o elemento combinável
HomeScreen()
para transmitir o lambda de repetição.
@Composable
fun HomeScreen(
marsUiState: MarsUiState, retryAction: () -> Unit, modifier: Modifier = Modifier
) {
when (marsUiState) {
//...
is MarsUiState.Error -> ErrorScreen(retryAction, modifier = modifier.fillMaxSize())
}
}
- No arquivo
ui/theme/MarsPhotosApp.kt
, atualize a chamada de funçãoHomeScreen()
para definir o parâmetro de lambdaretryAction
comomarsViewModel::getMarsPhotos
. Isso recupera as fotos de Marte do servidor.
HomeScreen(
marsUiState = marsViewModel.marsUiState,
retryAction = marsViewModel::getMarsPhotos
)
6. Atualizar o teste do ViewModel
O MarsUiState
e o MarsViewModel
agora mostram uma lista de fotos em vez de uma única foto. No estado atual, o MarsViewModelTest
espera que a classe de dados MarsUiState.Success
contenha uma propriedade de string. Portanto, o teste não é compilado. É necessário atualizar o teste marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess()
para declarar que o MarsViewModel.marsUiState
é igual ao estado Success
que contém a lista de fotos.
- Abra o arquivo
rules/MarsViewModelTest.kt
. - No teste
marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess()
, modifique a chamada de funçãoassertEquals()
para comparar um estadoSuccess
(transmitindo a lista de fotos falsas ao parâmetro fotos) para omarsViewModel.marsUiState
.
@Test
fun marsViewModel_getMarsPhotos_verifyMarsUiStateSuccess() =
runTest {
val marsViewModel = MarsViewModel(
marsPhotosRepository = FakeNetworkMarsPhotosRepository()
)
assertEquals(
MarsUiState.Success(FakeDataSource.photosList),
marsViewModel.marsUiState
)
}
O teste agora é compilado, executado e aprovado.
7. Acessar o código da solução
Para fazer o download do código do codelab concluído, use este comando git:
$ git clone https://github.com/google-developer-training/basic-android-kotlin-compose-training-mars-photos.git
Se preferir, você pode fazer o download do repositório como um arquivo ZIP, descompactar e abrir no Android Studio.
Confira o código da solução deste codelab no GitHub (link em inglês).
8. Conclusão
Parabéns por concluir este codelab e criar o app Mars Photos! É hora de mostrar seu app com fotos reais de Marte aos seus familiares e amigos.
Não se esqueça de compartilhar seu trabalho nas redes sociais com a hashtag #AndroidBasics.
9. Saiba mais
Documentação do desenvolvedor Android:
- Listas e grades | Jetpack Compose | Desenvolvedores do Android
- Grades lentas | Jetpack Compose | Desenvolvedores do Android
- Visão geral do ViewModel
Outro:
- Coil (link em inglês)