• iOS经典面试题之深入解析分类Category的本质以及如何被加载


    一、分类的本质

    ① Category 与 extension

    • Category 是 Objective-C 2.0 之后添加的语言特性,Category 的主要作用是为已经存在的类添加方法。
    • extension 看起来很像一个匿名的 Category,但是 extension 和有名字的 Category 几乎完全是两个东西。extension 在编译期决议,它就是类的一部分,在编译期和头文件里的 @interface 以及实现文件里的 @implement 一起形成一个完整的类,它伴随类的产生而产生亦随之一起消亡。extension 一般用来隐藏类的私有信息,必须有一个类的源码才能为一个类添加 extension,所以无法为系统的类比如 NSString 添加 extension。
    • 但是 Category 则完全不一样,它是在运行期决议的。就 Category 和 extension 的区别来看,可以推导出一个明显的事实,extension 可以添加实例变量,而 Category 是无法添加实例变量的(因为在运行期,对象的内存布局已经确定,如果添加实例变量就会破坏类的内部布局,这对编译型语言来说是灾难性的)。

    ② Category 分析

    • 新建一个 YDWPerson 类,并添加一个分类 YDWPerson (Test),并在分类里面添加 ydw_test 方法并实现, 然后 Clang;

    在这里插入图片描述

    • 在 Clang 之前,先看一下分类的数据结构,在 Apple 官方文档中说明如下:

    在这里插入图片描述

    • 我们都知道:不管是对象还是类,存储在内存空间都是结构体的形式,id -> objc-object,Class -> objc_class,同样 Category 对应的结构体如下:
      • 过时的分类定义如下:

    在这里插入图片描述

      • 现在的分类定义如下:

    在这里插入图片描述

    • category_t 结构体的具体定义如下:
    struct category_t {
    	// 类的名称
        const char *name;
        // cls 要扩展的类对象,编译期间这个值不会存在,在 APP 被 runtime 加载时才会根据 name 对应的类对象
        classref_t cls;
    	// 实例方法和类方法
        WrappedPtr<method_list_t, PtrauthStrip> instanceMethods;
        WrappedPtr<method_list_t, PtrauthStrip> classMethods;
        // 协议
        struct protocol_list_t *protocols;
        // 实例属性
        struct property_list_t *instanceProperties;
        // Fields below this point are not always present on disk.
        // 类属性
        struct property_list_t *_classProperties;
    
        method_list_t *methodsForMeta(bool isMeta) {
            if (isMeta) return classMethods;
            else return instanceMethods;
        }
    
        property_list_t *propertiesForMeta(bool isMeta, struct header_info *hi);
        
        protocol_list_t *protocolsForMeta(bool isMeta) {
            if (isMeta) return nullptr;
            else return protocols;
        }
    };
    
    • 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
    • 然后回到通过 Clang 编译之后的文件,看看编译时做了什么内容:
    static struct _category_t _OBJC_$_CATEGORY_YDWPerson_$_Test __attribute__ ((used, section ("__DATA,__objc_const"))) = 	// 存放在 _DATA 数据段中的 __objc__const 字段中
    {
    	// 类名
    	"YDWPerson",
    	0, // &OBJC_CLASS_$_YDWPerson, //  编译期间不会有
    	(const struct _method_list_t *)&_OBJC_$_CATEGORY_INSTANCE_METHODS_YDWPerson_$_Test, // 实例方法
    	0,
    	0,
    	0,
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 搜索一下 CATEGORY_INSTANCE_METHODS_YDWPerson$_Test:
    static struct /*_method_list_t*/ {
    	// 方法的定义
    	unsigned int entsize;  // sizeof(struct _objc_method)
    	unsigned int method_count;
    	struct _objc_method method_list[1];
    } _OBJC_$_CATEGORY_INSTANCE_METHODS_YDWPerson_$_Test __attribute__ ((used, section ("__DATA,__objc_const"))) = {
    	sizeof(_objc_method),
    	1,
    	// struct objc_selector *)"ydw_test":Selector 
    	// "v16@0:8":方法签名 
    	// (void *)_I_YDWPerson_Test_ydw_test:函数指针地址
    	{{(struct objc_selector *)"ydw_test", "v16@0:8", (void *)_I_YDWPerson_Test_ydw_test}}
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 搜索 _I_YDWPerson_Test_ydw_test 完成之后,可以看到定义的方法:
    // @implementation YDWPerson (Test)
    
    static void _I_YDWPerson_Test_ydw_test(YDWPerson * self, SEL _cmd) {
    }
    // @end
    
    • 1
    • 2
    • 3
    • 4
    • 5
    static struct _category_t *L_OBJC_LABEL_CATEGORY_$ [1] __attribute__((used, section ("__DATA, __objc_catlist,regular,no_dead_strip")))= {
    	&_OBJC_$_CATEGORY_YDWPerson_$_Test,
    };
    // 最后,这个类的 category 生成了一个数组,存在了 __DATA 字段下的 __objc_catlistsection 里
    static struct IMAGE_INFO { unsigned version; unsigned flag; } _OBJC_IMAGE_INFO = { 0, 2 };
    
    • 1
    • 2
    • 3
    • 4
    • 5

    二、Category 如何被加载?

    • 通过 Clang 知道了系统在编译时期对分类做了什么事情,那么什么时候被加载进来呢?程序的入口点在 iOS 中被称之为 main 函数:所写的所有代码,它的执行的第一步,均是由 main 函数开始的。但其实,在程序进入 main 函数之前,内核已经为程序加载和运行做了许多的事情。
    • 当设置符号断点 objc _init,可以看到如下调用堆栈信息,这些函数都是在 main 函数调用前,被系统调用的:

    在这里插入图片描述

    • _objc_init 是 0bject-C runtime 的入口函数,在这里面主要功能是读取 Mach-0 文件 0C 对应的 Segment seciton,并根据其中的数据代码信息,完成为 0C 的内存布局,以及初始化 runtime 相关的数据结构。
    • 可以看到,_objc_init 是被 _dyld_start 所调用起来的,_dyld_start 是 dyld 的 bootstrap 方法,最终调用到 _objc_init。
    • 当程序启动时,系统内核首先会加载 dyld,而 dyld 会将 APP 所依赖的各种库加载到内存空间中,其中就包括 libobjc 库(OC 和runtime),这些工作是在 APP 的 main 函数执行前完成的。
    void _objc_init(void) {
        static bool initialized = false;
        if (initialized) return;
        initialized = true;
        
        // 读取影响运行时的环境变量,如果需要,还可以打开环境变量帮助 export OBJC_HELP = 1
        environ_init();
        // 关于线程key的绑定,例如线程数据的析构函数
        tls_init();
        // 运行C++静态构造函数,在dyld调用我们的静态析构函数之前,libc会调用_objc_init(),因此必须自己做
        static_init();
        // runtime运行时环境初始化,里面主要是unattachedCategories、allocatedClasses,分类初始化
        runtime_init();
        // 初始化libobjc的异常处理系统
        exception_init();
    #if __OBJC2__
        // 缓存条件初始化
        cache_t::init();
    #endif
        // 启动回调机制,通常这不会做什么,因为所有的初始化都是惰性的,但是对于某些进程,会迫不及待地加载trampolines dylib
        _imp_implementationWithBlock_init();
    
        /*
         _dyld_objc_notify_register -- dyld 注册的地方
         - 仅供objc运行时使用
         - 注册处理程序,以便在映射、取消映射 和初始化objc镜像文件时使用,dyld将使用包含objc_image_info的镜像文件数组,回调 mapped 函数
         
         map_images:dyld将image镜像文件加载进内存时,会触发该函数
         load_images:dyld初始化image会触发该函数
         unmap_image:dyld将image移除时会触发该函数
         */
        _dyld_objc_notify_register(&map_images, load_images, unmap_image);
    
    #if __OBJC2__
        didCallDyldNotifyRegister = true;
    #endif
    }
    
    • 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
    • 可以看到,_objc_init 是被 _dyld_start 所调用起来的,_dyld_start 是 dyld 的 bootstrap 方法,最终调用了 _objc_init,dyld 是苹果的动态加载器,用来加载 image(Mach-O 的二进制文件)。
    //
    // Note: only for use by objc runtime
    // Register handlers to be called when objc images are mapped, unmapped, and initialized.
    // Dyld will call back the "mapped" function with an array of images that contain an objc-image-info section.
    // Those images that are dylibs will have the ref-counts automatically bumped, so objc will no longer need to
    // call dlopen() on them to keep them from being unloaded.  During the call to _dyld_objc_notify_register(),
    // dyld will call the "mapped" function with already loaded objc images.  During any later dlopen() call,
    // dyld will also call the "mapped" function.  Dyld will call the "init" function when dyld would be called
    // initializers in that image.  This is when objc calls any +load methods in that image.
    //
    void _dyld_objc_notify_register(_dyld_objc_notify_mapped    mapped,
                                    _dyld_objc_notify_init      init,
                                    _dyld_objc_notify_unmapped  unmapped);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 说明:
      • _dyld_objc_notify_mapped 对应 map_image 回调:当 dyld 已将 images 加入内存时;
      • _dyld_objc_notify_init 对应 load_image 回调:当 dyld 初始化 image 时,OC 调用类的 +load 方法,就是在这个时候进行的;
      • _dyld_objc_notify_unmapped 对应 unmap_image 回调,当 dyld 将 images 移除内存时。
    • 而 category 写入 target class 的方法列表,则是在 _dyld_objc_notify_mapped,即将 Mach-0 相关 sections 都加载到内存之后所发生的。
    • 当 dyld 将 images 加载加入内存时,会触发以下这个回调函数:
    /***********************************************************************
    * map_images
    * Process the given images which are being mapped in by dyld.
    * Calls ABI-agnostic code after taking ABI-specific locks.
    *
    * Locking: write-locks runtimeLock
    **********************************************************************/
    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);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 最终会调用 _read_images 方法读取 OC 相关 Section,并以此来初始化 OC 环境:
    if (hCount > 0) {
    	_read_images(hList, hCount, totalClasses, unoptimizedTotalClasses);
    }
    
    • 1
    • 2
    • 3
    • runtime 调用 _getObjc2XXX 格式的方法,依次来读取对应的 section 内容,并根据其结果初始化其自身结构:
    for (EACH_HEADER) {
        if (! mustReadClasses(hi, hasDyldRoots)) {
            // Image is sufficiently optimized that we need not call readClass()
            continue;
        }
    
        classref_t const *classlist = _getObjc2ClassList(hi, &count);
    
        bool headerIsBundle = hi->isBundle();
        bool headerIsPreoptimized = hi->hasPreoptimizedClasses();
    
        for (i = 0; i < count; i++) {
            Class cls = (Class)classlist[i];
            Class newCls = readClass(cls, headerIsBundle, headerIsPreoptimized);
    
            if (newCls != cls  &&  newCls) {
                // Class was moved but not deleted. Currently this occurs
                // only when the new class resolved a future class.
                // Non-lazily realize the class below.
                resolvedFutureClasses = (Class *)
                    realloc(resolvedFutureClasses,
                            (resolvedFutureClassCount+1) * sizeof(Class));
                resolvedFutureClasses[resolvedFutureClassCount++] = newCls;
            }
        }
    }
    
    • 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
    • _getObjc2XXX 方法有如下几种,可以看到它们都一一对应 Mach-O 中相关的 OC seciton:
    //      function name                 content type     section name
    GETSECT(_getObjc2SelectorRefs,        SEL,             "__objc_selrefs"); 
    GETSECT(_getObjc2MessageRefs,         message_ref_t,   "__objc_msgrefs"); 
    GETSECT(_getObjc2ClassRefs,           Class,           "__objc_classrefs");
    GETSECT(_getObjc2SuperRefs,           Class,           "__objc_superrefs");
    GETSECT(_getObjc2ClassList,           classref_t const,      "__objc_classlist");
    GETSECT(_getObjc2NonlazyClassList,    classref_t const,      "__objc_nlclslist");
    GETSECT(_getObjc2CategoryList,        category_t * const,    "__objc_catlist");
    GETSECT(_getObjc2CategoryList2,       category_t * const,    "__objc_catlist2");
    GETSECT(_getObjc2NonlazyCategoryList, category_t * const,    "__objc_nlcatlist");
    GETSECT(_getObjc2ProtocolList,        protocol_t * const,    "__objc_protolist");
    GETSECT(_getObjc2ProtocolRefs,        protocol_t *,    "__objc_protorefs");
    GETSECT(getLibobjcInitializers,       UnsignedInitializer, "__objc_init_func");
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 可以看到,所使用的类,协议和 category 都是在 _read_images 方法中读取出来的;在读取 cateogry 的方法 get0bjc2CategoryList(hi, &count) 中,读取的是 Mach-0 文件的 _objc_catlist 段:
    if (cls->isStubClass()) {
        // Stub classes are never realized. Stub classes
        // don't know their metaclass until they're
        // initialized, so we have to add categories with
        // class methods or properties to the stub itself.
        // methodizeClass() will find them and add them to
        // the metaclass as appropriate.
        if (cat->instanceMethods ||
            cat->protocols ||
            cat->instanceProperties ||
            cat->classMethods ||
            cat->protocols ||
            (hasClassProperties && cat->_classProperties))
        {
        	// 如果分类中有实例方法、协议、实例属性,就会改写 target class 的结构
            objc::unattachedCategories.addForClass(lc, cls);
        }
    } else {
        // First, register the category with its target class.
        // Then, rebuild the class's method lists (etc) if
        // the class is realized.
        if (cat->instanceMethods ||  cat->protocols
            ||  cat->instanceProperties)
        {
        	// 如果分类中有类方法、协议或类属性,会改写 target class 的元类结构
            if (cls->isRealized()) {
                attachCategories(cls, &lc, 1, ATTACH_EXISTING);
            } else {
                objc::unattachedCategories.addForClass(lc, cls);
            }
        }
    
        if (cat->classMethods  ||  cat->protocols
            ||  (hasClassProperties && cat->_classProperties))
        {
            if (cls->ISA()->isRealized()) {
                attachCategories(cls->ISA(), &lc, 1, ATTACH_EXISTING | ATTACH_METACLASS);
            } else {
                objc::unattachedCategories.addForClass(lc, cls->ISA());
            }
        }
    }
    
    • 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
    • 总结:
      • 先调用 _get0bjc2CategoryList 读取 _objc_catlist seciton 下所记录的所有 category,并存放到 category _t * 数组中;
      • 依次读取数组中的 category_t *cat;
      • 对每一个 cat,先调用 remapClass(cat->cls),并返回一个 objc_ class* 对象 cls,这一步的目的在于找到 category 对应的类对象 cls;
      • 找到 category 对应的类对象 cls 后,就开始进行对 cls 的修改操作:
        • 如果 category 中有实例方法、协议和实例属性之一的话,则直接对 cls 进行操作;
        • 如果 category 中包含了类方法、协议、类属性(不支持)之一的话,还要对 (cls 所对应的元类 (cls- ->ISA()) 进行操作;
      • 不管是对 cls 还是 cls 的元类进行操作,都是调用的方法 addUnattachedCategoryForClass,但这个方法并不是 category 实现的关键,其内部逻辑只是将 class 和其对应的 category 做一个映射,这样以 class 为 key 就可以取到所其对应的所有的 category;
      • 做好 class 和 category 的映射后,会调用 remethodizeClass 方法来修改 class 的 method list 结构,这才是 runtime 实现 category 的关键所在。
    • remethodizeClass:
    static void remethodizeClass(Class cls) {
    	category_list *cats;
    	bool isMeta;
    	runtimeLock.assertLocked();
    	
    	isMeta = cls->isMetaClass();
    	// 通过 unattachedCategoriesForClass 取出还未被附加到 class 上的 category list
    	if((cats = unattachedCategoriesForClass(cls, false))) {
    		if (PrintConnecting) {
    			_objc_inform("CLASS: attaching categories to class '%s' %s", cls->nameForLogging(), isMeta ? "(meta)" : "")
    		}
    		// attachCategories 将这些 category 附加在 class 上
    		attachCategories(cls, cats, true);
    		free(cats);
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    // Attach method lists and properties and protocols from categories to a class.
    // Assumes the categories in cats are all loaded and sorted by load order, 
    // oldest categories first.
    static void
    attachCategories(Class cls, const locstamped_category_t *cats_list, uint32_t cats_count,
                     int flags) {
        if (slowpath(PrintReplacedMethods)) {
            printReplacements(cls, cats_list, cats_count);
        }
        if (slowpath(PrintConnecting)) {
            _objc_inform("CLASS: attaching %d categories to%s class '%s'%s",
                         cats_count, (flags & ATTACH_EXISTING) ? " existing" : "",
                         cls->nameForLogging(), (flags & ATTACH_METACLASS) ? " (meta)" : "");
        }
    
        /*
         * Only a few classes have more than 64 categories during launch.
         * This uses a little stack, and avoids malloc.
         *
         * Categories must be added in the proper order, which is back
         * to front. To do that with the chunking, we iterate cats_list
         * from front to back, build up the local buffers backwards,
         * and call attachLists on the chunks. attachLists prepends the
         * lists, so the final result is in the expected order.
         */
         // 分别以 cats->counts 的大小为例,创建方法列表、属性列表、协议列表
        constexpr uint32_t ATTACH_BUFSIZ = 64;
        method_list_t   *mlists[ATTACH_BUFSIZ];
        property_list_t *proplists[ATTACH_BUFSIZ];
        protocol_list_t *protolists[ATTACH_BUFSIZ];
    
        // ...
        rwe->properties.attachLists(proplists + ATTACH_BUFSIZ - propcount, propcount);
        rwe->protocols.attachLists(protolists + ATTACH_BUFSIZ - protocount, protocount);
    }
    
    • 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
    // 这个时候循环遍历当前的cats,然后将当前的cat中的数组存放到二维数组当中,将方法列表、属性列表、协议列表存放到二维数组当中
    while (i--) {
    	auto& entry = cats->list[i];
    	method_list_t *mlist = entry.cat->methodsForMeta(isMeta);
    	if (mlist) {
    		mlists[mcount++] = mlist;
    		fromBundle |= entry.hi->isBundle();
    	}
    
    	property_list_t *proplist = entry.cat->propertiesForMeta(isMeta, entry.hi) ;
    	if (proplist) {
    		proplists[propcount++] = proplist;
    	}
    	protocol_list_t *protolist = entry.cat->protocols;
    	if (protolist) {
    		protolists[protocount++] = protolist;
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    // 取出class的data()数据,其实是class_rw_t*指针,其对应结构体实例存储了class的基本信息
    auto rw = cls->data();
    
    // 将当前准备好的方法列表、属性列表、协议列表附加到Class当中
    prepareMethodLists(cls, mlists, mcount, NO, fromBundle);
    rw->methods.attachL1sts(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);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    if (hasArray()) {
    	// 旧内存数组大小 
    	uint32_t oldCount = array()->count;
    	// 新数组内存大小
    	uint32_t newCount = oldCount + addedCount;
    	// 重新分配内存空间
    	setArray((array_t *)realloc(array(), array_t:byteSize(newCount)));
    	array()->count = newCount;
    	// 将array->lists的oldCount字节大小的内存区域,拷贝到array->lists + addedCount的内存区域
    	memmove(array()->lists + addedCount, array()->lists, oldCount * sizeof(array()->lists[0]));
    	// 同理,但是memmove更安全
    	memcpy(array()->lists, addedLists, addedCount * sizeof(array()->lists[0]));
    }  
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    三、如何给 Category 添加属性?关联对象以什么形式进行存储?

    • 关联对象以哈希表的格式,存储在一个全局的单例中:
    @interface YDWPerson (Test)
    
    @property (nonatomic, copy) NSString *name;
    
    @end
    
    
    @implementation YDWPerson (Test)
    
    - (void)setName:(NSString *)name {
        objc_setAssociatedObject(self, @selector(name), name, OBJC_ASSOCIATION_COPY_NONATOMIC);
    }
    
    - (NSString *)name {
        return objc_getAssociatedObject(self, @selector(name));
    }
    
    @end
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    四 、Category 在编译过后,是在什么时机与原有的类合并到一起的?

    • 程序启动后,通过编译之后,Runtime 会进行初始化,调用 _objc_init;
    • 然后会 map_images;
    • 接下来调用 map_images_nolock;
    • 再然后就是 read_images,这个方法会读取所有的类的相关信息;
    • 最后是调用 reMethodizeClass,这个方法是重新方法化的意思;
    • 在 reMethodizeClass 方法内部会调用 attachCategories,这个方法会传入 Class 和 Category,会将方法列表,协议列表等与原有的类合并;
    • 最后加入到 class_rw_t 结构体中。
  • 相关阅读:
    服务端挂了,客户端的 TCP 连接还在吗?
    猿创征文|大厂说的 代码门禁如何实现?
    基于51单片机的温度测量报警系统的设计与制作
    高级测试开发进阶知识详解
    在Linux中快速编译带图标的windows程序
    SQL Server - 使用 Merge 语句实现表数据之间的对比同步
    8-2插入排序-折半插入排序
    算法设计与智能计算 || 专题九: 基于拉普拉斯算子的谱聚类算法
    为什么我从 Swift 转向 Flutter,你也应该这样做
    电阻代码的谐音助记口诀
  • 原文地址:https://blog.csdn.net/Forever_wj/article/details/126530555