Saltar al contenido
K
ProducciónPaso a paso40 min Avanzado

Producción en iOS

Al terminar: Archivar la app con su manifiesto de privacidad y su versión sacada de un único sitio.

El equivalente de la lección anterior, en iOS. Con una diferencia que conviene decir pronto: casi todo se puede hacer sin pagar. Crear el archivo de la app, comprobar que la compilación de release está sana y guardar los símbolos no necesitan el Apple Developer Program (la cuenta de pago de Apple, 99 USD al año). Solo el último paso, exportar el .ipa (el fichero que se sube a App Store Connect, la web de Apple donde se gestionan las apps y sus versiones), la necesita, y lo mismo vale para TestFlight, que es la vía de Apple para pasar compilaciones de prueba a otras personas.

Y la parte más interesante es la que casi todo el mundo copia de una plantilla: el manifiesto de privacidad. Aquí se deduce del binario.

No se instala ninguna librería. Se toca Config.xcconfig, el proyecto de Xcode (dos ajustes) y el Info.plist, y se crean un fichero (PrivacyInfo.xcprivacy) y un color. El ExportOptions.plist del paso 6 solo se lee: no se crea.

iosApp
Configuration → Config.xcconfigse modifica (paso 1)
iosApp → PrivacyInfo.xcprivacyse crea (paso 2)
iosApp → Info.plistse modifica (paso 3)
iosApp → Assets.xcassets → FondoDeArranquese crea (paso 3)
Lo que se toca. Los nodos son los del Project navigator de Xcode.

Paso 1 · La versión, en Config.xcconfig

Qué es. En iOS la versión son dos ajustes del proyecto: MARKETING_VERSION (la que lee la gente, 1.0.0) y CURRENT_PROJECT_VERSION (el número de compilación, que solo puede subir). Son los mismos dos números que appVersionName y appVersionCode del catálogo de la lección anterior, pero no salen del mismo sitio: Android los lee del catálogo de Gradle e iOS, de Config.xcconfig. Son dos sistemas de compilación distintos y los dos ficheros no se leen entre sí, así que los igualas a mano cada vez que subes de versión: una versión de la app es una versión.

1 · En Config.xcconfig. Es el fichero de ajustes del proyecto que ya conoces de Configuración y secretos por entorno. En Xcode: iosApp → Configuration → Config.xcconfig. Añade al final del fichero (tras tus líneas de Supabase, si las tienes):

Config.xcconfig: las dos líneas nuevas
// La versión de la app. Se iguala a mano con appVersionName y
// appVersionCode del catálogo de Gradle.
MARKETING_VERSION=1.0.0
CURRENT_PROJECT_VERSION=1

2 · Mira qué versión usa el proyecto de verdad. En la Terminal de Android Studio, en la carpeta del proyecto. Primero una variable de la Terminal con el nombre de tu esquema (scheme, lo viste en Tu primera app de iOS); si seguiste el curso se llama Pokedex, y si no, es el que enseña xcodebuild -project iosApp/iosApp.xcodeproj -list. Una variable vale mientras no cierres esa pestaña de la Terminal; si la cierras, vuelve a escribirla:

ESQUEMA=Pokedex   # el tuyo
xcodebuild -project iosApp/iosApp.xcodeproj -scheme $ESQUEMA -configuration Release -showBuildSettings | grep -E " (MARKETING_VERSION|CURRENT_PROJECT_VERSION) "
    CURRENT_PROJECT_VERSION = 1
    MARKETING_VERSION = 0.1.0

(Tus números pueden ser otros, por ejemplo 1.0.) La versión sigue siendo la antigua, no la que acabas de escribir: el proyecto de Xcode trae su propio valor en el target, y un valor del target gana al del fichero .xcconfig.

3 · Haz que el target herede del fichero. En Xcode, pulsa el nodo azul iosApp (arriba del todo del Project navigator), elige el target de la app (el que se llama como tu app, no el de los tests) y la pestaña Build Settings. Escribe version en el buscador de esa pestaña. Verás las filas Marketing Version y Current Project Version: haz doble clic en el valor de cada una, escribe $(inherited) y pulsa Return (si la fila se despliega en Debug y Release, ponlo en las dos). $(inherited) significa «el valor que venga de más arriba», es decir, el de Config.xcconfig.

Repite el comando del punto 2:

    CURRENT_PROJECT_VERSION = 1
    MARKETING_VERSION = 1.0.0

Ahora cambiar la versión es tocar una línea de Config.xcconfig.

Paso 2 · El manifiesto de privacidad, deducido del binario

El concepto. Apple exige que la app declare, en un fichero llamado manifiesto de privacidad (PrivacyInfo.xcprivacy), tres cosas: si sigue al usuario, qué datos suyos recoge y qué funciones «de motivo obligatorio» usa. Esas funciones son llamadas concretas del sistema (por ejemplo, leer la fecha de un fichero con stat) que Apple quiere que justifiques. Lo que hace difícil el fichero es que basta con que estén enlazadas en tu app para que Apple las detecte al subirla, aunque no las llame tu código: las puede traer una librería.

Así que en vez de copiar una plantilla, lo averiguas. Construye el framework de Kotlin en su versión de release para dispositivo y pregúntale qué funciones del sistema necesita (nm -u lista los símbolos que el binario no define, es decir, los que toma prestados del sistema; grep -E deja solo los nombres de la lista, que son las funciones de las categorías «de motivo obligatorio» que usan las librerías de ficheros):

./gradlew :shared:linkReleaseFrameworkIosArm64
nm -u shared/build/bin/iosArm64/releaseFramework/Shared.framework/Shared \
  | grep -E '_(stat|fstat|lstat|statfs|fstatfs|statvfs|fstatvfs|getattrlist|mach_absolute_time)$'
_fstat
_fstatfs
_lstat
_stat
_statfs

(El orden y los repetidos pueden variar: lo que cuenta son los nombres.) Salen cinco, y ninguna la llama tu código: vienen de SQLite (Room) y de la librería de ficheros del almacén de ajustes. Caen en dos categorías de las de Apple: stat, fstat y lstat son fechas de ficheros; statfs y fstatfs son espacio libre en disco.

El framework de Kotlin es estático: acaba dentro del binario de la app, que es lo que Apple examina. Por eso, tras archivar en el paso 4, repetirás la misma búsqueda sobre el binario de la app, que además incluye lo que tu código Swift pueda enlazar.

Crea el fichero. En Xcode: clic derecho sobre la carpeta iosApp (la de dentro, la que contiene Info.plist) → New File from Template… → en la sección Resource elige App Privacy → Next. El nombre que propone, PrivacyInfo, es el que Apple espera: déjalo → Create. (Plan B si ese menú no te lo ofrece: touch iosApp/iosApp/PrivacyInfo.xcprivacy en la Terminal, en la carpeta del proyecto.)

El proyecto de Xcode usa carpetas sincronizadas (como viste en La lista en iOS): el fichero viaja dentro de la app al compilar. Clic derecho sobre él → Open As → Source Code (así se ve como texto, igual que hiciste con el Info.plist), selecciona todo y pega:

PrivacyInfo.xcprivacy
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<!-- ¿La app sigue al usuario entre apps de otras empresas (publicidad)? No. -->
	<key>NSPrivacyTracking</key>
	<false/>
	<key>NSPrivacyTrackingDomains</key>
	<array/>
 
	<!--
		Datos que la app recoge: solo el correo de la cuenta.
		Linked = true: está asociado a la identidad del usuario.
		Tracking = false: no se usa para publicidad ni se cruza con otros datos.
		Purposes: solo para que la app funcione.
		Los favoritos NO se declaran: nunca salen del dispositivo.
	-->
	<key>NSPrivacyCollectedDataTypes</key>
	<array>
		<dict>
			<key>NSPrivacyCollectedDataType</key>
			<string>NSPrivacyCollectedDataTypeEmailAddress</string>
			<key>NSPrivacyCollectedDataTypeLinked</key>
			<true/>
			<key>NSPrivacyCollectedDataTypeTracking</key>
			<false/>
			<key>NSPrivacyCollectedDataTypePurposes</key>
			<array>
				<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
			</array>
		</dict>
	</array>
 
	<!-- Las funciones del sistema «de motivo obligatorio» que lleva la app. -->
	<key>NSPrivacyAccessedAPITypes</key>
	<array>
		<dict>
			<!-- Fechas de ficheros: stat, fstat y lstat. -->
			<key>NSPrivacyAccessedAPIType</key>
			<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
			<key>NSPrivacyAccessedAPITypeReasons</key>
			<array>
				<!-- C617.1: se leen sobre ficheros dentro del contenedor de la app. -->
				<string>C617.1</string>
			</array>
		</dict>
		<dict>
			<!-- Espacio libre en disco: statfs y fstatfs. -->
			<key>NSPrivacyAccessedAPIType</key>
			<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
			<key>NSPrivacyAccessedAPITypeReasons</key>
			<array>
				<!-- E174.1: se mira el espacio libre antes de escribir. -->
				<string>E174.1</string>
			</array>
		</dict>
	</array>
</dict>
</plist>

Guarda con Cmd+S. Los dos motivos (C617.1 y E174.1) son los que Apple ofrece para llamadas sobre ficheros de la propia app y para espacio en disco:

El segundo motivo es exigente: si tu app no se comporta de forma distinta según el espacio libre, elige con cuidado el motivo que describa lo que de verdad hace. Aquí las llamadas las hace SQLite (Room) por dentro; declara lo que mejor encaje y, si Apple pregunta al subir la compilación, se responde con eso.

Comprueba que el fichero es un plist válido:

plutil -lint iosApp/iosApp/PrivacyInfo.xcprivacy

Debe responder …PrivacyInfo.xcprivacy: OK. (Si no te ofrece Open As → Source Code, abre el fichero con open -e iosApp/iosApp/PrivacyInfo.xcprivacy, que lo abre como texto en TextEdit, y pega ahí.)

Cada dato que la app recoge y cada categoría de funciones deben coincidir con lo que declararás en la ficha de la tienda: el Checklist de las dos tiendas lo recuerda.

Paso 3 · El cifrado y la pantalla de lanzamiento

Dos claves del Info.plist, y un color.

El cifrado. Cada vez que subes una compilación, App Store Connect pregunta si tu app usa cifrado. La tuya solo habla HTTPS, que cuenta como uso exento, así que la respuesta es siempre la misma y se deja contestada en el proyecto. Sin esta clave, cada compilación se queda esperando a que alguien conteste un formulario.

La pantalla de lanzamiento. Hoy tu Info.plist tiene UILaunchScreen con un diccionario vacío, que da una pantalla blanca; en modo oscuro es un fogonazo. Se le da un color del catálogo de recursos, con variante oscura.

El color. Un color del catálogo es una carpeta .colorset con un Contents.json dentro. Lo habitual en Xcode es Assets.xcassets → + → Color Set, y elegir los dos colores con el selector; aquí lo crea una orden, que es más corta de reproducir y de comprobar. mkdir -p crea la carpeta (y las que falten); cat > fichero <<'EOF' … EOF escribe en el fichero todo lo que hay hasta la línea EOF, tal cual. El color claro es un lila muy suave y el oscuro, casi negro: components son rojo, verde y azul entre 0 y 1:

mkdir -p iosApp/iosApp/Assets.xcassets/FondoDeArranque.colorset
cat > iosApp/iosApp/Assets.xcassets/FondoDeArranque.colorset/Contents.json <<'EOF'
{
  "colors" : [
    {
      "color" : {
        "color-space" : "srgb",
        "components" : { "alpha" : "1.000", "blue" : "0.980", "green" : "0.945", "red" : "0.953" }
      },
      "idiom" : "universal"
    },
    {
      "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ],
      "color" : {
        "color-space" : "srgb",
        "components" : { "alpha" : "1.000", "blue" : "0.043", "green" : "0.043", "red" : "0.043" }
      },
      "idiom" : "universal"
    }
  ],
  "info" : { "author" : "xcode", "version" : 1 }
}
EOF

En Xcode, iosApp → iosApp → Assets.xcassets debe mostrar ahora un color llamado FondoDeArranque, con dos casillas (Any Appearance y Dark).

El Info.plist. Clic derecho sobre iosApp → iosApp → Info.plist → Open As → Source Code. Dos cambios. Antes de <key>LSRequiresIPhoneOS</key> (si tu Info.plist no tiene esa clave, pon la del cifrado justo antes del </dict> final), añade:

Info.plist: el cifrado, claves nuevas
	<!--
		Cifrado: la app solo usa HTTPS, que es un uso exento. Con `false`, App
		Store Connect deja de preguntarlo en cada subida.
	-->
	<key>ITSAppUsesNonExemptEncryption</key>
	<false/>

Y sustituye el <key>UILaunchScreen</key> con su <dict/> vacío por:

Info.plist: UILaunchScreen, antes y después
	<!-- La pantalla de lanzamiento, con el color del recurso FondoDeArranque. -->
	<key>UILaunchScreen</key>
	<dict>
		<key>UIColorName</key>
		<string>FondoDeArranque</string>
	</dict>

Guarda con Cmd+S y comprueba que el fichero sigue siendo válido: plutil -lint iosApp/iosApp/Info.plist.

Míralo. Ejecuta la app en el simulador (Cmd + R) y ciérrala. La pantalla de lanzamiento dura un parpadeo; para verla quieta, lanza la app desde la Terminal pidiéndole que espere a un depurador que nunca llega (sustituye <tu identificador> por el Bundle Identifier de tu target, en target → General), y cámbiale la apariencia al simulador:

xcrun simctl ui booted appearance dark
xcrun simctl terminate booted <tu identificador>
xcrun simctl launch --wait-for-debugger booted <tu identificador>

Debes ver una pantalla casi negra, sin texto. Repite con appearance light y verás el lila claro. Cuando termines: xcrun simctl terminate booted <tu identificador>.

Paso 4 · El archivo, sin cuenta de pago

El concepto. Un archivo (.xcarchive) es la compilación de release, completa, lista para exportar y subir. Xcode lo crea con la orden xcodebuild archive. Normalmente lo firma con tu identidad de Apple; aquí se le pide que no firme (CODE_SIGNING_ALLOWED=NO), que es lo que no necesita cuenta de pago y ya comprueba lo importante: que la release compila sana.

Primero dos variables más (en la misma pestaña de la Terminal que ESQUEMA). ARCHIVO es dónde se guarda el archivo: fuera de la carpeta build de Gradle y fuera del proyecto, para no mezclarlos. NOMBRE es el nombre de tu app dentro del archivo; si seguiste el curso es Pokedex, y lo ves con ls $ARCHIVO/Products/Applications tras archivar.

NOMBRE=Pokedex   # el tuyo
ARCHIVO=~/Archivos-iOS/$NOMBRE.xcarchive
xcodebuild archive -project iosApp/iosApp.xcodeproj -scheme $ESQUEMA \
  -configuration Release -destination 'generic/platform=iOS' \
  -archivePath $ARCHIVO \
  CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO

Tarda unos minutos (compila Kotlin para dispositivo). Termina en:

** ARCHIVE SUCCEEDED **

Dentro del archivo hay una app para dispositivo y una carpeta de símbolos. Compruébalo todo en el binario, no en el código:

APP=$ARCHIVO/Products/Applications/$NOMBRE.app
du -sh $APP $ARCHIVO/dSYMs/*
ls $APP | grep PrivacyInfo
plutil -extract CFBundleShortVersionString raw $APP/Info.plist   # 1.0.0
plutil -extract CFBundleVersion raw $APP/Info.plist              # 1
plutil -extract ITSAppUsesNonExemptEncryption raw $APP/Info.plist # false
plutil -p $APP/Info.plist | grep -A2 UILaunchScreen

Y la búsqueda del paso 2, ahora sobre el binario de la app (el que Apple examina; se llama como la app, y debe dar los mismos cinco nombres):

nm -u $APP/$NOMBRE | grep -E '_(stat|fstat|lstat|statfs|fstatfs|statvfs|fstatvfs|getattrlist|mach_absolute_time)$' | sort -u

La app pesa en torno a 9 MB y la carpeta de símbolos, en torno a 17 MB (tus cifras serán parecidas, no idénticas). PrivacyInfo.xcprivacy aparece dentro de la app compilada: si solo estuviera en tu carpeta, Apple no lo vería. Y UILaunchScreen enseña FondoDeArranque.

Paso 5 · Los símbolos de Kotlin, que están donde no esperas

Un dSYM es el fichero que traduce las direcciones de memoria de un fallo a nombres de funciones. Sin él, un informe de fallo de tu versión publicada llega con números y nada más: hay que guardarlo de cada versión que subas.

Lo esperable es que Kotlin deje un dSYM junto a su framework. Busca:

find shared/build/bin -name "*.dSYM"

No sale ninguno. El framework es estático: se funde con el binario de la app al enlazar y no tiene información de depuración propia que empaquetar. Esa información acaba en el dSYM de la app, y se comprueba buscando los nombres de tu código Kotlin:

nm -a $ARCHIVO/dSYMs/$NOMBRE.app.dSYM/Contents/Resources/DWARF/$NOMBRE \
  | grep -c 'kfun:com.sebastiannarvaez'

Sale un número de varios cientos (cada función de Kotlin que escribiste aparece con un nombre como _kfun:com.sebastiannarvaez.pokedex#currentPlatform(){}…). Ese es el dSYM que hay que guardar de cada versión. Es una carpeta, así que se copia con -R, con la versión en el nombre, junto al mapping.txt de Producción en Android:

cp -R $ARCHIVO/dSYMs/$NOMBRE.app.dSYM ~/claves/$NOMBRE-1.0.0.dSYM

Paso 6 · Exportar el .ipa — requiere cuenta de pago

Este paso es el único del archivo que no puedes hacer sin el Apple Developer Program (99 USD al año), así que aquí se lee, no se ejecuta. (Subir a TestFlight también lo exige, como se verá en la lección siguiente.) Un ExportOptions.plist le dice a xcodebuild cómo convertir el archivo en un .ipa:

ExportOptions.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<!-- Para subir a App Store Connect. Otros métodos (ad-hoc, development,
	     enterprise) producen compilaciones que no llegan a la tienda. -->
	<key>method</key>
	<string>app-store-connect</string>
	<!-- El identificador de tu equipo en Apple: sin cuenta de pago no tienes
	     ninguno, y por eso está vacío. Pon aquí el tuyo. -->
	<key>teamID</key>
	<string></string>
	<!-- Manda el dSYM a Apple: así los informes de fallo de la tienda llegan
	     con nombres y no con direcciones. -->
	<key>uploadSymbols</key>
	<true/>
	<key>signingStyle</key>
	<string>automatic</string>
	<key>destination</key>
	<string>export</string>
</dict>
</plist>

Con cuenta de pago, se guardaría como iosApp/Configuration/ExportOptions.plist y se exportaría así:

xcodebuild -exportArchive \
  -archivePath $ARCHIVO \
  -exportOptionsPlist iosApp/Configuration/ExportOptions.plist \
  -exportPath ~/Archivos-iOS/ipa

Punto de control

  • xcodebuild -showBuildSettings (con $ESQUEMA) enseña MARKETING_VERSION = 1.0.0 y CURRENT_PROJECT_VERSION = 1, y cambian al tocar Config.xcconfig.
  • plutil -lint da OK en PrivacyInfo.xcprivacy y en Info.plist.
  • xcodebuild archive con CODE_SIGNING_ALLOWED=NO termina en ** ARCHIVE SUCCEEDED **.
  • El archivo tiene dentro una app de unos 9 MB y un dSYM de unos 17 MB.
  • PrivacyInfo.xcprivacy está dentro de la app compilada, y plutil -extract devuelve 1.0.0, 1 y false.
  • nm -a sobre el dSYM de la app encuentra símbolos kfun:com.sebastiannarvaez.
  • nm -u sobre el binario de la app da los mismos cinco nombres del paso 2.
  • La pantalla de lanzamiento es lila clara en el simulador claro y casi negra en el oscuro.

Reto · Declara de menos

Haz una copia de PrivacyInfo.xcprivacy (por ejemplo cp iosApp/iosApp/PrivacyInfo.xcprivacy ~/PrivacyInfo.copia). Quita del original el bloque de NSPrivacyAccessedAPICategoryDiskSpace, repite el xcodebuild archive del paso 4 y busca el fichero en la app.

El fichero se empaqueta igual y el archivo termina en ARCHIVE SUCCEEDED. ¿Cuándo te enterarías del problema, y qué dice eso de este tipo de comprobaciones? Después restaura la copia.

Ver solución

Te enterarías al subir la compilación, en un correo automático de App Store Connect que llega minutos u horas después, y que avisa de una declaración que falta en el manifiesto.

Es la peor clase de comprobación que existe: no falla al compilar, no falla al ejecutar, no falla al archivar. Falla en un sistema remoto, más tarde, y por correo.

De ahí la búsqueda con nm -u del paso 2: son cinco segundos de trabajo y convierten un aviso diferido en una comprobación local. La lección vale para más cosas: cuando una regla la comprueba alguien lejos y tarde, merece la pena inventarse la forma de comprobarla cerca y pronto.

Restaura con cp ~/PrivacyInfo.copia iosApp/iosApp/PrivacyInfo.xcprivacy.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.De los pasos de esta lección, ¿cuál necesita el Apple Developer Program?

  2. 2.¿Cómo se sabe qué APIs de «motivo obligatorio» hay que declarar?

  3. 3.Esperabas encontrar un .dSYM junto al framework de Kotlin y no hay ninguno. ¿Por qué?

  4. 4.¿Qué evita ITSAppUsesNonExemptEncryption en el Info.plist?

  5. 5.Declarar de menos en el manifiesto de privacidad, ¿cuándo se nota?

Faltan 5 de 5