Boas práticas Kotlin no Android: null safety, extensions, coroutines e legibilidade

Capítulo 9

Tempo estimado de leitura: 11 minutos

+ Exercício

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 nulo

Operadores 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) ?: return

Passo a passo: tornando um fluxo de leitura de extras seguro

Objetivo: ler um extra opcional e um obrigatório sem !!.

  1. Identifique quais valores são realmente obrigatórios.
  2. Para obrigatórios, use requireNotNull com mensagem.
  3. 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.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

Práticas recomendadas

  • Use val por padrão; use var apenas quando necessário.
  • Prefira List/Map (somente leitura) ao expor dados; mantenha MutableList interno.
  • Em modelos, prefira data class com copy para “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>? = null

Refatoração:

  1. Crie uma sealed class com estados válidos.
  2. Troque as flags por uma única variável de estado.
  3. Atualize o render para um when exaustivo.
// 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.Loading

Extension 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.kt com 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çãoRetornaContextoUso típico
letresultado do blocoitnull safety, transformações
runresultado do blocothiscomputar valor a partir de objeto
applyo próprio objetothisconfiguração de objeto
alsoo próprio objetoitefeitos colaterais (log, métricas)
withresultado do blocothisagrupar 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 val em SCREAMING_SNAKE_CASE.
  • Evite abreviações ambíguas: prefira request a req, response a resp.

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 items nulo 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()

Agora responda o exercício sobre o conteúdo:

Ao refatorar um trecho que usa "!!" para ler valores que podem ser nulos, qual abordagem melhor mantém a segurança contra null e deixa explícito o que é obrigatório vs. opcional?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

A prática recomendada é deixar nulo apenas o que pode ser nulo e tratar isso explicitamente: requireNotNull para obrigatórios (falha cedo e clara) e ?: / ?.let para opcionais, evitando !! e crashes.

Próximo capitúlo

Permissões Android: runtime permissions, casos comuns e UX responsável

Arrow Right Icon
Capa do Ebook gratuito Android para Iniciantes com Kotlin: construindo seu primeiro app moderno
60%

Android para Iniciantes com Kotlin: construindo seu primeiro app moderno

Novo curso

15 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.