Mobile Development 17 min read

Hooking iOS Delegates with Method Swizzling and Proxy Patterns: Challenges, Crash Analysis, and Compatibility Solutions

This article explores using AOP techniques such as Method Swizzle and NSProxy‑based delegate proxies to instrument iOS view controllers and collection view delegates, analyzes crashes caused by multiple setDelegate hooks—including interactions with RxCocoa—and proposes several practical solutions to ensure stable hooking in complex app environments.

ByteDance Terminal Technology
ByteDance Terminal Technology
ByteDance Terminal Technology
Hooking iOS Delegates with Method Swizzling and Proxy Patterns: Challenges, Crash Analysis, and Compatibility Solutions

Introduction

ByteDance's recommendation system relies on extensive event tracking; manual instrumentation is costly, so a no‑instrumentation approach using AOP is needed. The article demonstrates hooking UIViewController lifecycle and UICollectionView delegate methods via Objective‑C method swizzling.

1. Solution Iterations

1.1 Method Swizzle

The first iteration directly swaps viewDidAppear: and collectionView:didSelectItemAtIndexPath: implementations. Integration in the Toutiao app fails because third‑party libraries (e.g., IGListKit) wrap UICollectionView and its delegate, preventing the swizzle from reaching the real delegate.

@interface UIViewController (MyHook)
@end

@implementation UIViewController (MyHook)
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        swizzleMethods(self, @selector(viewDidAppear:), @selector(my_viewDidAppear:));
    });
}
- (void)my_viewDidAppear:(BOOL)animated {
    // custom logic
    [self my_viewDidAppear:animated]; // call original implementation
}
@end

1.2 Proxy Pattern and NSProxy

To avoid the limitations of direct swizzling, a proxy pattern using NSProxy is introduced. The proxy forwards messages to the original delegate after performing extra tracking logic. References to external articles on proxy pattern and NSProxy usage are provided.

1.3 Using Proxy for Hooking

A concrete DelegateProxy for UICollectionView is implemented. It records selection events and forwards them to the original delegate, storing the proxy with associated objects to keep a strong reference.

/// Complete wrapper for common message forwarding
@interface DelegateProxy : NSProxy
@property (nonatomic, weak, readonly) id target;
@end

@interface BDCollectionViewDelegateProxy : DelegateProxy
@end

@implementation BDCollectionViewDelegateProxy
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
    // track event here
    if ([self.target respondsToSelector:@selector(collectionView:didSelectItemAtIndexPath:)]) {
        [self.target collectionView:collectionView didSelectItemAtIndexPath:indexPath];
    }
}
- (BOOL)respondsToSelector:(SEL)aSelector {
    if (aSelector == @selector(bd_isCollectionViewTrackerDecorator)) {
        return YES;
    }
    return [super respondsToSelector:aSelector];
}
@end

@implementation UICollectionView (MyHook)
- (void)setMyDelegate:(id)delegate {
    if (delegate == nil) { [self setMyDelegate:delegate]; return; }
    if ([delegate respondsToSelector:@selector(bd_isCollectionViewTrackerDecorator)]) { [self setMyDelegate:delegate]; return; }
    BDCollectionViewDelegateProxy *proxy = [[BDCollectionViewDelegateProxy alloc] initWithTarget:delegate];
    [self setMyDelegate:proxy];
    self.bd_TrackerProxy = proxy;
}
@end

2. Pitfalls

2.1 First Crash

A crash occurs when UIWebView delegate is an assign property. Both MySDK and a client hook setDelegate: , leading to a dangling pointer because the delegate ends up pointing to a released proxy.

Thread 0 Crashed:
0   libobjc.A.dylib   objc_msgSend + 28
1   UIKit             -[UIWebView webView:decidePolicyForNavigationAction:request:frame:decisionListener:] + 200
2   CoreFoundation    __invoking___ + 144
3   CoreFoundation    -[NSInvocation invoke] + 292
...

The analysis shows two setDelegate calls: the first receives a bridge object, the second receives the real delegate. The first proxy is weakly referenced and released, leaving the assign delegate pointing to a deallocated object.

2.2 Interaction with RxCocoa

When RxCocoa also creates a delegate proxy, its internal assertions fail because Rx expects to be the sole proxy. Detailed examination of Rx's delegate‑proxy creation, the installForwardDelegate method, and how MySDK’s proxy interferes reveals a conflict that triggers Rx’s assert statements.

open class RxScrollViewDelegateProxy {
    public static func proxy(for object: ParentObject) -> Self {
        let maybeProxy = self.assignedProxy(for: object)
        let proxy: AnyObject
        if let existingProxy = maybeProxy {
            proxy = existingProxy
        } else {
            proxy = castOrFatalError(self.createProxy(for: object))
            self.assignProxy(proxy, toObject: object)
            assert(self.assignedProxy(for: object) === proxy)
        }
        let currentDelegate = self._currentDelegate(for: object)
        let delegateProxy: Self = castOrFatalError(proxy)
        if currentDelegate !== delegateProxy {
            delegateProxy._setForwardToDelegate(currentDelegate, retainDelegate: false)
            assert(delegateProxy._forwardToDelegate() === currentDelegate)
            self._setCurrentDelegate(proxy, to: object)
            assert(self._currentDelegate(for: object) === proxy)
        }
        return delegateProxy
    }
}

extension Reactive where Base: UIScrollView {
    public var delegate: DelegateProxy
{
        return RxScrollViewDelegateProxy.proxy(for: base)
    }
    public func setDelegate(_ delegate: UIScrollViewDelegate) -> Disposable {
        return RxScrollViewDelegateProxy.installForwardDelegate(delegate, retainDelegate: false, onProxyForObject: self.base)
    }
}

Because MySDK wraps the Rx proxy with its own DelegateProxy , Rx’s _currentDelegate no longer matches the expected proxy, causing the assertion to fire and leading to a circular reference that can dead‑lock the message forwarding.

2.3 Solutions

Three possible remedies are discussed:

Modify RxCocoa: Detect an existing third‑party proxy and avoid re‑wrapping it.

Adapt MySDK: Add a special method to the proxy to recognise an Rx proxy and skip additional wrapping.

Forwarding‑target approach: Perform tracking in forwardingTargetForSelector before Rx’s proxy gets a chance to intercept, thereby bypassing the conflict.

3. Conclusion

Hooking system interfaces is fragile. Developers should use hooks cautiously, enforce strict conventions for delegate handling, and thoroughly test compatibility with other SDKs to avoid crashes and undefined behaviour.

iOSaopHookingMethod SwizzlingDelegate ProxyNSProxyRxCocoa
ByteDance Terminal Technology
Written by

ByteDance Terminal Technology

Official account of ByteDance Terminal Technology, sharing technical insights and team updates.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.