Programación en Swift y SwiftUI para iOS Developers

Cómo mostrar una imagen desde una URL en SwiftUI

Introducción

En el desarrollo de aplicaciones móviles modernas, es casi imposible encontrar una app que viva 100% desconectada (“offline”). Ya sea que estés construyendo un e-commerce, una red social, una aplicación de noticias o un simple catálogo, tarde o temprano te enfrentarás a uno de los requisitos más comunes y, a veces, frustrantes del desarrollo iOS: cargar imágenes desde internet.

Si vienes del viejo mundo de UIKit, recordarás que UIImageView no tenía una forma nativa de cargar una URL. Teníamos que lidiar con URLSession, despachar tareas al hilo principal (Main Thread) manualmente o depender de librerías gigantescas para hacer algo que debería ser trivial.

Con la llegada de SwiftUI, Apple prometió simplificar nuestras vidas. Pero, ¿realmente lo ha logrado? ¿Es AsyncImage la solución definitiva o deberíamos seguir usando librerías externas?

En este tutorial masivo, vamos a desglosar todo lo que necesitas saber para manejar imágenes remotas en Xcode con SwiftUI. No solo aprenderás a mostrar una foto; aprenderás sobre caché, gestión de memoria, manejo de errores y cómo crear una experiencia de usuario fluida.


Parte 1: La Solución Nativa (iOS 15+) – AsyncImage

Con el lanzamiento de iOS 15, Apple introdujo AsyncImage. Esta es la forma estándar y recomendada para la mayoría de los casos de uso sencillos.

1.1 El uso básico

La implementación más sencilla requiere apenas una línea de código. AsyncImage se encarga de:

  1. Hacer la petición de red asíncrona.
  2. Descargar los datos.
  3. Decodificar la imagen.
  4. Mostrarla en pantalla.
import SwiftUI

struct BasicImageView: View {
    // URL de ejemplo (asegúrate de usar HTTPS)
    let imageUrl = URL(string: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e")

    var body: some View {
        VStack {
            Text("Mi Imagen Remota")
                .font(.headline)
            
            // Carga básica
            AsyncImage(url: imageUrl)
        }
    }
}

El problema de este enfoque: Si ejecutas este código, notarás algo extraño. Si la imagen es más grande que la pantalla del iPhone, se saldrá de los bordes. Si intentas agregarle el modificador .resizable(), Xcode te dará un error.

¿Por qué? Porque AsyncImage es un wrapper (envoltorio), no una imagen en sí misma hasta que se carga.

1.2 Personalización y Redimensionamiento

Para poder manipular la imagen (hacerla redimensionable, cambiar el aspect ratio, recortarla), necesitamos usar el inicializador que nos proporciona un closure.

AsyncImage(url: imageUrl) { image in
    image
        .resizable()
        .scaledToFill() // O .scaledToFit()
} placeholder: {
    // Lo que se muestra mientras carga
    ProgressView()
}
.frame(width: 300, height: 300)
.clipShape(RoundedRectangle(cornerRadius: 20))

Análisis del código:

  • image: Es el objeto Image real una vez descargado. Aquí es donde aplicamos .resizable().
  • placeholder: Es una vista temporal. Usar un ProgressView (el spinner nativo) es la mejor práctica de UX.
  • Modificadores externos: El .frame() y .clipShape() se aplican al contenedor del AsyncImage, asegurando que tanto el placeholder como la imagen final respeten el tamaño.

Parte 2: Gestión de Estados (Carga, Éxito y Error)

En el mundo real, las conexiones fallan. Las URLs se rompen. Los servidores se caen. Una app profesional no puede quedarse con un espacio en blanco si algo sale mal.

Para esto, AsyncImage nos ofrece un control granular a través de AsyncImagePhase.

2.1 Implementando Fases

Este inicializador nos da acceso a un enum que nos dice exactamente qué está pasando.

struct AdvancedImageView: View {
    let url = URL(string: "https://ejemplo.com/imagen-que-quizas-falle.jpg")

    var body: some View {
        AsyncImage(url: url) { phase in
            switch phase {
            case .empty:
                // Fase 1: Cargando
                ZStack {
                    Color.gray.opacity(0.1)
                    ProgressView()
                }
                
            case .success(let image):
                // Fase 2: Éxito
                image
                    .resizable()
                    .scaledToFit()
                    .transition(.opacity.animation(.easeIn)) // Animación suave
                
            case .failure(let error):
                // Fase 3: Error
                VStack {
                    Image(systemName: "wifi.slash")
                        .font(.largeTitle)
                        .foregroundColor(.red)
                    Text("Error al cargar")
                        .font(.caption)
                        .foregroundColor(.secondary)
                    // Opcional: Imprimir el error en consola para debug
                    let _ = print(error.localizedDescription) 
                }
                
            @unknown default:
                // Futuros casos de Apple
                EmptyView()
            }
        }
        .frame(height: 250)
        .background(Color(.systemGray6))
        .cornerRadius(12)
    }
}

Ventajas de este método:

  1. Feedback visual: El usuario siempre sabe qué está pasando.
  2. Manejo de errores: Puedes mostrar un icono de “reintentar” o un placeholder por defecto si la imagen falla.
  3. Transiciones: Nota cómo agregamos .transition(.opacity). Esto evita que la imagen aparezca de golpe (el efecto “pop-in”), haciendo la app más elegante.

Parte 3: El Gran Debate – ¿Caché o no Caché?

Aquí es donde entramos en terreno de “Ingeniería de Software” real.

AsyncImage usa el sistema de caché de URL predeterminado de iOS (URLCache). Esto significa que:

  • Si el servidor envía las cabeceras HTTP correctas (Cache-Control), iOS guardará la imagen temporalmente.
  • Sin embargo, esta caché es volátil. Si cierras la app y la vuelves a abrir, o si el sistema necesita memoria RAM, la imagen se descargará de nuevo.
  • En una lista (List/LazyVStack) con cientos de imágenes, AsyncImage puede volver a descargar imágenes que ya viste hace 10 segundos si haces scroll hacia arriba y abajo rápidamente.

3.1 ¿Cuándo es suficiente AsyncImage?

  • Prototipos.
  • Apps con pocas imágenes.
  • Imágenes que cambian muy frecuentemente y no deben guardarse.

3.2 ¿Cuándo necesitas algo más potente?

  • Redes sociales (feeds infinitos).
  • Apps que deben funcionar con mala conexión.
  • Cuando necesitas optimizar el rendimiento de la CPU y la batería.

Parte 4: La Solución Profesional (Kingfisher)

Si necesitas caché persistente (en disco) y alto rendimiento, la comunidad de iOS tiene un estándar de oro: Kingfisher. Aunque es una librería externa, es tan común que prácticamente se considera parte del ecosistema.

4.1 Instalación

En Xcode:

  1. Ve a File > Add Packages...
  2. Pega la URL: https://github.com/onevcat/Kingfisher
  3. Selecciona tu proyecto y dale a “Add Package”.

4.2 Uso de Kingfisher en SwiftUI (KFImage)

Kingfisher simplifica todo lo que vimos en la Parte 2 y añade caché automático en disco y memoria RAM.

import SwiftUI
import Kingfisher

struct KingfisherExampleView: View {
    let url = URL(string: "https://images.unsplash.com/photo-1550258987-190a2d41a8ba")

    var body: some View {
        KFImage(url)
            .placeholder {
                // Lo que se muestra mientras carga
                ProgressView()
            }
            .onFailure { error in
                print("Falló la carga: \(error)")
            }
            .resizable() // Kingfisher permite poner esto directamente
            .loadDiskFileSynchronously() // Optimización de carga
            .cacheMemoryOnly() // Opcional: Configuración de caché
            .fade(duration: 0.25) // Transición integrada
            .aspectRatio(contentMode: .fill)
            .frame(width: 300, height: 300)
            .clipShape(Circle())
    }
}

4.3 ¿Por qué usar Kingfisher?

  1. Downsampling (Submuestreo): Si descargas una imagen 4K pero la muestras en un icono de 50x50px, Kingfisher puede redimensionarla en memoria para no gastar RAM innecesaria. Esto evita que tu app se cierre por falta de memoria (crashes).
  2. Caché inteligente: Gestiona automáticamente la expiración de archivos viejos para no llenar el iPhone del usuario.
  3. Prefetching: Puedes descargar imágenes antes de que aparezcan en pantalla para una experiencia instantánea.

Parte 5: Creando tu propio ImageLoader (Avanzado)

Para terminar este tutorial, vamos a ver cómo hacerlo “a mano”. ¿Por qué? Porque en una entrevista técnica te pueden pedir que no uses librerías, o quizás necesitas un control muy específico que AsyncImage no te da.

Usaremos el patrón ObservableObject y Combine.

5.1 El ViewModel (ImageLoader)

import Combine

class ImageLoader: ObservableObject {
    @Published var image: UIImage? = nil
    @Published var isLoading: Bool = false
    
    private var cancellables = Set<AnyCancellable>()
    private static let cache = NSCache<NSString, UIImage>() // Caché simple en memoria

    func load(from urlString: String) {
        guard let url = URL(string: urlString) else { return }
        
        // 1. Verificar Caché
        if let cachedImage = ImageLoader.cache.object(forKey: urlString as NSString) {
            self.image = cachedImage
            return
        }
        
        self.isLoading = true
        
        // 2. Petición de Red
        URLSession.shared.dataTaskPublisher(for: url)
            .map { UIImage(data: $0.data) }
            .replaceError(with: nil)
            .receive(on: DispatchQueue.main) // Importante: Volver al hilo principal
            .sink { [weak self] loadedImage in
                self?.isLoading = false
                if let validImage = loadedImage {
                    // 3. Guardar en Caché
                    ImageLoader.cache.setObject(validImage, forKey: urlString as NSString)
                    self?.image = validImage
                }
            }
            .store(in: &cancellables)
    }
}

5.2 La Vista Personalizada

struct CustomRemoteImage: View {
    @StateObject private var loader = ImageLoader()
    let url: String

    var body: some View {
        Group {
            if loader.isLoading {
                ProgressView()
            } else if let image = loader.image {
                Image(uiImage: image)
                    .resizable()
            } else {
                Image(systemName: "photo") // Placeholder de error
            }
        }
        .onAppear {
            loader.load(from: url)
        }
    }
}

Este código te da una comprensión profunda de cómo funcionan los datos asíncronos en SwiftUI. Aunque es más “verboso” que AsyncImage, te da control total sobre la lógica de caché y la sesión de red.


Conclusión

Hemos recorrido un largo camino. Para resumir, aquí tienes mi recomendación final como desarrollador senior:

  1. Usa AsyncImage si estás empezando, si tu app es sencilla o si solo necesitas mostrar algunas imágenes estáticas (como un perfil de usuario o una cabecera de detalle). Es nativo, ligero y no requiere dependencias.
  2. Usa Kingfisher (o SDWebImage) si estás construyendo una app comercial compleja con listas largas (LazyVStack), grids, o si la experiencia de carga debe ser instantánea y persistente.
  3. Implementa tu propio Loader solo si tienes requisitos de seguridad muy estrictos (como autenticación en headers de imágenes) o por fines educativos.

Dominar la carga de imágenes es el primer paso para dominar el rendimiento en iOS. Una imagen mal optimizada puede arruinar la experiencia de usuario más bonita.

Si tienes cualquier duda sobre este artículo, contacta conmigo y estaré encantado de ayudarte 🙂. Puedes contactar conmigo en mi perfil de X o en mi perfil de Instagram

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

Mejores prácticas para crear aplicaciones en SwiftUI

Next Article

Mejores paquetes para aplicaciones en SwiftUI

Related Posts