Skip to content

Instantly share code, notes, and snippets.

@iby
Last active August 8, 2024 11:55
Show Gist options
  • Save iby/7db7dad3fc428a54b61838e022ceafe2 to your computer and use it in GitHub Desktop.
Save iby/7db7dad3fc428a54b61838e022ceafe2 to your computer and use it in GitHub Desktop.
Research notes and playground for ISA-swizzling breaking KVO and causing exceptions during observer removal / observee deinitialization when using ReactiveCocoa on macOS 10.15 and above, see full discussion: https://github.com/ReactiveCocoa/ReactiveCocoa/issues/3690

This experiment was born out of ReactiveCocoa#3690 – a homeopathy to fix swizzling with more swizzling.

Ideally, there would be a way to simply intercept the call with a custom handler to add some logic. However, that's far from simple:

  • Need to handle existing class methods (add vs. replace).
  • Need to call original implementation and pass it parameters – no way of doing it in Swift in some cases (via message forwarding), no way of doing it reliably in Objective-C in others (via va_list)…

In theory, where my research stops, it's possible to achieve this via scenario:

  1. Add a block implementation under a random non-clashing selector.
  2. Add a message forwarding like ReactiveCocoa does.

Swizzling

Swizzling is a very broad term and there are several approaches:

  • Global behavior changes: method_exchangeImplementations for swapping method implementations globally.
  • Specific behavior changes: method_setImplementation for replacing specific method implementations.
  • Dynamic method addition: class_addMethod for adding methods at runtime.
  • Dynamic class changes: ISA swizzling for altering an instance’s class.
  • Message redirection: forwardingTargetForSelector: for redirecting messages efficiently.
  • Complex message handling: forwardInvocation: for detailed control over message forwarding.
  • Dynamic method resolution: resolveInstanceMethod: for resolving methods at runtime.
  • Modular changes: Category method swizzling for making changes in a modular way.

Method swizzling with method_exchangeImplementations

Exchange the implementations of two methods. Can be used for instance and class methods to modify behavior globally in a safe manner.

Method originalMethod = class_getInstanceMethod([MyClass class], @selector(originalMethod));
Method swizzledMethod = class_getInstanceMethod([MyClass class], @selector(swizzledMethod));
method_exchangeImplementations(originalMethod, swizzledMethod);

Method implementation replacing with method_setImplementation

Replace the implementation of a method directly. Can be used to replace a method with a new implementation, often for specific, non-reversible changes.

Method originalMethod = class_getInstanceMethod([MyClass class], @selector(originalMethod));
IMP newIMP = imp_implementationWithBlock(^(id _self) {
    // New implementation…
});
method_setImplementation(originalMethod, newIMP);

Method adding with class_addMethod

Add a new method to a class dynamically. Can be used to add a non-existing method to a class, for example, to handle dynamic behavior or to conform to a protocol.

void newMethodIMP(id self, SEL _cmd) {
    // New implementation…
}
class_addMethod([MyClass class], @selector(newMethod), (IMP)newMethodIMP, "v@:");

ISA swizzling

Change the ISA pointer of an instance to point to a different class. The ISA pointer is a key part of the Objective-C runtime that tells the system what class an object is an instance of. Used for dynamically altering the class of an instance at runtime, commonly used in proxy and KVO implementations.

object_setClass(instance, [NewClass class]);

Message forwarding with forwardingTargetForSelector:

Forward messages to another object by overriding forwardingTargetForSelector: for redirecting messages to another object.

- (id)forwardingTargetForSelector:(SEL)aSelector {
    if (aSelector == @selector(methodToForward)) {
        return anotherObject;
    }
    return [super forwardingTargetForSelector:aSelector];
}

Message forwarding with forwardInvocation:

Override forwardInvocation: to manually forward messages to different objects or handle them for more control over message forwarding.

- (void)forwardInvocation:(NSInvocation *)anInvocation {
    if ([anotherObject respondsToSelector:[anInvocation selector]]) {
        [anInvocation invokeWithTarget:anotherObject];
    } else {
        [super forwardInvocation:anInvocation];
    }
}

Method resolution using resolveInstanceMethod: and resolveClassMethod:

Dynamically add method implementations during runtime for dynamically adding methods that aren't available at compile-time.

+ (BOOL)resolveInstanceMethod:(SEL)sel {
    if (sel == @selector(dynamicMethod)) {
        class_addMethod([self class], sel, (IMP)dynamicMethodIMP, "v@:");
        return YES;
    }
    return [super resolveInstanceMethod:sel];
}

Category method swizzling

Use categories to override methods. Can be used for modular code changes and to avoid touching original class implementations directly.

@implementation MyClass (Swizzling)
+ (void)load {
    Method originalMethod = class_getInstanceMethod(self, @selector(originalMethod));
    Method swizzledMethod = class_getInstanceMethod(self, @selector(swizzledMethod));
    method_exchangeImplementations(originalMethod, swizzledMethod);
}
@end

Notes

ReactiveCocoa

They use ISA-swizzling – a technique that involves changing the ISA pointer of an object to change the class of an object at runtime. If I understand correctly, this is what allows per-instance swizzling without affecting the actual class… and what causes KVO issues.

They have a bunch of really cool Objective-C API usage:

Articles

GitHub

import Foundation
import AppKit
protocol Interceptor: NSObject {}
extension NSObject: Interceptor {}
extension Interceptor where Self: NSObject {
static func intercept(selector oldSelector: Selector, with newSelector: Selector) {
guard let originalMethod = class_getInstanceMethod(self, oldSelector) else { fatalError() }
guard let swizzledMethod = class_getInstanceMethod(self, newSelector) else { fatalError() }
guard let originalEncoding = method_getTypeEncoding(originalMethod) else { fatalError() }
guard let swizzledEncoding = method_getTypeEncoding(swizzledMethod) else { fatalError() }
// Verify that both selectors are compatible – just in case…
precondition(String(cString: originalEncoding) == String(cString: swizzledEncoding))
if class_addMethod(self, oldSelector, method_getImplementation(swizzledMethod), swizzledEncoding) {
class_replaceMethod(self, newSelector, method_getImplementation(originalMethod), originalEncoding)
} else {
method_exchangeImplementations(originalMethod, swizzledMethod)
}
}
/// Intercepts the specified selector invocation by adding / replacing its method implementation. The interception
/// handling is defined by the specified the block that takes the current method implementation and its selector,
/// and returns a `@convention(block)` that becomes the new implementation.
///
/// IMPORTANT: The `Implementation` and `Method` signatures must match – there's no safe way to check them and,
/// unfortunately, generic params can't be used with Objective-C functions.
///
/// typealias Implementation = @convention(c) (Unmanaged<NSObject>, Selector, Arg1, Arg2, Arg3, ...) -> T
/// typealias Method = @convention(block) (Unmanaged<NSObject>, Arg1, Arg2, Arg3, ...) -> T
/// self.intercept(selector: #selector(...), with: { (implementation: Implementation) -> Method in
/// { reference, arg1, arg2, arg3, ... in
/// print("Intercepted before:", reference.takeUnretainedValue())
/// implementation(reference, selector, observer, keyPath, options, context)
/// // Custom after…
/// }
/// })
static func intercept<Implementation, Method>(selector: Selector, with block: (Implementation) -> Method) {
guard let method = class_getInstanceMethod(self, selector) else { fatalError() }
guard let encoding = method_getTypeEncoding(method) else { fatalError() }
let oldImplementation = method_getImplementation(method)
let newImplementation = imp_implementationWithBlock(block(unsafeBitCast(oldImplementation, to: Implementation.self)))
if !class_addMethod(self, selector, newImplementation, encoding) {
method_setImplementation(method, newImplementation)
}
}
static func intercept(selector: Selector, with block: @escaping (Self) -> Void) {
typealias Implementation = @convention(c) (Unmanaged<NSObject>, Selector, NSObject, String, NSKeyValueObservingOptions, UnsafeMutableRawPointer?) -> Void
typealias Method = @convention(block) (Unmanaged<NSObject>, NSObject, String, NSKeyValueObservingOptions, UnsafeMutableRawPointer?) -> Void
self.intercept(selector: selector, with: { (implementation: Implementation) -> Method in
{ reference, a1, a2, a3, a4 in
block(reference.takeUnretainedValue() as! Self)
implementation(reference, selector, a1, a2, a3, a4)
}
})
}
}
class A: NSView {}
class B: A {}
class C: B {
/// It's important to test overriding as it's usually one of the trickiest part to make work correctly with swizzling…
override func addObserver(_ observer: NSObject, forKeyPath keyPath: String, options: NSKeyValueObservingOptions = [], context: UnsafeMutableRawPointer?) {
print(">>> override in C")
super.addObserver(observer, forKeyPath: keyPath, options: options, context: context)
}
}
extension A {
@objc func swizzled_myMethodA(_ observer: NSObject, forKeyPath keyPath: String, options: NSKeyValueObservingOptions = [], context: UnsafeMutableRawPointer?) {
print("... swizzled in A (selector)")
self.swizzled_myMethodA(observer, forKeyPath: keyPath, options: options, context: context)
}
}
extension B {
@objc func swizzled_myMethodB(_ observer: NSObject, forKeyPath keyPath: String, options: NSKeyValueObservingOptions = [], context: UnsafeMutableRawPointer?) {
print("... swizzled in B (selector)")
self.swizzled_myMethodB(observer, forKeyPath: keyPath, options: options, context: context)
}
}
extension C {
@objc func swizzled_myMethodC(_ observer: NSObject, forKeyPath keyPath: String, options: NSKeyValueObservingOptions = [], context: UnsafeMutableRawPointer?) {
print("... swizzled in C (selector)")
self.swizzled_myMethodC(observer, forKeyPath: keyPath, options: options, context: context)
}
}
// A.intercept(selector: #selector(A.addObserver(_:forKeyPath:options:context:)), with: #selector(A.swizzled_myMethodA(_:forKeyPath:options:context:)))
// B.intercept(selector: #selector(B.addObserver(_:forKeyPath:options:context:)), with: #selector(B.swizzled_myMethodB(_:forKeyPath:options:context:)))
// C.intercept(selector: #selector(C.addObserver(_:forKeyPath:options:context:)), with: #selector(C.swizzled_myMethodC(_:forKeyPath:options:context:)))
A.intercept(selector: #selector(A.addObserver(_:forKeyPath:options:context:)), with: { _ in print("... swizzled in A (block)") })
B.intercept(selector: #selector(B.addObserver(_:forKeyPath:options:context:)), with: { _ in print("... swizzled in B (block)") })
C.intercept(selector: #selector(C.addObserver(_:forKeyPath:options:context:)), with: { _ in print("... swizzled in C (block)") })
autoreleasepool {
class KVO: NSObject {
static let context = UnsafeMutableRawPointer.allocate(byteCount: 1, alignment: 0)
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
print("!!! observed value:", keyPath!, change!)
}
}
let kvo = KVO()
let a = A()
let b = B()
let c = C()
a.addObserver(kvo, forKeyPath: #keyPath(NSView.isHidden), options: .new, context: KVO.context)
a.isHidden.toggle()
print("-----")
b.addObserver(kvo, forKeyPath: #keyPath(NSView.isHidden), options: .new, context: KVO.context)
b.isHidden.toggle()
print("-----")
c.addObserver(kvo, forKeyPath: #keyPath(NSView.isHidden), options: .new, context: KVO.context)
c.isHidden.toggle()
print("-----")
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment