Internos do @Observable: A macro, o registrar e o que o ObservableObject errou
O framework Observation, introduzido no iOS 17 e Swift 5.9, substituiu o modelo ObservableObject baseado em Combine por um sistema orientado por macro, com rastreamento de acesso por propriedade1. A mudança parece pequena no ponto de chamada (uma macro @Observable em vez de : ObservableObject mais @Published em todo lugar), mas o comportamento em runtime é diferente de uma forma que afeta performance, correção e o caminho de migração. A mudança, em uma frase: views que não leram uma propriedade alterada não são mais reavaliadas quando essa propriedade muda.
Este post percorre os internos do framework comparando com a documentação da Apple e a proposta SE-03952. O recorte é “o que a macro realmente gera e por quê”, porque a maioria dos times adota @Observable pela sintaxe e perde a mudança estrutural na propagação de updates, que é onde o ganho real de performance (e as armadilhas da migração) vivem.
TL;DR
@Observableé uma macro Swift que expande uma classe em um tipo que adota o protocolo marcadorObservable, com uma instância_$observationRegistrar: ObservationRegistrarsintetizada como propriedade armazenada3.- O getter de cada propriedade encapsula
_$observationRegistrar.access(self, keyPath:). O setter de cada propriedade encapsula_$observationRegistrar.withMutation(of:keyPath:_:). O registrar rastreia quais escopos acessaram quais key paths. - O vocabulário de substituição:
class Foo: ObservableObjectvira@Observable class Foo.@Published var nameviravar name.@StateObject var foo = Foo()vira@State var foo = Foo().@EnvironmentObjectvira@Environment(Foo.self).@ObservedObject var foovira apenas usar a propriedade. @Bindableé o novo property wrapper para criar bindings para as propriedades de uma instância observable (substitui alguns casos de uso de@ObservedObjectpara binding).- A armadilha da migração:
@Statecom um tipo de referência se comporta de forma diferente de@StateObjectem aspectos sutis em torno da identidade da view. Apps que fazem a troca cegamente podem produzir um comportamento de inicialização confuso quando a view é reconstruída.
A expansão da macro
Quando o compilador vê @Observable, ele expande o tipo adicionando três coisas3:
@Observable
class UserProfile {
var name: String = ""
var email: String = ""
var preferences: [String] = []
}
A expansão (simplificada) gera:
class UserProfile: Observable {
@ObservationIgnored private let _$observationRegistrar = ObservationRegistrar()
private var _name: String = ""
var name: String {
get {
access(keyPath: \.name)
return _name
}
set {
withMutation(keyPath: \.name) {
_name = newValue
}
}
}
// ... mesmo padrão para email e preferences
func access<Member>(keyPath: KeyPath<UserProfile, Member>) {
_$observationRegistrar.access(self, keyPath: keyPath)
}
func withMutation<Member, T>(
keyPath: KeyPath<UserProfile, Member>,
_ mutation: () throws -> T
) rethrows -> T {
try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}
}
Três mudanças estruturais:
O registrar. Uma instância privada de ObservationRegistrar detém o estado de rastreamento. O registrar é a ponte entre as mutações no modelo e as reavaliações dos escopos dependentes. A macro o marca como @ObservationIgnored para que o próprio registrar não seja rastreado.
Reescrita do armazenamento de propriedade. Cada propriedade armazenada declarada vira um campo de respaldo privado mais uma propriedade computada cujos getter e setter chamam o registrar. Os accessors gerados pelo compilador são o que faz o rastreamento por propriedade funcionar.
Conformidade com Observable. O protocolo marcador que o API do registrar espera. O protocolo não tem requisitos; é uma verificação de conformidade, não um contrato de interface.
O trabalho do registrar
O ObservationRegistrar faz duas coisas3:
Rastrear acesso. Quando withObservationTracking { ... } onChange: { ... } (o API de rastreamento subjacente que o SwiftUI usa para corpos de view) executa o closure, o registrar registra cada par (self, keyPath) que é lido. O conjunto de paths acessados é a “pegada de dependência” do escopo.
Disparar invalidação. Quando uma propriedade é mutada, o registrar encontra cada escopo que acessou aquele keyPath específico e dispara seu closure onChange. Escopos que não acessaram o keyPath não são afetados.
O contraste com ObservableObject é a mudança estrutural. O publisher objectWillChange do ObservableObject dispara em cada mutação @Published, e todos os subscribers recebem a notificação. A maquinaria de corpo de view do SwiftUI usa o publisher para saber “algo mudou; reavaliar”. A reavaliação roda contra a view inteira; o SwiftUI então calcula quais views dependentes realmente mudaram e atualiza apenas essas, mas a reavaliação do corpo já aconteceu. Com @Observable, a própria reavaliação do corpo é controlada: se o corpo não leu a propriedade alterada, ele não é executado novamente.
Para um UserProfile com três propriedades e uma view que lê apenas name, a diferença é real: um modelo @ObservableObject aciona reavaliação do corpo também em mudanças de email e preferences; um modelo @Observable não. Em um app complexo com muitos modelos e muitas views, a economia acumulada é significativa.
Mapeamento da migração
O vocabulário da migração, lado a lado4:
| ObservableObject | @Observable |
|---|---|
class Foo: ObservableObject |
@Observable class Foo |
@Published var name: String |
var name: String |
@StateObject var foo = Foo() |
@State var foo = Foo() |
@ObservedObject var foo: Foo |
var foo: Foo (ou @Bindable var foo: Foo para bindings) |
@EnvironmentObject var foo: Foo |
@Environment(Foo.self) var foo |
.environmentObject(foo) |
.environment(foo) |
O wrapper @Bindable merece uma nota separada. É a nova forma de criar Bindings para as propriedades de uma instância @Observable:
@Bindable var profile: UserProfile
TextField("Name", text: $profile.name)
TextField("Email", text: $profile.email)
Sem @Bindable, a sintaxe $profile.name não funciona porque tipos @Observable não fornecem automaticamente projected values. Com ele, cada propriedade ganha uma forma de binding. Use @Bindable quando uma view filha precisa de binding bidirecional para o modelo observable do pai; use uma referência simples (var profile: UserProfile) quando a filha apenas lê.
A armadilha de @State vs @StateObject
A linha da migração que causa mais bugs em produção: @StateObject var foo = Foo() vira @State var foo = Foo(). A mudança compila. O comportamento diverge por um mecanismo sutil: como a expressão de valor padrão é avaliada5.
Tanto @State quanto @StateObject preservam a instância através das reconstruções de view do SwiftUI quando a identidade da view é estável; ambos têm armazenamentos de respaldo indexados por identidade que descartam reinicializações dirigidas pelo pai. A diferença está em quando a expressão do inicializador roda.
@StateObject declara seu parâmetro através de @autoclosure. A expressão inicializadora Foo() é encapsulada e só é avaliada quando o SwiftUI realmente precisa construir a instância. Em reconstruções do pai onde a identidade da view é preservada e a instância existente é reutilizada, a expressão nunca é invocada. O inicializador caro nunca é disparado.
@State não é encapsulado por autoclosure. A expressão inicializadora Foo() é avaliada avidamente toda vez que o init da view roda (o que acontece em cada reconstrução do pai, mesmo quando a identidade da view é preservada e a instância existente é mantida no armazenamento). A alocação de Foo() acontece; o SwiftUI descarta a nova instância e continua usando a armazenada. Para modelos com init() barato, a alocação desperdiçada é invisível. Para modelos com init() caro (requisições de rede, carregamento de grandes volumes de dados, trabalho assíncrono disparado no init), a diferença é a diferença entre um app que funciona e um app que faz DDoS no próprio backend a cada reconstrução do pai.
O padrão defensivo: mantenha o init() do modelo barato para que a diferença não importe, ou inicialize o modelo caro uma vez no nível do app e passe-o para baixo via .environment(). Modelos que precisam de configuração cara não devem rodar esse trabalho no init independentemente de qual property wrapper os mantém; inicialização preguiçosa ou métodos de setup explícitos são o padrão correto tanto para casos @State quanto @StateObject.
withObservationTracking para rastreamento explícito
Fora do SwiftUI, a primitiva de rastreamento é withObservationTracking { ... } onChange: { ... }6:
import Observation
let profile = UserProfile()
withObservationTracking {
print("Name: \(profile.name)")
} onChange: {
print("Something we read changed")
}
profile.name = "Alice" // Triggers onChange
profile.email = "..." // Does NOT trigger onChange (we didn't read it)
O closure roda uma vez e registra cada acesso observable. Quando qualquer uma das propriedades de origem desses acessos muda, onChange dispara exatamente uma vez (é um callback de disparo único). Para rastrear novamente, o closure precisa ser configurado de novo. O padrão é o que o SwiftUI usa internamente para rastrear dependências de corpo de view; para código fora do SwiftUI (NSWindowController, apps Cocoa, ferramentas de linha de comando), withObservationTracking é a primitiva certa.
Quando ObservableObject ainda é a escolha certa
Três casos onde ObservableObject mantém seu lugar:
Apps com target iOS 16 ou anterior. O framework Observation é iOS 17+. Apps com targets de deployment mais antigos precisam do ObservableObject. Quando o target de deployment migra para 17+, a migração é segura.
Modelos que precisam publicar notificações fora do grafo de valor. O objectWillChange do ObservableObject é um Combine publisher; código que quer assinar “qualquer mudança” através de pipelines do Combine (debouncing, throttling, transformação do stream de eventos) ganha isso de graça com ObservableObject e teria que reconstruir o equivalente com @Observable. O framework Observation prioriza eficiência de reavaliação de view sobre assinaturas arbitrárias de publisher.
Bases de código existentes onde o custo da migração supera o benefício. Uma base de código ObservableObject funcionando que não mediu um problema de performance não ganha o suficiente com a migração para justificar a auditoria. Migre quando você já está mexendo no arquivo ou quando o profiling identificar um hot spot.
Para código novo, em targets iOS 17+, @Observable é o padrão moderno e o caminho da migração é claro.
O que esse padrão significa para apps iOS 26+
Três conclusões.
-
Use
@Observablepor padrão para código novo. A macro é concisa, o rastreamento por propriedade melhora a performance para casos comuns e o vocabulário da migração é claro. Modelos novos em bases de código iOS 17+ devem ser@Observable. -
Audite migrações
@StateObject→@Statequanto à identidade da view. A troca compila limpamente, mas pode produzir reinicialização surpreendente em views com estrutura condicional. Modelos que fazem trabalho caro noinit()precisam de migração cuidadosa; modelos que não fazem são seguros. -
Use
@Bindabledeliberadamente. É o novo padrão para bindings bidirecionais em modelos observable. Recorra a ele em views filhas que precisam mutar o modelo do pai; mantenha a referência simples (var foo: Foo) para views somente leitura.
O cluster Apple Ecosystem completo: App Intents tipados; servidores MCP; a questão do roteamento; Foundation Models; a distinção runtime vs tooling LLM; três superfícies; o padrão de fonte única de verdade; Dois Servidores MCP; hooks para desenvolvimento Apple; Live Activities; o runtime watchOS; internos do SwiftUI; o modelo mental espacial do RealityKit; disciplina de schema do SwiftData; padrões Liquid Glass; shipping multi-plataforma; a matriz de plataformas; framework Vision; Symbol Effects; inferência Core ML; API Writing Tools; Swift Testing; Privacy Manifest; Acessibilidade como plataforma; tipografia SF Pro; padrões espaciais visionOS; framework Speech; migrações SwiftData; focus engine do tvOS; sobre o que me recuso a escrever. O hub está na Apple Ecosystem Series. Para um contexto mais amplo de iOS com agentes de AI, veja o iOS Agent Development guide.
FAQ
Por que a Apple substituiu o ObservableObject?
Duas razões. Primeiro, performance: o publisher objectWillChange do ObservableObject dispara em cada mutação @Published, acionando reavaliação do corpo em cada view dependente independentemente de a view realmente ler a propriedade alterada. O rastreamento por propriedade do @Observable controla a reavaliação do corpo na propriedade que a view de fato acessa. Segundo, sintaxe: a anotação @Published por propriedade e a escada @StateObject/@ObservedObject/@EnvironmentObject eram verbosas para o que conceitualmente é uma única ideia (“isto é estado mutável compartilhado”). @Observable mais @State mais @Environment é mais curto.
@Observable funciona com structs?
Não. @Observable requer semântica de referência; structs não se qualificam. A macro é para classes que detêm estado mutável entre views. Para estado de tipo de valor em uma única view, use @State diretamente com o tipo de valor.
Posso usar @Observable e ObservableObject no mesmo app?
Sim. Eles coexistem sem conflito. Uma migração pode prosseguir arquivo por arquivo. A fronteira é por tipo: uma classe é ou ObservableObject ou @Observable, não ambos, mas classes diferentes no mesmo app podem usar abordagens diferentes.
E quanto a propriedades @Published que disparam pipelines do Combine?
@Observable não fornece um equivalente Combine publisher para propriedades individuais. Código que usa padrões $foo.publisher de propriedades @Published precisa reconstruir essa assinatura de forma diferente com @Observable (por exemplo, encapsular a propriedade em um modelo de tipo de valor e observar através do ciclo de update do SwiftUI, ou usar withObservationTracking repetidamente). Para caminhos de código pesados em Combine, a migração é trabalho de engenharia real.
Como @Observable interage com @Model do SwiftData?
Tipos @Model (SwiftData) são automaticamente @Observable. O framework de persistência adiciona conformidade com Observable como parte de seu codegen, então modelos SwiftData participam do mesmo rastreamento por propriedade que tipos @Observable simples. Views que observam propriedades de um tipo @Model ganham o mesmo comportamento de reavaliação de granularidade fina. Os posts migrações SwiftData e disciplina de schema do SwiftData do cluster cobrem o lado da persistência da mesma superfície de observação.
Para que serve @ObservationIgnored?
Ele opta uma propriedade armazenada para fora do rastreamento de observação. A macro normalmente reescreve cada propriedade armazenada para passar pelo registrar; propriedades marcadas com @ObservationIgnored mantêm armazenamento direto sem rastreamento. Use-o para propriedades que não devem disparar reavaliação de view: caches, file handles, contadores de métricas, o próprio registrar.
Referências
-
Apple Developer Documentation: Observation framework. A referência do framework cobrindo o protocolo
Observablee a macro@Observable. Disponível em iOS 17+, macOS 14+, Swift 5.9+. ↩ -
Swift Evolution: SE-0395 Observability. A proposta Swift aceita com a justificativa de design, requisitos semânticos e o contrato do protocolo do registrar. ↩
-
Apple Developer Documentation:
ObservationRegistrareObservable. Os tipos de runtime aos quais a macro gera conformidade e o API do registrar que os accessors sintetizados chamam. ↩↩↩ -
Apple Developer Documentation: Migrating from the Observable Object protocol to the Observable macro. O guia de migração oficial da Apple cobrindo a tabela de mapeamento de property wrappers e mudanças de integração com SwiftUI. ↩
-
Apple Developer Documentation:
StateeStateObject. A semântica de inicialização documentada dos dois property wrappers em torno da identidade da view e do ciclo de vida de reconstrução. ↩ -
Apple Developer Documentation:
withObservationTracking(_:onChange:). A primitiva de rastreamento explícito usada fora do rastreamento automático de corpo de view do SwiftUI. ↩