RxSwift异步事件追踪定位工具的研发历程

文章概要: 本文主要从分析RxSwift操作符的实现原理入手,然后介绍了Swift反射机制、Swift的函数派发机制及命名空间机制,同时我们设计了一套实现Hook Swift的动态及静态方法的解决方案,希望对广大iOS开发者有所帮助。

1. 背景: RxSwift之痛

RxSwift是GitHub的ReactiveX团队研发的一套函数响应式编程框架,其主要思想是把事件封装成信号流并采用观察者模式来实现监听。

当你使用RxSwift来实现一些简单的功能如发送一次网络请求、监听按钮点击事件等会让你的代码看起来非常直观简洁,但是如果你使用RxSwift实现了一个异步热流且在不同的类之间层层传递和加工转换之后代码的可读性就大大降低,甚至因为抓不到异步事件产生的堆栈而出现难以调试的情况。

为解决RxSwift的调试难题,我们通过阅读源码分析RxSwift操作符实现原理,然后利用Swift反射机制来dump “Observable Link”,最后又根据Swift语言的函数派发机制和命名空间机制设计了一套安全高效的hook Swift的动态及静态方法的方案,通过这套hook方案完成了对流事件传递链上的关键函数的拦截处理从而顺利实现了精准定位和调试RxSwift中异步事件的目标。

2. Dump Observable Link

2.1 RxSwift操作符实现原理简析

一个Observable使用操作符可以转换成一个新的Observable,而这个源Observable经过一些连续的操作符转换之后就形成了一条Observable Link,要追踪一个异步事件的源头首先需要找到整个Observable Link的Head节点。

阅读RxSwift的源码之后发现RxSwift的各种操作符的基本原理就是当你使用某个操作符对一个Observable A进行转换的时候,这个操作符都会生成一个新的Observable B,并且在这个新的Observable B内部持有原来的那个Observable A,当有其他人订阅Observable B的时候,Observable B内部同时也会订阅Observable A以此来实现整个Observable Link的“联动”效果。此时你也许会有了一些思路,既然每个操作符都会在其内部持有上一个Observable,那我们根据这个规律沿着一个操作符Observable一直往上回溯直到根Observable是不是就可以dump出整个Observable Link了?这个思路是正确的,然而现实却很残酷——所有操作符Observable用于持有其源Observable的属性都是Private的,这也就意味着你根本无法直接获取到这些属性!然而天无绝人之路,所幸的是我们还可以利用Swift的反射机制来到达目的。

2.2 Swift反射机制

尽管 Swift一直都在强调强类型、编译时安全并推荐使用静态调度,但它的标准库仍然提供了一个基于Mirror的Struct来实现的反射机制。简单来说,例如你有一个Class A并创建了一个A的实例对象a,此时你就可以通过Mirror(reflecting: a)来生成一个Mirror对象m,然后遍历m.children就可以获取到a对象的所有属性。

看到这里你应该知道如何去dump一个Observable Link了吧,话不多说,先上代码为敬:

2.3 为已有的类动态添加存储型属性

dump出的Observable Link上的所有Observable都是我们需要在运行时重点观察的对象,那么我们该如何对这些Observable与其它Observable做出区分呢?我们可以为Observable添加一个tag属性,在运行时如果发现某个Observable的tag不为空就监控这个Observable上产生的event。不过这里有一个关联类型问题,any类型可以转换为某种协议类型,但无法转换为关联类型协议的类型,因为关联的具体类型是未知的。为解决这个问题,我们设计了一个无关联类型的协议RxEventTrackType,在这个协议的extension里面为其添加eventTrackerTag属性,然后让Obseverble遵守此协议。为了给一个协议类型在extension中添加一个存储型属性,这里我选择了一个在OC时代经常使用的实现方案:objc_setAssociatedObject。

3. Hook Swift动态和静态方法

3.1 Swift的函数派发机制

函数派发就是处理如何去调用一个函数的问题。编译型语言有三种常见的函数派发方式:直接派发(Direct Dispatch)、函数表派发(Table Dispatch)和消息派发(Message Dispatch)。Swift同时支持这三种函数派发方式。

直接派发(Direct Dispatch)是最快的,不止是因为需要调用的指令集会更少,并且编译器还能够有很大的优化空间,例如函数内联等。然而静态调用对于编程来说也就意味着因为缺乏动态性而无法支持继承。

函数表派发(Table Dispatch)是编译型语言实现动态行为最常见的实现方式。函数表使用了一个数组来存储类声明的每一个函数的指针。大部分语言把这个称为“virtual table”(虚函数表),Swift里称为 “witness table”。每一个类都会维护一个函数表,里面记录着类所有需要通过函数表派发的函数,如果在本类中override了父类函数的话表里面只会保存被override之后的函数。一个子类在声明体内新添加的函数都会被插入到这个函数表的后面,运行时会根据这一个表去决定实际要被调用的函数。

消息机制(Message Dispatch)是调用函数最动态的方式,这样的机制催生了KVO,UIAppearence和CoreData等功能。这种运作方式的关键在于开发者可以在运行时改变函数的行为,不止可以通过swizzling来改变,甚至可以用isa-swizzling修改对象的继承关系,可以在面向对象的基础上实现自定义派发。

Swift函数派发规则总结:

  • 值类型声明作用域里的函数总是会使用直接派发

  • Class声明作用域里的函数都会使用函数表进行派发(某些特殊情况下编译器会优化为直接派发)

  • 而协议和类的extension都会使用直接派发

  • 协议里声明的,并且带有默认实现的函数会使用函数表进行派发

  • 用dynamic修饰的函数会通过运行时进行消息机制派发

3.2 静态语言Swift的Hook难点

相比于动态语言OC,静态语言Swift的方法Hook变得异常困难。主要原因如下:

1. 目标函数查找难

在OC中我们可以通过一个Selector(你可以简单理解为一个字符串)查找到对应的method,这个method内部的imp字段存储的即是函数指针。而Swift中的动态方法利用witness table或者protocol witness table通过偏移寻址来查找对应函数指针,Swift中的静态方法的地址更是在编译期就已经确定。

2.强行直接替换函数指针比较危险

如果非要Hook Swift中的动态方法,我们还是可以利用Xcode的lldb调试工具在运行时通过反汇编观察并记录某个函数对应的在witness table中的偏移量,然后找到这个类的meta data并根据这些偏移量找到对应的函数指针来进行Hook。然而这是一个非常危险的做法,如果某天Swift调整了其类对象的内存模型,我们通过固有偏移来实现的Hook将一触及崩!

3.3 移花接木——巧用命名空间

在Swift中每个module都代表了一个单独的命名空间,在不同的module里面可以定义相同的类型名称或者方法名称。例如Swift为我们提供的基本数据类型String里面定义了一个lowercased方法,如果此时我们在自己的module里面利用extension给String再增加一个lowercased方法,此时这两个lowercased方法是可以共存的,而且当你在自己的module里面调用String的lowercased方法时候默认优先调用的是你自己module里面的lowercased方法。

现在,你是不是感觉在Swift中Hook方法似乎有了一些眉目,然而目前还有一个更重要的问题亟待解决:如何在我们自己的lowercased方法中调用原生的lowercased方法呢?答案同样是利用命名空间。我们可以另外再建一个B module(demo中利用创建一个pod库的方式实现),在这个B module中给String增加一个originalLowercased方法,这个方法的内部实现很简单就是直接调用一下String的原生lowercased方法。然后就可以在我们自己module的lowercased方法中调用originalLowercased从而间接实现对String的原生lowercased方法的调用。

稍微有些遗憾的是,利用上面所述的这种方案Hook的方法只在我们自己的module里面有效,不过对于一般的Hook需求来说已经足够使用了。

4. Hook RxSwift的方法

上面关于Hook的介绍已经给我们提高了充分的理论基础,下面我们就可以用理论来指导实践了。

如果要追踪一个流事件产生的源头,关键要做的就是监听ObserverType的onNext、onError、onComplete方法和BehaviorRelay的accept方法。然后当一个ObserverType的对象的onNext等方法被调用的时候如果发现这个对象带有observerTypeTrackerTag就认为这是一个需要被重点观察和监控的对象并作出相应的处理,我们也可以同时在这里加上一个条件断点方便调试,代码截图如下:                           

使用此定位工具来追踪和定位一步事件源调试效果如下Gif图所示:

5. 总结

在此次RxSwift异步事件追踪定位工具的研发过程中,最为关键也是难点之一的就是如何实现hook Swift的动态及静态方法,我们在尝试了两三种方案之后才最终确定了这种利用Swift语言的函数派发机制和命名空间机制来安全高效的hook Swift的动态及静态方法的方案,相信我们的这套hook方案也会给你在以后的开发中在处理类似问题时带来更多的思路和灵感。

欢迎关注 「字节跳动技术团队」