El fallo que cruza a Swift
Al terminar: Encontrar el problema cuando el error viene de Kotlin pero explota en Swift.
En una app con dos mundos, el síntoma casi nunca está donde está la causa: el cierre inesperado lo ves en Xcode, y el fallo está en Kotlin. Esta lección te enseña a leer ese fallo. Vas a provocar a propósito el peor caso del puente, una excepción de Kotlin que mata la app de iOS, y a seguir el rastro que deja en la consola y en el depurador.
Al terminar sabrás leer una traza de Kotlin dentro de Xcode, habrás visto que el depurador (LLDB) puede equivocarse con los nombres de Kotlin, sabrás qué es un dSYM y por qué hay que guardar el de cada versión, y tendrás claro qué herramienta abrir según el problema. No cambia nada en el proyecto: lo que creas lo borras al final, y los tests siguen como estaban (127 / 89 / 92 en el núcleo, 13 en androidApp y 4 de interfaz en iOS).
Paso 1 · Leer la excepción de Kotlin que mata la app de iOS
Una traza (o pila de llamadas) es la lista de funciones que estaban en marcha cuando saltó el error: arriba, la que falló; debajo, la que la llamó; y así hasta el principio. Es el rastro que deja un error, y leerla es la habilidad de esta lección.
Qué se crea. Un fichero temporal en el módulo compartido, solo para iOS. Clic derecho sobre el nodo shared → New → File y escribe src/iosMain/kotlin/com/sebastiannarvaez/pokedex/Depuracion.kt (el método de El módulo compartido y la prueba de humo; plan B: touch en la Terminal con la misma ruta). Es una clase, no una función suelta, porque aquella lección te enseñó que el nombre de una función suelta lo decide el fichero.
package com.sebastiannarvaez.pokedex
// Una clase cuyo único método siempre lanza. `error(…)` lanza una
// IllegalStateException. Devuelve String para que Swift pueda llamarlo como
// cualquier otro método.
class Depuracion {
fun provocarFallo(): String = error("Fallo provocado desde Kotlin")
}Ahora llámala desde Swift. Abre en Xcode iosApp → iosApp → ContentView.swift y, en body, justo debajo de la línea .preferredColorScheme(ajustes.tema.esquema), añade una línea:
// Temporal: llama a Kotlin nada más aparecer la pantalla.
// `_ =` descarta el valor devuelto: Swift avisa si ignoras un
// resultado sin decir que lo ignoras a propósito.
.onAppear { _ = Depuracion().provocarFallo() }Ejecuta la app en el simulador (Cmd + R). En cuanto aparece la pantalla se para, y Xcode te lleva al depurador. (Si tienes claves de Supabase y la app abre pidiendo entrar, entra: ContentView solo se muestra con la sesión iniciada, y el fallo salta en cuanto aparece.) En la consola de Xcode (la zona de abajo, la misma donde viste el registro en La primera dependencia) hay esto:
Function doesn't have or inherit @Throws annotation and thus exception isn't propagated from Kotlin to Objective-C/Swift as NSError.
It is considered unexpected and unhandled instead. Program will be terminated.
Uncaught Kotlin exception: kotlin.IllegalStateException: Fallo provocado desde Kotlin
at 0 Pokedex.debug.dylib 0x102c9bfbf kfun:kotlin.Throwable#<init>(kotlin.String?){} + 99
at 1 Pokedex.debug.dylib 0x102c9ac7b kfun:kotlin.Exception#<init>(kotlin.String?){} + 95
at 2 Pokedex.debug.dylib 0x102c9ae3b kfun:kotlin.RuntimeException#<init>(kotlin.String?){} + 95
at 3 Pokedex.debug.dylib 0x102c9b263 kfun:kotlin.IllegalStateException#<init>(kotlin.String?){} + 95
at 4 Pokedex.debug.dylib 0x10297fa8f kfun:com.sebastiannarvaez.pokedex.Depuracion#provocarFallo(){}kotlin.String + 231
at 5 Pokedex.debug.dylib 0x1029a6243 objc2kotlin_kfun:com.sebastiannarvaez.pokedex.Depuracion#provocarFallo(){}kotlin.String + 147
at 6 Pokedex.debug.dylib 0x1028fbde3 $s7Pokedex11ContentViewV4bodyQrvgyycfU0_ + 47
…Las direcciones pueden diferir; la forma es la misma. Puede que Kotlin/Native pegue además, al final de la primera línea de la traza, una nota que dice unable to find file names and line numbers y remite a una incidencia de JetBrains: avisa de que no sabe dar el fichero y la línea de Kotlin. Si no la ves, no pasa nada; no afecta a lo que sigue. Pokedex.debug.dylib es un fichero que Xcode crea solo en Debug (la compilación de trabajar, no la de publicar): guarda el código de tu app (y el de Kotlin, que va dentro por ser estático) en una biblioteca aparte para recompilar más rápido. En Release no existe.
La consola dice, en este orden, qué pasó, por qué y dónde.
Qué pasó y por qué. Las dos primeras líneas lo explican: una excepción de Kotlin que cruza a Swift sin la anotación @Throws no se convierte en un error de Swift que se pueda capturar: se considera «inesperada» y termina el programa. La tercera línea es la excepción real, con su tipo y su mensaje.
Dónde. La traza se lee de arriba abajo, y las primeras líneas engañan: las cuatro de arriba son el constructor de la propia excepción (Throwable, Exception…), código del lenguaje, no tuyo. La primera línea tuya es la que empieza por tu paquete:
kfun:com.sebastiannarvaez.pokedex.Depuracion#provocarFallo(){}kotlin.String
└┬─┘└────────────────┬─────────────┘└┬┘└──────┬──────┘└┬┘└────────┬────────┘
│ │ │ │ │ └ tipo que devuelve
│ │ │ │ └ parámetros (aquí ninguno)
│ │ │ └ nombre del método
│ │ └ separador
│ └ paquete y clase
└ «Kotlin function»: así llama Kotlin/Native a sus funcionesLas llaves vacías {} son una parte del nombre completo que aquí no lleva nada (otras funciones ponen ahí información sobre sus tipos); léelas como un separador entre los parámetros y lo que devuelve.
La siguiente, objc2kotlin_kfun:…, es el puente (el trozo que traduce la llamada de Swift a Kotlin), y la que sigue ya es Swift: tu ContentView, el cierre del .onAppear que escribiste. (En trazas más largas puedes ver nombres con $invokeCOROUTINE$: es el cuerpo de una función suspend; no significa que el fallo sea de las corrutinas, solo que esa línea vive dentro de una.)
Paso 2 · Lo mismo, con el depurador
Cuando la app muere así bajo Xcode, el depurador se detiene por una señal SIGABRT (el aviso que manda un programa que aborta a propósito) y la consola de abajo te deja escribir tras el texto (lldb), que es el sitio donde se teclean las órdenes. LLDB es el depurador de Xcode. Haz clic ahí, escribe bt 14 (bt es backtrace, la pila de llamadas; el 14 limita cuántas líneas se imprimen) y verás la pila, con los marcos (frames) numerados:
(lldb) bt 14
frame #8: … Kotlin_ObjCExport_trapOnUndeclaredException + 36
frame #9: … kfun:com.sebastiannarvaez.pokedex.data.local.FavoriteDao_Impl#<init>(…) at FavoriteDao_Impl.kt:24:2
frame #10: … closure #2 in ContentView.body.getter at ContentView.swift:52:38Salen más líneas por encima, las del propio aborto; aquí van las que importan. Míralas con cuidado:
trapOnUndeclaredExceptiones el nombre del mecanismo: «atrapar una excepción no declarada». Es el marco donde Kotlin decide matar el programa.- El marco 10 es Swift, y LLDB lo sabe todo de él:
ContentView.swift:52:38es el sitio exacto desde donde se llamó. El número de línea puede ser otro en tu fichero. - El marco 9 es el de Kotlin, y aquí LLDB puede equivocarse. Lo normal es ver el puente,
objc2kotlin_kfun:com.sebastiannarvaez.pokedex.Depuracion#provocarFallo, que es lo que esperas. Pero en algunas compilaciones pone el nombre de una función de Room (comoFavoriteDao_Impl) con un fichero y una línea que no tienen nada que ver. Es la limitación que avisaba la nota de la consola: Kotlin/Native no deja a LLDB casar bien los nombres de fichero y de línea de Kotlin. Si te sale bien, mejor; el consejo de abajo sigue valiendo.
Limpia. Borra Depuracion.kt (clic derecho sobre el fichero → Delete) y quita de ContentView.swift el .onAppear temporal. Comprueba que no queda nada, ni de lo temporal ni de @Throws. grep -rn busca un texto en todos los ficheros de las carpetas indicadas y escribe cada línea que lo contiene (\| separa las dos cosas a buscar):
grep -rn "@Throws\|Depuracion" shared/src iosApp/iosAppNo debe devolver nada (si sale alguna línea de @Throws que dejaste en Las funciones que cruzan a Swift, bórrala también). Y ahora sabes por qué: esta app no usa @Throws a propósito. Nada de lo que Swift llama lanza excepciones: los errores viajan como estado, dentro de UiState, con el UiError que construiste en Errores que se pueden contar, que además es lo que la interfaz necesita para pintarlos. La anotación existe, pero la mejor defensa es que no haga falta.
Paso 3 · Los símbolos de un fallo en release
Un dSYM es el fichero que guarda los nombres de funciones y los números de línea de un binario, para poder traducir las direcciones de memoria de un informe de fallo a código legible. A eso se le llama simbolicar. Sin el dSYM de una versión publicada, un informe de fallo llega con direcciones (0x103ecffd3) y nada más. («Binario» es el fichero ejecutable ya compilado.)
En esta app el framework de Kotlin es estático (lo viste en Gradle sin dolor), así que no tiene dSYM propio: sus símbolos acaban en el dSYM de la app, y lo genera Xcode al compilar en Release. Compruébalo. Una variable de la Terminal es un nombre al que le das un valor para no repetirlo; luego se usa escribiendo $ delante ($APP vale lo que le diste). Define dos con tus nombres (el de la app y el de su esquema, los de Ejecutar en iOS; si seguiste el curso, los dos son Pokedex). Se pierden al cerrar la Terminal:
APP=Pokedex
ESQUEMA=PokedexY desde la carpeta de tu proyecto:
xcodebuild -project iosApp/iosApp.xcodeproj -scheme $ESQUEMA -configuration Release \
-sdk iphonesimulator -destination 'generic/platform=iOS Simulator' \
-derivedDataPath build/ddrel buildTarda unos minutos. Al terminar, junto a la app hay una carpeta de símbolos:
ls build/ddrel/Build/Products/Release-iphonesimulatorEntre otras cosas, debe aparecer una carpeta que se llama como tu app seguida de .app.dSYM (con el nombre del curso, Pokedex.app.dSYM, unos 18 MB) al lado de Pokedex.app (unos 15 MB: es lo que de verdad pesa la app una vez enlazado el framework). Dentro del dSYM están los nombres de Kotlin. La orden usa dos piezas: nm -a lista los símbolos (los nombres de funciones) de un fichero ejecutable; | grep … | head -3 enlaza esa salida con un filtro que se queda con las líneas que contienen kfun:com.sebastiannarvaez y de ellas muestra solo tres:
nm -a build/ddrel/Build/Products/Release-iphonesimulator/$APP.app.dSYM/Contents/Resources/DWARF/$APP \
| grep 'kfun:com.sebastiannarvaez' | head -3Por ejemplo, salen líneas como estas (las tuyas dependen de tu código):
… t _kfun:com.sebastiannarvaez.pokedex#currentPlatform(){}…
… t _kfun:com.sebastiannarvaez.pokedex.Greeting#greet(){}kotlin.String
… t _kfun:com.sebastiannarvaez.pokedex.IosPlatform.<get-name>#internalEn total hay más de mil. Cada dSYM lleva además un identificador que lo empareja con su compilación:
xcrun dwarfdump --uuid build/ddrel/Build/Products/Release-iphonesimulator/$APP.app.dSYMImprime un UUID (universally unique identifier, un código largo que no se repite entre compilaciones) con la arquitectura entre paréntesis. Ese es el que aparece en un informe de fallo: el dSYM de otra compilación, aunque sea del mismo código, no sirve. Por eso hay que guardar el dSYM de cada versión que publiques; de eso se ocupa Producción en iOS.
Cuando termines, puedes borrar lo que acabas de generar, que ocupa espacio: rm -rf build/ddrel.
Paso 4 · Qué herramienta abrir para cada problema
Medir no es solo el tamaño. Las herramientas para el resto de problemas ya las usaste en Marcador, y funcionan igual en la Pokédex: aquí solo se decide cuál abrir.
- El código falla, o hace algo raro, y quieres ver los valores. Un punto de ruptura (breakpoint): lo aprendiste en Ficheros y depurador (Android Studio) y en registrar lanza errores: throws tipado y Result (Xcode). Para la lógica (todo lo de
shared), lo cómodo es depurar desde Android Studio: es Kotlin de verdad, con sus nombres y sus variables. En Xcode se depura Swift. (Si necesitas parar dentro de Kotlin desde Xcode, existe un complemento de Touchlab llamado xcode-kotlin que lo permite.) - Un mensaje que explique qué pasó, en cualquier plataforma. El registro
Logde Kermit que instalaste en La primera dependencia: sale en Logcat y en la consola de Xcode. - La pantalla se congela o va a tirones en Android. El Profiler de Android Studio, que viste en El hilo principal y el ANR.
- La memoria de la app de Android sube y no baja. LeakCanary y el camino de la fuga, de Fugas de memoria. Es una librería que se añade al módulo de la app con una línea de
debugImplementation; en la Pokédex no está puesta, y la receta es la de esa lección. - La memoria de la app de iOS sube y no baja. El Memory Graph de Xcode, de Ver un ciclo de retención: deinit y Memory Graph, e Instruments, que presenta Delegados y tareas.
Punto de control
- Con
Depuracion().provocarFallo()la app de iOS se cierra y la consola diceUncaught Kotlin exception: kotlin.IllegalStateException, con una líneakfun:com.sebastiannarvaez.pokedex.Depuracion#provocarFallo…. - En LLDB,
bt 14muestraKotlin_ObjCExport_trapOnUndeclaredExceptiony un marco de Swift conContentView.swift; el marco de Kotlin intermedio puede llevar un nombre que no es el tuyo, o el correcto. Depuracion.ktestá borrado, el.onAppeartemporal también, y elgrepde la limpieza no devuelve nada.ls build/ddrel/Build/Products/Release-iphonesimulatormuestra$APP.app.dSYMynm -asobre él encuentra símbolos que empiezan porkfun:com.sebastiannarvaez.- Los tests no han cambiado: el proyecto quedó como antes. En el núcleo, 127 en la JVM, 89 en Android host y 92 en iOS;
./gradlew :androidApp:testDebugUnitTest, 13; y en Xcode, Cmd + U deja los cuatro tests de interfaz con marca verde en el Test navigator.
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.Una función de Kotlin sin
@Throwslanza una excepción y quien la llamó es Swift. ¿Qué ocurre?2.En la traza
kfun:com.sebastiannarvaez.pokedex.Depuracion#provocarFallo(){}kotlin.String, ¿qué es lo que va detrás de(){}(las llaves vacías son parte del nombre)?3.¿Por qué la Pokédex no tiene ninguna función con
@Throws?4.El framework de Kotlin es estático. ¿Dónde acaban sus símbolos de depuración en un build de release?
5.En
bt 14, a veces el marco de Kotlin muestra el nombre de otra función (FavoriteDao_Impl) y noprovocarFallo. ¿Qué haces?6.¿Qué es una traza (o pila de llamadas)?