通過dylib實現iOS執行時Native程式碼注入(動態除錯)

Soulghost發表於2018-07-08

背景

在我們除錯React Native或是Weex程式時,藉助於JavaScript的動態執行能力,可以實現程式碼的動態注入與熱更新除錯,從而大大提高了UI和邏輯的除錯效率。相反的,在Native程式碼程式設計中,一般而言都需要不斷地重啟App來除錯新程式碼,對於一些編譯和連結指令碼複雜的專案這無疑大大降低了開發效率,這時候,可以藉助dlopen開啟動態庫和切面程式設計的思想來實現執行時動態庫載入和邏輯替換,從而實現動態程式碼注入。需要注意的是,該方式在Release到App Store的App中是被明令禁止的,且真機也無法通過dlopen開啟一個沒有跟隨App一起簽名的動態庫,所以此方法僅能用於模擬器除錯

筆者通過上述原理實現了一個Native程式碼熱部署的除錯框架,命名為Dyamk,本文將介紹其原理和使用方式。

效果

下面的GIF演示了一個簡單的程式碼注入。

通過dylib實現iOS執行時Native程式碼注入(動態除錯)

原始碼

github.com/Soulghost/D…

原理

概述

通過dylib實現iOS執行時Native程式碼注入(動態除錯)

上圖是Dyamk的架構和工作流程圖,Dyamk主要包括兩個部分,一個是用於建立和分發動態庫的DyamkInjector,另一個是執行於宿主Main App當中的DyamkClient

DyamkInjector是一個iOS動態庫工程,當動態庫完成編譯後,會執行一系列指令碼,將動態庫簽名、移動到共享目錄、通過Socket通知DyamkClient有新的動態庫可載入。

宿主Main App中的DyamkClient在收到Socket訊息後,會從共享目錄中載入新生成的動態庫,由於Dyamk已經約定好了動態庫的切面執行方式,因此動態庫載入後會按照約定的介面進行執行,從而動態修改已有的邏輯,實現動態Native程式碼除錯。

注入器部分

注入器主要由兩個Target構成,一個是Xcode動態庫工程DyamkInjector,用於編譯和生成動態庫,另一個是前者的Aggregate物件BuildMe,用於實現在動態庫簽名之後的移動和通知,這裡之所以使用了一個Aggregate物件,是為了保證動態庫簽名完成後才執行後續指令碼。

通過dylib實現iOS執行時Native程式碼注入(動態除錯)

DyamkInjector工程中,包含了一個編譯前指令碼Do symbol replace,用於實現動態符號替換,這裡替換的是動態庫原始碼的類名,做這個替換的目的在於Objective-C的執行時動態庫載入限制。在Objective-C中使用dlopen開啟動態庫後,不能通過dlclose將其關閉,也不能通過dlopen實現同名覆蓋,有關內容可以參考stackoverflow.com/questions/8…。因此在每次生成動態庫時,對動態庫的名稱以及動態庫內的類名都進行了動態替換,替換的方式為提供一個計數字尾,形如SomeClass_1SomeClass_2

為了保證注入器生成的動態庫及其符號和宿主App中的DyamkClient讀取的相關內容的一致性,需要通過一個共享檔案來記錄當前動態庫的名稱以及符號名稱,這個檔案被命名為framework_version,並通過數字儲存當前的符號字尾值,這個檔案和動態庫被儲存在同一目錄下,以便為注入器和宿主中的Client共享,在Dyamk中,使用了/opt/Dyamk/dylib作為共享資料夾,這也利用了iOS模擬器能夠讀取macos檔案系統這一特性

通過上述描述,Do symbol replace指令碼的功能變得清晰起來,它需要讀取共享檔案下的framework_version檔案,並完成動態庫的符號替換。

#!/bin/sh
# 拼接framework_version的路徑
cd /opt/Dyamk/dylib
path=`pwd`'/'
number_name='framework_version'
number=$path$number_name
v=0
# 判斷檔案是否存在
if [ -e $number ]; then
# 存在則直接讀取
v=`cat $number_name`
else
# 不存在則按照0處理
echo 0 > $number_name
fi
# 通過正規表示式動態替換動態庫原始碼中的符號
sed -i -e 's/DyamkNativeInjector_[0-9]*/DyamkNativeInjector_'$v'/g' ${SRCROOT}'/DyamkInjector/core/DyamkNativeInjector.m'
複製程式碼

在Aggregate物件BuildMe中包含了四個指令碼,他們均在動態庫完成編譯、連結、簽名後才執行。

  • Delete old dylib

    該指令碼用於刪除共享目錄中已生成的動態庫,從而保證新生成的能夠正確的將其替換。

  • Copy dylib

    該指令碼使用了Xcode自帶的Copy File Phase功能,將新生成的動態庫複製到共享目錄。

  • Process with dylib

    該指令碼用於替換動態庫的名稱,與DyamkInjector物件中的符號修改邏輯一致,在完成動態庫名稱修改後,要將framework_version自增一,從而保證下次能夠使用新的名稱和符號。

    #!/bin/sh
    cd /opt/Dyamk/dylib
    path=`pwd`'/'
    number_name='framework_version'
    number=$path$number_name
    v=0
    if [ -e $number ]; then
      v=`cat $number_name`
    else
      echo 0 > $number_name
    fi
    # 獲取並替換動態庫名稱
    from="DyamkInjector.framework/DyamkInjector"
    to="DyamkInjector.framework/DyamkInjector_"$v
    mv $from $to
    
    # 增加framework_version檔案中的動態庫符號計數
    v="$(($v+1))"
    echo $v > $number_name
    
    複製程式碼
  • Trig Update

    該指令碼用於通知宿主中的DyamkClient有新的動態庫可以載入,通知管道為Socket。

    # -*- coding: utf-8 -*-
    
    import socket
    import sys
    
    def conn():
        args = sys.argv
        ip = args[1]
        port = int(args[2])
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.connect((ip, port))
        # 通知訊息的內容為當前動態庫版本號
        f = open('/opt/Dyamk/dylib/framework_version', 'r')
        number = int(f.readlines()[0])
        if number > 0:
            number -= 1
        msg = "{}".format(number)
        s.send(msg.encode())
        s.close()
    
    if __name__ == '__main__':
        conn()
    複製程式碼

通過上述內容可以知道,DyammInjector完成了對動態庫的生成和加工,以及對宿主App中Client的通知工作,這也是Dyamk中最複雜的部分,Client端部分僅僅需要監聽Socket訊息並且完成動態庫載入,因此邏輯會變成比較簡單。

Client部分

Client通過新增一個無侵入的DyamkClient框架來實現動態庫載入,筆者已經將其封裝為一個CocoaPods庫以方便使用。

Client通過Socket實現訊息監聽,這裡使用了CocoaAsyncSocket來實現這一功能,有關Socket的監聽程式碼不再贅述,這裡主要介紹動態庫載入有關的程式碼。

// 該方法在Socket收到訊息後呼叫,在呼叫之前已經將當前動態庫版本號儲存在`_currentDylibNo`成員變數中
- (void)performDylib {
    // 共享目錄中的dylib根目錄
    NSString *libPath = @"/opt/Dyamk/dylib/DyamkInjector.framework";
    // 在共享目錄中拼接動態庫二進位制路徑
    libPath = [libPath stringByAppendingPathComponent:[NSString stringWithFormat:@"DyamkInjector_%@", @(self.currentDylibNo)]];
    // 開啟動態庫
    void *handle = dlopen(libPath.UTF8String, RTLD_NOW);
    if (!handle) {
        NSLog(@"Error: cannot find <%@>", libPath);
        return;
    }
    // 拼接動態庫符號
    NSString *className = [NSString stringWithFormat:@"DyamkNativeInjector_%@", @(self.currentDylibNo)];
    // 類載入和切面方法執行
    Class class = NSClassFromString(className);
    if (class == nil) {
        NSLog(@"Error: cannot find class %@", className);
        dlclose(handle);
        return;
    }
    [class performSelector:@selector(run)];
    // 關閉動態庫,由於Objective-C的執行時限制,實際上這一句並不能將動態庫解除安裝
    dlclose(handle);
}
複製程式碼

每當DyamkInjector工程的Target BuildMe 編譯時,就會通過Socket通知Client,讀取和載入動態庫,並執行切面方法,從而完成動態程式碼注入。

切面程式設計部分

DyamkInjector的工程中有一個DyamkCodePlayground.m檔案,其中的__dyamk_debug_code_goes_here函式是動態庫執行的起點,所有需要動態注入的程式碼都需要在這裡去編寫,由於所有的程式碼均以切面的形式存在,因此在處理事件繫結時需要進行執行時方法新增,新增的步驟如下。

處理動態事件繫結

  • 新建一個函式,函式的前兩個引數型別分別為idSEL,這是由Objective-C的訊息轉發機制決定的,其中第一個引數id為訊息接收者,第二個引數SEL為方法的選擇器,這裡我們假設為SomeClass的一個新增一個add例項方法,它接收一個引數n,來累加類內的計數器v。

    void __SomeClass__add(id self, SEL _cmd, int n) {
        self.v += n;
    }
    複製程式碼
  • 通過class_replaceMethod實現方法的新增或替換,這裡使用replace而不是add是因為在多次載入時,需要對原來已經新增的方法進行覆蓋。

    class_replaceMethod(NSStringFromClass(@"SomeClass"), @selector(add:), (IMP)__SomeClass__add, "v@:i");
    複製程式碼

    這裡需要注意的是最後一個引數,它是方法的Type Encoding,可以通過 nshipster.com/type-encodi… 進一步瞭解。

  • 在完成了上述步驟後,就可以以切面形式對某個例項動態新增事件處理函式了,隨後即可通過selector的形式將其繫結到特定事件,由於編譯期檢查不到動態繫結的selector,所以會出現警告,因此__dyamk_debug_code_goes_here函式使用預編譯指令消除了這一警告。

    #pragma clang diagnostic push
    #pragma clang diagnostic ignored "-Wundeclared-selector"
    
    void __dyamk_debug_code_goes_here() {
        // code goes here
    }
    
    #pragma clang diagnostic pop
    複製程式碼

通過巨集函式簡化操作

上述事件繫結過程在使用中非常不便,且為了避免符號衝突,需要新增繁瑣而冗長的字首,為了解決這個問題,筆者封裝了一系列的巨集函式,來解決這一問題,例如函式的定義可以通過巨集函式進行簡化,下面是對比。

// 原來的實現
void __SomeClass__add(id self, SEL _cmd, int n) {
    self.v += n;
}

// 通過巨集函式實現
Dyamk_Method_1(void, add, int, n) {
    self.v += n;
}
複製程式碼

巨集函式將每個用於Objective-C訊息接收的函式的公共部分進行了抽象,開發者只需要填寫返回值型別、函式名和引數列表,這裡的引數列表是以type、name、type、name...的形式存在,Dyamk_Method_N中的N代表所定義的函式除去前兩個公共引數外的引數個數。

同樣的,動態方法新增也通過巨集函式進行了相應簡化。

// 原來的實現
class_replaceMethod(NSStringFromClass(@"SomeClass"), @selector(add:), (IMP)__SomeClass__add, "v@:i");

// 通過巨集函式實現
Dyamk_AddMethod(SomeClass, @selector(add:), add, v@:i);
複製程式碼

使用教程

有關使用的文件可以參考GitHub上的Dyamk Wiki,目前使用Wiki依然在完善中。

不足與展望

筆者曾經嘗試將dylib利用網路傳送到iOS真機的沙盒中進行真機動態除錯,奈何真機的dlopen函式總是失敗,同樣的動態庫如果隨著App靜態打包則可以進行載入,因此筆者猜測與簽名機制有關,這一機制導致該框架暫時只能在模擬器上使用。

對於越獄開發而言,每次修改了dylib後都要進行deb打包和重新安裝,以及App重啟,對於一些體量較大的App,例如SpringBoard.app會耽誤較多的時間,如果能夠將Dyamk用於越獄裝置外掛的動態除錯,將能夠極大的提高開發效率。

相關文章