作为一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这是一个我的iOS开发交流群:130595548,不管你是大牛还是小白都欢迎入驻 ,让我们一起进步,共同发展!(群内会免费提供一些群主收藏的免费学习书籍资料以及整理好的几百道面试题和答案文档!)
1. Runtime
是什么?
Runtime
是一套有C
、C++
和汇编混合编写的API
,为OC
加入了面向对象以及运行时的功能。
运行时是指将数据类型的确定有编译时,推迟到了运行时。
比如:在编译时,只读取macho
中的数据到ro
,而真正方法的读取是在rw
中体现的,编译好的ro
是无法修改的,可以通过运行时API
给编译好的类可以通过运行时添加方法
和属性
。
可以通过Runtime API
,可以动态的添加属性,交换方法,调用底层发送消息
2. 方法的本质是什么?SEL是什么?IMP是什么?两者之间的关系是什么?
方法的本质是发送消息,发送消息会有一下几个流程
- 快速查找
(objc_msgSend)
,从cache_t中查找是否有缓存的IMP
。 - 慢速查找,递归自己然后父类,
lookUpImpOrForward
- 查找不到消息开始动态方法解析,
resolveInstanceMethod
- 消息快速转发流程,
forwardingTargetForSelector
- 消息慢速转发流程,
methodSignatureForSelector & forwardInvocation
SEL
是方法编号,在read_iamges
期间,就被编译进了内存中的相关表中 IMP
就是我们函数实现的指针,找IMP
,就是找函数实现的过程。
就比如:sel
相当于书本的目录的标题,IMP
就相当于书本的页码,我们首先知道我们想看什么,即SEL
,然后根据目录找到对应的页码IMP
,然后翻到具体内容的一个过程。
3. 能否向编译后的得到的类中增加实例变量?能否向运行时创建的类添加实例变量?
- 不能向编译后得到的类中添加实例变量,因为我们编译好的实例变量存储在
ro
中,一旦编译完成,就无法修改。 - 运行时创建的类还没注册到内存,可以添加实例变量。在动态创建的类添加属性的时候,系统不会生成
setter
和getter
,要手动添加。 - 编译后的得到的类,我们也要通过
类拓展
添加实例变量关联对象,类拓展在编译的时候做为类的一部分直接编译到ro
中的, - 也通过
分类
添加实例变量,需要用关联对象,本质是实例变量的值在关联哈希表中的存储和读取。
4. isKindOfClass 和 isMemberOfClass 的区别
下面代码怎么打印:
BOOL re1 = [(id)[NSObject class] isKindOfClass:[NSObject class]]; //
BOOL re2 = [(id)[NSObject class] isMemberOfClass:[NSObject class]]; //
BOOL re3 = [(id)[LGPerson class] isKindOfClass:[LGPerson class]]; //
BOOL re4 = [(id)[LGPerson class] isMemberOfClass:[LGPerson class]]; //
NSLog(@" re1 :%hhd\n re2 :%hhd\n re3 :%hhd\n re4 :%hhd\n",re1,re2,re3,re4);
BOOL re5 = [(id)[NSObject alloc] isKindOfClass:[NSObject class]]; //
BOOL re6 = [(id)[NSObject alloc] isMemberOfClass:[NSObject class]]; //
BOOL re7 = [(id)[LGPerson alloc] isKindOfClass:[LGPerson class]]; //
BOOL re8 = [(id)[LGPerson alloc] isMemberOfClass:[LGPerson class]]; //
NSLog(@" re5 :%hhd\n re6 :%hhd\n re7 :%hhd\n re8 :%hhd\n",re5,re6,re7,re8);
打印结果:1 0 0 0 1 1 1 1
经典isa
走位图:
isKindOfClass
有一个继承递归父类
的过程,有更多的容错
isMemberOfClass
直接对比元类
和类
5. [self class] 和 [super class] 的区别
[self class]
本质就是发送消息objc_msgSend
,消息接受者是 self
, 方法编号:class
。
[super class]
本质是objc_msgSendSuper
,消息的接收者还是self
, 方法编号:class
。只是objc_msgSendSuper
会更快 直接跳过 self
的查找
6. weak 原理,weak 如何实现,为什么可以自动置为 nil
通过汇编
查看,当用__weak
去修饰一个对象的时候,底层会调用下面的方法:
id objc_initWeak(id *location, id newObj)
{
if (!newObj) {
*location = nil;
return nil;
}
return storeWeak<DontHaveOld, DoHaveNew, DoCrashIfDeallocating>
(location, (objc_object*)newObj);
}
查看
storeWeak
方法
static id
storeWeak(id *location, objc_object *newObj)
{
assert(haveOld || haveNew);
if (!haveNew) assert(newObj == nil);
Class previouslyInitializedClass = nil;
id oldObj;
SideTable *oldTable;
SideTable *newTable;
// Acquire locks for old and new values.
// Order by lock address to prevent lock ordering problems.
// Retry if the old value changes underneath us.
retry:
if (haveOld) {
oldObj = *location;
oldTable = &SideTables()[oldObj];
} else {
oldTable = nil;
}
if (haveNew) {
newTable = &SideTables()[newObj];
} else {
newTable = nil;
}
SideTable::lockTwo<haveOld, haveNew>(oldTable, newTable);
if (haveOld && *location != oldObj) {
SideTable::unlockTwo<haveOld, haveNew>(oldTable, newTable);
goto retry;
}
// Prevent a deadlock between the weak reference machinery
// and the +initialize machinery by ensuring that no
// weakly-referenced object has an un-+initialized isa.
if (haveNew && newObj) {
Class cls = newObj->getIsa();
if (cls != previouslyInitializedClass &&
!((objc_class *)cls)->isInitialized())
{
SideTable::unlockTwo<haveOld, haveNew>(oldTable, newTable);
class_initialize(cls, (id)newObj);
// If this class is finished with +initialize then we're good.
// If this class is still running +initialize on this thread
// (i.e. +initialize called storeWeak on an instance of itself)
// then we may proceed but it will appear initializing and
// not yet initialized to the check above.
// Instead set previouslyInitializedClass to recognize it on retry.
previouslyInitializedClass = cls;
goto retry;
}
}
// ✅Clean up old value, if any.
if (haveOld) {
weak_unregister_no_lock(&oldTable->weak_table, oldObj, location);
}
// ✅Assign new value, if any.
if (haveNew) {
newObj = (objc_object *)
weak_register_no_lock(&newTable->weak_table, (id)newObj, location,
crashIfDeallocating);
// weak_register_no_lock returns nil if weak store should be rejected
// Set is-weakly-referenced bit in refcount table.
if (newObj && !newObj->isTaggedPointer()) {
newObj->setWeaklyReferenced_nolock();
}
// Do not set *location anywhere else. That would introduce a race.
*location = (id)newObj;
}
else {
// No new value. The storage is not changed.
}
SideTable::unlockTwo<haveOld, haveNew>(oldTable, newTable);
return (id)newObj;
}
在这个方法中,看到底层维护了一张散列表SideTable
,从SideTable
中找到维系的一张weak_table
,然后判断新值
和旧值
。
- 判断有旧值,直接进入
weak_unregister_no_lock
,最终在weak_resize
方法中
static void weak_resize(weak_table_t *weak_table, size_t new_size)
{
size_t old_size = TABLE_SIZE(weak_table);
weak_entry_t *old_entries = weak_table->weak_entries;
weak_entry_t *new_entries = (weak_entry_t *)
calloc(new_size, sizeof(weak_entry_t));
weak_table->mask = new_size - 1;
weak_table->weak_entries = new_entries;
weak_table->max_hash_displacement = 0;
weak_table->num_entries = 0; // restored by weak_entry_insert below
if (old_entries) {
weak_entry_t *entry;
weak_entry_t *end = old_entries + old_size;
for (entry = old_entries; entry < end; entry++) {
if (entry->referent) {
//✅ entry加入到我们的weak_table
weak_entry_insert(weak_table, entry);
}
}
free(old_entries);
}
}
通过
weak_entry_insert(weak_table, entry)
将修饰的对象,插入到eak_table
中。
- 判断有新值,进入
weak_register_no_lock
,
/**
* Registers a new (object, weak pointer) pair. Creates a new weak
* object entry if it does not exist.
*
* @param weak_table The global weak table.
* @param referent The object pointed to by the weak reference.
* @param referrer The weak pointer address.
*/
id
weak_register_no_lock(weak_table_t *weak_table, id referent_id,
id *referrer_id, bool crashIfDeallocating)
{
objc_object *referent = (objc_object *)referent_id;
objc_object **referrer = (objc_object **)referrer_id;
if (!referent || referent->isTaggedPointer()) return referent_id;
// ensure that the referenced object is viable
bool deallocating;
if (!referent->ISA()->hasCustomRR()) {
deallocating = referent->rootIsDeallocating();
}
else {
BOOL (*allowsWeakReference)(objc_object *, SEL) =
(BOOL(*)(objc_object *, SEL))
object_getMethodImplementation((id)referent,
SEL_allowsWeakReference);
if ((IMP)allowsWeakReference == _objc_msgForward) {
return nil;
}
deallocating =
! (*allowsWeakReference)(referent, SEL_allowsWeakReference);
}
if (deallocating) {
if (crashIfDeallocating) {
_objc_fatal("Cannot form weak reference to instance (%p) of "
"class %s. It is possible that this object was "
"over-released, or is in the process of deallocation.",
(void*)referent, object_getClassName((id)referent));
} else {
return nil;
}
}
// now remember it and where it is being stored
weak_entry_t *entry;
// ✅根据referent 找到 entyry
if ((entry = weak_entry_for_referent(weak_table, referent))) {
append_referrer(entry, referrer);
}
else {
// ✅创建了这个数组 - 插入weak_table
weak_entry_t new_entry(referent, referrer);
weak_grow_maybe(weak_table);
weak_entry_insert(weak_table, &new_entry);
}
// Do not set *referrer. objc_storeWeak() requires that the
// value not change.
return referent_id;
}
通过referent
从 weak_table
中,找到entry
,判断entry
是否存在(entry = weak_entry_for_referent(weak_table, referent))
),存在append_referrer
,将其添加到entry
中,不存在,则创建一个weak_entry_t
,然后四分之三扩容,然后插入到new_entry
中(weak_entry_insert(weak_table, &new_entry)
)。
总结:
__weak
底层维系一张散列表SideTable
,SideTable
中维系一张弱引用表weak_table
,在这张弱引用表中,有很多弱引用对象的实体weak_entry_t *entry
。
image
__weak
就是根据传进来的弱引用对象,去weak_table
中找到对应的实体weak_entry_t *entry
,然后检查是否需要扩容(3/4扩容
),然后拼接进行内部持有(new_referrers[i] = entry->inline_referrers[i]
)的过程,如果这个entry
不存在,则创建一下新的实体entry
,然后扩容,再插入weak_entry_insert
那么,
__weak
为什么可以自动置为nil
?
查看dealloc
源码,最终在objc_destructInstance
方法中,如下源码:
void *objc_destructInstance(id obj)
{
if (obj) {
// Read all of the flags at once for performance.
bool cxx = obj->hasCxxDtor();
// ✅关联对象表
bool assoc = obj->hasAssociatedObjects();
// This order is important.
if (cxx) object_cxxDestruct(obj);
// ✅移除关联对象表
if (assoc) _object_remove_assocations(obj);
obj->clearDeallocating();
}
return obj;
}
进入obj->clearDeallocating()
方法
inline void
objc_object::clearDeallocating()
{
if (slowpath(!isa.nonpointer)) {
// Slow path for raw pointer isa.
sidetable_clearDeallocating();
}
else if (slowpath(isa.weakly_referenced || isa.has_sidetable_rc)) {
// Slow path for non-pointer isa with weak refs and/or side table data.
clearDeallocating_slow();
}
assert(!sidetable_present());
}
在clearDeallocating
经过判断,最终都会进入到weak_clear_no_lock
方法中,
void
weak_clear_no_lock(weak_table_t *weak_table, id referent_id)
{
objc_object *referent = (objc_object *)referent_id;
weak_entry_t *entry = weak_entry_for_referent(weak_table, referent);
if (entry == nil) {
/// XXX shouldn't happen, but does with mismatched CF/objc
//printf("XXX no entry for clear deallocating %p\n", referent);
return;
}
// zero out references
weak_referrer_t *referrers;
size_t count;
if (entry->out_of_line()) {
referrers = entry->referrers;
count = TABLE_SIZE(entry);
}
else {
referrers = entry->inline_referrers;
count = WEAK_INLINE_COUNT;
}
for (size_t i = 0; i < count; ++i) {
objc_object **referrer = referrers[i];
if (referrer) {
if (*referrer == referent) {
*referrer = nil;
}
else if (*referrer) {
_objc_inform("__weak variable at %p holds %p instead of %p. "
"This is probably incorrect use of "
"objc_storeWeak() and objc_loadWeak(). "
"Break on objc_weak_error to debug.\n",
referrer, (void*)*referrer, (void*)referent);
objc_weak_error();
}
}
}
weak_entry_remove(weak_table, entry);
}
在weak_clear_no_lock
中直接将关联对象的指针referrer
置为nil
,所以当其释放时,会自动设置为空。
7. __strong 分析
跟__weak
一样,先通过汇编分析,找到底层调用的objc_storeStrong
方法,如下:
void
objc_storeStrong(id *location, id obj)
{
id prev = *location;
if (obj == prev) {
return;
}
objc_retain(obj);
*location = obj;
objc_release(prev);
}
先objc_retain(obj)
,在objc_release(prev)
,而objc_retain
方法和objc_release
方法底层都是发送retain
和release
消息。
8. 对 数组 越界与什么好的处理方法?(Method Swizzling 的使用)
在处理数组越界的时候,我们很容易想到的就是通过Runtime
进行方法交换。
我们通常会创建一个分类,如下:
#import "NSArray+LG.h"
#import "LGRuntimeTool.h"
#import <objc/runtime.h>
@implementation NSArray (LG)
+ (void)load{
[LGRuntimeTool lg_methodSwizzlingWithClass:objc_getClass("__NSArrayI") oriSEL:@selector(objectAtIndex:) swizzledSEL:@selector(lg_objectAtIndex:)];
[LGRuntimeTool lg_methodSwizzlingWithClass:objc_getClass("__NSArrayI") oriSEL:@selector(objectAtIndexedSubscript:) swizzledSEL:@selector(lg_objectAtIndexedSubscript:)];
}
// 交换的方法
- (id)lg_objectAtIndex:(NSUInteger)index{
if (index > self.count-1) {
NSLog(@"数组越界 -- ");
NSLog(@"取值越界了,记录:%lu > %lu", (unsigned long)index, self.count - 1);
return nil;
}
return [self lg_objectAtIndex:index];
}
- (id)lg_objectAtIndexedSubscript:(NSUInteger)index {
if (index > self.count-1) {
NSLog(@"数组越界 -- ");
NSLog(@"取值越界了,记录:%lu > %lu", (unsigned long)index, self.count - 1);
return nil;
}
return [self lg_objectAtIndexedSubscript:index];
}
#import "LGRuntimeTool.h"
#import <objc/runtime.h>
@implementation LGRuntimeTool
+ (void)lg_methodSwizzlingWithClass:(Class)cls oriSEL:(SEL)oriSEL swizzledSEL:(SEL)swizzledSEL{
if (!cls) NSLog(@"传入的交换类不能为空");
Method oriMethod = class_getInstanceMethod(cls, oriSEL);
Method swiMethod = class_getInstanceMethod(cls, swizzledSEL);
method_exchangeImplementations(oriMethod, swiMethod);
}
@end
对objectAtIndex
和lg_objectAtIndex
进行交换,当调用系统objectAtIndex
方法时,调用lg_objectAtIndex
方法,方便我们在数组越界时的一些处理(比如防崩溃)
那么假如我们在添加了NSArray
的分类之后,在某个调用的地方,不小心调用了,NSArray
的load
方法,比如下面的代码,会发生什么呢?
self.dataArray = @[@"Hank",@"Cooci",@"Kody",@"CC"];
[NSArray load];
NSLog(@"%@",[self.dataArray objectAtIndex:4]);
通过验证,上面的代码,会崩溃,当再次调用load
方法时,会再次交换方法,调用系统的objectAtIndex
方法,而导致崩溃。
所以,我们要在添加分类的load
方法中,使用单例,防止方法多次重复交换
+ (void)load{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
[LGRuntimeTool lg_methodSwizzlingWithClass:objc_getClass("__NSArrayI") oriSEL:@selector(objectAtIndex:) swizzledSEL:@selector(lg_objectAtIndex:)];
[LGRuntimeTool lg_methodSwizzlingWithClass:objc_getClass("__NSArrayI") oriSEL:@selector(objectAtIndexedSubscript:) swizzledSEL:@selector(lg_objectAtIndexedSubscript:)];
});
}
那么在日常的开发中,经常出现继承关系,而在这个继承关系中,经常出现子类继承父类的方法,那么在子类中交换继承自父类的方法会出现什么情况呢?如下代码:
// 父类
@interface LGPerson : NSObject
- (void)personInstanceMethod;
+ (void)personClassMethod;
@end
#import "LGPerson.h"
@implementation LGPerson
- (void)personInstanceMethod{
NSLog(@"person对象方法:%s",__func__);
}
+ (void)personClassMethod{
NSLog(@"person类方法:%s",__func__);
}
@end
// 子类
@interface LGStudent : LGPerson
@end
#import "LGStudent.h"
@implementation LGStudent
@end
上面代码中LGStudent
继承自LGPerson
,而LGPerson
中实现了personInstanceMethod
和personClassMethod
两个方法,
在子类LGStudent
中,对personInstanceMethod
方法进行方法交换,如下:
@implementation LGStudent (LG)
+ (void)load{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
[LGRuntimeTool lg_methodSwizzlingWithClass:self oriSEL:@selector(personInstanceMethod) swizzledSEL:@selector(lg_studentInstanceMethod)];
});
}
- (void)lg_studentInstanceMethod{
[self lg_studentInstanceMethod];
NSLog(@"LGStudent分类添加的lg对象方法:%s",__func__);
}
@end
+ (void)lg_methodSwizzlingWithClass:(Class)cls oriSEL:(SEL)oriSEL swizzledSEL:(SEL)swizzledSEL{
if (!cls) NSLog(@"传入的交换类不能为空");
Method oriMethod = class_getInstanceMethod(cls, oriSEL);
Method swiMethod = class_getInstanceMethod(cls, swizzledSEL);
method_exchangeImplementations(oriMethod, swiMethod);
}
这样的方法交换,在我们调用LGStudent
的时候,完美的实现了方法互换,那么在我们调用父类LGPerson
时,会有什么问题呢?调用如下:
LGStudent *s = [[LGStudent alloc] init];
[s personInstanceMethod];
LGPerson *p = [[LGPerson alloc] init];
[p personInstanceMethod];
结果如下:
image在运行结果中看到,在父类调用personInstanceMethod
时,出现了父类调用lg_studentInstanceMethod
,而父类本身没有这个方法,所以就崩溃了。
那么,子类在交换继承自父类而自己本身没有重写的方法时,应该怎么做呢 ?
其实,我们可以在方法交换的时候做一个判断,先尝试添加一个方法,
+ (void)lg_betterMethodSwizzlingWithClass:(Class)cls oriSEL:(SEL)oriSEL swizzledSEL:(SEL)swizzledSEL{
if (!cls) NSLog(@"传入的交换类不能为空");
// oriSEL personInstanceMethod
// swizzledSEL lg_studentInstanceMethod
Method oriMethod = class_getInstanceMethod(cls, oriSEL);
Method swiMethod = class_getInstanceMethod(cls, swizzledSEL);
// 尝试添加
// ✅ 首先第一步:会先尝试给自己添加要交换的方法 :personInstanceMethod (SEL) -> swiMethod(IMP)
BOOL success = class_addMethod(cls, oriSEL, method_getImplementation(swiMethod), method_getTypeEncoding(oriMethod));
/**
personInstanceMethod(sel) - lg_studentInstanceMethod(imp)
lg_studentInstanceMethod (swizzledSEL) - personInstanceMethod(imp)
*/
//oriSEL:personInstanceMethod
if (success) {// 自己没有 - 交换 - 没有父类进行处理 (重写一个)
// ✅ 然后再将父类的IMP给swizzle personInstanceMethod(imp) -> swizzledSEL
class_replaceMethod(cls, swizzledSEL, method_getImplementation(oriMethod), method_getTypeEncoding(oriMethod));
}else{ // 自己有
method_exchangeImplementations(oriMethod, swiMethod);
}
}
这样就可以完美的对子类的方法进行交换了,调用结果如下,
image那么,还有一个问题,假如某人交换了只有声明并没有实现的方法,上面的方式就会出现死循环,因为根本没有父类方法的IMP
,所以,还要对其进行改造。
代码如下:
+ (void)lg_bestMethodSwizzlingWithClass:(Class)cls oriSEL:(SEL)oriSEL swizzledSEL:(SEL)swizzledSEL{
if (!cls) NSLog(@"传入的交换类不能为空");
Method oriMethod = class_getInstanceMethod(cls, oriSEL);
Method swiMethod = class_getInstanceMethod(cls, swizzledSEL);
if (!oriMethod) {
// 在oriMethod为nil时,替换后将swizzledSEL复制一个不做任何事的空实现,代码如下:
class_addMethod(cls, oriSEL, method_getImplementation(swiMethod), method_getTypeEncoding(swiMethod));
method_setImplementation(swiMethod, imp_implementationWithBlock(^(id self, SEL _cmd){ }));
}
// 一般交换方法: 交换自己有的方法 -- 走下面 因为自己有意味添加方法失败
// 交换自己没有实现的方法:
// 首先第一步:会先尝试给自己添加要交换的方法 :personInstanceMethod (SEL) -> swiMethod(IMP)
// 然后再将父类的IMP给swizzle personInstanceMethod(imp) -> swizzledSEL
//oriSEL:personInstanceMethod
BOOL didAddMethod = class_addMethod(cls, oriSEL, method_getImplementation(swiMethod), method_getTypeEncoding(swiMethod));
if (didAddMethod) {
class_replaceMethod(cls, swizzledSEL, method_getImplementation(oriMethod), method_getTypeEncoding(oriMethod));
}else{
method_exchangeImplementations(oriMethod, swiMethod);
}
}
在方法交换的时候,先对被交换方法进行判断,判断这个方法是否实现,当方法未实现时,我们手动添加一个空的方法实现,在这个空的方法实现中,我们可以做一些上传等操作,来记录收集crash
。
9. 内存偏移问题
- 如下代码,能否正常运行?
@interface LGPerson : NSObject
@property (nonatomic, copy)NSString *name;
@property (nonatomic, copy)NSString *subject;
@property (nonatomic)int age;
- (void)saySomething;
@end
@implementation LGPerson
- (void)saySomething{
NSLog(@"NB %s ",__func__);
}
@end
#import "ViewController.h"
#import <objc/runtime.h>
#import "LGPerson.h"
@interface ViewController ()
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
// NSString *tem = @"KC";
id pcls = [LGPerson class];
void *pp= &pcls;
[(__bridge id)pp saySomething];
// p -> LGPerson 实例对象
LGPerson *p = [LGPerson alloc];
[p saySomething];
}
运行打印结果如下,那么为什么会这样呢?
image首先,指针p
指向的是LGPerson
的实例对象的存储空间,而指针pcls
指向的是LGPerson
类对象的存储空间。pp
指向的是指针pcls
的空间,而实例对象中的isa
也指向指针pcls
,所以pp
能够正常调用saySomething
方法
- 对
saySomething
方法进行修改,如下,并打印self.name
。
- (void)saySomething{
NSLog(@"NB %s - %@",__func__,self.name);
}
通过调试,打印结果如下:
image那么为什么会打印<ViewController: 0x105105e10>
呢,接下来我们把上面的// NSString *tem = @"KC"
注释打开,再来看一下,这一次打印的是KC
。
那么为什么会这样呢?
因为栈的内存是连续的,而我们的属性的读取是通过指针偏移来读取的,而压栈的示意如下:
image上图中pp
指向的是isa
,在通过指针偏移读取self.name
时,刚好读取到了tem
,所以打印了KC
,所以当没有打开注释时,打印的是ViewController
接着在saySomething
中打印subject
,打印出的是ViewController
那么我们接着再添加属性
@property (nonatomic)NSString *age;
打印的话,就会出现野指针报错
或者将age
的类型改成int
,也会因为读取不到内存,而报错。
作为一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这是一个我的iOS开发交流群:130595548,不管你是大牛还是小白都欢迎入驻 ,让我们一起进步,共同发展!(群内会免费提供一些群主收藏的免费学习书籍资料以及整理好的几百道面试题和答案文档!)
网友评论