iOS通過注入動態庫的方式實現極速編譯調試(InjectionIII、熱重載、熱編譯)原理解析

iOS開發2019-09-11 08:39:03

黑客技術
點擊右側關注,瞭解黑客的世界!

Java開發進階
點擊右側關注,掌握進階之路!

Python開發
點擊右側關注,探討技術話題!


作者 | 黑化肥發灰,

來源丨知識小集

簡書地址:

https://www.jianshu.com/u/fb5591dbd1bf




前言


iOS 原生代碼的編譯調試,都是通過一遍又一遍地編譯重啟 APP來進行的。所以項目代碼量越大,編譯時間就越長。雖然我們可以將部分代碼先編譯成二進制集成到工程裏,來避免每次都全量編譯來加快編譯速度,但即使這樣,每次編譯都還是需要重啟App,需要再走一遍調試流程。幸運的是,John Holdsworth 開發了一個叫做 InjectionIII 的工具可以動態地將 Swift 或 Objective-C 的代碼在已運行的程序中執行,以加快調試速度,同時保證程序不用重啟。


看過幾篇寫 Injection 的文章,但都比較老,而且也沒有介紹全面,因此決定自己動手寫一下,從應用到原理完整介紹一遍。

實踐步驟


1、下載 InjectionIII,並安裝好



2、運行 InjectionIII


InjectionIII 運行後 icon 如下,藍色針頭



3、修改項目源碼


在 - (BOOL)application:(UIApplication *)application willFinishLaunchingWithOptions:(NSDictionary *)launchOptions 方法裏添加如下代碼


- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {

。。。。。。

#if DEBUG
    [[NSBundle bundleWithPath:@"/Applications/InjectionIII.app/Contents/Resources/iOSInjection.bundle"] load];
#endif

}


4、運行項目,選擇項目目錄


加載 bundle 的時候會讓你選擇項目目錄,InjectionIII 就是監控的這個目錄,裏面文件變動會有通知。



5、注意


• 只能在模擬器上看改動效果,真機上不行

• 如果改了一個頁面代碼,要退出頁面然後進入才能看到

原理介紹


1、總體介紹


InjectionIII 分為server 和 client部分,client部分在你的項目啟動的時候會作為 bundle load 進去,server部分在Mac App那邊,server 和 client 都會在後台發送和監聽 Socket 消息,


實現邏輯分別在 


InjectionServer.mm 和 InjectionClient.mm


裏的 runInBackground 方法裏面。InjectionIII 會監聽源代碼文件的變化,如果文件被改動了,server 就會通過 Socket 通知 client 進行 rebuildClass 重新對該文件進行編譯,打包成動態庫,也就是 .dylib 文件。


然後通過 dlopen 把動態庫文件載入運行的 App 裏,接下來 dlsym 會得到動態庫的符號地址,然後就可以處理類的替換工作。當類的方法被替換後,我們就可以開始重新繪製界面了。整個過程無需重新編譯和重載 App,使用動態庫方式極速調試的目的就達成了。


原理用一張圖表示如下


備註:此圖作者戴銘


2、編譯 InjectionIII 源碼


要了解一個工具,最好的方式當然直接看源碼了。InjectionIII 的源代碼鏈接如下:


https://github.com/johnno1962/InjectionIII ,


可以下載下來對着源碼分析。


clone 源碼以後直接編譯會報錯,下面一一解決。


首先如下圖,證書問題,直接勾選 Automatically manage signing,同時選擇一下團隊,注意 InjectionIII 和 InjectionBundle 兩個 target 都要選擇好。



接着編譯還是會出問題,如下圖所示,説是找不到 XprobeSwift.swift 和 SwiftSwizzler.swift 兩個文件,到 Finder 裏面根據目錄去找確實找不到,XprobePlugin 文件夾為空。



因此到 InjectionIII 的 github 上去看個究竟,發現 SwiftTrace 和 XprobePlugin 是這樣的



點擊都能跳轉到對應的倉庫那裏。好了,知道原因了,看來這個文件夾下要把這個倉庫 clone 下來。


clone 了以後運行項目,還是報錯,如下



看了一下,報錯信息裏面有 Xcode101.app,這顯然不對啊,應該是 Xcode.app,不然路徑肯定不對,然後去 Run Script 裏面看到了確實有 Xcode101,如下圖



將 Xcode101 全部改為 Xcode,然後繼續編譯,終於可以看到下面這個無比讓人欣慰的界面了。接下來就可以放肆地玩了。



最後説一下,如果我們要用源碼分析,當然要將源碼編譯起來,打斷點看流程。


這樣的話就在


 willFinishLaunchingWithOptions 裏面加載的路徑就要相應修改了,我這邊是這樣的。


#if DEBUG
    [[NSBundle bundleWithPath:@"/Users/xxxxxx/Library/Developer/Xcode/DerivedData/InjectionIII-fvgzelftiqykfxebnrehvynhccwz/Build/Products/Debug/InjectionIII.app/Contents/Resources/iOSInjection.bundle"] load];
#endif


可以在 Products 下選中 InjectionIII.app 然後 Show in Finder,參考我的目錄一步一步點進去找到 iOSInjection.bundle。



3、源碼分析


1. InjectionIII 項目運行前


InjectionIII 項目有兩個 target,一個 InjectionIII,一個 InjectionBundle。如下圖

可以看看 InjectionIII Build Phases 下面的 Run Script,從中能找到項目具體對這個 target 做了什麼,腳本如下


SYMROOT=/tmp/Injection
export PLATFORM_DIR_OS=$DEVELOPER_DIR/Platforms/iPhoneSimulator.platform &&
"$DEVELOPER_BIN_DIR"/xcodebuild SYMROOT=$SYMROOT PRODUCT_NAME=iOSInjection LD_RUNPATH_SEARCH_PATHS="$DEVELOPER_DIR/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphonesimulator $PLATFORM_DIR_OS/Developer/Library/Frameworks @loader_path/Frameworks" -arch x86_64 -sdk iphonesimulator -config Debug -target InjectionBundle &&
/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild SYMROOT=/tmp/Injection10 PRODUCT_NAME=iOSInjection10 LD_RUNPATH_SEARCH_PATHS="$DEVELOPER_DIR/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphonesimulator $PLATFORM_DIR_OS/Developer/Library/Frameworks @loader_path/Frameworks" -arch x86_64 -sdk iphonesimulator -config Debug -target InjectionBundle &&
rsync -au $SYMROOT/Debug-iphonesimulator/iOSInjection.bundle /tmp/Injection10/Debug-iphonesimulator/iOSInjection10.bundle "$CODESIGNING_FOLDER_PATH/Contents/Resources" &&

export PLATFORM_DIR_OS=$DEVELOPER_DIR/Platforms/AppleTVSimulator.platform &&
"$DEVELOPER_BIN_DIR"/xcodebuild SYMROOT=$SYMROOT PRODUCT_NAME=tvOSInjection LD_RUNPATH_SEARCH_PATHS="$DEVELOPER_DIR/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/appletvsimulator $PLATFORM_DIR_OS/Developer/Library/Frameworks @loader_path/Frameworks" -arch x86_64 -sdk appletvsimulator -config Debug -target InjectionBundle &&
/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild SYMROOT=/tmp/Injection10 PRODUCT_NAME=tvOSInjection10 LD_RUNPATH_SEARCH_PATHS="$DEVELOPER_DIR/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/appletvsimulator $PLATFORM_DIR_OS/Developer/Library/Frameworks @loader_path/Frameworks" -arch x86_64 -sdk appletvsimulator -config Debug -target InjectionBundle &&
rsync -au $SYMROOT/Debug-appletvsimulator/tvOSInjection.bundle /tmp/Injection10/Debug-appletvsimulator/tvOSInjection10.bundle "$CODESIGNING_FOLDER_PATH/Contents/Resources" &&

export PLATFORM_DIR_OS=$DEVELOPER_DIR/Platforms/MacOS.platform &&
/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild SYMROOT=/tmp/Injection10 PRODUCT_NAME=macOSInjection10 LD_RUNPATH_SEARCH_PATHS="$DEVELOPER_DIR/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphonesimulator $PLATFORM_DIR_OS/Developer/Library/Frameworks @loader_path/Frameworks" -arch x86_64 -config Debug -target InjectionBundle &&
rsync -au /tmp/Injection10/Debug/macOSInjection10.bundle "$CODESIGNING_FOLDER_PATH/Contents/Resources" &&
find $CODESIGNING_FOLDER_PATH/Contents/Resources/*.bundle -name '*.h' -delete


內容比較多,重點關注的是iPhoneSimulator.platform 平台


SYMROOT=/tmp/Injection
export PLATFORM_DIR_OS=$DEVELOPER_DIR/Platforms/iPhoneSimulator.platform &&
"$DEVELOPER_BIN_DIR"/xcodebuild SYMROOT=$SYMROOT PRODUCT_NAME=iOSInjection LD_RUNPATH_SEARCH_PATHS="$DEVELOPER_DIR/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphonesimulator $PLATFORM_DIR_OS/Developer/Library/Frameworks @loader_path/Frameworks" -arch x86_64 -sdk iphonesimulator -config Debug -target InjectionBundle &&
/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild SYMROOT=/tmp/Injection10 PRODUCT_NAME=iOSInjection10 LD_RUNPATH_SEARCH_PATHS="$DEVELOPER_DIR/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphonesimulator $PLATFORM_DIR_OS/Developer/Library/Frameworks @loader_path/Frameworks" -arch x86_64 -sdk iphonesimulator -config Debug -target InjectionBundle &&
rsync -au $SYMROOT/Debug-iphonesimulator/iOSInjection.bundle /tmp/Injection10/Debug-iphonesimulator/iOSInjection10.bundle "$CODESIGNING_


可以看到首先使用 xcodebuild 命令將 InjectionBundle 編譯成名字為 iOSInjection.bundle 的動態庫,放到 /tmp/Injection 目錄下



然後使用 rsync (rsync命令介紹--可以使用 rsync 同步本地硬盤中的不同目錄)命令將 iOSInjection.bundle 同步到 InjectionIII.app 目錄下 


"$CODESIGNING_FOLDER_PATH/Contents/Resources"


如下圖



我們需要熱加載的項目的 willFinishLaunchingWithOptions 方法裏面要加載 iOSInjection.bundle。這個作為客户端和 InjectionIII 通信。注意,bundle 是不能被鏈接的 dylib,只能在運行時使用 dlopen() 加載。


2. Injection 初始化


• 服務端初始化


在 InjectionIII 啟動時調用 SimpleSocket 的 startServer 方法並傳入端口號 在後台運行開啟服務端socket 服務用於和客户端的通訊,並運行 InjectionServer 類的 runInBackground 方法進行初始化操作,彈出選擇項目目錄對話框,如果之前選擇過的話就不會彈出。


+ (void)startServer:(NSString *)address {
    [self performSelectorInBackground:@selector(runServer:) withObject:address];
}

+ (void)runServer:(NSString *)address {
    struct sockaddr_storage serverAddr;
    [self parseV4Address:address into:&serverAddr;];

    int serverSocket = [self newSocket:serverAddr.ss_family];
    if (serverSocket 0)
        return;

    if (bind(serverSocket, (struct sockaddr *)&serverAddr;, serverAddr.ss_len) 0)
        [self error:@"Could not bind service socket: %s"];
    else if (listen(serverSocket, 5) 0)
        [self error:@"Service socket would not listen: %s"];
    else
        while (TRUE) {
            struct sockaddr_storage clientAddr;
            socklen_t addrLen = sizeof clientAddr;

            int clientSocket = accept(serverSocket, (struct sockaddr *)&clientAddr;, &addrLen;);
            if (clientSocket > 0) {
                @autoreleasepool {
                    struct sockaddr_in *v4Addr = (struct sockaddr_in *)&clientAddr;
                    NSLog(@"Connection from %s:%d
"
,
                          inet_ntoa(v4Addr->sin_addr), ntohs(v4Addr->sin_port));
                    [[[self alloc] initSocket:clientSocket] run];
                }
            }
            else
                [NSThread sleepForTimeInterval:.5];
        }
}


• 客户端初始化


在 InjectionIII 啟動後,打開需要調試的 Xcode 工程,Xcode 工程必須在其App啟動方法里加載 InjectionIII 目錄下對應的 bundle 動態庫,bundle 是不能被鏈接的 dylib,只能在運行時使用 dlopen() 加載。此時運行需要調試的 Xcode 工程,


App 會加載 bundle 動態庫,並執行動態庫裏 InjectionClient 類的 +load 方法。在 InjectionClient 類的 +load 方法裏會調用其 connectTo 方法傳入對應的端口號來連接服務端的 socket 服務用於通訊,並運行其runInBackground 方法進行初始化操作。


+ (void)load {
    // connect to InjetionIII.app using sicket
    if (InjectionClient *client = [self connectTo:INJECTION_ADDRESS])
        [client run];
    else
        printf("💉 Injection loaded but could not connect. Is InjectionIII.app running?
"
);

}


• Injection 初始化詳細步驟


首先服務端和客户端會讀取一些數據傳給對方保存在 SwiftEval 單例中方便後期進行代碼注入,傳送的數據包括:Injection App 的沙盒目錄、調試 Xcode 工程的物理路徑、目標 App 芯片類型和沙盒路徑、Xcode App 物理路徑和調試工程的 build 物理路徑 等。


接下來服務端會通過 FileWatcher 開啟調試工程目錄下文件改變的監聽,當文件發生改變後會執行傳入的 injector block 方法來進行代碼注入。最後客户端和服務端都會通過 socket 的 readInt 來持續獲取交互命令來執行對應的操作。


項目啟動以後可以在控制枱執行 image list -o -f 查看加載的動態庫,可以看到 iOSInjection.bundle 確實已經以動態庫的形式加載進來了。



3. 重新編譯、打包動態庫和簽名


InjectionIII 運行以後會在後台監聽 socket 消息,每隔0.5秒檢查一次是否有客户端連接過來,等我們app 啟動以後加載了 iOSInjection.bundle,就會啟動 client 跟 server 建立連接,然後就可以發送消息了。



當我們在調試工程中修改了代碼並保存後,FileWatcher 會立即收到文件改變的回調,FileWatcher 使用 Mac OS 上的 FSEvents 框架實現,並執行如下圖的 injector block 方法。



在該方法中會判斷是否為自動注入,如果是則執行 injectPending 方法,通過 socket 對客户端下發InjectionInject 代碼注入命令並傳入需要代碼注入的文件名物理路徑。如果不是自動注入那麼就在控制枱輸出“xx文件已保存,輸入ctrl-=進行注入”告訴我們手動注入的觸發方式。


當客户端收到代碼注入命令後會調用 SwiftInjection 類的 injectWithOldClass: classNameOrFile: 方法進行代碼注入,如下圖:

    
public class func inject(oldClass: AnyClass?, classNameOrFile: String{
        do {
            let tmpfile = try SwiftEval.instance.rebuildClass(oldClass: oldClass,
                                    classNameOrFile: classNameOrFile, extra: nil)
            try inject(tmpfile: tmpfile)
        }
        catch 
{
        }
    }


這個方法分為兩步,第一步是調用 SwiftEval 單例的 rebuildClass 方法來進行修改文件的重新編譯、打包動態庫和簽名,第二步是加載對應的動態庫進行方法的替換。這裏我們先看第一步的操作步驟。


首先根據修改的類文件名在 Injection App 的沙盒路徑生成對應的編譯腳本,腳本命名為eval+數字,數字以100為基數,每次遞增1。腳本生成調用方法如下圖:


injectionNumber += 1
        let tmpfile = "(tmpDir)/eval(injectionNumber)"
        let logfile = "(tmpfile).log"

        guard var (compileCommand, sourceFile) = try SwiftEval.compileByClass[classNameOrFile] ??
            findCompileCommand(logsDir: logsDir, classNameOrFile: classNameOrFile, tmpfile: tmpfile) ??
            SwiftEval.longTermCache[classNameOrFile].flatMap({ ($0 as! String, classNameOrFile) }) else {
            throw evalError("""
                Could not locate compile command for (classNameOrFile)
                (Injection does not work with Whole Module Optimization.
                There are also restrictions on characters allowed in paths.
                All paths are also case sensitive is another thing to check.)
                """
)
        }




其中 findCompileCommand 為生成 sh 腳本的具體方法,主要是針對當前修改類設置對應的編譯腳本命令。由於腳本太長,這裏就不貼上來了,有興趣的同學可以自行查看。


使用改動類的編譯腳本可以生成其.o文件,具體如下圖:


let toolchain = ((try! NSRegularExpression(pattern: "\s*(\S+?\.xctoolchain)", options: []))
            .firstMatch(in: compileCommand, options: [], range: NSMakeRange(0, compileCommand.utf16.count))?
            .range(at: 1)).flatMap { compileCommand[$0] } ?? "(xcodeDev)/Toolchains/XcodeDefault.xctoolchain"

let osSpecific: String
if compileCommand.contains("iPhoneSimulator.platform") {
    osSpecific = "-isysroot (xcodeDev)/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk -mios-simulator-version-min=9.0 -L(toolchain)/usr/lib/swift/iphonesimulator -undefined dynamic_lookup"// -Xlinker -bundle_loader -Xlinker "(Bundle.main.executablePath!)""


這裏針對模擬器環境進行腳本配置,配置完成後使用 clang 命令把對應的.o文件生成相同名字的動態庫,具體如下圖:


guard shell(command"""
    (xcodeDev)/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -arch "
(arch)" -bundle (osSpecific) -dead_strip -Xlinker -objc_abi_version -Xlinker 2 -fobjc-arc (tmpfile).o -L "(frameworks)" -F "(frameworks)" -rpath "(frameworks)" -o (tmpfile).dylib >>(logfile) 2>&1
    """
else {
    throw evalError("Link failed, check (tmpDir)/command.sh
(try! String(contentsOfFile: logfile))"
)
}


由於蘋果會對加載的動態庫進行簽名校驗,所以我們下一步需要對這個動態庫進行簽名,使用 signer block 方法來進行簽名操作,簽名方法如下:


// make available implementation of signing delegated to macOS app
[SwiftEval sharedInstance].signer = ^BOOL(NSString *_Nonnull dylib) {
    [self writeCommand:InjectionSign withString:dylib];
    return [reader readString].boolValue;
};


由於簽名需要使用 Xcode 環境,所以客户端是無法進行的,只能通過 socket 告訴服務端來進行操作。當服務端收到 InjectionSign 簽名命令後會調用 SignerService 類的 codesignDylib 來對相應的動態庫進行簽名操作,具體簽名腳本操作如下:


服務端代碼如下


case InjectionSign: {
    NSString *sockStr = [self readString];
    BOOL signedOK = [SignerService codesignDylib:sockStr];
    [self writeCommand:InjectionSigned withString: signedOK ? @"1"@"0"];
    break;
}

+ (BOOL)codesignDylib:(NSString *)dylib {
    NSString *command = [NSString stringWithFormat:@""
                         "(export CODESIGN_ALLOCATE=/Applications/Xcode.app"
                         "/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/codesign_allocate; "
                         "/usr/bin/codesign --force -s '-' "%@")", dylib];
    return system(command.UTF8String) >> 8 == EXIT_SUCCESS;
}




至此修改文件的重新編譯、打包動態庫和簽名操作就全部完成了,接下來就是我們最熟悉的加載動態庫進行方法替換了。


4. 加載動態庫進行方法替換


• 加載並注入動態庫


上面提到了在調用了 SwiftEval 類的 rebuildClass 方法進行編譯打包動態庫和簽名後,會再調用SwiftInjection 類的 inject 方法來進行動態庫的加載和方法的替換,讓我們一起看看具體的實現步驟。在獲取到改變後的新類的符號地址後就可以通過 runtime 的方式來進行方法的替換了。


• 方法的替換


在拿到新類的符號地址後,我們把新類裏所有的類方法和實例方法都替換到對應的舊類中,使用的是SwiftInjection 的 injection 方法,具體實現如下圖:



最後我們修改的代碼就在不需要重啟 App 重新編譯的情況下生效了,當然為了執行修改過的代碼,需要退出當前頁面,再進來才可以看到效果。


參考文章

[1]http://www.sohu.com/a/322177371_781946 
[2]https://time.geekbang.org/column/article/87188 
[3]https://juejin.im/entry/5b1f4c5f5188257d7c35e9d9


 推薦↓↓↓ 

👉16個技術公眾號】都在這裏!

涵蓋:程序員大咖、源碼共讀、程序員共讀、數據結構與算法、黑客技術和網絡安全、大數據科技、編程前端、Java、Python、Web編程開發、Android、iOS開發、Linux、數據庫研發、幽默程序員等。

萬水千山總是情,點個 “在看” 行不行
https://hk.wxwenku.com/d/201348634