阅读 429

iOS底层--类的加载分析

欢迎阅读iOS底层系列(建议按顺序)

iOS底层 - alloc和init探索

iOS底层 - 包罗万象的isa

iOS底层 - 类的本质分析

iOS底层 - cache_t流程分析

iOS底层 - 方法查找流程分析

iOS底层 - 消息转发流程分析

iOS底层 - dyld是如何加载app的

iOS底层 - 类的加载分析

1.本文概述

本文旨在分析dyld初始化主程序时,类结构是如何被加载的,类数据是如何处理的。这部分也隶属于main()函数前的流程。

2.类加载探索

2.1 寻找切入点

上篇dyld是如何加载app的分析了dyld的流程,说明了在准备初始化主程序时,libObjc会来_objc_init()到对项目中所有的类结构进行初始化。因此,_objc_init()就是切入点。

2.2 _objc_init()分析

直接在libObjc中搜索_objc_init(

先看它的定义,了解下它是做什么的:

* Bootstrap initialization. Registers our image notifier with dyld.
* Called by libSystem BEFORE library initialization time
译:启动初始化。通过dyld注册我们的图像通知。在库初始化时间之前由libSystem调用
复制代码

来到实现源码,在看它是怎么做的:

① 如果已经执行过初始化,直接返回。保证只初始化一次。
复制代码
② 读取影响运行时的环境变量。如果需要,也打印环境变量help。
复制代码

内部通过字符串匹配读取已设置的环境变量。这里的环境变量基本是以OBJC_开头的,区别于dyld流程中以dyld_开头的环境变量。

可以在iTerm2中输入export OBJC_HELP=1查看系统提供的环境变量。其中OBJC_PRINT_LOAD_METHODS是较为常用的,它会打印出所有实现的+load()方法,开发者可以根据其输出,选择性的删除不必要的+load()方法,提高启动速度。

③ 设置objc的预定义的线程特定键和键的析构函数,来存储objc的私有数据。
复制代码
④ 运行c++静态构造函数
复制代码

内部通过getLibobjcInitializers获取macho__objc_init_func段。因为libObjcdyld调用静态构造函数之前就会先调用_objc_init(),因此这里只能先手动调用,所以这里都是调用系统类的c++静态构造函数。自己写的将会在后续调用。

⑤ 无任何操作
复制代码
void lock_init(void)
{
}
复制代码

内部是个空实现。libObjc是用cc++实现的,它们自身有一套锁的机制,说明这套机制在oc中同样适用。这里默认使用这套机制,不做任何操作。这行代码的意义可能在于增加可读性。

⑥ 初始化异常处理系统
复制代码

内部通过@try@catch保证程序执行过程中出现的异常能详细的输出。

⑦ 在dyld初始化主程序时,通过指针回调实现images的map,load,unmap操作
复制代码

这是_objc_init()的核心部分,前六步只是准备工作。

接下来就来看看,libObjcdyld那里接手的maploadunmap是在做些什么。

2.3 map_images() 分析

void
map_images(unsigned count, const char * const paths[],
           const struct mach_header * const mhdrs[])
{
    mutex_locker_t lock(runtimeLock);
    return map_images_nolock(count, paths, mhdrs);
}
复制代码

map_images()内部通过调用map_images_nolock(),参数是dyld传递来的镜像文件个数count,文件路径pathsmacho头文件信息mach_header

...
        while (i--) {
            const headerType *mhdr = (const headerType *)mhdrs[i];

            auto hi = addHeader(mhdr, mhPaths[i], totalClasses, unoptimizedTotalClasses);
            if (!hi) {
                continue;
            }
            
            if (mhdr->filetype == MH_EXECUTE) {
#if __OBJC2__
                size_t count;
                _getObjc2SelectorRefs(hi, &count);
                selrefCount += count;
                _getObjc2MessageRefs(hi, &count);
                selrefCount += count;
...
复制代码

map_images_nolock内部通过遍历头文件信息,当头文件类型是MH_EXECUTE可执行文件时,获取macho__objc_selrefs__objc_msgrefs段,为注册方法作准备。

...
    if (firstTime) {
        sel_init(selrefCount);
        arr_init();
...
复制代码

然后执行一次性的运行时初始化,该初始化必须延迟到找到可执行文件本身。此初始化包括:

① 注册部分系统方法选择器

#define s(x) SEL_##x = sel_registerNameNoLock(#x, NO)
#define t(x,y) SEL_##y = sel_registerNameNoLock(#x, NO)

    s(load);
    s(initialize);
    t(resolveInstanceMethod:, resolveInstanceMethod);
    t(resolveClassMethod:, resolveClassMethod);
    t(.cxx_construct, cxx_construct);
    t(.cxx_destruct, cxx_destruct);
    s(retain);
    s(release);
    s(autorelease);
    s(retainCount);
    s(alloc);
    t(allocWithZone:, allocWithZone);
    s(dealloc);
    s(copy);
    s(new);
    t(forwardInvocation:, forwardInvocation);
    t(_tryRetain, tryRetain);
    t(_isDeallocating, isDeallocating);
    s(retainWeakReference);
    s(allowsWeakReference);
复制代码

可以看到好多熟悉的方法,那为什么只有这些?

因为这一系列方法系统内部要使用到,需要提早注册,其他方法选择器将会在类结构初始化时注册。

② 自动释放池初始化,全局散列表初始化

void arr_init(void) 
{
    AutoreleasePoolPage::init();
    SideTableInit();
}
复制代码

这里的散列表用来存储后续的weak表,引用计数表等。

map_images_nolock()函数最后执行_read_images()开始读取macho初始化类信息。

...
if (hCount > 0) {
    _read_images(hList, hCount, totalClasses, unoptimizedTotalClasses);
}
复制代码

2.4 _read_images() 分析

首先,_read_images()的目的是读取macho初始化类信息,读取的内容必然需要容器来存储。

int namedClassesSize = 
    (isPreoptimized() ? unoptimizedTotalClasses : totalClasses) * 4 / 3;
gdb_objc_realized_classes =
     NXCreateMapTable(NXStrValueMapPrototype, namedClassesSize);
        
allocatedClasses = NXCreateHashTable(NXPtrPrototype, 0, nil);
复制代码

所以,刚进来,libObjc就先创建了两张表gdb_objc_realized_classesallocatedClasses做准备。

那为什么需要两张表?

  • gdb_objc_realized_classes :未在dyld共享缓存中的已命名类,无论是否实现。并且根据当前类数量做动态扩容。

  • allocatedClasses : 已分配的所有类(元类)。

这也容易理解,系统后续需要依赖表做相关处理,因此总表保存原始所有数据,小表用来保存需要初始化的数据,提高查询效率。

创建好容易,接下来就开始读取数据。

如果宏观的查看此方法,会发现它的写法很有意思,富有仪式感

可以看到,它依次做了相关处理,并且实现的逻辑类似,最后都有对应输出。

主体流程清晰后,来看看其中比较重要的几个处理:

① 类处理

for (EACH_HEADER) {
    classref_t *classlist = _getObjc2ClassList(hi, &count);
    if (! mustReadClasses(hi)) {
        continue;
    }
    bool headerIsBundle = hi->isBundle();
    bool headerIsPreoptimized = hi->isPreoptimized();
    for (i = 0; i < count; i++) {
        Class cls = (Class)classlist[i];
        Class newCls = readClass(cls, headerIsBundle, headerIsPreoptimized);
        ...
        }
    }
}
复制代码
GETSECT(_getObjc2ClassList,           classref_t,      "__objc_classlist");
复制代码

点击_getObjc2ClassList,来到这个宏定义,意思为

macho__objc_classlist段下读取类信息(后面其他的处理也是如此,只是读取的字段不一样),遍历读取的类调用readClass()

乍看之下,readClass里面很多rorw相关的代码,那rw肯定就是在这被设置了,很多文章也是这样说明的。过于草率,可能会忽略细节。

其实这里的设置rw有个判断条件popFutureNamedClass(),这是苹果预留的FutureClass的判断,正常的流程是不会进来,有疑问的同学也可以自行加以断点验证。所以readClass主要做了两件事:

  • 不会执行
  • addNamedClass()
  • addClassTableEntry()

addNamedClass()addClassTableEntry()也很类似,把读取到的类和元类插入到gdb_objc_realized_classes总表中。

这里的类处理,只是把类添加到表中,并为做加载操作。

② 方法编号处理

static size_t UnfixedSelectors{
    mutex_locker_t lock(selLock);
    for (EACH_HEADER) {
        if (hi->isPreoptimized()) continue;
        bool isBundle = hi->isBundle();
        SEL *sels = _getObjc2SelectorRefs(hi, &count);
        UnfixedSelectors += count;
        for (i = 0; i < count; i++) {
            const char *name = sel_cname(sels[i]);
            sels[i] = sel_registerNameNoLock(name, isBundle);
        }
    }
}
复制代码

先从macho__objc_selrefs段下读取方法编号信息,遍历读取的方法编号调用sel_registerNameNoLock()插入到方法编号哈希表中。类似上面系统注册自身使用的方法,这里注册了剩余的其他方法。

③ 非懒加载类实现

for (EACH_HEADER) {
    classref_t *classlist = _getObjc2NonlazyClassList(hi, &count);
    for (i = 0; i < count; i++) {
        Class cls = remapClass(classlist[i]);
        if (!cls) continue;
...
        addClassTableEntry(cls);
...
        realizeClassWithoutSwift(cls);
    }
}
复制代码

先从macho__objc_nlclslist段下读取非懒加载类,遍历读取的非懒加载类调用remapClass()addClassTableEntry()保证已经添加到对应的表中。然后调用realizeClassWithoutSwift() 实现类。

需要注意,整个流程仅处理非懒加载类,懒加载类不加载。
复制代码

那为什么只加载非懒加载类呢?

原因是这样的:目前的流程还是在main()函数之前,一个工程中一般都是几千几万个类起步,而大部分的类是在启动后才被使用到,甚至有些类永远不会被使用。如果启动前都需要去加载,那么启动时间可想而知会有多长,苹果只去加载启动时需要用到的类是很合理的也很必要的。

④ 分类处理

for (EACH_HEADER) {
    category_t **catlist = _getObjc2CategoryList(hi, &count);
    bool hasClassProperties = hi->info()->hasCategoryClassProperties();
    for (i = 0; i < count; i++) {
        category_t *cat = catlist[i];
        Class cls = remapClass(cat->cls);
        if (!cls) {
            catlist[i] = nil;
            ...
            continue;
        }
        bool classExists = NO;
        if (cat->instanceMethods || cat->protocols || cat->instanceProperties) {
            addUnattachedCategoryForClass(cat, cls, hi);
            if (cls->isRealized()) {
                remethodizeClass(cls);
                classExists = YES;
            }
            ...
        }
        if (cat->classMethods || cat->protocols ||  (hasClassProperties && cat->_classProperties)) {
            addUnattachedCategoryForClass(cat, cls->ISA(), hi);
            if (cls->ISA()->isRealized()) {
                remethodizeClass(cls->ISA());
            }
            ...
        }
    }
}
复制代码

先从macho__objc_catlist段下读取分类,遍历读取的分类调用remethodizeClass(),内部在调用attachCategories(),把分类的methodprotocolproperty附加到类。

文章2.6会有部分分析,分类相关的下一章会进一步分析(涉及到懒加载和非懒加载到情况),这里先分析下重点函数realizeClassWithoutSwift()

2.5 realizeClassWithoutSwift()分析

从字面意思来看,realizeClassWithoutSwift()是非swift环境下实现类的调用,那是否存在swift环境下实现类的调用。尝试搜索下,果然看到_objc_realizeClassFromSwift()

看其实现:

本质上依然是调用realizeClassWithoutSwift(),所以无论是否swift,分析realizeClassWithoutSwift()即可。

还是先看它是做什么的:

Performs first-time initialization on class cls,including allocating its read-write data.
Returns the real class structure for the class. 
译:在类上执行首次初始化,包括分配其读写数据。返回类的真实类结构
复制代码

然后在看它是怎么做的:

① 绝大多数情况都执行Normal class,这里是真正开始设置rw的地方,但是需要注意,rw只赋值了roflgs,其他的methodsprotocol还未赋值。

② 递归实现类的父类和元类,递归出口是cls=nil,会一直递归到NSObject

③ 对类的结构体内部的isasupercls赋值,类的结构包含isasuperclscache_tbits。很好理解,实现类的同时,肯定需要对内部属性做处理。

④ 如果存在supercls,反向把cls添加为supercls对子类,否则直接设置为rootClass。③和④相当于双向链表关联clssupercls

methodizeClass() 的作用是修复cls的方法列表、协议列表和属性列表。附加任何额外的类别。

2.6 methodizeClass()分析

来到methodizeClass(),

它保持先类后分类的顺序做了两件事(部分情况时,此时的ro已经存在分类的数据,下一章分析):

  • rw中依次attachLists()方法列表、协议列表和属性列表
  • unattachedCategoriesForClass()获取未附加的分类列表,调用attachCategories()rw中也依次attachLists()方法列表、协议列表和属性列表。

来到 attachCategories()

static void attachCategories(Class cls, category_list *cats, bool flush_caches){
    if (!cats) return;
    ...
    auto rw = cls->data();

    prepareMethodLists(cls, mlists, mcount, NO, fromBundle);
    rw->methods.attachLists(mlists, mcount);
    free(mlists);
    if (flush_caches  &&  mcount > 0) flushCaches(cls);

    rw->properties.attachLists(proplists, propcount);
    free(proplists);

    rw->protocols.attachLists(protolists, protocount);
    free(protolists);
}
复制代码

可以看到内部同样是调用attachLists()

有个有趣的现象,方法列表、协议列表和属性列表都可以调用attachLists(),说明它们的底层结构是类似的。

来到 attachLists(),

这是数据的具体操作步骤。

  • 在确定个数且为多个的情况下,旧数据整体向后移动新数据的个数,把新数据插入到列表的前面
  • 在确定个数且为一个情况下,新数据直接插在第一个
  • 不确定个数的情况下,旧数据往后移动一个,新数据插入一个在前面,反复执行到添加结束

总的来说,新数据总是在旧数据前面,这也就解释了为什么相同方法,分类方法会有"覆盖"主类方法的假象(+load除外)

至此,_objc_initdyld接手的map_images()工作结束,类的结构被加载,类数据被处理。但是需要注意的是,以上仅限于非懒加载类

3.部分相关问题总结和面试题

1.分类和主类实现了相同的方法会被覆盖嘛,谁覆盖谁?

都不会覆盖,分类和主类的方法同时存在,只是分类存储位先于主类,所以调用方法优先读取到分类的方法,产生了一种主类方法被分类覆盖的假象

2.类和分类的数据谁先被加入rw?

先类后分类。分类的数据是需要被attachrw中才能生效,所以需要先加载类,使其拥有rw。即使有些情况分类先被执行,但也只是被保存起来,等到类被执行后才attach分类

3.为什么有了ro,还需要rw

因为oc是动态的,除了编译期的数据,还能在运行时添加数据

4.ro和rw的关系

ro存储了当前类在编译期就已经确定的属性、方法以及遵循的协议;

rw是在运行时才确定,它会先将ro的内容拷贝过去,然后再将当前类的分类的这些属性、方法等拷贝到其中。rwro的超集

5.macho里面的数据是怎么到内存的

读取macho的对应字段下的内容,用哈希表存储,然后根据表初始化

4.写在后面

相对于dyld,类的加载原理流程较为简单,但是根据它衍生出来的面试题较多,因此了解它的流程是性价比很高的一件事,值得我们花时间去深入。

下一章是分类的加载分析,涉及到类和分类在懒加载和非懒加载时的流程,是对本章内容的补充。