背景
在我們除錯React Native或是Weex程式時,藉助於JavaScript的動態執行能力,可以實現程式碼的動態注入與熱更新除錯,從而大大提高了UI和邏輯的除錯效率。相反的,在Native程式碼程式設計中,一般而言都需要不斷地重啟App來除錯新程式碼,對於一些編譯和連結指令碼複雜的專案這無疑大大降低了開發效率,這時候,可以藉助dlopen
開啟動態庫和切面程式設計
的思想來實現執行時動態庫載入和邏輯替換,從而實現動態程式碼注入。需要注意的是,該方式在Release到App Store的App中是被明令禁止的,且真機也無法通過dlopen
開啟一個沒有跟隨App一起簽名的動態庫,所以此方法僅能用於模擬器除錯。
筆者通過上述原理實現了一個Native程式碼熱部署的除錯框架,命名為Dyamk,本文將介紹其原理和使用方式。
效果
下面的GIF演示了一個簡單的程式碼注入。
原始碼
原理
概述
上圖是Dyamk的架構和工作流程圖,Dyamk主要包括兩個部分,一個是用於建立和分發動態庫的DyamkInjector
,另一個是執行於宿主Main App當中的DyamkClient
。
DyamkInjector
是一個iOS動態庫工程,當動態庫完成編譯後,會執行一系列指令碼,將動態庫簽名、移動到共享目錄、通過Socket通知DyamkClient
有新的動態庫可載入。
宿主Main App中的DyamkClient
在收到Socket訊息後,會從共享目錄中載入新生成的動態庫,由於Dyamk已經約定好了動態庫的切面執行方式,因此動態庫載入後會按照約定的介面進行執行,從而動態修改已有的邏輯,實現動態Native程式碼除錯。
注入器部分
注入器主要由兩個Target構成,一個是Xcode動態庫工程DyamkInjector
,用於編譯和生成動態庫,另一個是前者的Aggregate物件BuildMe
,用於實現在動態庫簽名之後的移動和通知,這裡之所以使用了一個Aggregate物件,是為了保證動態庫簽名完成後才執行後續指令碼。
在DyamkInjector
工程中,包含了一個編譯前指令碼Do symbol replace
,用於實現動態符號替換,這裡替換的是動態庫原始碼的類名,做這個替換的目的在於Objective-C的執行時動態庫載入限制。在Objective-C中使用dlopen
開啟動態庫後,不能通過dlclose
將其關閉,也不能通過dlopen
實現同名覆蓋,有關內容可以參考stackoverflow.com/questions/8…。因此在每次生成動態庫時,對動態庫的名稱以及動態庫內的類名都進行了動態替換,替換的方式為提供一個計數字尾,形如SomeClass_1
、SomeClass_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
函式是動態庫執行的起點,所有需要動態注入的程式碼都需要在這裡去編寫,由於所有的程式碼均以切面的形式存在,因此在處理事件繫結時需要進行執行時方法新增,新增的步驟如下。
處理動態事件繫結
-
新建一個函式,函式的前兩個引數型別分別為
id
和SEL
,這是由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用於越獄裝置外掛的動態除錯,將能夠極大的提高開發效率。