本文是<
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 也提供了动画演示效果,如下:
作者已经开源了这个工具,地址是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 分为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,使用动态库方式极速调试的目的就达成了。
原理如下:
源码分析
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 重新编译的情况下生效了.