iOS通過注入動(dòng)態(tài)庫(kù)的方式實(shí)現(xiàn)極速編譯調(diào)試(InjectionIII、熱重載、熱編譯)原理解析

前言

iOS 原生代碼的編譯調(diào)試,都是通過一遍又一遍地編譯重啟 APP來進(jìn)行的。所以項(xiàng)目代碼量越大,編譯時(shí)間就越長(zhǎng)。雖然我們可以將部分代碼先編譯成二進(jìn)制集成到工程里,來避免每次都全量編譯來加快編譯速度,但即使這樣,每次編譯都還是需要重啟App,需要再走一遍調(diào)試流程。幸運(yùn)的是,John Holdsworth 開發(fā)了一個(gè)叫做 InjectionIII 的工具可以動(dòng)態(tài)地將 Swift 或 Objective-C 的代碼在已運(yùn)行的程序中執(zhí)行,以加快調(diào)試速度,同時(shí)保證程序不用重啟。

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

實(shí)踐步驟

1. 下載 InjectionIII,并安裝好

2. 運(yùn)行 InjectionIII

InjectionIII 運(yùn)行后 icon 如下,藍(lán)色針頭

3. 修改項(xiàng)目源碼

在 - (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. 運(yùn)行項(xiàng)目,選擇項(xiàng)目目錄

加載 bundle 的時(shí)候會(huì)讓你選擇項(xiàng)目目錄,InjectionIII 就是監(jiān)控的這個(gè)目錄,里面文件變動(dòng)會(huì)有通知。

5. 注意

  • 只能在模擬器上看改動(dòng)效果,真機(jī)上不行
  • 如果改了一個(gè)頁面代碼,要退出頁面然后進(jìn)入才能看到

原理介紹

一. 總體介紹

InjectionIII 分為server 和 client部分,client部分在你的項(xiàng)目啟動(dòng)的時(shí)候會(huì)作為 bundle load 進(jìn)去,server部分在Mac App那邊,server 和 client 都會(huì)在后臺(tái)發(fā)送和監(jiān)聽 Socket 消息,實(shí)現(xiàn)邏輯分別在 InjectionServer.mm 和 InjectionClient.mm 里的 runInBackground 方法里面。InjectionIII 會(huì)監(jiān)聽源代碼文件的變化,如果文件被改動(dòng)了,server 就會(huì)通過 Socket 通知 client 進(jìn)行 rebuildClass 重新對(duì)該文件進(jìn)行編譯,打包成動(dòng)態(tài)庫(kù),也就是 .dylib 文件。然后通過 dlopen 把動(dòng)態(tài)庫(kù)文件載入運(yùn)行的 App 里,接下來 dlsym 會(huì)得到動(dòng)態(tài)庫(kù)的符號(hào)地址,然后就可以處理類的替換工作。當(dāng)類的方法被替換后,我們就可以開始重新繪制界面了。整個(gè)過程無需重新編譯和重載 App,使用動(dòng)態(tài)庫(kù)方式極速調(diào)試的目的就達(dá)成了。

原理用一張圖表示如下

備注:此圖作者戴銘

二. 編譯 InjectionIII 源碼

要了解一個(gè)工具,最好的方式當(dāng)然直接看源碼了。InjectionIII 的源代碼鏈接如下:https://github.com/johnno1962/InjectionIII,可以下載下來對(duì)著源碼分析。
clone 源碼以后直接編譯會(huì)報(bào)錯(cuò),下面一一解決。
首先如下圖,證書問題,直接勾選 Automatically manage signing,同時(shí)選擇一下團(tuán)隊(duì),注意 InjectionIII 和 InjectionBundle 兩個(gè) target 都要選擇好。

接著編譯還是會(huì)出問題,如下圖所示,說是找不到 XprobeSwift.swift 和 SwiftSwizzler.swift 兩個(gè)文件,到 Finder 里面根據(jù)目錄去找確實(shí)找不到,XprobePlugin 文件夾為空。

因此到 InjectionIII 的 github 上去看個(gè)究竟,發(fā)現(xiàn) SwiftTrace 和 XprobePlugin 是這樣的

點(diǎn)擊都能跳轉(zhuǎn)到對(duì)應(yīng)的倉(cāng)庫(kù)那里。好了,知道原因了,看來這個(gè)文件夾下要把這個(gè)倉(cāng)庫(kù) clone 下來。
clone 了以后運(yùn)行項(xiàng)目,還是報(bào)錯(cuò),如下

看了一下,報(bào)錯(cuò)信息里面有 Xcode101.app,這顯然不對(duì)啊,應(yīng)該是 Xcode.app,不然路徑肯定不對(duì),然后去 Run Script 里面看到了確實(shí)有 Xcode101,如下圖

將 Xcode101 全部改為 Xcode,然后繼續(xù)編譯,終于可以看到下面這個(gè)無比讓人欣慰的界面了。接下來就可以放肆地玩了。

最后說一下,如果我們要用源碼分析,當(dāng)然要將源碼編譯起來,打斷點(diǎn)看流程。這樣的話就在 willFinishLaunchingWithOptions 里面加載的路徑就要相應(yīng)修改了,我這邊是這樣的。

#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,參考我的目錄一步一步點(diǎn)進(jìn)去找到 iOSInjection.bundle。


三. 源碼分析

1. InjectionIII 項(xiàng)目運(yùn)行前

InjectionIII 項(xiàng)目有兩個(gè) target,一個(gè) InjectionIII,一個(gè) InjectionBundle。如下圖


可以看看 InjectionIII Build Phases 下面的 Run Script,從中能找到項(xiàng)目具體對(duì)這個(gè) 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

內(nèi)容比較多,重點(diǎn)關(guān)注的是iPhoneSimulator.platform 平臺(tái)

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"

可以看到首先使用 xcodebuild 命令將 InjectionBundle 編譯成名字為 iOSInjection.bundle 的動(dòng)態(tài)庫(kù),放到 /tmp/Injection 目錄下

然后使用 rsync (rsync命令介紹--可以使用 rsync 同步本地硬盤中的不同目錄)命令將 iOSInjection.bundle 同步到 InjectionIII.app 目錄下 "$CODESIGNING_FOLDER_PATH/Contents/Resources"
如下圖

我們需要熱加載的項(xiàng)目的 willFinishLaunchingWithOptions 方法里面要加載 iOSInjection.bundle。這個(gè)作為客戶端和 InjectionIII 通信。注意,bundle 是不能被鏈接的 dylib,只能在運(yùn)行時(shí)使用 dlopen() 加載。

2. Injection 初始化

  • 服務(wù)端初始化
    在 InjectionIII 啟動(dòng)時(shí)調(diào)用 SimpleSocket 的 startServer 方法并傳入端口號(hào) 在后臺(tái)運(yùn)行開啟服務(wù)端socket 服務(wù)用于和客戶端的通訊,并運(yùn)行 InjectionServer 類的 runInBackground 方法進(jìn)行初始化操作,彈出選擇項(xiàng)目目錄對(duì)話框,如果之前選擇過的話就不會(huì)彈出。
+ (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\n",
                          inet_ntoa(v4Addr->sin_addr), ntohs(v4Addr->sin_port));
                    [[[self alloc] initSocket:clientSocket] run];
                }
            }
            else
                [NSThread sleepForTimeInterval:.5];
        }
}

  • 客戶端初始化
    在 InjectionIII 啟動(dòng)后,打開需要調(diào)試的 Xcode 工程,Xcode 工程必須在其App啟動(dòng)方法里加載 InjectionIII 目錄下對(duì)應(yīng)的 bundle 動(dòng)態(tài)庫(kù),bundle 是不能被鏈接的 dylib,只能在運(yùn)行時(shí)使用 dlopen() 加載。此時(shí)運(yùn)行需要調(diào)試的 Xcode 工程,App 會(huì)加載 bundle 動(dòng)態(tài)庫(kù),并執(zhí)行動(dòng)態(tài)庫(kù)里 InjectionClient 類的 +load 方法。在 InjectionClient 類的 +load 方法里會(huì)調(diào)用其 connectTo 方法傳入對(duì)應(yīng)的端口號(hào)來連接服務(wù)端的 socket 服務(wù)用于通訊,并運(yùn)行其runInBackground 方法進(jìn)行初始化操作。
+ (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?\n");

}
  • Injection 初始化詳細(xì)步驟
    首先服務(wù)端和客戶端會(huì)讀取一些數(shù)據(jù)傳給對(duì)方保存在 SwiftEval 單例中方便后期進(jìn)行代碼注入,傳送的數(shù)據(jù)包括:Injection App 的沙盒目錄、調(diào)試 Xcode 工程的物理路徑、目標(biāo) App 芯片類型和沙盒路徑、Xcode App 物理路徑和調(diào)試工程的 build 物理路徑 等。接下來服務(wù)端會(huì)通過 FileWatcher 開啟調(diào)試工程目錄下文件改變的監(jiān)聽,當(dāng)文件發(fā)生改變后會(huì)執(zhí)行傳入的 injector block 方法來進(jìn)行代碼注入。最后客戶端和服務(wù)端都會(huì)通過 socket 的 readInt 來持續(xù)獲取交互命令來執(zhí)行對(duì)應(yīng)的操作。

項(xiàng)目啟動(dòng)以后可以在控制臺(tái)執(zhí)行 image list -o -f 查看加載的動(dòng)態(tài)庫(kù),可以看到 iOSInjection.bundle 確實(shí)已經(jīng)以動(dòng)態(tài)庫(kù)的形式加載進(jìn)來了。

3. 重新編譯、打包動(dòng)態(tài)庫(kù)和簽名

InjectionIII 運(yùn)行以后會(huì)在后臺(tái)監(jiān)聽 socket 消息,每隔0.5秒檢查一次是否有客戶端連接過來,等我們app 啟動(dòng)以后加載了 iOSInjection.bundle,就會(huì)啟動(dòng) client 跟 server 建立連接,然后就可以發(fā)送消息了。

當(dāng)我們?cè)谡{(diào)試工程中修改了代碼并保存后,F(xiàn)ileWatcher 會(huì)立即收到文件改變的回調(diào),F(xiàn)ileWatcher 使用 Mac OS 上的 FSEvents 框架實(shí)現(xiàn),并執(zhí)行如下圖的 injector block 方法。

在該方法中會(huì)判斷是否為自動(dòng)注入,如果是則執(zhí)行 injectPending 方法,通過 socket 對(duì)客戶端下發(fā)InjectionInject 代碼注入命令并傳入需要代碼注入的文件名物理路徑。如果不是自動(dòng)注入那么就在控制臺(tái)輸出“xx文件已保存,輸入ctrl-=進(jìn)行注入”告訴我們手動(dòng)注入的觸發(fā)方式。

當(dāng)客戶端收到代碼注入命令后會(huì)調(diào)用 SwiftInjection 類的 injectWithOldClass: classNameOrFile: 方法進(jìn)行代碼注入,如下圖:

    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 {
        }
    }

這個(gè)方法分為兩步,第一步是調(diào)用 SwiftEval 單例的 rebuildClass 方法來進(jìn)行修改文件的重新編譯、打包動(dòng)態(tài)庫(kù)和簽名,第二步是加載對(duì)應(yīng)的動(dòng)態(tài)庫(kù)進(jìn)行方法的替換。這里我們先看第一步的操作步驟。

首先根據(jù)修改的類文件名在 Injection App 的沙盒路徑生成對(duì)應(yīng)的編譯腳本,腳本命名為eval+數(shù)字,數(shù)字以100為基數(shù),每次遞增1。腳本生成調(diào)用方法如下圖:

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 腳本的具體方法,主要是針對(duì)當(dāng)前修改類設(shè)置對(duì)應(yīng)的編譯腳本命令。由于腳本太長(zhǎng),這里就不貼上來了,有興趣的同學(xué)可以自行查看。

使用改動(dòng)類的編譯腳本可以生成其.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"http:// -Xlinker -bundle_loader -Xlinker \"\(Bundle.main.executablePath!)\""

這里針對(duì)模擬器環(huán)境進(jìn)行腳本配置,配置完成后使用 clang 命令把對(duì)應(yīng)的.o文件生成相同名字的動(dòng)態(tài)庫(kù),具體如下圖:

        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\n\(try! String(contentsOfFile: logfile))")
        }

由于蘋果會(huì)對(duì)加載的動(dòng)態(tài)庫(kù)進(jìn)行簽名校驗(yàn),所以我們下一步需要對(duì)這個(gè)動(dòng)態(tài)庫(kù)進(jìn)行簽名,使用 signer block 方法來進(jìn)行簽名操作,簽名方法如下:

    // 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 環(huán)境,所以客戶端是無法進(jìn)行的,只能通過 socket 告訴服務(wù)端來進(jìn)行操作。當(dāng)服務(wù)端收到 InjectionSign 簽名命令后會(huì)調(diào)用 SignerService 類的 codesignDylib 來對(duì)相應(yīng)的動(dòng)態(tài)庫(kù)進(jìn)行簽名操作,具體簽名腳本操作如下:

服務(wù)端代碼如下

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;
}

至此修改文件的重新編譯、打包動(dòng)態(tài)庫(kù)和簽名操作就全部完成了,接下來就是我們最熟悉的加載動(dòng)態(tài)庫(kù)進(jìn)行方法替換了。

4. 加載動(dòng)態(tài)庫(kù)進(jìn)行方法替換

  • 加載并注入動(dòng)態(tài)庫(kù)
    上面提到了在調(diào)用了 SwiftEval 類的 rebuildClass 方法進(jìn)行編譯打包動(dòng)態(tài)庫(kù)和簽名后,會(huì)再調(diào)用SwiftInjection 類的 inject 方法來進(jìn)行動(dòng)態(tài)庫(kù)的加載和方法的替換,讓我們一起看看具體的實(shí)現(xiàn)步驟。在獲取到改變后的新類的符號(hào)地址后就可以通過 runtime 的方式來進(jìn)行方法的替換了。

  • 方法的替換
    在拿到新類的符號(hào)地址后,我們把新類里所有的類方法和實(shí)例方法都替換到對(duì)應(yīng)的舊類中,使用的是SwiftInjection 的 injection 方法,具體實(shí)現(xiàn)如下圖:

最后我們修改的代碼就在不需要重啟 App 重新編譯的情況下生效了,當(dāng)然為了執(zhí)行修改過的代碼,需要退出當(dāng)前頁面,再進(jìn)來才可以看到效果。

參考文章

Injection源碼深度解析
App 如何通過注入動(dòng)態(tài)庫(kù)的方式實(shí)現(xiàn)極速編譯調(diào)試?
Injection:iOS熱重載背后的黑魔法

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容