iOS常见问题总结与解答(1)

428 阅读23分钟

分类和扩展有什么区别?可以分别用来做什么?分类有哪些局限性?分类的结构体里面有哪些成员?

分类(Category)和扩展(Extension)是Objective-C和Swift编程语言中的两种机制,用于对现有类进行功能的扩展和组织。

区别:

  1. 分类是Objective-C中的机制,而扩展是Swift中的机制。
  2. 分类可以为现有类添加新的方法,但不能添加新的实例变量,而扩展可以添加新的方法、计算属性和实例变量。
  3. 分类可以在运行时动态地应用于类,而扩展在编译时就被应用于类。

用途:

  1. 分类和扩展都可以用来对现有类进行功能的扩展,使得我们可以在不修改原始类的情况下添加新的方法或属性。
  2. 分类和扩展可以用来组织代码,将相关的方法和属性分组放置在不同的分类或扩展中,提高代码的可读性和可维护性。

分类的局限性:

  1. 分类不能添加新的实例变量,只能添加方法。
  2. 分类中的方法实现会覆盖原始类中同名方法的实现,可能导致意外的行为。
  3. 分类不能重写原始类的方法,只能添加新的方法。

分类的结构体成员: 分类的结构体成员包括:

  1. isa:指向类对象的指针。
  2. superclass:指向父类的指针。
  3. name:分类的名称。
  4. instanceMethods:实例方法列表。
  5. classMethods:类方法列表。
  6. protocols:遵循的协议列表。
  7. properties:属性列表。

这些成员用于描述分类的结构和定义的方法、属性以及遵循的协议。

讲一下atomic的实现机制;为什么不能保证绝对的线程安全(最好可以结合场景来说)?

atomic的实现机制是通过使用自旋锁(spinlock)来保证setter和getter方法的线程安全。自旋锁是一种基于忙等待的锁机制,它会不断地检查锁的状态,直到获取到锁为止。在使用atomic修饰属性时,编译器会自动生成原子读写方法,并在内部使用自旋锁来保护属性的读写操作[1]

然而,尽管atomic修饰的属性在单独的原子操作中是线程安全的,但在组合操作中无法保证绝对的线程安全。这是因为多线程环境下,即使使用了自旋锁,仍然存在竞态条件(race condition)的问题。

竞态条件是指多个线程对共享资源进行读写操作时,由于执行时序的不确定性,可能导致结果的不确定性或错误。举个例子来说,假设有两个线程同时对一个属性进行自增操作,每个线程执行10000次自增。由于自增操作不是原子操作,可能会出现以下情况:

  1. 线程A读取属性的值为0,然后自增为1;
  2. 线程B读取属性的值为0,然后自增为1;
  3. 线程A将自增后的值1写回属性;
  4. 线程B将自增后的值1写回属性;

最终的结果是属性的值只增加了1,而不是预期的2。这是因为两个线程在读取和写回属性值的过程中,没有进行同步,导致了数据的不一致性。

因此,为了解决这个问题,需要增加操作的颗粒度,将读取和写入操作合并为一个原子操作。这可以通过使用更高级别的锁机制,如互斥锁(mutex)或信号量(semaphore),来保证操作的原子性和线程安全。

综上所述,尽管atomic修饰的属性可以提供一定程度的线程安全性,但在多线程环境下仍然需要谨慎处理共享资源的读写操作,以避免竞态条件和数据不一致的问题。


Learn more:

  1. atomic的实现机制;为什么不能保证绝对的线程安全 - 简书
  2. iOS面试题(每日一更)2020.6.2_讲一下 atomic 的实现机制;为什么不能保证绝对的线程安全(最好可以结合场景来说)-CSDN博客
  3. atomic为何在多线程不是绝对的安全 - 掘金

被weak修饰的对象在被释放的时候会发生什么?是如何实现的?知道sideTable么?里面的结构可以画出来么?

被weak修饰的对象在被释放时会发生以下情况:

  1. 被置为nil:当一个对象被释放时,所有指向该对象的weak指针会自动被置为nil。这是由Runtime自动处理的,无需手动操作[1]

实现方式: 被weak修饰的对象在被释放时,是通过Runtime维护的weak表来实现的。weak表是一个哈希表,用于存储指向某个对象的所有weak指针。具体实现如下:

  1. 初始化时:Runtime会调用objc_initWeak函数,初始化一个新的weak指针并指向对象的地址[2]
  2. 添加引用时:当有新的weak指针指向对象时,Runtime会调用objc_storeWeak()函数,更新指针的指向,并创建对应的弱引用表[2]
  3. 释放时:当对象被释放时,Runtime会调用clearDeallocating函数。该函数会根据对象地址获取所有指向该对象的weak指针地址的数组,然后遍历这个数组将其中的数据设为nil,并从weak表中删除该entry,最后清理对象的记录[2]

sideTable是Runtime中用于存储引用计数和weak引用的数据结构。它的结构如下:

struct SideTable {
    spinlock_t slock; // 保证原子操作的自旋锁
    RefcountMap refcnts; // 引用计数的哈希表
    weak_table_t weak_table; // weak引用的全局哈希表
}

struct weak_table_t {
    weak_entry_t *weak_entries; // 保存了所有指向指定对象的weak指针
    size_t num_entries; // 存储空间
    uintptr_t mask; // 参与判断引用计数的辅助量
    uintptr_t max_hash_displacement; // hash key最大偏移值
};

Learn more:

  1. iOS -- 问题杂记 - 稀土掘金
  2. 被weak修饰的对象在被释放的时候会发生什么?是如何实现的?知道sideTable么?里面的结构可以画出来么? - 简书
  3. 被weak修饰的对象在被释放的时候会发生什么?是如何实现的?知道sideTable么?里面的结构可以画出来么? - 简书

关联对象有什么应用,系统如何管理关联对象?其被释放的时候需要手动将所有的关联对象的指针置空么?

关联对象(Associated Object)是Objective-C运行时提供的一种机制,用于在运行时为已存在的类添加属性。关联对象的应用非常广泛,可以用于以下场景:

  1. 为分类(Category)添加属性:分类无法直接添加实例变量和存取方法,但通过关联对象可以为分类添加属性,实现类似于属性的功能。

  2. 在不改变源码的情况下增加实例变量:关联对象可以在运行时为已存在的类添加实例变量,而不需要修改类的源码。

  3. 与其他对象建立关联关系:关联对象可以用于建立对象之间的关联关系,例如在一个对象中关联另一个对象,以便在需要时可以方便地获取到关联的对象。

系统是如何管理关联对象的呢?Objective-C运行时使用一个全局的AssociationsManager来管理所有的关联对象[1]。AssociationsManager内部维护了一个AssociationsHashMap,用于存储所有的关联对象。这个AssociationsHashMap的key是对象的指针地址,value是另一个AssociationsHashMap,用于保存关联对象的键值对。

当对象被释放时,Objective-C运行时会自动处理关联对象的释放。在对象的dealloc方法中,会调用object_dispose函数来销毁对象。在object_dispose函数中,会依次执行以下操作[1]

  1. 检查对象是否有关联对象,如果有,则调用_object_remove_assocations函数来移除关联对象。
  2. 执行C++析构函数,如果对象是一个C++对象。
  3. 释放对象的内存。

因此,不需要手动将所有的关联对象的指针置空,Objective-C运行时会自动处理关联对象的释放。


Learn more:

  1. 关联对象有什么应用,系统如何管理关联对象?其被释放的时候需要手动将所有的关联对象的指针置空么? - 简书
  2. 关联对象 AssociatedObject 完全解析 - 面向信仰编程
  3. AssociatedObject关联对象原理实现 | Vanch's Blog

KVO的底层实现?如何取消系统默认的KVO并手动触发(给KVO的触发设定条件:改变的值符合某个条件时再触发KVO)?

KVO(Key-Value Observing)是一种用于监听对象属性值改变的机制。在iOS中,KVO的底层实现涉及到Runtime和动态子类的创建。

KVO的底层实现原理:

  1. 当一个对象使用KVO监听时,iOS系统会动态创建一个该对象的子类,并将该对象的isa指针指向这个新创建的子类。这个子类是通过Runtime动态生成的,命名规则为NSKVONotifyin_原类名。
  2. 在这个新创建的子类中,会重写被监听属性的setter方法。在重写的setter方法中,会调用父类的setter方法,然后再发送通知给监听者。
  3. 在调用父类的setter方法之前,会调用willChangeValueForKey方法,用于通知监听者属性值即将发生改变。
  4. 在调用父类的setter方法之后,会调用didChangeValueForKey方法,用于通知监听者属性值已经发生改变。

取消系统默认的KVO并手动触发KVO:

  1. 取消系统默认的KVO可以使用removeObserver:forKeyPath:方法,将监听者从被监听对象中移除。
  2. 手动触发KVO可以使用willChangeValueForKey:和didChangeValueForKey:方法。在改变属性值之前调用willChangeValueForKey:方法,然后在改变属性值之后调用didChangeValueForKey:方法。这样可以手动触发KVO通知,将改变的值传递给监听者。

给KVO的触发设定条件:

  1. 如果想要在改变的值符合某个条件时再触发KVO,可以在重写的setter方法中添加条件判断语句。只有当属性值满足条件时,才调用willChangeValueForKey:和didChangeValueForKey:方法,触发KVO通知。

Learn more:

  1. iOS底层原理总结 - 探寻KVO本质 - 掘金
  2. iOS KVO底层实现原理 (一) - 掘金
  3. iOS开发之KVO底层实现原理篇 | 平凡的世界

Autoreleasepool所使用的数据结构是什么?AutoreleasePoolPage结构体了解么?

Autoreleasepool使用的数据结构是一个双向链表,其中每个节点都是一个AutoreleasePoolPage结构体[1]。AutoreleasePoolPage是一个C++实现的类,它是AutoreleasePool的核心数据结构[3]

AutoreleasePoolPage结构体的主要作用是保存添加进AutoreleasePool的对象的内存地址[1]。每个AutoreleasePoolPage对象会开辟4096字节的内存空间,除了保存实例变量所占空间外,剩余的空间用来存储autorelease对象的地址[3]。AutoreleasePoolPage结构体中有一个id *next指针,它作为游标指向栈顶最新添加的autorelease对象的下一个位置[2]

当一个AutoreleasePoolPage的空间被占满时,会新建一个AutoreleasePoolPage对象,并将其与之前的AutoreleasePoolPage对象通过双向链表连接起来[3]。新的AutoreleasePoolPage对象的next指针被初始化在栈底的位置,然后继续向栈顶添加新的autorelease对象[2]

AutoreleasePoolPage结构体的嵌套形式实现了AutoreleasePool的嵌套功能。每次进行objc_autoreleasePoolPush调用时,会向当前的AutoreleasePoolPage中添加一个哨兵对象,用于标记AutoreleasePool的边界[3]。当执行objc_autoreleasePoolPop操作时,会根据传入的哨兵对象地址找到哨兵对象所在的AutoreleasePoolPage,并在当前的AutoreleasePoolPage中将晚于哨兵对象插入的所有autorelease对象都发送一次release消息,然后将next指针回移至正确的位置[2]


Learn more:

  1. iOS AutoreleasePool 原理原创 - CSDN博客
  2. iOS面试题:Autoreleasepool所使用的数据结构是什么?AutoreleasePoolPage结构体了解么? - 简书
  3. 从数据结构理解Autoreleasepool 原理 - 掘金

讲一下对象,类对象,元类,根元类结构体的组成以及他们是如何相关联的?为什么对象方法没有保存的对象结构体里,而是保存在类对象的结构体里?

对象、类对象、元类、根元类的结构体组成及关联如下:

  1. 对象(Object)的结构体组成:

    • isa指针:指向对象所属的类对象。
    • 其他实例变量:存储对象的属性和状态信息。
  2. 类对象(Class Object)的结构体组成:

    • isa指针:指向元类对象。
    • superclass指针:指向父类对象。
    • ivars:实例变量列表,保存类的所有实例变量。
    • methodLists:方法列表,保存实例方法和类方法。
    • protocols:协议列表,保存类遵循的协议。
    • cache:方法缓存,用于提高方法查找的效率。
  3. 元类(Meta Class)的结构体组成:

    • isa指针:指向根元类对象。
    • superclass指针:指向父类的元类对象。
    • methodLists:方法列表,保存类方法。
    • cache:方法缓存,用于提高方法查找的效率。
  4. 根元类(Root Meta Class)的结构体组成:

    • isa指针:指向自身。
    • superclass指针:指向根类对象。
    • methodLists:方法列表,保存类方法。
    • cache:方法缓存,用于提高方法查找的效率。

关联关系:

  • 对象和类对象之间的关联:对象的isa指针指向类对象,通过类对象可以访问到对象所属的类的属性和方法。
  • 类对象和元类之间的关联:类对象的isa指针指向元类,通过元类可以访问到类对象的类方法。
  • 元类和根元类之间的关联:元类的isa指针指向根元类,通过根元类可以访问到元类的类方法。

为什么对象方法没有保存在对象结构体里,而是保存在类对象的结构体里?

  • 对象方法是属于类的,而不是属于对象的。类对象保存了类的方法列表,包括实例方法和类方法,而对象只需要保存自己的属性和状态信息即可。通过类对象,对象可以访问到类的方法。这样设计的好处是可以节省内存空间,因为多个对象可以共享同一个类对象的方法列表。

Learn more:

  1. 对象、类、元类、isa指针之间的爱恨情仇
  2. Interview - gitKong的博客 | gitKong
  3. Objective-C底层汇总 | 逆水行舟

class_ro_t 和 class_rw_t 的区别?

class_ro_t 和 class_rw_t 是在 iOS 开发中用于表示类的两个结构体。它们的区别如下:

class_ro_t:

  • class_ro_t 存储了当前类在编译期间就已经确定的属性、方法以及遵循的协议[3]
  • class_ro_t 是一个只读结构体,无法进行修改[3]
  • class_ro_t 结构体中的成员变量包括 flags、instanceStart、instanceSize、ivarLayout、name、baseMethodList、baseProtocols、ivars、weakIvarLayout 和 baseProperties[3]

class_rw_t:

  • class_rw_t 存储了类的属性、方法以及遵循的协议等信息[3]
  • class_rw_t 是在运行时生成的,它包含了 class_ro_t,并且更新了 data 部分,将其替换为 class_rw_t 结构体的地址[2]
  • class_rw_t 结构体中的成员变量包括 flags、version、ro、methods、properties、protocols、firstSubclass 和 nextSiblingClass[2]

区别:

  • class_ro_t 存放的是编译期间就确定的内容,而 class_rw_t 是在运行时才确定的[1]
  • class_rw_t 会先将 class_ro_t 的内容拷贝过去,然后再将当前类的分类的属性、方法等拷贝到其中[1]
  • class_rw_t 可以看作是 class_ro_t 的超集,实际访问类的方法、属性等也都是访问的 class_rw_t 中的内容[1]

Learn more:

  1. iOS底层- 类的结构(下) - 稀土掘金
  2. class_ro_t 和 class_rw_t 的区别? - 简书
  3. iOS开发-class_ro_t和class_rw_t的区别_class_rw_t 和 class_ro_t 的区别-CSDN博客

iOS 中内省的几个方法?class方法和objc_getClass方法有什么区别?

在iOS中,内省(introspection)是指通过运行时机制来获取类、对象和方法等信息的能力。以下是几个常用的内省方法:

  1. class方法:

    • class方法是一个Objective-C中的类方法,用于获取一个对象所属的类。
    • 通过调用对象的class方法,可以获取该对象的类对象。
    • 例如:[object class]会返回对象object所属的类对象。
  2. objc_getClass函数:

    • objc_getClass函数是一个C函数,用于获取指定类名对应的类对象。
    • 通过传入类名的字符串,可以获取对应的类对象。
    • 例如:Class cls = objc_getClass("ClassName")会返回类名为"ClassName"的类对象。

区别:

  • class方法是Objective-C中的一个类方法,用于获取对象所属的类对象。
  • objc_getClass函数是一个C函数,用于根据类名获取对应的类对象。
  • class方法是通过对象调用的,而objc_getClass函数是直接调用的。
  • class方法可以获取对象的类对象,而objc_getClass函数可以根据类名获取对应的类对象。

需要注意的是,以上方法都是在运行时获取信息的,可以用于动态地获取类、对象和方法等信息,而不是在编译时就确定的静态信息。

一个int变量被__block修饰与否的区别?

一个int变量被__block修饰与否的区别在于其在Block内部的可变性。

当一个int变量被__block修饰时,它可以在Block内部被修改。这是因为__block修饰符会将变量从栈上移动到堆上,使得Block内部可以捕获并修改该变量的值。这种情况下,Block内部对该变量的修改会影响到Block外部的变量。

而当一个int变量没有被__block修饰时,它在Block内部是只读的,无法被修改。这是因为没有使用__block修饰符,变量仍然保留在栈上,Block只能访问变量的值,但无法修改它。

下面是一个示例来说明这两种情况的区别:

int main() {
    __block int a = 10;
    
    void (^block)(void) = ^{
        a = 20; // 可以修改a的值
        NSLog(@"Inside Block: %d", a);
    };
    
    block();
    
    NSLog(@"Outside Block: %d", a);
    
    return 0;
}

输出结果为:

Inside Block: 20
Outside Block: 20

在上述示例中,由于a__block修饰,Block内部可以修改a的值,并且这个修改也影响到了Block外部的变量。因此,无论是在Block内部还是外部,a的值都被修改为20。

如果将__block修饰符移除,即将__block int a = 10;改为int a = 10;,那么在Block内部尝试修改a的值将会导致编译错误。

为什么在block外部使用__weak修饰的同时需要在内部使用__strong修饰?

在使用__weak修饰符来避免循环引用的情况下,同时在Block内部使用__strong修饰符是为了确保在Block执行期间,被__weak修饰的对象不会被提前释放。

当一个对象被__weak修饰时,它会被弱引用,意味着当没有强引用指向该对象时,它会被自动设置为nil。这是为了避免循环引用,特别是在Block中持有对象的情况下。

然而,当我们在Block内部使用__weak修饰的对象时,由于该对象可能已经被释放,所以它的值可能为nil。为了在Block内部安全地使用该对象,我们需要在Block内部使用__strong修饰符来创建一个强引用,以确保在Block执行期间对象不会被提前释放。

下面是一个示例来说明这种情况:

__weak NSObject *weakObject = someObject;

void (^block)(void) = ^{
    __strong NSObject *strongObject = weakObject;
    if (strongObject) {
        // 在Block内部使用strongObject,确保对象不会被提前释放
        NSLog(@"%@", strongObject);
    }
};

// 在Block执行之前,someObject被释放
block();

在上述示例中,我们使用__weak修饰符创建了一个对someObject的弱引用weakObject。然后,在Block内部使用__strong修饰符创建了一个强引用strongObject,以确保在Block执行期间对象不会被提前释放。通过检查strongObject是否为nil,我们可以安全地在Block内部使用该对象。

这种使用__weak__strong修饰符的组合常用于解决Block中的循环引用问题,并确保在Block执行期间对象不会被提前释放。

RunLoop的作用是什么?它的内部工作机制了解么?(最好结合线程和内存管理来说)

RunLoop是iOS和macOS中的一个重要概念,它是一个事件循环机制,用于处理事件、定时器和输入源等。它的主要作用是保持线程的活跃状态,同时处理来自系统和应用程序的事件。

RunLoop的内部工作机制如下:

  1. 当线程启动时,会自动创建一个RunLoop对象,并在RunLoop中运行。
  2. RunLoop会不断地从事件队列中获取事件,并将其分发给相应的处理器进行处理。
  3. RunLoop会根据事件的类型和优先级来决定事件的处理顺序。
  4. 如果没有事件需要处理,RunLoop会进入休眠状态,直到有新的事件到达或定时器触发。
  5. 定时器可以用来触发周期性的事件,例如定时执行某个任务。
  6. RunLoop还可以处理输入源,例如处理用户输入事件、网络请求等。
  7. 当线程即将退出时,RunLoop会被销毁。

RunLoop与线程和内存管理的关系如下:

  • 线程:每个线程都有一个对应的RunLoop,它们是一一对应的关系。RunLoop的存在可以使线程保持活跃状态,避免线程的空闲浪费。
  • 内存管理:RunLoop可以影响对象的内存管理。在RunLoop中,当没有事件需要处理时,线程会进入休眠状态,此时可以释放一些不必要的资源,以节省内存。而当有事件到达时,RunLoop会重新唤醒线程,需要使用的资源也会被重新加载。

总结来说,RunLoop的作用是保持线程的活跃状态,并处理来自系统和应用程序的事件。它的内部工作机制包括事件的分发和处理、定时器的触发、休眠和唤醒等。RunLoop与线程和内存管理密切相关,可以使线程保持活跃,同时也可以影响对象的内存管理。

Learn more:

  1. Run Loops
  2. Objective C++, how to use runloop in background thread? - Stack Overflow
  3. RunLoop Internals by Example. In my previous article I spoke about… | by Bharath Nadampalli | Medium

当涉及到需要处理事件、定时器和输入源的情况时,RunLoop是一个非常有用的工具。以下是一个实际应用场景的示例:

场景:开发一个聊天应用程序,需要使用RunLoop处理来自服务器的消息并保持用户界面更新。

解决方案:

  1. 创建一个自定义的ChatServer类,用于处理与服务器的通信。
  2. 配置ChatServer实例以监听传入的消息。
  3. 创建一个定时器,每隔一段时间向服务器发送一个“ping”消息以保持连接活跃。
  4. 将ChatServer实例和定时器添加到RunLoop中,以便它们可以被处理。
  5. 添加一个观察者到RunLoop中,以检查是否有新的消息到达,并通知主线程更新用户界面。
  6. 启动RunLoop以开始处理事件。

代码示例(Objective-C):

// 创建ChatServer实例并配置
ChatServer *chatServer = [[ChatServer alloc] init];
[chatServer listenForMessages];

// 创建定时器发送ping消息
NSTimer *pingTimer = [NSTimer scheduledTimerWithTimeInterval:30.0 repeats:YES block:^(NSTimer * _Nonnull timer) {
    [chatServer sendPing];
}];

// 获取当前RunLoop并添加ChatServer和定时器
NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
[runLoop addPort:chatServer forMode:NSDefaultRunLoopMode];
[runLoop addTimer:pingTimer forMode:NSDefaultRunLoopMode];

// 添加观察者以检查新消息并更新UI
RunLoopObserver *observer = [[RunLoopObserver alloc] initWithEvents:@[@(kCFRunLoopBeforeWaiting), @(kCFRunLoopExit)] handler:^(RunLoopObserver * _Nonnull observer, RunLoopActivity activity) {
    if ([chatServer hasIncomingMessages]) {
        dispatch_async(dispatch_get_main_queue(), ^{
            [self updateUI];
        });
    }
}];
[runLoop addObserver:observer forMode:NSDefaultRunLoopMode];

// 启动RunLoop
[runLoop run];

在这个示例中,我们使用RunLoop来处理来自服务器的消息并保持用户界面更新。通过将ChatServer实例和定时器添加到RunLoop中,我们可以确保服务器连接保持活跃,并及时处理传入的消息。同时,通过使用观察者,我们可以检查新消息并相应地更新用户界面。

请注意,这只是一个简单的示例,实际应用中可能还需要处理其他因素,如取消RunLoop、错误处理和正确的内存管理。


Learn more:

  1. iphone - What's the real point and benefit of having an Run Loop? - Stack Overflow
  2. How to use NSRunLoop - Part 2. Here’s a more complex examples that… | by Ruslan Dzhafarov | Medium
  3. iOS Run Loop, What Why When. Run Loop is a mechanism that allows… | by Prafulla Singh | Medium

哪些场景可以触发离屏渲染?

在iOS中,以下场景可能触发离屏渲染:

  1. 阴影(shadows):当一个layer添加了阴影效果时,会触发离屏渲染[1]
  2. 圆角(cornerRadius):当一个layer设置了圆角并且需要剪切圆角以外的内容时,会触发离屏渲染[1]
  3. 遮罩(mask):当一个layer使用了遮罩来裁剪内容时,会触发离屏渲染[1]
  4. 组透明(allowsGroupOpacity):当一个layer设置了组透明度为YES,并且透明度不为1时,会触发离屏渲染[1]
  5. 光栅化(shouldRasterize):当一个layer的shouldRasterize属性被设置为true时,会触发离屏渲染[1]

这些场景中,离屏渲染的本质原因是需要将layer及其子layer的内容先渲染到一个独立的帧缓冲区中,然后再将最终结果显示到屏幕上。离屏渲染会增加CPU和GPU的负担,可能导致性能下降,因此在开发中需要注意优化[1]


Learn more:

  1. iOS 常见触发离屏渲染场景及优化方案总结-阿里云开发者社区
  2. iOS中触发离屏渲染的本质原因与场景 - 维唯为为
  3. iOS 离屏渲染探究 - 掘金