← 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 no iOS 26: autorização, tipos de amostra e padrões multiplataforma a partir do envio de dois apps

Padrões reais de produção do Water (rastreamento de água, HKQuantitySample) e do Return (sessões mindful, HKCategorySamp…

16 min de leitura

Liquid Glass no SwiftUI: três padrões de quem lançou o Return no iOS 26

O Liquid Glass da Apple é uma API SwiftUI de uma linha. Três padrões do Return vão além do .glassEffect(): glass sobre t…

18 min de leitura

Loop Engineering: Loops Win Where Verification Is Cheap

Loop engineering, checked against Boris Cherny's full transcripts: every loop he names has cheap verification. That cons…

19 min de leitura