普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月4日iOS

iOS 知识点 - Category / Extension / Protocol 小合集

作者 齐生1
2025年12月3日 16:27

谈到 OC 基础,错不开的三种机制:Category / Extension / Protocol

它们分别解决了:

  • Category:不修改类源代码、不继承的前提下,给已有类 “添加方法”(组织文件、系统类加功能、AOP 风格 hook 等)。
  • Extention: 在实现文件里补充声明 私有 属性、实例变量。
  • Protocol: 只定义 “接口规范”(方法/属性的声明),不提供实现,用于解耦(代码只依赖协议,不依赖具体类)& 多态(不同类实现同一协议,都可赋给协议限定类型id<Protocol>

Category

  • 概念:category 是一种给已有类(包括系统类)增加实例方法/类方法的机制,不需要子类化,也不需要访问原类源码。

  • 限制

    • 不能直接增加新实例变量(ivar),但是可以通过 关联对象 间接添加 “类似属性” 的存储。
        #import <objc/runtime.h>
    
        static const void *kNameKey = &kNameKey;
    
        @implementation NSObject (Name)
    
        - (void)setName:(NSString *)name {
            objc_setAssociatedObject(self, kNameKey, name, OBJC_ASSOCIATION_COPY_NONATOMIC);
        }
    
        - (NSString *)name {
            return objc_getAssociatedObject(self, kNameKey);
        }
        
        @end
    
    • 不能直接访问原类 pivate 类型的变量/方法(同子类),必须要原类在 .h 中公开声明。
  • 编译后的本质category 在编译后,额外的方法会被编译器 “合并” 到原类的 method_list 中,runtime 加载类时一起注册。

    • 简化过程
      1. 编译:每个 .m 中的 @implementation ClassName (CategoryName) 生成一个 category_t 结构,其中包含:
        • class name
        • instance methods list
        • class methods list
        • protocol list
      2. 程序加载:runtimeload_images 时遍历所有 category
        • 找到对应 class
        • 把 category 的 methods_list 插入到 class 的 methods_list 列表前方。(同名方法覆盖原来的实现)
  • ⚠️注意事项

    • 如果分类与原类(或其他分类)有同名方法,后加载的 method 会 覆盖前面的实现(行为依赖于加载顺序)
  • 经典用途

    • 给类添加方法(NSArray+Utils.h 等)
    • 拆分类的实现,按照功能分块(常用于 swift 代码风格)
    • 方法交换(日志、埋点、hook 等)

runtime 讲解篇:juejin.cn/post/757172…

延伸名词 runtimeload_images(面向切面编程)

  • 概念load_images 是 runtime 的一个内部函数,在 dyld 把一个新的 Mach-O image (主程序 / 动态库 / 插件) 加载进来时,会回调 runtime 的 map_images / load_images 这一整套流程。

  • 接口作用

    • 注册 image 里的 类列表、分类列表、协议列表、选择子 等;
    • 把 category 的 方法/协议 挂载到对应的类上;
    • 收集并按照一定顺序调用 +load 方法(先类,后分类)。

你也可以简化理解成:每当一块儿新的二进制文件被载入进程,runtime 就用 load_images 把这块儿里的 oc 元数据接入到系统里。

延伸名词 AOP(面向切面编程)

含义:一种编程思想,能够在「不改动原有代码逻辑」的前提下,在指定的“切面点”上插入额外逻辑(如埋点、日志、监控、权限校验等)。

核心概念 含义
切点(Pointcut) 想“切入”的位置,比如方法调用前/后
通知(Advice) 在切点执行的额外逻辑(before、after、around)
切面(Aspect) 切点 + 通知 的组合
织入(Weaving) 把这些逻辑动态插入代码执行流程的过程

iOS 实现 AOP 的方法

  1. 方法交换:在交换方法中实现新的逻辑
  2. 消息转发:利用 forwardInvocation:resolveInstanceMethod: 在运行时拦截消息,再“转发”到自己的处理逻辑。
  3. 三方库:Aspects

Extension

  • 概念: 在类的实现被编译前,给它再“补充”一些

    • 方法声明
    • 属性声明
    • 额外 ivar(实例变量)
  • ⚠️注意事项:

    • Extension 必须和 @implementation MyClass 在同一个编译单元或可见范围,这样编译器才会把它当成类定义的一部分,生成 ivar 和访问器方法 (setter/getter)。

Protocol

  • 概念:接口规范与解耦,只写签名、不写实现。
  • 可以包含
    • 实例/类方法
    • Property 声明。

@required@optional 的意义和成本

编译期行为

  • @required:类遵循了这个协议,但是没实现 required 方法 → 编译器 warning
  • @optional:完全由你自己决定是否实现,编译器不强制。

运行时行为

  • 协议本身只是一堆元数据(protocol_t),runtime 存储了:
    • 协议有哪些 @required@optional 的方法;
    • 哪些是实例 / 类方法;
    • 哪些是 property。
  • 但是消息派发是不看协议的:
    • 派发时只看这个对象的方法列表里有没有该 selector;
    • 至于这个 selector 来自哪个协议、是否声明在协议里,派发阶段都不关系。
    /// 因此,在调用时常配合:
    if ([self.delegate respondsToSelector:@selector(doOptionalThing)]) {
        [self.delegate doOptionalThing];
    }
    

协议里的 property 到底是什么?

前置知识:协议只写签名,不写实现。

  • 实际上在协议中声明的 @property (nonatomic, copy) NSString *name; 仅仅等价于 setter、getter 的声明(无实现、无 ivar)。
- (NSString *)name;
- (void)setName:(NSString *)name;

protocol 经典用途

代理模式

  • 例如:vc 处理在 view 上的交互事件,view 通过代理调用 vc 实际处理事件的方法。
/// View.h
@interface View : UIView

@property (nonatomic, weak) id<MyViewDelegate> delegate;

@end

/// View.m
- (void)buttonTapped {
    if ([self.delegate respondsToSelector:@selector(didClickButton:)]) {
        [self.delegate didClickButton:self];
    }
}

/// ViewController.m
@interface ViewController () <MyViewDelegate>
@end

@implementation ViewController

#pragma mark - MyViewDelegate
- (void)didClickButton:(MyView *)view {
    // 处理事件
}
  • 这样就做到了:解耦 + 多态 + 依赖倒置

模块/组件之间的接口抽象 类型约束/API设计

protocol vs 继承

协议 继承
本质 接口集合,不带实现、不带存储 类型扩展机制,带实现、带存储
语义关系 描述 “能做” 某些事情 描述 “就是” 某种事物
数量 一个类可实现 个协议 一个类只能有 个直接父类(多继承例外)
主要用途 解耦、抽象、多态 复用、建立层次结构
编译期检查 检查是否实现 required 方法(warning) 检查 override 签名、类型转换等
运行时检查 conformsToProtocol:
 class_conformsToProtocol
isKindOfClass:
 isMemberOfClass:

延伸知识点 isKindOfClass:isMemberOfClass:

  • isKindOfClass:: 是不是这个类或它的子类
- (BOOL)isKindOfClass:(Class)aClass {
    for (Class c = object_getClass(self); c; c = class_getSuperclass(c)) {
        if (c == aClass) return YES;
    }
    return NO;
}
  • isMemberOfClass:: 是不是这个类本身(不包括子类)
- (BOOL)isMemberOfClass:(Class)aClass {
    return object_getClass(self) == aClass;
}

对于实例对象(instance)

@interface Animal : NSObject
@end

@interface Dog : Animal
@end

Animal *a = [Animal new];
Dog    *d = [Dog new];

// isKindOfClass:
[a isKindOfClass:[Animal class]]; // YES  (Animal 本身)
[a isKindOfClass:[Dog class]];    // NO   (Animal 不是 Dog 家族)

[d isKindOfClass:[Animal class]]; // YES  (Dog 是 Animal 的子类,被认为是 Animal 家族的一员)
[d isKindOfClass:[Dog class]];    // YES  (Dog 本身)

// isMemberOfClass:
[a isMemberOfClass:[Animal class]]; // YES  (a 的 class 恰好是 Animal)
[a isMemberOfClass:[Dog class]];    // NO   (class 是 Animal,不是 Dog)

[d isMemberOfClass:[Animal class]]; // NO   (class 是 Dog,不是 Animal)
[d isMemberOfClass:[Dog class]];    // YES  (class 恰好是 Dog)

对于类对象(class)

/// ❌错误的用法
// [Animal isKindOfClass:Animal]; // 左侧的 “Animal” 被 oc 语法解释为消息接受者,右侧的 “Animal” 会被认作 “类型名”,编译器报错参数异常。
// 

// ✅正确的用法
[Animal isSubclassOfClass:[Animal class]]; // YES (正经用法)
[Animal isKindOfClass:[Animal class]];     // NO  (不正经用法)
  • [Animal isKindOfClass:[Animal class]]; 为什么输出 NO?
    • 首先,在 runtime 讲解中已知,类本身也是对象,类型为 Class
    • 再结合 isKindOfClass: 方法的实现:
      1. object_getClass(self) 开始循环向上查询
      2. self 是类对象本身(class object), 首次查询的结果就是 metaClass object;
      3. class object != metaClass object,已经错过了类对象本身,因此返回 NO。

独立开发者亲测:MLX框架让我的App秒变AI原生!15年iOS老兵的2025新感悟

作者 JZXStudio
2025年12月3日 11:16

大家好,我是K哥,一个写了15年iOS代码的独立开发者。从Objective-C时代一路写到Swift,见证过App Store的黄金十年,也熬过内卷最狠的那几年。但2025年,我第一次感受到——AI真的不是噱头,而是每个iOS开发者都能亲手掌控的生产力革命

这一切,都源于苹果在WWDC25正式力推的 MLX框架


🚀 以前做AI功能?难如登天

过去想在App里加个智能推荐、图像生成或语音理解,要么调用第三方API(贵+慢+隐私风险),要么自己啃PyTorch(iOS端部署?别想了)。作为独立开发者,既没算力也没团队,AI功能基本是“看看就好”。

但MLX彻底改变了游戏规则。


🔥 MLX到底强在哪?亲测三大颠覆点

✅ 1. 本地跑大模型,不联网也能AI原生

MLX专为Apple Silicon优化,M1/M2/M3芯片直接硬件加速。我在Mac mini M2上跑7B参数的文本生成模型,响应速度比某些云API还快!用户数据全程留在设备端,隐私合规不再是难题——这在欧盟DSA和国内个保法时代太重要了。

✅ 2. Swift + Python双支持,老iOS人无缝上手

作为Swift死忠粉,我本担心要重学Python生态。结果MLX同时提供Swift和Python API!我用Swift直接调用预训练模型,几行代码就给我的笔记App加上了“智能摘要”和“灵感扩写”功能。不用改架构,不依赖后端,三天上线AI模块——真·秒变AI原生。

✅ 3. 不仅能推理,还能本地微调!

最震撼的是:MLX支持在Mac上直接训练和微调模型。我用自己积累的用户行为数据(脱敏后)微调了一个小模型,个性化推荐准确率提升40%。以前这得租GPU集群,现在一台Mac搞定,成本趋近于零。


💡 给同行的3条实战建议

  1. 别等“完美模型”:从小场景切入,比如“图片自动打标签”、“输入补全”、“语音转待办事项”,用户感知强,开发量小。
  2. 善用Lazy Computation:MLX的延迟计算特性可大幅减少内存占用,这对iPhone内存敏感场景至关重要。
  3. 关注多模态融合:2025年趋势是“文本+图像+音频”联动。比如用户拍张图,App自动生成图文笔记——MLX全支持!

🌟 写在最后:这是属于独立开发者的黄金时代

曾几何时,我们觉得AI是大厂的游戏。但MLX的出现,把顶级AI能力交到了每一个Mac用户手中——而你我,正是最懂如何把它变成好产品的那群人。

15年iOS开发教会我:工具会变,平台会变,但“解决用户真实问题”的初心不变。而今天,MLX给了我们前所未有的杠杆。

如果你也在做独立App,别犹豫——去Apple Developer官网下载MLX,跑通第一个Demo。你会发现,AI原生,真的只差一行import。


#iOS开发 #AI原生应用 #MLX框架 #独立开发者 #AppleSilicon #WWDC25 #程序员日常 #AI创业 #Swift开发 #2025技术趋势

昨天 — 2025年12月3日iOS

[转载] 基于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、微信等都接入使用,并整体反馈也是不错的。

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

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

❌
❌