【Swift】Swift黑魔法 - Runtime

一、什么是Runtime:
Runtime是苹果开发中比extension更加强大的一项黑科技
extension允许用户在不修改原始代码的情况下,为类增加额外的方法
而Runtime则允许用户在不修改原始代码的情况下,为类增加额外的属性,甚至直接改变原有方法的实现
*Runtime在OC和Swift中都可以使用

二、方法交叉:Method Swizzling
1、为什么要用Method Swizzling
在实际应用中,我们可能会遇到这样的场景:
我们定义了很多Label控件,有些是在xib中定义的,有些是通过代码创建的
在这些Label中,有些使用的是系统认字体,有些使用的则是自定义字体
然后有一天,设计师突然给了一个新字体,说所有的系统认字体都应该替换成这个字体
虽然很不爽,但还是花了半天时间把系统中所有的Label字体都修改了一遍(可能有些还有遗漏)
然后又有一天,老板说这个字体不好看!给我换成另外一个字体!于是设计师又给了一个新字体
于是又花了半天时间把所有Label的字体再修改一遍,并且出现了更多的遗漏
就这样,我们浪费了很多时间在改字体上面,最后出来的效果还不尽人意

于是我们就心想,如果有一个方法,能够把所有Label的系统认字体,都替换成我们想要的新字体该多好啊
这样,不管我们在xib中定义Label还是在代码中创建Label,都直接使用系统认字体即可
即使要改字体的话,也直接修改这个方法中的字体就行,然后所有Label就都自动改过来了

当然,这个需求是可以实现的,但首先要有解决问题的思路:
思路一:每当程序需要获取系统认字体时,直接返回我们想要的字体
思路二:每当Label被创建或加载时,判断当前Label的字体是否为系统字体,若是,则修改为我们想要的字体

那么问题来了,无论用哪种思路,都要求必须要修改系统原始控件中的方法,例如
思路一:需要修改UIFont的systemFontOfSize等方法,使其返回指定的字体
思路二:需要修改UILabel的awakeFromNib等方法,将其修改为指定的字体

但是,我们并不能直接修改UIFont和UILabel的代码,那么要如何实现我们想要的效果呢?
这就要用到Runtime中的Method Swizzling机制了

2、什么是Method Swizzling:
Swizzling的中文翻译是“交叉混合”,要想理解Method Swizzling机制,首先需要了解iOS中方法调用的原理

在iOS中,每个方法(或称为函数,下同)都是由方法的声明和方法的实现两部分来构成的
其中,方法的声明就是方法名称和参数,而方法的实现就是方法中定义的代码
*为了简单起见,在下面的介绍中,方法的声明将简称为方法名,方法的实现将简称为方法

在正常调用方法的时候,我们通常是将两者作为一个整体调用的,即通过方法名直接调用方法
但实际上,方法名和方法体是两个不同的对象,方法名的对象类型为Selector,而方法体的对象类型则为Method
当我们调用一个方法时,系统首先会找到该方法对应的Selector,然后再根据Selector找到对应的Method
当然,我们也可以直接提供给系统一个Selector,系统也可以通过该Selector找到对应的Method
但是,如果我们把一个方法的Selector,指向另一个方法的Method,那会怎样呢?

为了理解这个概念,我们首先看看下面这个类:
class SwizzlingDemo {
    func printA() {
        print("A")
    }
    
    func printB () {
        print("B")
    }
}
很明显:
当我们调用printA()的时候,控制台就会打印A
当我们调用printB()的时候,控制台就会打印B

那么,我们能不能在 完全不修改这个类的任何代码的前提下
调用printA()的时候,让控制台打印B
调用printB()的时候,让控制台打印A呢?
这样就需要用到Method Swizzling了

Method Swizzling,其作用就是 在不修改任何代码的前提下,交换两个方法的声明与实现
为了实现上述需求,我们首先需要使用extension,对SwizzlingDemo进行扩展:
extension Swizzling Demo {    
   class func swizzlePrintMethod() {
        //获取printA和printB的Selector
        let printASelector = #selector(SwizzlingDemo.printA)
        let printBSelector = #selector(SwizzlingDemo.printB)
        
        //获取printA和printB的Method
        let printAMethod = class_getInstanceMethod(self,printASelector)
        let printBMethod = class_getInstanceMethod(self,printBSelector)
        
        //交换两个方法的Method
        method_exchangeImplementations(printAMethod,printBMethod)
    }
}
我们在SwizzlingDemo中扩展了一个方法:swizzlePrintMethod()
代码中可以很清晰地看出:
我们首先获取了printA和printB两个方法的Selector
再根据两个Selector取出了两个方法的Method
最后交换了两个方法的Method,Swizzing完毕。

是的,就是这么简单。
执行了这段代码之后,当我们再调用printA的时候
系统实际上执行的就是printB的Method,也就是在控制台打印B了
所以,问题就变成了,我们应该在什么时候调用这个方法呢?

一个最推荐(也是最安全)的做法是:
重写这个类的initialize方法,并且通过dispatch_once仅调用一次,如下:
extension SwizzlingDemo {
    //首先我们需要重写initialize方法,当系统在加载这个类的时候就会执行其中的代码
    override class func initialize() {
       //创建单次标识(固定写法,照抄即可)
       struct Static {
           static var token: dispatch_once_t =0
       }
        
       //使用单次标识代码块,确保其中的代码仅执行一次(固定写法,照抄即可)
       //*重要!*此代码块不可省略!否则会产生不可预知的崩溃!
       dispatch_once(&Static.token) {
           //若对象类型正确,则执行Swizzle方法
           if self == SwizzlingDemo.self {
               swizzlePrintMethod()
           }
        }
    }
}
由于initialize方法会在系统加载这个类的时候被调用
因此,我们就可以确保我们的代码在最初的时候就可以执行,且仅执行一次
*注:在OC的开发中,一般是在load方法中执行Swizzling的
但Swift从1.2版本之后就不再支持load方法,因此只能重写initialize方法

这样,我们就通过extension完成了一次完整的Swizzling
当我们在程序中任何其他地方调用这个对象的printA方法时,控制台打印的将始终是B

3、使用Method Swizzling解决问题:
那么回到我们最初的问题
如果我们想要在不修改UIFont和UILabel代码的前提下加入我们想要的操作,要如何做呢?
代码实现的基本原理还是一样的,为了简单起见,在此只以UILabel的awakeFromNib方法为例

首先,我们需要在UILabel中,新定义一个可以修改系统认字体的方法
// MARK: - New Font Methods
extension UILabel {
   //方法名随意,但由于准备替换掉系统的awakeFromNib方法,因此如此命名
   @objc func myAwakeFromNib() {     
        //仅替换掉系统认字体,不修改其他字体
       if self.font.fontDescriptor().postscriptName == ".SFUIText-Regular" {
           let font = UIFont(name: "FZLanTingKanHei-R-GBK",size: self.font.pointSize)
           self.font = font
       }
    }
}
然后,我们需要一段代码,使用自定义方法替换掉系统的awakeFromNib方法
extension UILabel { 
   private class func swizzleSystemLabel() {
       //获取两个awakeFromNib方法的声明
       let systemAwakeFromNibSelector = #selector(UILabel.awakeFromNib)
       let myAwakeFromNibSelector = #selector(UILabel.myAwakeFromNib)

       //获取两个awakeFromNib方法的实现
       let systemAwakeFromNibMethod = class_getInstanceMethod(self,systemAwakeFromNibSelector)
       let myAwakeFromNibMethod = class_getInstanceMethod(self,myAwakeFromNibSelector)
       
       //交换两个方法的实现
       method_exchangeImplementations(systemAwakeFromNibMethod,myAwakeFromNibMethod)
   }
}
最后,我们需要重写UILabel的initialize方法,确保我们的swizzle方法被执行:
extension UILabel { 
   override public class func initialize() {
        struct Static {
            staticvar token: dispatch_once_t =0
        }
        
        dispatch_once(&Static.token) {
            if self == UILabel.self {
                swizzleSystemLabel()
            }
        }
    }
}
这样,当UILabel从xib文件中被加载的时候,系统会自动调用UILabel的awakeFromNib方法
但由于这个方法的实现已经被我们替换了,因此实际上执行的就是myAwakeFromNib方法的实现
从而用我们自定义的字体,来替换掉系统的认字体了

4、使用Method Swizzling的注意事项:
由于Method Swizzling本质上是直接替换了两个方法的实现
因此,如果我们替换了一些有系统认实现的方法(例如init方法),那要如何保证原有的实现也能够被调用呢?

答案并不难:在自定义方法的实现中,调用系统原有方法的实现就可以了
但是!(注意但是!)让我们看下面两个例子,想想到底哪个是正确的:

例子1:
@objc func myAwakeFromNib() {
   self.awakeFromNib()          //调用awakeFromNib方法
   ...
}
例子2:
@objc func myAwakeFromNib() {
   self.myAwakeFromNib()       //调用myAwakeFromNib方法
   ...
}
乍一看,似乎第一个例子是正确的,因为调用的就是系统原有的awakeFromNib方法
但是,再仔细想一想,这个 方法应该是什么时候,通过 哪个方法调用的呢?
答案就是,在程序运行的时候,通过awakeFromNib方法名被调用

所以,正确答案应该是例子2
虽然看上去像是递归,实际上只是 貌合神离罢了
虽然现在看上去像是在一起,但只要程序一运行,当方法交换完成后,这个方法体就不再属于这个方法名了

在这个时候,myAwakeFromNib的方法体,事实上已经属于awakeFromNib这个方法名了
而系统原有的awakeFromNib这个方法的实现,则已经到了myAwakeFromNib这个方法名下了

三、关联对象:Associated Objects
1、为什么要用Associated Objects
如果我们想要往一个类中增加属性,通常情况下可以使用继承的方法
例如,如果我们想要往UIImage中增加一个通用的属性category
那就可以先定义一个MYIMage,使其继承自UIImage
然后向其中增加category属性,并且在程序的所有地方使用MYImage就可以了

但是,这样的做法,会带来几个问题:
1、在程序中所有应当使用UIImage的地方,都必须使用MYImage,整个程序的可读性变差,并且很容易因为疏忽而出错
2、在xib或storyboard中拖入控件的时候,必须要将其类型设置为MYImage,进一步增加出错的可能性
3、最关键的是,往往我们是在开发过程中,才想到加上这个属性,这样就需要全局大改一次代码

因此,如果我们想要在不改动源代码和不使用子类的情况下,想要往类中增加属性,该怎么办呢?
答案就是使用Associated Objects

2、什么是Associated Objects:
简单理解,Associated Objects就是在不修改原始代码的情况下,为类增加额外的属性

首先以上面的场景为例,若我们在extension中直接增加为UIImage增加category属性,编译器会直接报错
错误的原因很明显,在extension中不允许声明可以保存数据的变量

那么,如果我们想要让其编译通过,就把这个属性声明为getter和setter,如下:
extension UIImage {
    var category: String? {
        get {}
        set {}
    }
}
看起来好像只是比上面多了两个方法而已,为什么就不报错了呢?
是因为这样写并不等同于定义category变量,而是等同于定义了两个方法
func getCategory () -> String {}
func setCategory (newValue: String) {}
这样,我们就可以通过UIImage.category设置和获取属性了。
但是!此时的get和set方法还并没有实现,也就是说如果现在我们就调用这个属性,会直接崩溃报错
那么,我们要如何实现这两个方法呢?

我们知道,set方法的含义,就是将传入的值保存起来,而get方法的含义,就是将保存的值取出来
所以,现在的问题就变成了,当我们实现set方法的时候,我们需要把传入的值保存到哪里
以及当我们实现get方法的时候,我们需要从哪里取出这个值
这就要使用到Associated Objects了

下面的代码即是完整的Associated Objects实现:
extension UIImage {
    //定义属性的指针
    private struct AssociatedKeys {
        static var categoryPointer = "demo_categoryPointer"
    }
   
    var category: String? {
        get {
            //根据指针从内存中取出变量的值
            return objc_getAssociatedobject(self,&AssociatedKeys.categoryPointer) as? String
        }
        set {
            //将传入的值保存到指针所对应的地址中
            if let newValue = newValue {
                objc_setAssociatedobject(self,&AssociatedKeys.categoryPointer,newValue as String?,.OBJC_ASSOCIATION_RETAIN_NONATOMIC
                )
            }
        }
    }
}
在上面的例子中,可以看到使用了objc_getAssociatedobject和objc_setAssociatedobject两个方法
这两个方法即是Associated Objects的核心,它们的原理是先定义一个指针,然后通过这个指针的地址来存取变量

当按照以上的方式填写好之后,我们就完全正常的使用UIImage的category属性了,就好像这个属性是原生的那样

3、如何使用Associated Objects:
使用Associated Objects,主要分为三步:
1、定义属性的指针:
private struct AssociatedKeys {
    static var categoryPointer = "demo_categoryPointer"
}
在这一步中,任何名称都可以任意修改包括struct的名称,变量的名称和对应的字符串
但是需要注意的是,这里的字符串才代表着指针的名称,而不是这里的变量

也就是说,当我们需要存取category这个属性值的时候
是根据 "demo_categoryPointer 这个指针的地址来存取变量的,而不是根据 categoryPointer来存取变量的
因此,我们在定义这个字符串的时候,为了避免和系统原有指针发生冲突,最好在前面加上【xx_】这样的格式

2、定义get方法
get {
     return objc_getAssociatedobject(self,&AssociatedKeys.categoryPointer) as? String
}
这个方法很好理解,第二个参数 & AssociatedKeys .categoryPointer对应的就是变量保存的地址
在这里,如果我们要定义其他类型的变量,则把as后面的String改成对应类型的变量就可以了

3、定义set方法
set {
     //将传入的值保存到指针所对应的地址中
     if let newValue = newValue {
         objc_setAssociatedobject(self,.OBJC_ASSOCIATION_RETAIN_NONATOMIC
         )
     }
}
在这里,我们需要注意的是最后一个参数: . OBJC_ASSOCIATION_RETAIN_NONATOMIC
这个参数的含义表示了该地址占用的内存,应当在什么情况下被释放,以及是否线程安全

该参数共有5种类型,分别如下:
case OBJC_ASSOCIATION_ASSIGN
case OBJC_ASSOCIATION_RETAIN_NONATOMIC
case OBJC_ASSOCIATION_copY_NONATOMIC
case OBJC_ASSOCIATION_RETAIN
case OBJC_ASSOCIATION_copY
乍看上去似乎很复杂,但现在还不是理解它们的时候
要想理解这5种类型,首先要理解在OC中,内存的分配和自动回收(ARC)的机制

在OC中,当一个变量想要存取值的时候,本质上是从这个变量的地址 指向的内存中进行读取的
而想要让一个变量指向一块内存,主要有两种方式:
第一种:创建一个变量并申请一块内存,使这个变量的地址指向这块内存
第二种:将一个变量已经指向的一块内存,赋值给另一个变量

好,OC的内存分配机制就讲到这里,下面再讲一下ARC的机制:
当某块内存被分配给变量时,ARC会自动为这块内存维护一个引用计数
当有变量 引用这块内存的时候,这块内存的引用计数+1
当引用这块内存的变量被销毁的时候,这块内存的引用计数-1
当某块内存的引用计数为0的时候,这块内存被释放

以上两个机制看起来似乎都很好理解,那么问题来了
在内存分配中,一个变量是“ 指向”一块内存的
而在ARC中,一个变量则是“ 引用”一块内存的
那么这里的“指向”和“引用”是什么关系呢?

让我们把上面两种关系合起来,答案就很清楚了:
第一种:当我们创建一个变量并申请一块内存,同时让这个变量指向这块内存的时候,该变量是否引用了这块内存?
答案是肯定的,这个变量肯定引用了这块内存。因为这块内存刚刚被创建,引用计数还是0
如果该变量没有引用这块内存的话,这块内存很快就会被回收,那创建这个变量就毫无意义了

第二种:当我们将一个变量指向的内存赋值给另一个变量时,被赋值的变量是否引用了这块内存?
这个问题的答案,就要根据赋值的方式而定了,可以分为以下三种情况:
*注:为了简单起见,将这块内存称之为内存A,将被赋值的变量称为变量B
方式一:仅让变量B指向内存A,但不持有内存A的引用
方式二:让变量B指向内存A,同时持有内存A的引用
方式三:重新申请一块内存B,复制内存A的内容,并让变量B指向内存B

以上三种方式中,当变量A被释放后,变量B的值就会有所不同
方式一:当变量A被释放后,内存A的引用计数变为0,内存A释放,变量B的值自动变为nil
方式二:当变量A被释放后,变量B还持有内存A的引用,内存A不释放,通过变量B还可以正常访问内存A
方式三:当变量A被释放后,内存A的引用计数变为0,内存A释放,但通过变量B还可以正常访问内存B

以上三种方式是不是都很好理解?以及,是不是觉得【方式一】、【方式二】、【方式三】这三种叫法实在太难听了?
其实,在OC中,对于以上三种方式的赋值,是有相应的名字的:
方式一:assign
方式二:retain
方式三:copy

好,下面再返回来看set方法最后一个参数的5种类型:
case OBJC_ASSOCIATION_ASSIGN
case OBJC_ASSOCIATION_RETAIN_NONATOMIC
case OBJC_ASSOCIATION_copY_NONATOMIC
case OBJC_ASSOCIATION_RETAIN
case OBJC_ASSOCIATION_copY
除了最后一个NONATOMIC单词还不认识之外,其他的是不是都能看明白了呢?

至于NONATOMIC这个单词,主要控制的是该变量是否线程安全
所谓的线程安全就是,是否能有多个线程同时访问并修改这个变量所指向的内存
如果名称中包含NONATOMIC,则说明该变量所指向的内存可以被多个线程同时访问(即线程不安全),反之,则不可以同时访问(线程安全)

由于线程安全会导致运行时效率略有降低,并且在通常情况下我们很少会多线程访问一个变量
因此在不确定选择哪个的情况下,直接使用 OBJC_ASSOCIATION_RETAIN_NONATOMIC即可

4、使用Associated Objects的注意事项:
最重要的一点就是: 不要滥用
任何技术都是这样,除非你确定为什么要使用,并且使用这项技术是最佳解决方案的时候,再去使用它
因此,仅当你想要从架构的层面上为类增加某个属性的时候,并且这个属性的作用十分通用和明确,才使用Associated Objects

资料来源:
使用Runtime全局修改UILabel的认字体: http://my.oschina.net/u/2340880/blog/538356

相关文章

软件简介:蓝湖辅助工具,减少移动端开发中控件属性的复制和粘...
现实生活中,我们听到的声音都是时间连续的,我们称为这种信...
前言最近在B站上看到一个漂亮的仙女姐姐跳舞视频,循环看了亿...
【Android App】实战项目之仿抖音的短视频分享App(附源码和...
前言这一篇博客应该是我花时间最多的一次了,从2022年1月底至...
因为我既对接过session、cookie,也对接过JWT,今年因为工作...