in iOS ~ read.

iOS - Hot Reload

Author:Mitchell

Hot reload for iOS - InjectionIII

  • InjectionIII 是一个可以在模拟器上帮助实现 OC 与 Swift 热重载的 mac 端应用,可以很大程度上提高 iOS App 的研发效率,仓库源码在这里: github repo

原理

  • InjectionIII 整体框架上是一个 C/S 的架构,当 InjectionIII 启动之后会创建一个 server ,之后会像要调试的 app 进程中注入一个打包好客户端代码的 bundle 来启动一个 client,用来监听 server 发送的消息,当客户端收到了服务器发送的改变的消息之后,会将改动文件打包并进行重载,InjectionIII 提供了两种方法来实现 Hot Reload
    • 一种是通过文件观察者实现对工程中文件的变化监听来实现的自动更新,自动更新通过是否勾选状态栏中的 File watcher 来进行监控文件改变的开启或者关闭。
    • 另外一种是通过对键盘输出监听来实现的手动更新,通过 DDHotKeyCenter 来监听键盘的回调,通过虚拟键盘 ctrl+= 来手动进行更新。
    • 当文件改变或者进行手动触发更新之后,server 会将变更的文件通知给 client,由 client 对变更的文件进行重新编译与打包并注入执行。
    • 这里整理了一副较完整的运行流程图,根据这个图能够大概理解 InjectionIII 的运行的流程: 原理图

代码分析

  • InjectionIII 运行之后首先创建了 socket server 用来向注入的客户端发送注入的消息。
  • 手动Hot Reload流程,InjectionIII 创建了 DDHotKeyCenter 的实例来监听键盘输入的回调,在回调中向注入的客户端发送注入的指令,这样当在模拟器按下 ctrl+= 的时候就会回调并执行:
//Appdelegate.m
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
    appDelegate = self;
//socket server
    [InjectionServer startServer:INJECTION_ADDRESS];
    NSStatusBar *statusBar = [NSStatusBar systemStatusBar];
    statusItem = [statusBar statusItemWithLength:statusBar.thickness];
    statusItem.toolTip = @"Code Injection";
    statusItem.highlightMode = TRUE;
    statusItem.menu = statusMenu;
    statusItem.enabled = TRUE;
    statusItem.title = @"";
    enabledTDDItem.state = ([[NSUserDefaults standardUserDefaults] boolForKey:UserDefaultsTDDEnabled] == YES)
        ? NSControlStateValueOn
        : NSControlStateValueOff;
    enableVaccineItem.state = ([[NSUserDefaults standardUserDefaults] boolForKey:UserDefaultsVaccineEnabled] == YES)
        ? NSControlStateValueOn
        : NSControlStateValueOff;
    [self setMenuIcon:@"InjectionIdle"];
    //管理虚拟键盘输入,并在触发回调时自动调用 autoInject:方法
    [[DDHotKeyCenter sharedHotKeyCenter] registerHotKeyWithKeyCode:kVK_ANSI_Equal                                                     modifierFlags:NSEventModifierFlagControl
target:self action:@selector(autoInject:) object:nil];  
}
//接收键盘输入回调处理
OSStatus dd_hotKeyHandler(EventHandlerCallRef nextHandler, EventRef theEvent, void *userData) {  
    @autoreleasepool {
        EventHotKeyID hotKeyID;
        GetEventParameter(theEvent, kEventParamDirectObject, typeEventHotKeyID, NULL, sizeof(hotKeyID), NULL, &hotKeyID);
        UInt32 keyID = hotKeyID.id;        
        NSSet *matchingHotKeys = [[DDHotKeyCenter sharedHotKeyCenter] hotKeysMatching:^BOOL(DDHotKey *hotkey) {
            return hotkey.hotKeyID == keyID;
        }];
        if ([matchingHotKeys count] > 1) { NSLog(@"ERROR!"); }
        DDHotKey *matchingHotKey = [matchingHotKeys anyObject];
        NSEvent *event = [NSEvent eventWithEventRef:theEvent];
        NSEvent *keyEvent = [NSEvent keyEventWithType:NSKeyUp
                                             location:[event locationInWindow] modifierFlags:[event modifierFlags] timestamp:[event timestamp] windowNumber:-1 context:nil characters:@"" charactersIgnoringModifiers:@"" isARepeat:NO keyCode:[matchingHotKey keyCode]];
    [matchingHotKey invokeWithEvent:keyEvent];
    }
    return noErr;
}
  • 自动注入的流程:当选中想要监听的功能之后,端上会创建FileWatcher 监听文件变化改动类的实例,当文件有改动的时候会调用事先注册好的回调函数,并获取改变的文件,然后执行 autoInject: 注入方法,具体如下:
//Appdelegate
//选中工程方法
- (BOOL)application:(NSApplication *)theApplication openFile:(NSString *)filename {
    NSOpenPanel *open = [NSOpenPanel new];
    open.prompt = NSLocalizedString(@"Select Project Directory", @"Project Directory");
    //    open.allowsMultipleSelection = TRUE;
    if (filename)
        open.directory = filename;
    open.canChooseDirectories = TRUE;
    open.canChooseFiles = FALSE;
    //    open.showsHiddenFiles = TRUE;
    if ([open runModal] == NSFileHandlingPanelOKButton) {
        NSArray<NSString *> *fileList = [[NSFileManager defaultManager]                                         contentsOfDirectoryAtPath:open.URL.path error:NULL];
        if(NSString *projectFile =
           [self fileWithExtension:@"xcworkspace" inFiles:fileList] ?:
           [self fileWithExtension:@"xcodeproj" inFiles:fileList]) {
            //设置选中的工程
            self.selectedProject = [open.URL.path stringByAppendingPathComponent:projectFile];
            //发送选中工程的命令,创建文件变化监控器
            [self.lastConnection setProject:self.selectedProject];
            [[NSDocumentController sharedDocumentController]
             noteNewRecentDocumentURL:open.URL];
            return TRUE;
        }
    }
    return FALSE;
}
//FileWatcher
@interface FileWatcher : NSObject
/*
初始化方法
@param projectRoot 工程路径 
@param plugin 注册回调
*/
- (instancetype)initWithRoot:(NSString *)projectRoot plugin:(InjectionCallback)callback;
//InjectionServer
@implementation InjectionServer 
    //注册文件变化回调
    injector = ^(NSArray *changed) {
        NSMutableArray *changedFiles = [NSMutableArray arrayWithArray:changed];
        if ([[NSUserDefaults standardUserDefaults] boolForKey:UserDefaultsTDDEnabled]) {
            for (NSString *injectedFile in changed) {
                NSArray *matchedTests = testCache[injectedFile] ?:
                    (testCache[injectedFile] = [InjectionServer searchForTestWithFile:injectedFile
projectRoot:projectFile.stringByDeletingLastPathComponent  
                                    fileManager:[NSFileManager defaultManager]]);
                [changedFiles addObjectsFromArray:matchedTests];
            }
        }
        NSTimeInterval now = [NSDate timeIntervalSinceReferenceDate];
        BOOL automatic = appDelegate.enableWatcher.state == NSControlStateValueOn;
        for (NSString *swiftSource in changedFiles)
            if (![pending containsObject:swiftSource])
                if (now > lastInjected[swiftSource].doubleValue + MIN_INJECTION_INTERVAL && now > pause) {
                    lastInjected[swiftSource] = [NSNumber numberWithDouble:now];
                    [pending addObject:swiftSource];
                    if (!automatic)
                        [self writeCommand:InjectionLog
                                withString:[NSString stringWithFormat:
                                            @"'%@' saved, type ctrl-= to inject",
                                            swiftSource.lastPathComponent]];
                }
        if (automatic)
            [self injectPending];
    };
}
//InjectionServer 注入方法
- (void)injectPending {
    for (NSString *swiftSource in pending)
        dispatch_async(injectionQueue, ^{
            [self writeCommand:InjectionInject withString:swiftSource];
        });
    [pending removeAllObjects];
}
//向客户端发送注入的命令
- (BOOL)writeCommand:(int)command withString:(NSString *)string {
    return write(clientSocket, &command, sizeof command) == sizeof command &&
        (!string || [self writeString:string]);
}
  • 接下来分析下 autoInject:,源码中将注入的逻辑用宏给禁用掉了,这里将宏展开,整体流程是,首先通过 SMJobBless(什么是 SMJobBless) 创建了一个 Helper tool,这个 Helper 的主要作用是将 iOSInjection.bundle 注入到所正在模拟器中运行的应用的进程中
- (IBAction)autoInject:(NSMenuItem *)sender {
    [self.lastConnection injectPending];
//#if 0
    NSError *error = nil;
    // Install helper tool
    if ([HelperInstaller isInstalled] == NO) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
        if ([[NSAlert alertWithMessageText:@"Injection Helper"
                             defaultButton:@"OK" alternateButton:@"Cancel" otherButton:nil
                 informativeTextWithFormat:@"InjectionIII needs to install a privileged helper to be able to inject code into "
              "an app running in the iOS simulator. This is the standard macOS mechanism.\n"
              "You can remove the helper at any time by deleting:\n"
              "/Library/PrivilegedHelperTools/com.johnholdsworth.InjectorationIII.Helper.\n"
              "If you'd rather not authorize, patch the app instead."] runModal] == NSAlertAlternateReturn)
            return;
#pragma clang diagnostic pop
        if ([HelperInstaller install:&error] == NO) {
            NSLog(@"Couldn't install Smuggler Helper (domain: %@ code: %d)", error.domain, (int)error.code);
            [[NSAlert alertWithError:error] runModal];
            return;
        }
    }
    NSString *bundlePath = [[NSBundle mainBundle] pathForResource:@"iOSInjection" ofType:@"bundle"];
    if ([HelperProxy inject:bundlePath error:&error] == FALSE) {
        NSLog(@"Couldn't inject Simulator (domain: %@ code: %d)", error.domain, (int)error.code);
        [[NSAlert alertWithError:error] runModal];
    }
//#endif
}
  • 安装 Helper 的过程:
///HelperInstaller.m
+ (BOOL)install:(NSError **)error {
    AuthorizationRef authRef = NULL;
    BOOL result = [self askPermission:&authRef error:error];
    if (result == YES) {
        result = [self installHelperTool:[self kInjectionHelperID] authorizationRef:authRef error:error];
    }
    if (result == YES) {
        NSLog(@"Installed v%@", [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"]);
    }
    return result;
}
+ (BOOL)installHelperTool:(NSString *)executableLabel authorizationRef:(AuthorizationRef)authRef error:(NSError **)error {
    CFErrorRef blessError = NULL;
    BOOL result = SMJobBless(kSMDomainSystemLaunchd, (__bridge CFStringRef)executableLabel, authRef, &blessError);

    if (result == NO) {
        NSLog(@"Could not install %@ - %@", executableLabel, blessError);
        *error = (__bridge NSError *)blessError;
    } else {
        NSLog(@"Installed %@ successfully", executableLabel);
    }

    return result;
}
  • 注入 iOSInjection.bundle
+ (BOOL)inject:(NSString *)bundlePath error:(NSError **)error {
    NSConnection *c = [NSConnection connectionWithRegisteredName:@HELPER_MACH_ID host:nil];
    assert(c != nil);

    Helper *helper = (Helper *)[c rootProxy];
    assert(helper != nil);

    NSLog(@"Injecting %@", bundlePath);
    //真正执行注入的地方
    mach_error_t err = [helper inject:[NSBundle mainBundle].bundlePath
                               bundle:bundlePath
                               client:__FILE__
                     dlopenPageOffset: dlopenPageOffset
                    dlerrorPageOffset: dlerrorPageOffset];

    if (err == 0) {
        NSLog(@"Injected Simulator");
        return YES;
    } else {
        NSString *description;
        switch( err ) {
            case SMHelperErrorsPayload: description = @"Unable to init payload"; break;
            case SMHelperErrorsNoSim:   description = @"Simulator is not running"; break;
            case SMHelperErrorsNoNm:    description = @"Unable to find dlopen. Is xcode-select correct?"; break;
            case SMHelperErrorsNoApp:   description = @"Could not find App running in simulator"; break;
            case SMHelperErrors32Bits:  description = @"Injection only possible for 64 bit targets"; break;
            default:
                description = [NSString stringWithCString:mach_error_string(err) ?: "Unkown mach error" encoding:NSASCIIStringEncoding];
        }

        NSLog(@"an error occurred while injecting Simulator: %@ (error code: %d)", description, (int)err);

        *error = [NSError errorWithDomain:[NSBundle mainBundle].bundleIdentifier
                                     code:err
                                 userInfo:@{NSLocalizedDescriptionKey: description}];
        return NO;
    }
}
  • Client 收到文件改变的消息之后,将改动的文件重新编译,通过 dlopen 来进行动态重载
///InjectionClient.m
- (void)runInBackground {
...
 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;
                }
...
}
//SwiftInjection.swift
    @objc
    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 {
        }
    }

总结

  • 感兴趣的同学可以去看看源码,整个工程源码梳理下来涉及到的知识点很多,涵盖:
    • 抽取 Xcode 头文件
    • 动态构建 target 产出,
    • 开辟子进程执行任务
    • mac bundle 动态注入
    • 文件重编译
    • 读取修改 nib,storyboard 文件
    • 动态库符号表替换
    • ...
  • 不得不感叹作者深厚的编程功底,也对热更新的流程有了进一步的认识。

相关链接:

Flutter hot reload

React native hot reload

comments powered by Disqus