iOS 原生代码的编译调试,都是通过一遍又一遍地编译重启 APP来进行的。所以项目代码量越大,编译时间就越长。虽然我们可以将部分代码先编译成二进制集成到工程里,来避免每次都全量编译来加快编译速度,但即使这样,每次编译都还是需要重启App,需要再走一遍调试流程。
幸运的是,John Holdsworth 开发了一个叫做 InjectionIII 的工具可以动态地将 Swift 或 Objective-C 的代码在已运行的程序中执行,以加快调试速度,同时保证程序不用重启。
InjectionIII下载使用 下载InjectionIII 如果仅用于使用的话在mac appStore下载就可以了:
在项目中添加代码 在 - (BOOL)application:(UIApplication *)application willFinishLaunchingWithOptions:(NSDictionary *)launchOptions 方法里添加如下代码
1 2 3 4 5 6 7 8 - (BOOL )application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { ... #if DEBUG [[NSBundle bundleWithPath:@"/Applications/InjectionIII.app/Contents/Resources/iOSInjection.bundle" ] load; #endif }
1 2 3 4 5 //成功时控制台会打印: 💉 Injection connected 👍 //没有运行InjectionIII 控制台会打印 💉 Injection loaded but could not connect. Is InjectionIII.app running?
运行InjectionIII 并选择项目目录 使用的时候会让你选择项目目录,InjectionIII 就是监控的这个目录,里面文件变动会有通知。
使用InjectionIII 并测试几种场景: 你可以在在对应的VC控制器中实现 - (void)injected 编写代码,写完后,command+s 保存切执行代码,Injjection就开始编译修改过的文件为动态库,然后我们在Injected方法内做UI reload工作,即可重绘UI。
也可以直接修改你想测试的VC代码,然后退出重进即可重载VC。
测试一下四种场景:
其中 修改方法/新增方法/新增类 都可以动态修改
而当动态添加属性时则会报错
我们很自然的想到因为不能向已有类动态添加属性,但是InjectionIII到底做了什么呢?让我们带着疑问来看看InjectionIII的原理
没有看到效果的问题?
确认 Injection 监听的目录和 Xcode 项目目录是否一致。
再看下有没有保存成功,也就是针筒的颜色由绿色变成红色。
确认上面那句话有没有打印,也就是说有没有真的运行这个工具。
如果修改的是 cell / item 上面的内容,需要上下滚动才能看到效果。
如果修改的是一个普通页面的内容,最好是退出这个页面,再进入这个页面。
确认 Xcode 的版本和启动时添加的代码是否匹配,Xcode10 需要 iOSInjection10.bundle 才能生效
InjectionIII原理 大致流程梳理
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,使用动态库方式极速调试的目的就达成了。
这里有一点需要说明一下,模拟器下iOS可加载Mac任意文件,而真机设备如果加载动态库,只能加载App.content目录下的,换句话说,这个工具只支持模拟器。
编译InjectionIII工程 要了解一个工具,最好的方式当然直接看源码了。InjectionIII 的源代码链接如下:https://github.com/johnno1962/InjectionIII ,可以下载下来对着源码分析。
下载/clone InjectionIII 主工程代码,子工程 remote SwiftTrace XprobePlugin 也一并点进链接下载到对应目录。
然后解决下证书问题 ,直接勾选 Automatically manage signing,同时选择一下团队,注意 InjectionIII 和 InjectionBundle 两个 target 都要选择好。就可以愉快的编译了~
最后说一下,如果我们要用源码分析,当然要将源码编译起来,打断点看流程。这样的话就在 willFinishLaunchingWithOptions 里面加载的路径就要相应修改了,我这边是这样的。
1 2 3 #if DEBUG [[NSBundle bundleWithPath:@"/Users/suntongsheng/Library/Developer/Xcode/DerivedData/InjectionIII-aornltecfreqqwezgayglgjnnckg/Build/Products/Debug/InjectionIII.app/Contents/Resources/iOSInjection.bundle" ] load]; #endif
源码分析 一、初始化Server和Client并通过Socket建立链接 Server端 即是InjectionIII工程中的Target-InjectionIII
初始化调用 SimpleSocket 的 startServer 方法并传入端口号 在后台运行开启服务端socket 服务用于和客户端的通讯,并运行 InjectionServer 类的 runInBackground 方法进行初始化操作,弹出选择项目目录对话框,如果之前选择过的话就不会弹出。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 + (void)startServer:(NSString * )address { NSLog (@" %s %p " ,__func__,self ); [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 ]; } }
Client端 即是InjectionIII工程中的Target-InjectionBundle
会在我们的demo工程中加载,项目启动以后可以在控制台执行 image list -o -f 查看加载的动态库,可以看到 iOSInjection.bundle 确实已经以动态库的形式加载进来了。
1 2 3 image list -o -f [377] 0x0000000102b13000 /Users/suntongsheng/Library/Developer/Xcode/DerivedData/InjectionIII-aornltecfreqqwezgayglgjnnckg/Build/Products/Debug/InjectionIII.app/Contents/Resources/iOSInjection.bundle/iOSInjection
其初始化在InjectionClient 类的 +load 方法。在 InjectionClient 类的 +load 方法里会调用其 connectTo 方法传入对应的端口号来连接服务端的 socket 服务用于通讯,并运行其runInBackground 方法进行初始化操作。
1 2 3 4 5 6 7 8 9 10 11 12 + (void)load { NSLog (@" %s %p " ,__func__,self ); if (InjectionClient * client = [self connectTo:INJECTION_ADDRESS ]) [client run]; else { printf("💉 Injection loaded but could not connect. Is InjectionIII.app running?\n " ); #ifndef __IPHONE_OS_VERSION_MIN_REQUIRED printf("⚠️ For a macOS app you need to turn off the sandbox to connect. ⚠️\n " ); #endif } }
InjectionIII 运行以后会在后台监听 socket 消息,每隔0.5秒检查一次是否有客户端连接过来,等我们app 启动以后加载了 iOSInjection.bundle,就会启动 client 跟 server 建立连接,然后就可以发送消息了。
二、监听文件修改 当我们在调试工程中修改了代码并保存后,FileWatcher 会立即收到文件改变的回调,FileWatcher 使用 Mac OS 上的 FSEvents 框架实现,FileWatcher
中通过 filesChanged
告诉回调方法即InjectionServer
中的fileChangeHandler
方法
在InjectionServer
方法中会判断是否为自动注入,如果是则执行 injectPending 方法,通过 socket 对客户端下发InjectionInject 代码注入命令并传入需要代码注入的文件名物理路径。如果不是自动注入那么就在控制台输出“xx文件已保存,输入ctrl-=进行注入”告诉我们手动注入的触发方式。
三、重新编译、打包动态库和签名 文件修改后若是自动注入injectPending
方法会调用 recompileAndInject
方法实际上会调用 SwiftEval 单例的 rebuildClass 方法来进行修改文件的重新编译、打包动态库和签名:
首先根据修改的类文件名在 Injection App 的沙盒路径生成对应的编译脚本,脚本命名为eval+数字,数字以100为基数,每次递增1。脚本生成调用方法如下图:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 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 or consult: "\(commandFile) ". """ )}
其中 findCompileCommand 为生成 sh 脚本的具体方法,主要是针对当前修改类设置对应的编译脚本命令。由于脚本太长,这里就不贴上来了,有兴趣的同学可以自行查看。
使用改动类的编译脚本可以生成其.o文件,具体如下图:
1 2 3 4 5 6 7 8 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" }
这里针对模拟器环境进行脚本配置,配置完成后使用 clang 命令把对应的.o文件生成相同名字的动态库,具体如下图:
1 2 3 4 5 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 \(commandFile) \n \(try! String(contentsOfFile: logfile)) " ) }
过程中产生的产物如下:
苹果会对加载的动态库进行签名校验,所以下一步需要对这个动态库签名,由于签名需要使用 Xcode 环境,所以客户端是无法进行的,只能通过 socket 告诉服务端来进行操作。当服务端收到 InjectionSign 签名命令后会调用 SignerService 类的 codesignDylib 来对相应的动态库进行签名操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 @implementation SignerService + (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 ; } - (void)runInBackground { char __unused skip, buffer[1000 ]; buffer[read(clientSocket, buffer, sizeof buffer- 1 )] = '\000 '; NSString * path = [[NSString stringWithUTF8String:buffer] componentsSeparatedByString:@" " ][1 ]; if ([[self class ] codesignDylib:path identity:nil ]) { snprintf(buffer, sizeof buffer, "HTTP/1.0 200 OK\r \n \r \n " ); write(clientSocket, buffer, strlen(buffer)); } } @end
至此修改文件的重新编译、打包动态库和签名操作就全部完成了,接下来就是我们最熟悉的加载动态库进行方法替换了。
四、加载动态库进行方法替换 在生成动态库后 先使用dlopen方法把对应的动态库加载到当前运行的调试工程的进程中 并拿到返回的指针dl,然后使用dlsym拿到动态库的符号地址。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 @objc func loadAndInject (tmpfile : String , oldClass : AnyClass ? = nil ) throws -> [AnyClass ] { ... guard let dl = dlopen("\(tmpfile) .dylib" , RTLD_NOW ) else { ... } if oldClass != nil { ... } else { return try extractClasses(dl: dl, tmpfile: tmpfile) } } @objc func extractClasses (dl : UnsafeMutableRawPointer , tmpfile : String ) throws -> [AnyClass ] { guard shell(command: """ \(xcodeDev) /Toolchains/XcodeDefault.xctoolchain/usr/bin/nm \(tmpfile) .o | grep -E ' S _OBJC_CLASS_\\ $_| _(_T0|\\ $S|\\ $s).*CN$' | awk '{print $3}' >\(tmpfile) .classes """ ) else { throw evalError("Could not list class symbols" ) } guard var classSymbolNames = (try? String (contentsOfFile: "\(tmpfile) .classes" ))? .components(separatedBy: "\n " ) else { throw evalError("Could not load class symbol list" ) } classSymbolNames.removeLast() let result = Set (classSymbolNames.compactMap { dlsym(dl, String ($0 .dropFirst())) }) .map { unsafeBitCast ($0 , to: AnyClass .self ) } return result; }
如上代码所示,dlopen 会把 tmpfile 动态库文件载入运行的 App 里,返回指针 dl。回到测试工程使用 lldb image list -o -f
确实看到eval101.dylib 被加载进来
1 [408] 0x0000000111bfe000 /var/folders/tw/nl7v5_cd577bbdzfgxjy63bw0000gn/T/com.johnholdsworth.InjectionIII/eval101.dylib
接下来, dlsym 会得到 tmpfile 动态库的符号地址,然后就可以处理类的替换工作了。在拿到新类的符号地址后,我们把新类里所有的类方法和实例方法都替换到对应的旧类中,使用的是SwiftInjection 的 injection 方法,通过OC runtime 的class_replaceMethod把整个类的实现方法都替换了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 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) } }