A44e154b62567e8f95452aa4692a2561
iOS 上修改私有方法的几种方式解析

面试过程中,看到应聘者简历上有熟悉或精通 Runtime、AOP 等描述时,就会针对性地问一些修改私有方法的东西来了解应聘者的掌握程度,但大多数人只能回答到基于 mattt 博客中推荐的 Method Swizzling 的方式,且并不完全理解为什么这么做,部分应聘者甚至从未阅读过 mattt 解析的原文,对于其他 AspectFishhook 更是了解甚少。因而,一直有梳理下这部分知识点的想法,帮自己巩固基础的同时,也和同行一起分享探讨自己关于 iOS 上修改私有方法的这几种方式的理解。本文更偏向于一片总结性的梳理类文章,旨在让大家能对如何修改私有类方法这个问题有一个较清晰的回答思路,在一些技术细节上就不会做太多挖掘,但会提供一些质量较高的解析文章供大家深入学习,共勉。

通常,我们修改一个类私有方法的方式有以下四种:

  • 通过创建 Category 复写,这种方式简单而粗暴,在面试中往往会被遗忘,但它有什么明显的副作用,而且为什么在 Category 中复写就能覆盖原方法实现,也很值得深究;
  • 通过 void method_exchangeImplementations(Method m1, Method m2) 等类似方式实现 Method Swizzling,可能由于 mattt 的强大社区影响力这种方式现在已经“烂大街”了,绝大多数开发者都会多少有所了解,但这种“烂大街”的写法是为什么这么写的,仍然值得探讨;
  • 通过第三方框架 Aspects 来修改私有方法,对于项目内有强 AOP 编程需求的很可能会选择这个方式,那么 Aspects 相对于普通的 Method Swizzling 有什么区别,它又是怎么实现交换后的还原呢,这点也想和大家分享;
  • Fishhook 这种方式本不该拿到这里来讨论,相对于前面三种方式都是针对 OC 这个语言的动态性来做文章的,Fishhook 则是对 C 语言方法进行了 Hook,它在逆向技术中占据了很大的作用。OC 的消息转发机制是依赖于 C 函数 objc_msgSend ,那么在一些特殊需求需要直接 Hook C 函数的话,Fishhook 就会派上用场了,这里也会对 Fishhook 如何实现 Hook C 语言方法作一些简介;

下文会分别针对以上四种方式进行解析,并且尽可能回答上面提出的几个问题。

通过 Category 复写

这种方式并不会在项目中被使用,大家可能都从不同渠道听“前辈”或者同行强调过 - “不要试图通过在 Category 中复写一个方法来实现修改私有方法的目的”。那么这里主要从两个问题谈起:

  • 使用 Category 复写有什么副作用?
  • 复写之后,原方法去哪儿了?

副作用

谈副作用之前,我们先再看一遍苹果对 Category 的推荐场景是什么。从 Category 的介绍文档 中我们可以看到,苹果建议的使用场景有这三种:

  • 为现有的类添加方法;
  • 为你相对复杂的类拆分代码;
  • 声明私有方法 - Extension 的使用;

显然通过 Category 直接复写私有方法,也并不是苹果推荐的使用方式。苹果也强调过在 Category 添加方法的时候,要避免方法与私有方法重名。

Because the methods declared in a category are added to an existing class, you need to be very careful about method names.

If the name of a method declared in a category is the same as a method in the original class, or a method in another category on the same class (or even a superclass), the behavior is undefined as to which method implementation is used at runtime. This is less likely to be an issue if you’re using categories with your own classes, but can cause problems when using categories to add methods to standard Cocoa or Cocoa Touch classes.

直接通过 Category 复写私有方法的副作用在于以下几点:

  • 不能直接调用 super,除了特殊的 + load 方法外,其他分类中的方法,在被调用之前,并不会去调用原始方法,这将导致外部在使用这个类的时候,永远调用不到原始的实现,尤其是一些系统类内部互相调用的情况,将会无法预估;
  • 当项目中存在多个分类复写了同一个私有方法后,最终调用哪个分类中的方法将由编译顺序所决定,这又充满了不确定性,因为分类自身并不知道这个私有方法是否已经被其他分类所复写;
  • 一旦通过分类复写私有方法,它的影响将会是全局的,所有用到这个类的地方,都将变成分类调用;

原方法去哪儿了

关于原方法去哪儿了这个问题,其实与为什么通过分类复写就能直接改写原类私有方法实现是类似的问题,答案都在 OC Runtime 对于 Category 的处理上。先说结论好了,原方法还在那里(试试自己 Log 一下 class_copyMethodList),并没有去哪里了,而我们之所以调用不到,只是分类的方法先被 Runtime 查找到了而已,也就是说,通过分类复写后,实际上该类的 method list 里存在至少两个相同 SEL 的 method。具体是什么样子的呢?App 在启动后,会通过 _objc_init -> dyld_register_image_state_change_handler -> map_images -> _read_images 这样的顺序来加载 OC 类的,并最终通过 attachCategoryMethodsattachMethodLists 把类的原方法和分类中的方法一起添加的 Method List 中,由于分类中的方法是后加的,而原类方法是先加的(这和 + load 方法被调用的顺序是一致的),这直接导致了,方法调用时,Runtime 最先会找到的是分类中的方法。

需要深挖这部分的同学,推荐美团技术团队的:深入理解Objective-C:Category 中『4、追本溯源-category如何加载』这个小节

Method Swizzling

前文提到的 Category 直接复写修改这种方式是不推荐的,那么 Method Swizzling 这个方式则是现在很普遍的一种手段了。先看看我们现在网上最常见,也是被使用最广的一种写法是什么样子的(摘自 mattt 的博客):

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class class = [self class];

        SEL originalSelector = @selector(viewWillAppear:);
        SEL swizzledSelector = @selector(xxx_viewWillAppear:);

        Method originalMethod = class_getInstanceMethod(class, originalSelector);
        Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);

        // When swizzling a class method, use the following:
        // Class class = object_getClass((id)self);
        // ...
        // Method originalMethod = class_getClassMethod(class, originalSelector);
        // Method swizzledMethod = class_getClassMethod(class, swizzledSelector);

        BOOL didAddMethod =
            class_addMethod(class,
                originalSelector,
                method_getImplementation(swizzledMethod),
                method_getTypeEncoding(swizzledMethod));

        if (didAddMethod) {
            class_replaceMethod(class,
                swizzledSelector,
                method_getImplementation(originalMethod),
                method_getTypeEncoding(originalMethod));
        } else {
            method_exchangeImplementations(originalMethod, swizzledMethod);
        }
    });
}

33 行代码,能拿出来探讨的确有不少,接下来会以 QA 的方式来逐一分析,其中部分 mattt 已经在博文中做了解释,这里权当复述了。

为什么是 + load 方法

swizzling 应该只在 +load 中完成。 在 Objective-C 的运行时中,每个类有两个方法都会自动调用。+load 是在一个类被初始装载时调用,+initialize 是在应用第一次调用该类的类方法或实例方法前调用的。两个方法都是可选的,并且只有在方法被实现的情况下才会被调用。

mattt 这里推荐了两个使用 swizzling 的时机,一个是例子中的 + load,另一个是 + initialize,他们的共同点都是两个方法是被实现的时候才自动被调用的,且不会被重复调用,区别是 + load 的调用时机更靠前,而 + initialize 则是懒加载的。也就是说,我们选择做 swizzling 的时候,需要考虑一点就是是否会被多次调用?如果需要在某些条件下才触发 swizzling,也需要通过这点去选择。

为什么是 dispatch_once

swizzling 应该只在 dispatch_once 中完成。

由于 swizzling 改变了全局的状态,所以我们需要确保每个预防措施在运行时都是可用的。原子操作就是这样一个用于确保代码只会被执行一次的预防措施,就算是在不同的线程中也能确保代码只执行一次。Grand Central Dispatch 的 dispatch_once 满足了所需要的需求,并且应该被当做使用 swizzling 的初始化单例方法的标准。

top Created with Sketch.