Del ecosistema expo-* a los frameworks de Apple: la tabla de equivalencias por dominio
Qué framework de Apple reemplaza a cada paquete expo-* al pasar de React Native a iOS nativo: cámara, ubicación, autenticación, pagos, notificaciones, archivos y más, con los permisos de Info.plist y el crash silencioso que todos cometen.
Expo hizo algo que se nota cuando lo pierdes: envolvió cada capacidad del teléfono en un paquete con la misma forma. expo-camera, expo-location, expo-notifications, expo-secure-store: instalar, pedir permiso con una función, usar. En nativo no existe esa uniformidad. Cada capacidad es un framework de Apple con su propia historia, su propia API (a veces en Swift moderno, a veces con delegates de 2010) y su propia forma de pedir permisos. Este post es la tabla que me hubiera gustado tener: por dominio, qué paquete expo-* usabas, qué framework lo reemplaza, qué permiso requiere y qué cambia en el uso. Y al final, el error que todos cometemos la primera vez: el crash silencioso por una clave que falta en Info.plist.
Resumen para perezosos
- Casi todo paquete
expo-*es un envoltorio de un framework de Apple: AVFoundation, CoreLocation, UserNotifications, StoreKit, AuthenticationServices, PhotosUI. Aprender el framework es aprender lo que el paquete escondía, incluidas sus decisiones de diseño. - Cada permiso tiene dos partes: una clave de uso en
Info.plistcon el texto que ve el usuario, y una llamada de solicitud en tiempo de ejecución. Si falta la clave, la app se cierra sin diálogo y sin error legible; es el crash silencioso de la primera semana. - Para lo que Apple no cubre (analytics de terceros, mapas de Google, ciertos SDK), Swift Package Manager tiene la mayoría de los SDK oficiales. Lo que no existe en SPM es un aviso: probablemente tampoco existe una alternativa madura.
En este artículo:
- La tabla — Cómo leer la tabla · Cámara, fotos y medios · Ubicación y mapas · Autenticación · Pagos y suscripciones · Notificaciones · Dispositivo y sistema
- Los permisos — Info.plist y el crash silencioso · Un patrón para pedirlos
- Lo que falta — Lo que Apple no cubre
Cómo leer la tabla
Cada fila da el paquete de Expo, el framework de Apple que lo reemplaza, el permiso que hace falta (la clave de Info.plist y, si aplica, la capacidad o entitlement del proyecto) y el cambio de uso que más sorprende. No es exhaustiva; es la lista de lo que aparece en la mayoría de las apps comerciales.
Dos aclaraciones generales:
- “Framework” no significa una sola clase.
expo-camerase reemplaza con AVFoundation, que es un framework enorme; para tomar una foto usas cuatro o cinco tipos de él. La tabla apunta al framework, y el texto a la parte que usas. - Muchos de estos frameworks tienen dos APIs: una moderna con
async/awaity otra anterior con delegates o callbacks. Cuando existe la moderna, es la que uso; cuando no,withCheckedContinuationoAsyncStreamla envuelven una vez, como explica el post de concurrencia.
Cámara, fotos y medios
| Expo | Apple | Permiso | Lo que cambia |
|---|---|---|---|
expo-camera | AVFoundation (AVCaptureSession, AVCapturePhotoOutput) | NSCameraUsageDescription; NSMicrophoneUsageDescription para video | No hay un componente de vista previa: montas una AVCaptureSession con dispositivo, entrada y salida, y muestras la vista previa con AVCaptureVideoPreviewLayer envuelta en UIViewRepresentable. Es más código y control total (enfoque, exposición, formato RAW). |
expo-image-picker (cámara) | UIImagePickerController (envuelto) | NSCameraUsageDescription | Para “tomar una foto y ya”, el picker del sistema sigue siendo la opción corta. Es UIKit, se envuelve una vez. |
expo-image-picker (galería) | PhotosUI (PhotosPicker) | Ninguno | PhotosPicker es SwiftUI nativo y corre fuera de tu proceso: el usuario elige y tú recibes solo lo elegido, sin pedir permiso de fototeca. Es el cambio más agradable de la tabla. |
expo-media-library | Photos (PHPhotoLibrary) | NSPhotoLibraryUsageDescription; NSPhotoLibraryAddUsageDescription para solo guardar | Solo si necesitas leer la biblioteca completa o guardar en un álbum. Desde iOS 14 el usuario puede dar acceso limitado a fotos seleccionadas, y tu código tiene que soportar ese estado. |
expo-av (reproducción) | AVFoundation (AVPlayer), AVKit (VideoPlayer) | Ninguno | VideoPlayer de AVKit es una vista de SwiftUI con controles del sistema. AVPlayer para audio y control programático. Para audio en segundo plano, la capacidad Background Modes con Audio. |
expo-av (grabación) | AVFoundation (AVAudioRecorder, AVAudioSession) | NSMicrophoneUsageDescription | AVAudioSession es el concepto que Expo escondía: decide cómo convive tu audio con el del sistema (categoría, modo, interrupciones por llamada). Configurarlo mal es la causa de la mayoría de los bugs de audio. |
expo-speech | AVFoundation (AVSpeechSynthesizer) | Ninguno | API directa. Voces del sistema, con calidad mejorada descargable por el usuario. |
expo-barcode-scanner | AVFoundation (AVCaptureMetadataOutput) o Vision (VNDetectBarcodesRequest) | NSCameraUsageDescription | Vision detecta códigos en una imagen ya tomada; AVCaptureMetadataOutput los lee en vivo desde la sesión de captura. Ambos sin librerías. |
expo-image | AsyncImage (básico) o Nuke / Kingfisher (SPM) | Ninguno | AsyncImage no cachea en disco ni hace prefetch. Para una app con imágenes de red, Nuke o Kingfisher son la dependencia estándar. |
Ubicación y mapas
| Expo | Apple | Permiso | Lo que cambia |
|---|---|---|---|
expo-location | CoreLocation (CLLocationManager, CLLocationUpdate) | NSLocationWhenInUseUsageDescription; NSLocationAlwaysAndWhenInUseUsageDescription para segundo plano; capacidad Background Modes con Location | Desde iOS 17, CLLocationUpdate.liveUpdates() entrega ubicaciones como AsyncSequence sin delegate. El permiso “siempre” se pide en dos pasos (primero “al usar”, después “siempre”) y el sistema decide cuándo mostrar el segundo diálogo, no tú. |
expo-location (geocoding) | CoreLocation (CLGeocoder) | Ninguno | Límites de tasa del servicio de Apple. Para volumen, un servicio propio. |
react-native-maps | MapKit (Map de SwiftUI) | Ninguno; NSLocationWhenInUseUsageDescription si muestras la ubicación del usuario | Map en SwiftUI con anotaciones, overlays y cámara controlable desde iOS 17. Sin API key, sin cuota. Para mapas de Google, el SDK oficial por SPM. |
| Geofencing | CoreLocation (CLMonitor) | Permiso “siempre” | CLMonitor (iOS 17) reemplaza a las regiones con delegate. Límite de 20 regiones monitoreadas por app. |
Autenticación y sesión
| Expo | Apple | Permiso / capacidad | Lo que cambia |
|---|---|---|---|
expo-apple-authentication | AuthenticationServices (SignInWithAppleButton) | Capacidad Sign in with Apple | Botón nativo de SwiftUI. Apple exige ofrecerlo si ofreces cualquier otro login social. El correo real solo llega la primera vez; hay que guardarlo entonces. |
expo-auth-session (OAuth) | AuthenticationServices (ASWebAuthenticationSession) | Ninguno; esquema de URL o Universal Link para el callback | Abre una sesión de Safari aislada para el flujo OAuth y devuelve la URL de callback. Es lo que hacía expo-auth-session por debajo. |
expo-local-authentication | LocalAuthentication (LAContext) | NSFaceIDUsageDescription | LAContext.evaluatePolicy con async. Face ID requiere la clave; Touch ID no. Si falta la clave y el dispositivo tiene Face ID, crash silencioso. |
expo-secure-store | Security (Keychain) | Capacidad Keychain Sharing solo para compartir con extensiones | Cubierto en el post de persistencia: sobrevive a la desinstalación. |
| Passkeys | AuthenticationServices (ASAuthorizationPlatformPublicKeyCredentialProvider) | Associated Domains con webcredentials: | Sin equivalente maduro en Expo. Es una de las capacidades que hoy justifican nativo en apps con login. |
| Autofill de contraseñas | .textContentType(.username / .password / .oneTimeCode) | Associated Domains con webcredentials: | Un modifier por campo, y el sistema ofrece las credenciales guardadas y los códigos de SMS. |
Pagos y suscripciones
| Expo | Apple | Permiso / capacidad | Lo que cambia |
|---|---|---|---|
react-native-iap, RevenueCat | StoreKit 2 (Product, Transaction) | Capacidad In-App Purchase | StoreKit 2 es async/await puro: Product.products(for:), product.purchase(), Transaction.currentEntitlements. La verificación de recibos viene firmada y validada en el dispositivo. RevenueCat sigue teniendo sentido para analytics y backend de suscripciones, pero la API base ya no duele. |
@stripe/stripe-react-native | Stripe iOS SDK (SPM) | Ninguno | Mismo SDK oficial. Recordar: los bienes digitales consumidos en la app pasan por In-App Purchase, no por Stripe; App Review lo revisa. |
| Apple Pay | PassKit (PKPaymentAuthorizationController) | Capacidad Apple Pay con Merchant ID | Para bienes físicos y servicios. El botón nativo (PayWithApplePayButton) es SwiftUI. |
expo-store-review | StoreKit (requestReview en el entorno) | Ninguno | @Environment(\.requestReview) y una llamada. El sistema decide si muestra el diálogo. |
Notificaciones
| Expo | Apple | Permiso / capacidad | Lo que cambia |
|---|---|---|---|
expo-notifications (push) | UserNotifications + APNs | Capacidad Push Notifications; registerForRemoteNotifications | Expo tenía su servicio de push que te ahorraba hablar con APNs; ahora tu backend habla con APNs directo (token de auth con la clave .p8) o vía Firebase Cloud Messaging. El token del dispositivo llega a AppDelegate, que en SwiftUI se agrega con @UIApplicationDelegateAdaptor. |
expo-notifications (locales) | UserNotifications (UNUserNotificationCenter) | Solicitud de permiso en tiempo de ejecución, sin clave en plist | requestAuthorization(options:) es async. Las locales se programan con triggers de tiempo, calendario o ubicación. |
| Notificaciones ricas (imagen, acciones) | Notification Service Extension y Content Extension | Target de extensión | Un target aparte, en Swift, que modifica la notificación antes de mostrarla. Es el primer sitio donde muchos equipos escriben Swift aunque la app sea React Native. |
| Badges | UNUserNotificationCenter.setBadgeCount | Incluido en el permiso de notificaciones | Directo. |
Dispositivo, archivos y sistema
| Expo | Apple | Permiso / capacidad | Lo que cambia |
|---|---|---|---|
expo-file-system | Foundation (FileManager, URL) | Ninguno | Documents con backup, Caches sin backup y borrable. Ver el post de persistencia. |
expo-document-picker | .fileImporter (SwiftUI) | Ninguno | Un modifier de SwiftUI que abre el selector de archivos del sistema y devuelve URLs con acceso de seguridad (startAccessingSecurityScopedResource). |
expo-sharing | ShareLink (SwiftUI) o UIActivityViewController | Ninguno | ShareLink(item:) es una línea. Para compartir con vista previa personalizada, Transferable. |
expo-clipboard | UIPasteboard | Ninguno; iOS avisa al usuario cuando lees el portapapeles | Leer el portapapeles sin acción del usuario muestra un aviso del sistema. Solo se lee tras un toque explícito. |
expo-haptics | .sensoryFeedback (SwiftUI) o UIImpactFeedbackGenerator | Ninguno | .sensoryFeedback(.success, trigger: value) como modifier. |
expo-device, expo-constants | UIDevice, ProcessInfo, Bundle.main | Ninguno | El nombre del modelo exacto (“iPhone 16 Pro”) no está expuesto; utsname da el identificador de hardware y hay que mapearlo. |
expo-network | Network (NWPathMonitor) | Ninguno | Monitor de conectividad como AsyncStream. Reporta si hay ruta, no si internet funciona. |
expo-linking | .onOpenURL, openURL del entorno | LSApplicationQueriesSchemes para consultar si otra app está instalada | Cubierto en el post de navegación. |
expo-web-browser | SFSafariViewController (envuelto) | Ninguno | Safari dentro de la app, con cookies compartidas con Safari. Para contenido propio, WKWebView. |
expo-localization | Foundation (Locale, String(localized:), String Catalogs) | Ninguno | Los String Catalogs (.xcstrings) reemplazan a los archivos JSON de i18n, con plurales y variantes por dispositivo integrados. Xcode extrae las cadenas automáticamente. |
expo-calendar, expo-contacts | EventKit, Contacts | NSCalendarsFullAccessUsageDescription, NSContactsUsageDescription | Desde iOS 17, el calendario tiene acceso de “solo escribir” sin permiso completo (EKEventEditViewController). Contactos permite acceso limitado. |
expo-sensors | CoreMotion | NSMotionUsageDescription | Acelerómetro, giroscopio, podómetro. Los datos de salud van aparte, en HealthKit, con su propio permiso por tipo de dato. |
expo-background-fetch, expo-task-manager | BackgroundTasks (BGAppRefreshTask, BGProcessingTask) | Capacidad Background Modes con Background fetch / processing; identificadores en Info.plist | El sistema decide cuándo corre la tarea según el uso de la app; no hay garantía de frecuencia. Es el punto donde más expectativas se rompen. |
expo-updates | No existe | — | No hay actualizaciones OTA de código en nativo. Configuración remota y feature flags, sí; código, no. |
Permisos: Info.plist y el crash silencioso
Cada permiso en iOS tiene dos mitades. La primera es una clave en Info.plist cuyo valor es el texto que el usuario lee en el diálogo del sistema (“Esta app usa la cámara para escanear tus recibos”). La segunda es la llamada en tiempo de ejecución que dispara el diálogo. Expo unía las dos: el config plugin del paquete escribía la clave por ti, con un texto por defecto, y la función requestPermissionsAsync hacía la llamada.
En nativo, si haces la llamada sin haber escrito la clave, la app se cierra en ese instante. No hay excepción de Swift que atrapar, no hay diálogo, no hay error en la consola de la app. El mensaje aparece en el log del sistema (en Console.app, o en la salida de Xcode si estás conectado), y dice algo como que la app se terminó por un motivo de privacidad porque intentó acceder a datos sensibles sin una descripción de uso. Es el crash silencioso de la primera semana, y también el que aparece en producción cuando alguien agrega Face ID a una app que solo tenía Touch ID.
Las claves más comunes, para tenerlas a mano:
| Capacidad | Clave de Info.plist |
|---|---|
| Cámara | NSCameraUsageDescription |
| Micrófono | NSMicrophoneUsageDescription |
| Fototeca (leer) | NSPhotoLibraryUsageDescription |
| Fototeca (solo guardar) | NSPhotoLibraryAddUsageDescription |
| Ubicación al usar | NSLocationWhenInUseUsageDescription |
| Ubicación siempre | NSLocationAlwaysAndWhenInUseUsageDescription |
| Face ID | NSFaceIDUsageDescription |
| Contactos | NSContactsUsageDescription |
| Calendario | NSCalendarsFullAccessUsageDescription |
| Movimiento | NSMotionUsageDescription |
| Bluetooth | NSBluetoothAlwaysUsageDescription |
| Seguimiento entre apps (ATT) | NSUserTrackingUsageDescription |
| Red local | NSLocalNetworkUsageDescription |
| Salud (leer / escribir) | NSHealthShareUsageDescription / NSHealthUpdateUsageDescription |
Tres reglas sobre el texto:
- Explica el beneficio concreto, no la capacidad. “Para escanear recibos” en vez de “Para usar la cámara”. App Review rechaza descripciones vagas, y la tasa de aceptación del usuario cambia con el texto.
- Localízalo. Las claves de
Info.plistse traducen enInfoPlist.xcstrings; una app en español con el diálogo en inglés se nota. - Pide el permiso en contexto, cuando el usuario toca la función que lo necesita, no al arrancar. Cada permiso se pide una sola vez; si el usuario dice que no, solo puede cambiarlo en Ajustes, y tu app tiene que ofrecer un botón que abra
UIApplication.openSettingsURLString.
Un detalle que en Expo no se veía: desde iOS 17, las apps que usan ciertas APIs consideradas sensibles (UserDefaults, marcas de tiempo de archivos, espacio en disco, tiempo de arranque del sistema) deben declarar el motivo en un Privacy Manifest (PrivacyInfo.xcprivacy), y lo mismo aplica a los SDK de terceros que incluyas. Sin él, App Store Connect rechaza el envío con un correo de advertencia. El post de build y App Store lo cubre.
Un patrón para pedir permisos sin repetir código
Cada framework pide permiso a su manera: CoreLocation con un delegate, AVFoundation con async, UserNotifications con async throws, Photos con un callback. Lo que hago es normalizarlos detrás de un solo tipo, para que las vistas no sepan de frameworks:
enum PermissionStatus {
case notDetermined, granted, denied, restricted
}
protocol Permission: Sendable {
var status: PermissionStatus { get async }
func request() async -> PermissionStatus
}
struct CameraPermission: Permission {
var status: PermissionStatus {
get async {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized: .granted
case .denied: .denied
case .restricted: .restricted
case .notDetermined: .notDetermined
@unknown default: .denied
}
}
}
func request() async -> PermissionStatus {
let granted = await AVCaptureDevice.requestAccess(for: .video)
return granted ? .granted : .denied
}
}
Y una vista que lo usa de la misma forma para cualquier permiso:
struct PermissionGate<Content: View>: View {
let permission: any Permission
let rationale: String
@ViewBuilder let content: () -> Content
@State private var status: PermissionStatus = .notDetermined
@Environment(\.openURL) private var openURL
var body: some View {
Group {
switch status {
case .granted:
content()
case .notDetermined:
ContentUnavailableView(rationale, systemImage: "lock", description: Text("Necesitamos tu permiso para continuar."))
.overlay(alignment: .bottom) {
Button("Permitir") { Task { status = await permission.request() } }
}
case .denied, .restricted:
ContentUnavailableView("Permiso desactivado", systemImage: "gear")
.overlay(alignment: .bottom) {
Button("Abrir Ajustes") {
openURL(URL(string: UIApplication.openSettingsURLString)!)
}
}
}
}
.task { status = await permission.status }
}
}
Con eso, PermissionGate(permission: CameraPermission(), rationale: "Escanea tus recibos") { ScannerView() } resuelve la pantalla de explicación, la solicitud y el caso denegado, y agregar un permiso nuevo es implementar el protocolo una vez.
Lo que Apple no cubre, y de dónde sale
Hay dominios donde no hay framework de Apple y la respuesta es un SDK de terceros por Swift Package Manager:
- Analytics y crash reporting: Firebase (Analytics, Crashlytics), Sentry, PostHog, Mixpanel. Todos con SPM. MetricKit de Apple da diagnósticos agregados sin SDK, pero no reemplaza a un crash reporter.
- Mapas de Google, Mapbox: SDK oficiales por SPM. MapKit cubre la mayoría de los casos sin ellos.
- Chat y video en tiempo real: Stream, Sendbird, Twilio, Agora, LiveKit. Con SPM.
- Feature flags y configuración remota: Firebase Remote Config, LaunchDarkly, o un endpoint propio. Es el reemplazo parcial de OTA: lo que se puede cambiar sin release es lo que la app lee del servidor.
- Backend as a service: Firebase, Supabase (
supabase-swift), Appwrite. Cubierto en el post de data.
La señal que uso para evaluar un SDK: si no está en SPM (solo CocoaPods o un .xcframework manual), probablemente no está mantenido para Swift moderno, y con Swift 6 estricto eso se vuelve un problema en la primera compilación. CocoaPods sigue funcionando, pero está en modo de mantenimiento desde 2024 y ya no es la ruta por defecto.
La primera app que migré de Expo a nativo tenía catorce paquetes
expo-*. Al terminar, doce eran frameworks de Apple sin dependencia, y dos eran SDK de terceros por SPM. El código de permisos, que en Expo eran catorce llamadas con la misma forma, terminó siendo un protocolo y catorce implementaciones de veinte líneas. Más código, y por primera vez sabía exactamente qué pedía cada uno.
Preguntas frecuentes
¿Cómo sé qué framework usar para algo que no está en la tabla?
La documentación de Apple está organizada por framework, y el buscador de Xcode (la documentación integrada) es más rápido que la web. Mi atajo es buscar el nombre del paquete de Expo en su propio repositorio: el código fuente del módulo iOS de expo-lo-que-sea importa exactamente el framework de Apple que necesitas y muestra cómo lo usa.
¿Vale la pena usar UIKit para la cámara o hay algo en SwiftUI?
Para vista previa y captura, la sesión de AVFoundation se muestra con una capa de UIKit envuelta en UIViewRepresentable. No hay una vista de cámara en SwiftUI puro. Es una de las pocas envolturas de UIKit que toda app con cámara escribe, y se escribe una vez.
¿Qué pasa con los permisos en el simulador?
La mayoría se pueden probar: el simulador muestra los diálogos y guarda la decisión. Cámara y ciertos sensores no funcionan en el simulador; para eso hace falta un dispositivo. xcrun simctl privacy permite conceder o revocar permisos por línea de comandos, útil en tests de UI.
¿Cómo reseteo un permiso ya concedido para volver a probar el diálogo?
En el dispositivo, desinstalando la app (el permiso se borra, el Keychain no). En el simulador, xcrun simctl privacy booted reset all com.miapp.bundle. En Ajustes, Privacidad, se puede cambiar el estado, pero no volver a “no determinado”.
¿Los SDK de terceros también necesitan claves de permiso?
Sí. Si un SDK de analytics accede a la red local, al IDFA o al portapapeles, tu Info.plist necesita la clave correspondiente y tu Privacy Manifest debe declararlo, aunque el acceso lo haga el SDK. App Review considera responsable a la app, no al SDK.
Conclusión
Cada paquete expo-* era un envoltorio con forma uniforme sobre un framework de Apple con forma propia. Pasar a nativo es quitar el envoltorio: AVFoundation para cámara y audio, CoreLocation y MapKit para ubicación, AuthenticationServices para login, StoreKit 2 para pagos, UserNotifications con APNs para push, y Foundation para archivos y localización. Lo que se gana es control y APIs del día uno; lo que se pierde es la uniformidad, y eso se recupera con un protocolo propio de permisos y con SPM para lo que Apple no cubre. El crash silencioso por una clave de Info.plist que falta es el bug que todos cometen una vez y ninguno dos.
Para empezar: haz tu propia tabla con los paquetes de tu package.json, escribe las claves de Info.plist antes que el código que las necesita, normaliza los permisos detrás de un protocolo, y elige SPM como único gestor de dependencias. El siguiente post cubre la capa visual: design tokens sin NativeWind, animaciones sin Reanimated, y Dynamic Type y accesibilidad como estándar y no como extra.