Propiedades Equatable en clases @Observable: menos actualizaciones innecesarias en SwiftUI
Arturo Rivas Arias
Una de las ventajas más importantes del sistema Observation es su rendimiento. Cuando una vista lee una propiedad de un modelo marcado con @Observable, SwiftUI registra esa dependencia concreta. Si después cambia otra propiedad del mismo objeto que la vista no utiliza, su body no necesita volver a evaluarse.
Sin embargo, observar por propiedades no resuelve de por sí todos los cálculos innecesarios. También importa qué entiende Swift por «cambio». En las versiones actuales de la macro @Observable, el tipo de la propiedad determina si una asignación con el mismo valor genera una notificación. Una conformidad aparentemente sencilla como Equatable puede evitar reevaluaciones completas de una vista.
Acceder a una propiedad crea una dependencia
La macro @Observable, introducido con Swift 5.9 y disponible en los sistemas de Apple desde iOS 17, macOS 14, tvOS 17 y watchOS 10, transforma las propiedades almacenadas para registrar tanto sus lecturas como sus mutaciones.
Imaginemos una aplicación que recibe periódicamente el estado de reproducción de otro dispositivo:
import Observation
import SwiftUI
struct PlaybackSnapshot {
var title: String
var artist: String
var elapsedSeconds: Int
var isPlaying: Bool
}
@MainActor
@Observable
final class RemotePlayerModel {
var snapshot = PlaybackSnapshot(
title: "",
artist: "",
elapsedSeconds: 0,
isPlaying: false
)
func receive(_ newSnapshot: PlaybackSnapshot) {
snapshot = newSnapshot
}
}
Una vista que lea model.snapshot queda vinculada a esa propiedad durante la evaluación de su body:
struct NowPlayingView: View {
let model: RemotePlayerModel
var body: some View {
VStack(alignment: .leading) {
Text(model.snapshot.title)
.font(.headline)
Text(model.snapshot.artist)
.foregroundStyle(.secondary)
ProgressView(
value: Double(model.snapshot.elapsedSeconds),
total: 300
)
}
}
}
SwiftUI no observa el objeto como una única caja opaca. Registra las propiedades consultadas mientras ejecuta body y utiliza esa información para decidir qué vistas debe invalidar cuando el modelo notifica una mutación. Este seguimiento granular es una diferencia esencial respecto a ObservableObject, donde objectWillChange anuncia cambios para el objeto completo.
Asignar no siempre significa cambiar
En el ejemplo anterior, PlaybackSnapshot no conforma a Equatable. Si una conexión remota entrega varias veces exactamente los mismos datos, cada llamada a receive(_:) se considera una posible mutación. Observation no dispone de una operación genérica con la que comparar dos valores arbitrarios, por lo que adopta la opción segura: notificar.
El resultado no implica necesariamente que todos los píxeles se vuelvan a dibujar. SwiftUI invalida la vista dependiente, vuelve a evaluar su body y reconcilia el resultado con la jerarquía existente. Aun así, esa evaluación, la creación de valores intermedios, el cálculo del layout y otras operaciones asociadas pueden acumular un coste considerable cuando las actualizaciones son frecuentes.
La solución puede ser tan sencilla como añadir la conformidad:
struct PlaybackSnapshot: Equatable {
var title: String
var artist: String
var elapsedSeconds: Int
var isPlaying: Bool
}
Como todas las propiedades almacenadas también son Equatable, el compilador sintetiza el método ==. A partir de ese momento, el setter generado por @Observable puede comparar el valor almacenado con el nuevo. Si ambos son iguales, realiza la asignación, pero no registra una mutación ante los observadores.
Lo que genera realmente el macro
Las macros no añaden un comportamiento invisible en tiempo de ejecución: generan código Swift durante la compilación. La implementación actual de @Observable incorpora varias sobrecargas equivalentes a estas, simplificadas para facilitar su lectura:
private nonisolated func shouldNotifyObservers<Member>(
_ oldValue: Member,
_ newValue: Member
) -> Bool {
// ...
}
private nonisolated func shouldNotifyObservers<Member: Equatable>(
_ oldValue: Member,
_ newValue: Member
) -> Bool {
// ...
}
private nonisolated func shouldNotifyObservers<Member: AnyObject>(
_ oldValue: Member,
_ newValue: Member
) -> Bool {
// ...
}
private nonisolated func shouldNotifyObservers<Member: Equatable & AnyObject>(
_ oldValue: Member,
_ newValue: Member
) -> Bool {
// ...
}
Esta selección ocurre en tiempo de compilación según el tipo de la propiedad:
- Un valor sin
Equatablenotifica con cada asignación. - Un valor
Equatablesolo notifica sioldValue != newValue. - Una instancia de una clase no
Equatablesolo notifica si cambia su identidad. - Una instancia de una clase
Equatableutiliza su igualdad por valor.
El setter generado tiene, de forma aproximada, esta estructura:
set {
guard shouldNotifyObservers(_snapshot, newValue) else {
_snapshot = newValue
return
}
withMutation(keyPath: \.snapshot) {
_snapshot = newValue
}
}
Hay un matiz importante: una asignación considerada igual no se elimina. El nuevo valor se escribe igualmente en el almacenamiento, pero la operación se realiza sin avisar a Observation. De este modo se preservan efectos propios del lenguaje, incluidos los observadores didSet, sin provocar una invalidación innecesaria de las vistas.
Este código generado es un detalle de implementación y puede evolucionar. No conviene llamar directamente a esas funciones ni depender de sus firmas; lo relevante para el código de aplicación es proporcionar una semántica de igualdad correcta.
Las colecciones también se benefician
Los tipos estándar String, Int, Bool, Date o UUID ya conforman a Equatable. Además, colecciones como Array, Set y Dictionary obtienen esa conformidad cuando sus elementos —y sus claves o valores, según corresponda— también son comparables.
struct Album: Identifiable, Equatable {
let id: UUID
var title: String
var artist: String
var isDownloaded: Bool
}
@MainActor
@Observable
final class MusicLibraryModel {
var albums: [Album] = []
func replaceAlbums(with latest: [Album]) {
albums = latest
}
}
Si latest contiene los mismos álbumes, con los mismos valores y en el mismo orden, la asignación no notifica a las vistas que hayan leído albums. Sin la conformidad de Album, [Album] tampoco es Equatable y cada respuesta del servidor invalida esas vistas, aunque su contenido efectivo sea idéntico.
La igualdad de un array también incluye el orden. Dos colecciones con los mismos elementos en posiciones diferentes no son iguales y deben provocar una actualización, algo especialmente relevante en un List, donde el orden forma parte de la interfaz.
Asignar una colección no es lo mismo que mutarla
La optimización de igualdad se aplica al setter, es decir, cuando se asigna un valor completo a la propiedad. Las mutaciones realizadas directamente sobre una colección utilizan el método _modify generado por la macro y registran la mutación sin hacer una comparación previa.
// Pasa por el setter y puede omitir la notificación si los arrays son iguales.
model.albums = response.albums
// Usa el acceso de modificación y registra una mutación.
model.albums.append(newAlbum)
// También registra una mutación aunque el array ya estuviera ordenado.
model.albums.sort { $0.title < $1.title }
Esta diferencia es deliberada. Comparar una colección antes de cada modificación podría convertir una operación barata en otra lineal y forzar trabajo adicional relacionado con su almacenamiento copy-on-write. Además, en operaciones como append, lo normal es que el resultado sea realmente distinto.
Si existe una posibilidad elevada de que una transformación al vuelo no cambie nada y las vistas dependientes son costosas, se puede calcular el resultado en una variable independiente y asignarlo después:
func sortAlbumsIfNeeded() {
let sortedAlbums = albums.sorted { $0.title < $1.title }
albums = sortedAlbums
}
En este caso, la última línea sí pasa por el setter y la conformidad Equatable permite omitir la notificación cuando el orden ya era el correcto. No es una regla para aplicar a todas las colecciones: primero hay que valorar si el coste de comparar compensa el trabajo que se evita.
Equatable tampoco es gratis
En una estructura pequeña, comparar sus propiedades suele ser muy «barato». En una colección grande, el peor caso puede requerir recorrer todos sus elementos. Si los valores cambian casi siempre, se ejecuta la comparación y después se realiza igualmente la actualización de SwiftUI.
La optimización tiene más sentido cuando coinciden varias condiciones:
- El modelo recibe datos repetidos mediante polling, callbacks, streams o sincronización en tiempo real.
- Una proporción significativa de las asignaciones contiene el mismo estado.
- Las vistas dependientes tienen un
body, un layout o una jerarquía relativamente costosos. - La igualdad puede calcularse de forma más barata que el trabajo de interfaz que evita.
Por el contrario, añadir Equatable por defecto a modelos enormes no garantiza una mejora. Apple insiste en medir los problemas de rendimiento y, desde Xcode 26, la plantilla de SwiftUI en Instruments permite inspeccionar la cantidad y duración de las actualizaciones de body. Es la herramienta adecuada para confirmar si el cambio reduce trabajo real en una pantalla concreta.
Durante el desarrollo también puede resultar útil Self._printChanges() para conocer por qué SwiftUI está reevaluando una vista:
struct NowPlayingView: View {
let model: RemotePlayerModel
var body: some View {
let _ = Self._printChanges()
Text(model.snapshot.title)
}
}
Al tratarse de una API cuyo nombre comienza por guion bajo, debe reservarse para diagnóstico y no formar parte de la lógica de producción.
La igualdad debe describir el estado de verdad
Swift puede sintetizar Equatable para una estructura cuando todas sus propiedades también conforman al protocolo. Esta implementación suele ser la opción más segura porque incluye todo el estado almacenado. Una implementación manual permite ignorar ciertos campos, pero puede crear errores muy difíciles de detectar.
struct DownloadState: Equatable {
var fileName: String
var completedBytes: Int64
var totalBytes: Int64
static func == (lhs: Self, rhs: Self) -> Bool {
// ❌ Si una vista muestra el progreso, ignorar completedBytes
// puede impedir que la interfaz reciba actualizaciones necesarias.
lhs.fileName == rhs.fileName &&
lhs.totalBytes == rhs.totalBytes
}
}
Si completedBytes cambia pero == continúa devolviendo true, Observation no notificará a las vistas que muestran el progreso. La interfaz puede quedarse congelada aunque el modelo sí haya recibido el valor nuevo. El problema no estaría en SwiftUI, sino en una definición de igualdad que afirma que dos estados visualmente distintos son equivalentes.
Cuando existen metadatos que cambian con frecuencia pero no forman parte de la interfaz, suele ser preferible separarlos en otra propiedad o marcarlos con @ObservationIgnored si no deben formar parte en el seguimiento. Falsear == para obtener una mejora de rendimiento rompe el contrato de Equatable y puede afectar también a tests, colecciones, algoritmos y cualquier otro consumidor del tipo.
Una optimización pequeña con consecuencias importantes
La precisión de Observation tiene dos niveles. Primero, SwiftUI registra qué propiedades lee cada vista. Después, el setter generado decide si una nueva asignación constituye un cambio observable. Equatable permite que ese segundo nivel distinga entre recibir un valor y recibir un estado realmente diferente.
En modelos alimentados por datos externos, conformar los valores propios a Equatable puede reducir de manera notable las reevaluaciones redundantes. Para valores simples suele ser una mejora prácticamente sin coste; para colecciones grandes debe decidirse a partir de su frecuencia de actualización, el porcentaje de valores repetidos y mediciones reales con Instruments.
La regla práctica es clara: define igualdad cuando el tipo tenga una semántica de igualdad honesta, aprovecha la asignación completa cuando quieras que el setter pueda comparar y no confundas una invalidación de SwiftUI con un redibujado completo de la pantalla. Con esas condiciones, Equatable se convierte en una pieza útil del modelo de observación y no únicamente en un protocolo para escribir tests o comparar valores.