stringwithformat jastringbuilder转string

本文从iOS开发的一个钩子案例开始。首先简要介绍了Objective-C的动态特性和传统钩子方法的常见缺陷,如命名冲突、操作繁琐、钩子链意外断裂、钩子范围不可控等。然后详细介绍了一种基于消息转发机制的轻量级hook方案:SDMagicHook。

Github项目地址:github-larksuite/sdmagibook:一个安全且有影响力的限制方法挂钩,适用于objective-c和swift。

背景

某月某日,产品小s向开发者Q提出了一个简单却不简单的需求:扩大一个按钮的点击面积。小Q听后暗自庆幸:还好这是自定义按钮,我只需要重写一下按钮的pointInside:withEvent: method即可。只见小Q拿起刀,落在了产品小s的眼里,轻松完成。代码如下:

stringwithformat javastringbuilder转string

第二天,产品小s再次满怀期待的找到了开发者小Q:欧巴~啊。帮我扩大这个按钮的点击区域。小Q这次搞错了,心想:这是系统提供的标准UI组件中的按钮。我只能用,不能换。我觉得你明显是在故意让我难堪,胖虎!我…我…我…-小Q卒。

在这种情况下,小Q的遭遇真的很让人同情。但是痛定思痛,这个问题真的无解吗?事实上,否则,你的读者坐下来听听我的分析:

1. Objective-C 的动态特性

作为一门古老而灵活的语言,Objective-C有很多开发者津津乐道的动态特性,其中最著名的就是动态类型、动态绑定、动态加载等特性。很多在其他语言看来不可能实现的功能,在OC中使用这些动态特性也可以达到事半功倍的效果。

1.1 动态类型(Dynamic typing)

动态类型化意味着对象的真实类型是在运行时确定的。例如,我们可以向id类型的对象发送任何消息,这在编译时是合法的,因为类型可以动态确定,消息真正起作用的时间是在运行时确定对象的类型之后,这将在下面讨论。我们甚至可以在运行时动态修改一个对象的isa指针来修改它的类型。OC中KVO的实现是动态类型的典型应用。

1.2 动态绑定(Dynamic binding)

当一个对象的类型确定后,其对应的属性和响应消息也就确定了,这就是动态绑定。绑定完成后,运行时可以根据对象的类型在类型信息中找到真正的函数地址,然后执行。

1.3 动态加载(Dynamic loading)

根据需求加载所需的材料资源和代码资源。用户可以根据需求加载一些可执行代码资源,而不是在启动时加载所有组件。可执行代码可以包含新的类。

了解了OC的这些动态特性之后,我们再来回顾一下产品的需求:产品只想任意修改任意按钮的点击区域,而这次恰好这个按钮是系统原生组件中的子视图。所以目前要解决的关键问题是如何改变一个由系统原生类型实例化的组件的“点击区域检测方法”。刚才在OC的动态类型特性介绍中,我们说了“消息真正起作用的时间是在运行时确定这个对象的类型之后”“我们甚至可以在运行时动态修改一个对象的isa指针来修改它的类型,KVO在OC中的实现就是动态类型的典型应用”。看到这里,你大概应该有些想法了。我们不妨遵循模仿KVO的原则。

2. 初版 SDMagicHook 方案

为了使用这种类似KVO的方案来代替isa指针,首先需要解决以下问题:

2.1 如何动态创建一个新的类

在OC中,我们可以调用运行时的objc_allocateClassPair和objc_registerClassPair函数动态生成新的类,然后调用object_setClass函数用我们自己的临时类替换一个对象的isa。

2.2 如何给这些新建的临时类命名

作为一个有意义的临时类名,首先要直观的看到这个临时类和它的基类之间的关系,这样就可以拼接出新的类名【NS String String with format:@ ” SD Hook * % s “,originalclsName】了,但是有一个很明显的问题,一个对象不可能独占一个专有类,所以我们可以继续扩展。您还可以在类名中添加一个对象的唯一标记内存地址。新的类名由[ns string stringwithformat:@ ” SD hook _ % s _ % p “,originalclsname,self]组成,这次看起来很完美,但是极端情况下会出错。比如我们在一万次For循环中不断创建同一类型的对象,那么新对象的内存地址就有很大概率与之前释放的对象的内存地址相同,我们会在一个对象被析构后很快释放它所使用的临时类,这很可能导致新生成的对象所使用的类被释放,然后崩溃。为了解决这类问题,我们需要给这个临时类名添加一个随机标签,以降低这种情况发生的概率。最终的类名如下:@ “sdhook _% s _% p _% d “,originalclsname,self,mgr.random flag】。

2.3 何时销毁这些临时类

我们可以通过objc_setAssociatedObject为每个NSObject动态关联一个SDNewCnager实例,并在SDNewCnager实例中保存当前对象使用的临时类。当当前对象被销毁时,SDNewCnager的这个实例也会被销毁,然后我们可以在SDNewCnager实例的dealloc方法中做一些操作来销毁临时类。但是这里我们不能立即销毁临时类,因为这个对象此时还没有被完全析构,还在做一些其他的善后操作。如果这个时候销毁临时类,必然会造成崩溃,所以需要把这些临时类的销毁推迟一段时间。代码如下:

好了,到目前为止我们已经实现了钩子方案的第一个版本,但是有两个明显的问题:

每次 hook 都要增加一个 category 定义一个函数相对比较麻烦;如果我们在某个 Class 的两个 category 里面分别实现了一个同名的方法就会导致只有一个方法最终能被调用到。

为此,我们开发了第二版,对第一版的缺点进行了改进和优化。

3. 优化版 SDMagicHook 方案

针对上面提到的两个问题,我们可以用block生成IMP,然后用目标选择器对应的方法替换这个IMP。API示例代码如下:

这个块方案看起来确实简单方便,但是也面临着任何钩子方案都无法避免的问题,那就是如何在块中调用原生的对应方法?

3.1 关键点一:如何在 block 里面调用原生方法

在该方案的第一个版本中,我们在一个类的类别中添加了一个特定于钩子的方法,然后在方法交换完成后,我们可以通过向实例发送与特定于钩子的方法本身相对应的选择器消息来回调原生方法。但是现在我们使用该块创建一个“匿名函数”来替换原生方法。既然是匿名函数,就没有明确的选择器,也就是说方法交换后我们没有办法找到它的原生方法!

那么现在的关键问题就是找到一个合适的选择器映射到钩子的原生函数。目前我们在当前编译环境下唯一可以方便调用的,并且和这个块有一定关系的选择器就是原方法的选择器,也就是我们演示中的pointInside:withEvent:这样,pointInside:withEvent:这个选择器就变成了一对多的映射键。当有人在外部向我们的按钮发送pointInside:withEvent: message时,我们应该先将pointInside:withEvent:转发给我们自定义块实现的IMP,然后在块内部再次向按钮发送pointInside:withEvent: message时,再将消息转发给系统的原生方法实现,这样就可以完成一次完美的方法调度。

3.2 关键点二:如何设计消息调度方案

在OC中,如果要调度方法,就需要得到消息转发的控制权,而如果要得到消息转发的这种控制权,就需要在每次接收到这个消息的时候,强制这个接收方触发它的消息转发机制,然后我们在消息转发的过程中做出相应的调度。在这个例子中,我们将目标按钮的pointInside:withEvent:对应的方法的imp指针替换为_objc_msgForward,这样每当有人调用这个按钮的pointInside:withEvent:方法时,最终都会转到消息转发方法forwardInvocation:,我们将实现这个方法来完成具体的方法调度工作。

因为目标按钮的pointInside:withEvent:对应的方法的imp指针被替换为_objc_msgForward,所以我们需要再添加一个方法A和方法B,分别存储目标按钮的pointInside:withEvent:方法的块自定义实现和本机实现。然后,当需要在自定义方法内部调用原始方法时,通过调用callOriginalMethodInBlock:this API显式通知它。示例代码如下:

callOriginalMethodInBlock方法的内部实现实际上是为这个调用增加了一个标识符,用来判断在调度方法时是否需要调用原来的方法。其实现代码如下:

当目标按钮实例收到pointInside:withEvent:的消息时,会启用我们定制的消息调度机制,检查如果OriginalCallFlag为假,则调用定制的实现方法A,否则调用原实现方法B,从而成功实现方法调度。流程图和示例代码如下:

想象这样一个应用场景:有一个全局的keywindow,所有的业务都想监听keywindow的layoutSubviews方法。那么我们如何管理和维护添加到keywindow的多个钩子实现之间的关系呢?如果一个对象要被销毁,它需要移除它之前与keywindow的挂钩。这个时候应该怎么做?

我们的解决方案是为每个被挂钩的目标原生方法生成一个挂钩表,按照挂钩发生的顺序依次为它们生成内部选择器并添加到挂钩表中。当keywindow收到layoutSubviews消息时,我们从钩子表中取出与该消息对应的钩子选择器,并发送给keywindow,让它执行相应的动作。如果删除一个钩子,只需要从钩子表中删除它对应的选择器。代码如下:

4. 防止 hook 链意外断裂

我们都知道,当我们挂钩一个方法时,我们需要在我们的挂钩代码方法体中调用挂钩的原始方法。如果省略这一步,钩子链就会断裂,导致钩子原来的方法永远不会被调用。如果有人在你之前挂钩这个方法,会导致你之前的所有挂钩都莫名其妙的失败。因为这是一个非常隐蔽的问题,你往往很难意识到你的钩子操作已经给别人造成了严重的问题。

为了方便钩子操作人员快速及时的发现这个问题,我们在调试模式下增加了一套“钩子断链检测机制”,其实现原理大致如下:

如前所述,我们已经实现了钩子目标方法的自定义调度,这让我们有机会在方法执行过程中通过callOriginalMethodInBlock检测这些方法是否调用了原方法。如果发现一个方法体不是钩子的目标函数的原方法体,且该方法执行后还没有调用原方法,那么它会通过raise(SIGTRAP)模式发送中断信号挂起当前程序,提醒开发者下一次钩子操作还没有调用原方法。

5. SDMagicHook 的优缺点

与传统的在category中添加一个自定义方法然后hook的方案相比,SDMagicHook的优缺点如下:

优点:只用一个 block 即可对任意一个实例的任意方法实现 hook 操作,不需要新增任何 category,简洁高效,可以大大提高你调试程序的效率;hook 的作用域可以控制在单个实例粒度内,将 hook 的副作用降到最低;可以对任意普通实例甚至任意类进行 hook 操作,无论这个实例或者类是你自己生成的还是第三方提供的;可以随时添加或去除者任意 hook,易于对 hook 进行管理。缺点:为了保证增删 hook 时的线程安全,SDMagicHook 进行增删 hook 相关的操作时在实例粒度内增加了读写锁,如果有在多线程频繁的 hook 操作可能会带来一点线程等待开销,但是大多数情况下可以忽略不计;因为是基于实例维度的所以比较适合处理对某个类的个别实例进行 hook 的场景,如果你需要你的 hook 对某个类的所有实例都生效建议继续沿用传统方式的 hook。总结

SDMagichook方案可以直接在OC和Swift的UIKit层使用,hook的范围可以限制在你指定的一个实例,避免污染其他不相关的实例。Api设计简单易用,只需要一分钟就可以轻松快速上手。希望我们的解决方案能给大家带来更精彩的iOS开发体验。

欢迎加入字节跳动技术团队。

免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。

发表回复

登录后才能评论