Swift y Objective-C

Swizzling de métodos en Objective-C (Method Swizzling): guía completa

El swizzling de métodos es una técnica que intercambia en tiempo de ejecución la tabla que conecta selectores e IMP. Aquí se cubren la implementación convencional completa, el efecto secundario de que _cmd mienta y los límites de un uso aceptable.

5 min de lectura
Imagen de portada de Swizzling de métodos en Objective-C (Method Swizzling): guía completa

Al añadir Firebase Analytics, los eventos de entrada a pantalla se registran automáticamente. Aunque no añadimos ni una línea de código en viewDidAppear.

Esto es posible gracias a Method Swizzling. Es una técnica que reemplaza por completo la implementación de un método en tiempo de ejecución: un caso representativo de la flexibilidad del runtime de Objective-C, pero también un arma de doble filo que puede abrir las puertas del infierno del debugging si se usa mal.

Como se explicó en el artículo sobre objc_msgSend, una llamada a un método de Objective-C consiste en «buscar un IMP (puntero a función) mediante un selector y saltar a él». El swizzling modifica en tiempo de ejecución esta tabla de correspondencias selector→IMP.


Principio: intercambiar dos filas de la tabla

La lista de métodos de una clase es una tabla de correspondencias «selector → IMP». method_exchangeImplementationsintercambia los IMP de dos entradas.

Selector IMP antes del intercambio IMP después del intercambio
viewDidAppear: Implementación original Mi implementación
swz_viewDidAppear: Mi implementación Implementación original

Después del intercambio, cuando el sistema llama a viewDidAppear:, se ejecuta mi implementación. La original no ha desaparecido; solo se ha mudado detrás del nombre swz_viewDidAppear:.

Diagrama que muestra el cambio de las conexiones entre selectores e IMP antes y después del swizzling
La implementación original no desaparece; se muda detrás de otro selector

Implementación convencional completa

Así queda la implementación habitual en producción, con todas las medidas de seguridad incluidas.

@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]; // ¡No es recursión! Explicación abajo
    NSLog(@"Entrada a pantalla: %@", NSStringFromClass([self class]));
}

@end

En este código hay dos puntos fáciles de confundir.

Primero, [self swz_viewDidAppear:animated]no es recursión. Cuando se ejecuta este código, los IMP ya se han intercambiado, así que el selector swz_ está conectado a la implementación original. En otras palabras, esta línea es la «llamada original». Si se omite, desaparece toda la lógica de transición de pantalla, por lo que es prácticamente obligatoria.

Segundo, ¿por qué probar primero class_addMethod en lugar de llamar directamente a method_exchangeImplementations? Porque el método objetivo puede estar implementado solo en la clase padre, no en esa clase. Hacer exchange directamente modificaría el método de la clase padre y afectaría a todas las demás subclases que heredan de ella. Si class_addMethod tiene éxito, se ha «añadido una nueva implementación original a esta clase», por lo que el intercambio queda limitado de forma segura a la propia clase.

El motivo para usar +load y dispatch_once es sencillo. El swizzling cambia el estado global, así que debe ejecutarse una sola vez, lo antes posible durante la vida de la app. El momento exacto de invocación de +load se trata por separado en el siguiente artículo (+load vs +initialize).


Efecto secundario silencioso: _cmd miente

Si imprimes _cmd (el selector actual) dentro de un método con swizzling, aparece swz_viewDidAppear: en lugar de viewDidAppear:. La implementación ha cambiado, pero el selector de la ruta de llamada permanece igual.

Normalmente no hay problema, pero al combinarlo con código que usa _cmd como clave (por ejemplo, el patrón de usar _cmd como clave de Associated Objects), aparece un bug sutil. Si la implementación original dependía internamente de _cmd, el comportamiento puede cambiar.


Hasta dónde es seguro usarlo

El ámbito en el que el swizzling está justificado es bastante reducido.

  • Instrumentación común a todas las pantallas: cuando un SDK de Analytics o logging recopila automáticamente entradas a pantalla y toques en botones
  • Evasión de errores de terceros o del sistema: al corregir temporalmente el comportamiento de un framework cuyo código fuente no está disponible
  • Herramientas de depuración para desarrollo: al querer rastrear todas las llamadas a un método concreto

En cambio, estas situaciones son señales de alarma.

  • Si varias bibliotecas hacen swizzling del mismo método, el orden de ejecución depende del orden de carga, y si una omite la llamada original, todo lo demás se viene abajo
  • Los métodos swz_ se cuelan en el stack trace y dificultan interpretar los informes de crash
  • El swizzling que depende de implementaciones internas del sistema puede romperse con una sola actualización del sistema operativo

Por eso, si el problema se puede resolver mediante subclasificación, un proxy de delegado o composición, esas opciones deben ir siempre primero. El swizzling debe reservarse como último recurso cuando «otros métodos sean estructuralmente imposibles».


¿Y en Swift?

Los métodos Swift puros usan despacho estático (o una vtable), así que esta técnica no funciona. Para aplicar swizzling, el método objetivo debe estar expuesto al runtime de Objective-C.

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

Debe añadirse @objc dynamic para que la llamada siga la ruta objc_msgSend y así poder reemplazar la tabla de correspondencias. Las clases de UIKit se basan en Objective-C, por lo que el swizzling sigue funcionando, pero cuanto más nos acercamos al mundo SwiftUI, menos espacio tiene esta técnica.

Ilustración de los riesgos del swizzling de métodos, con una tarjeta reemplazada en una pila de tarjetas de código
Si sacas una tarjeta equivocada, todo el conjunto se tambalea

Resumen

  • El swizzling intercambia en tiempo de ejecución la tabla selector→IMP; la implementación original no desaparece, sino que se muda detrás de otro selector
  • La llamada [self swz_...] dentro de la implementación con swizzling no es recursión, sino la llamada original
  • Se prueba primero class_addMethod para no tocar el método de la clase padre
  • Usa +load + dispatch_once para ejecutarlo una sola vez durante la vida de la app
  • Como las inconsistencias de _cmd, los conflictos entre bibliotecas y el riesgo de actualizaciones del sistema operativo son reales, úsalo solo como último recurso
  • En Swift, solo se pueden aplicar swizzling a los métodos marcados con @objc dynamic

En el siguiente artículo analizaremos otra magia del runtime que se confunde fácilmente con el swizzling: el isa-swizzling de KVO (Key-Value Observing). Se trata de reemplazar la clase completa, no un método.

Seguir leyendo