Author: Byte-Mobile Technology — Duan Wenbin

preface

As is known to all, accurate recommendation is inseparable from a large number of buried points. The common buried point collection scheme is to carry out buried points on the path that responds to user behavior operation. However, because apps usually have multiple interfaces and operation paths, the maintenance cost of active buried points will be very large. So the industry approach is no-buried, and no-buried implementations require AOP programming.

A common scenario is that you want to record time stamps at the time a UIViewController appears and disappears to count how long the page is displayed. There are many ways to achieve this goal, but AOP is far and away the simplest and most effective. There are many different ways to Hook in Objective-C. Here is an example using Method Swizzle.

@interface UIViewController (MyHook) @end @implementation UIViewController (MyHook) + (void)load { static dispatch_once_t onceToken; SwizzleMethods (self, @selector(viewDidAppear:), @selector(my_viewDidAppear:)); /// more Hook}); } - (void)my_viewDidAppear:(BOOL) my_viewDidAppear:(BOOL)animated {// [self my_viewDidAppear: animated]; } @endCopy the code

Let’s explore a specific scenario:

A UICollectionView, or UITableView, is a very common list UI component in iOS, where the click event callback for a list element is done via a delegate. Here in UICollectionView, for example, UICollectionView delegate, a method statement, collectionView: didSelectItemAtIndexPath:, implement this method, we can add click event to list elements.

Our goal is to Hook the delegate method to perform additional buried operations when a callback is clicked.

Scheme iteration

Scheme 1 Method Swizzle

In general, Method Swizzle meets most of your AOP programming needs. Therefore, in the first iteration, we directly used Method Swizzle to Hook.

@interface UICollectionView (MyHook) @end @implementation UICollectionView (MyHook) // Hook, SetMyDelegate: and setDelegate: swapped - (void)setMyDelegate:(id)delegate {if (delegate! = nil) {/ / / conventional Method Swizzle swizzleMethodsXXX (delegate, @ the selector (collectionView: didSelectItemAtIndexPath:), the self, @selector(my_collectionView:didSelectItemAtIndexPath:)); } [self setMyDelegate:nil]; } - (void)my_collectionView:(UICollectionView *)ccollectionView didSelectItemAtIndexPath:(NSIndexPath *)index { /// // the implementation of the Hook method is already the original method. [self my_collectionView:ccollectionView didSelectItemAtIndexPath:index]; } @endCopy the code

We integrated this solution into toutiao App for testing and verification, but found it impossible to pass the verification.

The main reason is that Toutiao App is a huge project, which introduces a lot of tripartite libraries, such as IGListKit, etc. These tripartite libraries usually encapsulate the use of UICollectionView, and these encapsulation, That’s why we can’t Hook the delegate using the regular Method Swizzle. The direct reasons are summarized as follows:

  1. setDelegateThe object passed in is not an implementationUICollectionViewDelegateThe object of the agreement

As shown, setDelegate is passed to a proxy object, proxy refers to the actual delegate that implements the UICollectionViewDelegate protocol, The proxy does not actually implement any of the methods of the UICollectionViewDelegate; it forwards all methods to the actual delegate. In this case, we cannot Swizzle the proxy directly

  1. Many timessetDelegate

In the example above, the user calls the setDelegate twice in a row, the first time as a real delegate and the second time as a proxy. We need to treat it differently.

The proxy mode and NSProxy are introduced

The proxy mode is used to proxy the original object and then invoke the original object after processing additional operations. In Objective-C, for proxy mode, NSProxy is more efficient. Refer to the following articles for details.

  • The proxy pattern
  • NSProxy use

The setDelegate of a UICollectionView passing in a proxy is a very common operation, such as IGListKit, and the App may also encapsulate this layer based on its own needs.

In the UICollectionView’s setDelegate, wrap the delegate in the proxy, set the proxy to the UICollectionView, and use the proxy to forward messages to the delegate.

Scheme 2 uses proxy mode

Scenario 1 was no longer enough for our needs, and we figured since proxying a delegate is a routine operation, why not use the proxy mode and proxying the proxy again.

Code implementation

  • First the hooksUICollectionViewthesetDelegatemethods
  • The agentdelegate

The simple code is shown below

@interface DelegateProxy: NSProxy @property (nonatomic, weak, readOnly) id target; @ the end / / / for CollectionView delegate forwarding message proxy @ interface BDCollectionViewDelegateProxy: DelegateProxy @end @implementation BDCollectionViewDelegateProxy <UICollectionViewDelegate> - (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath { //track event here if ([self.target respondsToSelector:@selector(collectionView:didSelectItemAtIndexPath:)]) { [self.target collectionView:collectionView didSelectItemAtIndexPath:indexPath]; } } - (BOOL)bd_isCollectionViewTrackerDecorator { return YES; } - (BOOL)respondsToSelector:(SEL)aSelector {if (aSelector == @selector(bd_isCollectionViewTrackerDecorator)) { return YES; } return [self.target respondsToSelector:aSelector]; } @end @interface UICollectionView (MyHook) @end @implementation UICollectionView (MyHook) - (void) setDd_TrackerProxy:(BDCollectionViewDelegateProxy *)object { objc_setAssociatedObject(self, @selector(bd_TrackerProxy), object, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } - (BDCollectionViewDelegateProxy *) bd_TrackerProxy { BDCollectionViewDelegateProxy *bridge = objc_getAssociatedObject(self, @selector(bd_TrackerProxy)); return bridge; } // Hook, SetMyDelegate: and setDelegate: swapped - (void)setMyDelegate:(id)delegate {if (delegate == nil) {[self setMyDelegate:delegate]; Return} // will not be released, Don't repeat Settings if ([delegate respondsToSelector: @ the selector (bd_isCollectionViewTrackerDecorator)]) {[self setMyDelegate: delegate];  return; } BDCollectionViewDelegateProxy *proxy = [[BDCollectionViewDelegateProxy alloc] initWithTarget:delegate]; [self setMyDelegate:proxy]; self.bd_TrackerProxy = proxy; } @endCopy the code

model

In the following figure, solid lines represent strong references and dotted lines represent weak references.

Is a

If the consumer does not delegate, and we use proxy mode

  • UICollectionView, itsdelegatePointer to DelegateProxy
  • DelegateProxy, which is strongly referenced by UICollectionView as runtime, weakly refers to the real Delegate by its target

Case 2

If the consumer also proxies the delegate, we use proxy mode

  • We just need to ensure that our DelegateProxy is part of the chain of proxies

From this we can see that the proxy pattern is very extensible and allows for continuous nesting of the chain of agents, as long as we all follow the principles of the proxy pattern.

So far, our solution has been tested and passed on toutiao App. But the story is far from over.

Hit the pit

At present, it can be compared, but it can not completely avoid the problem. This is not just a delegate for the UICollectionView, it includes:

  • UIWebView
  • WKWebView
  • UITableView
  • UICollectionView
  • UIScrollView
  • UIActionSheet
  • UIAlertView

We all Hook in the same way. At the same time, we package the scheme into an SDK to provide externally, collectively referred to as MySDK below.

First step in the hole

After a customer connected to our solution, Crash was reported during the integration process. The following is a detailed introduction of this experience.

The stack information

Key information is [UIWebView webView: decidePolicyForNavigationAction: request: frame: decisionListener:].

Thread 0 Crashed: 0 libobjc.A.dylib 0x000000018198443c objc_msgSend + 28 1 UIKit 0x000000018be05b4c -[UIWebView webView:decidePolicyForNavigationAction:request:frame:decisionListener:] + 200 2 CoreFoundation 0x0000000182731cd0 __invoking___ + 144 3 CoreFoundation 0x000000018261056c -[NSInvocation invoke] + 292 4 CoreFoundation 0x000000018261501c  -[NSInvocation invokeWithTarget:] + 60 5 WebKitLegacy 0x000000018b86d654 -[_WebSafeForwarder forwardInvocation:] + 156Copy the code

UIWebView’s delegate pointer is a wild pointer.

Here, I first explain the direct cause of the crash, and then make a specific analysis of why the problem occurred.

  1. MySDK hooks the setDelegate
  2. The client also hooks the setDelegate
  3. First the MySDK Hook logic call is executed, then the client Hook logic call is executed

Client Hook code

@interface UIWebView (JSBridge) @end @implementation UIWebView (JSBridge) - (void)setJsBridge:(id)object { objc_setAssociatedObject(self, @selector(jsBridge), object, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } - (WebViewJavascriptBridge *)jsBridge { WebViewJavascriptBridge *bridge = objc_getAssociatedObject(self, @selector(jsBridge)); return bridge; } + (void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ swizzleMethods(self, @selector(setDelegate:), @selector(setJSBridgeDelegate:)); swizzleMethods(self, @selector(initWithFrame:), @selector(initJSWithFrame:)); }); } - (instancetype)initJSWithFrame:(CGRect)frame { self = [self initJSWithFrame:frame]; if (self) { WebViewJavascriptBridge *bridge = [WebViewJavascriptBridge bridgeForWebView:self]; [self setJsBridge:bridge]; } return self; } /// webview.delegate = XXX will be called multiple times and pass different objects - (void)setJSBridgeDelegate:(id)delegate {WebViewJavascriptBridge *bridge = self.jsBridge; if (delegate == nil || bridge == nil) { [self setJSBridgeDelegate:delegate]; } else if (bridge == delegate) { [self setJSBridgeDelegate:delegate]; } else {/// for the first entry pass bridge /// for the second entry pass a delegate if (! [delegate isKindOfClass:[WebViewJavascriptBridge class]]) { [bridge setWebViewDelegate:delegate]; / / / the following this line of code is a customer of / / / fix with this [self setJSBridgeDelegate: bridge]; } else { [self setJSBridgeDelegate:delegate]; } } } @endCopy the code

MySDK Hook code

@interface UIWebView (MyHook) @end @implementation UIWebView (MyHook) // Hook, SetWebViewDelegate: and setDelegate: swapped - (void)setWebViewDelegate:(id)delegate {if (delegate == nil) {[self setWebViewDelegate:delegate]; } BDWebViewDelegateProxy *proxy = [[BDWebViewDelegateProxy alloc] initWithTarget:delegate]; self.bd_TrackerDecorator = proxy; [self setWebViewDelegate:proxy]; } @endCopy the code

Cause of wild pointer

UIWebView calls the setDelegate method twice, the first time passing a WebViewJavascriptBridge, and the second time passing another actual WebViewDelegate. Let’s just say the first time you pass a bridge and the second time you pass an actual delegate.

  1. The first time MySDK Hook is called, the bridge is wrapped with DelegateProxy, all methods are forwarded to the bridge via DelegateProxy, here to DelegateProxysetJSBridgeDelegate:(id)delegateDelegate is actually DelegateProxyRather than a bridge,.

Note that UIWebView delegate pointing to DelegateProxy is set by the client, and the assign property is not weak. This assign is critical, assigin does not automatically become nil after the object is freed.

  1. The second call to MySDK Hook wraps the delegate with a new DelegateProxy WebViewDelegate, and MySDK’s logic is to place the new DelegateProxy in a strong reference, The old DelegateProxy loses the strong reference and is therefore released.

If no processing is done, the current state is as shown in the figure below:

  • Delegate points to freed DelegateProxy, wild pointer
  • UIWebview triggers a callback and crashes

Repair methods

SetJSBridgeDelegate :(id)delegate after determining that the delegate is not a bridge, set the UIWebView’s delegate to bridge.

Fix with this the next line of code in the comment

The repaired model is shown below

conclusion

The use of Proxy can also solve certain problems, but users need to follow certain specifications, be aware of third-party SDKS may also setDelegate Hook, may also use Proxy

Step in the hole for the second time

Let’s start with some references

  • RxCocoa source code reference github.com/ReactiveX/R…
  • – DelegateProxy rxcocoa learning

RxCocoa also uses the proxy mode to delegate, which should not be a problem. But the implementation of RxCocoa is a bit different.

RxCocoa

If only the RxCocoa scheme was used, and it was consistent with the scheme, there would be no problem.

RxCocoa+MySDK

After RxCocoa+MySDK, it looks like this. UICollectionView’s Delegate is directly referred to after the setDelegate method is called.

The theory should be fine, just a poxy wrapper around the chain of references. But there are really two problems.

Question 1

The delegate method of RxCocoa hits Assert

// UIScrollView+Rx.swift
extension Reactive where Base: UIScrollView {
    public var delegate: DelegateProxy<UIScrollView.UIScrollViewDelegate> {
        return RxScrollViewDelegateProxy.proxy(for: base)
        // Base can be understood as a UIScrollView instance}}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)
          	// hit the assert line below
            assert(self._currentDelegate(for: object) = = = proxy)
            assert(delegateProxy._forwardToDelegate() = = = currentDelegate)
        }
        return delegateProxy
    }
}
Copy the code

The key logic

  • Even if delegateProxy RxDelegateProxy
  • CurrentDelegate is the object pointed to by RxDelegateProxy
  • Rxdelegateproxy. _setForwardToDelegate Points RxDelegateProxy to a real Delegate
  • The first line in red executes by calling the setDelegate method and setting RxDelegateProxy’s proxy to UIScrollView(which is an instance of UICollectionView)
  • We then go to MySDK’s Hook method and wrap RxDelegateProxy into a layer
  • The end result is shown below
  • Then cause self._CurrentDelegate (for: Object) to be DelegateProxy instead of RxDelegateProxy, triggering the red-label assertion

This assertion is a bully, equivalent to RxCocoa thinking that it is the only one that can wrap a delegate with a Proxy, and that no one else can, so as long as it does, assert.

Further analysis

  • The current state

  • Way to get into Rx again
    • CurrentDelegate is the DelegateProxy that UICollectionView points to (wrapper for MySDK)
    • DelegateProxy points to RxDelegateProxy again
    • If Rx is triggered,Rx switches its pointing to the real Delegate to the DelegateProxy that UICollectionView points to
    • The loop points, and the real Delegate in the reference chain is lost

Question 2

As mentioned above, multiple calls lead to circular pointing, which leads to an infinite loop when the actual method is forwarded.

Responds code

open class RxScrollViewDelegateProxy {
    override open func responds(to aSelector: Selector!). -> Bool {
        return super.responds(to: aSelector)
            || (self._forwardToDelegate?.responds(to: aSelector) ?? false)
            || (self.voidDelegateMethodsContain(aSelector) && self.hasObservers(selector: aSelector))
        }
}
Copy the code
@implementation BDCollectionViewDelegateProxy

- (BOOL)respondsToSelector:(SEL)aSelector {
    if (aSelector == @selector(bd_isCollectionViewTrackerDecorator)) {
        return YES;
    }
    return [super respondsToSelector:aSelector];
}

@end
Copy the code

Seems to be ok as long as you don’t call it many times, right?

The key is that Rx’s setDelegate method also calls the GET method, causing a get to trigger a second call. Multiple calls are unavoidable.

The solution

The cause of the problem is quite obvious. If the code of RxCocoa is reformed and the third-party hooks are taken into account, the problem can be completely solved.

Solution 1

Referring to MySDK’s proxy schema, add a special method to the proxy to determine if RxDelegateProxy is already in the reference chain without actively changing the reference chain.

open class RxScrollViewDelegateProxy {
    public static func proxy(for object: ParentObject) -> Self {
        .
        let currentDelegate = self._currentDelegate(for: object)
        let delegateProxy: Self = castOrFatalError(proxy)
        //if currentDelegate ! == delegateProxy
        if !currentDelegate.responds(to: xxxMethod) {
            delegateProxy._setForwardToDelegate(currentDelegate, retainDelegate: false)
            assert(delegateProxy._forwardToDelegate() = = = currentDelegate)
            self._setCurrentDelegate(proxy, to: object)
            assert(self._currentDelegate(for: object) = = = proxy)
            assert(delegateProxy._forwardToDelegate() = = = currentDelegate)
        } else {
            return currentDelegate
        }

        return delegateProxy
    }

}
Copy the code

Modifications like this can solve the problem. We talked to the Rx team and offered PR, but they turned us down. Rx explains that Hook is not an elegant way. It does not recommend any method of Hook system and does not want to be compatible with any third party Hook.

Solution 2

Is it possible that RxCocoa doesn’t change the code to MySDK for compatibility?

As I mentioned, there could be two states.

  • State 1
    • SetDelegate, advanced Rx method, advanced MySDK Hook method,
    • The delegate to Rx
    • The pass to MySDK is RxDelegateProxy
    • Delegate get calls trigger bugs

  • State 2
    • SetDelegate, advanced MySDK Hook method, advanced Rx method?
    • The DelegateProxy passed to Rx

In fact, if it is state 2, it seems that the Rxcocoa bug will not be repeated.

But take a closer look at the setDelegate code in Rxcocoa

extension Reactive where Base: UIScrollView {
    public func setDelegate(_ delegate: UIScrollViewDelegate)

    -> Disposable {
        return RxScrollViewDelegateProxy.installForwardDelegate(delegate, retainDelegate: false, onProxyForObject: self.base)
    }
}

open class RxScrollViewDelegateProxy {
    public static func installForwardDelegate(_ forwardDelegate: Delegate.retainDelegate: Bool.onProxyForObject object: ParentObject) -> Disposable {
        weak var weakForwardDelegate: AnyObject? = forwardDelegate as AnyObject
        let proxy = self.proxy(for: object)
        assert(proxy._forwardToDelegate() = = = nil."")
        proxy.setForwardToDelegate(forwardDelegate, retainDelegate: retainDelegate)
        return Disposables.create {
            .}}}Copy the code

Emmm? Rx: UICollectionView’s setDelegate and Delegate get methods are not hooks…

collectionView.rx.setDelegate(delegate)

let delegate = collectionView.rx.delegate
Copy the code

The final process has to be

  • When setDelegate, pass Rx’s methods to Rx’s real delegate
  • Backward MySDK Hook method
  • The pass to MySDK is RxDelegateProxy
  • Rx gets the CollectionView’s delegate trigger
  • Delegate get calls trigger bugs

If MySDK still uses the current Hook scheme, it cannot be solved in MySDK.

Solution 3

The Rx method invocation is achieved by rewriting RxDelegateProxy’s forwardInvocation, i.e

  • RxDelegateProxy is not implementedUICollectionViewDelegateAny method of
  • ForwardInvocation processUICollectionViewDelegateThe callback

Review the message forwarding mechanism

We can be treated in forwardingTargetForSelector this step, so that we can avoid the conflict associated with Rx, processed to skip again.

  • ForwardingTargetForSelector to delegate callback, the target returns a SDK processing class, than DelegateProxy
  • When the DelegateProxy invocation is complete, call the forwardInvocation method that jumps to RxDelegateProxy

This solution is not perfect, and can only temporarily avoid conflicts with Rx. Problems can also arise if other SDKS subsequently handle Hook conflicts at this stage.

conclusion

Indeed, as the Rx team describes it, hooks are not very elegant, and any Hook can have compatibility issues.

  1. Be careful with hooks
  2. Hook system interface must follow certain specifications, can not assume that only you in the Hook interface
  3. Don’t assume what others will do, just integrate multiple scenarios, build multiple scenarios, test compatibility

The schemes listed in this article may not be complete or perfect, if there are better schemes, welcome to discuss.

Reference documentation

  • NSProxy use
  • The proxy pattern
  • – DelegateProxy rxcocoa learning
  • Github.com/ReactiveX/R…

About the Byte Mobile Platform team

Client Infrastructure, bytedance’s mobile platform team, is an industry leader in big front-end Infrastructure technology. It is responsible for the construction of big front-end Infrastructure in Bytedance’s China region to improve the performance, stability and engineering efficiency of the company’s entire product line. The supported products include but are not limited to Douyin, Toutiao, Watermelon Video, Huoshan Video, etc., and have in-depth research on mobile terminal, Web, Desktop and other terminals.

Now! Client/front-end/server/side intelligent algorithm/test development for global recruitment! Let’s change the world with technology. If you are interested, please contact [email protected] with the subject line resume – Name – Job intention – desired city – Phone number.