Desenvolvimento web e mobile

Fundamentos de Android: activities, Compose, arquitetura e trabalho em segundo plano

Iniciante10 min de leituraAtualizado

Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês

O Android acumulou duas décadas de APIs, e a parte difícil para quem está começando é saber quais delas ainda representam o caminho recomendado. Este guia percorre um app moderno, do build do Gradle e do manifesto até o ciclo de vida das activities, o Jetpack Compose, as camadas de ViewModel e repositório, coroutines e Flow, armazenamento, rede e o modelo de permissões em tempo de execução.

O que você vai aprender

  • Navegar por um projeto Android: módulos do Gradle, o manifesto, os recursos e os source sets
  • Explicar o ciclo de vida de uma activity e sobreviver a mudanças de configuração e à morte do processo
  • Construir telas com Jetpack Compose e ler os layouts XML que você ainda vai herdar
  • Estruturar um app com um ViewModel, um repositório e um fluxo de estado unidirecional
  • Tirar trabalho da thread principal com coroutines e agendar de forma confiável o trabalho adiável
  • Solicitar permissões em tempo de execução e escolher uma opção de armazenamento adequada

O desenvolvimento Android tem uma curva de aprendizado incomum: a plataforma é antiga o bastante para que quase todo problema tenha três soluções documentadas, uma recomendada, uma legada e uma descontinuada. Quem está começando e pesquisa como carregar dados ou navegar entre telas encontra respostas que cobrem quinze anos de design de API, sem nada no resultado que indique a qual época cada uma pertence. Conhecer o caminho recomendado hoje já é metade do caminho andado.

Este guia segue um único caminho de ponta a ponta: como um projeto é montado, o que o sistema operacional espera dos seus componentes, como desenhar uma tela com Jetpack Compose e como organizar o código para que os dados fluam em uma só direção e sobrevivam às interrupções que a plataforma vai impor. Os exemplos estão em Kotlin, a linguagem padrão do Android e a que toda a documentação atual usa.

Do que um projeto é feito

Um projeto Android é um build do Gradle. A raiz contém as configurações e as definições de versão, e cada módulo tem seu próprio script de build; o módulo padrão é o app.

// app/build.gradle.kts
android {
    namespace = "com.example.orders"
    compileSdk = 36

    defaultConfig {
        applicationId = "com.example.orders"
        minSdk = 26
        targetSdk = 36
        versionCode = 14
        versionName = "2.3.0"
    }

    buildFeatures { compose = true }
}

dependencies {
    implementation(libs.androidx.activity.compose)
    implementation(libs.androidx.lifecycle.viewmodel.compose)
    implementation(libs.room.runtime)
}

Três números de SDK têm funções diferentes e são confundidos o tempo todo. compileSdk é o nível de API contra o qual seu código é compilado: use o mais recente disponível. minSdk é a versão mais antiga em que o app pode ser instalado, o que define quanto da API moderna você pode usar sem verificações de versão. targetSdk é uma declaração de que você testou o app em determinada versão; o sistema aplica as mudanças de comportamento daquele nível e mantém camadas de compatibilidade para tudo o que for mais novo, e as lojas de apps exigem que ele se mantenha atualizado. As referências libs. vêm de um version catalog em gradle/libs.versions.toml, que é a forma atual de manter as versões das dependências em um só lugar para todos os módulos.

O manifesto declara ao sistema operacional o que o app contém e do que ele precisa:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:name=".OrdersApplication"
        android:icon="@mipmap/ic_launcher"
        android:theme="@style/Theme.Orders">

        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>
</manifest>

Cada activity, service, broadcast receiver e content provider precisa ser declarado aqui, junto com as permissões e eventuais requisitos de hardware. Um intent-filter anuncia o que um componente consegue tratar: o de cima é o que coloca um ícone no launcher, e outros fazem o app aparecer no menu de compartilhamento ou abrir uma determinada categoria de links.

Activities e o ciclo de vida

Uma activity é um ponto de entrada no seu app: uma tela que o sistema pode iniciar, parar, recriar ou matar. A maioria dos apps modernos usa uma única activity que hospeda muitos destinos, composables ou fragments, em vez de uma activity por tela.

O ciclo de vida existe porque o seu app não controla quando é executado. Chega uma ligação, o usuário troca de app, o aparelho gira, a memória fica escassa. Os callbacks são onCreate, onStart, onResume, onPause, onStop e onDestroy, e o pareamento é simétrico: o que você adquire em onStart, libera em onStop.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent { OrdersApp() }
    }
}

Duas consequências moldam todo o resto. Primeiro, uma mudança de configuração (rotação, modo escuro, idioma, entrada em tela dividida) destrói e recria a activity para que os recursos da nova configuração possam ser carregados. Tudo o que estava em um campo da activity se perde. Segundo, quando o app está em segundo plano, o sistema pode matar o processo inteiro e restaurá-lo depois, enquanto o usuário espera voltar exatamente de onde parou.

A resposta ao primeiro problema é o ViewModel, que tem o escopo da tela e sobrevive à recriação. A resposta ao segundo é SavedStateHandle ou rememberSaveable, que preservam uma pequena quantidade de estado através da morte do processo em um Bundle. Bloquear a rotação no manifesto não é solução; isso só esconde um entre muitos gatilhos.

Intents e a navegação entre telas

Uma intent é uma mensagem que descreve algo a ser feito. Uma intent explícita nomeia o componente de destino e é assim que você inicia a sua própria activity. Uma intent implícita descreve uma ação e deixa o sistema oferecer qualquer app instalado capaz de tratá-la.

// Explícita
startActivity(Intent(this, DetailActivity::class.java).putExtra("orderId", id))

// Implícita — qualquer app que saiba abrir um endereço web
startActivity(Intent(Intent.ACTION_VIEW, "https://example.com/orders/$id".toUri()))

Para obter resultados de outro app (escolher uma foto, tirar uma foto, solicitar uma permissão), use as Activity Result APIs com registerForActivityResult, que lidam corretamente com o problema da recriação. Dentro de um app de activity única, a navegação entre telas fica a cargo do componente Navigation: os destinos são declarados em um grafo, os argumentos são tipados e a back stack é gerenciada para você, sem malabarismos com activities. Os fragments vieram antes dos composables como unidade de tela reutilizável e continuam comuns; eles têm um ciclo de vida próprio, defasado em relação ao da activity hospedeira, e um ciclo de vida separado para a sua view, que é a origem da maioria dos bugs com fragments.

Desenhando a tela

Dois toolkits de interface convivem. O sistema de Views descreve uma hierarquia em XML e a infla em tempo de execução; é o que a esmagadora maioria dos apps existentes usa e o que você vai ler em qualquer base de código já estabelecida.

<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <TextView
        android:id="@+id/reference"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:textAppearance="?attr/textAppearanceTitleMedium" />
</androidx.constraintlayout.widget.ConstraintLayout>

O Jetpack Compose é o toolkit recomendado para trabalho novo. Em vez de descrever uma árvore e alterá-la, você escreve funções que transformam estado em interface, e o framework chama essas funções de novo quando o estado que elas leem muda.

@Composable
fun OrderRow(order: Order, onRemove: (String) -> Unit, modifier: Modifier = Modifier) {
    Row(
        modifier = modifier.fillMaxWidth().padding(16.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Column(Modifier.weight(1f)) {
            Text(order.reference, style = MaterialTheme.typography.titleMedium)
            Text(order.formattedTotal, style = MaterialTheme.typography.bodySmall)
        }
        IconButton(onClick = { onRemove(order.id) }) {
            Icon(Icons.Default.Delete, contentDescription = "Remove order")
        }
    }
}

Três convenções concentram a maior parte do aprendizado. O estado que pertence a um composable fica em remember { mutableStateOf(...) }, ou em rememberSaveable quando precisa sobreviver à recriação. O estado de que vários composables precisam é elevado (state hoisting) até o ancestral comum mais próximo, de modo que os filhos recebem um valor e um callback e continuam reutilizáveis e fáceis de pré-visualizar. E todo composable que renderiza algo deve aceitar um parâmetro Modifier como primeiro argumento opcional, para que quem chama controle o tamanho, o padding e o comportamento de clique.

A acessibilidade merece atenção aqui pelos mesmos motivos que na web: contentDescription é o equivalente ao texto alternativo, as áreas de toque devem ter pelo menos 48dp e os princípios discutidos em o essencial do HTML valem também para a tela de um celular.

Recursos, configuração e localização

Tudo o que não é código fica em res/: values/strings.xml, drawable/, mipmap/ para os ícones do launcher, layout/ para XML, font/ e as definições de tema. O nome do diretório pode ter qualificadores, e o sistema escolhe a melhor correspondência em tempo de execução: values-fr/ para francês, values-night/ para o modo escuro, drawable-hdpi/ para uma densidade, values-sw600dp/ para telas maiores. É por isso que deixar fixo no código um texto visível ao usuário é um defeito de verdade: o app fica impossível de traduzir, e o lint vai apontar o problema.

Use dp para medidas, para que um layout ocupe o mesmo tamanho físico em qualquer densidade, e sp para texto, para respeitar o tamanho de fonte preferido pelo usuário. Desde o nível de API 35, os apps desenham de borda a borda (edge-to-edge) por padrão, então você precisa aplicar os window insets em vez de supor um retângulo livre das barras do sistema.

Listas

Listas são o carro-chefe da interface mobile, e os dois toolkits reciclam views para que uma lista de mil itens não custe mais do que uma tela cheia. No sistema de Views isso é o RecyclerView, que precisa de um adapter, de um ViewHolder que guarda em cache as referências às views do item e de um LayoutManager; o ListAdapter com um DiffUtil.ItemCallback calcula o conjunto mínimo de mudanças entre duas listas para que os itens sejam animados corretamente. No Compose, o mesmo comportamento já vem embutido:

LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
    items(orders, key = { it.id }) { order ->
        OrderRow(order = order, onRemove = viewModel::remove)
    }
}

A key é o equivalente ao callback de diff: ela diz ao Compose qual item é qual entre recomposições, para que o estado e as animações acompanhem a linha certa. Omiti-la em uma lista reordenável produz a mesma categoria de bug que em qualquer framework de diff de listas.

Arquitetura: estado em uma só direção

A arquitetura recomendada tem três camadas. A camada de UI (composables mais um ViewModel) renderiza o estado e encaminha os eventos. A camada de domínio é opcional e contém casos de uso quando, sem ela, a lógica de negócio ficaria duplicada. A camada de dados contém os repositórios, cada um responsável por um tipo de dado e decidindo entre fontes locais e remotas.

data class OrdersUiState(
    val orders: List<Order> = emptyList(),
    val isLoading: Boolean = false,
    val error: String? = null,
)

class OrdersViewModel(private val repository: OrderRepository) : ViewModel() {
    private val _state = MutableStateFlow(OrdersUiState(isLoading = true))
    val state: StateFlow<OrdersUiState> = _state.asStateFlow()

    init { refresh() }

    fun refresh() = viewModelScope.launch {
        _state.update { it.copy(isLoading = true, error = null) }
        runCatching { repository.load() }
            .onSuccess { orders -> _state.update { it.copy(orders = orders, isLoading = false) } }
            .onFailure { e -> _state.update { it.copy(error = e.message, isLoading = false) } }
    }
}

O fluxo é unidirecional: o repositório produz os dados, o ViewModel os consolida em um único objeto de estado imutável, a interface renderiza esse objeto e as ações do usuário voltam como chamadas de função. Uma única classe de estado, em vez de várias flags independentes, é o que impede combinações impossíveis, como carregamento e erro verdadeiros ao mesmo tempo. O ViewModel não deve referenciar uma view, um context nem uma activity (é isso que causa vazamentos de memória), e a interface coleta o fluxo levando o ciclo de vida em conta, para que a coleta pare quando a tela não estiver visível.

Coroutines e o trabalho que precisa sobreviver

A thread principal renderiza a interface, então qualquer coisa lenta nela gera travamentos ou uma caixa de diálogo de "o app não está respondendo". As coroutines do Kotlin são a resposta padrão: funções de suspensão liberam a thread enquanto esperam, em vez de bloqueá-la.

class OrderRepository(private val api: OrderApi, private val dao: OrderDao) {
    suspend fun load(): List<Order> = withContext(Dispatchers.IO) {
        val fresh = api.fetchOrders()
        dao.replaceAll(fresh)
        fresh
    }

    fun observe(): Flow<List<Order>> = dao.observeAll()
}

O viewModelScope cancela suas coroutines automaticamente quando o ViewModel é limpo, e é isso que impede uma requisição de sobreviver à tela que a pediu. Dispatchers.IO serve para chamadas bloqueantes, Dispatchers.Default para trabalho de CPU e o dispatcher principal para tudo o que mexe na interface. Flow transporta fluxos de valores ao longo do tempo e StateFlow sempre guarda um valor atual, por isso se encaixa tão bem no estado de uma tela. Se você vem do modelo de threads e executors descrito em concorrência em Java, as coroutines são concorrência estruturada sobre essa mesma maquinaria, com um cancelamento que realmente se propaga.

O trabalho que precisa ser concluído mesmo que o usuário saia do app (enviar um formulário que ficou na fila, sincronizar durante a noite) é tarefa do WorkManager, que persiste a solicitação, aplica restrições como "somente em rede não tarifada e com o aparelho carregando", tenta de novo com backoff e sobrevive a reinicializações. Não o use para nada que a tela atual esteja esperando.

Rede, armazenamento e permissões

A maioria dos apps conversa com uma API HTTP, normalmente com Retrofit ou Ktor sobre OkHttp, e serializa JSON com kotlinx.serialization ou Moshi. Defina a API como uma interface de funções de suspensão, mantenha os modelos de transporte separados dos modelos de domínio e faça a conversão no repositório, para que uma mudança no backend não se espalhe até a interface. Trate os códigos de status de forma deliberada em vez de encarar qualquer resposta fora da faixa 2xx como uma mesma falha: as distinções da referência de códigos de status HTTP se traduzem diretamente em tentar de novo, autenticar novamente ou mostrar uma mensagem, e o contrato do lado do servidor é tratado em design de APIs REST.

Para armazenamento, as opções são: DataStore para preferências chave-valor, que substitui o antigo SharedPreferences e é assíncrono e type-safe; Room para dados locais estruturados, uma camada SQL verificada em tempo de compilação que retorna Flow para que a interface se atualize quando a tabela mudar; o diretório de arquivos privado do app para documentos e caches; e o MediaStore ou o seletor de fotos do sistema para mídia compartilhada. Nada sensível deve ficar em preferências comuns: use o Keystore para chaves e credenciais.

As permissões são declaradas no manifesto e, para tudo o que o sistema considera perigoso (localização, câmera, microfone, contatos, notificações), também precisam ser concedidas pelo usuário em tempo de execução por meio da Activity Result API. Peça-as no momento em que o recurso é usado, e não na abertura do app, explique o motivo antes de pedir quando ele não for óbvio e trate a recusa como um caminho normal, não como um erro. A plataforma continua restringindo o que um app pode acessar sem pedir, então prefira as APIs que não exigem permissão nenhuma: o seletor de fotos em vez do acesso ao armazenamento, e o seletor de documentos em vez de caminhos de arquivo.

Principais conclusões

  • Um projeto Android é um build do Gradle com um manifesto que declara os componentes ao sistema; compileSdk, minSdk e targetSdk controlam, cada um, algo diferente.
  • O ciclo de vida das activities existe porque é o sistema que decide quando o seu app roda: conte com a recriação em mudanças de configuração e com a morte do processo em segundo plano, e mantenha o estado em um ViewModel, com SavedStateHandle para o restante.
  • O Jetpack Compose é o toolkit de interface recomendado: eleve o estado, passe um Modifier e deixe a recomposição redesenhar o que mudou; o sistema de Views continua essencial para ler código existente.
  • Estruture o app em camadas de UI, domínio (opcional) e dados, com um único objeto de estado imutável por tela e os eventos voltando como callbacks.
  • Coroutines com viewModelScope e Flow tiram o trabalho da thread principal e são canceladas junto com a tela; o WorkManager serve apenas para trabalho que precisa sobreviver ao fechamento do app.
  • Escolha o armazenamento pelo formato dos dados (DataStore para preferências, Room para dados estruturados) e peça permissões em tempo de execução no momento do uso, preferindo APIs que não exijam nenhuma.

Fontes e leituras complementares

Perguntas frequentes

Devo aprender primeiro Compose ou layouts XML?
Aprenda Compose primeiro, porque é o toolkit recomendado para interfaces novas e é para onde a documentação e as bibliotecas estão caminhando. Você ainda vai precisar ler layouts XML, já que uma quantidade enorme de código em produção os usa e a interoperabilidade é comum em telas mistas. Encare o sistema de Views como algo que você sabe manter, e não como algo que escolheria para uma tela nova.
Por que meu app perde o estado quando a tela gira?
Uma mudança de configuração destrói e recria a activity para que ela recarregue os recursos da nova configuração. Tudo o que estiver em um campo da activity se perde. Mantenha o estado da tela em um ViewModel, que sobrevive à recriação, e coloque a pequena parte que precisa sobreviver à morte do processo no SavedStateHandle. Bloquear a rotação não resolve: a mesma recriação acontece ao trocar o idioma, ativar o modo escuro ou usar várias janelas.
Qual a diferença entre uma coroutine e o WorkManager?
Uma coroutine executa trabalho agora, vinculada a algo que está vivo (geralmente um ViewModel), e é cancelada quando esse scope termina. O WorkManager agenda trabalho adiável que precisa ser concluído em algum momento, mesmo que o usuário saia do app ou o aparelho reinicie, como enviar um relatório que ficou na fila. Use coroutines para tudo o que alimenta a tela atual e o WorkManager só quando a execução garantida importa.
Preciso de um framework de injeção de dependências?
Não para começar. Construir as dependências à mão em um app pequeno é claro e não tem custo de build. À medida que o grafo cresce, passar um banco de dados, um cliente de rede e meia dúzia de repositórios por todos os construtores vira ruído, e um framework como o Hilt gera essa ligação e torna simples trocar dependências por fakes nos testes. Adote-o quando a ligação manual começar a incomodar, não antes.