【iOS】源码学习-类与对象底层原理

【iOS】源码学习-类与对象底层原理

OC对象本质

探索对象本质

clang是一个由Apple主导编写,基于LLVM的C/C++/OC的编译器。主要用于底层编译,将一些文件输出成C++文件,其目的是为了更好地观察底层呢的一些结构及实现的逻辑,方便理解底层原理。

常用的几个跑编译器代码的OC命令:

  • 基础命令:将 main.m 编译成 main.cpp:
bash 复制代码
clang -rewrite-objc main.m -o main.cpp
  • 完整SDK命令:将 ViewController.m 编译成 ViewController.cpp:
bash 复制代码
clang -rewrite-objc -fobjc-arc -fobjc-runtime=ios-13.0.0 -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator13.7.sdk ViewController.m

以下两种方式是通过指定架构模式的命令行,使用xcode工具 xcrun:

  • 模拟器文件编译:自动匹配当前Xcode模拟器SDK
bash 复制代码
xcrun -sdk iphonesimulator clang -arch arm64 -rewrite-objc main.m -o main-arm64.cpp 
  • 真机文件编译:编译成真机arm64架构的C++代码
bash 复制代码
xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main- arm64.cpp 

通过一个实例来哦看一下:

objc 复制代码
#import <Foundation/Foundation.h>

@interface LYDPerson : NSObject

@property (nonatomic, strong) NSString *name;

@end

@implementation LYDPerson

@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
    }
    return 0;
}

clang编译一下,我们可以看到:

cpp 复制代码
// NSObject的底层编译
struct NSObject_IMPL {
	Class isa;
};

// LYDPerson的底层编译
struct LYDPerson_IMPL {
	struct NSObject_IMPL NSObject_IVARS;
	NSString *_name;
};

我们发现isa的类型是Class,这是因为我们定义alloc的核心方法initInstance的源码中初始化isa指针是通过isa_t类型初始化的。

为了让开发人员更加清晰明确,需要在isa返回的时候做一个类型强制转换。

总结一下:

  • OC对象的本质就是结构体
  • LYDPerson中的isa继承自NSObject中的isa。
  • isa的作用是让对象在运行时找到自己对应的类,从而继续查找方法、属性、缓存等信息。也就是OC对象的本质是一个以结构体像是存在,并通过isa与类建立联系的运行时对象。

探索objc_setProperty源码

OC对象的本质清楚后,类中还有属性的setter和getter方法。

cpp 复制代码
// getter方法
static NSString * _I_LYDPerson_name(LYDPerson * self, SEL _cmd) { return (*(NSString **)((char *)self + OBJC_IVAR_$_LYDPerson$_name)); }
// setter方法
static void _I_LYDPerson_setName_(LYDPerson * self, SEL _cmd, NSString *name) { objc_setProperty (self, _cmd, __OFFSETOFIVAR__(struct LYDPerson, _name), (id)name, 1, 1); }

我们可以看到setter方法依赖于一个objc_setProperty方法:

objc 复制代码
// self:当前对象实例
// SEL _cmd:当前调用的选择器,即setter方法
// ptrdiff_t offset:实例变量在对象内存中的偏移量,是为了直接通过指针运算找到内存地址,而不是通过字符串名字查找、效率更高
// newValue:要设置的新值
// atomic:表示该属性是否声明为atomic
// shouldCopy:标志位,指示是否需要复制值。可能为0(NO),1(YES/COPY),2(MUTABLE_COPY)
void objc_setProperty(id self, SEL _cmd, ptrdiff_t offset, id newValue, BOOL atomic, signed char shouldCopy)
{
    bool copy = (shouldCopy && shouldCopy != MUTABLE_COPY);
    bool mutableCopy = (shouldCopy == MUTABLE_COPY);
    reallySetProperty(self, _cmd, newValue, offset, atomic, copy, mutableCopy);
}
objc 复制代码
static inline void reallySetProperty(id self, SEL _cmd, id newValue, ptrdiff_t offset, bool atomic, bool copy, bool mutableCopy)
{
    // 在OC对象内存布局中,偏移量为0的位置对应的是对象的isa指针,指向类的原数据。如果偏移量为0说明不是在设置普通实例变量,而是在动态修改对象的类(修改类不涉及常规的retain/release逻辑,类对象本身就是单例且常驻内存)
    if (offset == 0) {
        // 修改isa指针
        object_setClass(self, newValue);
        return;
    }

    id oldValue;
    // 计算对象内部存储该属性的具体内存地址即实例变量的地址
    id *slot = (id*) ((char*)self + offset);

    // 决定怎么保存
    if (copy) {
        newValue = [newValue copyWithZone:nil];
    } else if (mutableCopy) {
        newValue = [newValue mutableCopyWithZone:nil];
    } else {
        if (*slot == newValue) return;
        // 如果发现当前内存槽位的值和新值是同一个对象指针,那么直接返回。这里的判断非常重要,如果没有,后续release的旧值再retain新值会导致对象被提前释放
        newValue = objc_retain(newValue); // 新值retain
    }

    // 决定要不要加锁
    if (!atomic) {
        oldValue = *slot;
        *slot = newValue; // 设置新值
    } else {
        // 给atomic属性赋值时加锁,防止多个线程同时改同一个属性
        // 顺序为加锁->取出旧值->把属性改成新值->解锁
        spinlock_t& slotlock = PropertyLocks.get()[slot];
        slotlock.lock();
        oldValue = *slot;
        *slot = newValue; // 设置新值        
        slotlock.unlock();
    }

    objc_release(oldValue); // release旧值
}

objc_setProperty方法本质是就是一个接口,适用于关联上层的set方法以及底层的set方法。这么设计的原因是:上层的set方法有很多,如果直接把底层的reallySetProperty()方法结合到每一个set方法中,会带来很大开销。因此iOS采用了适配器设计模式(即把底层接口适配为客户端需要的接口),对外提供一个接口,供上层set方法使用,对内调用底层的set方法,使其相互不受影响,达到上下层接口隔离的目的。

cls与类的关联原理

笔者总是混淆几个概念,再次明确一下:

  • obj:对象地址
  • isa:对象内部的指针指向cls类对象
  • cls:类对象地址

探索isa_t类型

isa指针的类型是isa_t,它是通过一个联合体定义的。

  • 结构体:把不同的数据组合成一个整体,其变量是共存的,变量不管是否使用,都会分配内存。存储容量大,包容性强,成员之间不会相互影响,但比较浪费内存。
  • 联合体(共用体):由不同数据类型组成吧,变量互相排斥,所有成员共占一段内存。其采用内存覆盖技术,同一时刻只能保存一个成员的值,如果对新的成员赋值,会将原来成员的值覆盖掉,因此修改一个成员会影响其余所有成员。相比结构体,包容性弱,但节省内存空间。

结构体内存>=所有成员内存总和,联合体内存等于最大成员占用内存

objc 复制代码
#include "isa.h"

union isa_t {
    // 默认的一个构造函数
    isa_t() { }
    // 用value初始化isa的原始bits
    isa_t(uintptr_t value) : bits(value) { }

    // isa联合体的第一个成员,是isa的原始二进制数据
    uintptr_t bits;

private:
    // Accessing the class requires custom ptrauth operations, so // 访问class指针需要特殊的ptrauth(pointer authentication,指针认证)操作
    // force clients to go through setClass/getClass by making this // 强制外部调用者通过setClass/getClass来访问class
    // private.
    // isa联合体的第二个成员,是一个指向类对象的指针
    Class cls;

public:
    // 条件编译:定义了ISA_BITFIELD宏,下面代码才能参与编译
#if defined(ISA_BITFIELD)
    // isa联合体的第三个成员,把isa拆成多个位域字段
    struct {
        // isa的位域定义,不同平台、结构的isa位域可能不同,通过宏来适配
        ISA_BITFIELD;  // defined in isa.h
    };

#if ISA_HAS_INLINE_RC
    // 判断对象是否处于正在释放状态
    bool isDeallocating() const {
      // extra_rc == 0:表示isa内联引用计数为0
      // has_sidetable_rc == 0:表示引用计数没有溢出到SideTable,或者SideTable里没有额外引用计数标记
      // SideTable:runtime给对象准备的一张外挂信息表,存储isa空间里放不下的信息
      // 主要存:1. 溢出的引用计数 2. weak 弱引用表 3. 一些对象额外管理信息
        return extra_rc == 0 && has_sidetable_rc == 0;
    }
    // 标记对象进入销毁状态
    void setDeallocating() {
        extra_rc = 0;
        has_sidetable_rc = 0;
    }
#endif // ISA_HAS_INLINE_RC

#endif

    // 用来设置当前isa里面保存的类对象信息,可能会对cls进行编码、签名或写入bits位域。这里只是函数声明,没有函数实现。
    // cls:要写入isa中的类对象指针
    // obj:当前isa所属的对象
    void setClass(Class cls, objc_object *obj);
    
    // authenticated:表示取出Class时是否需要进行ptrauth指针认证
    // 用来从当前isa中取出对象所属的类对象Class
    Class getClass(bool authenticated) const;

    // 用来从isa的原始bits中解码出(Decoded表示"解码后的")真正的类对象Class,返回从isa bits中经过位移、掩码、解码、认证后得到的类对象指针
    Class getDecodedClass(bool authenticated) const;
};

从isa_t的定义中可以看出:

  • 提供了两个成员,cls和bits共用一块内存,彼此互斥,意味着初始化isa指针有两种初始化方式:
    • 通过cls初始化
    • 通过bits初始化
  • 提供了一个结构体定义的位域,用于存储类信息及其他信息。结构体成员ISA_BITFIELD有两个版本:__arm64__(对应ios 移动端) 和 __x86_64__(对应macOS)。
  1. __x86_64__(对应macOS):
objc 复制代码
#   define ISA_BITFIELD
      uintptr_t nonpointer        : 1; // 是否对isa指针开启指针优化,0表示纯isa指针,1表示不只是类对象
      uintptr_t has_assoc         : 1; // 是否有关联对象,0表示没有关联对象,1表示存在关联对象
      uintptr_t has_cxx_dtor      : 1; 
// 在isa位域里,用一个bit记录这个对象所属的类是否需要执行C++析构逻辑/OC析构相关清理(类似于dealloc)
// 0表示没有复杂析构逻辑,可以更快释放,1表示这个对象销毁时不能直接释放内存,还要先走析构清理逻辑
// 析构清理逻辑:对象真正释放内存之前,要先把内部持有的资源清理干净
      uintptr_t shiftcls          : 44; /*MACH_VM_MAX_ADDRESS 0x7fffffe00000*/ // 存储类的指针的值
      uintptr_t magic             : 6; // 调试器判断对象是真对象还是未初始化空间
      uintptr_t weakly_referenced : 1; // 对象是否被指向或者曾指向一个ARC的弱变量,没有弱引用可以更快释放
      uintptr_t unused            : 1; // 未被使用
      uintptr_t has_sidetable_rc  : 1; // 表示当对象引用计数大于10时,则需要借用该变量存储进位
      uintptr_t extra_rc          : 8 // 额外的引用计数,表示该对象的引用计数值,值为实际引用计数值减1
  1. __arm64__(对应ios 移动端):
objc 复制代码
#     define ISA_BITFIELD                                                      \
        uintptr_t nonpointer        : 1;                                       \
        uintptr_t has_assoc         : 1;                                       \
        uintptr_t has_cxx_dtor      : 1;                                       \
        uintptr_t shiftcls          : 33; /*MACH_VM_MAX_ADDRESS 0x1000000000*/ \
        uintptr_t magic             : 6;                                       \
        uintptr_t weakly_referenced : 1;                                       \
        uintptr_t unused            : 1;                                       \
        uintptr_t has_sidetable_rc  : 1;                                       \
        uintptr_t extra_rc          : 19

探索cls与isa关联原理

通过alloc调用方法实现路径,查找到初始化isa指针源码:

objc 复制代码
inline void 
objc_object::initInstanceIsa(Class cls, bool hasCxxDtor)
{
    // 断言这个类的实例对象不要求使用raw isa,即是优化后的isa,isa里面不只存了类地址,还有引用计数、弱引用标记、关联对象标记等信息
    ASSERT(!cls->instancesRequireRawIsa());
    // 检查这个类是否有C++析构逻辑
    ASSERT(hasCxxDtor == cls->hasCxxDtor());

    initIsa(cls, true, hasCxxDtor);
}
objc 复制代码
inline void 
objc_object::initIsa(Class cls, bool nonpointer, UNUSED_WITHOUT_INDEXED_ISA_AND_DTOR_BIT bool hasCxxDtor)
{
    // 断言当前对象不是Tagged Pointer
    // Tagged Pointer:是一种特殊对象,比如某些小数字、小字符串,系统不会真的分配对象内存,而是直接把值塞进指针里。这种对象没有正常的内存结构,不能像普通对象一样写isa。
    ASSERT(!isTaggedPointer());
    
    // 创建新的isa_t变量并初始化为0
    isa_t newisa(0);

    if (!nonpointer) {
        // 把类对象cls设置进newisa
        newisa.setClass(cls, this);
    } else {
        // 确保支持优化isa指针
        ASSERT(!DisableNonpointerIsa);
        ASSERT(!cls->instancesRequireRawIsa());

// indexed isa:不直接存完整类地址,而是存一个类索引
#if SUPPORT_INDEXED_ISA
        // 断言这个类在数组里的索引必须大于0
        ASSERT(cls->classArrayIndex() > 0);
        // 先把indexed isa的基本模版写进去
        newisa.bits = ISA_INDEX_MAGIC_VALUE;
        // isa.magic is part of ISA_MAGIC_VALUE
        // isa.nonpointer is part of ISA_MAGIC_VALUE
        newisa.has_cxx_dtor = hasCxxDtor;
        // 把类的索引存到isa里
        newisa.indexcls = (uintptr_t)cls->classArrayIndex();
#else
        // 不支持indexed isa的设置普通基础值
        newisa.bits = ISA_MAGIC_VALUE;
        // isa.magic is part of ISA_MAGIC_VALUE
        // isa.nonpointer is part of ISA_MAGIC_VALUE
#   if ISA_HAS_CXX_DTOR_BIT
        newisa.has_cxx_dtor = hasCxxDtor;
#   endif
        newisa.setClass(cls, this);
#endif
#if ISA_HAS_INLINE_RC
        newisa.extra_rc = 1;
#endif
    }

    // This write must be performed in a single store in some cases
    // (for example when realizing a class because other threads
    // may simultaneously try to use the class).
    // fixme use atomics here to guarantee single-store and to
    // guarantee memory order w.r.t. the class index table
    // ...but not too atomic because we don't want to hurt instantiation
    isa() = newisa;
}

从以上方法我们可以看出:

  1. initIsa初始化isa主要分为两个部分:
  • 通过cls初始化isa
  • 通过bits初始化isa
  1. 初始化isa分两条路线:
  • raw isa:isa里只存类对象地址
  • nonpointer isa:优化后isa,这种isa里不仅存类信息,还存其他信息(eg:has_cxx_dtor、extra_rc等)

isa有几下几种:

  • raw isa:类对象Class object、协议对象Protocol object、要求raw isa的特殊实例对象、禁用nonpointer isa的环境。
  • nonpointer isa:普通实例对象
  • 没有普通 isa 结构:Tagged Pointer对象
  • indexed nonpointer isa:indexed isa环境下的普通实例对象
  1. cls与isa关联原理就是isa指针中shiftcls位域中存储了类信息,initInstanceIsa的作用是给calloc出来的对象内存初始化isa,把这块裸内存和当前类cls建立关联,真正完成isa写入的是initIsa,其中具体写入类信息的操作在setClass方法中完成。

验证原理

我们进一步验证一下,主要有以下几种验证方式:

  1. 通过shiftcls赋值(uintptr_t)newCls >> 3验证

我们发现shiftcls存储了当前类的信息。

shiftcls赋值的逻辑是将类进行编码后右移3位,由于指针是8位对齐的,所以后三位一定是000,因此腾出位置存储信息。

通过initIsa中创建isa这里打断点也能看出cls指向了我们的一个类。

这里shiftcls出现强制转换的原因是:内存的存储不能存储字符串,机器码只能识别0、1两种数字,因此需要将其转换为uintptr_t数据类型,这样shiftcls中存储的类信息才能被机器码理解。在64位机器上,intptr_t和uintptr_t分别是long int、unsigned long int的别名;在32位的机器上,intptr_t和uintptr_t分别是int、unsigned int的别名。

  1. 通过isa&ISA_MASK验证

将isa指针的地址&ISA_MASK,得出当前类信息。

  1. 通过object_getClass验证
objc 复制代码
OBJC_EXPORT Class _Nullable
object_getClass(id _Nullable obj) 
    OBJC_AVAILABLE(10.5, 2.0, 9.0, 1.0, 2.0);
objc 复制代码
Class object_getClass(id obj)
{
    if (obj) return obj->getIsa();
    else return Nil;
}
objc 复制代码
inline Class
objc_object::getIsa() const
{
    if (fastpath(!isTaggedPointer())) return ISA(/*authenticated*/true);

    extern objc_class OBJC_CLASS_$___NSUnrecognizedTaggedPointer;
    uintptr_t slot, ptr = (uintptr_t)this;
    Class cls;

    slot = (ptr >> _OBJC_TAG_SLOT_SHIFT) & _OBJC_TAG_SLOT_MASK;
    cls = objc_tag_classes[slot];
    if (slowpath(cls == (Class)&OBJC_CLASS_$___NSUnrecognizedTaggedPointer)) {
        slot = (ptr >> _OBJC_TAG_EXT_SLOT_SHIFT) & _OBJC_TAG_EXT_SLOT_MASK;
        cls = objc_tag_ext_classes[slot];
    }
    return cls;
}

通过getIsa()源码,拿到isa的bits这个位域,再&ISA_MASK,获得当前类信息检验。

  1. 通过位运算验证

shiftcls只有44位的大小,这44位存储了一个类信息,需要经过位运算,手动通过位运算找到shiftcls对应地址输出cls地址对比。

类与类结构的底层分析

类的分析

我们主要探索一下isa的走向和继承关系。

我们通过一段示例调试实例对象的isa、类对象的isa、元类对象的isa以及superclass继承链来分析:

objc 复制代码
@interface LYDPerson : NSObject {
    NSString *hobby;
}

@property (nonatomic, copy) NSString *name;

- (void)sayHello;
- (void)sayBye;

@end

@implementation LYDPerson

- (void)sayHello {
    
}

- (void)sayBye {
    
}

@end
objc 复制代码
@interface LYDTeather : LYDPerson

@end
  
@implementation LYDTeather

@end

这里0x0000000100008228是person对象的isa指针地址,其&后得到的是创建person的类LYDPerson;0x0000000100008200是isa中获取的类信息所指的类的isa的指针地址,即LYDPerson类的类的isa指针地址,在苹果中我们把一个类的类叫做元类。因此打印信息相同,都为LYDPerson。

元类

什么是元类:

  • 类其实也是一个对象,可以称为类对象,对象的isa指向类,类isa的位域指向苹果定义的元类
  • 元类是系统给的,其定义和创建都是由编译器完成的,在这个过程中,类的归属来自于元类
  • 元类是类对象的类,每个类都有一个独一无二的元类来存储类方法的相关信息。
  • 元类本身是没有名称的,由于与类相关联,所以使用了同类名一样的名称。

我们通过lldb调试来探索一下元类的走向:

  1. person的isa指向LYDPerson(类):
  1. LYDPerson(类)的isa指向元类:
  1. 元类的isa指向NSObject(根元类):
  1. NSObject(根元类)的isa指向自己:

从图中的走向我们可以得到一个关系链:对象->类->元类->NSObject,NSObject指向自身

NSObject永远只有一个,我们发现打印的NSObject的元类地址和LYDPerson元类地址其实是同一个。因此内存中只存在一份根元类NSObject ,且根元类指向它自己。而由于类的信息在内存中只有一份,因此类对象也只有一份

isa继承关系

从图中我们可以得到isa和superclass的走位:

  • isa走位:
    • 实例对象(Instance of Subclass)的isa指向类(class)
    • 类对象的isa指向元类(Meta class)
    • 元类的isa指向根元类(Root metal class)
    • 根元类(NSObject)的isa指向它自己形成闭环
  • superclass(继承关系)走位:
    • 类之间的继承关系:
      • 类(Subclass)继承自父类(Superclass)
      • 父类继承自根类(Rootclass),此时的根类是指NSObject
      • 根类继承自nil,即根类为无中生有的万物起源
    • 元类之间的继承关系:
      • 子类的元类(metal Subclass)继承自父类的元类(metal Superclass)
      • 父类的元类继承自根元类(root Metalclass)
      • 根元类继承自根类(Rootclass),此时的根类是指NSObject

实例对象之间没有继承关系,类之间有继承关系

我们把刚刚的示例替换到isa继承关系图中更直观地看一下:

objc_class & objc_object

isa走位清楚后,我们需要知道对象和类都有isa属性的原因。

之前我们看过NSObject clang编译后是一个结构体。其中含有一个Class类型的isa指针,而Class是由结构体objc_class定义的类型。

cpp 复制代码
struct NSObject_IMPL {
	Class isa;
};
typedef struct objc_class *Class;

我们具体看一下这两个结构体:

objc 复制代码
struct objc_object {
    Class _Nonnull isa  OBJC_ISA_AVAILABILITY;
};
objc 复制代码
struct objc_class : objc_object {
  objc_class(const objc_class&) = delete;
  objc_class(objc_class&&) = delete;
  void operator=(const objc_class&) = delete;
  void operator=(objc_class&&) = delete;
    // Class ISA;
    Class superclass;
    cache_t cache;             // formerly cache pointer and vtable
    class_data_bits_t bits;    // class_rw_t * plus custom rr/alloc flags
  //...

从源码中我们可以看出:

  • objc_class与objc_object的关系:
    • 结构体objc_class继承自结构体objc_object,其中objc_object有一个isa属性,所以objc_class也拥有了isa属性。
    • NSObject中的isa在底层是由Class定义的,其中Class的底层编码来自objc_class类型,所以NSObject也拥有了isa属性。
    • NSObject是一个类,用它初始化一个实例对象objc,objc满足objc_object的特性,即有isa属性。这是因为isa是由NSObject从objc_class继承过来的,而objc_class继承自objc_object,objc_object又有isa属性。因此每个对象都有一个isa,isa表示来自于当前的objc_object。
  • objc_object与对象的关系:
    所有的对象都是从objc_object继承过来的。因为所有的对象都是来源于NSObject,NSObject到底层就是objc_object的结构体

总结一下:
所有OC实例对象、类对象、元类对象都有 isa。实例对象底层可以抽象为objc_object,类对象和元类对象底层都由 objc_class 描述,而objc_class又继承 objc_object,所以类对象和元类对象也拥有 isa

而在结构层面可以理解为下层通过结构体定义模版,上层通过底层模版创建类型,从而实现上层OC与底层的对接。

类结构分析

objc 复制代码
struct objc_class : objc_object {
  objc_class(const objc_class&) = delete;
  objc_class(objc_class&&) = delete;
  void operator=(const objc_class&) = delete;
  void operator=(objc_class&&) = delete;
    // Class ISA;
    Class superclass;
    cache_t cache;             // formerly cache pointer and vtable
    class_data_bits_t bits;    // class_rw_t * plus custom rr/alloc flags
  //...

objc_class的结构体里有以下几个属性:

  • isa:继承自objc_object的isa,占8字节。
  • superclass:Class类型,Class是由objc_object定义的,是一个指针,占8字节。
  • cache:方法缓存,提高方法性能,加速objc_msgSend查找方法。class_data_bits_t是一个结构体,内存大小根据内部成员确定。
  • bits:只有首地址经过上面三个属性的内存大小总和的平移才能获得bits。

总结

这里简单了解下类结构,iOS类的结构分析笔者将单独写一篇博客总结。

相关推荐
不灭锦鲤2 小时前
网络安全学习第98天
学习·安全
星幻元宇VR2 小时前
VR自行车骑行模拟系统|让交通安全教育“骑”进现实
科技·学习·安全·vr
知识分享小能手2 小时前
R语言入门学习教程,从入门到精通,R语言数值关系数据可视化 - 完整知识点(5)
学习·信息可视化·r语言
嵌入式小企鹅5 小时前
CPU供需趋紧、DeepSeek V4全链适配、小米开源万亿模型
人工智能·学习·开源·嵌入式·小米·算力·昇腾
三品吉他手会点灯10 小时前
C语言学习笔记 - 20.C编程预备计算机专业知识 - 变量为什么必须的初始化【重点】
c语言·笔记·学习
sakiko_10 小时前
UIKit学习笔记1-创建项目(使用UIKit)、使用组件
笔记·学习
生信碱移11 小时前
PACells:这个方法可以鉴定疾病/预后相关的重要细胞亚群,作者提供的代码流程可以学习起来了,甚至兼容转录组与 ATAC 两种数据类型!
人工智能·学习·算法·机器学习·数据挖掘·数据分析·r语言
星幻元宇VR13 小时前
VR航空航天科普设备【VR时空直升机】
科技·学习·安全·生活·vr
_李小白13 小时前
【android opencv学习笔记】Day 2: Mat类(图片数据结构体)
android·opencv·学习