Extensão — Java no Android: o que Mudou com o Kotlin e um App que Consome a API

Extensão — Java no Android: o que Mudou com o Kotlin e um App que Consome a API

As Trilhas da Ilha chegam ao celular: por que o Android usa a linguagem Java sem a JVM, o que decidiu o processo Oracle contra Google e por que o Kotlin virou a primeira escolha. Um app em Java consome a API com o mesmo contrato do painel desktop e é comparado à versão em Kotlin com Compose.
Java

• • 28 min de leitura

Nesta aula as Trilhas da Ilha chegam ao celular. Você vai entender por que o Java do Android não é exatamente o Java do JDK, o que o processo Oracle contra Google decidiu, por que o Kotlin virou a primeira escolha, e vai escrever em Java um app que consome a API com o mesmo contrato do painel desktop, comparando com a versão equivalente em Kotlin.

O Android é, de longe, a plataforma com mais código escrito na linguagem Java no planeta: bilhões de aparelhos rodando aplicativos compilados a partir de arquivos .java. E, no entanto, nenhum desses aparelhos roda uma JVM. Esse paradoxo é a primeira coisa a entender sobre Java no Android. A segunda é que, de 2019 para cá, a própria Google passou a recomendar outra linguagem para novos aplicativos. Nesta aula você vai entender as duas coisas e ver, na prática, o que continua valendo do que você aprendeu.

Um Java sem JVM

O Android foi criado por uma pequena empresa comprada pela Google em 2005, e o primeiro aparelho, o HTC Dream, saiu em 2008. A equipe escolheu a linguagem Java por um motivo prático: milhões de programadores já a conheciam. Mas não adotou a plataforma Java:

  • Outra máquina virtual. O código Java é compilado para bytecode de JVM normalmente, e depois uma ferramenta o converte para outro formato, o DEX (Dalvik Executable). Quem executa esse formato é uma máquina virtual própria do Android. A primeira foi a Dalvik, baseada em registradores, e não em pilha, como a JVM. Desde o Android 5.0, em 2014, é a ART (Android Runtime), que mistura compilação antecipada, JIT e perfis de uso para compilar o que o usuário mais usa. Hoje o conversor se chama D8, e o otimizador que o acompanha nos builds de produção, R8.
  • Outra biblioteca padrão. O Android traz um subconjunto próprio das classes do Java. Começou com o Apache Harmony, uma implementação independente, e desde o Android 7 (2016) usa código do OpenJDK. Mas é um subconjunto, em outra versão. Não há Swing nem JavaFX, é claro, e também não há java.net.http: o HttpClient do painel desktop simplesmente não existe no Android.

Essa reimplementação levou ao processo judicial mais importante da história do software. Em 2010, a Oracle, que acabara de comprar a Sun, processou a Google alegando que o Android copiava a API do Java: os nomes dos pacotes, das classes e as assinaturas dos métodos, ainda que não o código que as implementava. O caso atravessou onze anos e dois julgamentos com júri. Em abril de 2021, a Suprema Corte dos Estados Unidos decidiu, por 6 a 2, que o uso que a Google fez das declarações da API Java constituía uso justo (fair use). A decisão teve consequências para toda a indústria: reimplementar uma interface para permitir que programadores usem o que já conhecem, como fazem emuladores, bancos compatíveis e bibliotecas alternativas, seguiu sendo possível.

Até hoje, as versões do Java suportadas no Android vêm com atraso e por partes. A documentação oficial relaciona cada versão do Android às APIs do Java disponíveis: o Android 13 traz as bibliotecas equivalentes às do Java 11, e o Android 14, às do Java 17. Recursos de linguagem, como records, switch com padrões e var, dependem do compilador e das conversões do D8, e por isso chegam antes. As APIs de biblioteca dependem da versão do Android instalada no aparelho. Para algumas delas existe o desugaring: o D8 reescreve chamadas a APIs novas para uma implementação embutida no app, e o java.time, por exemplo, funciona até em aparelhos antigos.

Do Java ao Kotlin

Em 2017 a Google anunciou suporte oficial ao Kotlin, a linguagem da JetBrains que compila para bytecode de JVM (e para DEX) e conversa com Java nos dois sentidos. Em 2019 veio o anúncio de que o desenvolvimento Android passava a ser "Kotlin first": documentação, exemplos e bibliotecas novas pensados primeiro para Kotlin. Em 2021 chegou a versão estável do Jetpack Compose, o novo sistema de interface declarativo, que só pode ser usado em Kotlin, porque depende de um plugin do compilador Kotlin.

Por que o Kotlin venceu no Android? Não foi por um único recurso, mas por uma soma de conveniências que pesam no dia a dia de um app.

  • Nulidade no sistema de tipos. String e String? são tipos diferentes, e o compilador impede o NullPointerException mais comum. É o que o JSpecify de "Contratos à Vista — Imutabilidade, Programação Defensiva e JSpecify" tenta trazer ao Java por anotações.
  • Corrotinas. Código assíncrono escrito como se fosse sequencial, com cancelamento amarrado ao ciclo de vida da tela.
  • Menos cerimônia. Data classes, properties, funções de extensão e parâmetros com valor padrão.
  • Independência da versão do Android. Enquanto o Java do Android ficava anos atrás do JDK, o Kotlin podia evoluir por conta própria.

Mas repare no calendário. Muito do que tornava o Java verboso em 2017 não existe mais: records, var, switch com padrões, text blocks e as virtual threads, que no Android ainda não chegaram. O Java continua totalmente suportado para apps Android, com as Views clássicas, as bibliotecas Jetpack e o mesmo ambiente de build. O que mudou é o ecossistema: exemplos, bibliotecas novas e o Compose pressupõem Kotlin. Por isso esta aula faz o app em Java, com tudo o que você já sabe, e depois mostra a mesma peça em Kotlin, para você enxergar o que muda.

O que é portátil, e o que não é

O painel desktop foi dividido em camadas, e agora essa divisão paga a conta. Observe o que atravessa para o Android sem mudar uma linha:

Peça do painel desktop No Android
records ResumoDeTrilha, Pagina, Problema, Sessao igual (Java puro)
interface ApiDeTrilhas, ErroDaApi igual
ClienteHttpDaApi (java.net.http + Jackson) não serve: sem java.net.http no Android
CatalogoViewModel (javafx.beans) não serve: sem JavaFX; o Android tem o seu próprio ViewModel
FXML, CSS, controlador não serve: o Android tem Views em XML ou Compose

O contrato, com os records e a interface, vai para um módulo Java puro, compartilhado pelos dois aplicativos. Cada plataforma implementa o resto com as suas próprias ferramentas. É a arquitetura da aula anterior se provando: quem dependia de abstrações atravessou, quem dependia de detalhes de plataforma ficou.

O cliente HTTP do Android

O Android oferece duas coisas embutidas que servem aqui: o velho HttpURLConnection e a biblioteca org.json. Muitos apps usam bibliotecas como OkHttp e Retrofit, mas começar pelo que vem no sistema deixa claro o que essas bibliotecas fazem por você:

package br.com.exemplo.painel;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;

/**
 * O mesmo contrato ApiDeTrilhas do painel desktop, com o que o Android oferece:
 * HttpURLConnection (não existe java.net.http no Android) e org.json (já vem no sistema).
 */
public final class ClienteAndroidDaApi implements ApiDeTrilhas {

    private final String base;
    private final AtomicReference<Sessao> sessao = new AtomicReference<>();

    public ClienteAndroidDaApi(String base) {
        this.base = base;
    }

    @Override
    public Sessao entrar(String usuario, String senha) throws IOException, ErroDaApi, JSONException {
        var corpo = new JSONObject().put("usuario", usuario).put("senha", senha);
        JSONObject t = new JSONObject(pedir("POST", "/api/v1/auth/token", corpo.toString(), false));
        var nova = new Sessao(usuario, t.getString("access_token"),
                Instant.now().plusSeconds(t.getLong("expires_in")));
        sessao.set(nova);
        return nova;
    }

    @Override
    public Pagina<ResumoDeTrilha> listar(String regiao, int pagina, int tamanho)
            throws IOException, ErroDaApi, JSONException {
        String caminho = "/api/v1/trilhas?pagina=" + pagina + "&tamanho=" + tamanho
                + (regiao == null ? "" : "&regiao=" + URLEncoder.encode(regiao, "UTF-8"));
        JSONObject p = new JSONObject(pedir("GET", caminho, null, false));
        JSONArray a = p.getJSONArray("itens");
        List<ResumoDeTrilha> itens = new ArrayList<>(a.length());
        for (int i = 0; i < a.length(); i++) {
            JSONObject t = a.getJSONObject(i);
            itens.add(new ResumoDeTrilha(t.getLong("id"), t.getString("nome"), t.getString("regiao"),
                    t.getDouble("distanciaKm"), t.getString("dificuldade")));
        }
        return new Pagina<>(itens, p.getInt("pagina"), p.getInt("tamanho"),
                p.getLong("totalDeItens"), p.getInt("totalDePaginas"));
    }

    @Override
    public void remover(long id) throws IOException, ErroDaApi {
        pedir("DELETE", "/api/v1/trilhas/" + id, null, true);
    }

    private String pedir(String metodo, String caminho, String corpoJson, boolean autenticar)
            throws IOException, ErroDaApi {
        var conexao = (HttpURLConnection) URI.create(base + caminho).toURL().openConnection();
        try {
            conexao.setRequestMethod(metodo);
            conexao.setConnectTimeout(5_000);
            conexao.setReadTimeout(10_000);
            conexao.setRequestProperty("Accept", "application/json, application/problem+json");
            if (autenticar) {
                Sessao s = sessao.get();
                if (s == null || Instant.now().isAfter(s.expiraEm().minusSeconds(30))) throw new ErroDaApi(401, null);
                conexao.setRequestProperty("Authorization", "Bearer " + s.token());
            }
            if (corpoJson != null) {
                conexao.setDoOutput(true);
                conexao.setRequestProperty("Content-Type", "application/json; charset=utf-8");
                try (OutputStream out = conexao.getOutputStream()) {
                    out.write(corpoJson.getBytes(StandardCharsets.UTF_8));
                }
            }
            int status = conexao.getResponseCode();
            if (status >= 400) {
                if (status == 401) sessao.set(null);
                // Em erro, o corpo vem pelo getErrorStream(): getInputStream() lançaria IOException
                throw new ErroDaApi(status, lerProblema(conexao.getErrorStream(), conexao.getContentType()));
            }
            return ler(conexao.getInputStream());
        } finally {
            conexao.disconnect();
        }
    }

    private static Problema lerProblema(InputStream erro, String tipo) {
        if (erro == null || tipo == null || !tipo.startsWith("application/problem+json")) return null;
        try {
            var p = new JSONObject(ler(erro));
            return new Problema(p.optString("type", null), p.optString("title", null),
                    p.optInt("status"), p.optString("detail", null));
        } catch (IOException | JSONException e) {
            return null;
        }
    }

    private static String ler(InputStream in) throws IOException {
        if (in == null) return "";
        // Sem InputStream.transferTo nem ByteArrayOutputStream.toString(Charset):
        // essas APIs só existem no Android 13+ (API 33), e o minSdk do app é 26
        try (in) {
            var buffer = new ByteArrayOutputStream();
            byte[] bloco = new byte[8192];
            for (int n; (n = in.read(bloco)) != -1; ) buffer.write(bloco, 0, n);
            return new String(buffer.toByteArray(), StandardCharsets.UTF_8);
        }
    }
}

Este cliente foi exercitado contra a API de ensaio da aula anterior, usando a mesma implementação de org.json que acompanha o Android (reescrita pelo próprio projeto Android), com o mesmo roteiro de verificações:

página: [Caminho do Ribeirão, Galheta, Lagoinha do Leste] de 5
remover sem sessão → ErroDaApi 401: HTTP 401
senha errada → ErroDaApi 401: Usuário ou senha incorretos.
remover como EDITOR → ErroDaApi 403: HTTP 403
remover como ADMIN → ok
remover de novo → ErroDaApi 404: Não existe trilha com id 6.

Três detalhes são específicos do Android.

  • getErrorStream() para os erros. Com status 4xx ou 5xx, o getInputStream() do HttpURLConnection lança IOException. O corpo do Problem Details só é acessível pelo getErrorStream(), e muitos apps perdem a mensagem da API por não saberem disso.
  • APIs escolhidas pela versão do aparelho. A primeira versão deste código usava InputStream.transferTo e ByteArrayOutputStream.toString(Charset), que existem no JDK desde as versões 9 e 10, e compilou sem reclamar. Mas no Android elas só chegaram no Android 13 (API 33). Num celular com Android 10, o app fecharia com NoSuchMethodError na primeira chamada à API. A versão final usa o laço clássico. O Android Studio aponta esses casos com o lint NewApi, e é por isso que o minSdk do projeto importa tanto.
  • URI.create(...).toURL() em vez de new URL(...), cujo construtor foi descontinuado no JDK 20. As duas formas funcionam no Android, mas a primeira compila sem aviso nos dois mundos.

O projeto Android

A estrutura é criada pelo Android Studio. O arquivo de build do módulo do app, app/build.gradle.kts, declara as versões:

plugins {
    id("com.android.application")
}

android {
    namespace = "br.com.exemplo.trilhas"
    compileSdk = 37                       // Android 17: as APIs mais novas disponíveis para compilar

    defaultConfig {
        applicationId = "br.com.exemplo.trilhas"
        minSdk = 26                       // Android 8.0: o aparelho mais antigo aceito
        targetSdk = 37
        versionCode = 1
        versionName = "1.0"
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
}

dependencies {
    implementation(project(":contrato"))                  // records + ApiDeTrilhas, compartilhados com o desktop
    implementation("androidx.appcompat:appcompat:1.7.1")
    implementation("androidx.recyclerview:recyclerview:1.4.0")
    implementation("androidx.lifecycle:lifecycle-viewmodel:2.9.4")
    implementation("androidx.lifecycle:lifecycle-livedata:2.9.4")
}

(Os números das bibliotecas Jetpack mudam a cada poucas semanas: use as versões que o Android Studio sugerir.)

Os três números do SDK têm papéis distintos:

  • compileSdk: quais APIs do Android você pode chamar no código.
  • minSdk: o aparelho mais antigo em que o app instala. Toda API mais nova que ele precisa de verificação em tempo de execução.
  • targetSdk: o comportamento que o app declara estar pronto para enfrentar. O Android liga regras mais rígidas conforme esse número sobe, e a Play Store exige que ele acompanhe as versões recentes.

O manifesto, AndroidManifest.xml, declara a permissão de rede e a tela inicial:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:label="Trilhas da Ilha"
        android:theme="@style/Theme.AppCompat.Light.DarkActionBar"
        android:networkSecurityConfig="@xml/rede">
        <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>

Sem a permissão INTERNET, qualquer conexão falha com SecurityException. E desde o Android 9 o sistema bloqueia HTTP sem criptografia por padrão. Em produção, a API está atrás do HTTPS do nginx e nada precisa mudar. Para desenvolver contra a API rodando no seu computador, o arquivo res/xml/rede.xml libera HTTP só para o endereço especial que o emulador usa para alcançar a máquina hospedeira:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <!-- Só para desenvolvimento: 10.0.2.2 é o "localhost" do computador visto de dentro do emulador -->
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="false">10.0.2.2</domain>
    </domain-config>
</network-security-config>

Um detalhe que confunde todo iniciante: dentro do emulador, localhost é o próprio emulador. A API que roda no seu computador está em http://10.0.2.2:8080.

A regra de ouro, agora imposta pelo sistema

A thread principal do Android (main thread ou UI thread) desenha a tela e recebe os toques, exatamente como a EDT do Swing. A diferença é que o Android não confia na disciplina do programador:

  • Se a thread principal ficar cinco segundos sem responder a um toque, o sistema mostra o diálogo de ANR (Application Not Responding) e oferece ao usuário a opção de fechar o app.
  • Uma chamada de rede feita na thread principal nem chega a esperar: o Android lança NetworkOnMainThreadException imediatamente. A regra de "Extensão — Janelas em Java: AWT, Swing e JavaFX" virou exceção de sistema.
  • Tocar numa View fora da thread principal lança CalledFromWrongThreadException, a mesma verificação que o JavaFX faz só em parte.

Há ainda um desafio que o desktop não tinha. Girar o celular destrói a tela e a recria do zero. A Activity, que é a tela, é destruída a cada mudança de configuração: rotação, troca de idioma, modo escuro. Se o estado morasse nela, uma consulta em andamento seria perdida, e a lista recarregada a cada giro. É exatamente o problema que o ViewModel da Jetpack resolve: ele sobrevive à recriação da Activity e só é descartado quando a tela sai de vez.

O ViewModel do Android

package br.com.exemplo.trilhas;

import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import br.com.exemplo.painel.ApiDeTrilhas;
import br.com.exemplo.painel.ErroDaApi;
import br.com.exemplo.painel.ResumoDeTrilha;

import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/** Sobrevive à rotação da tela; morre quando a tela sai de vez (onCleared). */
public final class TrilhasViewModel extends ViewModel {

    /** O estado inteiro da tela num valor imutável: a tela é uma função dele. */
    public record Estado(List<ResumoDeTrilha> itens, int pagina, int totalDePaginas,
                         boolean ocupado, String mensagem) {
        static Estado inicial() { return new Estado(List.of(), 0, 0, false, ""); }
        boolean podeVoltar() { return !ocupado && pagina > 0; }
        boolean podeAvancar() { return !ocupado && pagina + 1 < totalDePaginas; }
    }

    private static final int TAMANHO = 20;

    private final ApiDeTrilhas api;
    private final ExecutorService trabalho = Executors.newFixedThreadPool(2);
    private final MutableLiveData<Estado> estado = new MutableLiveData<>(Estado.inicial());

    public TrilhasViewModel(ApiDeTrilhas api) {
        this.api = api;
        carregar(0);
    }

    public LiveData<Estado> estado() { return estado; }

    public void proxima() { if (atual().podeAvancar()) carregar(atual().pagina() + 1); }
    public void anterior() { if (atual().podeVoltar()) carregar(atual().pagina() - 1); }

    private void carregar(int pagina) {
        Estado antes = atual();
        estado.setValue(new Estado(antes.itens(), antes.pagina(), antes.totalDePaginas(), true, ""));
        trabalho.execute(() -> {
            try {
                var p = api.listar(null, pagina, TAMANHO);
                // postValue: seguro de qualquer thread; entrega o valor na thread principal
                estado.postValue(new Estado(p.itens(), p.pagina(), p.totalDePaginas(), false, ""));
            } catch (ErroDaApi e) {
                estado.postValue(new Estado(antes.itens(), antes.pagina(), antes.totalDePaginas(), false,
                        e.problema() != null ? e.problema().detail() : "A API respondeu com erro " + e.status() + "."));
            } catch (Exception e) {
                estado.postValue(new Estado(antes.itens(), antes.pagina(), antes.totalDePaginas(), false,
                        "Sem contato com a API. Verifique a conexão."));
            }
        });
    }

    private Estado atual() { return estado.getValue(); }

    @Override
    protected void onCleared() {
        trabalho.shutdownNow();              // a tela saiu de vez: interrompe o que estiver em andamento
    }

    /** O Android cria os ViewModels; a fábrica é como entregamos a dependência a ele. */
    public static ViewModelProvider.Factory fabrica(ApiDeTrilhas api) {
        return new ViewModelProvider.Factory() {
            @NonNull
            @Override
            @SuppressWarnings("unchecked")
            public <T extends ViewModel> T create(@NonNull Class<T> tipo) {
                return (T) new TrilhasViewModel(api);
            }
        };
    }
}

A estrutura difere da versão JavaFX num ponto que vale estudar: o estado inteiro é um único record imutável. Em vez de dez propriedades separadas, há um Estado, e cada mudança produz um novo. É o padrão de fluxo unidirecional que a documentação do Android recomenda hoje: eventos sobem da tela para o ViewModel, e estados descem do ViewModel para a tela. As regras derivadas (podeAvancar) viram métodos do record. Um estado imutável não pode ser visto "pela metade" por outra thread, e a tela sempre desenha uma fotografia coerente.

O LiveData é o mecanismo de observação: a tela o observa amarrada ao seu ciclo de vida. Quando a Activity é destruída na rotação, a observação é cancelada sozinha. Quando a nova Activity se registra, recebe imediatamente o último estado. O setValue só pode ser chamado na thread principal, e o postValue, de qualquer thread, entrega o valor na principal: é o Platform.runLater embutido.

A tela

O layout, res/layout/activity_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent" android:layout_height="match_parent"
    android:orientation="vertical">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/lista"
        android:layout_width="match_parent" android:layout_height="0dp"
        android:layout_weight="1" />

    <ProgressBar
        android:id="@+id/ocupado"
        style="?android:attr/progressBarStyleHorizontal"
        android:indeterminate="true"
        android:layout_width="match_parent" android:layout_height="wrap_content" />

    <LinearLayout
        android:layout_width="match_parent" android:layout_height="wrap_content"
        android:orientation="horizontal" android:padding="8dp">
        <Button android:id="@+id/anterior" android:text="←"
            android:layout_width="wrap_content" android:layout_height="wrap_content" />
        <TextView android:id="@+id/resumo" android:gravity="center"
            android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" />
        <Button android:id="@+id/proxima" android:text="→"
            android:layout_width="wrap_content" android:layout_height="wrap_content" />
    </LinearLayout>

    <TextView android:id="@+id/mensagem" android:padding="8dp"
        android:layout_width="match_parent" android:layout_height="wrap_content" />
</LinearLayout>

A Activity, que só observa e repassa cliques:

package br.com.exemplo.trilhas;

import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ProgressBar;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.lifecycle.ViewModelProvider;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import br.com.exemplo.painel.ClienteAndroidDaApi;
import br.com.exemplo.painel.ResumoDeTrilha;

import java.util.Locale;

public class MainActivity extends AppCompatActivity {

    private static final String API = "http://10.0.2.2:8080";   // em produção: https://trilhas.example.com

    @Override
    protected void onCreate(Bundle salvo) {
        super.onCreate(salvo);
        setContentView(R.layout.activity_main);

        // Na rotação, o ViewModelProvider devolve o MESMO ViewModel: nada é recarregado
        var vm = new ViewModelProvider(this, TrilhasViewModel.fabrica(new ClienteAndroidDaApi(API)))
                .get(TrilhasViewModel.class);

        RecyclerView lista = findViewById(R.id.lista);
        var adaptador = new AdaptadorDeTrilhas();
        lista.setLayoutManager(new LinearLayoutManager(this));
        lista.setAdapter(adaptador);

        ProgressBar ocupado = findViewById(R.id.ocupado);
        Button anterior = findViewById(R.id.anterior), proxima = findViewById(R.id.proxima);
        TextView resumo = findViewById(R.id.resumo), mensagem = findViewById(R.id.mensagem);
        anterior.setOnClickListener(v -> vm.anterior());
        proxima.setOnClickListener(v -> vm.proxima());

        // Um só observador: a tela inteira é redesenhada a partir do estado
        vm.estado().observe(this, e -> {
            adaptador.submitList(e.itens());
            ocupado.setVisibility(e.ocupado() ? View.VISIBLE : View.INVISIBLE);
            anterior.setEnabled(e.podeVoltar());
            proxima.setEnabled(e.podeAvancar());
            resumo.setText(e.totalDePaginas() == 0 ? "" : "Página " + (e.pagina() + 1) + " de " + e.totalDePaginas());
            mensagem.setText(e.mensagem());
        });
    }

    /** RecyclerView: recicla as linhas que saem da tela, como a JTable só pinta as visíveis. */
    static final class AdaptadorDeTrilhas extends ListAdapter<ResumoDeTrilha, AdaptadorDeTrilhas.Linha> {

        static final class Linha extends RecyclerView.ViewHolder {
            final TextView texto;
            Linha(TextView v) { super(v); texto = v; }
        }

        AdaptadorDeTrilhas() {
            super(new DiffUtil.ItemCallback<>() {
                @Override public boolean areItemsTheSame(@NonNull ResumoDeTrilha a, @NonNull ResumoDeTrilha b) {
                    return a.id() == b.id();
                }
                @Override public boolean areContentsTheSame(@NonNull ResumoDeTrilha a, @NonNull ResumoDeTrilha b) {
                    return a.equals(b);                 // records: equals compara todos os componentes
                }
            });
        }

        @NonNull @Override
        public Linha onCreateViewHolder(@NonNull ViewGroup pai, int tipo) {
            var v = (TextView) LayoutInflater.from(pai.getContext())
                    .inflate(android.R.layout.simple_list_item_1, pai, false);
            return new Linha(v);
        }

        @Override
        public void onBindViewHolder(@NonNull Linha linha, int posicao) {
            ResumoDeTrilha t = getItem(posicao);
            // Locale.of só existe a partir do Android 16 (API 36); forLanguageTag, desde a API 21
            linha.texto.setText(String.format(Locale.forLanguageTag("pt-BR"), "%s — %s, %.1f km",
                    t.nome(), t.regiao(), t.distanciaKm()));
        }
    }
}

O RecyclerView é o parente móvel da JTable e da TableView: só existem as linhas visíveis, recicladas ao rolar. O ListAdapter com DiffUtil compara a lista nova com a antiga e anima apenas o que mudou. Repare como o equals automático dos records de "Dados sem Cerimônia — Records e Enums de Verdade" serve exatamente para isso.

A mesma peça em Kotlin

Para ver concretamente "o que mudou com o Kotlin", este é o mesmo ViewModel, escrito como a documentação atual do Android recomenda:

class TrilhasViewModel(private val api: ApiDeTrilhas) : ViewModel() {

    data class Estado(
        val itens: List<ResumoDeTrilha> = emptyList(),
        val pagina: Int = 0,
        val totalDePaginas: Int = 0,
        val ocupado: Boolean = false,
        val mensagem: String = "",
    ) {
        val podeVoltar get() = !ocupado && pagina > 0
        val podeAvancar get() = !ocupado && pagina + 1 < totalDePaginas
    }

    private val _estado = MutableStateFlow(Estado())
    val estado: StateFlow<Estado> = _estado

    init { carregar(0) }

    fun proxima() { if (_estado.value.podeAvancar) carregar(_estado.value.pagina + 1) }
    fun anterior() { if (_estado.value.podeVoltar) carregar(_estado.value.pagina - 1) }

    private fun carregar(pagina: Int) {
        _estado.update { it.copy(ocupado = true, mensagem = "") }
        viewModelScope.launch {                        // cancelada sozinha quando o ViewModel morre
            // a rede fica FORA do update: ele pode repetir o bloco se outro update chegar antes
            try {
                val p = withContext(Dispatchers.IO) { api.listar(null, pagina, 20) }
                _estado.update {
                    it.copy(itens = p.itens(), pagina = p.pagina(), totalDePaginas = p.totalDePaginas(), ocupado = false)
                }
            } catch (e: ErroDaApi) {
                _estado.update { it.copy(ocupado = false, mensagem = e.problema()?.detail() ?: "Erro ${e.status()}") }
            } catch (e: IOException) {                 // Kotlin não obriga a tratar: sem isto, o app fecharia
                _estado.update { it.copy(ocupado = false, mensagem = "Sem contato com a API. Verifique a conexão.") }
            }
        }
    }
}

E a tela, em Jetpack Compose, que substitui o XML, o adaptador e o observador:

@Composable
fun TelaDeTrilhas(vm: TrilhasViewModel) {
    val e by vm.estado.collectAsStateWithLifecycle()
    Column {
        LazyColumn(Modifier.weight(1f)) {
            items(e.itens, key = { it.id() }) { t ->
                Text("${t.nome()} — ${t.regiao()}, ${t.distanciaKm()} km", Modifier.padding(12.dp))
            }
        }
        if (e.ocupado) LinearProgressIndicator(Modifier.fillMaxWidth())
        Row(verticalAlignment = Alignment.CenterVertically) {
            Button(onClick = vm::anterior, enabled = e.podeVoltar) { Text("←") }
            Text("Página ${e.pagina + 1} de ${e.totalDePaginas}", Modifier.weight(1f), textAlign = TextAlign.Center)
            Button(onClick = vm::proxima, enabled = e.podeAvancar) { Text("→") }
        }
        Text(e.mensagem, Modifier.padding(8.dp))
    }
}

O que mudou, ponto a ponto:

  • data class com copy. A cópia com um campo alterado (it.copy(ocupado = true)) substitui os construtores longos que repetem todos os campos. Os withers dos records Java estão em discussão no OpenJDK justamente por isso.
  • Corrotinas. O viewModelScope.launch e o withContext(Dispatchers.IO) substituem o executor manual e o postValue. O código parece sequencial, e o cancelamento acontece sozinho quando o ViewModel morre. É uma ideia muito próxima da concorrência estruturada de "Tarefas em Família — Structured Concurrency e Scoped Values", e é o que as virtual threads e o StructuredTaskScope oferecem ao Java no servidor, mas que ainda não chegou ao Android.
  • Nulidade no tipo. O e.problema()?.detail() ?: "..." substitui o != null ? : e é verificado pelo compilador.
  • Interface declarativa. O Compose descreve a tela como função do estado: não há XML, adaptador nem findViewById. Quando o estado muda, o Compose recalcula só o que depende do que mudou. É a ideia dos bindings do JavaFX, levada ao extremo.

Repare no que não mudou: o record ResumoDeTrilha e a exceção ErroDaApi, escritos em Java no módulo de contrato, são usados pelo Kotlin sem nenhuma adaptação (daí o it.id() com parênteses: é o acessor do record). Essa interoperabilidade completa é o que permitiu a migração gradual de milhões de apps, arquivo por arquivo. Um projeto Android pode ter Java e Kotlin lado a lado para sempre.

Um último cuidado, de segurança. Se o app precisar guardar o token entre execuções, não use SharedPreferences em texto puro. A antiga recomendação, EncryptedSharedPreferences, foi descontinuada pela Google em 2025, e o caminho atual é cifrar com uma chave guardada no Android Keystore, que o próprio hardware do aparelho protege.

Fechando a Trilha B

A Trilha B levou o Java para fora do servidor, em quatro aulas.

  • A história. Começou em "Extensão — Janelas em Java: AWT, Swing e JavaFX", do AWT e seus pares nativos ao Swing que desenha tudo sozinho, ao SWT e ao JavaFX. Dela ficou a regra de ouro que atravessou todas as aulas seguintes: uma única thread desenha a tela, e ela nunca espera. Você a mediu (1,5 s de interface congelada contra 51 ms com SwingWorker), viu o JavaFX detectar parte das violações e o Android transformá-la em exceção de sistema.
  • O modelo declarativo. Com "Extensão — JavaFX: Cenas, Controles e FXML" vieram a estrutura em FXML, a aparência em CSS e o comportamento em propriedades e bindings: a interface como fórmula dos dados, e não como sequência de comandos.
  • A arquitetura. "Extensão — Um App Desktop que Consome a API (MVVM)" deu a separação que tornou tudo testável: sete cenários de uma tela inteira testados em menos de 200 ms, sem janela.
  • A portabilidade. O Android mostrou o que essa separação vale: o contrato atravessou sem mudar uma linha, e só as peças de plataforma foram reescritas.

A lição que une as quatro aulas vale para qualquer interface que você venha a construir, seja com Swing, JavaFX, Android, Compose ou a web do htmx: estado e regras de um lado, apresentação do outro, e threads num lugar só. As bibliotecas passam; essa separação continua valendo.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um colega testou o ClienteAndroidDaApi no JDK 25 com uma versão que usava in.transferTo(buffer), e tudo funcionou. No celular de um usuário, com Android 11, o app fecha ao abrir a lista. Explique a diferença e como detectar isso antes do usuário.

Ver resposta

✓ Resposta: InputStream.transferTo existe no JDK desde a versão 9, mas a biblioteca padrão do Android só a incluiu no Android 13 (API 33). O código compila porque o compileSdk é 37, que conhece o método. No Android 11 o método não existe, e a chamada lança NoSuchMethodError em tempo de execução. Testar no JDK prova que a lógica está certa, não que as APIs existem no aparelho. Para detectar antes: o lint do Android (NewApi) aponta chamadas a APIs mais novas que o minSdk, e testar num emulador com a versão mínima suportada pega o erro na prática.

Exercício 2

Por que o TrilhasViewModel do Android não recarrega a lista quando o usuário gira o celular, mesmo com a Activity sendo destruída e recriada?

Ver resposta

✓ Resposta: O ViewModelProvider guarda os ViewModels num ViewModelStore associado ao "dono" da tela, e esse armazenamento sobrevive às mudanças de configuração. A Activity nova pede o ViewModel com a mesma chave (a classe) e recebe a mesma instância, com o estado e a consulta em andamento. O construtor, onde está o carregar(0), não roda de novo. O LiveData entrega à nova Activity o último estado assim que ela começa a observar. O ViewModel só é destruído (onCleared) quando a tela sai de vez, por exemplo quando o usuário volta com o botão Voltar.

Exercício 3

No ViewModel Android, o que aconteceria se o código usasse estado.setValue(...) dentro do trabalho.execute(...) em vez de postValue? E se usasse postValue duas vezes seguidas, rapidamente, a partir de threads de trabalho?

Ver resposta

✓ Resposta: setValue fora da thread principal lança IllegalStateException ("Cannot invoke setValue on a background thread"): o LiveData confere a thread, como o Android faz com as Views. Com dois postValue rápidos, o LiveData pode entregar só o último, porque os valores pendentes são substituídos antes de a thread principal processá-los. Para um estado de tela isso é o comportamento desejado (só o estado mais recente importa), mas é uma armadilha se alguém usar LiveData para eventos que não podem se perder, como "mostre esta mensagem uma vez". Para eventos, o padrão é incluí-los no estado e marcá-los como consumidos.

Exercício 4

Explique por que o record ResumoDeTrilha e a exceção ErroDaApi puderam ir para um módulo compartilhado entre o desktop e o Android, mas o CatalogoViewModel do desktop não. O que seria preciso para compartilhar também as regras de navegação?

Ver resposta

✓ Resposta: Os records, a exceção e a interface só usam o java.lang, o java.util e o java.time, que existem nas duas plataformas (o java.time desde o Android 8, que é o nosso minSdk). O CatalogoViewModel depende de javafx.beans e javafx.collections, que não existem no Android. Para compartilhar as regras, seria preciso extraí-las para classes sem dependência de mecanismo de observação, por exemplo um record Estado com os métodos podeAvancar() e podeVoltar() e funções puras de transição (Estado aposCarregar(Pagina p)). Cada plataforma embrulharia essas funções no seu mecanismo: propriedades JavaFX no desktop e LiveData ou StateFlow no Android.

Exercício 5

Compare o tratamento de erro da versão Java (catch (ErroDaApi e) com e.problema() != null ? ... : ...) com a versão Kotlin (e.problema()?.detail() ?: ...). Que classe de erro o Kotlin evita em tempo de compilação, e o que o Java moderno oferece de parecido?

Ver resposta

✓ Resposta: O Kotlin sabe, pelo tipo, se problema() pode ser nulo, e exige o tratamento (?. e ?:) antes de acessar detail(): um acesso direto não compila. Isso elimina o NullPointerException por esquecimento. Como problema() vem de uma classe Java, o Kotlin o trata como tipo de plataforma, e anotar o método com @Nullable do JSpecify faz o compilador Kotlin aplicar a mesma verificação. No Java, a mesma garantia vem das anotações do JSpecify (@Nullable e @NullMarked) somadas a um verificador como o NullAway, visto em "Contratos à Vista — Imutabilidade, Programação Defensiva e JSpecify", ou do uso explícito de Optional nos pontos de API.

Comentários

Mais em Java

Extensão — Bancos de Verdade nos Testes: Testcontainers
Extensão — Bancos de Verdade nos Testes: Testcontainers

Nesta aula os testes passam a rodar contra o PostgreSQL e o MongoDB de…

A Primeira Pedra — ServerSocket e a Leitura do HTTP/1.1
A Primeira Pedra — ServerSocket e a Leitura do HTTP/1.1

Nesta aula nasce o projeto do Farol: um leitor de requisições que trabalha…

Extensão — MongoDB: Documentos, Agregações e Quando Escolher Cada Modelo
Extensão — MongoDB: Documentos, Agregações e Quando Escolher Cada Modelo

Os relatos das Trilhas da Ilha no MongoDB, com ou sem Docker: documentos…