Ir al contenido

Plataformas

Android

Los 25 primitivos sobre android.view, Material 3 resuelto sin Gradle, y los seis controles que miden cero hasta que les das un tamaño.

Los veinticinco primitivos, sobre android.view.View de verdad, con Material 3 debajo y sin Gradle por ninguna parte.

Ventana de terminal
an android # examples/hello-angular en un dispositivo conectado
an android examples/controls # los controles del sistema
an dev --android # lo mismo, vigilando, con recarga en caliente

El SDK de Android y el NDK se encuentran a través de ANDROID_HOME, ANDROID_SDK_ROOT o su ubicación habitual — y ANDROID_NDK_HOME para un NDK que no esté bajo <sdk>/ndk. De ellos no hay nada commiteado: la versión y el nombre del host (darwin-x86_64, linux-x86_64) se leen del disco, y el linker, el archivador y el sysroot de bindgen le llegan a cargo como entorno. .cargo/config.toml llevaba todo eso escrito hasta hace poco, lo que significaba que una compilación de Android funcionaba en la máquina de quien escribió el fichero y en ninguna otra.

Material 3 se resuelve una vez:

Ventana de terminal
python3 scripts/fetch-android-deps.py
python3 scripts/prepare-android-deps.py

El reparto del trabajo es deliberadamente distinto al de iOS. Allí Rust habla con UIKit directamente, porque el puente con Objective-C es barato y tipado. Aquí cada llamada cruza JNI, así que la superficie se mantiene lo más pequeña posible: Java expone un puñado de métodos en una sola clase host y Rust llama a esos. El crate de Rust son unas mil líneas; solo el host de Java son tres mil quinientas.

Todo el renderer son doce firmas de método —crear, destruir, insertar, quitar, poner prop, poner texto, poner listener, poner layout, poner tamaño de contenido, poner raíz, flush, limpiar— y cada prop viaja como cadena, los colores incluidos. El número de tipos distintos no justifica una firma JNI por tipo.

Rust guarda el JavaVM y nunca un JNIEnv, reenganchándose en cada llamada: todo corre en el hilo de UI, así que sale barato. Una excepción de Java se describe y se limpia antes de informar de ella, porque si no lo único que obtienes es «Java exception was thrown» sin saber cuál.

stderr se redirige a logcat con una tubería, un dup2 y un hilo lector. Sin eso, cada eprintln! y cada mensaje de panic se esfuma, y un fallo parece una pantalla en blanco.

La misma pila de 8 MB por el mismo motivo, el mismo presupuesto de 12 ms por fotograma, la misma regla de no encolar un tick encima de otro que sigue en marcha, la misma semántica de recarga en caliente. El reloj es un Choreographer.FrameCallback reprogramado en cada fotograma, que es la contraparte del CADisplayLink.

El modelo de montaje, y el contenedor que no mide nada

Sección titulada «El modelo de montaje, y el contenedor que no mide nada»

Una jerarquía de vistas real: una vista por nodo, addView para la estructura, y frames absolutos convertidos de puntos a píxeles.

El contenedor es un ViewGroup propio que no calcula nada. Dejar medir a Android pondría dos motores de layout en la misma pelea que empezaría Auto Layout en iOS. Sus layout params extienden MarginLayoutParams y no los simples, porque un ScrollView es internamente un FrameLayout y mide a sus hijos con measureChildWithMargins — con los params simples la app reventaba en el primer scroll. onMeasure mide cada hijo exactamente en su frame e informa de la unión, porque un ScrollView mide a su hijo con una altura sin especificar y devolver el mínimo sugerido colapsaría el contenido a nada.

Los hijos se recortan por defecto: están posicionados en absoluto y pueden caer muy fuera del padre.

Material, y lo que de verdad está dibujado a mano

Sección titulada «Material, y lo que de verdad está dibujado a mano»

La mayoría de los controles son los de verdad — MaterialSwitch, el Slider de Material, CircularProgressIndicator, LinearProgressIndicator, MaterialButton, SearchView, Spinner, Toolbar, WebView, VideoView, EditText, y diálogos reales para an-alert y an-modal.

Tres que parecen ensamblados no lo son:

Primitivo Qué es en realidad
an-tab-bar Un BottomNavigationView. La píldora tras el icono seleccionado, la animación del cambio, el comportamiento con TalkBack y la altura por versión vienen todos de Material. Solo la reconstrucción del menú y la lista de estados de dos colores están escritas aquí. La propia plataforma de Android no tiene barra inferior — android.widget se quedó en las pestañas de 2011.
an-segmented-control Un MaterialButtonToggleGroup, el botón segmentado de Material 3 tal cual viene. Las formas de los extremos, el contenedor seleccionado, la marca de verificación y la respuesta a la pulsación son de la librería.
an-stepper Compuesto, no dibujado: dos botones de icono de Material y un text view de Material. Material 3 no tiene stepper — no es que falte en la librería, es que está ausente del sistema de diseño — así que se ensambla con piezas que son Material en lugar de dibujar una imitación. Muestra el valor, al contrario que el UIStepper de iOS, porque en Android dos botones sueltos no dicen qué cambian.

Dos cosas sí están dibujadas a mano, y las dos lo dicen:

  • an-map-view son teselas de OpenStreetMap sobre un Canvas. Android no trae ningún mapa en la plataforma; el de Google vive en los Play Services detrás de una clave de API y una dependencia de Gradle. Así que: teselas de 256 píxeles de tile.openstreetmap.org, un descargador de cuatro hilos, una caché LRU dimensionada a un cuarto de la memoria de la app, la proyección de Mercator escrita a mano, y el arrastre manejado en onTouchEvent. Un bitmap centinela de un píxel marca una tesela que ya está en vuelo, para que arrastrar no la vuelva a pedir. Es una vista nativa de verdad —no un navegador escondido— pero tampoco es el mapa del sistema: sin rutas, sin búsqueda, sin punto azul.
  • Tirar para refrescar. El scroll view detecta el arrastre hacia abajo estando arriba y dibuja él mismo el arco del spinner, porque SwipeRefreshLayout es una dependencia aparte de AndroidX. El umbral son 72 dp.

Los iconos vienen de una fuente empaquetada, no de drawables del sistema: Material Symbols como TTF más su mapa de codepoints, buscados por nombre. android.R.drawable lleva congelado desde 2011 por compatibilidad y no es el conjunto de Material 3. Donde el sistema quiere un Drawable en lugar de una vista, el glifo se renderiza sobre un bitmap de 24 dp.

En un teléfono no hay nada sin soportar. La maquinaria que rechaza un primitivo existe y solo se consulta en un reloj — mira Wear OS.

onBackPressed pregunta al host, que envía back al último nodo que se suscribió —lo alto de la pila— y devuelve true. Si no hay nadie escuchando, cae al sistema y la app se cierra.

El host solo informa; deshacer la navegación es cosa del router. Ese es el mismo contrato que el gesto de borde de iOS.

El camino de los insets lee los insets de la ventana raíz, toma juntos las barras del sistema y el recorte de pantalla, divide por la densidad, compara contra los cuatro últimos valores, y solo despacha cuando hay un cambio real.

Cuatro números no caben en un evento posicional, así que viajan como JSON y Rust los parsea a mano, sin parser de JSON, precisamente para que el evento que llega a la plantilla sea idéntico al que envía iOS. La misma API se usa una segunda vez para sumar la altura de la franja de gestos a la tab bar medida.

Dos scripts de Python hacen de resolvedor de dependencias.

El primero descarga Material y todo lo que arrastra, resolviendo los POM a mano contra los repositorios de Google y de Maven Central. Sigue dependencyManagement incluidas las importaciones de BOM —sin eso, androidx media acaba sin versión y no se descarga nunca— y resuelve los rangos de versión de Maven a su cota inferior. Antes de eso, cualquier cosa con corchetes en la versión se descartaba, lo que dejaba fuera media androidx en silencio hasta que la app arrancaba y no encontraba una clase. Los conflictos se resuelven al primero visto, el más cercano a la raíz, como hace Gradle. Un puñado de artefactos se excluyen a propósito: desde Kotlin 1.8 los jars partidos de la biblioteca estándar están dentro del principal, y entregar los dos conjuntos hace que el dexer se niegue.

El segundo desempaqueta cada .aar, recoge los jars, compila los recursos de cada librería una vez, y escribe tres listas —un classpath, un conjunto de recursos y una lista de paquetes— reconstruyendo solo lo que falta, porque los recursos de cincuenta librerías tardan y no cambian nunca.

En el enlazado eso se convierte en --extra-packages (una clase R por librería, cuyos ids por tanto no pueden ser constantes), --non-final-ids y --auto-add-overlay, ya que los recursos de las librerías se solapan a propósito y de otro modo sería un error. Los recursos propios del shell van los últimos para poder sobreescribir.

Una entrada de texto es un EditText pelado, sin padding y sin fondo. Lo que configura el teclado es un entero, y las cuatro props son banderas suyas —tipo de teclado, capitalización, autocorrección, entrada segura— así que el entero entero se recompone desde el estado guardado cada vez que llega cualquiera de ellas. Aplicar una sola borraría las otras tres.

La entrada segura gana sobre la variante de teclado, porque un campo enmascarado con teclado de correo mostraría el texto. Las banderas de capitalización y de sin-sugerencias se saltan en teclados numéricos. Y fijar el tipo de entrada reinicia la tipografía a la monoespaciada de contraseña, así que hay que aplicar la tipografía otra vez justo después.

Un StaticLayout sobre un paint compartido, tomando como ancho la línea más ancha y como alto el del layout. Los dos vuelven empaquetados en un solo long como centésimas de punto: dos llamadas JNI por medición costarían el doble para nada.

Aquí el peso de la fuente es binario: 600 o más es negrita, cualquier otra cosa es normal. Eso es mucho más grueso que el mapa de nueve pasos que usan los hosts de Apple, y conviene saberlo cuando un diseño se apoya en 500 o en 300.

La caché vive en Rust y no en Java, con una clave que lleva todo aquello de lo que depende la respuesta —el texto, la tipografía, el espaciado, la altura de línea, el límite de ancho—, por el motivo que tienen los hosts de Apple más uno: aquí cada consulta cruza JNI, que es bastante más caro que un envío de mensaje de Objective-C. El ancho infinito viaja como -1, porque JNI no tiene tipo opción.

La altura de línea y el espaciado entre letras se miden y no solo se dibujan. Los dos se aplicaban al TextView y ninguno cruzaba JNI, así que el layout reservaba una caja para un texto sin ellos y el host dibujaba el texto con ellos: un espaciado positivo se salía de su caja o partía una palabra antes de tiempo, y una lineHeight mayor que la propia de la fuente no la reservaba nadie, con lo que las líneas se metían encima de lo que viniera detrás. Viajan como dos flotantes más en la misma llamada. Paint.setLetterSpacing quiere emes donde el core lleva puntos, así que el valor se divide por el tamaño del texto —la misma división que hace el lado que dibuja, y el motivo por el que hay que volver a aplicar el espaciado cada vez que cambia el tamaño—. La altura de línea se convierte en setLineSpacing(lineHeight - fontHeight, 1f), que es todo lo que hace setLineHeight, con el mismo tope en cero que usa el host por debajo de la API 28, donde no hay altura de línea, solo lo que se le suma a la que ya trae la fuente. La ausencia de altura de línea viaja como -1, igual que el ancho infinito.

  • Sin inset de teclado por debajo de la API 30. Desde la API 30 el IME llega como cualquier otro inset, y llega en movimiento: WindowInsetsAnimation.Callback lo da una vez por fotograma, así que el formulario viaja con el teclado. Antes de eso no existe WindowInsets.Type.ime() ni el callback, así que no llega nada y un campo abajo se queda debajo del teclado. El truco de siempre —vigilar cómo encoge el marco visible de la ventana— solo informa de algo si se deja redimensionar la ventana, y este shell pide que no, precisamente para que el layout que calculó el core sea el que se dibuja.
  • Atrás es el callback obsoleto. No hay atrás predictivo.
  • Solo se compila arm64-v8a. Ni imagen x86-64 de emulador ni ABI de 32 bits.
  • flush corre dos veces por fotograma productivo —una desde el lado de montaje del core y otra desde la activity— así que al contenedor se le pide maquetar dos veces y los diálogos se reconcilian dos veces.

Avisos, y ninguno silencioso: la corona en un teléfono (una vez); un paso de slider que no divide el rango, lo que hace que Material reviente al dibujar, así que se informa y el slider se queda continuo; un estado de accesibilidad que no es un objeto; un checked que no es ni booleano ni 'mixed'; un rol desconocido, que existe porque llegar hasta él significa que la lista de roles de TypeScript y la del host se han separado; expanded en una vista sin (press), ya que en Android expandir es una acción y sin ella un lector anunciaría algo que no se puede hacer. Qué se aplica y qué se rechaza está en Accesibilidad en Android.

app.appearance en angular-native.json acepta system, light o dark, y system es el valor por defecto: teléfono en claro, app en claro.

"app": { "name": "MyApp", "bundleId": "com.example.myapp", "appearance": "system" }

an lo escribe en el manifiesto fundido como una entrada <meta-data> y la Activity lo lee antes de super.onCreate — después, AppCompatActivity ya ha leído el modo nocturno y se recrearía en el primer fotograma. Lo que lo sigue es Material: diálogos, selectores de fecha, los tiradores de selección de texto. No es lo que pinta la pantalla; eso es el fondo de la propia app, así que una app que siga al dispositivo tiene que pintar con él.

Esto antes se forzaba. El shell llamaba a setDefaultNightMode(MODE_NIGHT_YES) en un bloque estático, en todas las apps compiladas con él, y el motivo era real: con el sistema en claro salía una barra de navegación blanca debajo de una pantalla que la app había pintado oscura. Aquello curaba un síntoma que era de las barras quitándole la decisión a todo el mundo.

Las barras se resuelven ahora donde viven. La ventana ya dibuja de borde a borde, así que son transparentes y lo que se ve detrás es el fondo de la propia app; sus iconos se eligen por la luminancia de ese fondo — la relativa de sRGB, porque el ojo es unas siete veces más sensible al verde que al azul y un azul saturado que promedia «claro» se lee como oscuro. Por debajo de la API 30 no hay WindowInsetsController y los iconos se quedan los del tema, lo cual se dice una vez en lugar de saltárselo en silencio.

Ventana de terminal
an android --sign --release # un APK firmado para publicar
an android --aab --release # el bundle que acepta Play

El keystore de depuración que usa la compilación de esta página es el que genera Android Studio, con la contraseña escrita en el código; no lo acepta ninguna tienda. --sign usa un keystore que generas y guardas tú, --aab compila un Android App Bundle — que es lo único que acepta Google Play desde agosto de 2021.

Para esto tampoco hay Gradle. aapt2 link --proto-format produce el manifiesto y los recursos en protobuf que quiere un bundle, el módulo se ensambla a mano, bundletool lo convierte en el .aab y jarsigner lo firma, porque apksigner se niega. bundletool no forma parte del SDK de Android — scripts/fetch-android-deps.py lo trae.

Todo este camino se ejecuta de punta a punta en scripts/check-signing.sh, con un keystore que genera la propia comprobación: una clave de subida no necesita el permiso de nadie. Lo que esa comprobación no puede hacer es subir nada, y an tampoco. Qué conseguir de Google, y dónde poner el keystore, está en Firma y distribución.

cargo build --target aarch64-linux-android -p an-android
aapt2 compile / link · javac · d8 · zip · zipalign · apksigner
→ build/android/<AppName>.apk

javac corre con -source/-target 17 en lugar de --release, porque con --release ignora el boot classpath y la compilación tiene que ir contra android.jar. El dexer parte en varios ficheros dex en cuanto Material está dentro, y todos van al zip. De aapt2 solo sale el manifiesto, así que los dex, la librería compartida y los assets se meten en el zip después.

La firma es solo de depuración: el keystore de depuración estándar, creado con keytool si no está. Aquí no hay identidad de publicación.

El id de la app puede diferir del paquete del shell: el paquete del manifiesto se renombra en el enlazado mientras las clases se quedan donde están, y por eso al lanzar se cualifica la activity como <applicationId>/dev.angularnative.MainActivity.

${applicationId} en el manifiesto se sustituye, y es el único marcador que hay. Renombrar el paquete reescribe el paquete y cualifica los nombres de clase relativos; deja el valor de cada atributo exactamente como lo encontró, y hay un atributo que no sobrevive a eso. La autoridad de un <provider> es única en todo el dispositivo, así que el FileProvider del shell —por el que share entrega los ficheros— no puede llevar un nombre fijo: dos apps de angular-native lo reclamarían y la segunda en instalarse fallaría con INSTALL_FAILED_CONFLICTING_PROVIDER. Se escribe ${applicationId}.anfiles y sale como el de la propia app.

Las entradas <uses-permission> y <uses-feature> de un plugin se funden en una copia del manifiesto, nunca en el tuyo. Un permiso duplicado se salta —pedirlo dos veces es pedirlo una— y una característica que la app ya declara con un required distinto conserva la de la app, con un log.

Un dispositivo físico funciona. Los dispositivos se listan con adb y se clasifican preguntándole a cada uno sus características de build; se exige exactamente uno de la forma correcta, y --device en an wearos se rechaza si la forma no cuadra. Todas las llamadas a adb llevan -s: sin eso adb se niega a actuar en cuanto hay dos dispositivos conectados — y si el otro no está autorizado ni siquiera se niega, le manda el APK del reloj al teléfono.

La recarga en caliente llega a un teléfono real. La dirección que se cocía en la app era 10.0.2.2 —la máquina anfitriona vista desde dentro del emulador, y nada en absoluto desde cualquier otro sitio—, así que un teléfono por USB sondeaba un sitio que no estaba y no decía nada, porque no había fallado nada. an dev --android abre ahora el puerto en el dispositivo con adb reverse y cuece 127.0.0.1 a secas, la misma dirección que usan todos los demás targets. Un dispositivo que rechace el reverse recibe igualmente la app, y se le dice que guardar no va a cambiar nada en pantalla.

El cliente de desarrollo hace long-polling sobre HTTP en lugar de usar un WebSocket, porque la plataforma no trae cliente de WebSocket y arrastrar una librería HTTP entera para esto no compensa.