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: oHttpClientdo 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.
StringeString?são tipos diferentes, e o compilador impede oNullPointerExceptionmais 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 ? "" : "®iao=" + 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, ogetInputStream()doHttpURLConnectionlançaIOException. O corpo do Problem Details só é acessível pelogetErrorStream(), 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.transferToeByteArrayOutputStream.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 comNoSuchMethodErrorna primeira chamada à API. A versão final usa o laço clássico. O Android Studio aponta esses casos com o lintNewApi, e é por isso que ominSdkdo projeto importa tanto. URI.create(...).toURL()em vez denew 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
NetworkOnMainThreadExceptionimediatamente. 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 classcomcopy. 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.launche owithContext(Dispatchers.IO)substituem o executor manual e opostValue. 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 oStructuredTaskScopeoferecem 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
- Android Developers — Java versions in Android builds: a tabela que relaciona versões do Android às APIs do Java disponíveis.
- Android Developers — Guide to app architecture: camadas, estado da interface e fluxo unidirecional de dados.
- Android Developers — ViewModel overview: o ciclo de vida que sobrevive à rotação e o
onCleared. - Android Developers — Network security configuration: o bloqueio de HTTP sem TLS e como liberar domínios específicos.
- Google LLC v. Oracle America, Inc. (Suprema Corte dos EUA, 2021): a decisão sobre o uso justo das declarações da API Java.
- EncryptedSharedPreferences is Dead (Include Security, 2026): por que a biblioteca foi descontinuada e as alternativas com o Android Keystore.
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.