普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月3日微言 | wyanassert

[转载] 基于dylib注入原理实现iOS热重载框架CocoaHotReload

作者 wyanassert
2025年12月3日 19:32

原文地址

背景

在iOS开发过程中,是否经常遇到这样的场景:简单修改几行代码,想要立刻看到效果,却需要重新增量编译,苦苦等待编译、链接、安装、运行这个漫长的过程;特别是在中大型项目(如手Q)中,增量编译耗时也要60s甚至更多,非常影响开发效率。

为了解决增量编译,业界上也有IPAPatchInjectionIII方案:

IPAPatch 是基于编译子工程,链接为动态库打包到宿主工程,需要改变代码依赖逻辑,使用局限性大且效果并不理想。

InjectionIII 也是基于dylib注入原理实现的iOS热重载能力,但是不支持真机以及dylib注入时仅简单处理函数替换,热重载的能力存在比较大的局限性,如存在对release版本静态库或动态库依赖的代码文件或存在被hook的类的实现代码文件进行热重载,要么无法支持,要么可能出现循环调用而导致应用出现Crash,且对Swift、C++代码的热重载支持也非常有限。如果基于InjectionIII的基础上进行开发,后续如果InjectionIII更新则不利于迭代。

上述两个方案均不能满足我们的需求,因此我们决定在基于dylib注入的原理基础上对热重载实现进行探索实现。

挑战

在复杂项目中实现热重载能力,我们面临的挑战也是巨大的。这里主要体现在dylib动态库的生成及注入后复杂场景的处理,在保证代码快速生效的同时也要保证业务逻辑的稳定性。

下图展示了iOS 源码文件的编译流程:     
 descript

  • 预编译(Precompile): 编译预处理,即替换宏、删除注释、展开头文件等,产生 .i 文件。
  • 编译(Compiling): 将之前的.i文件转换为汇编语言,产生 .s 文件。
  • 汇编(Assembly): 将汇编语言转换为机器码文件,产生 .o 文件。
  • 链接(Link): 将所以.o文件以及依赖的库链接后生成Mach-O类型的可执行文件。

可以看出,要生成dylib可执行文件,就需要获取源码文件,并进行预编译、编译及汇编操作后生成.o文件,再分析其依赖的库(.a.framework),最后链接后才可生成dylib。将dylib注入到运行的App中,并进行逻辑替换,即可使得代码生效,实现热重载能力。

所以我们就需要对源码文件变化的进行监听、查找对应的编译指令并使用xcode编译工具(clang swift)进行文件编译,接着分析编译后的.o文件依赖的库,并进行链接后,最终通过生成dylib

此外,生成dylib库后还需要有通信模块,将动态库发送到App端,进行dlopen注入到app中。

这里需要特别提下真机情况:通过dlopen注入时,会因为安全校验,导致dlopen失败,不过好消息是,在iOS13后,苹果在debug模式下开放了这个能力,使得热重载能力在真机上得到应用,而且在dlopen动态库后,还需要针对不同语言(ObjC、Swift、C)通过Runtime及其他方式进行代码逻辑的处理,才能保证代码及时生效。

针对复杂场景,如hook场景,还需要做保存原有调用栈,及避免死循环等逻辑的处理,保证逻辑正常运行及功能的稳定性,这又加大了热重载能力实现的难度。

设计与实现

项目架构

如前文分析,为了实现dylib的生成和注入以及两端之间的通信完成热重载能力,我们的思路是设计Mac appiOS framework来分别完成dylib的生成和注入,且两端的网络通信是通过Socket(TCP/USB)来完成。

主要的架构图如下:     
 descript

整个项目分为Mac appiOS framework两部分。

Mac app

主要职责是根据修改文件查找对应的编译指令并执行生成.o文件,接着链接.o文件及依赖的库生成dylib动态库。

主要的处理逻辑为标红部分:

ComplileCommandManager:
主要负责解压编译日志并通过正则表达式进行编译指令的提取&缓存,其中核心的正则表达式如下:

1
2
3
4
5
6
7
8
// 提取编译指令正则表达式
NSError *error;
// export LANG && -o 判断oc编译指令
// CompileSwift normal && -o判断swift编译指令
// CompileXIB && --compile 判断xib编译
// CompileStoryboard && --compilation-directory 判断sotryboard编译
// Siwft 编译指令太长会优化成文件映射 如 /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swift-frontend @/var/folders/xx/b4bgcx_1393fdwdnddgzkqmc0000gn/T/TemporaryDirectory.R2XBsY/arguments-639193350444245467.resp
NSRegularExpression *expression = [NSRegularExpression regularExpressionWithPattern:@"((export LANG)|(CompileSwift normal )|(CompileXIB )|(CompileStoryboard )).*?((swift-frontend @)|(-o )|(--compile )|(--compilation-directory )){1}.*?((\\.resp)|(\\.o)|(\\.xib)|(\\.storyboard)){1}" options:NSRegularExpressionDotMatchesLineSeparators error:&error];
  • FileDependentManager : 主要分析.h文件的依赖关系,当改动到.h文件时,需要分析所有依赖该.h的所有.m文件,并进行重编译,才能保证这个.h的改动能完全生效。分析依赖的逻辑是直接分析Xcode编译的中间产物Intermediates.noindex目录下的.d 文件,该文件中记录.m依赖的所有文件。

举个例子,ViewController.m 引用的头文件如下:

1
2
#import "ViewController.h"
#import "ObjCViewController.h"

对应生成的ViewController.d文件如下:

1
2
3
4
5
6
dependencies: \
/Users/username/Documents/Git/CocoaHotReload/CocoaHotReloadExample-CocoaPods/CocoaHotReloadExample-CocoaPods/ViewController.m \
/Users/username/Documents/Git/CocoaHotReload/CocoaHotReloadExample-CocoaPods/CocoaHotReloadExample-CocoaPods/ViewController.h \
/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator15.0.sdk/usr/include/kcdata.modulemap \
// ... 这里忽略系统的modulemap文件
/Users/username/Documents/Git/CocoaHotReload/CocoaHotReloadExample-CocoaPods/CocoaHotReloadExample-CocoaPods/ObjCViewController.h
  • LibManager:主要职责是分析编译日志中,各个Target依赖的frameworklib,并查找私有符号库,并在.o文件linkdlyib时,进行依赖私有符号库查找一同link打包到dylib中。主要目的是为了解决dlopen dylib时,出现Symbol not found的问题,后面内容会详细介绍。

这里查找私有符号库的条件是使用nm工具进行判断的,判断条件如下:

1
2
3
4
5
6
// 判断私有符号库
// 带有private external
// C 或 C++ 符号为_开头
// OC类符号为 _OBJC_CLASS_$
// Swift [alt entry] _$ 或 [alt entry] _OBJC_CLASS_$ 开头
NSString *source = [NSString stringWithFormat:@"nm -nm %@ | grep '(__.*,__.*) private external \\(\\[alt entry\\] \\)\\?_'", libraryFilePath];

iOS Framework

主要职责是进行动态库注入资源包(xib、storyboard)更新。

其中,主要的逻辑处理是类替换和hook函数的处理。

  • 类替换:

    • ObjC 的替换主要是通过Runtime实现的,通过Runtime保证同一个类的所有的函数实现指针都指向最新注入的动态库地址,以保证所有镜像中的同一个类的实现都一致。
    • Swift 的函数替换就较为复杂,由于Swfit涉及到和ObjC混编,及继承ObjC类或者与ObjC相互调用等复杂场景。导致Swift类的派发方式出现了StaticV-TableWitness TableMessage四种派发方式,解决方案是根据这四种派发方式,分别进行函数指针替换以实现热重载能力。具体实现,后续内容会详细描述。
    • C 函数的替换,由于C函数是直接派发的方式,所以不能通过Runtime机制处理,最终是通过第三方开源库 Dobby实现C函数替换。
  • hook函数处理:

    • 由于热重载最主要的逻辑是函数替换,所以函数替换的处理逻辑要极其严谨,否则就会导致逻辑异常甚至Crash。比如当热重载一个存在被其他分类hook的时候,如果直接使用method_exchangeImplementations进行替换的话,就会破坏函数调用栈;例子如下:

      • 如Class A的原始函数OriginMethod,有两个Category
        分别hook了这个OriginMethod,其调用栈流程如下:
        1
        Call OriginMethod -> hook1 imp -> hook2 imp -> OriginMehod imp
      • 当热重载Calss A时,会产生新的New
        OriginMethod实现,使用method_exchangeImplementations进行替换, 导致最终调用栈如下:
        1
        Call OriginMethod -> New OriginMehtod imp
    • 以上情况会导致直接不调用hook 1 imp 和 hook 2

imp,影响正常的业务逻辑。正确的做法应该是找到OriginMehod
imp这个节点,进行替换,来维持之前的调用栈。

问题来了,如果找到正确的替换节点呢?

  1. 需要知道哪些函数被hook

    • 目前我们这里只考虑常规的hook方法(通过Category进行hook),所以优先判断函数是否属于Catrgory。

    • 可通过如下代码获取imp信息:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14

      /*
      * Structure filled in by dladdr().
      */
      typedef struct dl_info {
      const char *dli_fname; /* Pathname of shared object */
      void *dli_fbase; /* Base address of shared object */
      const char *dli_sname; /* Name of nearest symbol */
      void *dli_saddr; /* Address of nearest symbol */
      } Dl_info;

      Dl_info info;
      IMP imp = method_getImplementation(method);
      int result = dladdr(imp, &info);
    • 如TestClass和TestClass (TestCategory) 两个类对应的函数如下:

      1
      2
      3
      4
      5
      6
      7
      @interface TestClass : NSObject
      - (void)testFunction;
      @end
      // 分类
      @interface TestClass (TestCategory)
      - (void)tc_testFunction;
      @end
    • 获取到的Dl_info中的dli_sname分别如下:

      1
      2
      -[TestClass testFunction]
      -[TestClass(TestCategory) tc_testFunction]
    • 由上诉结果可以看出,Category函数是会包含分类名称的。由此来判断分类函数,至于判断是否被hook,只需判断method name 和 imp中的函数名,如果不一致则代表被hook了。

  2. 需要找到哪个函数指向的imp是当前要替换的函数的同名imp,即递归获取函数调用栈,找到method对应的imp name为当前热重载的method name一致的这个method进行指针替换。

    • 关键代码如下:
    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
    37
    38
    39
    40
    41
    42
    43
    44
    // 查找原始实现所在的method
    + (Method)methodWithOrigImpForOriginClass:(Class)originClass
    originSel:(SEL)originSel
    baseClass:(Class)baseClass
    baseSel:(SEL)baseSel
    isClassMethod:(BOOL)isClassMethod
    {
    if (!originClass || !originSel) {
    return NULL;
    }

    NSString *baseClassName = NSStringFromClass(baseClass);
    NSString *baseSelName = NSStringFromSelector(baseSel);

    NSString *originClassName = NSStringFromClass(originClass);
    NSString *originSelName = NSStringFromSelector(originSel);

    Method originMethod = class_getInstanceMethod(originClass, originSel);
    IMP orginSelImp = method_getImplementation(originMethod);

    NSString *orginSelImpClassName = [self classNameForCategoryClassName:[self classNameForImp:orginSelImp]];
    NSString *orginSelImpMethodName = [self methodNameForImp:orginSelImp];

    // 这里只处理标准的hook即只在当前类的Category进行hook
    if (![originClassName isEqualToString:orginSelImpClassName] ||
    [originSelName isEqualToString:orginSelImpMethodName]) { // 没有被hook或者hook出现异常,返回base
    return class_getInstanceMethod(baseClass, baseSel);
    }
    if (([baseClassName isEqualToString:orginSelImpClassName] &&
    [baseSelName isEqualToString:orginSelImpMethodName])) { // 递归结束 // 返回需要baseClass类需要替换的函数
    return class_getInstanceMethod(baseClass, originSel);
    } else {
    Class class = NSClassFromString(orginSelImpClassName);
    if (isClassMethod) {
    class = object_getClass(class);
    }
    SEL sel = sel_registerName(orginSelImpMethodName.UTF8String);
    if (class && sel) { // 递归查找
    return [self methodWithOrigImpForOriginClass:class originSel:sel baseClass:baseClass baseSel:baseSel isClassMethod:isClassMethod];
    } else { // 失败 返回base
    return class_getInstanceMethod(baseClass, baseSel);
    }
    }
    }

工作原理

 descript

如上图所示,Mac app 和 iOS framework(CocoaHotReload.framework)之间是通过Socket(tcp/usb)进行发送command通信,连接成功后,Mac
app会监听文件变化和进行项目初始化(解压&解析编译日志、查找编译指令、签名、私有符号库),Xcode编辑源码并保存后,触发热重载指令,Mac app会查找修改文件的编译指令并执行,生成.o文件并分析.o文件依赖的私有符号库进行link后生成.dylib,并通过Socket发送到app;iOS framework 需内嵌在app中,当接收到.dylib时,通过Runtime&finshook进行类(ObjC&Swift)替换&C函数(动态库中)替换实现、资源(storyboardc&nib)更新及hook函数调用栈&死循环处理,实现dylib注入,完成一次热重载。

具体时序图如下:     
 descript

所遇挑战

这里主要介绍下项目实现过程中,比较难的挑战点:

dlopen error: Symbol not found

由于这个项目是通过dylib注入原理来实现的,所以dlopen是加载动态库的必经之路,但是在dlopen的时遇到了Symbol not found的问题,导致后续注入操作无法进行,直接导致热重载失效,这对于热重载能力是致命的问题。

具体问题如下图所示:     
 descript

该问题的背景如下:     
 descript

如上图所示:触发热重载的文件是QQWalletViewController.mm,该文件依赖了libPayCenterSDK.a文件中的TenpayPlugin类;为了减包,libPayCenterSDK.aRelease模式下Symbol Hidden by Default 设置为YES,该设置导致符号都被隐藏,dlopen时就出现了上方的错误。

最开始为了解决这个问题,尝试了以下两个方案:     
 descript

  • 方案一: 直接将这个Release模式改为NO,这样虽说能解决符号隐藏问题,但是带来了两个问题
    1. 需要业务更改工程设置
    2. 引来业务增包问题(特别是在手Q中对于包增量是很敏感的),所以增包问题是无法接受的。
  • 方案二: 在工程里面link库时,debug模式下linkdebug包,这样也可以解决问题。但是引来的其他问题是:业务都需要更改工程设置,且生成库的时候需要分别生成debug和release的库,对于业务使用热重载的成本还是不小。

以上两个方案,虽说解决了符号隐藏问题,但会带来业务增包问题和影响业务的工程设置。这对于用户使用来说并不友好。理想方案肯定既解决符号隐藏问题且对用户来说是无入侵、无感知且不要带来额外的问题。为了彻底解决问题,刨根问底,去探究下Symbol Hidden by Default这个设置对符号的影响。

这里通过下面的例子来分析下:     
 descript
根据苹果官方文档说明:Symbol Hidden by Default设置对应符号的影响等用于使用__attribute__((visibility("default|hidden")))修饰函数,来控制符号的可见性,所以通过__atrribute__修饰来看下符号影响。

新建一个SymbolTest工程,依赖libSymbolVisibilityTest.a静态库。

静态库中有两个函数分别通过__attribute__visibility("default")visibility("hidden")修饰。

生成静态库后,通过nm -nm libSymbolVisibility.a 查看符号信息,可以看到通过default修饰的符号为external,公开符号,可对外导出。

通过hidden修饰的,符号为private external,表示私有符号,不可导出。

再看下最终静态库打包到app后的符号信息,通过nm -nm symbolTest查看符号信息,可以明显看出default修饰的函数仍为external 可导出符号。

hidden修饰的函数,则变成non-external (was a private external)私有符号。

最后通过MachOView软件查看下SymbolTextExport Info对开开放的符号表信息可以看到只有external修饰的符号才会出现在这个表上面。

了解完Symbol Hidden by Default 设置对符号的影响后,来看下dlopen出现symbol not found的原因:     
 descript

如上图所示,动态库test.dylib,在没有link libSymblVisibilityTest.a库的情况下使用了这个库中的SymbolVisibilityHidden函数,当dlopen test.dylib时,会解析dylib中的符号,发现SymbolVisibilityHidden这个符号为 undenfine symbol时,会尝试调用resolveUndefined继续查找,继续调用findExportedSymbolAddress去符号导出表里面查找,如果能查找到就返回,查找不到及调用throwSymbolNotFound函数抛出”Symbol not found xxx”异常。之前尝试通过Symbol hidden by default设置为No可解决的原因就是设置完,符号会出现在符号导出表中,可被找到。但刚才分析了这个解决方案的不足之处,所以我们在这个调用链上再次寻找解决方案,发现在第二步undefine symbol是因为库没有一起打包到dylib,如果将库打包到dylib中,就不会出现undefine symbol符号问题,
因此,完美的解决方案出现了 通过link依赖的私有符号库即可解决 。现在有了解决方案以后,主要面临了以下两个实现问题:

  1. 如何查找哪些是私有符号库?

    • 首先,在解析编译日志的时候,需要解析出工程中所有target依赖的(farmework、lib),并且通过nm -nm 去分析这些库,只要库中出现了private external 符号及表示私有符号库,并记录下来。
  2. 如何判断哪些符号是私有符号且需要link哪个库?

    • 当生成.o文件后,可通过nm -nm 获取(undefined) external
      获取未定义的符号,但是这里有个问题就是undefined符号包含大量系统符号和私有符号。为了提升效率就需要需要先把系统符号过滤掉,要想过滤掉系统符号,就要先获取私有符号的集合,通过集合去过滤。获取app私有符号的集合可通过nm -nm去分析app可执行文件,通过non-external (was a private external)来记录下整个app中的所有私有符号,即可过滤掉系统符号,并通过这些私有符号在对应的哪些库中,就可以知道需要link哪些库。

最终的完整的实现方案如下: 
 descript

如上图所示,最终在项目初始化时,提前获取好项目的私有符号及私有符号库,触发热重载后,编译生成.o文件,获取.oundedined symbol符号,并且通过项目私有符号过滤掉系统符后,查找undeined symbol所在的库中,接着对依赖的库进行库瘦身再剔除库间重复的.o文件后link库生成dylib。这样就可以无入侵、无感知地完美解决symbol not found的问题。

Swift 函数热重载问题

最开始实现Swfit函数热重载的时候,并没有很透彻,导致出现各种热重载函数不生效的问题,这里看下影响Swift派发方式的因素,这里总结了下一些因素,不详细介绍,有兴趣的同学也可以了解相关的文章,如Swift 底层是怎么调度方法的

 descript
由上图可以看出,影响Swift派发方式有多种,且可以多个因素联合影响最终的派发方式,这里收敛后分析出最终派发的4种方式,如下: 
 
  descript

如上图所示:Swift的派发发送根据不同的数据类型和调用方式影响后,最终收归到4种派发方式,分别为Static(静态派发)V-Table(虚函数表)Witness
Table
以及Message(消息转发)。最终分别针对这4种派发方式进行热重载方案实现,如下图: 
   
 descript

  • Static: 与C函数一致都是属于静态派发函数,所以和C函数的热重载实现一致,均是通过Dobby进行inline hook来实现的热重载。

  • V-Table 和 Witness Table: 根据struct TargetClassMetadata的数据结构和内存布局去获取对应的表指针进行整个表的替换,实现热重载能力。

  • Message: 与ObjC的消息转发机制一致,即可以读取类结构中的MethodList函数列表,并通过Runtime 进行函数实现指针的替换,达到热重载的效果。

实现效果

在iOS开发中,通过CocoaHotReload热重载能力,能够节省增量编译时的链接、安装、运行App的时间,大大提升研发效率

Features

  • 支持模拟器&真机(iOS 13+)
  • 支持ObjC属性和方法多种操作(增加|删除|修改)
  • 支持第三方库设置Symbol hidden by Default为YES
  • 支持修改多种文件类型(.h|.m|.mm|.swift|.storyboard|.xib)
  • 支持新增的文件进行hot reload
  • 支持通过命令行工具生成dylib
  • 支持Swift(仅支持函数替换)
  • 支持Storyboard&xib
  • 支持Unit Tests
  • 支持C函数

效率提升效果

QQ场景

  descript

编译测试工具

 descript

总结与展望

本项目的初衷就是为了提升iOS开发的研发效率,以手Q开发为例,单文件从完成增量编译到应用完成安装、启动平均时间大约70s,使用CocoaHotReload无需启动应用,改动的代码完成带入注入后直接生效,改动生效的平均时间降低到6s左右。在公司内部的大型项目QQ、微信等都接入使用,并整体反馈也是不错的。

我们团队也在不断地探索更高效的研发方式,后续也在尝试实现远程热重载能力(使用远程构建机来完成动态库的生成,本机无需编译,仅拉取代码和安装包即可调试)。

如果你对这方面的技术也比较感兴趣,可以在文末留言,跟我们一起讨论。也欢迎大家给一些建议,非常感谢。

❌
❌