Skip to content

Instantly share code, notes, and snippets.

@iby
Last active August 8, 2024 11:55
Show Gist options
  • Select an option

  • Save iby/7db7dad3fc428a54b61838e022ceafe2 to your computer and use it in GitHub Desktop.

Select an option

Save iby/7db7dad3fc428a54b61838e022ceafe2 to your computer and use it in GitHub Desktop.

Revisions

  1. iby renamed this gist Aug 8, 2024. 1 changed file with 0 additions and 0 deletions.
    File renamed without changes.
  2. iby created this gist Aug 8, 2024.
    164 changes: 164 additions & 0 deletions README.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,164 @@
    This experiment was born out of [ReactiveCocoa#3690](https://github.com/ReactiveCocoa/ReactiveCocoa/issues/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.

    ```objective-c
    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.

    ```objective-c
    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.

    ```objective-c
    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.
    ```objective-c
    object_setClass(instance, [NewClass class]);
    ```


    ### Message forwarding with `forwardingTargetForSelector:`

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

    ```objective-c
    - (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.

    ```objective-c
    - (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.

    ```objective-c
    + (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.

    ```objective-c
    @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](https://github.com/ReactiveCocoa/ReactiveCocoa/issues/3690#issuecomment-642693035).
    They have a bunch of really cool Objective-C API usage:
    - https://github.com/ReactiveCocoa/ReactiveCocoa/tree/master/ReactiveCocoa
    - https://github.com/ReactiveCocoa/ReactiveCocoa/blob/master/ReactiveCocoa/ObjC+Constants.swift
    - https://github.com/ReactiveCocoa/ReactiveCocoa/blob/master/ReactiveCocoa/NSObject+Intercepting.swift
    ## Articles
    - **Samuel Défago**: [Yet another article about method swizzling](https://defagos.github.io/yet_another_article_about_method_swizzling)
    - https://gist.github.com/defagos/1312fec96b48540efa5c
    - https://github.com/defagos/CoconutKit/blob/3.0/CoconutKit-tests/Sources/Core/HLSRuntimeTestCase.m#L1275-L1368
    - **Peter Steinberger**: [Swizzling in Swift](https://pspdfkit.com/blog/2019/swizzling-in-swift)
    - https://gist.github.com/steipete/1d308fad786399b58875cd12e4b9bba2
    - https://x.com/steipete/status/1092725774254309376
    - **Mike Ash**: [objc_msgSend's New Prototype](https://mikeash.com/pyblog/objc_msgsends-new-prototype.html) – also see comments…
    - **Daniel Jalkut**: [Casting Objective-C Message Sends](https://indiestack.com/2019/10/casting-objective-c-message-sends) – follow up on Mike's article…
    - **Dong ZHENG**: [A Look Under the Hood of objc_msgSend()](https://blog.zhengdong.me/2013/07/18/a-look-under-the-hood-of-objc-msgsend)
    ## GitHub
    - https://github.com/steipete/Aspects
    - https://github.com/steipete/InterposeKit
    - https://github.com/rabovik/RSSwizzle
    - https://github.com/rbaumbach/Swizzlean
    - https://github.com/DavidGoldman/InspectiveC
    - https://github.com/mikeash/MAZeroingWeakRef (from **StackOverflow**: [My isa-swizzling breaks KVO](https://stackoverflow.com/a/14717767/458356) )
    123 changes: 123 additions & 0 deletions main.swift
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,123 @@
    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("-----")
    }