iOS中切面编程两种姿势的选择

1,528 阅读6分钟

1 为什么需要切面 —— 问题起因

 
  假设我们希望在iOS端实现如下的功能:  
 

  • 需要记录应用内页面跳转日志.  
     

在iOS中,我们可以通过记录 viewDidAppear(:) 和 viewDidDisappear(:) 方法对的出现次数来判断页面的跳转情况。 (当然他们可能不能完全准确地表现这一个记录,需要一些防御机制来保证哪些调用是有效的)

对应的,我们只需要在两个方法的对应实现中插入我们需要的记录代码,类似这样:
 

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        doSomeXYSRecord() // custom log
    }
    
    override func viewDidDisappear(_ animated: Bool) {
        viewDidDisappear(animated)
        
        doSomeXYSRecord() // custom log
    }


按照面向对象的思考方法,我们首先想到的就必须是继承, 大致做法如下:

  • 创建一个BaseViewController
  • 让工程中所有的ViewController都继承自这个Base
  • 重写以上的两个方法。

 
  假如满足于此,那么这个事情其实到这里就结束了。你已经实现了你想要的功能。  
  但是这么做你会面临几个困境:

  1. 在工程已经有一定规模的情况,如果没有全部继承自一个ViewController对其进行改造是代价比较大的。 
     
  2. 记录功能是对于一个工程层面的,而这么做之后将其耦合到了一个 ViewController中,如果工程中需要实现其他的对于全局而言的功能,会导致BaseViewController不停膨胀, 不符合面向对象的单一职责原则。 
     
  3. 在其他人接手项目的时候,会需要更多的学习成本,无法像开发普通应用程序那样直接创建一个ViewController, 可能需要一个文档来提醒他: 额,你如果创建ViewController需要继承BaseViewController哦 :) 
     

这篇文章目的是介绍 AOP(Aspect Oriented Programming)面向面向切面编程在iOS中应用的几种途径

 
  名词约定:

  • 我们将这些事件的发生点称之为一个"切面"。比如页面的出现是一个"切面",页面的消失是一个"切面",点击按钮是一个"切面"......

所谓的切面编程也就是利用一些手段去监视这些我们感兴趣的切面。  
下面会分析一下iOS中实现AOP的几种途径和各自的优缺点。

2 切面方式

2-1 Method Swizzle

这是一种最为普遍的iOS实现AOP编程的方法。  
  这个途径主要利用的是method_exchangeImplementations这个函数。  
  一个比较简化版实现大致可以被这样描述

+ (void)xys_excuteMethodSwizzle
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class aClass = [self class];
        
        SEL originalSelector = @selector(method_original:);
        SEL swizzledSelector = @selector(method_swizzle:);
        
        Method originalMethod = class_getInstanceMethod(aClass, originalSelector);
        Method swizzledMethod = class_getInstanceMethod(aClass, swizzledSelector);
       method_exchangeImplementations(originalMethod, swizzledMethod);
    });
}

 
  在iOS runtime中,函数调用被称之为消息发送,方法的实际调用结果必须要到运行时的时候才会被决定,而这个method_exchangeImplementations函数则是把原来应该被调用的函数替换成我们自己的函数以达到切面编程的目的。  
  这里这样的简简单单直接替换掉IMP(runtime中的函数指针)是比较简单不加防御的做法,在复杂的继承情况下可能会导致一些比较奇怪的结果,如何保证你的交换是正确的? 需要利用类似这样的做法: 在交换之前先检查当前类是否已经有对应的实现。

+ (void)xys_excuteMethodSwizzle
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class aClass = [self class];
        
        SEL originalSelector = @selector(method_original:);
        SEL swizzledSelector = @selector(method_swizzle:);
        
        Method originalMethod = class_getInstanceMethod(aClass, originalSelector);
        Method swizzledMethod = class_getInstanceMethod(aClass, swizzledSelector);
        BOOL didAddMethod =
        class_addMethod(aClass,
                        originalSelector,
                        method_getImplementation(swizzledMethod),
                        method_getTypeEncoding(swizzledMethod));
        
        if (didAddMethod) {
            class_replaceMethod(aClass,
                                               swizzledSelector,
                                               method_getImplementation(originalMethod),
                                               method_getTypeEncoding(originalMethod));
        } else {
            method_exchangeImplementations(originalMethod, swizzledMethod);
        }
    });
}

优点:

  1. 简单易懂,容易上手。
  2. 支持多次swizzle

缺点:

  1. 在继承层级上容易被滥用导致异常崩溃,特别是交换了子类没有实现,父类实现了的方法。(具体分析可以看这里 Objective-C-Method-Swizzling)

 
  关于这里的原因分析详情查看  
 

2-2 Method dynamic resolve

 
  消息发送是有可能失败的,假如你调用的消息没有被实现,那么发送的消息在继承层级的全部方法列表都没有找到对应的方法,那么消息调会进入下一阶段:

动态解析

它依次由3个步骤构成

  1. resolveInstanceMethod
  2. forwardingTargetForSelector
  3. forwardInvocation

其中,  
  resolveInstanceMethod 步骤是希望你去为这个类动态地增加一个方法来处理这个消息

 
  forwardingTargetForSelector 步骤是希望你找一个其他对象来接收这个消息

 
  forwardInvocation, 也就是消息解析的终点,在这里如果再不处理,就会抛出unrecognized selector的异常。这里也就是我们可以利用的AOP时机。

 
比较著名的JSPatch、Aspects都是在这个阶段工作的。

 
注: Aspects作者在2019.02的时候在Github上修改了简介,强烈不建议在生产环境里使用这个库

 
这类方法主要是直接越过了消息正常发送环节,把原Selector的Method替换成_objc_msgForward. 从而直接进入动态解析环节,通过swizzle forwardInvocation 方法来达到切面编程的目的。

3 总结

  1. 尽量不要去对同一继承层级的类同时做切面处理。(非那么不可的话,参考上述的Hook顺序和Hook方法选择建议)
  2. 如果只是做一些简单的Hook, 在没有比较特殊的情况下,不要引入第三方库。虽然它们在使用的时候会有不少的便利,但由于它们的实现细节相对比较复杂,引入成本过大。
  3. 使用方法交换的时候不要把方法放到load里,除非你的交换实现必须优先级非常高。否则请在didFinishLaunch里显式调用更加合适。
  4. 不要用Aspects, 首先作者是已经声明了这个库不适合用在生产环境上。其次他的实现导致他和其他的Method Swizzle是不兼容的, 先method swizzle然后再Aspect是可以的,但是先Aspect之后再Method Swizzle会导致抛出一个unrecognized selector异常,原因是Aspects在调用__ASPECTS_ARE_BEING_CALLED__的时候,会判断Selector的字符串从而来判断是否调起对应的动态解析,在二次方法交换之后,Selector字符串名被替换了,所以最终直接调用__ASPECTS_ARE_BEING_CALLED__的时候隐藏传入的_cmd就是新的字符串名字,导致Aspects无法正常运行。其实是属于以下的范畴,其实这个原理没有深入了解的必要。
  5. 如果使用了第三方库,请尽量避免同时使用两种不同类型仓库,避免陷入无意义的 '方法溯源', XD

本文首发于个人博客