在咱們調試React Native或是Weex程序時,藉助於JavaScript的動態執行能力,能夠實現代碼的動態注入與熱更新調試,從而大大提升了UI和邏輯的調試效率。相反的,在Native代碼編程中,通常而言都須要不斷地重啓App來調試新代碼,對於一些編譯和連接腳本複雜的項目這無疑大大下降了開發效率,這時候,能夠藉助dlopen
打開動態庫和切面編程
的思想來實現運行時動態庫加載和邏輯替換,從而實現動態代碼注入。須要注意的是,該方式在Release到App Store的App中是被明令禁止的,且真機也沒法經過dlopen
打開一個沒有跟隨App一塊兒簽名的動態庫,因此此方法僅能用於模擬器調試。python
筆者經過上述原理實現了一個Native代碼熱部署的調試框架,命名爲Dyamk,本文將介紹其原理和使用方式。git
下面的GIF演示了一個簡單的代碼注入。github
上圖是Dyamk的架構和工做流程圖,Dyamk主要包括兩個部分,一個是用於建立和分發動態庫的DyamkInjector
,另外一個是運行於宿主Main App當中的DyamkClient
。macos
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_1
、SomeClass_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經過添加一個無侵入的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用於越獄設備插件的動態調試,將可以極大的提升開發效率。