Swift e Objective-C

Swizzling de métodos em Objective-C: resumo completo

Swizzling de métodos é a técnica de trocar em tempo de execução a tabela que conecta seletores e IMPs. Aqui, reúno a implementação convencional, o efeito colateral de _cmd mentir e os limites de uso seguro.

5 min de leitura
Imagem de capa de Swizzling de métodos em Objective-C: resumo completo

Ao adicionar o Firebase Analytics, eventos de entrada em telas são registrados automaticamente. Mesmo sem inserir uma única linha de código em viewDidAppear.

Isso é possível graças ao swizzling de métodos (Method Swizzling). A técnica substitui toda a implementação de um método em tempo de execução. É um caso emblemático da flexibilidade do runtime de Objective-C, mas também uma faca de dois gumes que pode abrir as portas do inferno da depuração quando usada incorretamente.

Como explicado no artigo sobre objc_msgSend, uma chamada de método em Objective-C funciona buscando um IMP (ponteiro de função) pelo seletor e saltando até ele. O swizzling modifica em tempo de execução justamente essa tabela seletor→IMP.


Princípio: trocar as duas linhas da tabela

A lista de métodos de uma classe é uma tabela de mapeamento “seletor → IMP”. method_exchangeImplementations troca entre si os IMPs de duas entradas.

Seletor IMP antes da troca IMP depois da troca
viewDidAppear: Implementação original minha implementação
swz_viewDidAppear: Minha implementação implementação original

Depois da troca, quando o sistema chama viewDidAppear:, minha implementação é executada. A original não desapareceu; apenas se mudou para trás do nome swz_viewDidAppear:.

Diagrama mostrando a mudança na conexão entre seletores e IMPs antes e depois do swizzling de métodos
A implementação original não desaparece; muda-se para trás de outro seletor

Implementação convencional completa

Na prática, a forma com todos os mecanismos de segurança normalmente usados é esta.

@implementation UIViewController (Tracking)

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class cls = [self class];
        SEL originalSEL = @selector(viewDidAppear:);
        SEL swizzledSEL = @selector(swz_viewDidAppear:);
        Method original = class_getInstanceMethod(cls, originalSEL);
        Method swizzled = class_getInstanceMethod(cls, swizzledSEL);

        BOOL added = class_addMethod(cls, originalSEL,
                                     method_getImplementation(swizzled),
                                     method_getTypeEncoding(swizzled));
        if (added) {
            class_replaceMethod(cls, swizzledSEL,
                                method_getImplementation(original),
                                method_getTypeEncoding(original));
        } else {
            method_exchangeImplementations(original, swizzled);
        }
    });
}

- (void)swz_viewDidAppear:(BOOL)animated {
    [self swz_viewDidAppear:animated]; // Não é recursivo! Explicação abaixo
    NSLog(@"entrada na tela: %@", NSStringFromClass([self class]));
}

@end

Há dois pontos fáceis de confundir neste código.

Primeiro, [self swz_viewDidAppear:animated]não é recursivo. Quando este código executa, os IMPs já foram trocados, então o seletor swz_ está ligado à implementação original. Em outras palavras, esta linha é a “chamada original”. Se você omiti-la, toda a lógica de transição de tela desaparece; na prática, ela é obrigatória.

Segundo, por que não chamar method_exchangeImplementationsdiretamente e tentar class_addMethodprimeiro? Porque o método-alvo pode estar implementado apenas na superclasse, não nessa classe. Um exchange direto alteraria o método da superclasse e afetaria todas as subclasses que a herdam. Se class_addMethod for bem-sucedido, o original é “adicionado novamente a esta classe”, e a troca fica limitada com segurança à própria classe.

O motivo para usar +load e dispatch_once é simples: swizzling altera estado global e deve ocorrer uma única vez, no momento mais cedo possível do ciclo de vida do app. O momento exato de chamada de +load será tratado separadamente no próximo artigo (+load vs +initialize).


Efeito colateral silencioso: _cmd mente

Se você imprimir _cmd (o seletor atual) dentro de um método submetido a swizzling, obterá viewDidAppear: em vez de swz_viewDidAppear:. A implementação mudou, mas o seletor no caminho da chamada continua igual.

Normalmente isso não causa problemas, mas misturá-lo com código que usa _cmd como chave — por exemplo, o padrão de usar _cmd como chave de Associated Objects — pode gerar bugs sutis. Se a implementação original dependia internamente de _cmd, o comportamento pode mudar.


Até onde é aceitável usar

As situações que justificam swizzling são bastante restritas.

  • Instrumentação comum a todas as telas: quando SDKs de Analytics ou logging coletam automaticamente entradas em telas e toques em botões
  • Contorno de bugs de terceiros ou do sistema: para corrigir temporariamente o comportamento de um framework sem código-fonte disponível
  • Ferramentas de depuração para desenvolvimento: para rastrear todas as chamadas de um método específico

Por outro lado, estas situações são sinais de perigo.

  • Se várias bibliotecas fazem swizzling do mesmo método, a ordem de execução depende da ordem de carregamento; se uma omitir a chamada original, todas as outras quebram
  • Se métodos swz_ se infiltrarem no stack trace, fica difícil interpretar os relatórios de crash
  • Swizzling que depende de implementações internas do sistema pode quebrar com uma única atualização do OS

Por isso, se o problema puder ser resolvido com subclassing, proxy de delegate ou composição, essas opções sempre vêm primeiro. O swizzling deve ficar reservado como último recurso, “quando estruturalmente não houver outra alternativa”.


E no Swift?

Métodos puros de Swift usam despacho estático (ou vtable), portanto esta técnica não funciona. Para aplicar swizzling, o método-alvo precisa estar exposto ao runtime de Objective-C.

class Tracker: NSObject {
    @objc dynamic func fire() { }
}

É preciso adicionar @objc dynamic para que a chamada siga pelo caminho objc_msgSend; só então é possível substituir a tabela de conexões. As classes de UIKit são baseadas em Objective-C, então o swizzling ainda funciona, mas seu espaço diminui à medida que avançamos para o mundo de SwiftUI.

Ilustração dos riscos do swizzling de métodos, com uma carta substituída no topo de um castelo de cartas
Se você retirar a carta errada, tudo desmorona

Resumo

  • Swizzling troca em tempo de execução a tabela seletor→IMP — a implementação original não desaparece; muda-se para trás de outro seletor
  • A chamada de [self swz_...] dentro da implementação submetida a swizzling não é recursiva; é a chamada original
  • Tentamos class_addMethod primeiro para não alterar o método da superclasse
  • Usamos +load + dispatch_once para executar apenas uma vez durante o ciclo de vida do app
  • Como há risco real de _cmd inconsistência, conflitos entre bibliotecas e atualizações do OS, usamos como último recurso
  • No Swift, apenas métodos marcados com @objc dynamic podem sofrer swizzling

No próximo artigo, vamos dissecar outra magia do runtime facilmente confundida com swizzling: o isa-swizzling do KVO (Key-Value Observing). Não se trata de trocar um método, mas de substituir a própria classe.

Continue lendo