iOS原生项目支持热重载(Hot reload)

西门桃桃 2022-03-03 PM 2767℃ 0条

背景

John Holdsworth 开发了一个支持 OC、Swift 以及 Swift 和 OC 混编项目的 UI热重载工具 Injection 可以动态地将 Swift 或 Objective-C 的代码在已运行的程序中执行,修改完UI直接com+s,不用重新编译运行就能看到UI效果。可以用来提高调试代码的速度。

动画演示效果如下:

1.gif

Injection使用方法

1、安装Injection

github下载最新release版本,或者AppStore下载安装即可,推荐github下载安装,github更新比AppStore更新快。如果你的项目使用混编OC时,强烈建议使用github的releases版本

2、项目配置

1、安装后,打开InjectionIII,选择Open Project,选择你的项目目录

2.png

2、 在AppDelegate的DidFinishLaunchingWithOptions配置InjectionIII的路径

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
#ifdef DEBUG
    [[NSBundle bundleWithPath:@"/Applications/InjectionIII.app/Contents/Resources/iOSInjection.bundle"] load];
#endif
    return YES;
}

3、 在需要动态调试的页面控制器中写上injected方法, 把需要操作的UI方法添加到injected中执行, 如果想让全部的控制器都能使用, 直接添加到BaseViewController

// Objective-C:
- (void)injected {
    #ifdef DEBUG
        NSLog(@"I've been injected: %@", self);
        [self viewDidLoad];
    #endif    
}

// Swift
@objc func injected() { 
    #if DEBUG 
        print("I've been injected: \(self)")
        self.viewDidLoad() 
    #endif 
}

4、 重新编译项目, 控制台可以看到

**💉 InjectionIII connected /Users/***/Desktop/***/**/***.xcworkspace**
**💉 Watching files under /Users/***/Desktop/****
// 下面的只是警告, 作者在Issue中已经解释, 不耽误正常使用.
**💉 💉 ⚠️ Your project file seems to be in the Desktop or Documents folder and may prevent InjectionIII working as it has special permissions.**

5、 修改完UI, 直接cmd + S就能看到效果, 部分页面可能耗时比较久或无法使用, 正常页面均能使用

iOS原生项目热重载的原理

Injection 会监听源代码文件的变化,如果文件被改动了,Injection Server 就会执行 rebuildClass 重新进行编译、打包成动态库,也就是 .dylib 文件。编译、打包成动态库后使用 writeSting 方法通过 Socket 通知运行的 App。writeString 的代码如下:

- (BOOL)writeString:(NSString *)string {
    const char *utf8 = string.UTF8String;
    uint32_t length = (uint32_t)strlen(utf8);
    if (write(clientSocket, &length, sizeof length) != sizeof length ||
        write(clientSocket, utf8, length) != length)
        return FALSE;
    return TRUE;
}

Server 会在后台发送和监听 Socket 消息,实现逻辑在 InjectionServer.mm 的 runInBackground 方法里。Client 也会开启一个后台去发送和监听 Socket 消息,实现逻辑在 InjectionClient.mm里的 runInBackground 方法里

Client 接收到消息后会调用 inject(tmpfile: String) 方法,运行时进行类的动态替换

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

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

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


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

    print("???? Loading .dylib - Ignore any duplicate class warning...")
    // load patched .dylib into process with new version of class
    guard let dl = dlopen("\(tmpfile).dylib", RTLD_NOW) else {
        throw evalError("dlopen() error: \(String(cString: dlerror()))")
    }
    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

        try injectGenerics(tmpfile: tmpfile, handle: dl)

        guard shell(command: """
            \(xcodeDev)/Toolchains/XcodeDefault.xctoolchain/usr/bin/nm \(tmpfile).o | grep -E ' S _OBJC_CLASS_\\$_| _(_T0|\\$S).*CN$' | awk '{print $3}' >\(tmpfile).classes
            """) else {
            throw evalError("Could not list class symbols")
        }
        guard var symbols = (try? String(contentsOfFile: "\(tmpfile).classes"))?.components(separatedBy: "\n") else {
            throw evalError("Could not load class symbol list")
        }
        symbols.removeLast()

        return Set(symbols.flatMap { dlsym(dl, String($0.dropFirst())) }).map { unsafeBitCast($0, to: AnyClass.self) }

熟悉的动态库加载函数 dlopen:

guard let dl = dlopen("\(tmpfile).dylib", RTLD_NOW) else {
    throw evalError("dlopen() error: \(String(cString: dlerror()))")
}

如上代码所示,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")
}

当类的方法都被替换后,我们就可以开始重新绘制界面了。整个过程无需重新编译和重启 App,至此使用动态库方式极速调试的目的就达成了。 Injection 的工作原理如下所示:

3.png

参考:

06 | App 如何通过注入动态库的方式实现极速编译调试?-极客时间

GitHub - johnno1962/InjectionIII: Re-write of Injection for Xcode in (mostly) Swift

标签: 热重载, Hot reload

非特殊说明,本博所有文章均为博主原创。

评论啦~