一個在 Objective-C 和 Swift 中實現剖面導向編程的故事

案例:干預 UIScrollView 的 Pan Gesture Recognizer

咱們都知道, UIScrollView 將 pan gesture 信號轉換成 scrollViewDidXXX: 消息而後發送給它的 delegate,多數時候你只須要理解這二者的關係而後在 delegate 監聽這些消息就能夠了。可是若是你要干預 pan gesture recognizer 的工做怎麼辦?我是說,干預 pan gesture 的識別。git

在這裏,若是咱們不選擇修改 UIScrollView 的內部機制,那咱們將不得不選擇建立一個子類。github

因爲 UIScrollView 的 pan gesture recognizer 將他的 delegate 固化成了擁有這個 gesture recognizer 的 UIScrollView,若是你將它的 delegate 設置爲其餘的「中間人」,你將會獲得一個運行時異常。在這裏,多數人都會想到建立一個子類。可是若是你指望此次修改也能影響其餘 UIScrollView 的子類時怎麼辦?objective-c

在物件導向(陸譯面向對象,但我更喜歡「物件導向」這個譯法,感受更精準)編程範式中咱們並不鼓勵修改一個已存在的類的內部機制。由於物件導向編程是創建在不斷做出是什麼的斷言上——一個類的所做所爲造就了這個類這個類自己的緣由,故而物件導向編程的核心概念之一即是「擴展而不是修改」。修改已存在的類的內部機制打破了這個範式。若是你選擇修改,那麼這個「是什麼」斷言便不成立了,軟件架構的基礎也就隨之開始動搖。編程

因此咱們永遠不該該是某一種編程教派的狂熱信徒。這一次你須要剖面導向編程。有了它,你就能夠不用建立一個新類而達到干預 pan gesture recognizer 的工做這個目的了。而這也能夠影響到繼承自 UIScrollView 的子類。swift

剖面導向編程簡介

剖面導向編程多是編程世界中最被解釋得過於複雜的術語了。bash

與剖面導向編程相比,最類似的概念我認爲應該是植物嫁接。markdown

植物嫁接的意思是:將一顆植物的枝或芽固定在另外一顆活體植物的主幹或者莖的深切面上,這樣枝或者芽就能夠從這顆活體植物接受營養並繼續生長。閉包

植物嫁接
植物嫁接

剖面導向編程着實相似植物嫁接。架構

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

如上圖所示,剖面導向編程關心以下三件事:app

  • 新加入的代碼
  • 剖面
  • 被操做的對象

咱們能夠將剖面導向編程中新加入的代碼比做植物嫁接中植物的枝或芽,將剖面比做深切面,將被操做的對象比做活體植物。因而剖面導向編程就是將這三者固定在一塊兒的過程。

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 進行繁簡轉換

相關文章
相關標籤/搜索