← Todos os Posts

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 marcador Observable, com uma instância _$observationRegistrar: ObservationRegistrar sintetizada 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: ObservableObject vira @Observable class Foo. @Published var name vira var name. @StateObject var foo = Foo() vira @State var foo = Foo(). @EnvironmentObject vira @Environment(Foo.self). @ObservedObject var foo vira 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 @ObservedObject para binding).
  • A armadilha da migração: @State com um tipo de referência se comporta de forma diferente de @StateObject em 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.

  1. Use @Observable por 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.

  2. Audite migrações @StateObject@State quanto à identidade da view. A troca compila limpamente, mas pode produzir reinicialização surpreendente em views com estrutura condicional. Modelos que fazem trabalho caro no init() precisam de migração cuidadosa; modelos que não fazem são seguros.

  3. Use @Bindable deliberadamente. É 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


  1. Apple Developer Documentation: Observation framework. A referência do framework cobrindo o protocolo Observable e a macro @Observable. Disponível em iOS 17+, macOS 14+, Swift 5.9+. 

  2. Swift Evolution: SE-0395 Observability. A proposta Swift aceita com a justificativa de design, requisitos semânticos e o contrato do protocolo do registrar. 

  3. Apple Developer Documentation: ObservationRegistrar e Observable. Os tipos de runtime aos quais a macro gera conformidade e o API do registrar que os accessors sintetizados chamam. 

  4. 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. 

  5. Apple Developer Documentation: State e StateObject. 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. 

  6. Apple Developer Documentation: withObservationTracking(_:onChange:). A primitiva de rastreamento explícito usada fora do rastreamento automático de corpo de view do SwiftUI. 

Artigos relacionados

HealthKit + SwiftUI on iOS 26: Authorization, Sample Types, and Cross-Platform Patterns

Real production patterns from Water (water tracking, HKQuantitySample) and Return (mindful sessions, HKCategorySample). …

17 min de leitura

Liquid Glass in SwiftUI: Three Patterns From Shipping Return on iOS 26

Apple's Liquid Glass is a one-line SwiftUI API. Three patterns from Return go beyond .glassEffect(): glass on text via C…

19 min de leitura

The Cleanup Layer Is the Real AI Agent Market

Charlie Labs pivoted from building agents to cleaning up after them. The AI agent market is moving from generation to pr…

15 min de leitura