wandres.dev
IMÁGENES Y MULTIMEDIA · fotos, audio, vídeo

PhotosPicker, cámara y permisos

Elegir fotos sin pedir un solo permiso, entender el acceso limitado a la fototeca cuando PhotoKit es inevitable, capturar con AVFoundation sin bloquear el hilo principal, y las claves de Info.plist que deciden si tu app pasa la revisión.

⏱ 17 min

Pedirle al usuario acceso a sus fotos es una de las decisiones más caras que toma una app, y casi siempre es innecesaria. Apple lleva años construyendo intermediarios que te dan exactamente lo que necesitas sin darte nada más: eliges una foto, recibes esa foto, y la fototeca sigue siendo privada. Saber cuándo basta con el intermediario y cuándo hace falta la llave completa es la diferencia entre una app que el usuario acepta y una que rechaza en el primer diálogo.

🎯 Al terminar esta lección sabrás
  • Usar PhotosPicker sin declarar ningún permiso.
  • Entender el acceso limitado de PhotoKit y sus trampas.
  • Montar una sesión de captura con AVFoundation correctamente.
  • Escribir las claves de Info.plist que exige la revisión.

PhotosPicker: el permiso que no pides

PhotosPicker es la envoltura SwiftUI de PHPickerViewController, y su propiedad crucial es que se ejecuta en otro proceso. La interfaz que ve el usuario no pertenece a tu app: es del sistema, dibujada encima de la tuya. Tu proceso jamás toca la fototeca; recibe una copia de lo que el usuario eligió.

import PhotosUI

struct SelectorDeFoto: View {
    @State private var seleccion: PhotosPickerItem?
    @State private var imagen: Image?
    @Environment(\.displayScale) private var escala

    var body: some View {
        PhotosPicker(selection: $seleccion, matching: .images) {
            Label("Elegir foto", systemImage: "photo.on.rectangle")
        }
        .onChange(of: seleccion) { _, nuevo in
            Task {
                guard let datos = try? await nuevo?.loadTransferable(type: Data.self),
                      let reducida = reducir(datos: datos, lado: 300, escala: escala)
                else { return }
                imagen = Image(uiImage: reducida)
            }
        }
    }
}

Cero claves en Info.plist. Cero diálogos. Cero rechazos posibles. El filtro matching: acepta composiciones (.any(of: [.images, .videos]), .not(.screenshots)) y hay variantes para selección múltiple con maxSelectionCount y selectionBehavior: .ordered.

Fíjate en que loadTransferable devuelve Data, no un UIImage. Es deliberado: si pidieras Image.self estarías decodificando el original completo antes de poder reducirlo, con el coste de la lección anterior. Recibe bytes, reduce, y solo entonces construye la imagen.

El permiso más barato es el que nunca pides

Hay una arquitectura de privacidad detrás de todo esto que conviene ver entera, porque se repite en toda la plataforma. Apple distingue dos modelos de acceso a datos del usuario: el permiso, donde tu app obtiene una llave permanente a un dominio completo, y el intermediario, donde un proceso del sistema le muestra al usuario sus datos, recoge su elección y te entrega solo eso. PhotosPicker es un intermediario; también lo son el selector de documentos, el de contactos con ContactAccessButton, la hoja de compartir y el selector de ubicación de una sola vez. El patrón se llama a veces privacidad por arquitectura: no es que la app prometa portarse bien, es que técnicamente no puede portarse mal.

La consecuencia para ti no es solo ética, es de producto y es medible. Cada diálogo de permiso es un embudo con una salida irreversible: si el usuario pulsa “No permitir”, tu código no puede volver a preguntar nunca; solo puedes mandarlo a Ajustes, y ese camino tiene una tasa de conversión ridícula. Un permiso pedido en el momento equivocado —al arrancar, sin contexto, antes de que el usuario entienda por qué— no cuesta una fricción: cuesta la funcionalidad entera, para siempre, en ese dispositivo. Por eso la regla de diseño correcta no es “pide bien el permiso”, sino “revisa si existe un intermediario que lo haga innecesario, y si no existe, pide en el instante exacto en que el usuario ya quiere el resultado”. El permiso es la última opción, no la primera.

flowchart TD
Q1{Necesitas leer toda la fototeca o escribir en ella} -->|no| PK[PhotosPicker sin permisos]
Q1 -->|si| Q2{Basta con guardar lo que crea la app}
Q2 -->|si| ADD[NSPhotoLibraryAddUsageDescription]
Q2 -->|no| PHK[PhotoKit con autorizacion y modo limitado]
style PK fill:#a6e3a1,color:#11111b
style PHK fill:#f9e2af,color:#11111b

Cuando PhotoKit es inevitable: el acceso limitado

Hay casos legítimos en los que el intermediario no basta: una app que sincroniza la biblioteca completa, que observa cambios, que lee metadatos de todos los álbumes. Ahí entras en PhotoKit y en el sistema de autorización real.

import Photos

func pedirAcceso() async -> PHAuthorizationStatus {
    await PHPhotoLibrary.requestAuthorization(for: .readWrite)
}

Lo que casi nadie maneja bien es el estado .limited, introducido en iOS 14 y hoy elegido por una fracción enorme de usuarios. Significa: tienes acceso de PhotoKit, pero solo a un subconjunto que el usuario seleccionó. Tus consultas con PHAsset.fetchAssets devuelven ese subconjunto y nada indica que falten fotos. Una app que asume acceso total y encuentra doce fotos concluye alegremente que el usuario tiene doce fotos.

Dos herramientas para tratarlo con dignidad. La primera es ofrecer explícitamente ampliar la selección desde tu interfaz, con PHPhotoLibrary.shared().presentLimitedLibraryPicker(from:), bajo control del usuario y no por sorpresa. La segunda es la clave PHPhotoLibraryPreventAutomaticLimitedAccessAlert en Info.plist puesta a true: sin ella, el sistema muestra por su cuenta un recordatorio molesto cada vez que arrancas, porque asume que no has previsto el caso. Ponerla es asumir la responsabilidad de gestionarlo tú.

Cámara: AVFoundation sin bloquear la interfaz

No existe una cámara nativa en SwiftUI. Tienes dos caminos: UIImagePickerController con sourceType = .camera envuelto en un UIViewControllerRepresentable, que es la vía rápida y sin control, o AVCaptureSession, que es la vía real.

@Observable
final class Camara {
    let sesion = AVCaptureSession()
    private let salida = AVCapturePhotoOutput()
    private let cola = DispatchQueue(label: "camara.configuracion")

    func arrancar() async {
        guard await AVCaptureDevice.requestAccess(for: .video) else { return }

        cola.async { [sesion, salida] in
            sesion.beginConfiguration()
            sesion.sessionPreset = .photo

            if let dispositivo = AVCaptureDevice.default(.builtInWideAngleCamera,
                                                         for: .video, position: .back),
               let entrada = try? AVCaptureDeviceInput(device: dispositivo),
               sesion.canAddInput(entrada) {
                sesion.addInput(entrada)
            }
            if sesion.canAddOutput(salida) { sesion.addOutput(salida) }

            sesion.commitConfiguration()
            sesion.startRunning()   // bloqueante: jamas en el hilo principal
        }
    }
}

Tres reglas que la documentación menciona de pasada y que la práctica cobra caras. startRunning() es síncrono y bloqueante, puede tardar cientos de milisegundos: llamarlo en el hilo principal congela la app justo cuando el usuario abre la cámara. Toda reconfiguración va entre beginConfiguration() y commitConfiguration(), en la misma cola en serie, o la sesión se corrompe. Y la vista previa es una capa de Core Animation, así que necesitas un puente:

struct VistaPrevia: UIViewRepresentable {
    let sesion: AVCaptureSession

    func makeUIView(context: Context) -> UIView {
        let vista = UIView()
        let capa = AVCaptureVideoPreviewLayer(session: sesion)
        capa.videoGravity = .resizeAspectFill
        vista.layer.addSublayer(capa)
        return vista
    }

    func updateUIView(_ vista: UIView, context: Context) {
        vista.layer.sublayers?.first?.frame = vista.bounds
    }
}

Los permisos, con precisión

📷

NSCameraUsageDescription

Obligatoria en cuanto enlazas contra la cámara. Sin ella la app no se cae al pedir permiso: se cae al intentar usarla, y solo en dispositivo.

🎙️

NSMicrophoneUsageDescription

Necesaria si grabas vídeo con sonido, aunque nunca toques el micro directamente. Añadir una entrada de audio a la sesión ya la exige.

🖼️

NSPhotoLibraryUsageDescription

Solo si usas PhotoKit. Con PhotosPicker no hace falta, y declararla sin usarla llama la atención de la revisión.

💾

NSPhotoLibraryAddUsageDescription

Para guardar en la fototeca sin poder leerla. Es la mitad del permiso, y casi siempre la mitad que de verdad necesitas.

El texto de la cadena importa. La revisión rechaza las genéricas del tipo “esta app necesita acceso a la cámara”: tiene que decir para qué, en el idioma del usuario y en términos de beneficio concreto, por ejemplo “para escanear el código de barras de un libro y añadirlo a tu biblioteca”. Y son cadenas localizables: van en InfoPlist.strings, no codificadas en un solo idioma.

⚠️
Pedir permiso al arrancar es un error de diseño, no de estilo

El patrón de solicitar cámara y fototeca en el primer lanzamiento maximiza los rechazos y no tiene ninguna ventaja técnica: el estado .notDetermined no caduca. Pide en el momento en que el usuario pulsa el botón que necesita esa capacidad, cuando la intención ya está expresada y la razón es evidente sin explicarla.

⚔️ Del intermediario a la sesión
  1. Monta un PhotosPicker con selección múltiple ordenada que cargue Data y reduzca cada elemento antes de mostrarlo.
  2. Comprueba en el proyecto que no hay ninguna clave de fototeca en Info.plist y que aun así funciona. Explica por qué.
  3. Concede acceso limitado a una app que use PhotoKit y observa qué devuelve PHAsset.fetchAssets. Diseña cómo se lo comunicarías al usuario.
  4. Implementa la clase Camara de esta lección y mide con Instruments cuánto tarda startRunning() en tu dispositivo.
  5. Reescribe la cadena de NSCameraUsageDescription de un proyecto tuyo para que supere el criterio de la revisión.