Swift 5 之後 "Method Swizzling"?

冬瓜發表於2019-07-29

轉自公眾號《讓技術一瓜公食》,文章底部掃碼關注。

引子

隨著六月份的 WWDC 上對 SwiftUI 的釋出,感覺 Swift 有變成了熾手可熱的話題。在大會結束後,發現了有這麼幾條 Twitter 在討論一個叫做 @_dynamicReplacement(for:) 的新特性。

Swift 5 之後 "Method Swizzling"?

這是一個什麼東西呢,於是我在 Swift 社群中也檢索了對應的關鍵字,看到一個 Dynamic Method Replacement 的帖子**。**在爬了多層樓之後,大概看到了使用的方式(環境是 macOS 10.14.5,Swift 版本是 5.0,注意以下 Demo 只能在工程中跑,Playground 會報 error: Couldn't lookup symbols: 錯誤)。

class Test {
    dynamic func foo() {
        print("bar")
    }
}
    
extension Test {
    @_dynamicReplacement(for: foo())
    func foo_new() {
        print("bar new")
    }
}
    
Test().foo() // bar new
複製程式碼

看到這裡是不是眼前一亮?我們期待已久的 Method Swizzling 彷彿又回來了?

開始的時候只是驚喜,但是在平時的個人開發中,其實很少會用到 hook 邏輯(當然這裡說的不是公司專案)。直到有一天,朋友遇到了一個問題,於是又對這個東西做了一次較為深入的研究 ....

Method Swizzling in Objective-C

首先我們先寫一段 ObjC 中 Method Swizzling 的場景:

//
//  PersonObj.m
//  MethodSwizzlingDemo
//
//  Created by Harry Duan on 2019/7/26.
//  Copyright © 2019 Harry Duan. All rights reserved.
//
    
#import "PersonObj.h"
#import <objc/runtime.h>
    
@implementation PersonObj
    
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class class = [self class];
        SEL oriSelector = @selector(sayWords);
        SEL swiSelector = @selector(sayWordsB);
        Method oriMethod = class_getInstanceMethod(class, oriSelector);
        Method swiMethod = class_getInstanceMethod(class, swiSelector);
        method_exchangeImplementations(oriMethod, swiMethod);
        
        SEL swi2Selector = @selector(sayWorkdsC);
        Method swi2Method = class_getInstanceMethod(class, swi2Selector);
        method_exchangeImplementations(oriMethod, swi2Method);
    });
}
    
- (void)sayWords {
    NSLog(@"A");
}
    
- (void)sayWordsB {
    NSLog(@"B");
    [self sayWordsB];
}
    
- (void)sayWorkdsC {
    NSLog(@"C");
    [self sayWorkdsC];
}
    
@end
複製程式碼

上述程式碼我們宣告瞭 - (void)sayWords 方法,然後再 + (void)load 過程中,使用 Method Swizzling 進行了兩次 Hook。

在執行處,我們來呼叫一下 - sayWords 方法:

PersonObj *p = [PersonObj new];
[p sayWords];
    
// log
2019-07-26 16:04:49.231045+0800 MethodSwizzlingDemo[9859:689451] C
2019-07-26 16:04:49.231150+0800 MethodSwizzlingDemo[9859:689451] B
2019-07-26 16:04:49.231250+0800 MethodSwizzlingDemo[9859:689451] A
複製程式碼

正如我們所料,結果會輸出 CBA,因為 - sayWords 方法首先被替換成了 - sayWordsB ,其替換後的結果又被替換成了 - sayWordsC 。進而由於 Swizze 的方法都呼叫了原方法,所以會輸出 CBA。

來複習一下 Method Swizzling 在 Runtime 中的原理,我們可以概括成一句話來描述它:方法指標的交換。以下是 ObjC 的 Runtime 750 版本的原始碼:

void method_exchangeImplementations(Method m1, Method m2)
{
    if (!m1  ||  !m2) return;
    
    mutex_locker_t lock(runtimeLock);
		
		// 重點在這裡,將兩個方法的例項物件 m1 和 m2 傳入後做了一次啊 imp 指標的交換
    IMP m1_imp = m1->imp;
    m1->imp = m2->imp;
    m2->imp = m1_imp;
    
    
    // RR/AWZ updates are slow because class is unknown
    // Cache updates are slow because class is unknown
    // fixme build list of classes whose Methods are known externally?
    
    flushCaches(nil);
		
	// 來更新每個方法中 RR/AWZ 的 flags 資訊
	// RR/AWZ = Retain Release/Allow With Zone(神奇的縮寫)
    updateCustomRR_AWZ(nil, m1);
    updateCustomRR_AWZ(nil, m2);
}
複製程式碼

Swift 5 之後 "Method Swizzling"?

由於 ObjC 對於例項方法的儲存方式是以方法例項表,那麼我們只要能夠訪問到其指定的方法例項,修改 imp 指標對應的指向,再對引用計數和記憶體開闢等於 Class 相關的資訊做一次更新就實現了 Method Swizzling。

一個連環 Hook 的場景

上面的輸出 ABC 場景,是我朋友遇到的。在製作一個根據動態庫來動態載入外掛的研發工具鏈的時候,在主工程會開放一些介面,模擬 Ruby 的 alias_method 寫法,這樣就可以將自定義的實現注入到下層方法中,從而擴充套件實現。當然這種能力暴露的方案不是很好,只是一種最粗暴的外掛方案實現方法。

當然我們今天要說的不是 ObjC,因為 ObjC 在 Runtime 機制上都是可以預期的。如果我們使用 Swift 5.0 中 Dynamic Method Replacement 方案在 Swift 工程中實現這種場景。

import UIKit
    
class Person {
    dynamic func sayWords() {
        print("A")
    }
}
    
extension Person {
    @_dynamicReplacement(for: sayWords())
    func sayWordsB() {
        print("B")
        sayWords()
    }
}
    
extension Person {
    @_dynamicReplacement(for: sayWords())
    func sayWordsC() {
        print("C")
        sayWords()
    }
}
    
class ViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        Person().sayWords()
    }
}
複製程式碼

從視覺角度上來看,通過對 Swift Functions 的顯式宣告(有種 Swift 真香的感覺),我們完成了對於 Method Swizzling 的實現。跑一下程式碼,發現執行結果卻不如我們的預期:

C
A
複製程式碼

為什麼結果只顯示兩個?我交換了一下兩個 extension 的順序繼續嘗試,其列印結果又變成了 BA 。於是差不多可以總結一下規律,在執行順序上,後宣告的將會生效。那麼應該如何實現這種連環 Hook 的場景呢?從程式碼層面我沒有想到任何辦法。

從 Swift 原始碼來猜測 @_dynamicReplacement 實現

按照正常程式設計師的邏輯,如果我們在重構一個模組的程式碼,新的模組程式碼無論從功能還是效率上,都應該優於之前的方式、覆蓋之前所有的邏輯場景。如果 Swift 支援這種連環修改的場景,那這個新的 Feature 放出其實是功能不完備的!於是我們開始翻看 Swift 這個 Feature 的 PR 程式碼,來一探 Dynamic Method Replacement 的原理。

首先來看這個 Dynamic Method Replacement 特性的 Issue-20333,作者上來就貼了兩段很有意思的程式碼:

/// 片段一
// Module A
struct Foo {
 dynamic func bar() {}
}
// Module B
extension Foo {
  @_dynamicReplacement(for: bar())
  func barReplacement() {
    ...
    // Calls previously active implementation of bar()
    bar()
  }
}
    
/// 片段二
dynamic_replacement_scope AGroupOfReplacements {
   extension Foo {
     func replacedFunc() {}
   }
   extension AnotherType {
     func replacedFunc() {}
   }
}
    
AGroupOfReplacements.enable()
...
AGroupOfReplacements.disable()
複製程式碼

大概意思就是,他希望這種動態替換的特性,通過一些關鍵字標記和內部標記,相容動態替換和啟用開關。既然他們有規劃 enabledisable 這兩個方法來控制啟用,那麼就來檢索它們的實現。

通過關鍵字搜尋,我在 MetadataLookup.cpp 這個檔案的 L1523-L1536 中找到了 enabledisable 兩個方法的實現原始碼:

// Metadata.h#L4390-L4394:
// https://github.com/apple/swift/blob/659c49766be5e5cfa850713f43acc4a86f347fd8/include/swift/ABI/Metadata.h#L4390-L4394
    
/// dynamic replacement functions 的連結串列實現
/// 只有一個 next 指標和 imp 指標
struct DynamicReplacementChainEntry {
  void *implementationFunction;
  DynamicReplacementChainEntry *next;
};
    
// MetadataLookup.cpp#L1523-L1563
// https://github.com/aschwaighofer/swift/blob/fff13330d545b914d069aad0ef9fab2b4456cbdd/stdlib/public/runtime/MetadataLookup.cpp#L1523-L1563
void DynamicReplacementDescriptor::enableReplacement() const {
  // 拿到根節點
  auto *chainRoot = const_cast<DynamicReplacementChainEntry *>(
      replacedFunctionKey->root.get());
    
  // 通過遍歷連結串列來保證這個方法是 enabled 的
  for (auto *curr = chainRoot; curr != nullptr; curr = curr->next) {
    if (curr == chainEntry.get()) {
	  // 如果在 Replacement 鏈中發現了這個方法,說明已經 enable,中斷操作
      swift::swift_abortDynamicReplacementEnabling();
    }
  }
    
  // 將 Root 節點的 imp 儲存到 current,並將 current 頭插
  auto *currentEntry =
      const_cast<DynamicReplacementChainEntry *>(chainEntry.get());
  currentEntry->implementationFunction = chainRoot->implementationFunction;
  currentEntry->next = chainRoot->next;
    
  // Root 繼續進行頭插操作
  chainRoot->next = chainEntry.get();
	// Root 的 imp 換成了 replacement 實現
  chainRoot->implementationFunction = replacementFunction.get();
}
    
// 同理 disable 做逆操作
void DynamicReplacementDescriptor::disableReplacement() const {
  const auto *chainRoot = replacedFunctionKey->root.get();
  auto *thisEntry =
      const_cast<DynamicReplacementChainEntry *>(chainEntry.get());
    
  // Find the entry previous to this one.
  auto *prev = chainRoot;
  while (prev && prev->next != thisEntry)
    prev = prev->next;
  if (!prev) {
    swift::swift_abortDynamicReplacementDisabling();
    return;
  }
    
  // Unlink this entry.
  auto *previous = const_cast<DynamicReplacementChainEntry *>(prev);
  previous->next = thisEntry->next;
  previous->implementationFunction = thisEntry->implementationFunction;
}
複製程式碼

我們發現 Swift 中處理每一個 dynamic 方法,會為其建立一個 dynamicReplacement 連結串列來記錄實現記錄。那麼也就是說不管我們對原來的 dynamic 做了多少次 @_dynamicReplacement ,其實現原則上都會被記錄下來。但是呼叫方法後的執行程式碼我始終沒有找到對應的邏輯,所以無法判斷 Swift 在呼叫時機做了哪些事情。

通過 Unit Test 解決問題

以下思路是朋友 @Whirlwind 提供的。既然我們無法找到呼叫的實現,那麼另闢蹊徑:既然 Swift 已經通過鏈式記錄了所有的實現,那麼在單元測試的時候應該會有這種邏輯測試。

在根據關鍵字和檔案字尾搜尋了大量的單元測試檔案後,我們發現了這個檔案 dynamic_replacement_chaining.swift 。我們注意到 L13 的執行命令:

// RUN: %target-build-swift-dylib(%t/%target-library-name(B)) -I%t -L%t -lA %target-rpath(%t) -module-name B -emit-module -emit-module-path %t -swift-version 5 %S/Inputs/dynamic_replacement_chaining_B.swift -Xfrontend -enable-dynamic-replacement-chaining
複製程式碼

在命令引數中增加了 -Xfrontend -enable-dynamic-replacement-chaining ,第一反應:這個東西像 Build Settings 中的 Flags。翻看了 Build Settings 中所有的 Compile Flags,將其嘗試寫入 Other Swift Flags 中:

Swift 5 之後 "Method Swizzling"?

重新編譯執行,發現了一個神奇的結果:

Swift 5 之後 "Method Swizzling"?

出乎意料的達到了我們想要的結果。說明我們的實驗和猜想是正確的,Swift 在處理 dynamic 是將所有實現的 imp 儲存,並且也有辦法根據記錄的連結串列來觸發實現

一些不懷好意的揣測

@_dynamicReplacement 雖然在 Swift 5 中就已經帶入了 Swift 中,但是在官方論壇和官方倉庫中並未找到 Release 日誌痕跡,而我是從 Twitter 友的口頭才瞭解到的。並且這個 PR 雖然已經開了一年之久,蘋果在今年的 WWDC 之前就偷偷的 Merge 到了 master 分支上。

**不得不猜,Apple 是為了實現 SwiftUI,而專門定製的 Feature。**另外,在其他的一些特性中也能看到這種現象,例如 Function Builder(為了實現 SwiftUI 的 DSL)Combine(為了實現 SwiftUI 中的 Dataflow & Binding)也是如此。這種反社群未經同意的情況下,為了自己的技術產品而定製語言是一件好事嗎?

這裡腦補一個黑人問號,也許明年的我會說出“真香”!

Swift 5 之後 "Method Swizzling"?

相關文章