經過dylib實現iOS運行時Native代碼注入(動態調試)

背景

在咱們調試React Native或是Weex程序時,藉助於JavaScript的動態執行能力,能夠實現代碼的動態注入與熱更新調試,從而大大提升了UI和邏輯的調試效率。相反的,在Native代碼編程中,通常而言都須要不斷地重啓App來調試新代碼,對於一些編譯和連接腳本複雜的項目這無疑大大下降了開發效率,這時候,能夠藉助dlopen打開動態庫和切面編程的思想來實現運行時動態庫加載和邏輯替換,從而實現動態代碼注入。須要注意的是,該方式在Release到App Store的App中是被明令禁止的,且真機也沒法經過dlopen打開一個沒有跟隨App一塊兒簽名的動態庫,因此此方法僅能用於模擬器調試python

筆者經過上述原理實現了一個Native代碼熱部署的調試框架,命名爲Dyamk,本文將介紹其原理和使用方式。git

效果

下面的GIF演示了一個簡單的代碼注入。github

源碼

github.com/Soulghost/D…正則表達式

原理

概述

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

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

宿主Main App中的DyamkClient在收到Socket消息後,會從共享目錄中加載新生成的動態庫,因爲Dyamk已經約定好了動態庫的切面執行方式,所以動態庫加載後會按照約定的接口進行執行,從而動態修改已有的邏輯,實現動態Native代碼調試。bash

注入器部分

注入器主要由兩個Target構成,一個是Xcode動態庫工程DyamkInjector,用於編譯和生成動態庫,另外一個是前者的Aggregate對象BuildMe,用於實如今動態庫簽名以後的移動和通知,這裏之因此使用了一個Aggregate對象,是爲了保證動態庫簽名完成後才執行後續腳本。網絡

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文件系統這一特性app

經過上述描述,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用於越獄設備插件的動態調試,將可以極大的提升開發效率。

相關文章
相關標籤/搜索