Null safety no Android: escrevendo código que não quebra
Um dos maiores ganhos do Kotlin no Android é reduzir NullPointerException com um sistema de tipos que diferencia referências nulas e não nulas. A prática recomendada é: tornar nulo apenas o que realmente pode ser nulo e lidar com isso explicitamente.
Tipos anuláveis vs. não anuláveis
Em Kotlin, String não aceita null. Já String? aceita. Isso força você a tratar o caso nulo antes de usar o valor.
val name: String = "Ana" // nunca nulo
val nickname: String? = null // pode ser nuloOperadores essenciais: ?., ?:, let e requireNotNull
?.(safe call): só acessa se não for nulo.?:(Elvis): define um valor padrão quando for nulo.let: executa um bloco apenas quando não é nulo.requireNotNull: valida e falha cedo com mensagem clara (bom para invariantes).
val userName: String? = intent.getStringExtra("user_name")
// 1) Usar com fallback
val displayName = userName ?: "Convidado"
// 2) Executar bloco apenas se existir
userName?.let { nonNullName ->
println("Bem-vindo, $nonNullName")
}
// 3) Quando o app exige o valor (falha cedo)
val id = requireNotNull(intent.getStringExtra("id")) { "id é obrigatório" }Evite !! (not-null assertion)
!! ignora a segurança do Kotlin e pode causar crash. Use apenas em casos extremamente controlados (ex.: testes) e prefira alternativas como ?: return, requireNotNull ou validações.
// Ruim: pode crashar
val token = prefs.getString("token", null)!!
// Melhor: tratar ausência
val token = prefs.getString("token", null) ?: returnPasso a passo: tornando um fluxo de leitura de extras seguro
Objetivo: ler um extra opcional e um obrigatório sem !!.
- Identifique quais valores são realmente obrigatórios.
- Para obrigatórios, use
requireNotNullcom mensagem. - Para opcionais, use
?:ou?.let.
val userId = requireNotNull(intent.getStringExtra("user_id")) { "user_id é obrigatório" }
val referral = intent.getStringExtra("ref") ?: ""Imutabilidade: menos bugs, mais previsibilidade
Imutabilidade significa preferir val a var e estruturas imutáveis quando possível. Isso reduz efeitos colaterais e facilita raciocinar sobre estado, especialmente em apps com múltiplas fontes de eventos.
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
Práticas recomendadas
- Use
valpor padrão; usevarapenas quando necessário. - Prefira
List/Map(somente leitura) ao expor dados; mantenhaMutableListinterno. - Em modelos, prefira
data classcomcopypara “alterar” criando uma nova instância.
data class UiState(
val isLoading: Boolean = false,
val items: List<String> = emptyList(),
val errorMessage: String? = null
)
val newState = oldState.copy(isLoading = true)Data classes no Android: modelos claros e fáceis de manter
data class é ideal para representar dados (ex.: DTOs, modelos de domínio, estados de UI). Ela gera automaticamente equals, hashCode, toString e copy.
Boas práticas com data class
- Use nomes que expressem o papel:
User,Product,LoginUiState. - Evite colocar lógica pesada dentro de modelos; prefira funções puras pequenas ou mapeadores.
- Use valores padrão para reduzir boilerplate.
data class User(
val id: String,
val name: String,
val avatarUrl: String? = null
)Sealed classes: representando estados e resultados sem ambiguidade
sealed class permite modelar um conjunto fechado de possibilidades. Isso é excelente para resultados de operações (sucesso/erro) e estados de tela, porque o when pode ser exaustivo (o compilador ajuda a não esquecer casos).
Exemplo: resultado de uma operação
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val message: String, val cause: Throwable? = null) : Result<Nothing>()
data object Loading : Result<Nothing>()
}fun render(result: Result<List<String>>) {
when (result) {
is Result.Success -> println("Itens: ${result.data.size}")
is Result.Error -> println("Erro: ${result.message}")
Result.Loading -> println("Carregando...")
}
}Passo a passo: refatorando flags soltas para sealed class
Problema comum: múltiplas flags que podem entrar em combinações inválidas.
// Antes: combinações inválidas são possíveis
var loading: Boolean = false
var error: String? = null
var data: List<String>? = nullRefatoração:
- Crie uma sealed class com estados válidos.
- Troque as flags por uma única variável de estado.
- Atualize o render para um
whenexaustivo.
// Depois: estados válidos e explícitos
sealed class ItemsState {
data object Loading : ItemsState()
data class Content(val items: List<String>) : ItemsState()
data class Error(val message: String) : ItemsState()
}
var state: ItemsState = ItemsState.LoadingExtension functions: adicionando utilidades sem acoplamento
Extension functions permitem criar funções “como se” fossem métodos de uma classe, sem herança e sem modificar o código original. No Android, isso ajuda a reduzir repetição e manter utilidades próximas do uso.
Exemplos úteis
// String: validação simples
fun String.isEmailLike(): Boolean = contains("@") && contains(".")
// List: map seguro
fun <T> List<T>?.orEmptyList(): List<T> = this ?: emptyList()Extensions para reduzir acoplamento à UI
Uma boa prática é evitar que regras de negócio dependam de classes de UI. Extensions podem ajudar a encapsular detalhes de formatação e conversão em camadas apropriadas.
data class Price(val cents: Int)
fun Price.format(): String = "R$ %.2f".format(cents / 100.0)Assim, a UI só chama price.format() e não precisa conhecer a regra de conversão.
Organização recomendada
- Crie arquivos como
StringExtensions.kt,CollectionExtensions.kt,FormattingExtensions.kt. - Evite “God file”
Extensions.ktcom centenas de funções. - Nomeie funções por intenção:
toUiModel(),format(),isValid....
Scope functions: use com critério para manter legibilidade
let, run, apply, also e with ajudam a reduzir repetição, mas podem prejudicar a leitura quando aninhadas ou usadas sem necessidade. A regra prática: use quando melhora a clareza; caso contrário, prefira código explícito.
| Função | Retorna | Contexto | Uso típico |
|---|---|---|---|
let | resultado do bloco | it | null safety, transformações |
run | resultado do bloco | this | computar valor a partir de objeto |
apply | o próprio objeto | this | configuração de objeto |
also | o próprio objeto | it | efeitos colaterais (log, métricas) |
with | resultado do bloco | this | agrupar chamadas em um objeto |
Exemplos com boa legibilidade
// let: só executa se não for nulo
val length = userName?.let { it.length } ?: 0
// apply: configurar objeto
val user = User(id = "1", name = "Ana").copy().also {
// also: log/efeito colateral
println("Criado: $it")
}Evite aninhamento excessivo
// Difícil de ler (evite)
val result = input?.let { a ->
a.trim().run {
takeIf { it.isNotEmpty() }?.also { println(it) }
}
}Prefira passos nomeados:
val trimmed = input?.trim()
val nonEmpty = trimmed?.takeIf { it.isNotEmpty() }
nonEmpty?.let { println(it) }Coroutines no Android: assíncrono com cancelamento e clareza
Coroutines permitem executar tarefas assíncronas sem callbacks aninhados. O ponto central é que coroutines cooperam com cancelamento e permitem escolher o contexto de execução via Dispatchers.
Conceitos essenciais
- suspend function: função que pode pausar sem bloquear a thread.
- CoroutineScope: define o ciclo de vida das coroutines lançadas nele.
- Dispatchers: define onde roda (ex.: CPU, I/O).
- Structured concurrency: coroutines filhas são canceladas junto com o escopo pai.
Dispatchers mais usados
Dispatchers.Main: UI (atualizações de tela).Dispatchers.IO: I/O (rede, banco, arquivo).Dispatchers.Default: CPU-bound (parse, cálculos).
Passo a passo: mover trabalho pesado para o dispatcher correto
Exemplo: carregar dados (I/O) e depois preparar uma lista (CPU) antes de exibir.
suspend fun loadAndPrepare(): List<String> {
val raw = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
// simulação de I/O
listOf(" a ", " b ", "c ")
}
return kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) {
raw.map { it.trim() }.filter { it.isNotEmpty() }
}
}Repare que withContext muda o dispatcher apenas para o bloco necessário, mantendo o restante simples.
Cancelamento: escreva código que respeita o ciclo de vida
Em Android, tarefas devem ser canceladas quando não fazem mais sentido (ex.: usuário saiu da tela). Coroutines suportam cancelamento cooperativo: operações suspensas (como delay) e checagens de isActive ajudam.
suspend fun pollingExample() {
while (kotlinx.coroutines.currentCoroutineContext().isActive) {
// faça uma consulta periódica
kotlinx.coroutines.delay(1_000)
}
}Tratamento de erros: try/catch e CoroutineExceptionHandler
Em funções suspend, use try/catch para transformar exceções em resultados previsíveis (ex.: sealed class Result).
suspend fun safeLoad(): Result<List<String>> {
return try {
val data = loadAndPrepare()
Result.Success(data)
} catch (e: Exception) {
Result.Error(message = "Falha ao carregar", cause = e)
}
}Importante: não engula CancellationException. Se você capturar Exception, o cancelamento pode ser tratado como erro. Prefira re-lançar cancelamento:
suspend fun safeLoad(): Result<List<String>> {
return try {
Result.Success(loadAndPrepare())
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
Result.Error("Falha ao carregar", e)
}
}Legibilidade: naming, organização e limites entre camadas
Padrões de naming (Kotlin/Android)
- Classes: substantivos em PascalCase (
UserRepository,FetchUserUseCase). - Funções: verbos em camelCase (
loadUser,refreshItems). - Booleanos: prefixos como
is,has,can(isLoading,hasPermission). - Constantes:
const valem SCREAMING_SNAKE_CASE. - Evite abreviações ambíguas: prefira
requestareq,responsearesp.
Organização de arquivos: coesão acima de “tamanho”
- Um arquivo por tipo principal é um bom padrão (ex.:
User.kt,Result.kt). - Agrupe extensions por tema (strings, datas, formatação) e mantenha-as pequenas.
- Evite arquivos com múltiplas classes não relacionadas.
Reduzindo código acoplado à UI
Mesmo sem repetir arquitetura, uma regra prática é: UI deve apenas renderizar e encaminhar eventos. Regras de formatação, validação e transformação devem ficar fora da UI (em funções puras, mapeadores, ou classes específicas).
// Ruim: UI decide regra de negócio
fun buttonText(count: Int): String {
return if (count == 1) "1 item" else "$count itens"
}
// Melhor: regra isolada e testável
fun formatItemCount(count: Int): String =
if (count == 1) "1 item" else "$count itens"Exercícios de refatoração (clareza, segurança e coroutines)
Exercício 1: remover !! e deixar o fluxo explícito
Refatore para não usar !! e para deixar claro o que é obrigatório vs. opcional.
// Código inicial
fun buildGreeting(extras: Map<String, String?>): String {
val name = extras["name"]!!
val city = extras["city"]
return "Olá $name de ${city!!.trim()}"
}Requisitos da refatoração:
nameé obrigatório (falhar cedo com mensagem).cityé opcional (se nulo ou em branco, usar "sua cidade").
// Possível solução
fun buildGreeting(extras: Map<String, String?>): String {
val name = requireNotNull(extras["name"]) { "name é obrigatório" }
val city = extras["city"]?.trim().takeUnless { it.isNullOrEmpty() } ?: "sua cidade"
return "Olá $name de $city"
}Exercício 2: substituir múltiplas flags por sealed class
Refatore para um estado único e um when exaustivo.
// Código inicial
data class ScreenModel(
val loading: Boolean,
val error: String?,
val items: List<String>?
)
fun render(model: ScreenModel) {
if (model.loading) {
println("Loading")
} else if (model.error != null) {
println("Error: ${model.error}")
} else {
println("Items: ${model.items!!.size}")
}
}Requisitos da refatoração:
- Não permitir
itemsnulo quando for conteúdo. - Eliminar
!!.
// Possível solução
sealed class ScreenState {
data object Loading : ScreenState()
data class Error(val message: String) : ScreenState()
data class Content(val items: List<String>) : ScreenState()
}
fun render(state: ScreenState) {
when (state) {
ScreenState.Loading -> println("Loading")
is ScreenState.Error -> println("Error: ${state.message}")
is ScreenState.Content -> println("Items: ${state.items.size}")
}
}Exercício 3: usar coroutines com dispatcher correto e respeitar cancelamento
Refatore a função para:
- Executar I/O em
Dispatchers.IO. - Executar transformação pesada em
Dispatchers.Default. - Não capturar cancelamento como erro.
// Código inicial
suspend fun loadNames(): Result<List<String>> {
return try {
val raw = listOf(" maria ", " joão", "")
val cleaned = raw.map { it.trim() }.filter { it.isNotEmpty() }
Result.Success(cleaned)
} catch (e: Exception) {
Result.Error("Falhou", e)
}
}// Possível solução
suspend fun loadNames(): Result<List<String>> {
return try {
val raw = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
listOf(" maria ", " joão", "")
}
val cleaned = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) {
raw.map { it.trim() }.filter { it.isNotEmpty() }
}
Result.Success(cleaned)
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
Result.Error("Falhou", e)
}
}Exercício 4: extension function para reduzir repetição e melhorar intenção
Crie uma extension que transforme uma lista de strings em uma lista “limpa” (trim + remove vazios), e use-a no lugar do código repetido.
// Código inicial
fun clean(input: List<String>): List<String> {
return input.map { it.trim() }.filter { it.isNotEmpty() }
}// Possível solução
fun List<String>.cleaned(): List<String> = map { it.trim() }.filter { it.isNotEmpty() }
fun clean(input: List<String>): List<String> = input.cleaned()