Rendimiento en Swift: Instruments, Time Profiler, Allocations y optimización de memoria

La mayoría de problemas de rendimiento en apps Swift no vienen del lenguaje, sino de patrones de uso incorrectos que solo se revelan cuando la app está bajo carga real. Instruments es la herramienta de análisis de rendimiento de Apple: integrado en Xcode, permite medir con precisión qué funciones consumen tiempo de CPU, qué objetos acumulan memoria y dónde están los cuellos de botella reales, sin suposiciones.

Cuándo optimizar

La regla clásica sigue siendo válida: primero haz que funcione, luego mide, luego optimiza solo donde los datos señalan. Optimizar sin medir es tan probable que empeore el rendimiento como que lo mejore, y siempre produce código más difícil de mantener.

Las señales de que es el momento de medir:

  • El usuario reporta lentitud al hacer scroll o al navegar
  • La batería se agota más rápido de lo esperado
  • La app consume memoria que no libera (crash por memoria)
  • Las animaciones no llegan a 60fps (o 120fps en ProMotion)

Time Profiler: localizar funciones lentas

Time Profiler muestrea el stack de llamadas periódicamente (cada millisegundo por defecto) y muestra qué funciones ocupan más tiempo:

  1. Xcode > Product > Profile (Cmd+I)
  2. Selecciona "Time Profiler"
  3. Pulsa Record y reproduce el escenario lento
  4. Para y busca las funciones más pesadas en el flame chart
// Ejemplo: filtrado lento en el hilo principal
struct ListaView: View {
    let items: [Item]
    let busqueda: String

    // ? Cálculo pesado en la propiedad computada de la vista
    var itemsFiltrados: [Item] {
        items.filter { item in
            // Operación cara en cada renderizado
            item.texto.localizedCaseInsensitiveContains(busqueda) &&
            calcularPuntuacionCompleja(item) > 0.5
        }
    }
}

// Solución: cachear el resultado con @State o en el ViewModel
@Observable
class ListaViewModel {
    var items: [Item] = []
    var busqueda: String = "" {
        didSet { actualizarFiltro() }
    }
    private(set) var itemsFiltrados: [Item] = []

    private func actualizarFiltro() {
        // Ejecutar el filtrado en un task para no bloquear el hilo principal
        Task.detached(priority: .userInitiated) { [items, busqueda] in
            let filtrados = items.filter { item in
                item.texto.localizedCaseInsensitiveContains(busqueda) &&
                self.calcularPuntuacion(item) > 0.5
            }
            await MainActor.run { self.itemsFiltrados = filtrados }
        }
    }
}

Allocations: detectar memory leaks

El instrumento Allocations muestra qué objetos se están creando, cuántos hay vivos en cada momento y cuáles no se liberan:

  1. Profile > Allocations
  2. Usa la app durante varios minutos
  3. Activa "Mark Generation" antes y después de un ciclo de navegación
  4. Los objetos que persisten entre generaciones son candidatos a leak
// Leak clásico: retain cycle en closure
class DescargaManager {
    var callback: (() -> Void)?

    func iniciar() {
        URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
            // [weak self] evita el retain cycle
            self?.procesarDatos(data)
            self?.callback?() // callback también necesita [weak self] si captura self
        }.resume()
    }
}

// Leak en NotificationCenter (olvidar removeObserver)
class MiViewController: UIViewController {
    private var observador: NSObjectProtocol?

    override func viewDidLoad() {
        super.viewDidLoad()
        observador = NotificationCenter.default.addObserver(
            forName: .algoOcurrido, object: nil, queue: .main
        ) { [weak self] _ in
            self?.actualizar()
        }
    }

    deinit {
        // Sin esto, el ViewController nunca se libera
        if let obs = observador {
            NotificationCenter.default.removeObserver(obs)
        }
    }
}

Optimización con structs y value types

// ? Clase para datos simples que se copian constantemente
class Punto {
    var x: Double
    var y: Double
    init(x: Double, y: Double) { self.x = x; self.y = y }
}

// Punto se copiaría en el heap con overhead de reference counting
var puntos = [Punto(x: 1, y: 2), Punto(x: 3, y: 4)]

// Struct: almacenado en stack, sin ARC, sin overhead de heap
struct Punto {
    var x: Double
    var y: Double
}
// Especialmente importante para arrays de miles de elementos
var puntos = [Punto(x: 1, y: 2), Punto(x: 3, y: 4)]

Copy-on-Write: evitar copias innecesarias

Swift implementa copy-on-write para Arrays, Dictionaries y Strings: la copia real solo ocurre cuando modificas la copia. Pero si modificas dentro de un bucle de forma ineficiente, puedes forzar copias:

var grande = Array(0..<100_000)

// ? Cada append puede causar reallocation si la capacidad se supera
var resultado: [Int] = []
for i in 0..<10_000 {
    resultado.append(i * 2)
}

// Solución: reservar capacidad de antemano
var resultado: [Int] = []
resultado.reserveCapacity(10_000)
for i in 0..<10_000 {
    resultado.append(i * 2)
}

// O usar map (más idiomático y evita el problema)
let resultado = (0..<10_000).map { $0 * 2 }

@inlinable y optimizaciones del compilador

// Para funciones genéricas pequeñas que se llaman en bucles tight:
@inlinable
public func cuadrado<T: Numeric>(_ x: T) -> T {
    x * x
}

// El compilador puede inline este código en el llamador,
// eliminando el overhead de la llamada a función

// Desactivar verificaciones de seguridad en código numérico crítico
// (¡solo si has verificado que los rangos son seguros!)
func sumaRapida(_ array: [Int]) -> Int {
    var suma = 0
    for i in array.indices {
        suma &+= array[i] // &+ es suma que ignora overflow
    }
    return suma
}

SwiftUI: optimizar reconstrucciones de vista

// ? ForEach sin identificadores estables fuerza reconstrucción de todas las celdas
ForEach(items.indices, id: .self) { idx in
    ItemView(item: items[idx])
}

// Usa identificadores del modelo
ForEach(items) { item in // item.id se usa como identificador
    ItemView(item: item)
}

// Evitar vistas computadas que crean nuevas instancias en cada render
struct ListView: View {
    var items: [Item]

    // ? Crea una nueva función en cada renderizado
    var body: some View {
        List(items, id: .id) { item in
            Text(item.nombre)
                .onTapGesture {
                    // Esta closure se recrea en cada render
                    seleccionar(item)
                }
        }
    }
}

// Para listas grandes, usa LazyVStack dentro de ScrollView
// en lugar de List cuando no necesitas las funcionalidades de List
ScrollView {
    LazyVStack(spacing: 8) {
        ForEach(items) { ItemRow(item: $0) }
    }
}

Midiendo mejoras con XCTest

class RendimientoTests: XCTestCase {
    func testRenderizadoLista() {
        let items = (0..<1000).map { Item(id: $0, nombre: "Item ($0)") }

        measure {
            // El bloque se ejecuta 10 veces y calcula la media
            _ = items.filter { $0.nombre.contains("5") }
        }
    }
}

Resumen

El flujo correcto de optimización es: medir con Instruments > identificar el cuello de botella real > aplicar la solución > medir de nuevo para confirmar la mejora. Time Profiler localiza funciones lentas, Allocations detecta leaks y retención excesiva. En código Swift, los patrones más impactantes son mover trabajo pesado fuera del hilo principal, usar structs para datos sin identidad, reservar capacidad en arrays y estabilizar los identificadores de vistas SwiftUI.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP