Swift 5 以後 "Method Swizzling"?

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

引子

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

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

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

看到這裏是否是眼前一亮?咱們期待已久的 Method Swizzling 彷彿又回來了?swift

開始的時候只是驚喜,可是在平時的我的開發中,其實不多會用到 hook 邏輯(固然這裏說的不是公司項目)。直到有一天,朋友遇到了一個問題,因而又對這個東西作了一次較爲深刻的研究 ....bash

Method Swizzling in Objective-C

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

//
// 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。frontend

在執行處,咱們來調用一下 - sayWords 方法:ide

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);
}
複製代碼

因爲 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 在處理 dynamic 是將全部實現的 imp 保存,而且也有辦法根據記錄的鏈表來觸發實現

一些不懷好意的揣測

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

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

這裏腦補一個黑人問號,也許明年的我會說出「真香」!

相關文章
相關標籤/搜索