App 通过注入动态库的方式实现极速编译调试

本文是<> 第六篇学习笔记.

iOS 原生代码的编译调试,都是通过一遍又一遍地编译重启 App 来进行的。所以,项目代码量越大,编译时间就越长。虽然我们可以通过将部分代码先编译成二进制集成到工程里,来避免每次都全量编译来加快编译速度,但即使这样,每次编译都还是需要重启 App,需要再走一遍调试流程。

对于开发者来说,提高编译调试的速度就是提高生产效率。试想一下,如果上线前一天突然发现了一个严重的 bug,每次编译调试都要耗费几十分钟,结果这一天的黄金时间,一晃就过去了。到最后,可能就是上线时间被延误。

那么原生代码怎样才能够实现动态极速调试,以此来大幅提高编译调试速度呢?先看看其他工具是如何做的

Swift Playground

Playground是 Xcode 里集成的一个能够快速、实时调试程序的工具,可以实现所见即所得的效果,任何的代码修改都能够实时地反馈出来。

Flutter Hot Reload

Flutter 是 Google 开发的一个跨平台开发框架,调试也是快速实时的。

Flutter 会在 reload 时去查看自上次编译以后改动过的代码,重新编译涉及到的代码库,还包括主库,以及主库的相关联库。所有这些重新编译过的库都会转换成内核文件发到 Dart VM 里,Dart VM 会重新加载新的内核文件,加载后会让 Flutter framework 触发所有的 Widgets 和 Render Objects 进行重建、重布局、重绘。
Flutter 为了能够支持跨平台开发,使用了自研的 Dart 语言配合在 App 内集成 Dart VM 的方式运行 Flutter 程序。

Injection for Xcode

所幸的是,John Holdsworth 开发了一个叫作 Injection 的工具可以动态地将 Swift 或 Objective-C 的代码在已运行的程序中执行,以加快调试速度,同时保证程序不用重启。John Holdsworth 也提供了动画演示效果,如下:


image.png

作者已经开源了这个工具,地址是https://github.com/johnno1962/InjectionIII 。

使用方式是 clone 下代码,构建 InjectionPluginLite/InjectionPlugin.xcodeproj ;

** 刚开始编译时会失败 **

  • 证书不对,需要换成自己的证书
  • 文件缺失,可以对照源码地址. Remote @ 7f45504 在对应的文件夹下执行git clone

删除方式是,在终端里运行下面这行代码:

rm -rf ~/Library/Application\ Support/Developer/Shared/Xcode/Plug-ins/InjectionPlugin.xcplugin

使用

构建完成后,我们就可以开发项目了

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // Override point for customization after application launch.
    #if DEBUG
// - (BOOL)load;  : 动态地将捆绑包的可执行代码加载到正在运行的程序中(如果代码尚未加载)。
      [[NSBundle bundleWithPath:@"编译的APP的路径/InjectionIII.app/Contents/Resources/iOSInjection.bundle"] load];
    #endif
    return YES;
}

模拟器下iOS可加载Mac任意文件,不存在沙盒的说法,而真机设备如果加载动态库,只能加载App.content目录下的,因此这个工具只支持模拟器.

运行项目,加载 bundle 的时候会让你选择项目目录,InjectionIII 就是监控的这个目录,里面文件变动会有通知,并及时注入。

Injection原理分析

InjectionIII 分为serverclient部分,client部分在你的项目启动的时候会作为 bundle load 进去,server部分在Mac App那边,server 和 client 都会在后台发送和监听 Socket 消息.

实现逻辑分别在 InjectionServer.mmInjectionClient.mm里的 runInBackground 方法里面。

InjectionIII 会监听源代码文件的变化,如果文件被改动了,server 就会通过 Socket 通知 client 进行 rebuildClass 重新对该文件进行编译,打包成动态库,也就是 .dylib 文件。

然后通过 dlopen 把动态库文件载入运行的 App 里,接下来 dlsym 会得到动态库的符号地址,然后就可以处理类的替换工作。当类的方法被替换后,我们就可以开始重新绘制界面了。整个过程无需重新编译和重载 App,使用动态库方式极速调试的目的就达成了。

原理如下:


image.png
源码分析

1. 首先 InjectionIII 依赖 InjectionBundle

InjectionBundle 有个编译时脚本

if [ "$PRODUCT_NAME" = "macOSInjection" ]; then
    perl -e 'print "#define INJECTION_SALT @{[1_000_000 + int(rand() * ((1 << 31)) - 1_000_000)]}\n"' >/tmp/InjectionSalt.h
fi

InjectionIII 有两个运行时脚本

buildNumber=$(/usr/libexec/PlistBuddy -c "Print CFBundleVersion" "${PROJECT_DIR}/${INFOPLIST_FILE}")
buildNumber=$(($buildNumber + 1))
chmod +w "${PROJECT_DIR}/${INFOPLIST_FILE}"
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $buildNumber" "${PROJECT_DIR}/${INFOPLIST_FILE}"
InjectionIII/build_bundles.sh

build_bundles.sh

#!/bin/bash -x
#
#  build_bundles.sh
#  InjectionIII
#
#  Created by John Holdsworth on 04/10/2019.
#  Copyright © 2019 John Holdsworth. All rights reserved.
#
#  $Id: //depot/ResidentEval/InjectionIII/build_bundles.sh#62 $
#

# Injection has to assume a fixed path for Xcode.app as it uses
# Swift and the user's project may contain only Objective-C.
# The second "rpath" is to be able to find XCTest.framework.
FIXED_XCODE_DEVELOPER_PATH=/Applications/Xcode.app/Contents/Developer

function build_bundle () {
    FAMILY=$1
    PLATFORM=$2
    SDK=$3
    # swift 动态库的路径
    SWIFT_DYLIBS_PATH="$FIXED_XCODE_DEVELOPER_PATH/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/$SDK"
    # XCTEST_FRAMEWORK_PATH 路径
    XCTEST_FRAMEWORK_PATH="$FIXED_XCODE_DEVELOPER_PATH/Platforms/$PLATFORM.platform/Developer/Library/Frameworks"
    if [ ! -d "$SWIFT_DYLIBS_PATH" -o ! -d "${XCTEST_FRAMEWORK_PATH}/XCTest.framework" ]; then
        echo "Missing RPATH $SWIFT_DYLIBS_PATH $XCTEST_FRAMEWORK_PATH"
        exit 1
    fi
    # 打包SwiftTrace动态库和iOSInjection 和 InjectionBundle,放到SYMROOT(build)目录下
    "$DEVELOPER_BIN_DIR"/xcodebuild SYMROOT=$SYMROOT ARCHS="$ARCHS" -sdk $SDK -config $CONFIGURATION -target SwiftTrace &&
    "$DEVELOPER_BIN_DIR"/xcodebuild SYMROOT=$SYMROOT ARCHS="$ARCHS" PRODUCT_NAME="${FAMILY}Injection" LD_RUNPATH_SEARCH_PATHS="$SWIFT_DYLIBS_PATH $XCTEST_FRAMEWORK_PATH @loader_path/Frameworks" -sdk $SDK -config $CONFIGURATION -target InjectionBundle &&
    "$DEVELOPER_BIN_DIR"/xcodebuild SYMROOT=$SYMROOT ARCHS="$ARCHS" PRODUCT_NAME="${FAMILY}SwiftUISupport" -sdk $SDK -config $CONFIGURATION -target SwiftUISupport &&

    # 然后使用 rsync 命令将 iOSInjection.bundle 同步到 InjectionIII.app 目录下 "$CODESIGNING_FOLDER_PATH/Contents/Resources"
    rsync -au $SYMROOT/$CONFIGURATION-$SDK/*.bundle "$CODESIGNING_FOLDER_PATH/Contents/Resources" &&
    #创建文件夹
    mkdir -p "$CODESIGNING_FOLDER_PATH/Contents/Resources/${FAMILY}Injection.bundle/Frameworks/SwiftTrace.framework/Versions/A/Resources" &&
    # 同步 SwiftTrace
    rsync -au $SYMROOT/$CONFIGURATION-$SDK/SwiftTrace.framework/{Headers,Modules,SwiftTrace} "$CODESIGNING_FOLDER_PATH/Contents/Resources/${FAMILY}Injection.bundle/Frameworks/SwiftTrace.framework/Versions/A" &&
    ln -s A "$CODESIGNING_FOLDER_PATH/Contents/Resources/${FAMILY}Injection.bundle/Frameworks/SwiftTrace.framework/Versions/Current"
    for thing in SwiftTrace Modules Resources Headers; do
        ln -sf Versions/Current/$thing "$CODESIGNING_FOLDER_PATH/Contents/Resources/${FAMILY}Injection.bundle/Frameworks/SwiftTrace.framework"
    done
}

#build_bundle macOS MacOSX macosx &&
build_bundle iOS iPhoneSimulator iphonesimulator &&
build_bundle tvOS AppleTVSimulator appletvsimulator &&
# iphoneos on M1 mac
#build_bundle maciOS iPhoneOS iphoneos &&

# macOSSwiftUISupport needs to be built separately from the main app
"$DEVELOPER_BIN_DIR"/xcodebuild SYMROOT=$SYMROOT ARCHS="$ARCHS" -sdk macosx -config $CONFIGURATION -target SwiftUISupport &&

rsync -au $SYMROOT/$CONFIGURATION/macOSSwiftUISupport.bundle "$CODESIGNING_FOLDER_PATH/Contents/Resources" &&

# Copy across bundles and .swiftinterface files
rsync -au $SYMROOT/$CONFIGURATION/SwiftTrace.framework/Versions/A/{Headers,Modules} "$CODESIGNING_FOLDER_PATH/Contents/Resources/macOSInjection.bundle/Contents/Frameworks/SwiftTrace.framework/Versions/A" &&

for thing in Modules Resources Headers; do
    ln -sf Versions/Current/$thing $CODESIGNING_FOLDER_PATH/Contents/Resources/macOSInjection.bundle/Contents/Frameworks/SwiftTrace.framework
done &&

# This seems to be a bug producing .swiftinterface files.
perl -pi.bak -e 's/SwiftTrace.(Swift(Trace|Meta)|dyld_interpose_tuple)/$1/g' $CODESIGNING_FOLDER_PATH/Contents/Resources/{macOSInjection.bundle/Contents,{i,maci,tv}OSInjection.bundle}/Frameworks/SwiftTrace.framework/Modules/*/*.swiftinterface &&
find $CODESIGNING_FOLDER_PATH/Contents/Resources/*.bundle -name '*.bak' -delete

我们在需要热加载项目的 willFinishLaunchingWithOptions 方法里面要加载 iOSInjection.bundle。

这个作为客户端和 InjectionIII 通信。注意,bundle 里的 framework 是不能被链接的 dylib,只能在运行时使用 dlopen() 加载。

2. Injection 初始化

  • server初始化
    在 InjectionIII 启动时调用 InjectionServer 的 startServer 方法并传入端口号 在后台运行开启服务端socket 服务用于和客户端的通讯,并运行 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\n",
                          inet_ntoa(v4Addr->sin_addr), ntohs(v4Addr->sin_port));
                    [[[self alloc] initSocket:clientSocket] run];
                }
            }
            else
                [NSThread sleepForTimeInterval:.5];
        }
}

@objc override public func runInBackground() {
        var candiateProjectFile = appDelegate.selectedProject

        if candiateProjectFile == nil {
            DispatchQueue.main.sync {
                appDelegate.openProject(self)
            }
            candiateProjectFile = appDelegate.selectedProject
        }
        guard let projectFile = candiateProjectFile else {
            return
        }

        NSLog("Connection with project file: \(projectFile)")

        // tell client app the inferred project being watched
        if readInt() != INJECTION_SALT || readString() != INJECTION_KEY {
            sendCommand(.invalid, with: nil)
            return
        }

        builder = SwiftEval()
        defer {
            builder.signer = nil
            builder = nil
        }

        // client spcific data for building
        if let frameworks = readString() {
            builder.frameworks = frameworks
        } else { return }

        if let arch = readString() {
            builder.arch = arch
        } else { return }

        if appDelegate.isSandboxed {
            builder.tmpDir = NSTemporaryDirectory()
        } else {
            builder.tmpDir = builder.frameworks
        }
        write(builder.tmpDir)

        // log errors to client
        builder.evalError = {
            (message: String) in
            self.sendCommand(.log, with:message)
            return NSError(domain:"SwiftEval", code:-1,
                           userInfo:[NSLocalizedDescriptionKey: message])
        }

        builder.signer = {
            let identity = appDelegate.defaults.string(forKey: projectFile)
            if identity != nil {
                NSLog("Signing with identity: \(identity!)")
            }
            return SignerService.codesignDylib(
                self.builder.tmpDir+"/eval"+$0, identity: identity)
        }

        // Xcode specific config
        if let xcodeDevURL = appDelegate.runningXcodeDevURL {
            builder.xcodeDev = xcodeDevURL.path
        }

        builder.projectFile = projectFile

        appDelegate.setMenuIcon("InjectionOK")
        appDelegate.lastConnection = self
        pending = []

        var lastInjected = projectInjected[projectFile]
        if lastInjected == nil {
            lastInjected = [String: Double]()
            projectInjected[projectFile] = lastInjected!
        }

        guard let executable = readString() else { return }
        if appDelegate.enableWatcher.state == .on {
            let mtime = {
                (path: String) -> time_t in
                var info = stat()
                return stat(path, &info) == 0 ? info.st_mtimespec.tv_sec : 0
            }
            let executableBuild = mtime(executable)
            for (source, _) in lastInjected! {
                if !source.hasSuffix("storyboard") && !source.hasSuffix("xib") &&
                    mtime(source) > executableBuild {
                    recompileAndInject(source: source)
                }
            }
        }

        var pause: TimeInterval = 0.0
        var testCache = [String: [String]]()

        fileChangeHandler = {
            (changed: NSArray, ideProcPath: String) in
            var changed = changed as! [String]

            if UserDefaults.standard.bool(forKey: UserDefaultsTDDEnabled) {
                for injectedFile in changed {
                    var matchedTests = testCache[injectedFile]
                    if matchedTests == nil {
                        matchedTests = Self.searchForTestWithFile(injectedFile,
                              projectRoot:(projectFile as NSString)
                                .deletingLastPathComponent,
                            fileManager: FileManager.default)
                        testCache[injectedFile] = matchedTests
                    }

                    changed += matchedTests!
                }
            }

            let now = NSDate.timeIntervalSinceReferenceDate
            let automatic = appDelegate.enableWatcher.state == .on
            for swiftSource in changed {
                if !self.pending.contains(swiftSource) {
                    if (now > (lastInjected?[swiftSource] ?? 0.0) + MIN_INJECTION_INTERVAL && now > pause) {
                        lastInjected![swiftSource] = now
                        projectInjected[projectFile] = lastInjected!
                        self.pending.append(swiftSource)
                        if !automatic {
                            let file = (swiftSource as NSString).lastPathComponent
                            self.sendCommand(.log,
                                with:"'\(file)' changed, type ctrl-= to inject")
                        }
                    }
                }
            }
            self.lastIdeProcPath = ideProcPath
            self.builder.lastIdeProcPath = ideProcPath
            if (automatic) {
                self.injectPending()
            }
        }
        defer { fileChangeHandler = nil }

        // start up file watchers to write generated tmpfile path to client app
        setProject(projectFile)

        DispatchQueue.main.sync {
            appDelegate.updateTraceInclude(nil)
            appDelegate.updateTraceExclude(nil)
            appDelegate.toggleFeedback(nil)
            appDelegate.toggleLookup(nil)
        }

        // read status requests from client app
        commandLoop:
        while true {
            let commandInt = readInt()
            guard let command = InjectionResponse(rawValue: commandInt) else {
                NSLog("InjectionServer: Unexpected case \(commandInt)")
                break
            }
            switch command {
            case .frameworkList:
                appDelegate.setFrameworks(readString() ?? "",
                                          menuTitle: "Trace Framework")
                appDelegate.setFrameworks(readString() ?? "",
                                          menuTitle: "Trace SysInternal")
                appDelegate.setFrameworks(readString() ?? "",
                                          menuTitle: "Trace Package")
            case .complete:
                appDelegate.setMenuIcon("InjectionOK")
                if appDelegate.frontItem.state == .on {
                    print(executable)
                    let appToOrderFront: URL
                    if executable.contains("/MacOS/") {
                        appToOrderFront = URL(fileURLWithPath: executable)
                            .deletingLastPathComponent()
                            .deletingLastPathComponent()
                            .deletingLastPathComponent()
                    } else {
                        appToOrderFront = URL(fileURLWithPath: builder.xcodeDev)
                            .appendingPathComponent("Applications/Simulator.app")
                    }
                    NSWorkspace.shared.open(appToOrderFront)
                }
                break
            case .pause:
                pause = NSDate.timeIntervalSinceReferenceDate + Double(readString() ?? "0.0")!
                break
            case .sign:
                if !appDelegate.isSandboxed && xprobePlugin == nil {
                    sendCommand(.signed, with: "0")
                    break
                }
                sendCommand(.signed, with: builder
                                .signer!(readString() ?? "") ? "1": "0")
                break
            case .callOrderList:
                if let calls = readString()?
                    .components(separatedBy: CALLORDER_DELIMITER) {
                    appDelegate.fileReorder(signatures: calls)
                }
                break
            case .error:
                appDelegate.setMenuIcon("InjectionError")
                NSLog("Injection error: \(readString() ?? "Uknown")")
                break;
            case .exit:
                break commandLoop
            default:
                break
            }
        }

        // client app disconnected
        fileWatchers.removeAll()
        appDelegate.traceItem.state = .off
        appDelegate.setMenuIcon("InjectionIdle")
    }

  • 客户端初始化
    在 InjectionIII 启动后,打开需要调试的 Xcode 工程,Xcode 工程必须在其App启动方法里加载 InjectionIII 目录下对应的 bundle ,bundle 存放的是不能被直接链接的 dylib,只能在运行时使用 dlopen() 加载。此时运行需要调试的 Xcode 工程,App 会加载 bundle,并执行bundle的 load 方法(如果尚未加载bundle中的可执行代码,则将其动态加载到正在运行的程序中)。在 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?\n");
}
- (void)run {
    [self performSelectorInBackground:@selector(runInBackground) withObject:nil];
}

- (void)runInBackground {
    SwiftEval *builder = [SwiftInjectionEval sharedInstance];
    builder.tmpDir = NSTemporaryDirectory();

    [self writeInt:INJECTION_SALT];
    [self writeString:INJECTION_KEY];

    NSString *frameworksPath = [NSBundle mainBundle].privateFrameworksPath;
    [self writeString:builder.tmpDir];

    [self writeString:builder.arch];
    [self writeString:[NSBundle mainBundle].executablePath];

    builder.tmpDir = [self readString];
    BOOL notPlugin = ![@"/tmp" isEqualToString:builder.tmpDir];

    int codesignStatusPipe[2];
    pipe(codesignStatusPipe);
    SimpleSocket *reader = [[SimpleSocket alloc] initSocket:codesignStatusPipe[0]];
    SimpleSocket *writer = [[SimpleSocket alloc] initSocket:codesignStatusPipe[1]];

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

    NSDictionary *frameworkPaths;
    if (notPlugin) {
        NSMutableArray *frameworks = [NSMutableArray new];
        NSMutableArray *sysFrameworks = [NSMutableArray new];
        NSMutableDictionary *imageMap = [NSMutableDictionary new];
        const char *bundleFrameworks = frameworksPath.UTF8String;

        for (int32_t i = _dyld_image_count()-1; i >= 0 ; i--) {
            const char *imageName = _dyld_get_image_name(i);
            if (!strstr(imageName, ".framework/")) continue;
            NSString *imagePath = [NSString stringWithUTF8String:imageName];
            NSString *frameworkName = imagePath.lastPathComponent;
            [imageMap setValue:imagePath forKey:frameworkName];
            [strstr(imageName, bundleFrameworks) ?
             frameworks : sysFrameworks addObject:frameworkName];
        }

        [self writeCommand:InjectionFrameworkList withString:
         [frameworks componentsJoinedByString:FRAMEWORK_DELIMITER]];
        [self writeString:
         [sysFrameworks componentsJoinedByString:FRAMEWORK_DELIMITER]];
        [self writeString:[[SwiftInjection packageNames]
                           componentsJoinedByString:FRAMEWORK_DELIMITER]];
        frameworkPaths = imageMap;
    }

    // As tmp file names come in, inject them
    InjectionCommand command;
    while ((command = (InjectionCommand)[self readInt]) != InjectionEOF) {
        switch (command) {
        case InjectionVaccineSettingChanged: {
            NSString *string = [self readString];
            NSData *data = [string dataUsingEncoding:NSUTF8StringEncoding];
            id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];

            NSDictionary *dictionary = (NSDictionary *)json;
            if (dictionary != nil) {
                NSNumber *vaccineEnabled = [dictionary valueForKey:@"Enabled Vaccine"];
                builder.vaccineEnabled = [vaccineEnabled boolValue];
            }
            break;
        }
        case InjectionConnected: {
            NSString *projectFile = [self readString];
            builder.projectFile = projectFile;
            builder.derivedLogs = nil;
            printf(" Injection connected \n");
            NSString *pbxFile = [projectFile
                 stringByAppendingPathComponent:@"project.pbxproj"];
            NSString *pbxContents = [NSString
                 stringWithContentsOfFile:pbxFile
                 encoding:NSUTF8StringEncoding error:NULL];
            if (![pbxContents containsString:@"-interposable"])
                printf(" ⚠️ Have you remembered to add \"-Xlinker -interposable\" to your project's \"Other Linker Flags\"? ⚠️\n");
            break;
        }
        case InjectionWatching: {
            NSString *directory = [self readString];
            printf(" Watching %s/**\n", directory.UTF8String);
            break;
        }
        case InjectionLog:
            printf("%s\n", [self readString].UTF8String);
            break;
        case InjectionSigned:
            [writer writeString:[self readString]];
            break;
        case InjectionTrace:
            [SwiftTrace swiftTraceMainBundle];
            printf(" Added trace to non-final methods of classes in app bundle\n");
            [self filteringChanged];
            break;
        case InjectionUntrace:
            [SwiftTrace swiftTraceRemoveAllTraces];
            break;
        case InjectionTraceUI:
            [self loadSwuftUISupprt];
            [SwiftTrace swiftTraceMainBundleMethods];
            [SwiftTrace swiftTraceMainBundle];
            printf(" Added trace to methods in main bundle\n");
            [self filteringChanged];
            break;
        case InjectionTraceUIKit:
            dispatch_sync(dispatch_get_main_queue(), ^{
                Class OSView = objc_getClass("UIView") ?: objc_getClass("NSView");
                printf(" Adding trace to the framework containg %s, this will take a while...\n", class_getName(OSView));
                [OSView swiftTraceBundle];
                printf(" Completed adding trace.\n");
            });
            [self filteringChanged];
            break;
        case InjectionTraceSwiftUI:
            if (const char *AnyText = [self loadSwuftUISupprt]) {
                printf(" Adding trace to SwiftUI calls.\n");
                [SwiftTrace swiftTraceMethodsInBundle:AnyText packageName:nil];
                [self filteringChanged];
            }
            else
                printf(" Your app doesn't seem to use SwiftUI.\n");
            break;
        case InjectionTraceFramework: {
            NSString *frameworkName = [self readString];
            if (const char *frameworkPath =
                frameworkPaths[frameworkName].UTF8String) {
                printf(" Tracing %s\n", frameworkPath);
                [SwiftTrace swiftTraceMethodsInBundle:frameworkPath packageName:nil];
                [SwiftTrace swiftTraceBundlePath:frameworkPath];
            }
            else {
                printf(" Tracing package %s\n", frameworkName.UTF8String);
                NSString *mainBundlePath = [NSBundle mainBundle].executablePath;
                [SwiftTrace swiftTraceMethodsInBundle:mainBundlePath.UTF8String
                                          packageName:frameworkName];
            }
            [self filteringChanged];
            break;
        }
        case InjectionQuietInclude:
            [SwiftTrace setSwiftTraceFilterInclude:[self readString]];
            break;
        case InjectionInclude:
            [SwiftTrace setSwiftTraceFilterInclude:[self readString]];
            [self filteringChanged];
            break;
        case InjectionExclude:
            [SwiftTrace setSwiftTraceFilterExclude:[self readString]];
            [self filteringChanged];
            break;
        case InjectionStats:
            static int top = 200;
            printf("\n Sorted top %d elapsed time/invocations by method\n"
                   " =================================================\n", top);
            [SwiftInjection dumpStatsWithTop:top];
            [self needsTracing];
            break;
        case InjectionCallOrder:
            printf("\n Function names in the order they were first called:\n"
                   " ===================================================\n");
            for (NSString *signature : [SwiftInjection callOrder])
                printf("%s\n", signature.UTF8String);
            [self needsTracing];
            break;
        case InjectionFileOrder:
            printf("\n Source files in the order they were first referenced:\n"
                   " =====================================================\n"
                   " (Order the source files should be compiled in target)\n");
            [SwiftInjection fileOrder];
            [self needsTracing];
            break;
        case InjectionFileReorder:
            [self writeCommand:InjectionCallOrderList
                    withString:[[SwiftInjection callOrder]
                                componentsJoinedByString:CALLORDER_DELIMITER]];
            [self needsTracing];
            break;
        case InjectionUninterpose:
            [SwiftTrace swiftTraceRevertAllInterposes];
            [SwiftTrace swiftTraceRemoveAllTraces];
            printf(" Removed all traces (and injections).\n");
            break;
        case InjectionFeedback:
            SwiftInjection.traceInjection = [self readString].intValue;
            break;
        case InjectionLookup: {
            BOOL lookup = [self readString].intValue;
            [SwiftTrace setSwiftTraceTypeLookup:lookup];
            if ([SwiftTrace swiftTracing])
                printf(" Discovery of target app's types switched %s.\n",
                       lookup ? "on" : "off");
            break;
        }
        case InjectionInvalid:
            printf(" ⚠️ Connection rejected. Are you running the correct version of InjectionIII.app from /Applications? ⚠️\n");
            break;
        case InjectionIdeProcPath: {
            builder.lastIdeProcPath = [self readString];
            break;
        }
        default: {
            NSString *changed = [self readString];
            dispatch_async(dispatch_get_main_queue(), ^{
                NSError *err = nil;
                switch (command) {
                case InjectionLoad:
                    [SwiftInjection injectWithTmpfile:changed error:&err];
                    break;
                case InjectionInject: {
#ifdef __IPHONE_OS_VERSION_MIN_REQUIRED
                    if ([changed hasSuffix:@"storyboard"] || [changed hasSuffix:@"xib"]) {
                        if (![self injectUI:changed])
                            return;
                    }
                    else
#endif
                        [SwiftInjection injectWithOldClass:nil classNameOrFile:changed];
                    break;
                }
#ifdef XPROBE_PORT
                case InjectionXprobe:
                    [Xprobe connectTo:NULL retainObjects:YES];
                    [Xprobe search:@""];
                    break;
                case InjectionEval: {
                    NSArray *parts = [changed componentsSeparatedByString:@"^"];
                    int pathID = parts[0].intValue;
                    [self writeCommand:InjectionPause withString:@"5"];
                    if ([xprobePaths[pathID].object respondsToSelector:@selector(swiftEvalWithCode:)])
                        (void)[xprobePaths[pathID].object swiftEvalWithCode:parts[3].stringByRemovingPercentEncoding];
                    else
                        printf(" Xprobe: Eval only works on NSObject subclasses\n");
                    [Xprobe writeString:[NSString stringWithFormat:@"$('BUSY%d').hidden = true; ", pathID]];
                    break;
                }
#endif
                default:
                    [self writeCommand:InjectionError withString:[NSString
                          stringWithFormat:@"Invalid command #%d", command]];
                    break;
                }

                [self writeCommand:err ? InjectionError : InjectionComplete
                        withString:err ? err.localizedDescription : nil];
            });
        }
        }
    }
}

Injection 初始化详细步骤
首先服务端和客户端会读取一些数据传给对方保存在 SwiftEval 单例中方便后期进行代码注入,传送的数据包括:Injection App 的沙盒目录、调试 Xcode 工程的物理路径、目标 App 芯片类型和沙盒路径、Xcode App 物理路径和调试工程的 build 物理路径 等。
接下来服务端会通过 FileWatcher 开启调试工程目录下文件改变的监听,当文件发生改变后会执行传入的 injector block 方法来进行代码注入。
最后客户端和服务端都会通过 socket 的 readInt 来持续获取交互命令来执行对应的操作。

项目启动以后可以在控制台执行 image list -o -f 查看加载的动态库,可以看到 iOSInjection.bundle 文件夹确实有动态库加载进来了。

[343] 0x000000010b0a5000 /Users/geneqiao/Library/Developer/Xcode/DerivedData/InjectionIII-afkaewsbswefvfaojtsobybpeakl/Build/Products/Debug/InjectionIII.app/Contents/Resources/iOSInjection.bundle/iOSInjection
[344] 0x000000010b2f5000 /Users/geneqiao/Library/Developer/Xcode/DerivedData/InjectionIII-afkaewsbswefvfaojtsobybpeakl/Build/Products/Debug/InjectionIII.app/Contents/Resources/iOSInjection.bundle/Frameworks/SwiftTrace.framework/Versions/A/SwiftTrace

3. 重新编译、打包动态库和签名
InjectionIII 运行以后会在后台监听 socket 消息,每隔0.5秒检查一次是否有客户端连接过来,等我们app 启动以后加载了 iOSInjection.bundle,就会启动 client 跟 server 建立连接,然后就可以发送消息了。


    @objc func applicationDidFinishLaunching(_ aNotification: Notification) {
        appDelegate = self
        InjectionServer.startServer(INJECTION_ADDRESS)
  }

Injection 会监听源代码文件的变化,当我们在调试工程中修改了代码并保存后,FileWatcher 会立即收到文件改变的回调,FileWatcher 使用 Mac OS 上的 FSEvents 框架实现,并执行如下图的 injector block 方法.

fileChangeHandler = {
            (changed: NSArray, ideProcPath: String) in
            var changed = changed as! [String]

            if UserDefaults.standard.bool(forKey: UserDefaultsTDDEnabled) {
                for injectedFile in changed {
                    var matchedTests = testCache[injectedFile]
                    if matchedTests == nil {
                        matchedTests = Self.searchForTestWithFile(injectedFile,
                              projectRoot:(projectFile as NSString)
                                .deletingLastPathComponent,
                            fileManager: FileManager.default)
                        testCache[injectedFile] = matchedTests
                    }

                    changed += matchedTests!
                }
            }

            let now = NSDate.timeIntervalSinceReferenceDate
            let automatic = appDelegate.enableWatcher.state == .on
            for swiftSource in changed {
                if !self.pending.contains(swiftSource) {
                    if (now > (lastInjected?[swiftSource] ?? 0.0) + MIN_INJECTION_INTERVAL && now > pause) {
                        lastInjected![swiftSource] = now
                        projectInjected[projectFile] = lastInjected!
                        self.pending.append(swiftSource)
                        if !automatic {
                            let file = (swiftSource as NSString).lastPathComponent
                            self.sendCommand(.log,
                                with:"'\(file)' changed, type ctrl-= to inject")
                        }
                    }
                }
            }
            self.lastIdeProcPath = ideProcPath
            self.builder.lastIdeProcPath = ideProcPath
            if (automatic) {
                self.injectPending()
            }
        }

在该方法中会判断是否为自动注入,如果是则执行 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 = URL(fileURLWithPath: tmpDir)
            .appendingPathComponent("eval\(injectionNumber)").path
        let logfile = "\(tmpfile).log"

        guard var (compileCommand, sourceFile) = try 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).
                This could be due to one of the following:
                1. Injection does not work with Whole Module Optimization.
                2. There are restrictions on characters allowed in paths.
                3. File paths in the simulator paths are case sensitive.
                Try a build clean then rebuild to make logs available.
                """)
        }

其中 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 -fprofile-instr-generate \"\(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))")
        }

由于苹果会对加载的动态库进行签名校验,所以我们下一步需要对这个动态库进行签名,使用 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 来对相应的动态库进行签名操作,具体签名脚本操作如下:

+ (BOOL)codesignDylib:(NSString *)dylib identity:(NSString *)identity {
    static NSString *adhocSign = @"-";
    NSString *command = [NSString stringWithFormat:@""
                         "(export CODESIGN_ALLOCATE=/Applications/Xcode.app"
                         "/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/codesign_allocate; "
                         "if /usr/bin/file \"%@\" | grep ' bundle ' >/dev/null;"
                         "then /usr/bin/codesign --force -s \"%@\" \"%@\";"
                         "else exit 1; fi)",
                         dylib, identity ?: adhocSign, dylib];
    return system(command.UTF8String) >> 8 == EXIT_SUCCESS;
}

服务端代码如下

case .sign:
                if !appDelegate.isSandboxed && xprobePlugin == nil {
                    sendCommand(.signed, with: "0")
                    break
                }
                sendCommand(.signed, with: [SwiftEval sharedInstance]
                                .signer!(readString() ?? "") ? "1": "0")
                break

至此修改文件的重新编译、打包动态库和签名操作就全部完成了,接下来就是我们最熟悉的加载动态库进行方法替换了

4. 加载动态库进行方法替换

当开始注入的时候.Injection Server 会执行 rebuildClass 重新进行编译、打包成动态库,也就是 .dylib 文件。编译、打包成动态库后使用 writeSting 方法通过 Socket 通知运行的 App。writeString 的代码如下。

- (BOOL)writeString:(NSString *)string {
    const char *utf8 = string.UTF8String;
    uint32_t length = (uint32_t)strlen(utf8);
    return [self writeInt:length] &&
        write(clientSocket, utf8, length) == length;
}

Client 接收到消息后会调用 inject(tmpfile: String) 方法,运行时进行类的动态替换。inject(tmpfile: String) 方法的代码大部分都是做新类动态替换旧类。inject(tmpfile: String) 的入参 tmpfile 是动态库的文件路径..

public class func inject(tmpfile: String) throws {
        let newClasses = try SwiftEval.instance.loadAndInject(tmpfile: tmpfile)
        let oldClasses = //oldClass != nil ? [oldClass!] :
            newClasses.map { objc_getClass(class_getName($0)) as! AnyClass }
        var testClasses = [AnyClass]()
        injectionNumber += 1

        for i in 0...self)
            let classMetadata = unsafeBitCast(newClass, to:
                UnsafeMutablePointer.self)

            // Is this a Swift class?
            // Reference: https://github.com/apple/swift/blob/master/include/swift/ABI/Metadata.h#L1195
            let oldSwiftCondition = classMetadata.pointee.Data & 0x1 == 1
            let newSwiftCondition = classMetadata.pointee.Data & 0x3 != 0
            let isSwiftClass = newSwiftCondition || oldSwiftCondition
            if isSwiftClass {
                // Old mechanism for Swift equivalent of "Swizzling".
                if classMetadata.pointee.ClassSize != existingClass.pointee.ClassSize {
                    print(" ⚠️ Adding or removing methods on Swift classes is not supported. Your application will likely crash. ⚠️")
                }

                #if true // replaced by "interpose" code below
                func byteAddr(_ location: UnsafeMutablePointer) -> UnsafeMutablePointer {
                    return location.withMemoryRebound(to: UInt8.self, capacity: 1) { $0 }
                }

                let vtableOffset = byteAddr(&existingClass.pointee.IVarDestroyer) - byteAddr(existingClass)

                #if false
                // original injection implementaion for Swift.
                let vtableLength = Int(existingClass.pointee.ClassSize -
                    existingClass.pointee.ClassAddressPoint) - vtableOffset

                memcpy(byteAddr(existingClass) + vtableOffset,
                       byteAddr(classMetadata) + vtableOffset, vtableLength)
                #else
                // untried version only copying function pointers.
                let newTable = (byteAddr(classMetadata) + vtableOffset)
                    .withMemoryRebound(to: SwiftTrace.SIMP.self, capacity: 1) { $0 }

                SwiftTrace.iterateMethods(ofClass: oldClass) {
                    (name, slotIndex, vtableSlot, stop) in
                    vtableSlot.pointee = newTable[slotIndex]
                }
                #endif
                #endif
            }

            print(" Injected '\(oldClass)'")

            if let XCTestCase = objc_getClass("XCTestCase") as? AnyClass,
                newClass.isSubclass(of: XCTestCase) {
                testClasses.append(newClass)
//                if ( [newClass isSubclassOfClass:objc_getClass("QuickSpec")] )
//                [[objc_getClass("_TtC5Quick5World") sharedWorld]
//                setCurrentExampleMetadata:nil];
            }
        }

        // new mechanism for injection of Swift functions,
        // using "interpose" API from dynamic loader along
        // with -Xlinker -interposable other linker flags.
        #if true
        interpose(functionsIn: "\(tmpfile).dylib")
        #endif

        // Thanks https://github.com/johnno1962/injectionforxcode/pull/234
        if !testClasses.isEmpty {
            testQueue.async {
                testQueue.suspend()
                let timer = Timer(timeInterval: 0, repeats:false, block: { _ in
                    for newClass in testClasses {
                        NSObject.runXCTestCase(newClass)
                    }
                    testQueue.resume()
                })
                RunLoop.main.add(timer, forMode: RunLoop.Mode.common)
            }
        } else {
            performSweep(oldClasses: oldClasses)

            let notification = Notification.Name("INJECTION_BUNDLE_NOTIFICATION")
            NotificationCenter.default.post(name: notification, object: oldClasses)
        }
    }

inject(tmpfile: String) 方法的代码大部分都是做新类动态替换旧类。inject(tmpfile: String) 的入参 tmpfile 是动态库的文件路径,那么这个动态库是如何加载到可执行文件里的呢?

        let newClasses = try SwiftEval.instance.loadAndInject(tmpfile: tmpfile)

SwiftEval.instance.loadAndInject(tmpfile: tmpfile) 方法的实现:

@objc func loadAndInject(tmpfile: String, oldClass: AnyClass? = nil) throws -> [AnyClass] {

        _ = loadXCTest

        print(" Loading .dylib ...")
        // load patched .dylib into process with new version of class
        guard let dl = dlopen("\(tmpfile).dylib", RTLD_NOW) else {
            let error = String(cString: dlerror())
            if error.contains("___llvm_profile_runtime") {
                print(" Loading .dylib has failed, try turning off collection of test coverage in your scheme")
            } else if error.contains("Symbol not found:") {
                print("""
                     Loading .dylib has failed, This may be because Swift \
                    code being injected refers to a function with a default \
                    argument. Consult the section in the README at \
                    https://github.com/johnno1962/InjectionIII about \
                    using \"unhide\".
                    """)
            }
            throw evalError("dlopen() error: \(error)")
        }
        print(" Loaded .dylib - Ignore any duplicate class warning ^")

        if oldClass != nil {
            // find patched version of class using symbol for existing

            var info = Dl_info()
            guard dladdr(unsafeBitCast(oldClass, to: UnsafeRawPointer.self), &info) != 0 else {
                throw evalError("Could not locate class symbol")
            }

            debug(String(cString: info.dli_sname))
            guard let newSymbol = dlsym(dl, info.dli_sname) else {
                throw evalError("Could not locate newly loaded class symbol")
            }

            return [unsafeBitCast(newSymbol, to: AnyClass.self)]
        }
        else {
            // grep out symbols for classes being injected from object file

            return try extractClasses(dl: dl, tmpfile: tmpfile)
        }
    }

在这段代码中,有我们熟悉的动态库加载函数 dlopen

guard let dl = dlopen("\(tmpfile).dylib", RTLD_NOW) else {
            let error = String(cString: dlerror())
            if error.contains("___llvm_profile_runtime") {
                print(" Loading .dylib has failed, try turning off collection of test coverage in your scheme")
            } else if error.contains("Symbol not found:") {
                print("""
                     Loading .dylib has failed, This may be because Swift \
                    code being injected refers to a function with a default \
                    argument. Consult the section in the README at \
                    https://github.com/johnno1962/InjectionIII about \
                    using \"unhide\".
                    """)
            }
            throw evalError("dlopen() error: \(error)")
        }

dlopen 会把 tmpfile 动态库文件载入运行的 App 里,返回指针 dl。接下来,dlsym 会得到 tmpfile 动态库的符号地址,然后就可以处理类的替换工作了。dlsym 调用对应代码如下:

 guard let newSymbol = dlsym(dl, info.dli_sname) else {
        throw evalError("Could not locate newly loaded class symbol")
}

上面提到了在调用了 SwiftEval 类的 rebuildClass 方法进行编译打包动态库和签名后,会再调用SwiftInjection 类的 inject 方法来进行动态库的加载和方法的替换。在获取到改变后的新类的符号地址后就可以通过 runtime 的方式来进行方法的替换了。

方法的替换
在拿到新类的符号地址后,我们把新类里所有的类方法和实例方法都替换到对应的旧类中,使用的是SwiftInjection 的 injection 方法

static func injection(swizzle newClass: AnyClass?, onto oldClass: AnyClass?) {
        var methodCount: UInt32 = 0
        if let methods = class_copyMethodList(newClass, &methodCount) {
            for i in 0 ..< Int(methodCount) {
                let method = method_getName(methods[i])
                var replacement = method_getImplementation(methods[i])
                if traceInjection, let tracer = SwiftTrace
                    .trace(name: injectedPrefix+NSStringFromSelector(method),
                    objcMethod: methods[i], objcClass: newClass,
                    original: autoBitCast(replacement)) {
                    replacement = autoBitCast(tracer)
                }
                class_replaceMethod(oldClass, method, replacement,
                                    method_getTypeEncoding(methods[i]))
            }
            free(methods)
        }
    }

最后我们修改的代码就在不需要重启 App 重新编译的情况下生效了.

你可能感兴趣的:(App 通过注入动态库的方式实现极速编译调试)