Integración con iOS
Al terminar: Instalar a mano la conexión entre Xcode y tu módulo compartido, reconocer el error de cada pieza que falta y conocer los otros cuatro métodos.
Hay un salto que casi ningún tutorial explica bien: cómo llega tu Kotlin a Xcode. Lo normal es leer «el plugin lo hace por ti» y pasar página. Y luego, cuando algo falla —y falla—, no tienes ni idea de dónde mirar.
En Ejecutar en iOS viste funcionar la conexión. Tu proyecto ya la trae montada; esta lección te enseña a leerla pieza a pieza: miras cada ajuste, lo apagas a propósito, ves el error que sale y lo restauras. Así reconocerás el fallo cuando falte uno. Al terminar sabrás cuáles son los cinco métodos oficiales de integración, por qué la Pokédex usa el que usa y qué hace cada ajuste de Xcode que lo sostiene.
El problema, en una frase
Xcode no sabe qué es Kotlin. No sabe qué es un .klib. Lo único que entiende es un framework: una carpeta con un binario, una cabecera (el fichero que dice qué contiene, en el formato de Objective-C, el lenguaje antiguo de Apple) y un módulo (aquí la palabra no significa lo mismo que un módulo de Gradle: es el nombre que Swift importa con import Shared).
Qué le pides al módulo compartido
Antes de tocar Xcode, el lado de Gradle ya declara qué framework produce. Está en shared/build.gradle.kts (en la vista Project de Android Studio, dentro de la carpeta shared) y lo dejaste así en El módulo compartido y la prueba de humo:
listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
// El nombre del framework: es lo que Swift escribe en `import Shared`.
baseName = "Shared"
// Estático: se copia dentro de la app al enlazar, no se firma aparte.
isStatic = true
}
}Con eso, Gradle sabe producir Shared.framework. Falta que Xcode lo pida y lo encuentre.
Cómo está montada la integración en Xcode
Es la integración directa: Xcode llama a Gradle cuando compila la app. Son cuatro ajustes en Xcode y una línea en Swift, cada uno con su error si falta. Los ajustes viven en el target de tu app. Tu proyecto ya los trae puestos; para verlos fallar (que es lo que enseña), desmonta uno, mira el error y vuelve a ponerlo. Antes de empezar, abre el proyecto como en Ejecutar en iOS: open iosApp/iosApp.xcodeproj, y en el Project navigator (el panel de la izquierda) selecciona el proyecto iosApp, luego el target de tu app.
1 · Una fase de script que llame a Gradle
Una fase de compilación es un paso de la lista que Xcode sigue para construir la app; ya viste qué es en Ejecutar en iOS. Una fase de script es un paso donde tú pones el comando. Aquí el comando es «pídele a Gradle el framework».
Compruébala, sin cambiar nada:
- Pestaña Build Phases del target.
- Busca la fase de tipo Run Script: es la que ejecuta Gradle (su nombre lo eligió el asistente).
- Despliégala. Dentro, el script es el de Ejecutar en iOS: termina en
./gradlew :shared:embedAndSignAppleFrameworkForXcode. No hace falta que lo repases línea a línea; lo que importa aquí es que existe y qué pasa si algo de su alrededor falla.
Qué verás al compilar: en el registro (Report navigator) la fase se ejecuta y Gradle encadena compileKotlinIosSimulatorArm64, linkDebugFrameworkIosSimulatorArm64, assembleDebugAppleFrameworkForXcodeIosSimulatorArm64 y embedAndSignAppleFrameworkForXcode (esta última sale SKIPPED: con un framework estático no hay nada que incrustar ni firmar).
Error típico: Xcode no encuentra Java. Gradle necesita un JDK y Xcode no hereda la configuración de Android Studio. Si sale ERROR: JAVA_HOME is set to an invalid directory o Unable to locate a Java Runtime, el arreglo está explicado en Ejecutar en iOS (apartado «Si Xcode no encuentra Java»).
2 · La fase va antes de «Compile Sources»
Las fases se ejecutan en el orden en que aparecen en la lista. Una fase nueva se añade al final, y ahí no sirve: Swift intentaría compilar import Shared contra un framework que todavía no existe.
En tu proyecto la fase de script ya está antes de Compile Sources (es la primera de la lista). Para ver qué pasa si no lo está, primero borra el framework que dejó la compilación anterior, porque si sigue ahí Swift lo encuentra y el experimento no falla:
# Desde la carpeta del proyecto. `rm` borra; `-rf` lo hace con carpetas enteras y
# sin preguntar. Copia la ruta tal cual: es la carpeta del framework ya compilado.
rm -rf shared/build/xcode-frameworksLuego arrastra la fase hasta debajo de todas las demás y compila (Cmd + B): la compilación falla en ContentView.swift, en el import Shared:
ld: warning: search path '…/shared/build/xcode-frameworks/Debug/iphonesimulator26.2' not found
ContentView.swift:2:8: error: Unable to find module dependency: 'Shared'
import Shared
^Lo desconcertante es que el fallo apunta a Swift, no a la fase. La pista es el warning de ld (el enlazador, el programa que une las piezas compiladas): la carpeta donde debería estar el framework todavía no existe cuando Xcode la busca. Restáurala: arrastra la fase otra vez arriba del todo y la compilación vuelve a Build Succeeded. Mientras la fase siga al final, la compilación seguirá fallando siempre (Swift falla antes de que la fase llegue a ejecutarse), así que este paso de restaurar no es opcional.
3 · Que Xcode la ejecute en cada compilación
Xcode intenta saltarse las fases de script cuyas entradas y salidas no han cambiado. Esta fase no declara ni unas ni otras (las entradas serían todo tu Kotlin, que Xcode no entiende), así que la forma de decir «ejecútala siempre» es desmarcar la casilla Based on dependency analysis (análisis de dependencias) dentro de la propia fase. En tu proyecto ya viene desmarcada.
Este experimento es opcional: si quieres verlo, márcala, compila y mira el registro: no se rompe nada, pero cada compilación trae un aviso amarillo:
warning: Run script build phase '<nombre de tu fase>' will be run
during every build because it does not specify any outputs. To address
this issue, either add output dependencies to the script phase, or
configure it to run in every build by unchecking "Based on dependency
analysis" in the script phase.Si lo probaste, restáurala (desmárcala otra vez) y el aviso se convierte en una nota informativa:
note: Run script build phase '<nombre de tu fase>' will be run
during every build because the option to run the script phase
"Based on dependency analysis" is unchecked.Esa nota es lo correcto: no la arregles. Es la que ves también en Ejecutar en iOS. Con la casilla desmarcada, Xcode escribe alwaysOutOfDate = 1 en el project.pbxproj, dentro de esa fase.
Apple avisa de que ejecutar siempre un script puede dar resultados incorrectos si el script no sabe cuándo no hace falta repetir el trabajo. Aquí sí lo sabe: quien decide es Gradle, y cuando nada ha cambiado en shared/ responde UP-TO-DATE en un par de segundos. Por eso no hay riesgo de compilar de más.
4 · Quitar el aislamiento de scripts
Xcode puede ejecutar los scripts de compilación en una caja aislada (sandbox) que les impide leer y escribir fuera de unas carpetas concretas. Llamar a ./gradlew en la raíz del proyecto y escribir en shared/build queda fuera de esa caja, así que hay que apagarla: en la pestaña Build Settings del target, busca «User Script Sandboxing» (en la sección Build Options; el cuadro de búsqueda de arriba te lo encuentra). En tu proyecto ya está en No.
Este experimento también es opcional: si quieres verlo fallar, ponlo en Yes y compila. La tarea de Kotlin se da cuenta y lo dice claramente, en lugar de dejar que falle por permisos:
> Task :shared:checkSandboxAndWriteProtection FAILED
error: ⛔ User Script Sandboxing Enabled in Xcode Project
> ⛔ User Script Sandboxing Enabled in Xcode Project
You have sandboxing for user scripts enabled.
In your Xcode project, navigate to "Build Setting",
and under "Build Options" set "User script sandboxing" (ENABLE_USER_SCRIPT_SANDBOXING) to "NO".
Then, run "./gradlew --stop" to stop the Gradle daemonSi lo probaste, restáuralo poniéndolo en No. Y ese ./gradlew --stop es parte del arreglo: el demonio de Gradle (el proceso que se queda arrancado entre compilaciones, explicado en Ejecutar en iOS) pudo arrancar dentro de la caja, y si no lo paras sigue ahí y el arreglo parece no funcionar.
5 · Importar el módulo en Swift
Con el framework construido antes de compilar, Swift lo ve como un módulo con el nombre del baseName. Es la línea que ya está en tu ContentView.swift:
import SharedCompila (Cmd + B). Qué verás: Build Succeeded y, al ejecutar, «Hola desde iOS N» (la N es la versión del simulador).
Lo que el proyecto trae de más: dos ajustes de enlace
Si tu proyecto los trae, en Build Settings hay otros dos ajustes que apuntan al framework: Framework Search Paths (dónde buscar frameworks, con la ruta $(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)) y Other Linker Flags (opciones para el enlazador, con -framework Shared). Parecen imprescindibles y no lo son: la app compila igual sin ellos.
La razón es que la tarea de Kotlin deja además un enlace simbólico (un atajo del sistema de ficheros que apunta a otra carpeta) al framework en la carpeta de productos de Xcode, que ya está en la ruta de búsqueda por defecto. Si quieres verlo, después de una compilación:
# `ls -l` lista con detalle y muestra adónde apunta cada atajo (`->`).
# La carpeta de productos de Xcode (DerivedData) lleva un código al final
# del nombre; el comodín `*` lo rellena.
ls -l ~/Library/Developer/Xcode/DerivedData/iosApp-*/Build/Products/Debug-iphonesimulator/Shared.framework -> …/shared/build/xcode-frameworks/Debug/iphonesimulator26.2/Shared.frameworkDéjalos tal cual están: hacen explícito dónde está el framework y, si un día cambias de método de integración (por ejemplo, al paquete SPM local de abajo), esa ruta explícita sigue valiendo. Solo recuerda que, si algún día falta uno, no es la causa de un Unable to find module dependency: esa es la fase mal colocada del paso 2.
Los cinco métodos oficiales
Lo que acabas de revisar es el primero de cinco caminos. Los otros cuatro llevan el mismo documento a la misma ventanilla por otra ruta. Antes de verlos, tres palabras que aparecen en ellos:
- Un XCFramework es un envoltorio que empaqueta varios frameworks (el del iPhone y el del simulador, por ejemplo) en un solo paquete; lo verás a fondo en la sección siguiente.
- CocoaPods es un gestor de dependencias de iOS anterior a Swift Package Manager: se instala aparte, lee un fichero
Podfiley muchos proyectos de iOS existentes todavía lo usan. - Un Podspec es la ficha de un paquete de CocoaPods: un fichero que dice cómo se llama, de qué versión es y qué código contiene.
| Método | Cuándo tiene sentido |
|---|---|
| Integración directa | Las dos apps se compilan juntas y no usas CocoaPods. Es lo que aplica el plugin del IDE por defecto |
| Paquete SPM local | La app de iOS ya usa Swift packages y las dos apps se compilan juntas |
| Podspec local de CocoaPods | Todo se compila junto, en CocoaPods, o el módulo KMP depende de algún Pod (un paquete de CocoaPods) |
| SPM remoto + XCFramework | Distribuir el código compartido como paquete versionado, a otro equipo o proyecto |
| CocoaPods + XCFramework | Lo mismo, pero por CocoaPods |
La línea que separa las dos mitades de esa tabla es quién compila el Kotlin. En los métodos locales, compilarlo es parte de compilar la app de iOS. En los remotos, alguien lo compiló antes, lo empaquetó y lo publicó; el proyecto de iOS solo descarga un binario. Esa línea no es técnica, es organizativa: se decide mirando cuántos proyectos hay y quién tiene Gradle instalado, no mirando el código.
Un detalle a favor del paquete SPM local
Dicho con palabras llanas: cambias Kotlin y solo recompilas Kotlin, sin pasar por la compilación de iOS. Es comodidad diaria, no arquitectura: con SPM local el script va como pre-action del esquema (una acción que Xcode ejecuta antes de compilar, cada vez que lanzas ese esquema) en vez de como fase de build.
Por qué la Pokédex usa la integración directa
Las dos apps se compilan juntas y el framework no se distribuye a nadie: SPM remoto y CocoaPods resolverían un problema que este proyecto no tiene. Y el mecanismo entero son las pocas líneas de la fase de script que acabas de revisar.
Cuando hay que distribuir: el XCFramework
Todo lo anterior asume que las dos apps se compilan juntas. En cuanto el framework tiene que consumirse desde fuera —otro proyecto, otro equipo, un cliente— cambian las reglas.
Un .framework sirve a un solo destino (una plataforma y una arquitectura). Pero quien lo consume necesita compilar para el iPhone y para el simulador. Ahí entra el XCFramework: un envoltorio que lleva dentro todas las variantes y deja que Xcode elija la que toca.
Ojo a ese «cuando declaras»: en la Pokédex esas tareas no existen. Si pides ./gradlew :shared:assembleXCFramework, Gradle responde:
* What went wrong:
Selection failed
Cannot locate tasks that match ':shared:assembleXCFramework' as task
'assembleXCFramework' not found in project ':shared'.Aparecen al añadir un XCFramework() al bloque de binarios, igual que las tareas de enlazado aparecieron al declarar los targets. Un proyecto que solo se consume a sí mismo no lo necesita.
Para distribuirlo por SPM remoto hacen falta tres pasos más: comprimir el XCFramework en un zip, calcular su suma de verificación con swift package compute-checksum, y declararlo en un Package.swift como .binaryTarget apuntando a la URL del zip.
Cómo diagnosticar cuando falla
El plugin de KMP registra un grupo de tareas propio para esto:
./gradlew :shared:tasks --allXcode tasks
-----------
checkXcodeProjectConfiguration - Checks for configuration mismatches between Xcode and Kotlin Gradle project
convertPbxprojToJson - Converts .pbxproj file to JSON for inspectioncheckXcodeProjectConfiguration compara datos de los dos lados (nombres y configuración del proyecto de Xcode frente a los de Gradle). Úsala, pero sin pedirle más de lo que da: termina en BUILD SUCCESSFUL aunque la fase esté colocada al final y el aislamiento activado, los dos errores de arriba: puedes provocarlos tú y comprobarlo. Esos los atrapa el propio build (checkSandboxAndWriteProtection) o el compilador de Swift. Un orden que ahorra tiempo, de fuera hacia dentro:
- ¿Gradle produce el framework? Corre la tarea a mano:
./gradlew :shared:linkDebugFrameworkIosSimulatorArm64. Si falla aquí, el problema es de Kotlin, no de Xcode. - ¿Corre el script? Mira el registro de compilación de Xcode (Report navigator) y busca la fase de script de Gradle. Si no aparece, la fase se borró, o estás compilando otro target u otro esquema que no la tiene.
- ¿Falla en
import SharedconUnable to find module dependency? La fase está después de «Compile Sources» (paso 2). Ojo: ese error sale cuando no hay un framework viejo que lo tape; con uno de una compilación anterior, la app compila con el Kotlin de antes (el síntoma que anuncia Gradle sin dolor: cambias Kotlin y la app no se entera). El arreglo es el mismo: sube la fase antes de «Compile Sources» y compila. Borrarshared/build/xcode-frameworkssin moverla no lo arregla: la compilación falla siempre con este error. - ¿Falla la propia fase con
ERROR: JAVA_HOME…oUnable to locate a Java Runtime? El JDK: Ejecutar en iOS. - ¿Falla la tarea con «User Script Sandboxing Enabled»? Paso 4, y
./gradlew --stop. - ¿Compila pero un tipo no aparece en Swift? Ya no es integración: es exportación. Eso empieza en Los tipos que cruzan a Swift.
Punto de control
Esta lección no cambia nada del proyecto: lo que se comprueba es lo que ya estaba montado.
- En Build Phases del target de tu app, la fase de script de Gradle está antes de Compile Sources, y su casilla Based on dependency analysis está desmarcada.
- En Build Settings, «User Script Sandboxing» está en No.
- Si borras
shared/build/xcode-frameworks, arrastras la fase al final y compilas, saleUnable to find module dependency: 'Shared'; al devolverla arriba, la compilación vuelve aBuild Succeeded. ./gradlew :shared:checkXcodeProjectConfigurationtermina enBUILD SUCCESSFUL.grep -n "alwaysOutOfDate" iosApp/iosApp.xcodeproj/project.pbxprojdevuelve una línea con valor1../gradlew :shared:assembleXCFrameworkfalla diciendo que no encuentra la tarea, y ahora sabes por qué.
Comprueba lo que entendiste
8 preguntas · necesitas 6 para completar la lección
1.¿Qué es lo único que Xcode entiende de todo lo que produce Kotlin/Native?
2.Según la documentación oficial, ¿qué ventaja concreta tiene un paquete SPM local frente a la integración directa?
3.¿Cuándo necesitas un XCFramework en lugar de un framework normal?
4.En Xcode, la fase de script que compila el framework de Kotlin debe colocarse…
5.¿Por qué la Pokédex usa integración directa en vez de CocoaPods o SPM?
6.La compilación falla en
ContentView.swiftconUnable to find module dependency: 'Shared', y el enlazador avisa de que no encuentra la carpetaxcode-frameworks/…. ¿Cuál es la causa más probable?7.
./gradlew :shared:checkXcodeProjectConfigurationtermina enBUILD SUCCESSFUL. ¿Qué puedes concluir?8.Un equipo tiene la app de Android y el módulo compartido en un proyecto, y la app de iOS en otro, mantenido por gente que no quiere instalar Gradle. ¿Qué método de integración eligen?