一个在 Objective-C 和 Swift 中实现剖面导向编程的故事

2,268 阅读13分钟

案例:干预 UIScrollView 的 Pan Gesture Recognizer

我们都知道, UIScrollView 将 pan gesture 信号转换成 scrollViewDidXXX: 消息然后发送给它的 delegate,多数时候你只需要理解这两者的关系然后在 delegate 监听这些消息就可以了。但是如果你要干预 pan gesture recognizer 的工作怎么办?我是说,干预 pan gesture 的识别。

在这里,如果我们不选择修改 UIScrollView 的内部机制,那我们将不得不选择创建一个子类。

由于 UIScrollView 的 pan gesture recognizer 将他的 delegate 固化成了拥有这个 gesture recognizer 的 UIScrollView,如果你将它的 delegate 设置为其他的「中间人」,你将会得到一个运行时异常。在这里,多数人都会想到创建一个子类。但是如果你期望这次修改也能影响其他 UIScrollView 的子类时怎么办?

在物件导向(陆译面向对象,但我更喜欢「物件导向」这个译法,感觉更精准)编程范式中我们并不鼓励修改一个已存在的类的内部机制。因为物件导向编程是建立在不断作出是什么的断言上——一个类的所作所为造就了这个类这个类本身的原因,故而物件导向编程的核心概念之一便是「扩展而不是修改」。修改已存在的类的内部机制打破了这个范式。如果你选择修改,那么这个「是什么」断言便不成立了,软件架构的基础也就随之开始动摇。

所以我们永远不应该是某一种编程教派的狂热信徒。这一次你需要剖面导向编程。有了它,你就可以不用创建一个新类而达到干预 pan gesture recognizer 的工作这个目的了。而这也可以影响到继承自 UIScrollView 的子类。

剖面导向编程简介

剖面导向编程可能是编程世界中最被解释得过于复杂的术语了。

与剖面导向编程相比,最相似的概念我认为应该是植物嫁接。

植物嫁接的意思是:将一颗植物的枝或芽固定在另一颗活体植物的主干或者茎的深切面上,这样枝或者芽就可以从这颗活体植物接受养分并继续生长。

植物嫁接
植物嫁接

剖面导向编程着实类似植物嫁接。

植物嫁接 v.s. AOP
植物嫁接 v.s. AOP

如上图所示,剖面导向编程关心如下三件事:

  • 新加入的代码
  • 剖面
  • 被操作的对象

我们可以将剖面导向编程中新加入的代码比作植物嫁接中植物的枝或芽,将剖面比作深切面,将被操作的对象比作活体植物。于是剖面导向编程就是将这三者固定在一起的过程。

Objective-C 和 Swift 中已经存在的剖面导向编程

在 Objective-C 中,关于剖面导向编程有一个误解:苹果官方并不支持剖面导向编程。

不是的。

Objective-C 中的 Key-Value Observation 就是一个特设的剖面导向编程框架,并且这是由苹果带来的官方特性。我们可以将 Key-Value Observation 代入之前的植物嫁接模型中:

  • 被 Key-Value 观察的对象其 property 变化事件触发器就是植物的枝或芽(新加入的代码)
  • 可以被 Key-Value 观察的 properties 就是深切面(剖面)
  • 被 Key-Value 观察的对象就是活体植物(被操作的对象)

于是我们可以知道,Key-Value Observation 就是剖面导向编程,但是这个「剖面」是「特设」的。苹果没有官方支持的是支持「通用」剖面的剖面导向编程。

剖面导向编程的情况在 Swift 中比较复杂。通过借助 Objective-C,Swift 默认支持 Key-Value Observation。但是因为函数调用的派发可以在编译时被决议并且被写入编译产物,而 Key-Value Observation 又是在运行时生成代码,这些编译产物有可能永远都不知道如何调用这些运行时生成的代码。所以你需要将要被观察的 properties 标记上 @objc 的属性。这样就会强制编译器生成运行时决议该函数派发的代码。

就像 Objective-C,在 Swift 中并没有对支持「通用」剖面的剖面导向编程提供支持。

好了。苹果造了个好框架然后我们非常开心,然而你还是不能达成干预 UIScrollView 的 pan gesture recognizer 的目的——这就是故事的结尾了吗?

非也。

实现支持通用剖面的剖面导向编程

朴素的途径

在 Objective-C 中,最简单的不通过 subclassing 来修改一个类的实例其行为的方法就是 method swizzling 了。网上有许多资料讨论如何在 Objective-C 和 Swift 中进行 method swizzling 的,所以我并不想在这里再重复一遍。我想说说这个途径的缺点。

首先,method swizzling 是在类上干活的。如果我们 swizzle 了 UIScrollView,那么所有 UIScrollView 及其子类的实例都会获得同样的行为。

然后,虽然我们在进行剖面导向编程,这并不意味着我们就放弃了作「是什么」断言的行为。而「作『是什么』断言」这种行为是划分组件责任边界的关键步骤,也是不论什么编程范式中的一块基石。Method swizzling 是一种匿名的修改途径,这种修改途径绕过了「作『是什么』断言」,很容易动摇软件架构的基础,同时也是难以察觉和追踪的。

再者,因为 Swift 不支持重载 Objective-C 桥接类的 class func load() 方法,许多文章都建议你将 swizzle 代码放入 class func initialize() 中去。因为对于每一个模块的每一个类,app 在启动时只会调用一个 class func initialize() 的重载,于是你必须将同一个类的所有 swizzle 代码都放入一个文件——否则你将搞不清楚启动时到底将调用哪一个 class func initialize() 重载。这最终将导致 method swizzling 在代码管理方面潜在的混乱。

成熟的途径

一瞥官方支持的剖面导向编程框架 Key-Value Observation,我们可以察觉到其根本没有我们说的上述缺点。苹果是如何做到的?

实际上,苹果是通过一种叫 is-a swizzling 的技术实现这个剖面导向编程框架的。

Is-a swizzling 十分简单,甚至反映到代码上都是——设置一个对象的 is-a 指针为另一个类的。

Foo * foo = [[Foo alloc] init];
object_setClass(foo, [Bar class]);

而 Key-Value Observation 就是创建一个被观察对象的类的子类,然后设置这个对象的 is-a 指针为这个新建类的 is-a 指针。整个过程如下列代码所示:

@interface Foo: NSObject
// ...
@end

@interface NSKVONotifying_Foo: Foo
// ...
@end

NSKVONotifying_Foo * foo = [[NSKVONotifying_Foo alloc] init];
object_setClass(foo, [NSKVONotifying_Foo class]);

因为 Apple 已经给出了一个关于「特设」剖面导向编程的成熟的解决方案,那么创建一个对象的类的子类,然后再将其 is-a 指针设置为该对象的这条途径应该是行得通的。但是当我们在做系统设计的时候,最重要的问题是:为什么应该是行得通的?

KVO 设计分析

打开 Swift Playground 然后键入下列代码:

import Cocoa

class Foo: NSObject {
    @objc var intValue: Int = 0
}

class Observer: NSObject { }

let foo = Foo()

let observer = Observer()

 We need to use `object_getClass` to check the real is-a pointer.

print(NSStringFromClass(object_getClass(foo)!))
print(NSStringFromClass(object_getClass(observer)!))

foo.addObserver(observer, forKeyPath: "intValue", options: .new, context: nil)

print(NSStringFromClass(object_getClass(foo)!))
print(NSStringFromClass(object_getClass(observer)!))

然后你会看到下列输出:

__lldb_expr_2.Foo
__lldb_expr_2.Observer
NSKVONotifying___lldb_expr_2.Foo
__lldb_expr_2.Observer

__lldb_expr_2 是由 Swift Playground 生成并且由 Swift 编译器在桥接 Swift 类至 Objective-C 时加入的模块名。 NSKVONotifying_ 是由 KVO 生成的保护性前缀。 FooObserver 是我们在代码中使用的类名。

通过对 KVO 内部的一瞥,我们可以知道,KVO 为被观察的对象创建了一个新类。但是这就足够了吗?我是说,针对一个被观察对象的类创建一个子类就够了吗?

由于 KVO 是一个成熟的框架,我们当然可以通过直觉回答「是」。但是如果我们这么做了,那么我们将丧失一次学习个中原由的机会。

实际上,因为在 KVO 观察一个对象的 properties 中,所有可变的因素都在观察者的事件处理函数:[NSObject -observeValueForKeyPath:ofObject:change:context:] 中,另一方面,又由于被观察的对象仅仅只需要机械地发送事件,被观察对象一方其实是非常固定的。这意味着针对一个被观察对象的类创建一个子类是完全足够的——因为这些同一个类的被观察对象工作起来完全一样。

将 Swift Playground 中的代码替换成如下代码:

import Cocoa

class Foo: NSObject {
    @objc var intValue: Int = 0
}

class Observer: NSObject { }

let foo = Foo()

let observer = Observer()

func dumpObjCClassMethods(class: AnyClass) {
    let className = NSStringFromClass(`class`)

    var methodCount: UInt32 = 0;
    let methods = class_copyMethodList(`class`, &methodCount);

    print("Found \(methodCount) methods on \(className)");

    for i in 0..<methodCount {
        let method = methods![numericCast(i)]

        let methodName = NSStringFromSelector(method_getName(method))
        let encoding = String(cString: method_getTypeEncoding(method)!)

        print("\t\(className) has method named '\(methodName)' of encoding '\(encoding)'")
    }

    free(methods)
}

foo.addObserver(observer, forKeyPath: "intValue", options: .new, context: nil)

dumpObjCClassMethods(class: object_getClass(foo)!)

于是你将得到:

Found 4 methods on NSKVONotifying___lldb_expr_1.Foo
	NSKVONotifying___lldb_expr_1.Foo has method named 'setIntValue:' of encoding 'v24@0:8q16'
	NSKVONotifying___lldb_expr_1.Foo has method named 'class' of encoding '#16@0:8'
	NSKVONotifying___lldb_expr_1.Foo has method named 'dealloc' of encoding 'v16@0:8'
	NSKVONotifying___lldb_expr_1.Foo has method named '_isKVOA' of encoding 'c16@0:8'

通过 dump 出 KVO 创建的类的方法,我们可以注意到它重载了一些方法。重载 setIntValue: 的目的是直截了当的——我们已经告诉了框架要观察 intValue 这个 property,所以框架重载了这个方法以加入通知代码;class的重载则一定是要返回一个指向该对象原类的伪 is-a 指针;dealloc重载的意图则应该是释放垃圾用的。通过 Cocoa 的命名法则,我们可以猜测新方法 _isKVOA应该是一个返回布尔值的方法。我们可以在 Swift Playground 中加入以下代码:

let isKVOA = foo.perform(NSSelectorFromString("_isKVOA"))!.toOpaque()

print("isKVOA: \(isKVOA)")

然后我们将得到:

isKVOA: 0x0000000000000001

因为在 Objective-C 的实践中,布尔真在内存中就被储存为 1,所以我们可以确认 _isKVOA就是一个返回布尔值的方法。显然,我们可以推测 _isKVOA 是用来指示该类是否是一个 KVO 生成的类的(尽管我们并不知道结尾的那个 A 是什么意思)。

我们的系统

我们的系统和 KVO 截然不同。

首先,我们的目标是设计一个提供「通用」剖面支持的剖面导向编程系统。这意味着你可以对任何对象的任何方法注入自定义实现。这也导致针对一个被注入对象的类创建一个子类以统筹所有变更的方法不再适用了。

其次,我们想要一个「具名」的途径而不是一个「不具名」,或者说「匿名」的途径来实施代码注入。「名以命之」使我们划分出事物责任的边界,而这些边界就是干净的软件架构的基础。

第三,我们希望这个系统不会引入任何会导致「惊吓」到开发者的机制。

通过参考 KVO 的设计,我们可以给出如下设计

  • 一个对象应该包含被注入的目标方法
  • 一个 protocol 来代表定义目标注入方法的剖面(强制开发者为此给出一个具体的名字)
  • 一个以具名方式实现了这个剖面的类。它将提供要注入的方法实现。
  • 当一个对象被注入自定义实现时,系统将为此创建一个子类。子类间的区分将考虑所有已经完成的注入的和将被进行的注入。之后将对象的 is-a 指针设置为这个新建子类的 is-a 指针。

机制图解
机制图解

你可能已经注意到了这个由我们的系统创建的类的名字包含了字符串 “->”。这在源代码中是非法字符。但是在 Objective-C 运行时环境中,这些字符是被允许的在类名称中出现的。这些字符在系统创建的类和用户创建的类之间建立起了一个有保证的围栏。

实现的过程相当简单,直到你接触到解析 protocol 的继承层级为止:我应该注入哪些方法?

考虑下列代码:

@protocol Foo<NSObject>
- (void)bar;
@end

由于 Foo 继承自 NSObject protocol,那么方法 -isKindOfClass: 的声明也必然包含在 Foo 的继承层级之中。当我们将这个 protocol 当作一个剖面时,我们应该将方法 -isKindOfClass: 一同注入到对象中去吗?

显然不行。

因为剖面是方法注入的 proposal,而类提供要注入的实现,我在这里设置了一点限制:系统将仅仅注入在提供自定义实现的类的子叶层级有具体实现的方法。这意味着如果你不在提供自定义实现的类的子叶层级提供具体实现,诸如 -isKindOfClass: 的方法是不会被注入的;而你又可以通过在提供自定义实现的类的子叶层级提供具体实现来注入此类方法。

最终,这里是代码仓库。然后 API 看起来是这样:

API 图解
API 图解

最后是干预 UIScrollView 的 pan gesture recognizer 的范例代码:

MyUIScrollViewAspect.h

@protocol MyUIScrollViewAspect<NSObject>
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer;
@end

MyUIScrollView.h

#import <UIKitUIKit.h>
@interface MyUIScrollView: UIScrollView<MyUIScrollViewAspect>
@end

MyUIScrollView.m

#import "MyUIScrollView.h"

@implementation MyUIScrollView
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer;
{
	// Do what you wanna do.
	return [super gestureRecognizerShouldBegin: gestureRecognizer];
}
@end

MyViewController.m

// ...
UIScrollView * scrollView = [UIScrollView alloc] init];
object_graftImplemenationOfProtocolFromClass(scrollView, @protocol(MyUIScrollViewAspect), [MyUIScrollView class]);
// ...

后日谈

我于 2017 年设计了这个框架。当时我并没有设计一个真正有助于减轻软件开发痛苦的框架的经验,而我最惦记的一件事情就是划清楚责任的边界以让我们可以构建更加清澈的软件架构。但是软件的开发是一个过程。这种设计也许给了清澈的软件架构一个可能性,但是强迫开发者在一开始就给一个剖面命名的做法降低了开发速度。

名可名,非常名。

——「老子」

我们给一件东西取名字总有一个目的。如果目的改变了,名字就会跟着改变。举例来说,猪的组成成分的划分在一个屠夫眼中和一个生物学家眼中是不同的。在软件开发的过程中,这个目的来自于我们如何定义问题和解释问题。而这又会随着软件开发过程的发展而改变。所以一个真正有助于减轻软件构建痛苦的好的框架应该拥有一部分的使用匿名函数的 API,或者你也可以叫 Swift 中的闭包,Objective-C 中的 blocks。这样就可以防止我们在对一件事物有充分认知之前就去给它取一个名字。但是由于这个框架在 2017 年设计完成,我当时并没有意识到上面我所提及的,所以这个框架并不支持匿名函数。

要让这个框架支持匿名函数的话我还需要更多研究。至少目前从我初步观察,Swift 的函数引用大小居然长达两个 words,而 C 语言的是一个;另外 Swift 的编译时决议也是一个非常麻烦的问题。显然这需要很多工作,而我目前并没有时间。但是在将来的某一刻,这将成为现实。


本文中提到的代码仓库


原文刊发于本人博客(英文)

本文使用 OpenCC 进行繁简转换