Ejecutar en iOS
Al terminar: Compilar el framework de Kotlin y ver la app en el simulador desde Xcode.
Android arrancó a la primera. iOS no, y eso es lo interesante: entre tu Kotlin y una pantalla de simulador hay una fase de script que llama a Gradle desde Xcode, un compilador de Kotlin/Native que ya descargaste en El módulo compartido y la prueba de humo y un fallo del proyecto generado que hay que arreglar.
Eso ya lo sabes y no se repite. Esta lección cuenta lo propio de un proyecto KMP: qué ficheros de iosApp importan, por qué la primera compilación se cae, cómo llama Xcode a Gradle y qué errores salen cuando algo de esa cadena no está en su sitio. Al terminar, la misma frase que viste en Android estará en el simulador.
Una advertencia sobre los nombres: el proyecto de iOS lo generó el asistente, que puso los nombres del target (lo que Xcode compila) y del esquema (el menú junto a Run). En este curso se llaman Pokedex, y así aparecen en los textos y los errores de abajo; si ves otro nombre, el paso 6 enseña a verlo con una orden (xcodebuild -list).
Paso 1 · Abre el proyecto, no la carpeta
Lo que se abre es el fichero iosApp/iosApp.xcodeproj, no la carpeta iosApp/. Desde la Terminal, en la carpeta del proyecto (cd ~/Pokedex-KMP):
# `open` es el doble clic de la Terminal: abre el .xcodeproj con Xcode
open iosApp/iosApp.xcodeprojHay dos carpetas llamadas iosApp: la de fuera es el directorio del proyecto y la de dentro es el grupo con el código. Abrir la de fuera desde Xcode te deja un editor de textos con carpetas, sin targets ni botón de Run útil.
Cuando Xcode abre, el panel de la izquierda es el Project navigator (el árbol de ficheros). Arriba del todo está el proyecto iosApp; dentro, la carpeta iosApp con iOSApp.swift, ContentView.swift e Info.plist.
Paso 2 · Config.xcconfig, lo único que cambia de una máquina a otra
Un .xcconfig es un fichero de texto con ajustes de compilación: aquí, tres valores de la app (el único que cambia de una persona a otra es TEAM_ID) que viven en un fichero aparte, fuera del project.pbxproj.
Está en el Project navigator, en iosApp → Configuration → Config.xcconfig, y no necesitas tocarlo para el simulador: solo lo editas si vas a instalar en un iPhone. El tuyo puede traer alguna clave más o con otro nombre (por ejemplo PRODUCT_BUNDLE_IDENTIFIER en lugar de BUNDLE_ID); lo que importa es que haya un equipo (TEAM_ID) y un identificador de app. Este es el de ejemplo:
// Lo unico que cambia de un desarrollador a otro. TEAM_ID puede quedar vacio
// para el simulador; hace falta para instalar en un iPhone de verdad.
TEAM_ID=
BUNDLE_ID=com.sebastiannarvaez.pokedex
APP_NAME=PokédexLos ajustes del target leen esos valores con la forma $(TEAM_ID), $(BUNDLE_ID): es el nombre de la variable entre paréntesis. APP_NAME lo consume el Info.plist, que lo pone en CFBundleDisplayName: el nombre que se lee debajo del icono.
Qué pasa si TEAM_ID se queda vacío
En el simulador, nada. El simulador no verifica firmas. En un iPhone de verdad, Xcode se planta antes de compilar:
Signing for "Pokedex" requires a development team. Select a development team in the Signing & Capabilities editor.Tu Team ID es una cadena de diez caracteres. Lo ves en el ajuste DEVELOPMENT_TEAM (target → Build Settings, una vez que eliges tu equipo en Signing & Capabilities) o en developer.apple.com, en Membership details. Cuando lo pongas, el target hace el resto: tiene la firma en modo automático (CODE_SIGN_STYLE = Automatic: Xcode crea y renueva los certificados por ti).
Paso 3 · La trampa: dos comandos escriben el mismo Info.plist
Para entender el error de esta sección hace falta una palabra de Xcode. Una fase de compilación (build phase) es un paso de la lista que Xcode sigue para construir tu app, en orden: compilar el código, enlazar, copiar recursos, ejecutar un script… (enlazar es el último paso de compilar: juntar las piezas ya compiladas en un solo producto, aquí el framework, un paquete de código compilado que Xcode sabe usar). Cada target tiene su lista, y la ves en la pestaña Build Phases del target. Copy Bundle Resources es la fase que mete dentro de la app los ficheros que no son código (imágenes, textos).
Léelo despacio, porque el propio error te dice las dos mitades del problema. Hay un copy command, que copia Info.plist como si fuera un recurso más, y un process command, que lo procesa como lo que es: el Info.plist del target. Dos comandos, un mismo fichero de salida. El sistema de compilación se niega.
La causa: las carpetas sincronizadas
Nadie metió Info.plist en la fase de recursos. Lo metió Xcode, por una función que en general es buena.
Lo nuevo aquí: Info.plist está dentro de la carpeta, así que entra como recurso; y a la vez el target lo declara como su propio Info.plist (el ajuste Info.plist File de Build Settings). De ahí los dos comandos: uno porque es un recurso de la carpeta, otro porque es el Info.plist del target.
El arreglo: una excepción de pertenencia
Se hace desde la interfaz de Xcode:
- En el navegador de la izquierda (Project navigator), abre el grupo iosApp → iosApp y selecciona Info.plist.
- Abre el inspector de ficheros: View → Inspectors → File (
Cmd + Opt + 1), el panel de la derecha. - En la sección Target Membership (a qué targets pertenece el fichero), quita la marca del target de tu app.
- Vuelve a compilar (
Cmd + B): el error desaparece.
Xcode anota la excepción por ti en project.pbxproj; no hay nada más que hacer a mano.
Paso 4 · Cómo llega el Kotlin a Xcode
Con el Info.plist resuelto, toca entender la pieza que hace que todo esto funcione. La pieza está en Build Phases (selecciona el proyecto iosApp, luego el target de tu app y la pestaña Build Phases): una fase de script que corre antes de compilar las fuentes, y que ya venía en el proyecto: no la añades tú. Búscala por su tipo (Run Script); el nombre lo eligió el asistente. Este es su script, con cada línea explicada (el texto del echo puede variar en el tuyo):
# Xcode ejecuta esto con /bin/sh cada vez que compilas.
# Si el IDE (Android Studio) ya construyó el framework antes de lanzar Xcode,
# avisa con esta variable y no hay que repetir el trabajo. En este curso lanzas
# desde Xcode, así que no entrarás por aquí.
if [ "YES" = "$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then
echo "El IDE ya construyo el framework; no se llama a Gradle."
exit 0
fi
# $SRCROOT es la carpeta iosApp; con `..` subimos a la raíz del proyecto,
# que es donde vive `gradlew`.
cd "$SRCROOT/.."
# La tarea que compila Kotlin, lo enlaza como framework y lo deja donde Xcode
# lo espera. No recibe parámetros: lee variables de entorno de Xcode.
./gradlew :shared:embedAndSignAppleFrameworkForXcodeEn el registro de compilación se ve la cadena de tareas de Gradle encadenarse:
> Task :shared:compileKotlinIosSimulatorArm64
> Task :shared:linkDebugFrameworkIosSimulatorArm64
> Task :shared:assembleDebugAppleFrameworkForXcodeIosSimulatorArm64
> Task :shared:embedAndSignAppleFrameworkForXcode SKIPPEDLas dos primeras salen UP-TO-DATE si ya compilaste antes. UP-TO-DATE es Gradle diciendo «esto ya estaba hecho y no ha cambiado, no lo repito». Que la última diga SKIPPED es lo normal: el framework es estático (se pega dentro de la app al compilar, en lugar de viajar como un fichero aparte), así que no hay nada que copiar ni firmar (lo explica Gradle sin dolor).
Dónde deja el framework
embedAndSignAppleFrameworkForXcode lee variables de entorno que le pasa Xcode (CONFIGURATION, el tipo de compilación: Debug o Release; SDK_NAME, el kit de Apple contra el que se compila, con su versión; ARCHS, la arquitectura del procesador) y decide con ellas qué compilar y dónde dejarlo. La ruta es shared/build/xcode-frameworks/<CONFIGURATION>/<SDK_NAME>. Cuando la compilación termine, míralo con tus ojos (en la Terminal, desde la carpeta del proyecto):
# `find` busca ficheros: aquí, una carpeta llamada Shared.framework dentro de
# shared/build/xcode-frameworks, bajando como mucho tres niveles
find shared/build/xcode-frameworks -maxdepth 3 -name Shared.frameworkshared/build/xcode-frameworks/Debug/iphonesimulator26.2/Shared.frameworkEsa es la carpeta (la versión del final depende de tu SDK). Ojo: SDK_NAME incluye la versión del SDK, no solo la plataforma. Si actualizas Xcode, la carpeta cambia de nombre sola y el framework viejo se queda ahí ocupando sitio, sin que nada se rompa. (Y si cambias Kotlin y iOS parece no enterarse, el arreglo está en Integración con iOS.)
Esa carpeta depende de variables que solo pone Xcode. Si lanzas la tarea tú, desde la Terminal, Gradle se niega y te dice qué le falta:
# Fuera de Xcode no existen SDK_NAME, CONFIGURATION ni ARCHS
./gradlew :shared:embedAndSignAppleFrameworkForXcode> Task :shared:embedAndSignAppleFrameworkForXcode FAILED
error: Please run the embedAndSignAppleFrameworkForXcode task from Xcode ('SDK_NAME', 'CONFIGURATION', 'TARGET_BUILD_DIR', 'ARCHS' and 'FRAMEWORKS_FOLDER_PATH' not provided)Por eso la llama la fase de script, y no tú. Cuando quieras el framework sin Xcode, la tarea es linkDebugFrameworkIosSimulatorArm64, y lo deja en otro sitio: shared/build/bin/iosSimulatorArm64/debugFramework/Shared.framework. Dicho de otra manera: si algún día el framework no aparece donde Xcode lo busca, no busques el fallo en Gradle (el framework está compilado: si lo lanzas a mano con la tarea de arriba, lo tienes en bin/) sino en qué CONFIGURATION y qué SDK_NAME creía Xcode que estaba usando.
Quién ve la ruta de xcode-frameworks después lo cuenta Integración con iOS: el plugin también deja un enlace al framework en la carpeta de productos de Xcode.
Un aviso que verás en cada compilación
En el registro de compilación (Report navigator, el icono del último panel de la izquierda) aparece un note: sobre la casilla «Based on dependency analysis» de esta fase. Es correcto y es buscado: no lo arregles. Por qué está así y qué pasa si la marcas lo cuenta Integración con iOS.
La fase corre en cada compilación, pero cuando nada ha cambiado en shared/ Gradle responde UP-TO-DATE en un par de segundos. Lo que sí cuesta es arrancar el demonio de Gradle: un proceso que se queda vivo entre compilaciones para no empezar de cero cada vez. Por eso duele la primera compilación del día y casi nada el resto.
Si Xcode no encuentra Java
Gradle es un programa de Java, y quien lo lanza ahora es Xcode, no Android Studio ni tu Terminal. Xcode no hereda la configuración del IDE: Gradle busca un JDK en la variable JAVA_HOME y, si no está, en el comando java del PATH (lo viste en JDK y verificación del entorno).
Con el JDK instalado como paquete del sistema, la compilación funciona sin hacer nada. Los dos errores que salen cuando no es así, en el registro de compilación de Xcode (Report navigator → la compilación → la fase de script de Gradle):
ERROR: JAVA_HOME is set to an invalid directory: /no/existe
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation.
Command PhaseScriptExecution failed with a nonzero exit codeEs el caso de una variable JAVA_HOME que apunta a una carpeta que ya no existe (por ejemplo, un JDK que desinstalaste). Y este otro, el del sistema cuando no tiene ningún JDK registrado:
The operation couldn’t be completed. Unable to locate a Java Runtime.
Please visit http://www.java.com for information on installing Java.La solución más limpia es instalar el JDK como paquete (la receta de JDK y verificación del entorno). Si de verdad solo tienes el que trae Android Studio, se le dice a la fase de script cuál usar, con una línea nueva justo antes del cd. Edítala en Build Phases, en la fase de script de Gradle (el cuadro de texto del script):
# Fija el JDK para esta fase: el que trae Android Studio por dentro.
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
cd "$SRCROOT/.."Con esa línea, la compilación llega a BUILD SUCCEEDED aunque JAVA_HOME estuviera roto en tu entorno.
Si alguna vez se vuelve a descargar
Kotlin/Native se descarga entero antes de compilar nada: unos 300 MB que ocupan casi 2 GB descomprimidos. La descarga ya la hiciste en El módulo compartido y la prueba de humo, al pasar los tests de iOS desde la Terminal, así que tu primera compilación desde Xcode no la repite. Solo vuelve si borras ~/.konan o cambias de versión de Kotlin.
Dentro de Xcode esa descarga no da señales: la barra de progreso dice «Running script» y ahí se queda, y es muy fácil pensar que se ha colgado. Si pasa, lanza antes ./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 desde la Terminal, donde sí se ve el progreso: es el mismo trabajo.
Paso 5 · Pon el saludo en la pantalla
El asistente generó una pantalla de ejemplo en ContentView.swift. Vas a cambiarla por una que enseñe el saludo del módulo compartido, el mismo que viste en Android. En el Project navigator abre iosApp → iosApp → ContentView.swift, selecciona todo su contenido (Cmd + A) y sustitúyelo por este:
import SwiftUI
import Shared
struct ContentView: View {
// El saludo viene del modulo compartido: es la prueba de que iOS esta
// consumiendo el mismo Kotlin que Android.
private let saludo = Greeting().greet()
var body: some View {
Text(saludo)
.font(.title2)
.padding()
}
}
#Preview {
ContentView()
}Lo nuevo es import Shared: Shared es el framework de Kotlin, y su nombre es el baseName que dejaste en shared/build.gradle.kts. Greeting().greet() es la clase de commonMain llamada desde Swift, y Text(saludo).font(.title2).padding() pinta el resultado algo más grande de lo normal y con margen. Cómo se traduce cada tipo de Kotlin al otro lado de ese import Shared es el tema de Los tipos que cruzan a Swift, más adelante.
Paso 6 · Elegir simulador y pulsar Run
En la barra superior, a la derecha del nombre del esquema, elige un iPhone y pulsa Run (Cmd + R). El esquema se llama Pokedex en este curso; el menú lo muestra y, si dudas, xcodebuild -list (abajo) lo imprime.
Lo nuevo aquí es compilar el proyecto de Xcode entero con -project y -derivedDataPath (la carpeta donde deja lo compilado), e instalar y lanzar con simctl install y simctl launch. Los equivalentes de Terminal, para cuando quieras compilar sin abrir Xcode:
# Los esquemas que ve el terminal: aquí aparece el nombre exacto del tuyo
xcodebuild -list -project iosApp/iosApp.xcodeproj
# Qué simuladores hay instalados y disponibles
xcrun simctl list devices available
# Compilar para uno concreto, por UDID (el identificador de la lista anterior)
xcodebuild -project iosApp/iosApp.xcodeproj -scheme Pokedex \
-sdk iphonesimulator -destination 'id=<UDID>' -derivedDataPath build/dd build
# Instalar y lanzar (el identificador de la app es el BUNDLE_ID de Config.xcconfig)
xcrun simctl install <UDID> build/dd/Build/Products/Debug-iphonesimulator/Pokedex.app
xcrun simctl launch <UDID> com.sebastiannarvaez.pokedexUn esquema compartido es el que se guarda dentro del propio .xcodeproj (en su carpeta xcshareddata): así lo ven Xcode y el terminal por igual, y es el que xcodebuild -list enumera.
El resultado
En el simulador, la misma frase de Android con otro final:
Hola desde iOS N(N es el sistema del simulador que elegiste.)
La consecuencia práctica es la misma que en Android: el número que lees describe dónde corre la app, no con qué la compilaste.
Punto de control
- En el simulador se lee «Hola desde iOS N», con N el sistema de tu simulador.
- Si tu primera compilación falló por
Multiple commands produce, ahora no: seleccionando Info.plist y abriendo el inspector de ficheros, la casilla del target de tu app en Target Membership está desmarcada, y no queda eseerror:en el registro de compilación. - Si ves un
note:sobre «Based on dependency analysis», es lo correcto: no lo arregles. find shared/build/xcode-frameworks -maxdepth 3 -name Shared.frameworkimprime una ruta comoshared/build/xcode-frameworks/Debug/iphonesimulator26.2/Shared.framework, con la versión de tu SDK en el nombre de la carpeta.- Si instalas en un iPhone real,
TEAM_IDenConfig.xcconfiglleva tu identificador; en el simulador puede seguir vacío.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.Compilas para el simulador por primera vez y Xcode falla con «Multiple commands produce .../<tu app>.app/Info.plist». ¿Qué está pasando?
2.¿Cuál es el arreglo que conserva el
Info.plisten el proyecto y la sincronización de la carpeta?3.¿Qué hace
:shared:embedAndSignAppleFrameworkForXcodey de dónde saca para qué target compilar?4.Compilas desde Xcode y la fase de script falla con
ERROR: JAVA_HOME is set to an invalid directory. ¿Por qué Gradle no encuentra Java dentro de Xcode si en tu Terminal funciona?5.Dejas
TEAM_IDvacío enConfig.xcconfig. ¿Qué puedes hacer y qué no?