美团ios开发100道面试题及参考答案(上)

@property 支持的关键字有哪些?

@property 是 Objective-C 中用于快速声明属性的语法糖,其支持的关键字可按功能分为 内存管理、原子性、读写权限、方法名修饰、其他辅助 五大类,每类关键字各司其职,面试中需准确区分其作用域和使用场景。

一、内存管理关键字(核心重点)

这类关键字决定属性的内存管理策略,直接影响对象的生命周期,是面试高频考点:

  • strong:强引用,默认关键字(ARC 环境下)。持有对象,将对象引用计数 +1,直到当前持有者被销毁,对象才会释放。适用于绝大多数 OC 对象(如 NSString、NSArray、自定义类实例),确保对象在使用期间不被意外释放。
  • weak:弱引用,不持有对象,引用计数不变化。当对象被销毁时,系统会自动将 weak 指针置为 nil,避免野指针问题。适用于避免循环引用的场景(如 delegate 代理、block 捕获外部变量、父子视图相互引用)。
  • copy:拷贝引用,分为浅拷贝(mutableCopy 对于不可变对象)和深拷贝(copy 对于可变对象)。属性会创建对象的副本并持有,原对象的修改不会影响副本。适用于可变对象类型(如 NSMutableString、NSMutableArray),防止外部修改属性内部数据,确保属性数据的不可变性。
  • assign:直接赋值,不涉及引用计数管理。适用于基本数据类型(int、float、BOOL、CGFloat 等)和非 OC 对象指针(如 void*),在 ARC 环境下若用于 OC 对象,会导致野指针(对象销毁后指针不置空)。
  • retain:MRC 环境下的强引用关键字,功能等同于 ARC 中的 strong,会使对象引用计数 +1,MRC 中需手动 release 释放。
二、原子性关键字

控制属性访问器(getter/setter)的线程安全:

  • atomic:默认关键字,生成的 getter/setter 会通过加锁保证线程安全,同一时间只有一个线程能访问属性。但仅保证属性读写的原子性,不保证业务逻辑的线程安全(如多线程读写后进行计算),且性能开销较大。
  • nonatomic:非原子性,生成的 getter/setter 不加锁,线程不安全,但访问速度快。实际开发中绝大多数场景(如单线程环境、UI 线程操作)会使用 nonatomic,因为 atomic 的线程安全保障有限且影响性能。
三、读写权限关键字

控制属性是否可写:

  • readwrite:默认关键字,生成 getter 和 setter 方法,属性可读写。
  • readonly:仅生成 getter 方法,不生成 setter 方法,属性只读。若需在类内部修改,可在 .m 文件的延展(extension)中重新声明为 readwrite。
四、方法名修饰关键字

自定义 getter/setter 方法名,适配特定场景:

  • getter=自定义方法名:修改 getter 方法名,常见于 BOOL 类型属性(如 @property (nonatomic, assign, getter=isSelected) BOOL selected;),使方法名更符合 OC 命名规范。
  • setter=自定义方法名::修改 setter 方法名(较少使用),如 @property (nonatomic, copy, setter=setUserName:) NSString *userName;,调用时需用 [obj setUserName:@"xxx"]。
五、其他辅助关键字
  • nonnull:属性不可为 nil,编译器会进行空值检查,减少空指针崩溃。
  • nullable:属性可为 nil,明确告知编译器和开发者该属性支持空值。
  • null_resettable:属性 getter 不可为 nil(若 setter 传入 nil,需在内部处理为默认值),需重写 getter 或 setter 确保非空(如 @property (nonatomic, copy, null_resettable) NSString *name;,重写 setter 时若传入 nil,设置为 @"")。
  • class:类属性(类似 Swift 的 static 属性),需用 + (void)setClassProperty:(NSString *)classProperty; 和 + (NSString *)classProperty; 手动实现 getter/setter,不支持实例访问。
面试加分点
  • 能区分 ARC 和 MRC 下关键字的差异(如 retain 仅在 MRC 有效,ARC 中被 strong 替代)。
  • 明确 copy 关键字的适用场景(可变对象),并解释浅拷贝和深拷贝的区别。
  • 结合实际场景说明 weak 的作用(如 delegate 用 weak 避免循环引用)。
记忆法
  • 分类记忆法:将关键字按"内存管理、原子性、读写权限、方法名、辅助"五类划分,每类记住核心代表(如内存管理:strong/weak/copy/assign;原子性:atomic/nonatomic),再补充细节。
  • 场景关联记忆法:将关键字与使用场景绑定(如"delegate 用 weak""可变对象用 copy""基本类型用 assign""追求性能用 nonatomic"),通过场景反推关键字,避免混淆。

strong、copy、assign 和 weak 这四个关键字的区别是什么?

这四个关键字是 @property 最核心的内存管理关键字,其核心差异体现在 引用类型、引用计数影响、对象销毁后指针状态、适用场景 四个维度,面试中需结合内存管理原理和实际开发场景分析,避免只记表面区别。

一、核心区别对比(表格清晰呈现)
关键字 引用类型 引用计数影响 对象销毁后指针状态 适用场景 核心风险
strong 强引用 持有对象,引用计数 +1 指针仍指向原内存地址(变为野指针,ARC 下不会自动置空) 绝大多数 OC 对象(NSString、NSArray、自定义类实例等),需确保对象在使用期间存在 若形成循环引用(如 A 强引用 B,B 强引用 A),会导致内存泄漏
copy 拷贝引用(持有副本) 对副本的引用计数 +1(原对象引用计数不变) 指针指向副本内存地址,原对象销毁不影响副本,副本销毁后指针状态同 strong 可变对象类型(NSMutableString、NSMutableArray、NSMutableDictionary),需确保属性数据不被外部修改 对不可变对象使用 copy 会产生浅拷贝,虽不影响功能,但浪费内存(额外创建副本)
assign 直接赋值(无引用关系) 不影响引用计数 指针仍指向原内存地址(野指针),且 ARC 下不会自动置空 基本数据类型(int、float、BOOL、CGFloat)、非 OC 对象指针(void*、C 语言结构体指针) 若用于 OC 对象,会导致野指针崩溃(对象销毁后指针未置空,继续访问会触发 EXC_BAD_ACCESS)
weak 弱引用 不影响引用计数 系统自动将指针置为 nil(避免野指针) 1. delegate 代理(避免循环引用);2. block 中捕获外部变量(需配合 __weak 修饰);3. 父子视图相互引用(如子视图弱引用父视图);4. 避免不必要的强引用(如缓存对象的临时引用) 若对象已销毁,访问 weak 属性会返回 nil,需提前判断空值(否则可能导致业务逻辑异常,但不会崩溃)
二、关键细节补充
  1. strong 的循环引用问题:当两个对象相互强引用时(如 Person 类有一个 strong 属性 dog,Dog 类有一个 strong 属性 owner),即使外部没有引用,两者的引用计数也无法归零,导致内存泄漏。解决方式是将其中一方改为 weak(如 Dog 的 owner 用 weak)。

  2. copy 的拷贝机制差异

    • 对不可变对象(如 NSString)调用 copy 方法,会执行"浅拷贝"(retain 操作),本质是返回原对象,引用计数 +1(因为不可变对象无需创建副本,不会被修改);

    • 对可变对象(如 NSMutableString)调用 copy 方法,会执行"深拷贝",创建一个新的不可变对象(NSString),原对象的修改不会影响副本。示例代码:

      NSMutableString *mutableStr = [NSMutableString stringWithString:@"hello"];
      @property (nonatomic, copy) NSString *copyStr;
      self.copyStr = mutableStr;
      [mutableStr appendString:@" world"];
      NSLog(@"copyStr: %@", self.copyStr); // 输出 "hello"(副本不受原对象修改影响)

  3. assign 与 weak 对 OC 对象的差异 :若将 assign 用于 OC 对象:

    复制代码
    @property (nonatomic, assign) NSString *assignStr;
    self.assignStr = [NSString stringWithString:@"test"]; // 临时对象,赋值后立即释放
    NSLog(@"assignStr: %@", self.assignStr); // 野指针崩溃(EXC_BAD_ACCESS)

    而 weak 用于 OC 对象时,对象销毁后指针置空,访问不会崩溃:

    复制代码
    @property (nonatomic, weak) NSString *weakStr;
    self.weakStr = [NSString stringWithString:@"test"]; // 临时对象释放后,weakStr 置为 nil
    NSLog(@"weakStr: %@", self.weakStr); // 输出 "nil",无崩溃
  4. weak 的实现原理:weak 指针通过"弱引用表"(SideTable)管理,当对象被销毁时,runtime 会遍历弱引用表,将所有指向该对象的 weak 指针置为 nil。这一机制确保了 weak 不会产生野指针。

面试加分点
  • 能结合引用计数原理(ARC 自动管理,MRC 手动管理)解释关键字差异;
  • 能举例说明 copy 的深浅拷贝场景,以及 strong 和 weak 导致的循环引用问题及解决方案;
  • 明确指出 assign 用于 OC 对象的风险,以及 weak 避免野指针的底层逻辑。
记忆法
  • 口诀记忆法:"强引用(strong)计数加,弱引用(weak)置空佳,拷贝(copy)副本不被改,基本类型(assign)用它呀",通过口诀快速记住核心特性。
  • 对比记忆法:以"引用计数"和"指针状态"为核心对比维度,制作简易表格(如上述表格),明确每个关键字在两个维度的表现,再关联适用场景,形成逻辑链(如"不影响计数 + 指针置空 → 弱引用 → 适用于 delegate")。

@synthesize 和 @dynamic 的作用分别是什么?

@synthesize 和 @dynamic 是 Objective-C 中用于控制 @property 访问器(getter/setter)实现方式的关键字,二者核心差异在于 是否由编译器自动生成访问器和实例变量,面试中需结合属性实现原理、手动实现场景等细节回答,避免混淆二者的适用场景。

一、@synthesize 的作用与细节

@synthesize 的核心作用是 让编译器自动生成属性的 getter/setter 方法和实例变量,同时支持开发者手动指定实例变量名,或部分重写访问器方法。

  1. 默认行为(Xcode 4.4+ 之后):在 Xcode 4.4 及以上版本中,编译器默认会为 @property 自动生成 @synthesize 语句(无需手动写),默认实例变量名为"_属性名"(如 @property (nonatomic, copy) NSString *name; 会自动生成实例变量 _name,以及对应的 getter(- (NSString *)name;)和 setter(- (void)setName:(NSString *)name;)方法)。示例:

    复制代码
    // .h 文件
    @interface Person : NSObject
    @property (nonatomic, copy) NSString *name;
    @end
    
    // .m 文件(编译器自动生成以下代码,无需手动编写)
    @implementation Person
    @synthesize name = _name; // 实例变量名为 _name
    // 自动生成 getter
    - (NSString *)name {
        return _name;
    }
    // 自动生成 setter(ARC 环境下,copy 关键字会自动处理内存)
    - (void)setName:(NSString *)name {
        if (_name != name) {
            _name = [name copy];
        }
    }
    @end
  2. 手动指定实例变量名:若需自定义实例变量名(而非默认的 _属性名),可通过 @synthesize 显式指定,格式为"@synthesize 属性名 = 自定义实例变量名;"。示例:

    复制代码
    // .m 文件
    @implementation Person
    @synthesize name = myName; // 实例变量名为 myName,而非 _name
    - (void)test {
        self.name = @"张三";
        NSLog(@"myName: %@", myName); // 直接访问实例变量 myName,输出 "张三"
    }
    @end

    此时编译器生成的 getter/setter 会操作 myName 实例变量,而非默认的 _name。

  3. 部分重写访问器方法:若开发者手动实现了 getter 或 setter 中的一个方法,编译器仍会自动生成另一个未实现的方法(前提是未显式禁用 @synthesize);若手动实现了两个方法,编译器不会自动生成实例变量和任何访问器,需手动声明实例变量。示例(重写 setter,编译器自动生成 getter):

    复制代码
    @implementation Person
    // 手动重写 setter
    - (void)setName:(NSString *)name {
        if (_name != name) {
            _name = [name copy];
            NSLog(@"名字被设置为:%@", _name);
        }
    }
    // getter 由编译器自动生成,无需手动实现
    @end
  4. MRC 环境下的特殊处理:在 MRC 环境中,@synthesize 生成的访问器会根据属性的内存管理关键字(retain、assign、copy)处理引用计数,例如 retain 关键字的 setter 会自动 retain 新值、release 旧值:

    复制代码
    // MRC 环境下,@synthesize 生成的 retain 关键字 setter
    - (void)setName:(NSString *)name {
        if (_name != name) {
            [_name release]; // 释放旧值
            _name = [name retain]; // retain 新值
        }
    }
二、@dynamic 的作用与细节

@dynamic 的核心作用是 告诉编译器"属性的 getter/setter 方法由开发者手动实现,或通过 runtime 动态生成,编译器无需自动生成,也无需检查实现"。若使用 @dynamic 但未提供访问器实现,编译时不会报错,但运行时调用 getter/setter 会触发崩溃(unrecognized selector sent to instance)。

  1. 强制手动实现访问器:若需完全自定义 getter/setter 逻辑(如属性值存储在数据库、偏好设置中,而非实例变量),可使用 @dynamic 声明,编译器不会自动生成任何代码,需手动实现访问器。示例:

    复制代码
    // .h 文件
    @interface Person : NSObject
    @property (nonatomic, copy) NSString *name;
    @end
    
    // .m 文件
    @implementation Person
    @dynamic name; // 告诉编译器:getter/setter 由手动实现
    
    // 手动实现 getter(从偏好设置中读取名字)
    - (NSString *)name {
        return [[NSUserDefaults standardUserDefaults] stringForKey:@"user_name"];
    }
    
    // 手动实现 setter(将名字存储到偏好设置)
    - (void)setName:(NSString *)name {
        [[NSUserDefaults standardUserDefaults] setObject:name forKey:@"user_name"];
        [[NSUserDefaults standardUserDefaults] synchronize];
    }
    @end

    此时属性 name 不依赖实例变量,而是通过偏好设置存储数据,@dynamic 避免了编译器的自动生成逻辑。

  2. runtime 动态生成访问器:这是 @dynamic 最核心的高级用法。通过 Objective-C 的 runtime 机制,可在运行时动态添加 getter/setter 方法(如利用关联对象 Associated Objects 存储属性值),无需手动实现访问器。示例(runtime 动态生成访问器):

    复制代码
    #import <objc/runtime.h>
    
    @implementation Person
    @dynamic name;
    
    // 定义关联对象的 key
    static const char *NameKey = "NameKey";
    
    // 运行时动态添加 getter
    + (BOOL)resolveInstanceMethod:(SEL)sel {
        if (sel == @selector(name)) {
            class_addMethod([self class], sel, (IMP)dynamicGetName, "@@:");
            return YES;
        } else if (sel == @selector(setName:)) {
            class_addMethod([self class], sel, (IMP)dynamicSetName, "v@:@");
            return YES;
        }
        return [super resolveInstanceMethod:sel];
    }
    
    // getter 实现(通过关联对象获取值)
    static NSString *dynamicGetName(id self, SEL _cmd) {
        return objc_getAssociatedObject(self, NameKey);
    }
    
    // setter 实现(通过关联对象存储值)
    static void dynamicSetName(id self, SEL _cmd, NSString *name) {
        objc_setAssociatedObject(self, NameKey, name, OBJC_ASSOCIATION_COPY_NONATOMIC);
    }
    @end

    这种方式常用于框架开发(如为分类添加属性),因为分类无法直接添加实例变量,需通过关联对象+@dynamic 实现。

  3. 编译时不检查实现:若未使用 @dynamic,且未实现 getter/setter,编译器会报警告("Auto property synthesis will not synthesize property 'name' because it is 'readwrite' but does not specify a setter");而使用 @dynamic 后,编译器不会检查访问器是否实现,将检查延迟到运行时。

三、二者核心区别总结
特性 @synthesize @dynamic
访问器生成 编译器自动生成(默认) 不生成,需手动实现或 runtime 动态生成
实例变量 自动生成(默认 _属性名),支持自定义 不自动生成,需手动声明或通过关联对象存储
编译检查 检查访问器实现是否完整(部分重写时) 不检查,运行时才验证访问器是否存在
适用场景 1. 默认属性实现(无需手动干预);2. 自定义实例变量名;3. 部分重写访问器 1. 完全自定义访问器逻辑(如数据存储在外部);2. runtime 动态生成访问器(如分类添加属性);3. 避免编译器警告(明确手动实现)
面试加分点
  • 能区分 Xcode 4.4+ 前后 @synthesize 的默认行为差异(之前需手动写 @synthesize,之后自动生成);
  • 能结合 runtime 机制说明 @dynamic 的高级用法(如分类添加属性);
  • 明确指出 @dynamic 未实现访问器的运行时风险(崩溃),并举例说明手动实现和动态生成的场景。
记忆法
  • 核心功能记忆法:"synthesize 自动生成(访问器+实例变量),dynamic 手动/动态实现(不自动生成)",抓住"自动"和"手动/动态"的核心差异,再延伸细节。
  • 场景绑定记忆法:"日常开发用 synthesize(默认自动生成,无需关注),框架开发/分类属性用 dynamic(动态生成或自定义逻辑)",通过使用场景快速区分二者的作用。

Objective-C 的消息机制具体包含哪些步骤?

Objective-C 的消息机制是其核心特性之一,区别于 C 语言的直接函数调用,OC 采用"动态绑定"机制,即在运行时才确定要调用的方法,而非编译时。其完整流程可拆解为"消息发送、动态解析、消息转发"三大核心步骤,每个步骤都有明确的职责和底层逻辑,面试中需按流程逐一说明,结合底层原理和代码示例增强说服力。

一、核心前提:OC 消息的本质

OC 中"调用方法"的本质是"发送消息",语法 [receiver selector:argument] 会被编译器转换为 runtime 函数 objc_msgSend(receiver, selector, argument),其中:

  • receiver:消息接收者(对象或类);
  • selector:方法选择器(SEL,本质是方法名的唯一标识);
  • argument:方法参数(可选)。消息发送的核心目标是:找到 receiver 中 selector 对应的方法实现(IMP,函数指针),并执行该实现。
二、步骤一:消息发送(Message Sending)------ 查找方法实现

这是消息机制的第一步,也是最常见的流程(绝大多数消息会在此步骤完成),核心是通过"方法缓存"和"方法列表"查找 IMP。

  1. 快速查找:检查方法缓存(cache) 每个类(Class)和元类(Meta Class)都有一个 cache_t 类型的缓存(存储最近调用的方法),目的是优化查找效率,避免重复遍历方法列表。

    • 流程:发送消息时,先从 receiver 的类(对象调用实例方法)或元类(类调用类方法)的缓存中查找 selector 对应的 IMP;
    • 若找到,直接执行 IMP,流程结束;
    • 若未找到,进入慢速查找。
  2. **慢速查找:遍历方法列表(methodLists)**若缓存未命中,会通过"继承链"遍历方法列表,查找 selector:

    • 实例方法查找:receiver 的类 → 父类 → 祖父类 → ... → NSObject → nil;
    • 类方法查找:receiver 的元类 → 父元类 → ... → 根元类(NSObject 的元类

利用 Objective-C 的动态特性能做哪些事情?

Objective-C 作为一门动态语言,其核心动态特性基于 runtime 运行时库实现,支持在运行时灵活操作类、对象、方法和属性,突破编译时的静态限制。这些特性在实际开发中应用广泛,尤其在框架封装、性能优化、问题排查等场景中不可或缺,面试中需结合具体场景和代码示例说明,体现对动态特性的深度理解。

一、动态添加类与对象

OC 支持在运行时动态创建新类,无需在编译时声明,适用于需要动态扩展类结构的场景(如插件化开发、根据配置生成类)。

  • 核心原理:通过 objc_allocateClassPair 分配类内存,class_addMethod 添加方法,objc_registerClassPair 注册类,最终通过 alloc/init 创建实例。

  • 代码示例:

    #import <objc/runtime.h>

    // 动态创建类的实例方法实现
    static void dynamicClassTestMethod(id self, SEL _cmd) {
    NSLog(@"动态类的方法被调用");
    }

    // 动态创建类并使用
    void createDynamicClass() {
    // 1. 分配类对(参数:父类、类名、额外内存大小)
    Class DynamicClass = objc_allocateClassPair([NSObject class], "DynamicClass", 0);
    if (!DynamicClass) return;

    复制代码
      // 2. 为动态类添加实例方法(参数:类、SEL、IMP、方法签名)
      SEL testSel = @selector(testMethod);
      class_addMethod(DynamicClass, testSel, (IMP)dynamicClassTestMethod, "v@:");
      
      // 3. 注册类(必须注册后才能使用)
      objc_registerClassPair(DynamicClass);
      
      // 4. 创建动态类实例并调用方法
      id dynamicInstance = [[DynamicClass alloc] init];
      [dynamicInstance performSelector:testSel]; // 输出 "动态类的方法被调用"
      
      // 5. 释放类(可选,避免内存泄漏)
      objc_disposeClassPair(DynamicClass);

    }

  • 适用场景:插件化框架(如动态加载第三方插件类)、配置驱动开发(根据后台配置生成对应功能类)。

二、动态添加/修改/替换方法

运行时可灵活修改类的方法列表,包括添加新方法、修改已有方法的实现、替换方法(Method Swizzling),是 AOP(面向切面编程)的核心实现方式。

  1. 动态添加方法:为已有类添加未声明的方法,适用于为系统类或第三方类扩展功能。

    // 为 NSObject 动态添加方法
    static NSString *dynamicAddMethod(id self, SEL _cmd) {
    return @"动态添加的方法返回值";
    }

    void addMethodToNSObject() {
    SEL newSel = @selector(dynamicAddMethod);
    // 为 NSObject 类添加实例方法
    class_addMethod([NSObject class], newSel, (IMP)dynamicAddMethod, "@:@"");

    复制代码
     NSObject *obj = [[NSObject alloc] init];
     NSString *result = [obj performSelector:newSel];
     NSLog(@"result: %@", result); // 输出 "动态添加的方法返回值"

    }

  2. 方法替换(Method Swizzling):替换类中已有方法的实现,常用于埋点统计、日志打印、崩溃防护等场景,核心是交换两个方法的 IMP。

    #import <objc/runtime.h>

    @implementation UIViewController (Swizzling)

    • (void)load {
      // 确保只执行一次(load 方法在类加载时自动调用,且线程安全)
      static dispatch_once_t onceToken;
      dispatch_once(&onceToken, ^{
      // 获取原方法和替换方法的 SEL
      SEL originalSel = @selector(viewWillAppear:);
      SEL swizzledSel = @selector(swizzled_viewWillAppear:);

      复制代码
        // 获取方法实例
        Method originalMethod = class_getInstanceMethod([self class], originalSel);
        Method swizzledMethod = class_getInstanceMethod([self class], swizzledSel);
        
        // 交换方法实现(若原类未实现该方法,需先添加再交换)
        BOOL didAddMethod = class_addMethod([self class], originalSel, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod));
        if (didAddMethod) {
            class_replaceMethod([self class], swizzledSel, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod));
        } else {
            method_exchangeImplementations(originalMethod, swizzledMethod);
        }

      });
      }

    // 替换后的方法

    • (void)swizzled_viewWillAppear:(BOOL)animated {
      // 执行自定义逻辑(如埋点统计)
      NSLog(@"页面 %@ 将要显示", self.class);
      // 调用原方法(此时 swizzled_viewWillAppear 已与 viewWillAppear 交换,实际调用原实现)
      [self swizzled_viewWillAppear:animated];
      }

    @end

  • 适用场景:页面跳转埋点、网络请求日志打印、数组越界崩溃防护(替换 objectAtIndex: 方法添加边界判断)。
三、动态获取/修改属性与关联对象
  1. 动态获取属性:通过 runtime 遍历类的属性列表,可实现模型自动解析(如 JSON 转模型)、序列化与反序列化等功能。

    // 遍历 NSObject 子类的所有属性
    void getClassProperties(Class cls) {
    unsigned int propertyCount = 0;
    // 获取类的所有属性
    objc_property_t *properties = class_copyPropertyList(cls, &propertyCount);
    for (int i = 0; i < propertyCount; i++) {
    objc_property_t property = properties[i];
    // 获取属性名
    const char *propertyName = property_getName(property);
    // 获取属性类型
    const char *propertyType = property_getAttributes(property);
    NSLog(@"属性名:%@,类型:%@", [NSString stringWithUTF8String:propertyName], [NSString stringWithUTF8String:propertyType]);
    }
    // 释放内存
    free(properties);
    }

    // 调用:遍历 Person 类的属性
    getClassProperties([Person class]);

  2. 动态修改属性值 :通过 object_setIvar 直接修改实例变量的值,无需通过 setter 方法,适用于绕过访问权限限制。

    // 修改 Person 实例的 name 属性(假设 name 是私有实例变量 _name)
    void modifyPropertyValue(id instance) {
    Ivar ivar = class_getInstanceVariable([instance class], "_name");
    if (ivar) {
    object_setIvar(instance, ivar, @"修改后的名字");
    }
    }

  3. 关联对象(Associated Objects):为分类(Category)添加"伪属性",因为分类无法直接添加实例变量,通过关联对象可间接实现属性存储。

    #import <objc/runtime.h>

    @interface NSObject (AssociatedObject)
    @property (nonatomic, copy) NSString *associatedName;
    @end

    @implementation NSObject (AssociatedObject)

    // 定义关联对象的 key(静态常量确保唯一)
    static const char *AssociatedNameKey = "AssociatedNameKey";

    • (void)setAssociatedName:(NSString *)associatedName {
      // 关联对象:参数(目标对象、key、值、内存管理策略)
      objc_setAssociatedObject(self, AssociatedNameKey, associatedName, OBJC_ASSOCIATION_COPY_NONATOMIC);
      }

    • (NSString *)associatedName {
      // 获取关联对象
      return objc_getAssociatedObject(self, AssociatedNameKey);
      }

    @end

  • 适用场景:分类扩展属性、临时存储对象关联数据(如为 UIView 添加加载状态标识)。
四、动态方法解析与消息转发

利用 OC 消息机制的"动态解析"和"消息转发"特性,可实现方法缺失的优雅处理(避免崩溃)、多态扩展等功能。

  1. 动态方法解析 :当对象收到未实现的消息时,先调用 +resolveInstanceMethod:(实例方法)或 +resolveClassMethod:(类方法),可在此步骤动态添加方法实现。

    @implementation Person

    • (BOOL)resolveInstanceMethod:(SEL)sel {
      if (sel == @selector(unimplementedMethod)) {
      // 动态添加未实现方法的实现
      class_addMethod([self class], sel, (IMP)dynamicResolveMethod, "v@:");
      return YES;
      }
      return [super resolveInstanceMethod:sel];
      }

    static void dynamicResolveMethod(id self, SEL _cmd) {
    NSLog(@"动态解析未实现的方法");
    }
    @end

  2. 消息转发 :若动态解析未处理,会进入消息转发流程,可通过 forwardingTargetForSelector: 转发消息给其他对象,或通过 methodSignatureForSelector:forwardInvocation: 自定义转发逻辑。

    @implementation Person
    // 直接转发消息给 Target 对象

    • (id)forwardingTargetForSelector:(SEL)aSelector {
      if (aSelector == @selector(unimplementedMethod)) {
      return [[Target alloc] init]; // Target 类实现了 unimplementedMethod
      }
      return [super forwardingTargetForSelector:aSelector];
      }
      @end
  • 适用场景:方法缺失防护(避免崩溃)、组件间解耦(消息转发实现跨组件通信)。
面试加分点
  • 能结合底层原理说明动态特性的实现(如 runtime 库的核心函数、方法缓存机制);
  • 能指出动态特性的风险(如方法替换导致的兼容性问题、关联对象内存泄漏)及规避方案(如 dispatch_once_t 确保方法替换唯一执行、及时移除关联对象);
  • 能举例实际项目中的应用场景(如 JSON 转模型框架 MJExtension 利用属性遍历实现自动解析、AFNetworking 利用方法替换实现网络日志打印)。
记忆法
  • 功能分类记忆法:将动态特性按"类操作(创建/修改)、方法操作(添加/替换/解析/转发)、属性操作(获取/修改/关联对象)"三类划分,每类记住核心功能和对应的 runtime 函数,形成知识框架。
  • 场景关联记忆法:将每个动态特性与具体应用场景绑定(如"方法替换 → 埋点统计""关联对象 → 分类添加属性""消息转发 → 方法缺失防护"),通过场景反推功能,加深记忆。

简述你对 Swift 语言的理解?

Swift 是苹果于 2014 年发布的现代编程语言,专为 iOS、macOS、watchOS、tvOS 等苹果生态系统设计,旨在替代 Objective-C 成为主流开发语言。它融合了多种编程语言的优点,兼具安全性、高性能、易用性,同时保持与 Objective-C 的兼容性,是当前苹果生态开发的核心语言。理解 Swift 需从设计理念、核心特性、生态定位、适用场景等多维度展开,面试中需体现对语言本质和实际应用的深度认知。

一、Swift 的设计理念

Swift 的设计核心围绕"安全、现代、高效、互操作"四大理念,解决了 Objective-C 遗留的诸多问题:

  1. 安全优先:通过强类型系统、可选类型(Optional)、空值安全检查等机制,从语法层面减少空指针崩溃、类型转换错误等常见问题,让代码更可靠。
  2. 现代简洁 :摒弃 Objective-C 冗余的语法(如 @interface/@implementation 分离、分号结尾、繁琐的消息发送语法),采用更简洁的语法结构,降低编码复杂度,提高开发效率。
  3. 高性能:兼顾编译时优化和运行时效率,采用 LLVM 编译器,代码执行速度接近 C 语言;同时支持值类型(Struct/Enum),减少堆内存分配,降低内存开销。
  4. 无缝互操作:完全兼容 Objective-C,可在同一项目中混合使用(Swift 调用 Objective-C,Objective-C 调用 Swift),保护开发者既有代码资产,平滑过渡。
二、Swift 的核心特性
  1. 强类型与类型推断 :Swift 是强类型语言,每个变量/常量都有明确的类型,但编译器支持类型推断,无需显式声明类型(如 let name = "Swift" 自动推断为 String 类型),兼顾类型安全和编码简洁性。

  2. 可选类型(Optional) :专门处理空值场景,通过 ? 声明可选类型(如 var age: Int?),必须通过解包(! 强制解包、if let 可选绑定、guard let 守护解包)才能使用,从语法上杜绝空指针崩溃。示例:

    var optionalStr: String? = "Hello"
    // 可选绑定(安全解包)
    if let str = optionalStr {
    print(str) // 输出 "Hello"
    }
    // 守护解包(适用于提前退出场景)
    guard let str = optionalStr else {
    return
    }
    print(str)

  3. 值类型优先:默认推荐使用值类型(Struct、Enum),值类型赋值时会创建完整副本,不存在引用共享问题,线程安全且内存管理更高效;类(Class)为引用类型,仅在需要继承、共享状态时使用。示例:

    // Struct 是值类型
    struct Point {
    var x: Int
    }
    var p1 = Point(x: 10)
    var p2 = p1
    p2.x = 20
    print(p1.x) // 输出 10(p2 是副本,修改不影响 p1)

    // Class 是引用类型
    class Person {
    var age: Int
    init(age: Int) {
    self.age = age
    }
    }
    var person1 = Person(age: 20)
    var person2 = person1
    person2.age = 30
    print(person1.age) // 输出 30(person2 与 person1 指向同一对象)

  4. 函数式编程支持 :内置高阶函数(mapfilterreduce 等)、闭包(Closure)、不可变集合(let 修饰的数组/字典),支持函数式编程范式,代码更简洁、易读、易维护。示例:

    let numbers = [1, 2, 3, 4, 5]
    // map:转换数组元素
    let squaredNumbers = numbers.map { 0 * 0 } // [1, 4, 9, 16, 25]
    // filter:筛选数组元素
    let evenNumbers = numbers.filter { $0 % 2 == 0 } // [2, 4]
    // reduce:聚合数组元素
    let sum = numbers.reduce(0, +) // 15

  5. 面向协议编程(POP):以协议(Protocol)为核心,支持协议扩展(Protocol Extension),可实现多继承的效果,同时避免类继承的耦合问题,提高代码复用性和灵活性。示例:

    // 定义协议
    protocol Runnable {
    func run()
    }
    // 协议扩展(提供默认实现)
    extension Runnable {
    func run() {
    print("默认奔跑逻辑")
    }
    }
    // 类遵守协议,无需重新实现 run 方法(可重写)
    class Dog: Runnable {}
    let dog = Dog()
    dog.run() // 输出 "默认奔跑逻辑"

  6. 其他现代特性

    • 泛型(Generics):支持编写通用代码,适配多种类型(如 Array<T>、自定义泛型类/函数);
    • 枚举(Enum)增强:支持关联值(Associated Values)、原始值(Raw Values),功能远超 Objective-C 的枚举;
    • 属性观察器(Property Observers):通过 willSet/didSet 监听属性值变化;
    • 错误处理(Error Handling):通过 do-catch 机制优雅处理异常,替代 Objective-C 的 NSError 指针。
三、Swift 的生态定位与发展
  1. 苹果生态的核心语言:苹果已明确将 Swift 作为生态首选开发语言,新系统特性(如 SwiftUI、WidgetKit)优先支持 Swift,Objective-C 仅维护现有功能,不再新增核心特性。
  2. 跨平台扩展:Swift 开源后,支持 Linux、Windows 等平台,可用于服务器端开发、跨平台应用开发(如通过 SwiftUI 实现 iOS/macOS 跨平台)。
  3. 生态工具完善:拥有 Swift Package Manager(包管理工具)、SwiftUI(声明式 UI 框架)、Combine(响应式编程框架)等配套工具,形成完整的开发闭环。
四、Swift 与 Objective-C 的核心差异(补充,帮助理解定位)
特性 Swift Objective-C
语法风格 简洁现代,无冗余符号 繁琐,依赖 @ 符号、分号、消息发送语法
类型安全 强类型,编译时严格检查 弱类型,支持隐式类型转换
空值处理 可选类型,强制解包 直接允许 nil,易出现空指针崩溃
内存管理 ARC 自动管理,值类型无需ARC ARC(后期支持)/MRC(早期),仅引用类型需管理
编程范式 多范式(面向对象、函数式、POP) 主要面向对象
面试加分点
  • 能结合实际开发对比 Swift 与 Objective-C 的优劣(如 Swift 更安全高效,但 Objective-C 兼容性更好);
  • 能深入讲解 POP 与 OOP 的差异及适用场景;
  • 能说明 Swift 的性能优化点(如值类型减少堆内存分配、编译时优化);
  • 了解 Swift 的版本迭代趋势(如 Swift 5 实现 ABI 稳定,Swift 6 增强并发安全)。
记忆法
  • 核心特性口诀记忆法:"安全(可选类型)、简洁(语法)、高效(值类型)、多范式(OOP/POP/函数式)、互操作(兼容OC)",通过口诀快速抓住核心亮点,再展开每个特性的细节。
  • 对比记忆法:通过与 Objective-C 的差异对比,反向强化 Swift 的特性(如"OC 空值不安全 → Swift 有可选类型""OC 类继承耦合 → Swift 有 POP"),同时理解 Swift 的设计初衷。

Swift 语言和 Java 语言有什么区别?

Swift 和 Java 是两门面向不同生态、设计理念差异显著的现代编程语言:Swift 聚焦苹果生态开发,兼具安全性、简洁性和多编程范式支持;Java 主打跨平台(JVM 生态),以稳定性、成熟度和企业级应用为核心优势。二者的差异贯穿语法设计、类型系统、内存管理、生态定位等多个维度,面试中需从核心特性出发,结合应用场景深入分析,避免表面化对比。

一、设计目标与生态定位
  1. Swift 的设计目标与生态

    • 核心目标:为苹果生态(iOS、macOS、watchOS、tvOS)提供安全、高效、简洁的开发语言,替代 Objective-C 的历史冗余,同时支持跨平台扩展(Linux、Windows)。
    • 生态聚焦:深度绑定苹果系统API和框架(如 UIKit、SwiftUI、Combine),是苹果生态的首选语言,适用于移动应用、桌面应用、轻量级服务器开发。
    • 发展趋势:持续优化性能和安全性(如 Swift 6 的并发安全特性),强化跨平台能力,逐步成为苹果生态全场景开发的统一语言。
  2. Java 的设计目标与生态

    • 核心目标:"一次编写,到处运行(Write Once, Run Anywhere)",基于 JVM(Java 虚拟机)实现跨平台,强调稳定性、可扩展性和企业级支持。
    • 生态聚焦:覆盖企业级后端(Spring 生态)、Android 移动应用(早期唯一官方语言,现仍占主导)、大数据(Hadoop 生态)、桌面应用等多场景,拥有成熟的中间件、框架和工具链。
    • 发展趋势:保持向后兼容,持续优化 JVM 性能,引入现代语言特性(如 Lambda 表达式、Record 类型),巩固企业级市场地位。
二、语法设计与编程范式
  1. 语法风格

    • Swift:语法简洁现代,摒弃冗余符号,注重开发者体验:
      • 无需分号结尾(除非多行代码写在一行);
      • @interface/@implementation 分离,类定义集中在一个代码块;
      • 消息发送采用点语法(object.method()),替代 Objective-C 的中括号语法;
      • 支持类型推断,无需显式声明变量类型(let name = "Swift" 自动推断为 String)。示例(Swift 类定义):

    class Person {
    var name: String
    let age: Int

    复制代码
     init(name: String, age: Int) {
         self.name = name
         self.age = age
     }
     
     func introduce() {
         print("我是\(name),今年\(age)岁")
     }

    }

    let person = Person(name: "张三", age: 25)
    person.introduce()

  • Java:语法相对繁琐,遵循 C 语言风格,强调规范性:
    • 语句必须以分号结尾;

    • 类定义需严格遵循"一个文件一个公共类",且文件名与类名一致;

    • 方法调用采用点语法,但需显式声明变量类型(Java 10 后支持局部变量类型推断 var);

    • 必须包含 main 方法作为程序入口(控制台应用)。示例(Java 类定义):

      public class Person {
      private String name;
      private final int age;

      复制代码
      public Person(String name, int age) {
          this.name = name;
          this.age = age;
      }
      
      public void introduce() {
          System.out.println("我是" + name + ",今年" + age + "岁");
      }
      
      // Getter/Setter 必须手动生成(或通过 Lombok 简化)
      public String getName() {
          return name;
      }
      
      public void setName(String name) {
          this.name = name;
      }
      
      public static void main(String[] args) {
          Person person = new Person("张三", 25);
          person.introduce();
      }

      }

  1. 编程范式
    • Swift:支持多范式融合,灵活适配不同开发场景:
      • 面向对象(OOP):支持类、继承、多态、封装;
      • 面向协议(POP):以协议为核心,支持协议扩展,实现"多继承"效果,降低耦合;
      • 函数式编程:支持高阶函数(map/filter/reduce)、闭包、不可变数据,代码简洁易维护。
    • Java:以面向对象(OOP)为核心,后期逐步引入函数式特性:
      • 纯 OOP 设计:万物皆对象(除基本数据类型),支持类继承、接口实现、多态;
      • 函数式增强:Java 8 引入 Lambda 表达式、Stream API、函数式接口,支持部分函数式编程特性,但核心仍围绕 OOP;
      • 无 POP 支持:需通过接口+抽象类模拟类似功能,但灵活性不足。
三、类型系统与核心特性
  1. 类型安全与空值处理

    • Swift:强类型语言,类型安全级别极高:
      • 可选类型(Optional):专门处理空值,通过 ? 声明、if let/guard let 解包,从语法上杜绝空指针崩溃;
      • 无隐式类型转换:不同类型必须显式转换(如 IntString 需用 String(10));
      • 值类型优先:默认推荐 Struct/Enum(值类型),赋值时拷贝,线程安全,内存高效。
    • Java:强类型语言,但空值处理相对薄弱:
      • 支持 null 直接赋值给引用类型(如 String str = null),调用 str.length() 会抛出 NullPointerException(NPE),是 Java 开发中最常见的崩溃原因;
      • Java 8 引入 Optional 类(容器类型),但为可选使用,未从语法层面强制,普及度有限;
      • 引用类型主导:类是主要类型, Struct 仅用于包装基本数据类型(如 Integer),无值类型优势。
  2. 核心特性差异

    特性 Swift Java
    泛型支持 全面支持泛型类、函数、协议,语法简洁(func funcName<T>(param: T) 支持泛型,但语法繁琐(public class List<T>),早期版本(Java 5 前)不支持
    枚举(Enum) 增强型枚举,支持关联值、原始值、方法,功能强大(如 enum Result<Success, Failure: Error> 简单枚举,仅支持整型原始值,无关联值,功能有限
    属性系统 支持存储属性、计算属性、属性观察器(willSet/didSet)、延迟存储属性(lazy 仅支持普通属性,需通过 getter/setter 实现计算逻辑,无原生属性观察器(需手动编码)
    错误处理 基于 throw/try/do-catch 语法,类型安全,错误类型需遵循 Error 协议 基于异常体系(Exception 类),支持 try-catch-finally,但 checked exception 导致代码冗余(后期逐步简化)
    并发编程 Swift 5.5+ 引入 async/await 语法,支持结构化并发,简洁安全;同时支持 GCD 桥接 早期依赖线程池、Runnable/Callable,Java 8 引入 CompletableFuture,Java 19 引入虚拟线程(Virtual Thread),但语法和易用性不如 Swift
四、内存管理与编译运行
  1. 内存管理

    • Swift:
      • 自动引用计数(ARC):仅管理引用类型(Class),值类型(Struct/Enum)无需 ARC,直接栈上分配,释放高效;
      • 无手动内存管理:ARC 自动处理引用计数的增减,无需 retain/release(对比 Objective-C);
      • 弱引用(weak)、无主引用(unowned):用于解决循环引用,语法简洁。
    • Java:
      • 垃圾回收(GC):基于 JVM 的自动垃圾回收机制,开发者无需手动管理内存,通过可达性分析回收无用对象;
      • 内存区域划分:堆内存存储对象,栈内存存储基本类型和引用,方法区存储类信息;
      • 引用类型:支持强引用、软引用、弱引用、虚引用,用于灵活控制对象生命周期,但使用场景有限。
  2. 编译与运行

    • Swift:
      • 静态编译:直接编译为机器码(针对特定平台,如 iOS 的 ARM64 架构),运行速度快,性能接近 C 语言;
      • 无虚拟机:直接运行在操作系统上,依赖苹果生态的 runtime 库;
      • 混合编译:可与 Objective-C 混合编译,无缝调用 Objective-C 代码和框架。
    • Java:
      • 半编译半解释:先编译为字节码(.class 文件),再由 JVM 解释执行或即时编译(JIT)为机器码;
      • 跨平台核心:字节码可在任何安装 JVM 的平台运行,实现"一次编写,到处运行";
      • 无原生混合编译:需通过 JNI(Java Native Interface)调用 C/C++ 代码,复杂度高。
五、应用场景与生态工具
  1. 应用场景

    • Swift:
      • 核心场景:苹果生态应用开发(iOS 应用、macOS 桌面应用、watchOS 手表应用);
      • 扩展场景:SwiftUI 跨平台应用(iOS/macOS)、轻量级服务器开发(Vapor 框架)、命令行工具;
      • 不适用场景:企业级后端(生态不成熟)、大数据处理(无对应框架)。
    • Java:
      • 核心场景:企业级后端开发(Spring Boot/Spring Cloud 生态)、Android 应用开发;
      • 扩展场景:大数据处理(Hadoop/Spark)、桌面应用(Swing/JavaFX)、嵌入式开发;
      • 不适用场景:苹果生态核心应用(无官方支持,性能和兼容性差)。
  2. 生态工具

    • Swift:
      • 包管理:Swift Package Manager(SPM),苹果官方包管理工具,集成 Xcode;
      • 开发工具:Xcode(官方 IDE,功能强大,支持调试、编译、发布一站式);
      • 框架生态:SwiftUI(声明式 UI)、Combine(响应式编程)、CoreML(机器学习)等苹果官方框架。
    • Java:
      • 包管理:Maven、Gradle(主流),生态成熟,支持复杂依赖管理;
      • 开发工具:IntelliJ IDEA(主流)、Eclipse、NetBeans;
      • 框架生态:Spring 全家桶(Spring Boot、Spring Cloud)、MyBatis(ORM)、Netty(网络编程)等。
面试加分点
  • 能结合具体开发场景分析选择逻辑(如"开发 iOS 应用选 Swift,开发企业级后端选 Java");
  • 能深入对比内存管理机制的优劣(如 Swift 的 ARC 针对引用类型高效,Java 的 GC 适合复杂对象生命周期);
  • 了解两门语言的发展趋势(如 Swift 强化跨平台,Java 优化并发和简化语法);
  • 能指出二者的共性(如强类型、面向对象、自动内存管理),再突出差异,逻辑更全面。
记忆法
  • 维度分类记忆法:按"生态定位、语法范式、类型系统、内存管理、应用场景"五个维度划分,每个维度列出二者的核心差异,形成结构化知识框架,避免遗漏关键点。
  • 场景联想记忆法:将语言与典型应用场景绑定("Swift → iOS 应用""Java → 后端/Spring"),再从场景反推特性差异(如"iOS 需高性能 → Swift 静态编译""后端需跨平台 → Java JVM"),强化记忆关联。

请介绍 ARC(自动引用计数)的相关知识?

ARC(Automatic Reference Counting,自动引用计数)是苹果为 Objective-C 和 Swift 提供的自动内存管理机制,核心作用是在编译期插入引用计数管理代码,自动跟踪和管理对象的生命周期,避免内存泄漏和野指针问题 ,替代了 MRC(手动引用计数)时代需要开发者手动调用 retain/release/autorelease 的繁琐操作。理解 ARC 是 iOS 开发的基础,面试中需从核心原理、工作机制、引用类型、循环引用解决方案等维度全面阐述。

一、ARC 的核心原理

ARC 的本质是"基于引用计数的自动内存管理",核心逻辑围绕"对象的引用计数"展开:

  1. 引用计数(Reference Count) :每个 OC/Swift 引用类型对象(如 NSString、自定义类实例)都有一个隐藏的"引用计数"属性,记录当前持有该对象的指针数量。
  2. 计数规则
    • 当有新指针强引用对象时,引用计数 +1;
    • 当指针不再强引用对象时(如指针置空、超出作用域),引用计数 -1;
    • 当引用计数变为 0 时,对象占用的内存会被系统自动释放,避免内存泄漏。
  3. 自动管理的实现 :ARC 并非运行时的"垃圾回收"(GC),而是在编译阶段分析代码逻辑,自动在合适的位置插入 retain(计数+1)、release(计数-1)、autorelease(延迟释放)等底层调用,开发者无需手动编写这些代码,但需理解其背后的计数变化逻辑。
二、ARC 的工作机制(结合代码示例)

以 Objective-C 代码为例,直观展示 ARC 下引用计数的变化:

复制代码
// 1. 创建对象:引用计数 = 1(alloc 会初始化计数为 1)
Person *person = [[Person alloc] init]; 

// 2. 强引用赋值:新指针强引用对象,计数 +1 → 计数 = 2
Person *person2 = person; 

// 3. 指针置空:person2 不再引用对象,计数 -1 → 计数 = 1
person2 = nil; 

// 4. 指针超出作用域:person 是局部变量,函数执行完毕后超出作用域,计数 -1 → 计数 = 0
// 系统自动释放 Person 对象的内存

Swift 中 ARC 逻辑一致,只是语法更简洁:

复制代码
// 引用计数 = 1
var person: Person? = Person() 
// 计数 +1 → 2
var person2 = person 
// 计数 -1 → 1
person2 = nil 
// 计数 -1 → 0,对象释放
person = nil 
三、ARC 支持的引用类型

ARC 中存在三种核心引用类型,不同类型对引用计数的影响不同,适用场景也不同:

  1. 强引用(Strong Reference)

    • 核心特性:默认引用类型,持有对象时会使引用计数 +1,是对象保持存活的核心原因。
    • 适用场景:绝大多数需要长期持有对象的场景(如属性存储、方法参数传递后需保留的对象)。
    • 风险:若形成"循环强引用"(A 强引用 B,B 强引用 A),会导致双方引用计数无法归零,对象永远无法释放,引发内存泄漏。
  2. 弱引用(Weak Reference)

    • 核心特性:不持有对象,引用计数不变化;当对象被释放时,系统会自动将弱引用指针置为 nil,避免野指针崩溃。

    • 适用场景:避免循环引用(如 delegate 代理、block 中捕获外部变量、父子视图相互引用)、临时引用对象(无需影响对象生命周期)。

    • 语法:Objective-C 中用 __weak 修饰,Swift 中用 weak 修饰(需是可选类型)。

    • 示例(Objective-C delegate):

      复制代码
      // 避免循环引用:ViewController 强引用 TableView,TableView 的 delegate 用弱引用
      @interface UITableView (WeakDelegate)
      @property (nonatomic, weak) id<UITableViewDelegate> delegate;
      @end
  3. 无主引用(Unowned Reference)

    • 核心特性:同弱引用一样不持有对象,引用计数不变化;但对象被释放后,无主引用指针不会置为 nil,仍指向原内存地址(野指针),访问会崩溃。
    • 适用场景:对象生命周期明确关联,且被引用对象一定比引用者存活时间长(如"客户"和"订单",订单的无主引用指向客户,客户销毁前订单必然先销毁)。
    • 语法:Objective-C 中用 __unsafe_unretained(早期)或 __unowned(Swift 桥接),Swift 中用 unowned 修饰(非可选类型)。
    • 注意:无主引用风险高于弱引用,需确保被引用对象不会提前释放。
四、ARC 中的循环引用及解决方案

循环引用是 ARC 中最常见的内存泄漏原因,需掌握典型场景及解决方案:

  1. 典型循环引用场景

    • 场景 1:父子对象相互强引用(如 Person 类有 @property (nonatomic, strong) Dog *dog;Dog 类有 @property (nonatomic, strong) Person *owner;);
    • 场景 2:block 捕获外部强引用变量(如属性 block 中直接使用 self,且 self 强引用 block);
    • 场景 3:代理模式中 delegate 用强引用。
  2. 解决方案

    • 方案 1:弱引用打破循环(适用于大多数场景):

      • block 中使用 __weak typeof(self) weakSelf = self;(Objective-C)或 [weak self](Swift);

      • 代理 delegateweak 修饰。

      • 示例(Objective-C block 弱引用):

        复制代码
        __weak typeof(self) weakSelf = self;
        self.networkBlock = ^{
            // 若需确保 block 执行期间 self 不释放,可转为强引用(避免循环,因为 weakSelf 是弱引用)
            __strong typeof(weakSelf) strongSelf = weakSelf;
            [strongSelf requestData];
        };
    • 方案 2:无主引用打破循环(适用于被引用对象生命周期更长的场景):

      • Swift 中示例:

        复制代码
        class Customer {
            let name: String
            var order: Order?
            init(name: String) { self.name = name }
        }
        class Order {
            let product: String
            unowned let customer: Customer // 订单的客户一定比订单存活久
            init(product: String, customer: Customer) {
                self.product = product
                self.customer = customer
            }
        }
    • 方案 3:手动断开引用(适用于特定场景):如页面销毁时,将相互引用的属性置为 nilself.dog.owner = nil; self.dog = nil;)。

五、ARC 的适用范围与限制
  1. 适用范围

    • 仅管理 Objective-C/Swift 引用类型对象(如 NSObject 子类、Swift 自定义类);
    • 基本数据类型(intfloatBOOL)、结构体(struct)、枚举(enum)是值类型,不涉及引用计数,ARC 不管理。
  2. 限制与注意事项

    • ARC 无法自动处理"非引用类型的内存"(如 C 语言 malloc 分配的内存、Core Foundation 框架的对象),需手动释放(如 freeCFRelease);
    • 混合使用 ARC 和 MRC 代码时,需注意内存管理兼容(如 MRC 代码调用 ARC 代码时,无需手动 retain/release);
    • 避免"隐式循环引用"(如 block 嵌套中多次捕获 self,需统一使用弱引用)。
面试加分点
  • 能区分 ARC 与 GC(垃圾回收)的差异(ARC 是编译期插入代码,GC 是运行时扫描;ARC 性能更高,无 GC 的卡顿问题);
  • 能深入讲解弱引用的底层实现(通过 SideTable 弱引用表管理,对象销毁时遍历表置空指针);
  • 能结合实际项目案例说明循环引用的排查方法(如使用 Instruments 的 Leaks 工具、MLeaksFinder 第三方库)。
记忆法
  • 核心逻辑记忆法:"计数增减定生死,强引用加计数,弱/无主不加;循环引用是天敌,弱引用/无主引用破局",通过口诀记住 ARC 核心规则和关键问题。
  • 场景关联记忆法:将引用类型与场景绑定("delegate 用 weak""block 用 weakSelf""生命周期绑定用 unowned"),通过场景反推引用类型的选择,同时记住循环引用的解决方案。

你对 autoreleasepool 的了解有多少?

autoreleasepool(自动释放池)是 Objective-C 内存管理中的核心机制,与 ARC、MRC 均密切相关,核心作用是延迟释放对象,统一管理一批对象的生命周期 ,避免短时间内创建大量临时对象导致内存峰值过高。理解 autoreleasepool 的工作原理、使用场景和底层实现,是 iOS 开发面试的高频考点,需从定义、工作流程、使用场景、底层逻辑等维度全面阐述。

一、autoreleasepool 的核心定义与设计初衷
  1. 核心定义autoreleasepool 是一个"对象容器",通过 NSAutoreleasePool 类(MRC)或 @autoreleasepool 语法(ARC/MRC)创建,添加到池中的对象会被标记为"自动释放",当释放池被销毁(或耗尽)时,池会向所有内部对象发送 release 消息,降低对象的引用计数,实现批量延迟释放。

  2. 设计初衷

    • 解决"临时对象的生命周期管理"问题:如方法返回的临时对象(如 [NSString stringWithFormat:]),无法在方法内立即 release(否则返回后对象已释放),也不能依赖调用者手动 release(MRC 下易遗漏),通过 autorelease 加入释放池,延迟到池销毁时释放;
    • 控制内存峰值:短时间内创建大量临时对象(如循环创建 10000 个 UIImage),若不加入释放池,对象会持续占用内存导致峰值过高,加入池后可分批释放,降低内存压力。
二、autoreleasepool 的工作流程(结合 MRC/ARC 差异)

autoreleasepool 的工作流程核心是"对象入池 → 池销毁 → 对象释放",MRC 和 ARC 下的使用语法和底层调用略有差异:

  1. MRC 环境下的手动使用:MRC 中需手动创建、管理释放池,步骤清晰:

    复制代码
    // 1. 创建自动释放池(两种方式)
    NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
    // 或使用 NSAutoReleasePool 类方法
    // NSAutoreleasePool *pool = [NSAutoreleasePool new];
    
    // 2. 创建临时对象,调用 autorelease 加入池
    NSString *str = [[[NSString alloc] initWithFormat:@"hello"] autorelease];
    // 系统自带的类方法创建的对象(如 stringWithFormat),默认已调用 autorelease,无需手动调用
    NSString *str2 = [NSString stringWithFormat:@"world"];
    
    // 3. 释放池销毁,向池内所有对象发送 release 消息
    [pool release]; // 或 [pool drain];(drain 等价于 release,ARC 中仅支持 drain)
    • 流程说明:str 调用 autorelease 后,引用计数未立即减少,而是被加入释放池;pool release 时,strstr2 均收到 release 消息,引用计数 -1,若计数变为 0 则对象释放。
  2. ARC 环境下的自动与手动使用 :ARC 中编译器会自动管理 autorelease 调用和默认释放池,但仍需手动创建释放池处理大量临时对象:

    • 自动释放池:iOS 程序启动后,主线程会自动创建一个顶层 autoreleasepool,每次 RunLoop 循环结束时,顶层池会销毁并重建,池内自动释放的对象会被批量释放(这也是主线程临时对象不会内存泄漏的原因);

    • 手动创建释放池:使用 @autoreleasepool 语法(更简洁,推荐),替代 MRC 的 NSAutoreleasePool 实例:

      复制代码
      // ARC 中手动创建释放池,处理大量临时对象
      @autoreleasepool {
          for (int i = 0; i < 10000; i++) {
              // 循环创建临时对象,自动加入当前释放池
              UIImage *image = [UIImage imageNamed:@"test.png"];
              // 处理 image...
          }
          // 释放池结束,内部所有自动释放对象收到 release 消息
      }
    • ARC 特性:编译器会自动为符合"返回临时对象"规则的方法(如类方法 + (instancetype)createObject)插入 autorelease 调用,开发者无需手动调用。

三、autoreleasepool 的底层实现

autoreleasepool 的底层并非简单的"对象数组",而是基于 __AtAutoreleasePool 结构体和线程本地存储(TLS)实现的高效数据结构:

  1. 底层结构

    • 每个线程都有一个"自动释放池栈"(由多个释放池节点组成),栈顶节点是当前活跃的释放池;

    • 释放池节点包含一个"对象链表",存储加入池的自动释放对象;

    • @autoreleasepool { ... } 语法会被编译器转换为以下底层代码(简化版):

      复制代码
      __AtAutoreleasePool pool;
      // 等价于:
      // objc_autoreleasePoolPush();
      // { ... 代码块 ... }
      // objc_autoreleasePoolPop(pool.token);
    • objc_autoreleasePoolPush():创建新的释放池节点,压入线程的释放池栈,返回节点的 token(标识);

    • objc_autoreleasePoolPop(token):根据 token 找到对应的释放池节点,遍历节点内的对象链表,发送 release 消息,然后将节点从栈中弹出。

  2. autorelease 方法的底层逻辑 :当对象调用 autorelease 时(ARC 自动插入或 MRC 手动调用),底层会执行:

    • 通过线程本地存储(TLS)获取当前线程的释放池栈;
    • 若栈不为空,将对象添加到栈顶释放池的对象链表;
    • 若栈为空(无活跃释放池),则自动创建一个临时释放池,将对象加入,后续在合适时机(如线程结束)释放。
  3. 性能优化

    • 释放池的对象链表采用双向链表结构,插入和删除效率高;
    • 顶层释放池与 RunLoop 绑定,RunLoop 每次循环(如 UI 事件处理、网络请求回调)结束时自动 pop 旧池、push 新池,确保主线程内存稳定。
四、autoreleasepool 的适用场景
  1. 场景 1:循环创建大量临时对象:当循环中创建大量临时对象(如解析大文件、批量处理图片),若不手动创建释放池,对象会积累到 RunLoop 结束才释放,导致内存峰值过高(可能触发内存警告)。手动创建释放池可在循环内部分批释放对象:

    复制代码
    // 处理大文件,每 1000 行创建一个释放池
    for (int i = 0; i < 100000; i++) {
        if (i % 1000 == 0) {
            // 每次创建新池,旧池自动销毁,释放之前的临时对象
            @autoreleasepool {
                NSString *line = [self readLineFromFile:i]; // 临时对象
                [self processLine:line];
            }
        }
    }
  2. 场景 2:非主线程执行任务 :非主线程默认没有自动创建的 autoreleasepool,若在非主线程中创建自动释放对象(如调用系统类方法 [NSString stringWithFormat:]),且未手动创建释放池,对象会无法释放,导致内存泄漏。因此非主线程执行任务时,需手动包裹 @autoreleasepool

    复制代码
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        @autoreleasepool {
            NSString *str = [NSString stringWithFormat:@"非主线程任务"];
            NSLog(@"%@", str);
            // 任务执行完毕,释放池销毁,str 被释放
        }
    });
  3. 场景 3:MRC 与 ARC 代码混合调用 :若 MRC 代码调用 ARC 代码返回的自动释放对象,或 ARC 代码调用 MRC 代码返回的自动释放对象,需确保有活跃的 autoreleasepool 接收对象,避免对象提前释放或内存泄漏。

  4. 场景 4:降低内存峰值的性能优化 :即使不是大量对象,若单个对象占用内存较大(如高清图片),在使用后通过 @autoreleasepool 手动触发释放,可快速降低内存峰值,提升应用流畅度。

五、autoreleasepool 的常见误区
  1. 误区 1:ARC 中无需关注 autoreleasepool :ARC 仅自动管理 autorelease 调用,但大量临时对象仍会导致内存峰值过高,需手动创建释放池优化;
  2. 误区 2:释放池内的对象会立即释放 :释放池内的对象仅在池销毁时(pop 时)收到 release 消息,若对象还有其他强引用,引用计数未归零则不会释放;
  3. 误区 3:嵌套释放池会重复释放对象 :嵌套释放池是栈结构,内层池销毁时仅释放内层池的对象,外层池销毁时释放外层池的对象,不会重复释放(同一对象可加入多个池,每个池销毁时都会发送 release 消息,需注意引用计数变化);
  4. 误区 4:autorelease 会增加引用计数autorelease 不会改变对象的引用计数,仅将对象加入释放池,延迟发送 release 消息。
面试加分点
  • 能区分 autoreleasepool 在 MRC 和 ARC 下的使用差异;
  • 能深入讲解底层实现(释放池栈、push/pop 操作、线程本地存储);
  • 能结合 Instruments 工具(如 Memory 模板)说明如何通过 autoreleasepool 优化内存峰值;
  • 能举例说明非主线程不使用 autoreleasepool 导致内存泄漏的场景及排查方法。
记忆法
  • 流程记忆法 :"创建池 → 对象入池(autorelease) → 池销毁 → 对象 release",按流程记住 autoreleasepool 的核心工作逻辑,再延伸场景和底层细节。
  • 场景关联记忆法 :将 autoreleasepool 与"大量临时对象""非主线程任务""内存峰值优化"三个核心场景绑定,通过场景反推使用时机和注意事项,避免遗忘关键应用场景。

iOS 和 Android 的内存管理有什么区别?

iOS 和 Android 作为两大移动操作系统,其内存管理机制的核心目标都是"高效利用有限内存,避免泄漏,确保应用流畅运行",但因底层架构(iOS 基于 Mach 内核+Objective-C/Swift,Android 基于 Linux 内核+Java/Kotlin)、语言特性、系统设计理念的差异,二者在内存管理的实现方式、核心机制、开发者体验等方面存在显著区别。面试中需从核心机制、内存分配、回收策略、开发者操作、系统特性等维度全面对比,结合底层原理和实际开发场景说明差异。

一、核心内存管理机制

这是二者最根本的区别,源于所使用的编程语言和运行时环境:

  1. iOS 的核心机制:ARC(自动引用计数)

    • 适用语言:Objective-C 和 Swift(引用类型对象);
    • 核心原理:基于"引用计数"跟踪对象生命周期,编译期自动插入 retain(计数+1)、release(计数-1)代码,当对象引用计数为 0 时,系统立即释放内存;
    • 管理范围:仅管理 Objective-C/Swift 引用类型对象(如 NSObject 子类、Swift 类),值类型(intstruct)和 C 语言内存(malloc 分配)需手动管理;
    • 关键特性:无运行时垃圾回收(GC)过程,释放时机明确,内存占用稳定,无 GC 导致的卡顿;但需开发者避免循环引用(否则内存泄漏)。
  2. Android 的核心机制:GC(垃圾回收)

    • 适用语言:Java 和 Kotlin(基于 JVM/ART 运行时);
    • 核心原理:基于"可达性分析",运行时通过 GC 线程扫描堆内存中的对象,判断对象是否被"根对象"(如栈引用、静态变量)可达,不可达对象标记为垃圾,后续通过回收算法释放内存;
    • 管理范围:自动管理所有 Java/Kotlin 对象(引用类型),基本数据类型存储在栈上,无需管理;
    • 关键特性:开发者无需关注对象释放,降低开发门槛;但 GC 是运行时异步过程,可能导致"GC 停顿"(STW,Stop The World),影响应用流畅度(Android 后续版本通过分代回收、并发回收优化)。
二、内存分配与回收策略
  1. 内存分配差异

    维度 iOS Android
    分配区域 分为栈(栈帧存储局部变量、函数调用信息)、堆(存储引用类型对象)、全局区(静态变量、常量)、代码区(可执行代码);堆内存分配由系统直接管理,无虚拟机介入 基于 JVM/ART 内存模型,分为堆(新生代、老年代、永久代/元空间)、栈、方法区;堆内存分配由虚拟机统一管理,新生代用于存储短期对象,老年代存储长期对象
    分配机制 引用类型对象通过 alloc/init 直接在堆上分配,分配速度快;值类型在栈上分配,函数执行完毕后自动释放 对象通过 new 关键字创建,由虚拟机在堆上分配;新生代采用"空闲列表"或"TLAB(线程本地分配缓冲区)"分配,老年代采用"标记-整理"算法分配,分配逻辑更复杂
  2. 回收策略差异

    • iOS(ARC):
      • 回收时机:同步回收,对象引用计数为 0 时立即释放(如指针置空、超出作用域);
      • 回收过程:无单独 GC 线程,释放操作在当前线程执行,耗时极短(微秒级),无卡顿;
      • 回收算法:无复杂算法,仅简单释放对象占用的堆内存,归还给系统。
    • Android(GC):
      • 回收时机:异步回收,虚拟机根据内存压力(如堆内存达到阈值)自动触发,或通过 System.gc() 手动建议触发(虚拟机不一定执行);
      • 回收过程:早期采用"标记-清除""标记-复制""标记-整理"算法,存在 STW 停顿;Android 8.0 后 ART 虚拟机引入"并发标记-清扫(CMS)""分代回收",减少 STW 时间;
      • 分代回收:新生代对象生命周期短,采用"标记-复制"算法(回收快),老年代对象生命周期长,采用"标记-整理"算法(减少内存碎片)。
三、开发者操作与责任
  1. iOS 开发者的核心责任

    • 避免循环引用:这是 ARC 下最主要的内存泄漏原因,需使用 weak/unowned 修饰 delegate、block 捕获的变量,或手动断开循环引用;
    • 管理非引用类型内存:C 语言 malloc/calloc 分配的内存需手动 free,Core Foundation 框架的对象(如 CFStringRef)需手动 CFRelease
    • 优化内存峰值:大量临时对象需手动创建 autoreleasepool 分批释放,避免内存峰值过高触发系统内存警告;
    • 监控内存泄漏:使用 Instruments 的 Leaks 工具、MLeaksFinder 第三方库排查循环引用、未释放的 Core Foundation 对象等泄漏问题。
  2. Android 开发者的核心责任

    • 避免内存泄漏:常见原因包括静态引用Activity/Context、未取消的监听器(如 BroadcastReceiver)、线程未终止、资源未关闭(如数据库连接、文件流);
    • 优化 GC 性能:减少短期对象创建(避免频繁触发新生代 GC)、避免大对象分配(减少老年代 GC 频率)、合理使用软引用/弱引用(如图片缓存用软引用);
    • 管理大内存:通过 LargeHeap 配置申请更大堆内存,或使用 NDK 分配原生内存(不受虚拟机堆大小限制);
    • 监控 GC 状态:通过 Logcat 查看 GC 日志(如 GC_FOR_ALLOC"GC_CONCURRENT"),使用 Android Profiler 工具分析内存占用和 GC 停顿时间。
四、系统级内存管理特性
  1. 应用内存限制

    • iOS:系统对每个应用的内存占用有严格限制(因设备型号而异,如 iPhone 14 单应用内存限制约 16GB),当应用内存占用超过限制时,系统会直接终止应用(触发 didReceiveMemoryWarning 后仍未释放足够内存);
    • Android:每个应用的堆内存大小由系统动态分配(通过 dalvik.vm.heapsize 配置),默认较小(如 2GB 内存设备默认堆大小约 256MB),可通过 android:largeHeap="true" 申请更大堆,但仍有上限,超出上限会抛出 OutOfMemoryError(OOM)。
  2. 后台内存管理

    • iOS:后台应用采用"冻结"机制,进入后台后大部分

iOS 中的多线程技术(pthread、NSThread、GCD、NSOperation)之间的区别是什么?

iOS 开发中提供了四种核心多线程技术,分别是底层的 pthread、面向对象的 NSThread、系统推荐的 GCD、封装完善的 NSOperation。它们在抽象层次、使用难度、功能特性、底层实现等方面存在显著差异,实际开发中需根据场景选择合适的技术。面试中需从核心定位、使用方式、功能特性、优缺点等维度全面对比,体现对多线程技术选型的理解。

一、核心定位与底层实现
技术类型 核心定位 底层实现 抽象层次
pthread 跨平台底层线程库 基于 POSIX 标准的 C 语言库,iOS 系统通过封装 POSIX 接口实现 最低(C 语言层面)
NSThread OC 面向对象封装的线程类 对 pthread 进行 OC 封装,本质仍是调用底层 POSIX 接口 中等(OC 类层面)
GCD 系统级多线程调度框架 基于内核级线程池实现,由系统管理线程生命周期,无需手动管理 较高(框架层面,隐式线程)
NSOperation 基于 GCD 的高级封装 以 GCD 为底层内核,封装为 OC 对象,支持任务依赖、优先级等高级功能 最高(对象化任务管理)
二、详细特性与使用示例
  1. pthread

    • 核心特性:跨平台(支持 iOS、Linux、Windows 等),纯 C 语言接口,功能基础,需手动管理线程创建、启动、销毁,无自动内存管理。

    • 使用方式:通过 pthread_create 创建线程,pthread_join 等待线程结束,pthread_cancel 终止线程,需手动处理线程同步(如互斥锁 pthread_mutex_t)。

    • 代码示例:

      复制代码
      #import <pthread.h>
      #import <UIKit/UIKit.h>
      
      // 线程执行函数(C 语言函数)
      void *pthreadTask(void *param) {
          NSString *taskName = (__bridge NSString *)param;
          NSLog(@"pthread 任务执行:%@,线程号:%ld", taskName, pthread_self());
          return NULL;
      }
      
      // 创建并启动 pthread 线程
      - (void)createPthread {
          pthread_t thread;
          NSString *taskParam = @"测试任务";
          // 参数:线程对象、线程属性(NULL 为默认)、执行函数、传入参数
          int result = pthread_create(&thread, NULL, pthreadTask, (__bridge void *)taskParam);
          if (result == 0) {
              // 设置线程分离(无需等待线程结束,自动释放资源)
              pthread_detach(thread);
          } else {
              NSLog(@"pthread 创建失败,错误码:%d", result);
          }
      }
    • 优缺点:

      • 优点:跨平台兼容性强,底层可控性高;
      • 缺点:使用繁琐,无 OC 语法支持,手动管理成本高,易出现内存泄漏、线程安全问题,iOS 开发中极少直接使用。
  2. NSThread

    • 核心特性:OC 面向对象封装,使用简单,支持直接操作线程对象(如启动、暂停、终止),可获取线程状态(如是否在运行),支持线程命名。

    • 使用方式:三种创建方式(实例化后调用 start、类方法 detachNewThreadSelector、NSObject 分类方法 performSelectorInBackground),线程同步需通过 @synchronizedNSLock 等实现。

    • 代码示例:

      复制代码
      // 方式 1:实例化创建
      - (void)createNSThread1 {
          NSThread *thread = [[NSThread alloc] initWithTarget:self selector:@selector(nsThreadTask:) object:@"实例化任务"];
          thread.name = @"NSThread-1"; // 设置线程名
          thread.threadPriority = 0.5; // 设置优先级(0-1,已废弃,推荐用 qualityOfService)
          thread.qualityOfService = NSQualityOfServiceUserInitiated; // 服务质量(对应优先级)
          [thread start]; // 启动线程
      }
      
      // 方式 2:类方法快速创建(自动启动)
      - (void)createNSThread2 {
          [NSThread detachNewThreadSelector:@selector(nsThreadTask:) toTarget:self withObject:@"类方法任务"];
      }
      
      // 线程执行方法
      - (void)nsThreadTask:(NSString *)taskName {
          NSLog(@"NSThread 任务执行:%@,线程名:%@,线程号:%ld", taskName, [NSThread currentThread].name, (long)[NSThread currentThread]);
          // 模拟耗时操作
          [NSThread sleepForTimeInterval:2];
      }
    • 优缺点:

      • 优点:OC 语法友好,使用简单,可直接控制线程,适合简单多线程场景;
      • 缺点:需手动管理线程生命周期,无任务依赖、队列管理功能,线程同步需手动实现,频繁创建销毁线程效率低。
  3. GCD(Grand Central Dispatch)

    • 核心特性:苹果推荐的多线程技术,基于队列和任务的模型,系统自动管理线程池(线程创建、复用、销毁),支持串行/并发队列、同步/异步执行,提供丰富的调度功能(如延迟执行、栅栏函数、信号量)。

    • 核心概念:

      • 队列(Queue):存储任务的容器,分为串行队列(Serial Queue,任务顺序执行)和并发队列(Concurrent Queue,任务并发执行);
      • 任务(Task):执行逻辑,分为同步任务(sync,阻塞当前线程,等待任务完成)和异步任务(async,不阻塞当前线程,任务后台执行);
      • 系统队列:主队列(Main Queue,串行队列,运行在主线程)、全局并发队列(Global Concurrent Queue,系统提供的并发队列,分四个优先级)。
    • 代码示例:

      复制代码
      // 1. 全局并发队列 + 异步任务(常用场景:后台耗时操作)
      - (void)gcdConcurrentAsync {
          dispatch_queue_t globalQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
          dispatch_async(globalQueue, ^{
              NSLog(@"GCD 并发异步任务执行,线程号:%ld", (long)[NSThread currentThread]);
              // 模拟耗时操作(如下载图片)
              [NSThread sleepForTimeInterval:2];
              // 回到主线程更新 UI
              dispatch_async(dispatch_get_main_queue(), ^{
                  NSLog(@"GCD 任务完成,主线程更新 UI,线程号:%ld", (long)[NSThread currentThread]);
              });
          });
      }
      
      // 2. 串行队列 + 同步任务(顺序执行,阻塞线程)
      - (void)gcdSerialSync {
          dispatch_queue_t serialQueue = dispatch_queue_create("com.test.serial", DISPATCH_QUEUE_SERIAL);
          for (int i = 0; i < 3; i++) {
              dispatch_sync(serialQueue, ^{
                  NSLog(@"GCD 串行同步任务 %d,线程号:%ld", i, (long)[NSThread currentThread]);
              });
          }
      }
    • 优缺点:

      • 优点:使用简洁,无需管理线程,系统优化高效,支持丰富的调度功能,是 iOS 开发的首选多线程技术;
      • 缺点:基于 C 语言接口,缺乏对象化管理,复杂场景(如任务依赖)需手动实现。
  4. NSOperation / NSOperationQueue

    • 核心特性:基于 GCD 的 OC 对象化封装,将任务封装为 NSOperation 对象,通过 NSOperationQueue 管理执行,支持任务依赖、优先级设置、取消任务、暂停/恢复队列等高级功能。

    • 核心概念:

      • NSOperation:任务抽象类,需使用子类(NSBlockOperation、自定义子类),支持 completionBlock 回调;
      • NSOperationQueue:队列管理类,可设置最大并发数(maxConcurrentOperationCount,1 为串行,>1 为并发),默认使用全局并发队列。
    • 代码示例:

      复制代码
      // 1. NSBlockOperation + 依赖关系
      - (void)operationWithDependency {
          // 创建任务 1
          NSBlockOperation *op1 = [NSBlockOperation blockOperationWithBlock:^{
              NSLog(@"任务 1 执行,线程号:%ld", (long)[NSThread currentThread]);
              [NSThread sleepForTimeInterval:1];
          }];
          // 创建任务 2(依赖任务 1 完成)
          NSBlockOperation *op2 = [NSBlockOperation blockOperationWithBlock:^{
              NSLog(@"任务 2 执行(依赖任务 1),线程号:%ld", (long)[NSThread currentThread]);
          }];
          // 设置依赖
          [op2 addDependency:op1];
          // 创建队列并添加任务
          NSOperationQueue *queue = [[NSOperationQueue alloc] init];
          queue.maxConcurrentOperationCount = 2; // 最大并发数 2
          [queue addOperations:@[op1, op2] waitUntilFinished:NO];
          // 任务完成回调
          op2.completionBlock = ^{
              NSLog(@"任务 2 完成,回到主线程更新 UI");
              dispatch_async(dispatch_get_main_queue(), ^{
                  // 更新 UI 逻辑
              });
          };
      }
      
      // 2. 自定义 NSOperation 子类
      @interface CustomOperation : NSOperation
      @end
      
      @implementation CustomOperation
      - (void)main {
          if (!self.isCancelled) { // 检查是否被取消
              NSLog(@"自定义任务执行,线程号:%ld", (long)[NSThread currentThread]);
          }
      }
      @end
      
      // 使用自定义任务
      - (void)customOperation {
          CustomOperation *op = [[CustomOperation alloc] init];
          NSOperationQueue *queue = [[NSOperationQueue alloc] init];
          [queue addOperation:op];
      }
    • 优缺点:

      • 优点:对象化管理,支持任务依赖、取消、暂停等高级功能,易维护,适合复杂多线程场景(如多任务顺序执行、批量任务管理);
      • 缺点:封装层次高,轻微性能开销(相对于 GCD),简单场景下使用成本略高。
三、核心区别总结(补充维度)
对比维度 pthread NSThread GCD NSOperation
线程管理 手动管理(创建/销毁) 手动管理(部分自动) 系统自动管理(线程池) 系统自动管理(基于 GCD)
任务管理 无任务概念,仅线程执行函数 无任务队列,单线程单任务 队列+任务模型,支持批量任务 对象化任务,支持依赖、优先级
线程同步 需用 pthread 互斥锁 需用 @synchronized/NSLock 信号量、栅栏函数、dispatch_barrier 自带依赖管理,支持 NSLock
取消任务 手动调用 pthread_cancel 调用 cancel 方法(需手动处理) 无法直接取消已执行任务 支持 cancel 方法,可检查 isCancelled
适用场景 跨平台开发、底层开发 简单多线程场景(少量线程) 绝大多数场景(推荐首选) 复杂任务管理(依赖、批量任务)
学习成本 中高
面试加分点
  • 能明确"GCD 是系统级调度,NSOperation 是基于 GCD 的封装"的底层关系;
  • 能结合实际场景说明选型逻辑(如简单后台任务用 GCD 异步,复杂依赖任务用 NSOperation);
  • 能指出各技术的线程安全问题及解决方案(如 pthread 用互斥锁,GCD 用信号量);
  • 了解 NSOperation 的底层优化(如任务状态管理、依赖循环检测)。
记忆法
  • 层次记忆法:按"底层→高层"排序(pthread → NSThread → GCD → NSOperation),每层记住核心特点(底层手动、中层简单、高层封装),形成技术栈层次认知;
  • 场景绑定记忆法:将技术与场景绑定("跨平台用 pthread""简单任务用 NSThread""常规场景用 GCD""复杂依赖用 NSOperation"),通过场景快速定位合适技术。

什么是 iOS 的线程安全?如何解决 "卖票问题" 这类线程安全问题?

线程安全是多线程开发的核心概念,直接影响应用的稳定性和数据准确性。"卖票问题"是线程安全问题的典型场景(多个线程同时操作共享资源,导致超卖、重复售票等异常),面试中需先明确线程安全的定义,再结合 iOS 中的解决方案,通过代码示例演示如何解决该问题,体现对多线程同步机制的理解。

一、什么是 iOS 的线程安全?

线程安全的核心定义是:当多个线程同时访问共享资源(如全局变量、实例属性、数据库、文件)时,无论线程的执行顺序如何,都能保证最终结果的正确性,且不会出现数据竞争、死锁、资源损坏等异常

  1. 线程不安全的本质原因

    • 共享资源竞争:多个线程对同一资源进行"读-改-写"操作,且操作不是原子性的(原子性指操作不可分割,要么全部完成,要么全部不执行);
    • 指令重排序:编译器或 CPU 为优化性能,会对非原子操作的指令进行重排序,导致多线程执行顺序与预期不一致;
    • 缓存可见性:多线程中,每个线程有独立的工作内存,共享资源的修改可能未及时同步到主内存,导致其他线程读取到旧值。
  2. iOS 中常见的线程不安全场景

    • 多线程修改同一数组(如同时添加/删除元素,导致数组越界或数据错乱);
    • 多线程读写同一属性(如计数器自增 count++,实际是 load-count → increment → store-count 三步,非原子操作);
    • 多线程操作文件/数据库(如同时写入文件,导致文件内容损坏);
    • 经典的"卖票问题":假设 100 张票,3 个线程同时售票,未做同步处理会出现超卖(卖出票数>100)、重复售票(同一票号被多次卖出)等问题。
二、解决线程安全问题的核心思路

解决线程安全的核心是"保证共享资源的原子操作"和"控制线程访问顺序",本质是通过"同步机制"让多个线程对共享资源的访问变为"串行执行",避免同时操作。iOS 中提供了多种线程同步方案,需根据场景选择合适的方式。

三、iOS 中解决线程安全问题的具体方案(结合卖票问题)

以"卖票问题"为案例(100 张票,3 个线程同时售票,每次卖 1 张,需保证无超卖、无重复),演示各方案的实现:

  1. 方案 1:使用 @synchronized 同步块(简单易用)

    • 核心原理:@synchronized 是 OC 提供的轻量级同步机制,底层基于 pthread 互斥锁(mutex),通过指定"锁对象"实现临界区(共享资源操作代码块)的串行执行。

    • 核心特点:使用简单,无需手动创建/释放锁,锁对象通常用 self 或全局静态对象(避免锁对象被释放),但性能略差(每次进入同步块需查找锁对象)。

    • 代码示例(解决卖票问题):

      复制代码
      @interface TicketSeller : NSObject
      @property (nonatomic, assign) NSInteger ticketCount; // 共享资源:剩余票数
      @end
      
      @implementation TicketSeller
      - (instancetype)init {
          if (self = [super init]) {
              self.ticketCount = 100; // 初始 100 张票
          }
          return self;
      }
      
      // 售票方法(线程安全版)
      - (void)sellTicketWithName:(NSString *)threadName {
          while (YES) {
              // 同步块:锁对象用 self(需确保锁对象唯一且不被释放)
              @synchronized (self) {
                  if (self.ticketCount > 0) {
                      // 模拟售票耗时(如网络请求、数据库操作)
                      [NSThread sleepForTimeInterval:0.01];
                      self.ticketCount--;
                      NSLog(@"线程 %@ 卖出 1 张票,剩余票数:%ld", threadName, (long)self.ticketCount);
                  } else {
                      NSLog(@"线程 %@ 售票结束,无剩余票数", threadName);
                      break;
                  }
              }
              // 让出 CPU 时间片,让其他线程有机会执行(优化并发效率)
              [NSThread yield];
          }
      }
      @end
      
      // 测试代码:创建 3 个线程售票
      - (void)testTicketSelling {
          TicketSeller *seller = [[TicketSeller alloc] init];
          // 线程 1
          [NSThread detachNewThreadSelector:@selector(sellTicketWithName:) toTarget:seller withObject:@"售票窗口 1"];
          // 线程 2
          [NSThread detachNewThreadSelector:@selector(sellTicketWithName:) toTarget:seller withObject:@"售票窗口 2"];
          // 线程 3
          [NSThread detachNewThreadSelector:@selector(sellTicketWithName:) toTarget:seller withObject:@"售票窗口 3"];
      }
    • 注意事项:锁对象必须是同一实例(如上述 self),若使用不同对象,会导致锁失效;避免在同步块内执行耗时操作(如网络请求),否则会阻塞其他线程。

  2. 方案 2:使用 NSLock 锁(性能优于 @synchronized)

    • 核心原理:NSLock 是 OC 提供的面向对象锁,底层封装了 pthread 互斥锁,支持 lock(加锁)、unlock(解锁)、tryLock(尝试加锁,失败返回 NO)、lockBeforeDate:(指定时间前尝试加锁)等方法,性能比 @synchronized 高。

    • 核心特点:需手动管理加锁/解锁,必须保证"加锁后一定解锁"(否则会导致死锁),适合对性能有要求的场景。

    • 代码示例(修改卖票方法):

      复制代码
      @interface TicketSeller ()
      @property (nonatomic, strong) NSLock *ticketLock; // 锁对象
      @end
      
      @implementation TicketSeller
      - (instancetype)init {
          if (self = [super init]) {
              self.ticketCount = 100;
              self.ticketLock = [[NSLock alloc] init]; // 初始化锁
          }
          return self;
      }
      
      - (void)sellTicketWithName:(NSString *)threadName {
          while (YES) {
              // 加锁(若锁已被占用,当前线程阻塞)
              BOOL lockSuccess = [self.ticketLock lockBeforeDate:[NSDate dateWithTimeIntervalSinceNow:1.0]];
              if (!lockSuccess) {
                  NSLog(@"线程 %@ 加锁失败,跳过本次售票", threadName);
                  continue;
              }
              // 临界区:操作共享资源
              if (self.ticketCount > 0) {
                  [NSThread sleepForTimeInterval:0.01];
                  self.ticketCount--;
                  NSLog(@"线程 %@ 卖出 1 张票,剩余票数:%ld", threadName, (long)self.ticketCount);
              } else {
                  NSLog(@"线程 %@ 售票结束,无剩余票数", threadName);
                  [self.ticketLock unlock]; // 解锁后退出
                  break;
              }
              // 解锁(必须执行,否则锁会一直被占用)
              [self.ticketLock unlock];
              [NSThread yield];
          }
      }
      @end
    • 注意事项:避免"加锁后未解锁"(如临界区抛出异常),可使用 @try...@finally 确保解锁:

      复制代码
      @try {
          [self.ticketLock lock];
          // 临界区操作
      } @finally {
          [self.ticketLock unlock]; // 无论是否异常,都解锁
      }
  3. 方案 3:使用 GCD 信号量(dispatch_semaphore_t)

    • 核心原理:信号量是一种同步原语,通过计数器控制线程访问权限。dispatch_semaphore_create(1) 创建信号量(初始值为 1,即互斥锁),dispatch_semaphore_wait 使计数器减 1(若计数器为 0,线程阻塞),dispatch_semaphore_signal 使计数器加 1(唤醒阻塞线程)。

    • 核心特点:基于 GCD,性能高效,支持跨队列同步,不仅可用于线程安全,还可控制并发数(如初始值为 5 则支持 5 个线程并发)。

    • 代码示例(修改卖票方法):

      复制代码
      @interface TicketSeller ()
      @property (nonatomic, assign) dispatch_semaphore_t ticketSemaphore; // 信号量
      @end
      
      @implementation TicketSeller
      - (instancetype)init {
          if (self = [super init]) {
              self.ticketCount = 100;
              // 创建信号量,初始值为 1(互斥锁效果)
              self.ticketSemaphore = dispatch_semaphore_create(1);
          }
          return self;
      }
      
      - (void)sellTicketWithName:(NSString *)threadName {
          while (YES) {
              // 信号量减 1(加锁),超时时间 1 秒
              long result = dispatch_semaphore_wait(self.ticketSemaphore, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC * 1));
              if (result != 0) {
                  NSLog(@"线程 %@ 信号量等待超时", threadName);
                  continue;
              }
              // 临界区操作
              if (self.ticketCount > 0) {
                  [NSThread sleepForTimeInterval:0.01];
                  self.ticketCount--;
                  NSLog(@"线程 %@ 卖出 1 张票,剩余票数:%ld", threadName, (long)self.ticketCount);
              } else {
                  NSLog(@"线程 %@ 售票结束,无剩余票数", threadName);
                  dispatch_semaphore_signal(self.ticketSemaphore); // 解锁
                  break;
              }
              // 信号量加 1(解锁)
              dispatch_semaphore_signal(self.ticketSemaphore);
              [NSThread yield];
          }
      }
      @end
    • 扩展场景:若需支持 2 个线程同时售票(并发数为 2),只需将信号量初始值设为 2:dispatch_semaphore_create(2),适合需要控制并发度的场景。

  4. 方案 4:使用串行队列(GCD/NSOperationQueue)

    • 核心原理:将所有对共享资源的操作放入串行队列,通过队列的"顺序执行"特性,保证临界区操作的原子性,避免多线程竞争。

    • 核心特点:无需手动加锁,通过队列调度实现同步,代码简洁,适合批量处理共享资源操作。

    • 代码示例(GCD 串行队列):

      复制代码
      @interface TicketSeller ()
      @property (nonatomic, assign) dispatch_queue_t ticketSerialQueue; // 串行队列
      @end
      
      @implementation TicketSeller
      - (instancetype)init {
          if (self = [super init]) {
              self.ticketCount = 100;
              // 创建串行队列
              self.ticketSerialQueue = dispatch_queue_create("com.test.ticket.serial", DISPATCH_QUEUE_SERIAL);
          }
          return self;
      }
      
      - (void)sellTicketWithName:(NSString *)threadName {
          // 异步提交任务到串行队列(队列内任务顺序执行)
          dispatch_async(self.ticketSerialQueue, ^{
              while (YES) {
                  if (self.ticketCount > 0) {
                      [NSThread sleepForTimeInterval:0.01];
                      self.ticketCount--;
                      NSLog(@"线程 %@ 卖出 1 张票,剩余票数:%ld", threadName, (long)self.ticketCount);
                  } else {
                      NSLog(@"线程 %@ 售票结束,无剩余票数", threadName);
                      break;
                  }
                  [NSThread yield];
              }
          });
      }
    • 注意事项:需确保所有对 ticketCount 的操作都通过该串行队列执行,否则仍会出现线程安全问题。

四、各方案的对比与选型建议
方案 优点 缺点 适用场景
@synchronized 简单易用,无需手动管理锁 性能一般,锁对象易出错 简单场景,对性能要求低
NSLock 性能优于 @synchronized,面向对象 需手动加锁/解锁,易死锁 对性能有要求,需灵活控制锁
信号量 性能高效,支持并发数控制 需手动管理信号量增减 需控制并发度,跨队列同步
串行队列 无需加锁,代码简洁 仅适合批量操作,灵活性低 共享资源操作集中,无需并发
面试加分点
  • 能解释线程安全的底层原因(原子性、可见性、有序性);
  • 能区分"互斥锁"(保证同一时间只有一个线程访问)和"信号量"(支持多个线程同时访问)的差异;
  • 能指出死锁的产生条件(资源互斥、持有并等待、不可剥夺、循环等待)及避免方法;
  • 了解原子属性(atomic)的局限性:仅保证属性读写的原子性,不保证业务逻辑的线程安全(如 self.count++ 仍需同步)。
记忆法
  • 核心思路记忆法:"线程安全=共享资源+原子操作",解决问题的本质是"让共享资源操作原子化",所有方案都是围绕这一核心,只是实现方式不同;
  • 方案特性记忆法:给每个方案贴标签("@synchronized 简单""NSLock 高效""信号量控并发""串行队列无锁"),通过标签快速匹配场景,记住各方案的核心优势。

NSTimer 的执行是否准确?RunLoop 的模式有哪些?

NSTimer 是 iOS 中常用的定时执行工具,其执行准确性直接影响功能可靠性;而 RunLoop 作为 iOS 线程的核心调度机制,不仅决定了 NSTimer 的执行时机,其模式设计也影响着线程的事件处理逻辑。面试中需先明确 NSTimer 准确性的影响因素,再详细说明 RunLoop 的核心模式及作用,体现对 iOS 线程调度底层逻辑的理解。

一、NSTimer 的执行是否准确?

NSTimer 的执行并非绝对准确,而是"相对准确",其准确性受 RunLoop 运行状态、任务优先级、线程负载等多种因素影响,核心原因在于 NSTimer 依赖 RunLoop 的调度机制。

  1. NSTimer 的工作原理

    • NSTimer 本质是"RunLoop 事件源(Source)",创建后需添加到 RunLoop 中才能生效(默认添加到当前线程的 RunLoop,模式为 NSDefaultRunLoopMode);
    • 当 NSTimer 到达触发时间时,RunLoop 会将其对应的任务(selectorblock)加入执行队列,等待当前正在执行的任务完成后再执行;
    • NSTimer 的"触发时间"是"期望执行时间",而非"强制执行时间",若 RunLoop 被阻塞或处于非目标模式,Timer 会延迟执行。
  2. 影响 NSTimer 准确性的核心因素

    • 因素 1:RunLoop 被阻塞(最主要原因)RunLoop 同一时间只能执行一个任务,若当前线程的 RunLoop 正在执行耗时操作(如循环计算、网络请求、复杂 UI 绘制),Timer 任务会被阻塞,直到当前任务完成后才会执行,导致延迟。示例:

      复制代码
      // 主线程创建 Timer,触发间隔 1 秒
      self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(timerTask) userInfo:nil repeats:YES];
      
      // 主线程执行耗时操作(阻塞 RunLoop)
      - (void)longTimeTask {
          // 模拟 3 秒耗时

iOS 的事件传递和事件响应机制是什么?

iOS 的事件传递和响应机制是处理用户交互(如点击、滑动、长按)的核心逻辑,分为 "事件传递" 和 "事件响应" 两个阶段:事件传递阶段负责找到能处理事件的目标对象(hit-test view),事件响应阶段负责该对象及其父视图链对事件的处理逻辑。理解这一机制能帮助开发者解决手势冲突、点击失效等常见问题,面试中需从事件类型、传递流程、响应流程、关键方法等维度全面阐述。

一、先明确:iOS 中的事件类型

用户交互产生的事件主要分为三类,均遵循相同的传递和响应逻辑:

  1. 触摸事件(Touch Events):最常见,如点击(single tap)、滑动(pan)、长按(long press)、缩放(pinch)等;
  2. 运动事件(Motion Events):基于设备运动,如摇一摇(shake);
  3. 远程控制事件(Remote Control Events):如耳机线控、蓝牙设备控制(播放 / 暂停音乐)。

核心讲解以最常用的触摸事件为例,其他事件流程类似。

二、第一阶段:事件传递(寻找 Hit-Test View)

事件传递的核心目标是:从屏幕最顶层的 UIWindow 开始,向下遍历视图层级,找到 "用户实际点击的视图"(即 hit-test view),该视图是事件的第一响应者候选。

  1. 传递的起点与核心规则

    • 起点:用户触摸屏幕时,系统会生成 UIEvent 对象(包含触摸位置、事件类型等信息),并将其传递给当前活跃的 UIWindow
    • 核心规则:从上到下、从父到子遍历视图树,通过 "命中测试" 判断视图是否能接收事件,最终找到最底层的可接收事件的视图。
  2. 命中测试的关键方法 事件传递过程依赖两个核心方法(均定义在 UIView 中),开发者可重写这两个方法自定义传递逻辑:

    • + (BOOL)pointInside:(CGPoint)point withEvent:(nullable UIEvent *)event
      • 作用:判断触摸点(point,相对于当前视图的坐标)是否在当前视图的 bounds 内;
      • 返回值:YES 表示触摸点在视图内,继续向下遍历子视图;NO 表示不在,当前视图及子视图均不参与事件处理,返回父视图继续判断。
    • - (nullable UIView *)hitTest:(CGPoint)point withEvent:(nullable UIEvent *)event
      • 作用:递归执行命中测试,返回最终的 hit-test view;
      • 内部逻辑(默认实现):
        1. 若视图不可交互(userInteractionEnabled = NO)、隐藏(hidden = YES)、透明度低于 0.01(alpha < 0.01),直接返回 nil
        2. 调用 pointInside:withEvent:,若返回 NO,返回 nil
        3. 逆序遍历当前视图的子视图(从最上层子视图开始),将触摸点转换为子视图的本地坐标(convertPoint:toView:);
        4. 递归调用子视图的 hitTest:withEvent:,若子视图返回非 nil(找到 hit-test view),则直接返回该子视图;
        5. 若所有子视图均返回 nil,则当前视图即为 hit-test view,返回自身。
  3. 传递流程示例(直观理解) 假设视图层级为:UIWindow → UIViewController.view(A) → 子视图 B → 子视图 C(最底层),用户点击 C:

    1. 系统将事件传递给 UIWindowUIWindow 调用自身 hitTest:,判断触摸点在自身 bounds 内,开始遍历子视图(A);
    2. UIWindow 将触摸点转换为 A 的本地坐标,调用 A 的 hitTest:,A 满足交互条件且触摸点在 bounds 内,遍历子视图(B);
    3. A 将触摸点转换为 B 的本地坐标,调用 B 的 hitTest:,B 满足条件且触摸点在 bounds 内,遍历子视图(C);
    4. B 将触摸点转换为 C 的本地坐标,调用 C 的 hitTest:,C 满足条件且触摸点在 bounds 内,无更多子视图,返回 C;
    5. 递归回溯,B 返回 C,A 返回 C,UIWindow 返回 C,最终 C 成为 hit-test view(事件的目标对象)。
三、第二阶段:事件响应(Responder Chain 响应链)

事件传递找到 hit-test view 后,进入响应阶段:事件从 hit-test view 开始,沿着 "响应链"(Responder Chain)向上传递,直到找到能处理该事件的响应者(Responder),若所有响应者均不处理,事件最终被丢弃。

  1. 响应者(Responder)与响应链定义

    • 响应者:所有继承自 UIResponder 的对象(如 UIViewUIViewControllerUIWindowUIApplication),均具备处理事件的能力;
    • 响应链:由多个响应者组成的链式结构,事件沿该链向上传递,核心是 nextResponder(下一个响应者)属性,每个响应者都知道自己的下一个响应者是谁。
  2. nextResponder 的默认规则(响应链结构) 响应链的传递顺序由 nextResponder 决定,默认规则如下:

    • 若当前响应者是视图(UIView):
      1. 若视图是 UIViewController 的根视图(view 属性),则 nextResponder 是该 UIViewController
      2. 否则,nextResponder 是视图的父视图(superview);
    • 若当前响应者是 UIViewController
      1. 若视图控制器是被模态推出的(modal),则 nextResponder 是推出它的视图控制器;
      2. 否则,nextResponder 是视图控制器的根视图的父视图(通常是 UIWindow);
    • 若当前响应者是 UIWindownextResponderUIApplication
    • 若当前响应者是 UIApplicationnextResponderUIApplicationDelegate(若代理继承自 UIResponder)。
  3. 事件响应的流程以触摸事件为例,响应流程如下:

    1. 事件传递找到 hit-test view(如上述示例中的 C),系统首先将事件传递给 C,调用 C 的 touchesBegan:withEvent: 等触摸方法;
    2. 若 C 重写了该方法且未调用 [super touchesBegan:withEvent:event],表示 C 处理了事件,响应流程结束;
    3. 若 C 未重写该方法,或重写后调用了 super,则事件通过 nextResponder 传递给 C 的父视图 B;
    4. 重复步骤 2-3,事件依次传递给 A → UIViewController → UIWindow → UIApplication → UIApplicationDelegate;
    5. 若所有响应者均未处理(均调用 super 或未重写),事件最终被丢弃。
  4. **响应流程示例(延续传递示例)**视图 C 是 hit-test view,响应流程:

    • C 收到事件,若 C 重写 touchesBegan:withEvent: 且未调用 super → 事件处理完毕;
    • 若 C 调用 super → 事件传递给 B(C 的 nextResponder);
    • B 若未处理 → 传递给 A;
    • A 若未处理 → 传递给 UIViewController(A 是根视图);
    • 视图控制器若未处理 → 传递给 UIWindow;
    • UIWindow 若未处理 → 传递给 UIApplication;
    • UIApplication 若未处理 → 传递给 AppDelegate;
    • AppDelegate 若未处理 → 事件丢弃。
四、常见应用场景与面试考点
  1. 场景 1:点击失效问题排查

    • 原因可能包括:视图 userInteractionEnabled = NOhidden = YESalpha < 0.01;触摸点不在视图 bounds 内;父视图拦截事件(重写 hitTest: 返回 nil 或其他视图);
    • 排查方法:重写 hitTest:pointInside: 打印日志,确认事件传递是否到达目标视图。
  2. 场景 2:手势冲突解决

    • 例如:UITableViewdidSelectRowAtIndexPath 与单元格内按钮的点击冲突;
    • 解决思路:利用事件传递机制,重写父视图的 hitTest: 优先让子视图(按钮)成为 hit-test view;或利用手势的 delegate 方法(gestureRecognizer:shouldReceiveTouch:)控制手势是否响应。
  3. 场景 3:自定义响应链

    • 需求:让非父视图的响应者处理事件(如让视图控制器的兄弟视图处理);
    • 实现方式:重写视图的 nextResponder 方法,返回自定义的响应者(如 return self.customResponder)。
面试加分点
  • 能清晰区分 "事件传递"(从上到下找目标)和 "事件响应"(从下到上找处理者)的方向差异;
  • 能重写 hitTest:pointInside: 实现自定义传递逻辑(如扩大视图的点击区域);
  • 能解释 "为什么 alpha < 0.01 的视图不能接收事件"(系统底层优化,视为不可交互);
  • 了解 UIGestureRecognizer 对事件传递的影响(手势识别会先拦截事件,若识别成功则不会传递给视图的触摸方法)。
记忆法
  • 流程口诀记忆法:"传递从上到下(找目标),响应从下到上(找处理);传递靠 hitTest/pointInside,响应靠 nextResponder 链",通过口诀快速记住两个阶段的核心逻辑;
  • 结构联想记忆法:将传递流程联想为 "警察找人"(从全局到局部,最终找到目标),将响应流程联想为 "上报问题"(从基层到上级,找到能解决问题的人),通过具象化场景加深记忆。

UITableView 如何进行性能优化?

UITableView 是 iOS 开发中最常用的列表控件,其性能直接影响应用的流畅度(尤其是大数据量列表)。性能优化的核心目标是减少 CPU 计算压力、降低内存占用、减少视图层级和绘制开销,最终实现列表滑动时帧率稳定在 60fps(每帧约 16.67ms)。面试中需从数据处理、单元格复用、视图优化、绘制优化、滑动体验等维度全面阐述,结合具体实现方案和代码示例。

一、核心优化方向 1:单元格复用(最基础且关键)

UITableView 的默认复用机制是性能优化的基础,但开发者常因不当使用导致复用混乱或性能损耗,需掌握正确的复用方式和进阶优化。

  1. 正确使用默认复用机制

    • 核心原理:UITableView 仅创建 "屏幕可见数量 + 1~2" 个单元格(UITableViewCell),滑动时回收不可见单元格,复用给新出现的行,避免频繁创建和销毁视图(CPU 密集型操作);

    • 正确用法:注册单元格类或 xib,通过 dequeueReusableCellWithIdentifier:forIndexPath: 复用(而非 dequeueReusableCellWithIdentifier:,后者可能返回 nil);

    • 代码示例:

      复制代码
      // 1. 注册单元格(在 viewDidLoad 中)
      [self.tableView registerClass:[CustomCell class] forCellReuseIdentifier:@"CustomCellID"];
      // 或注册 xib
      // [self.tableView registerNib:[UINib nibWithNibName:@"CustomCell" bundle:nil] forCellReuseIdentifier:@"CustomCellID"];
      
      // 2. 复用单元格(在 cellForRowAtIndexPath 中)
      - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
          CustomCell *cell = [tableView dequeueReusableCellWithIdentifier:@"CustomCellID" forIndexPath:indexPath];
          // 配置单元格数据(需重置状态,避免复用混乱)
          [self configureCell:cell withData:self.dataArray[indexPath.row]];
          return cell;
      }
    • 关键注意:复用单元格时必须 "重置所有状态"(如文字、图片、选中状态、子视图显示隐藏),避免前一行的状态残留(如复用后图片显示错误、文字重叠)。

  2. 进阶复用优化:分区复用 + 多类型单元格复用

    • 分区复用:若列表有多个分区(section),且不同分区的单元格布局差异大,可为每个分区设置独立的复用标识(如 @"CellID_Section0"@"CellID_Section1"),避免跨分区复用导致的布局混乱和数据重置开销;

    • 多类型单元格:若同一分区有多种类型单元格(如朋友圈列表的文字、图片、视频单元格),需为每种类型设置独立复用标识,注册对应类 /xib,在 cellForRowAtIndexPath 中根据数据类型复用对应单元格:

      复制代码
      // 注册多种类型单元格
      [self.tableView registerClass:[TextCell class] forCellReuseIdentifier:@"TextCellID"];
      [self.tableView registerClass:[ImageCell class] forCellReuseIdentifier:@"ImageCellID"];
      
      - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
          DataModel *model = self.dataArray[indexPath.row];
          if (model.type == DataModelTypeText) {
              TextCell *cell = [tableView dequeueReusableCellWithIdentifier:@"TextCellID" forIndexPath:indexPath];
              [self configureTextCell:cell withModel:model];
              return cell;
          } else if (model.type == DataModelTypeImage) {
              ImageCell *cell = [tableView dequeueReusableCellWithIdentifier:@"ImageCellID" forIndexPath:indexPath];
              [self configureImageCell:cell withModel:model];
              return cell;
          }
          return [UITableViewCell new];
      }
二、核心优化方向 2:数据处理与预加载

数据处理是列表滑动卡顿的常见原因(如在 cellForRowAtIndexPath 中执行耗时操作),需提前处理数据、预加载内容,避免滑动时阻塞主线程。

  1. 提前处理数据,避免滑动时计算

    • 禁止在 cellForRowAtIndexPath 中执行耗时操作(如字符串拼接、日期格式化、数据转换),这些操作应在数据加载时(如网络请求回调、本地数据读取后)提前处理,存储到数据模型中;

    • 示例:日期格式化是耗时操作,提前格式化后存储:

      复制代码
      // 错误做法:在 cellForRowAtIndexPath 中格式化
      - (void)configureCell:(CustomCell *)cell withModel:(DataModel *)model {
          NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
          formatter.dateFormat = @"yyyy-MM-dd";
          cell.timeLabel.text = [formatter stringFromDate:model.date]; // 滑动时频繁创建 formatter,耗时
      }
      
      // 正确做法:数据模型初始化时提前格式化
      @interface DataModel : NSObject
      @property (nonatomic, copy) NSString *formattedTime; // 提前格式化后的时间字符串
      @property (nonatomic, strong) NSDate *date;
      @end
      
      @implementation DataModel
      - (instancetype)initWithDate:(NSDate *)date {
          if (self = [super init]) {
              self.date = date;
              // 提前格式化
              NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
              formatter.dateFormat = @"yyyy-MM-dd";
              self.formattedTime = [formatter stringFromDate:date];
          }
          return self;
      }
      @end
      
      // cell 配置时直接使用
      - (void)configureCell:(CustomCell *)cell withModel:(DataModel *)model {
          cell.timeLabel.text = model.formattedTime; // 无计算开销
      }
  2. 分页加载与预加载

    • 分页加载:大数据量列表(如千条以上数据)需分页加载(如每次加载 20 条),避免一次性加载所有数据导致内存暴涨和初始化卡顿;

    • 预加载:当列表滑动到 "倒数第 N 行" 时,提前请求下一页数据(如滑动到倒数第 5 行时加载下一页),避免用户滑动到末尾时出现空白等待;

    • 代码示例(预加载逻辑):

      复制代码
      - (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath {
          // 当滑动到倒数第 5 行,且未正在加载下一页时,触发预加载
          if (indexPath.row == self.dataArray.count - 5 && !self.isLoadingNextPage) {
              self.isLoadingNextPage = YES;
              [self loadNextPageData]; // 异步请求下一页数据
          }
      }
      
      // 数据请求成功后更新
      - (void)loadNextPageDataSuccess:(NSArray *)newData {
          [self.dataArray addObjectsFromArray:newData];
          [self.tableView reloadData];
          self.isLoadingNextPage = NO;
      }
三、核心优化方向 3:视图层级与绘制优化

单元格的视图层级过多、绘制操作频繁会导致 CPU 绘制压力增大,需简化视图结构、减少绘制开销。

  1. 简化单元格视图层级

    • 尽量使用 "单层子视图" 或 "少层级视图",避免嵌套过多(如 UIView → UIView → UILabel 可简化为直接在 cell.contentView 上添加 UILabel);
    • 优先使用 CALayer 替代 UIView:若仅需显示静态内容(如纯色背景、边框),使用 CALayer (如 cell.contentView.layer.backgroundColor),避免 UIView 的额外开销(UIView 本质是 CALayer 的封装,多一层管理成本);
    • 避免使用 UIWebView/WKWebView 加载简单文本:WebView 初始化和绘制开销极大,简单文本用 UILabelUITextView 替代,复杂富文本用 NSAttributedString 实现。
  2. 减少绘制操作(避免 drawRect: 重写)

    • 禁止重写 drawRect: 做自定义绘制,除非必须(如复杂图形):drawRect: 是 CPU 密集型操作,频繁调用会导致卡顿;
    • 替代方案:使用图片替代自定义绘制(如复杂图标提前切图),或使用 CAShapeLayer 绘制(GPU 加速,性能优于 drawRect:);
    • 若必须重写 drawRect::避免在方法内创建对象(如 UIFontUIColor),应提前缓存;同时调用 setNeedsDisplay 时需谨慎,避免频繁触发重绘。
  3. 设置视图的 opaque 属性

    • 对于不透明的视图(如背景色为纯色的 UILabel),设置 opaque = YES(默认 NO):系统绘制时会跳过该视图下方的内容绘制,减少混合(blending)开销,提升绘制效率;

    • 代码示例:

      复制代码
      - (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
          if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
              UILabel *titleLabel = [[UILabel alloc] init];
              titleLabel.opaque = YES; // 设置不透明
              titleLabel.backgroundColor = [UIColor whiteColor];
              [self.contentView addSubview:titleLabel];
              self.titleLabel = titleLabel;
          }
          return self;
      }
四、核心优化方向 4:图片加载与内存优化

图片是列表中内存占用的主要来源,图片加载不当会导致内存暴涨、滑动卡顿,需从加载、缓存、压缩三个维度优化。

  1. 异步加载图片,避免阻塞主线程

    • 禁止在 cellForRowAtIndexPath 中同步加载图片(如 [UIImage imageWithContentsOfFile:]),需使用异步加载(如 SDWebImageKingfisher 框架,或自定义异步加载);

    • 关键注意:图片加载完成后需判断单元格是否已复用(避免图片加载完成后单元格已滑动到其他行,导致图片显示错误);

    • 代码示例(SDWebImage 异步加载,避免复用问题):

      复制代码
      - (void)configureImageCell:(ImageCell *)cell withModel:(DataModel *)model {
          // 存储当前 indexPath,用于后续判断
          cell.currentIndexPath = indexPath;
          // 异步加载图片,设置占位图
          [cell.imageView sd_setImageWithURL:model.imageURL placeholderImage:[UIImage imageNamed:@"placeholder"] completed:^(UIImage *image, NSError *error, SDImageCacheType cacheType, NSURL *imageURL) {
              // 图片加载完成后,判断单元格是否已复用(currentIndexPath 是否与当前显示的 indexPath 一致)
              if (![cell.currentIndexPath isEqual:self.tableView.indexPathForCell:cell]) {
                  return; // 已复用,不设置图片
              }
              cell.imageView.image = image;
          }];
      }
  2. 图片压缩与尺寸适配

    • 加载图片时按单元格图片视图的尺寸压缩图片:避免加载过大尺寸的图片(如 2000x2000 像素的图片显示在 100x100 像素的视图中),导致内存浪费和缩放开销;

    • 示例(SDWebImage 压缩图片):

      复制代码
      // 自定义图片下载器,设置图片压缩尺寸
      SDWebImageDownloader *downloader = [SDWebImageDownloader sharedDownloader];
      downloader.shouldDecompressImages = YES; // 开启自动解压(避免图片显示时解压阻塞主线程)
      
      // 加载时指定目标尺寸
      [cell.imageView sd_setImageWithURL:model.imageURL placeholderImage:nil options:SDWebImageProgressiveLoad progress:nil completed:^(UIImage *image, NSError *error, SDImageCacheType cacheType, NSURL *imageURL) {
          if (image && !error) {
              // 按 imageView 尺寸压缩图片
              UIImage *scaledImage = [self scaleImage:image toSize:cell.imageView.bounds.size];
              cell.imageView.image = scaledImage;
          }
      }];
      
      // 图片压缩方法
      - (UIImage *)scaleImage:(UIImage *)image toSize:(CGSize)targetSize {
          UIGraphicsBeginImageContextWithOptions(targetSize, YES, [UIScreen mainScreen].scale);
          [image drawInRect:CGRectMake(0, 0, targetSize.width, targetSize.height)];
          UIImage *scaledImage = UIGraphicsGetImageFromCurrentImageContext();
          UIGraphicsEndImageContext();
          return scaledImage;
      }
  3. 图片缓存策略优化

    • 使用框架的缓存机制(如 SDWebImage 的内存缓存 + 磁盘缓存),避免重复下载;
    • 限制内存缓存大小:避免图片缓存过多导致内存暴涨,可设置 SDWebImage 的内存缓存最大尺寸和过期时间;
    • 滑动时暂停图片加载:列表快速滑动时,暂停不可见单元格的图片加载,滑动停止后再恢复,减少 CPU/GPU 压力(SDWebImage 支持 SDWebImageAvoidAutoSetImage 选项实现)。
五、核心优化方向 5:滑动体验与其他优化
  1. 关闭单元格的 clipsToBoundsmasksToBounds(如需圆角)

    • clipsToBounds = YESmasksToBounds = YES 会触发离屏渲染(offscreen rendering),耗时且消耗 GPU 资源;
    • 替代方案:使用 CALayercornerRadius 时,结合 layer.masksToBounds = NO + layer.shouldRasterize = YES + layer.rasterizationScale = [UIScreen mainScreen].scale,将视图光栅化(缓存为位图),减少重复渲染;或使用图片直接实现圆角,避免代码设置。
  2. 使用 estimatedRowHeight 优化动态行高

    • 动态行高(单元格高度不固定)时,设置 tableView.estimatedRowHeight = 100(预估行高)+ tableView.rowHeight = UITableViewAutomaticDimension,避免 heightForRowAtIndexPath 中计算所有行高(尤其是大数据量时);
    • 注意:预估行高应尽量接近实际行高,否则会导致列表滑动时出现跳动。
  3. 避免频繁调用 reloadData

    • reloadData 会刷新所有可见单元格,开销大,尽量使用局部刷新(reloadRowsAtIndexPaths:withRowAnimation:insertRowsAtIndexPaths:withRowAnimation:);

    • 示例(局部刷新单行):

      复制代码
      // 刷新第 0 行
      NSIndexPath *indexPath = [NSIndexPath indexPathForRow:0 inSection:0];
      [self.tableView reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
  4. 滑动时禁用不必要的动画和交互

    • 列表快速滑动时,禁用单元格的高亮动画、选中动画等,减少 CPU 开销;
    • 禁用 tableViewallowsSelectionDuringEditingallowsMultipleSelection 等不必要的交互属性,简化事件处理。
面试加分点
  • 能解释 "离屏渲染" 的概念及对 UITableView 性能的影响,给出替代方案;
  • 能结合 Instruments 工具(如 Time Profiler 分析 CPU 开销、Allocations 分析内存占用、Core Animation 分析渲染性能)说明优化排查流程;
  • 了解 UITableView 的底层渲染机制(如重用池的实现、Cell 的布局流程);
  • 能对比不同图片加载框架(SDWebImage、Kingfisher)的优化差异(如 Kingfisher 基于 Swift,支持更多图片格式,内存管理更优)。
记忆法
  • 维度记忆法:按 "复用→数据→视图→图片→滑动" 五个核心维度分类,每个维度记住 2-3 个关键优化点,形成结构化知识框架,避免遗漏;
  • 问题导向记忆法:将优化点与 "卡顿原因" 绑定(如 "视图层级多→简化层级""图片加载阻塞→异步加载""复用混乱→正确注册复用"),通过 "解决问题" 的逻辑记住优化方案。

MVC 和 MVVM 架构模式有什么区别?

MVC(Model-View-Controller)和 MVVM(Model-View-ViewModel)是 iOS 开发中最常用的两种架构模式,核心目标都是解耦代码、提高可维护性和可测试性,但因职责划分、依赖关系、数据流转方式的差异,适用于不同复杂度的项目。面试中需从核心定义、职责划分、依赖关系、数据流转、优缺点、适用场景等维度全面对比,体现对架构设计本质的理解。

一、先明确:两种架构的核心定义与职责划分

架构模式的核心是 "职责分离",两种模式的核心差异源于对 "业务逻辑、数据处理、视图展示" 的职责分配不同。

  1. MVC 架构MVC 是最经典的架构模式,iOS 原生框架(如 UIKit)天然支持 MVC,核心分为三层:

    • Model(模型)
      • 职责:存储数据(如用户信息、网络请求返回数据)、处理数据逻辑(如数据验证、数据转换、本地存储),不依赖任何其他层(View、Controller),是独立的业务实体;
      • 示例:UserModel 存储用户名、密码,提供 validateLoginParams(验证登录参数)、saveToLocal(本地存储)等方法。
    • View(视图)
      • 职责:展示数据、接收用户交互(如点击按钮、输入文本),不包含业务逻辑,仅依赖 Model 的数据,通过 Controller 响应交互事件;
      • 示例:LoginView 包含用户名输入框、密码输入框、登录按钮,提供 getUsername/getPassword(获取输入内容)、setLoginButtonEnabled(设置按钮状态)等方法。
    • Controller(控制器)
      • 职责:作为 Model 和 View 的中间枢纽,协调二者交互;接收 View 的交互事件,调用 Model 的方法处理业务逻辑,将 Model 的数据传递给 View 展示;
      • 示例:LoginViewController 监听登录按钮点击事件,调用 UserModelvalidateLoginParams 方法,验证通过后发起网络请求,请求成功后更新 View 显示登录结果。
  2. MVVM 架构MVVM 是基于 MVC 演化而来的架构,核心是引入 "ViewModel" 层,将 Controller 的业务逻辑和数据处理职责剥离,核心分为四层:

    • Model(模型)
      • 职责与 MVC 一致:存储数据、处理核心业务逻辑(如数据验证、网络请求、本地存储),独立于其他层,不依赖 ViewModel/View。
    • View(视图)
      • 职责与 MVC 类似:展示数据、接收用户交互,但不依赖 Controller,而是通过 "数据绑定"(Data Binding)与 ViewModel 关联,自动响应 ViewModel 的数据变化;
      • 示例:LoginView 与 MVC 一致,但无需 Controller 主动更新,通过数据绑定自动显示 ViewModel 中的 loginStatus(登录状态)。
    • ViewModel(视图模型)
      • 职责:作为 View 和 Model 的中间层,核心是 "数据转换" 和 "业务逻辑处理";将 Model 的数据转换为 View 可直接使用的格式(如将 NSDate 转换为 NSString 时间字符串),接收 View 的交互事件,调用 Model 的方法处理业务,通过数据绑定将结果反馈给 View;
      • 关键特性:不依赖 View(无 UIKit 导入),仅暴露数据属性和命令方法,可独立进行单元测试;
      • 示例:LoginViewModel 包含 username/password(与 View 输入框绑定)、loginStatus(与 View 状态绑定),提供 loginCommand(登录命令),内部调用 UserModel 的验证和网络请求方法。
    • Controller(控制器)
      • 职责被大幅弱化:仅负责 View 和 ViewModel 的初始化与绑定,不再处理业务逻辑;部分场景下(如 SwiftUI 开发),Controller 可被完全替代(View 直接与 ViewModel 绑定);
      • 示例:LoginViewControllerviewDidLoad 中初始化 LoginViewLoginViewModel,将 View 的输入内容与 ViewModel 的 username/password 绑定,将 ViewModel 的 loginStatus 与 View 的显示状态绑定,无需处理登录按钮点击事件(由 ViewModel 的 loginCommand 响应)。
二、核心区别对比(维度化分析)
对比维度 MVC MVVM
核心职责划分 Controller 承担 "协调 + 业务逻辑 + 数据处理" 多重职责 ViewModel 承担 "业务逻辑 + 数据处理",Controller 仅承担 "视图绑定 + 初始化"
依赖关系 View ←→ Controller ←→ Model(View 和 Model 不直接交互,依赖 Controller) View ←→(数据绑定)←→ ViewModel ←→ Model(View 和 Model 不直接交互,View 依赖 ViewModel,ViewModel 依赖 Model)
数据流转方式 单向 / 手动:Controller 主动从 Model 获取数据,手动传递给 View 更新(如 view.setData(model.data) 双向 / 自动:View 输入更新 → ViewModel 数据变化(双向绑定);ViewModel 数据变化 → View 自动更新(单向绑定)
对 UIKit 的依赖 Controller 和 View 依赖 UIKit(导入 UIKit.h),Model 不依赖 View 依赖 UIKit,ViewModel 和 Model 不依赖 UIKit(纯逻辑层)
单元测试难度 难:Controller 依赖 UIKit 和 View,无法独立测试;Model 可测试,但业务逻辑分散在 Controller 中 易:ViewModel 无 UIKit 依赖,仅依赖 Model,可独立编写单元测试;Model 同样可测试,核心业务逻辑集中在 ViewModel
代码耦合度 中高:Controller 与 View、Model 均有强耦合,Controller 容易臃肿("Massive View Controller" 问题) 低:View 与 ViewModel 通过数据绑定松散耦合,ViewModel 与 Model 依赖注入(DI),各层职责清晰,耦合度低
学习与实现成本 低:原生支持,无需额外框架,逻辑简单,上手快 中高:需理解数据绑定原理,可能需要第三方框架(如 RxSwift、Combine)支持,初期实现成本略高
三、关键差异详解(深入核心区别)
  1. Controller 的角色差异(最核心区别)

    • MVC 中的 Controller 是 "全能型角色":既要管理 View 的生命周期(如加载、布局),又要处理业务逻辑(如网络请求、数据验证),还要协调 Model 和 View 的交互,导致 Controller 代码臃肿(尤其是复杂页面, thousands of lines 很常见),维护困难;
    • MVVM 中的 Controller 是 "辅助型角色":仅负责 View 和 ViewModel 的初始化、绑定,以及少量视图相关逻辑(如页面跳转),核心业务逻辑全部转移到 ViewModel,Controller 代码量大幅减少,避免 "Massive View Controller" 问题。
  2. 数据流转与绑定差异

    • MVC 的数据流转是 "手动触发":例如用户输入用户名后,View 需通过代理 / Block 通知 Controller,Controller 再手动更新 Model 或自身存储的参数;Model 数据变化后,Controller 需手动调用 View 的方法更新显示,流程繁琐且易出错;
    • MVVM 的数据流转是 "自动响应":通过数据绑定,View 和 ViewModel 形成联动。例如:
      1. 用户在 View 的输入框输入文字 → 自动同步到 ViewModel 的 username 属性(双向绑定);
      2. ViewModel 的 username 变化后,自动触发数据验证逻辑(如判断是否为空);
      3. 登录成功后,ViewModel 的 loginStatus 变为 "成功" → 自动同步到 View,View 显示成功提示(单向绑定);
    • 数据绑定的实现方式:iOS 中无原生双向绑定,需通过第三方框架(如 RxSwift 的 Observable、Combine 的 Published)或 KVO、Block 手动实现。
  3. 单元测试的差异(核心优势对比)

    • MVC 的单元测试困境:Controller 依赖 UIViewControllerUIView 等 UIKit 类,这些类在单元测试环境(如 XCTest)中难以模拟,导致 Controller 中的业务逻辑无法独立测试;Model 虽可测试,但核心业务逻辑常分散在 Controller 中,测试覆盖率低;
    • MVVM 的单元测试优势:ViewModel 不依赖任何 UIKit 类,仅暴露纯数据属性和逻辑方法,可通过注入 Mock Model 模拟各种场景(如网络请求成功 / 失败、数据验证通过 / 不通过),轻松编写单元测试,确保业务逻辑的正确性。
四、优缺点对比与适用场景
  1. MVC 的优缺点与适用场景

    • 优点:
      • 简单易懂,学习成本低,iOS 原生支持,开发速度快;
      • 架构轻量,无需额外框架,适合小型项目或快速迭代的项目;
      • 职责划分清晰(理论上),适合新手入门。
    • 缺点:
      • Controller 容易臃肿,业务逻辑、数据处理、视图管理全部集中,维护难度随项目复杂度增加而飙升;
      • 耦合度较高,Controller 与 View、Model 强绑定,代码复用性差;
      • 单元测试困难,核心业务逻辑难以覆盖。
    • 适用场景:小型项目(如工具类 App)、快速原型开发、新手入门项目、依赖大量 UIKit 原生组件的项目。
  2. MVVM 的优缺点与适用场景

    • 优点:
      • 职责划分更清晰,ViewModel 剥离 Controller 的业务逻辑,Controller 轻量化,维护性强;
      • 耦合度低,各层依赖松散(View 依赖 ViewModel 接口,ViewModel 依赖 Model 接口),代码复用性高;
      • 单元测试友好,ViewModel 可独立测试,测试覆盖率高;
      • 数据绑定实现自动更新,减少手动传递数据的样板代码(Boilerplate Code)。
    • 缺点:
      • 学习成本高,需理解数据绑定、响应式编程等概念,可能需要引入第三方框架(如 RxSwift、Combine);
      • 初期实现成本高,需编写 ViewModel 层,增加代码量(但长期维护收益大于成本);
      • 数据绑定可能导致调试困难(如数据变化追踪复杂)。
    • 适用场景:中大型项目(如电商 App、社交 App)、业务逻辑复杂的项目、对测试覆盖率有要求的项目、多人协作开发的项目。
五、实际开发中的关键注意点
  1. MVC 开发的避坑点

    • 避免 "Controller 臃肿":将业务逻辑抽离到 Model 或工具类(如网络请求工具、数据转换工具),Controller 仅保留 "协调" 职责;
    • 避免 View 依赖 Controller:View 应提供接口供 Controller 调用,而非 Controller 直接操作 View 的子视图(如 Controller 不应直接修改 loginView.usernameTextField.text,而应调用 loginView.setUsername:text)。
  2. MVVM 开发的避坑点

    • 避免 ViewModel 依赖 View:ViewModel 中禁止导入 UIKit,不存储 View 实例,仅通过数据属性与 View 绑定;
    • 避免 ViewModel 臃肿:ViewModel 仅处理 "数据转换" 和 "业务逻辑",不承担 View 的生命周期管理(如页面跳转),页面跳转仍由 Controller 负责;
    • 合理选择数据绑定方案:简单场景可用 KVO、Block 实现,复杂场景可使用 RxSwift、Combine 框架,避免手动实现复杂的绑定逻辑。
面试加分点
  • 能结合实际项目经验说明 "为什么选择 MVVM 而非 MVC"(如 "项目业务逻辑复杂,MVC 导致 Controller 臃肿,MVVM 拆分后维护性提升");
  • 能解释 "数据绑定的底层实现"(如 KVO 监听属性变化、RxSwift 的 Observable 流、Combine 的 Publisher/Subscriber 模式);
  • 能对比其他架构模式(如 VIPER)与 MVC/MVVM 的差异,体现架构设计的全局视角;
  • 了解 iOS 原生对 MVVM 的支持(如 SwiftUI + Combine 天然适配 MVVM,UIKit 需手动实现绑定)。
记忆法
  • 核心差异记忆法:"MVC 靠 Controller 协调(手动传数据),MVVM 靠 ViewModel 绑定(自动更数据);MVC 重 Controller,MVVM 轻 Controller 重 ViewModel",通过核心差异口诀快速区分;
  • 职责绑定记忆法:将各层职责与 "依赖关系" 绑定(MVC:View→Controller→Model;MVVM:View→ViewModel→Model),结合 "数据流转方式"(手动 vs 自动),形成完整的架构逻辑链。

二叉搜索树的实现方式是什么?其时间复杂度如何?

二叉搜索树(Binary Search Tree,BST)是一种特殊的二叉树,核心特性是 "左子树所有节点值 < 根节点值 < 右子树所有节点值",基于该特性可实现高效的查找、插入、删除操作,是 iOS 开发中常见的数据结构(如排序、去重、范围查询场景)。面试中需先明确二叉搜索树的定义与特性,再详细阐述核心操作的实现方式,最后分析时间复杂度及影响因素,体现对数据结构底层逻辑的理解。

一、二叉搜索树的定义与核心特性
  1. 定义:二叉搜索树是一棵空树,或满足以下条件的二叉树:

    • 左子树中的所有节点的值均小于根节点的值;
    • 右子树中的所有节点的值均大于根节点的值;
    • 左、右子树也分别是二叉搜索树(递归定义);
    • (可选特性)不允许存在值相等的节点(若允许,需明确相等节点的存储规则,如全部存于左子树或右子树)。
  2. 核心特性(关键优势来源)

    • 中序遍历(左→根→右)的结果是 "升序排列" 的节点值,这是二叉搜索树用于排序、去重的核心依据;
    • 基于 "左小右大" 的特性,查找、插入、删除操作可通过 "二分查找" 思路快速定位节点,效率远高于普通二叉树。
二、二叉搜索树的实现方式(iOS 开发中常用 OC/Swift)

二叉搜索树的实现核心是 "节点结构定义" 和 "三大核心操作(查找、插入、删除)",以下以 Swift 语言为例(OC 实现逻辑一致,语法差异),基于 "类" 实现(引用类型,支持动态节点添加 / 删除)。

  1. 节点结构定义首先定义二叉搜索树的节点类,包含 "值""左子节点""右子节点" 三个核心属性:

    复制代码
    class BSTNode<T: Comparable> {
        var value: T // 节点值(需支持比较,故约束 T 为 Comparable)
        var leftChild: BSTNode? // 左子节点
        var rightChild: BSTNode? // 右子节点
        
        // 初始化方法
        init(value: T) {
            self.value = value
            self.leftChild = nil
            self.rightChild = nil
        }
    }
    • 约束 T: Comparable:因为二叉搜索树的核心是节点值比较,需确保节点值支持 < > 等比较操作(如 IntString 均满足)。
  2. 二叉搜索树类定义二叉搜索树类包含 "根节点" 属性,以及查找、插入、删除、遍历等方法:

    复制代码
    class BinarySearchTree<T: Comparable> {
        var root: BSTNode<T>? // 根节点(空树时为 nil)
        
        // 初始化:空树或带初始值的树
        init() {
            self.root = nil
        }
        
        init(initialValue: T) {
            self.root = BSTNode(value: initialValue)
        }
    }
  3. 核心操作 1:查找(Search)

    • 目标:根据给定值,查找二叉搜索树中是否存在该节点,若存在则返回节点,否则返回 nil

    • 实现思路(递归 / 迭代):

      1. 从根节点开始,比较目标值与当前节点值;
      2. 若目标值 == 当前节点值,找到节点,返回;
      3. 若目标值 < 当前节点值,递归查找左子树(若左子树为空,返回 nil);
      4. 若目标值 > 当前节点值,递归查找右子树(若右子树为空,返回 nil)。
    • 代码实现(递归版):

      复制代码
      // 对外暴露的查找方法
      func search(_ value: T) -> BSTNode<T>? {
          return searchHelper(node: root, value: value)
      }
      
      // 递归辅助方法
      private func searchHelper(node: BSTNode<T>?, value: T) -> BSTNode<T>? {
          guard let currentNode = node else {
              return nil // 节点为空,未找到
          }
          
          if value == currentNode.value {
              return currentNode // 找到目标节点
          } else if value < currentNode.value {
              return searchHelper(node: currentNode.leftChild, value: value) // 查找左子树
          } else {
              return searchHelper(node: currentNode.rightChild, value: value) // 查找右子树
          }
      }
    • 迭代版实现(避免递归栈溢出,更适合大数据量):

      复制代码
      func searchIterative(_ value: T) -> BSTNode<T>? {
          var currentNode = root
          while let node = currentNode {
              if value == node.value {
                  return node
              } else if value < node.value {
                  currentNode = node.leftChild
              } else {
                  currentNode = node.rightChild
              }
          }
          return nil
      }
  4. 核心操作 2:插入(Insert)

    • 目标:插入一个新节点,保持二叉搜索树的 "左小右大" 特性;

    • 实现思路(递归 / 迭代):

      1. 若树为空(根节点为 nil),直接创建新节点作为根节点;
      2. 从根节点开始,比较新节点值与当前节点值;
      3. 若新节点值 < 当前节点值:
        • 若当前节点左子树为空,直接将新节点作为左子节点;
        • 若左子树不为空,递归插入左子树;
      4. 若新节点值 > 当前节点值:
        • 若当前节点右子树为空,直接将新节点作为右子节点;
        • 若右子树不为空,递归插入右子树;
      5. (可选)若值相等,忽略插入或抛出异常(根据业务规则)。
    • 代码实现(递归版):

      复制代码
      func insert(_ value: T) {
          root = insertHelper(node: root, value: value)
      }
      
      private func insertHelper(node: BSTNode<T>?, value: T) -> BSTNode<T> {
          // 节点为空,创建新节点返回
          guard let currentNode = node else {
              return BSTNode(value: value)
          }
          
          if value < currentNode.value {
              // 插入左子树,更新左子节点引用
              currentNode.leftChild = insertHelper(node: currentNode.leftChild, value: value)
          } else if value > currentNode.value {
              // 插入右子树,更新右子节点引用
              currentNode.rightChild = insertHelper(node: currentNode.rightChild, value: value)
          } else {
              // 值相等,忽略插入(或根据业务处理)
              return currentNode
          }
          
          // 返回当前节点(维持树结构)
          return currentNode
      }
  5. 核心操作 3:删除(Delete)

    • 目标:删除指定值的节点,保持二叉搜索树特性,是三大操作中最复杂的;

    • 难点:删除节点后,需找到合适的节点替代该节点的位置,避免破坏 "左小右大" 特性;

    • 分类讨论(根据删除节点的子节点情况):

      1. 情况 1:删除节点是叶子节点(无左、右子节点) :直接删除该节点(将父节点的对应子节点引用置为 nil);
      2. 情况 2:删除节点只有一个子节点(左子节点或右子节点):将父节点的对应子节点引用指向该节点的子节点(替代删除节点);
      3. 情况 3:删除节点有两个子节点
        • 找到该节点的 "中序后继节点"(右子树中最小的节点,即右子树一直向左遍历到叶子的节点)或 "中序前驱节点"(左子树中最大的节点);
        • 将中序后继节点的值赋给删除节点;
        • 删除中序后继节点(中序后继节点必然是叶子节点或只有一个子节点,按情况 1 或 2 处理)。
    • 代码实现(递归版,基于中序后继节点):

      复制代码
      func delete(_ value: T) {
          root = deleteHelper(node: root, value: value)
      }
      
      private func deleteHelper(node: BSTNode<T>?, value: T) -> BSTNode<T>? {
          guard let currentNode = node else {
              return nil // 未找到要删除的节点,返回 nil
          }
          
          // 步骤 1:找到要删除的节点(递归遍历)
          if value < currentNode.value {
              currentNode.leftChild = deleteHelper(node: currentNode.leftChild, value: value)
          } else if value > currentNode.value {
              currentNode.rightChild = deleteHelper(node: currentNode.rightChild, value: value)
          } else {
              // 步骤 2:找到要删除的节点(currentNode),处理三种情况
              
              // 情况 1:叶子节点(无左、右子节点)
              if currentNode.leftChild == nil && currentNode.rightChild == nil {
                  return nil
              }
              
              // 情况 2:只有一个子节点(左或右)
              else if currentNode.leftChild == nil {
                  // 只有右子节点,返回右子节点替代当前节点
                  return currentNode.rightChild
              } else if currentNode.rightChild == nil {
                  // 只有左子节点,返回左子节点替代当前节点
                  return currentNode.leftChild
              }
              
              // 情况 3:有两个子节点,找到中序后继节点(右子树最小值)
              else {
                  // 找到右子树的最小值节点
                  let successorNode = findMinNode(in: currentNode.rightChild!)
                  // 将后继节点的值赋给当前节点
                  currentNode.value = successorNode.value
                  // 删除后继节点(递归删除,后继节点必然是情况 1 或 2)
                  currentNode.rightChild = deleteHelper(node: currentNode.rightChild, value: successorNode.value)
              }
          }
          
          // 返回当前节点(维持树结构)
          return currentNode
      }
      
      // 辅助方法:查找子树中的最小节点(中序后继节点)
      private func findMinNode(in node: BSTNode<T>) -> BSTNode<T> {
          var currentNode = node
          // 一直向左遍历,直到左子节点为 nil(最小节点)
          while let leftChild = currentNode.leftChild {
              currentNode = leftChild
          }
          return currentNode
      }
  6. **辅助操作:中序遍历(验证 BST 特性)**中序遍历结果为升序,可用于验证二叉搜索树的正确性:

    复制代码
    // 中序遍历(左→根→右),返回升序数组
    func inorderTraversal() -> [T] {
        var result = [T]()
        inorderHelper(node: root, result: &result)
        return result
    }
    
    private func inorderHelper(node: BSTNode<T>?, result: inout [T]) {
        guard let currentNode = node else { return }
        inorderHelper(node: currentNode.leftChild, result: &result) // 遍历左子树
        result.append(currentNode.value) // 访问根节点
        inorderHelper(node: currentNode.rightChild, result: &result) // 遍历右子树
    }
三、二叉搜索树的时间复杂度分析

二叉搜索树的时间复杂度核心取决于 "树的高度"(根节点到叶子节点的最长路径长度),不同树结构(平衡 / 不平衡)的时间复杂度差异极大。

  1. 理想情况:平衡二叉搜索树

    • 定义:树的左右子树高度差不超过 1(如 AVL 树、红黑树),树的高度为 log₂n(n 为节点总数);
    • 时间复杂度(查找、插入、删除):O(log n)
    • 原因:每次操作(查找、插入、删除)都会根据 "左小右大" 特性排除一半子树,类似二分查找,操作次数与树的高度成正比(log₂n 级)。
  2. 最坏情况:不平衡二叉搜索树(退化为链表)

    • 定义:当插入的节点值为有序序列(如 1→2→3→4→5)时,二叉搜索树会退化为单链表(所有节点只有右子节点),树的高度为 n(n 为节点总数);
    • 时间复杂度(查找、插入、删除):O(n)
    • 原因:每次操作都需要遍历从根节点到叶子节点的所有节点(类似链表遍历),操作次数与节点总数成正比(n 级)。
  3. 平均情况

    • 若插入的节点值随机分布,二叉搜索树的高度接近 log₂n,时间复杂度为 O(log n)
  4. 各操作时间复杂度总结

    操作 最好时间复杂度 最坏时间复杂度 平均时间复杂度
    查找 O(log n) O(n) O(log n)
    插入 O(log n) O(n) O(log n)
    删除 O(log n) O(n) O(log n)
    中序遍历 O(n) O(n) O(n) (遍历所有节点,与树结构无关)
四、二叉搜索树的局限性与优化方向
  1. 局限性

    • 不平衡时性能急剧下降(退化为链表,O(n) 复杂度);
    • 不支持范围查询的高效优化(如查找值在 [10, 20] 之间的所有节点,需遍历整个树);
    • 不支持重复节点(需额外处理,如计数节点)。
  2. 优化方向

    • 平衡二叉搜索树(如 AVL 树、红黑树):通过自动调整树结构维持平衡,确保时间复杂度稳定在 O(log n),iOS 中的 TreeSetTreeMap 底层基于红黑树;
    • 多路搜索树(如 B 树、B+ 树):适用于磁盘存储(如数据库索引),减少 I/O 操作;
    • 计数二叉搜索树:每个节点存储值出现的次数,支持重复节点。
面试加分点
  • 能区分 "二叉树""二叉搜索树""平衡二叉搜索树" 的差异,说明平衡树的优化逻辑;
  • 能手动推导时间复杂度(如平衡树高度 log₂n 的由来);
  • 能结合 iOS 开发实际场景说明二叉搜索树的应用(如排序去重、快速查找本地缓存数据);
  • 能实现非递归版的查找、插入、删除操作(避免递归栈溢出问题)。
记忆法
  • 特性 - 操作 - 复杂度联动记忆法:"左小右大(特性)→ 二分查找(操作思路)→ 平衡时 O (log n)(复杂度);有序插入(最坏情况)→ 退化为链表(结构)→ O (n)(复杂度)",通过特性→结构→复杂度的逻辑链记忆;
  • 删除操作分类记忆法:"删除节点分三类,叶子直接删,单子替自身,双子找后继(或前驱)",通过口诀记住删除操作的核心分类及处理逻辑。

链表和数组的区别是什么?

链表和数组是计算机科学中最基础的线性数据结构,二者均用于存储有序数据,但因底层存储结构、内存分配方式的差异,在访问、插入、删除等操作的效率、内存占用、适用场景上存在显著区别。iOS 开发中,数组对应 NSArray/Array,链表虽无原生实现(需自定义),但常用于底层优化(如哈希表冲突解决、LRU 缓存),面试中需从底层结构、核心操作效率、内存特性、适用场景等维度全面对比,体现对数据结构本质的理解。

一、底层存储结构(核心差异根源)

底层存储结构是链表和数组所有差异的根源,直接决定了二者的内存分配方式和操作逻辑:

  1. 数组(Array)

    • 定义:数组是 "连续的内存块" 存储结构,所有元素按顺序依次存储在一块连续的内存空间中;
    • 内存分配:初始化时需指定数组长度(静态数组)或动态扩容(动态数组,如 iOS 的 NSMutableArray、Swift 的 Array),动态数组会预分配一定容量,当元素数量超过容量时,会重新申请一块更大的连续内存(通常是原容量的 1.5 倍或 2 倍),并将原元素拷贝到新内存;
    • 索引机制:每个元素都有唯一的 "索引"(下标),通过索引可直接计算元素的内存地址(地址 = 数组起始地址 + 索引 × 元素大小),支持随机访问;
    • 示例(内存布局):假设数组 [1, 2, 3, 4],元素类型为 Int(占 8 字节),起始地址为 0x1000,则:
      • 元素 1 地址:0x1000(0x1000 + 0×8);
      • 元素 2 地址:0x1008(0x1000 + 1×8);
      • 元素 3 地址:0x1010(0x1000 + 2×8);
      • 元素 4 地址:0x1018(0x1000 + 3×8)。
  2. 链表(Linked List)

    • 定义:链表是 "非连续的内存块" 存储结构,数据元素(节点)通过 "指针"(引用)连接,形成有序序列;
    • 内存分配:每个节点独立分配内存(无需连续),节点包含两部分:数据域(存储元素值)和 指针域(存储下一个 / 上一个节点的地址);
    • 常见类型:
      • 单链表:每个节点只有 "下一个节点指针"(next),只能从表头遍历到表尾;
      • 双链表:每个节点有 "上一个节点指针"(prev)和 "下一个节点指针"(next),支持双向遍历;
      • 循环链表:表尾节点的 next 指向表头(单循环)或表头的 prev 指向表尾(双循环);
    • 示例(单链表内存布局):节点 1 → 2 → 3 → 4,各节点内存地址随机:
      • 节点 1:数据 = 1,next=0x2000(节点 2 地址);
      • 节点 2:数据 = 2,next=0x3000(节点 3 地址);
      • 节点 3:数据 = 3,next=0x4000(节点 4 地址);
      • 节点 4:数据 = 4,next=nil(表尾)。
二、核心区别对比(维度化分析)
对比维度 数组(Array) 链表(Linked List)
内存分配 连续内存块(静态数组固定长度,动态数组自动扩容) 非连续内存块(节点独立分配,无需预分配容量)
访问元素(随机访问) 支持,通过索引直接访问,时间复杂度 O (1) 不支持,需从表头 / 表尾遍历到目标节点,时间复杂度 O (n)
插入元素 1. 尾部插入:动态数组容量足够时 O (1),扩容时 O (n)(拷贝原元素);2. 中间 / 头部插入:需移动插入位置后的所有元素,时间复杂度 O (n) 1. 表头 / 表尾插入:已知表头 / 表尾指针时 O (1);2. 中间插入:找到插入位置后 O (1)(仅修改指针),整体时间复杂度 O (n)(遍历找位置)
删除元素 1. 尾部删除:O (1);2. 中间 / 头部删除:需移动删除位置后的所有元素,时间复杂度 O (n) 1. 表头 / 表尾删除:已知指针时 O (1);2. 中间删除:找到节点后 O (1)(修改指针),整体时间复杂度 O (n)(遍历找位置)
内存利用率 1. 静态数组:可能浪费内存(未使用的容量);2. 动态数组:扩容时可能产生内存碎片(原内存块释放) 无内存浪费(节点按需分配),但指针域占用额外内存(如双链表每个节点多 8 字节 prev 指针)
遍历效率 高:连续内存可利用 CPU 缓存(局部性原理),减少 I/O 开销 低:非连续内存无法有效利用 CPU 缓存,频繁切换内存地址
查找效率(按值查找) 无序数组:O (n);有序数组:可二分查找 O (log n) 无论有序与否,均需遍历 O (n)(无索引支持)
线程安全 原生不支持线程安全,需手动加锁(如 NSLock 原生不支持线程安全,需手动加锁,且指针修改需原子操作
iOS 原生支持 有(NSArray/NSMutableArray、Swift Array 无原生实现,需自定义节点和链表类
三、核心操作效率详解(结合 iOS 开发场景)
  1. 访问元素

    • 数组:let value = array[2](Swift),通过索引直接计算内存地址,瞬间获取,O (1) 复杂度,这是数组的核心优势;
    • 链表:访问第 3 个元素,需从表头开始,依次通过 next 指针遍历前 2 个节点,O (n) 复杂度,访问效率极低。
  2. 插入元素

    • 数组(中间插入):如 NSMutableArrayinsertObject:atIndex:,插入位置后的所有元素需向后移动一位(如插入到索引 1,索引 1 及以后的元素都要移动),元素越多,移动开销越大,O (n) 复杂度;
    • 链表(中间插入):假设已找到插入位置的前驱节点 prevNode,只需执行 newNode.next = prevNode.next; prevNode.next = newNode(单链表),仅修改两个指针,O (1) 操作开销,但查找前驱节点需 O (n) 时间。
  3. 删除元素

    • 数组(中间删除):如 NSMutableArrayremoveObjectAtIndex:,删除位置后的所有元素需向前移动一位,O (n) 复杂度;
    • 链表(中间删除):找到目标节点的前驱节点 prevNode,执行 prevNode.next = targetNode.next(单链表),O (1) 操作开销,查找前驱节点需 O (n) 时间。
  4. 动态扩容(仅数组)

    • iOS 中 NSMutableArray 和 Swift Array 都是动态数组,初始化时默认容量为 4(或根据初始元素数量调整),当元素数量超过容量时,会扩容为原容量的 1.5 倍(Swift)或 2 倍(NSMutableArray);
    • 扩容过程:申请新内存 → 拷贝原元素到新内存 → 释放原内存,该过程耗时 O (n),但因扩容频率低(指数级增长),平均时间复杂度仍为 O (1)( amortized O (1))。
四、优缺点对比与适用场景
  1. 数组的优缺点与适用场景

    • 优点:
      • 随机访问效率极高(O (1)),适合频繁通过索引访问元素的场景;
      • 遍历效率高,连续内存利用 CPU 缓存,速度快;
      • iOS 原生支持,API 丰富(如排序、过滤、查找),开发效率高。
    • 缺点:
      • 插入 / 删除中间元素效率低(O (n));
      • 动态扩容产生额外开销和内存碎片;
      • 静态数组长度固定,灵活性差。
    • 适用场景:
      • 频繁访问元素(如列表展示、数据缓存,通过索引快速获取);
      • 元素数量相对稳定,插入 / 删除操作少;
      • 需要排序、二分查找的场景(有序数组)。
    • iOS 示例:UITableView 的数据源数组(通过索引 indexPath.row 快速获取单元格数据)、网络请求返回的列表数据存储。
  2. 链表的优缺点与适用场景

    • 优点:
      • 插入 / 删除元素(无论位置)的操作开销低(O (1),排除查找时间),适合频繁插入 / 删除的场景;
      • 无需预分配容量,内存利用率高,元素数量可动态增长(无扩容问题);
      • 双链表支持双向遍历,适合需要前后节点访问的场景(如 LRU 缓存、双向队列)。
    • 缺点:
      • 不支持随机访问,访问元素效率低(O (n));
      • 指针域占用额外内存;
      • 遍历效率低,不利用 CPU 缓存;
      • iOS 无原生实现,需自定义,开发成本高。
    • 适用场景:
      • 频繁插入 / 删除元素的场景(如日志记录、消息队列,需在头部或中间插入);
      • 元素数量不确定,需动态增长且避免扩容开销;
      • 底层数据结构实现(如哈希表的冲突解决、LRU 缓存、双向队列)。
    • iOS 示例:自定义 LRU 缓存(用双链表维护元素访问顺序)、哈希表(NSDictionary 底层用链表解决哈希冲突)。
五、iOS 开发中的特殊注意点
  1. 数组的线程安全

    • NSArray 是不可变数组,线程安全(只读操作无需加锁);
    • NSMutableArray 是可变数组,线程不安全(多线程同时读写会导致数组越界、崩溃),需手动加锁(如 @synchronizedNSLock);
    • Swift Array 无论可变与否,都不是线程安全的,多线程操作需加锁。
  2. 链表的自定义实现

    • iOS 中无原生链表类,需自定义节点和链表结构,注意避免循环引用(OC 中用 __weak 修饰指针,Swift 中用 weakunowned);
    • 常用链表类型:双链表(支持双向遍历,更灵活)、循环链表(适合环形队列场景)。
  3. 数组与链表的混合使用

    • 哈希表(如 NSDictionary):底层用数组存储桶(bucket),每个桶内用链表解决哈希冲突(当多个 key 哈希值相同时,用链表存储);
    • 跳表(Skip List):基于链表的优化结构,通过多层索引实现快速查找(O (log n)),兼顾数组的访问效率和链表的插入 / 删除效率。
面试加分点
  • 能解释 "时间局部性" 和 "空间局部性" 原理,说明数组遍历效率高于链表的原因;
  • 能区分 "静态数组" 和 "动态数组" 的差异,说明 iOS 中动态数组的扩容机制;
  • 能结合底层实现说明哈希表中链表的作用(解决哈希冲突);
  • 能手动实现单链表 / 双链表的核心操作(插入、删除、反转、环检测),体现代码能力。
记忆法
  • 核心差异记忆法:"数组连续存,随机访问快(O (1)),插入删除慢(O (n));链表离散存,随机访问慢(O (n)),插入删除快(O (1))",通过核心操作效率口诀快速区分;
  • 场景绑定记忆法:将数据结构与场景绑定("频繁访问用数组""频繁增删用链表""哈希冲突用链表""排序查找用数组"),通过场景快速定位合适的数据结构。

C++和OC有什么区别?

C++和OC作为面向对象编程语言,核心差异源于设计理念、语法特性、运行机制等底层逻辑,以下从关键维度展开对比,同时明确面试考察重点与记忆方法:

对比维度 C++ OC
语言本质 静态编译型语言,基于C语言扩展,兼容C语法但强化面向对象 动态运行时语言,完全兼容C语言,核心基于运行时(Runtime)机制实现动态特性
面向对象核心 支持类、继承、多态(通过虚函数表vtable实现)、封装,同时保留面向过程编程能力 基于"消息传递"而非"函数调用",支持类、协议(Protocol)、分类(Category)、延展(Extension),多态通过动态绑定实现
类型检查 编译期强类型检查,变量类型固定,不支持运行时类型动态修改 编译期弱类型检查(允许id类型隐式转换),运行时可通过Runtime获取/修改类型信息
内存管理 手动管理(new/delete)、智能指针(unique_ptr/shared_ptr),无自动引用计数(ARC是OC特性,C++11后才有类似机制) 支持MRC(手动引用计数)、ARC(自动引用计数),Runtime负责引用计数维护,核心通过retain/release/autorelease实现
动态特性 静态特性为主,仅支持有限动态(如RTTI、虚函数),函数调用地址编译期确定 动态特性丰富,支持动态类型识别(isKindOfClass)、动态方法解析、消息转发、分类添加方法、运行时修改类结构
语法风格 语法紧凑,使用class定义类,->访问成员,支持运算符重载、模板、多重继承 语法独特,使用@interface/@implementation分离声明与实现,[]表示消息发送,->仅能访问实例变量(需暴露),不支持多重继承(通过协议替代)
应用场景 系统级开发、游戏引擎、高性能计算(依赖静态编译的效率) iOS/macOS开发(生态绑定),侧重界面交互、应用层开发(依赖动态特性的灵活性)

面试关键点:需突出"动态vs静态"的核心差异,尤其OC的Runtime机制、消息传递与C++虚函数表的本质区别;加分点在于结合应用场景说明选择逻辑(如iOS开发选OC是因动态特性适配界面迭代、分类复用代码)。

记忆方法:"核心维度对比记忆法"------以"动态/静态"为核心轴,串联类型检查、方法调用、内存管理三个子维度,再补充语法、应用场景等辅助维度,每个维度下对比两者的"反向特性"(如C++编译期确定地址,OC运行时查找地址),通过对立关系强化记忆;同时牢记"OC兼容C、C++不兼容OC"的语法兼容性特点,避免混淆。

利用OC的动态特性能做什么?

OC的动态特性核心依赖Runtime库(一套C语言API),允许程序在运行时而非编译期完成类型判断、方法调用、类结构修改等操作,其实际应用场景覆盖开发效率优化、功能扩展、问题排查等多个维度,具体如下:

1. 动态类型识别与安全调用

OC支持id万能指针存储任意对象类型,运行时可通过isKindOfClass:(判断是否为某类或其子类实例)、isMemberOfClass:(判断是否为某类直接实例)、respondsToSelector:(判断对象是否实现某方法)识别类型并安全调用方法,避免"未实现方法"崩溃。

  • 应用场景:多类型对象统一处理(如 tableView 不同 cell 复用、网络请求不同数据模型解析)。

  • 代码示例:

    id dataModel = [self getModelFromNetwork];
    // 动态判断类型并调用对应方法
    if ([dataModel isKindOfClass:[UserModel class]]) {
    [(UserModel *)dataModel parseUserInfo];
    } else if ([dataModel respondsToSelector:@selector(parseProductInfo)]) {
    [dataModel performSelector:@selector(parseProductInfo) withObject:nil afterDelay:0];
    }

  • 面试加分点:提及performSelector系列方法的局限性(参数最多2个),以及替代方案(NSInvocation支持多参数),体现对细节的掌握。

2. 动态方法解析与消息转发

当对象收到未实现的消息时,OC不会直接崩溃,而是通过"动态方法解析→快速转发→完整转发"三级机制提供补救机会,利用这一特性可实现方法容错、解耦等功能。

  • 动态方法解析:通过重写+resolveInstanceMethod:(实例方法)或+resolveClassMethod:(类方法),在运行时动态添加未实现的方法。代码示例(动态添加实例方法):

    #import <objc/runtime.h>
    // 动态添加的方法实现
    void dynamicMethodIMP(id self, SEL _cmd) {
    NSLog(@"动态解析未实现的方法:%@", NSStringFromSelector(_cmd));
    }
    // 重写resolveInstanceMethod:

    • (BOOL)resolveInstanceMethod:(SEL)sel {
      if (sel == @selector(unimplementedMethod)) {
      // 动态添加方法,关联实现
      class_addMethod(self, sel, (IMP)dynamicMethodIMP, "v@:");
      return YES;
      }
      return [super resolveInstanceMethod:sel];
      }
  • 消息转发:通过-forwardingTargetForSelector:(快速转发,将消息转发给其他对象)或-methodSignatureForSelector:+-forwardInvocation:(完整转发,自定义转发逻辑),实现方法功能复用或解耦。应用场景:组件化开发中跨组件方法调用(避免直接依赖)、容错处理(未实现方法统一返回默认值)。

3. 分类(Category)与运行时添加方法/属性

分类的底层依赖Runtime实现方法添加,而通过objc_setAssociatedObjectobjc_getAssociatedObject,还能突破分类不能直接添加实例变量的限制,动态关联属性。

  • 动态关联属性示例(给UIView添加点击事件属性):

    // UIView+TapGesture.h
    @interface UIView (TapGesture)
    @property (nonatomic, copy) void(^tapAction)(UIView *view);
    @end

    // UIView+TapGesture.m
    #import <objc/runtime.h>
    static const void *TapActionKey = &TapActionKey;
    @implementation UIView (TapGesture)

    • (void)setTapAction:(void (^)(UIView *))tapAction {
      // 动态关联属性,关联策略为OBJC_ASSOCIATION_COPY_NONATOMIC
      objc_setAssociatedObject(self, TapActionKey, tapAction, OBJC_ASSOCIATION_COPY_NONATOMIC);
      UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapGestureAction)];
      [self addGestureRecognizer:tap];
      }
    • (void (^)(UIView *))tapAction {
      return objc_getAssociatedObject(self, TapActionKey);
      }
    • (void)tapGestureAction {
      if (self.tapAction) self.tapAction(self);
      }
      @end
  • 应用场景:给系统类(如UIView、NSString)扩展功能,无需继承;模块化拆分代码(如将ViewController的网络请求、UI布局拆分到不同分类)。

4. 运行时修改类结构

通过Runtime API可动态添加/删除类的实例方法、类方法,甚至修改类的父类(class_setSuperclass),但修改父类风险极高(可能导致继承链混乱),实际应用较少,更多用于特殊场景(如Hook方法)。

  • 应用场景:AOP(面向切面编程),如通过method_exchangeImplementations(方法交换)实现日志打印、性能监控、埋点统计等无侵入式功能。代码示例(Hook UIViewController的viewDidAppear:方法,统计页面显示时间):

    #import <objc/runtime.h>
    @implementation UIViewController (Track)

    • (void)load {
      static dispatch_once_t onceToken;
      dispatch_once(&onceToken, ^{
      SEL originalSEL = @selector(viewDidAppear:);
      SEL swizzledSEL = @selector(hook_viewDidAppear:);
      Method originalMethod = class_getInstanceMethod(self, originalSEL);
      Method swizzledMethod = class_getInstanceMethod(self, swizzledSEL);
      // 交换方法实现
      method_exchangeImplementations(originalMethod, swizzledMethod);
      });
      }
    • (void)hook_viewDidAppear:(BOOL)animated {
      // 执行自定义逻辑(统计时间、埋点)
      NSLog(@"页面%@显示", NSStringFromClass([self class]));
      // 调用原方法(此时hook_viewDidAppear:已与viewDidAppear:交换,实际执行原实现)
      [self hook_viewDidAppear:animated];
      }
      @end
  • 面试加分点:强调方法交换的注意事项(在+load方法中执行,用dispatch_once_t保证唯一执行;避免递归调用;处理父类未实现该方法的情况)。

5. 序列化与反序列化(归档/解档)

利用Runtime可遍历对象的所有属性,自动生成encodeWithCoder:initWithCoder:方法,避免手动编写大量重复代码(尤其当模型属性较多时)。

  • 代码示例(模型自动归档):

    #import <objc/runtime.h>
    @implementation BaseModel (Archive)

    • (void)encodeWithCoder:(NSCoder *)encoder {
      unsigned int propertyCount = 0;
      objc_property_t *properties = class_copyPropertyList([self class], &propertyCount);
      for (int i = 0; i < propertyCount; i++) {
      objc_property_t property = properties[i];
      const char *propertyName = property_getName(property);
      NSString *key = [NSString stringWithUTF8String:propertyName];
      id value = [self valueForKey:key];
      [encoder encodeObject:value forKey:key];
      }
      free(properties);
      }
    • (instancetype)initWithCoder:(NSCoder *)decoder {
      if (self = [super init]) {
      unsigned int propertyCount = 0;
      objc_property_t *properties = class_copyPropertyList([self class], &propertyCount);
      for (int i = 0; i < propertyCount; i++) {
      objc_property_t property = properties[i];
      const char *propertyName = property_getName(property);
      NSString *key = [NSString stringWithUTF8String:propertyName];
      id value = [decoder decodeObjectForKey:key];
      [self setValue:value forKey:key];
      }
      free(properties);
      }
      return self;
      }
      @end
  • 应用场景:模型数据持久化(如存储用户信息、缓存网络数据),大幅简化代码维护成本(属性增减时无需修改归档代码)。

面试关键点:需结合具体场景说明动态特性的实际价值,而非单纯罗列API;重点突出Runtime的核心作用(运行时操作类/对象结构),以及常用API的使用场景与注意事项。

记忆方法:"场景-功能-API"关联记忆法------先明确核心应用场景(如容错、扩展、统计、归档),再对应每个场景的核心功能(如消息转发、方法交换、属性遍历),最后记住关键API(如resolveInstanceMethod:method_exchangeImplementationsclass_copyPropertyList),通过"场景驱动"强化记忆,避免孤立记API。

OC的消息机制具体步骤是什么?

OC的消息机制是其动态特性的核心,不同于C++的"编译期确定函数调用地址",OC中[receiver selector]的本质是"向接收者发送消息",消息的查找、执行全在运行时完成,具体步骤可分为"消息发送→动态方法解析→消息转发→崩溃"四个核心阶段,每个阶段都有明确的逻辑流程与底层原理:

1. 消息发送的初始化(编译期处理)

当编译器遇到[receiver selector]语法时,不会直接生成函数调用指令,而是将其转换为Runtime的objc_msgSend函数调用(不同参数情况对应objc_msgSend_stretobjc_msgSendSuper等变体),核心作用是"传递接收者、选择器(SEL)、参数"给运行时系统。

  • 转换规则:[receiver selector:arg1]objc_msgSend(receiver, @selector(selector:), arg1);若接收者是super(如[super viewDidLoad]),则转换为objc_msgSendSuper(&superClass, @selector(viewDidLoad)),其中superClass是包含当前类和父类信息的结构体。
  • 底层准备:objc_msgSend会先快速查找接收者的缓存(cache_t),缓存中存储了近期调用过的方法实现(IMP),目的是优化调用效率(避免重复查找)。
2. 核心阶段一:消息查找(快速查找+慢速查找)

这一阶段的目标是"找到选择器(SEL)对应的方法实现(IMP)",分为快速查找(缓存查找)和慢速查找(方法列表遍历)两步,是消息机制的核心流程。

(1)快速查找(缓存查找,cache_t)

每个OC类(Class)和元类(Meta-Class)都包含一个cache_t结构体(缓存),用于存储"SEL→IMP"的映射,且采用散列表(hash table)结构优化查找速度。

  • 查找流程:
    1. objc_msgSend接收receiver后,先通过object_getClass(receiver)获取接收者的类对象(Class);
    2. 检查类对象的cache_t,根据SEL的hash值计算索引,查找对应的缓存条目(cache_entry);
    3. 若找到缓存条目,直接获取IMP并执行方法,流程结束;
    4. 若未找到(缓存未命中),进入慢速查找阶段。
  • 面试加分点:提及缓存的存储规则------缓存会在方法调用时动态添加,当缓存满时会触发扩容(扩容为原大小的2倍),且缓存不存储父类的方法(仅存储当前类自身实现或重写的方法)。
(2)慢速查找(方法列表遍历,methodList)

若缓存未命中,Runtime会遍历类的方法列表(methodList),同时沿着继承链向上查找,直到找到IMP或遍历至根类(NSObject)。

  • 查找流程:
    1. 从当前类的methodList(存储在class_rw_t结构体中,包含实例方法列表、类方法列表)中,根据SEL查找对应的方法(Method);
    2. 若当前类的方法列表中找到目标Method,将其IMP存入缓存(供下次快速查找),然后执行IMP,流程结束;
    3. 若未找到,获取当前类的父类(superclass),重复步骤1-2,遍历父类的缓存和方法列表;
    4. 持续向上查找,直到根类(NSObject),若根类仍未找到目标Method,进入"动态方法解析"阶段。
  • 底层细节:class_rw_t中的方法列表是"可读写"的(rw=read-write),包含类在运行时添加的方法(如分类方法);而class_ro_t(read-only)存储类编译期确定的原始方法列表,这也是分类方法能"覆盖"原类方法的底层原因(分类方法会被添加到class_rw_t的方法列表前方,查找时优先匹配)。
3. 核心阶段二:动态方法解析(补救机会一)

当慢速查找未找到方法时,Runtime会给当前类一次"动态添加方法"的机会,即动态方法解析阶段,核心API是+resolveInstanceMethod:(实例方法)和+resolveClassMethod:(类方法)。

  • 流程细节:
    1. 若接收者是实例对象,Runtime调用当前类的+resolveInstanceMethod:;若为类对象(调用类方法),调用+resolveClassMethod:
    2. 开发者可在该方法中,通过class_addMethod动态添加目标SEL对应的IMP(方法实现);
    3. 若返回YES,表示已动态添加方法,Runtime会重新触发一次"消息发送"流程(从缓存查找开始),此时能找到新添加的方法并执行;
    4. 若返回NO(或未重写该方法,默认返回NO),进入"消息转发"阶段。
  • 注意点:动态方法解析仅能"添加当前类的方法",无法将消息转发给其他对象,若需转发需进入下一阶段。
4. 核心阶段三:消息转发(补救机会二)

若动态方法解析未处理,Runtime会启动消息转发机制,提供两次转发机会(快速转发+完整转发),允许开发者将消息转发给其他对象,或自定义转发逻辑。

(1)快速转发(forwardingTargetForSelector:)

这是最简单的转发方式,核心是"将消息直接转发给另一个对象(转发目标)",避免复杂的转发逻辑。

  • 流程:
    1. Runtime调用接收者的-forwardingTargetForSelector:(实例方法)或+forwardingTargetForSelector:(类方法);
    2. 开发者可在该方法中返回一个"能处理该SEL的对象"(target);
    3. 若返回非nil对象,Runtime会将消息重新发送给该target,流程从"消息查找"阶段重新开始;
    4. 若返回nil(或未重写该方法),进入完整转发阶段。
  • 优势:效率高,无需创建NSInvocation对象,仅做简单的目标替换;局限性:无法修改消息的参数或返回值,仅能转发给单个对象。
(2)完整转发(methodSignatureForSelector: + forwardInvocation:)

这是功能最灵活的转发方式,允许开发者自定义转发逻辑(如修改参数、转发给多个对象、记录日志等),核心依赖NSInvocation(封装消息的接收者、SEL、参数、返回值)。

  • 流程:
    1. Runtime先调用接收者的-methodSignatureForSelector:,要求返回目标SEL的方法签名(NSMethodSignature);方法签名包含返回值类型、参数类型等信息,是创建NSInvocation的前提;
    2. 若返回nil,表示无法提供方法签名,Runtime直接触发崩溃(抛出unrecognized selector sent to instance/class异常);
    3. 若返回有效方法签名,Runtime会创建NSInvocation对象(封装当前消息的所有信息),并调用-forwardInvocation:
    4. 开发者可在-forwardInvocation:中自定义逻辑:如通过[invocation setTarget:target]修改转发目标,[invocation invoke]执行转发;或遍历多个对象,将消息转发给多个目标;甚至不转发,仅记录日志;
    5. 若未在-forwardInvocation:中执行[invocation invoke],消息最终未被处理,但不会直接崩溃(仅返回默认值,如nil),但不建议这样做(可能导致逻辑异常)。
  • 面试加分点:提及NSInvocation的常用API(如getArgument:atIndex:获取参数、setReturnValue:设置返回值),体现对转发细节的掌握。
5. 最终阶段:崩溃(未处理消息)

若完整转发阶段仍未处理消息(如methodSignatureForSelector:返回nil),Runtime会调用doesNotRecognizeSelector:方法,抛出NSInvalidArgumentException异常,提示"未识别的选择器发送给接收者",程序崩溃。

面试关键点:需按"顺序性"梳理四个核心阶段,明确每个阶段的触发条件、核心API、处理逻辑;重点区分"动态方法解析"(添加方法)与"消息转发"(转发给其他对象)的本质区别;加分点在于提及底层数据结构(如cache_tclass_rw_t)和API细节(如NSInvocation的使用)。

记忆方法:"流程节点+关键词"串联记忆法------将整个流程简化为"初始化(objc_msgSend)→查找(缓存→方法列表→继承链)→解析(resolve)→转发(快速→完整)→崩溃",每个节点对应核心关键词(如查找阶段的"缓存命中/未命中"、转发阶段的"目标替换/Invocation"),通过"节点顺序+关键词关联"强化记忆;同时用"三次机会"辅助记忆:缓存查找是第一次机会,动态解析是第二次,消息转发是第三次,三次均失败则崩溃。

Category的底层实现原理是什么?为什么Category不能新增实例变量?

Category的底层实现原理

Category(分类)是OC中用于给已有类扩展功能的核心特性,其底层并非"继承"或"修改原类结构",而是通过Runtime在运行时动态合并分类方法到原类中,核心依赖category_t结构体与 Runtime 的合并逻辑,具体实现流程可分为"编译期处理"和"运行时合并"两个阶段:

1. 编译期:生成category_t结构体

当编译器编译分类文件(如UIView+Extension.m)时,不会修改原类的@interface@implementation,而是生成一个独立的category_t结构体(定义在objc-runtime-new.h中),存储分类的核心信息,结构体定义如下:

复制代码
struct category_t {
    const char *name; // 分类所属的原类名(如"UIView")
    classref_t cls; // 原类的Class指针(编译期为NULL,运行时赋值)
    struct method_list_t *instanceMethods; // 分类的实例方法列表
    struct method_list_t *classMethods; // 分类的类方法列表
    struct protocol_list_t *protocols; // 分类遵守的协议列表
    struct property_list_t *properties; // 分类声明的属性列表
    struct property_list_t *instanceProperties; // 已废弃,保留兼容
};
  • 关键细节:
    • 分类的name必须与原类名一致,否则运行时无法找到对应的原类;
    • 编译期cls为NULL,因为分类是独立编译的,此时原类可能尚未编译完成,cls会在运行时由Runtime动态赋值;
    • 分类中声明的@property仅会生成settergetter的声明(即.h文件中的方法声明),不会生成实例变量(ivar)和默认实现,需手动实现或通过关联对象实现。
2. 运行时:合并分类信息到原类

OC程序启动时,Runtime会加载所有类和分类(通过objc_init初始化),并执行分类与原类的合并操作,核心流程如下:

  • 步骤1:查找原类Runtime遍历所有已加载的category_t结构体,根据category_t->name找到对应的原类(Class),并将category_t->cls赋值为该原类的指针。

  • 步骤2:合并方法列表(核心)Runtime会将分类的方法列表(instanceMethodsclassMethods)合并到原类的方法列表中,合并规则直接决定了"分类方法覆盖原类方法"的现象:

    • 原类的方法列表存储在class_rw_t结构体中(class_rw_t包含methodspropertiesprotocols等可读写列表,与编译期确定的class_ro_t(只读)区分);
    • 合并时,分类的方法会被添加到原类方法列表的最前面(而非后面);
    • OC中方法查找是"遍历方法列表,找到第一个匹配SEL的方法即返回",因此分类方法会优先于原类方法被找到,表现为"分类方法覆盖原类方法"(原类方法并未被删除,只是查找时无法优先匹配)。
    • 多个分类的合并顺序:若多个分类给同一原类添加了同名方法,合并顺序由"编译顺序"决定(Build Phases -> Compile Sources中,后编译的分类方法会排在方法列表更前面,优先被调用)。
  • 步骤3:合并协议与属性列表

    • 分类的协议列表(protocols)会被合并到原类的协议列表中,原类会自动遵守分类声明的协议;
    • 分类的属性列表(properties)仅会合并"属性声明"(即告知Runtime该类有该属性),但不会生成实例变量和默认的setter/getter实现,需开发者手动处理(如关联对象)。
  • 步骤4:处理类方法类方法的合并逻辑与实例方法一致,但目标是"原类的元类(Meta-Class)":

    • 原类的类方法本质是"元类的实例方法";
    • Runtime会将分类的classMethods合并到原类元类的class_rw_t->methods列表中,同样遵循"后编译优先"规则,因此分类的类方法也会覆盖原类的类方法。
为什么Category不能新增实例变量?

要理解这一限制,需从OC类的内存结构、实例变量的存储机制,以及分类的设计初衷三个核心角度分析:

1. 实例变量的存储机制:ivars是编译期确定的只读结构

OC类的实例变量(ivar)存储在类的class_ro_t结构体中(ro=read-only,只读),class_ro_t的定义如下:

复制代码
struct class_ro_t {
    uint32_t flags;
    uint32_t instanceStart; // 实例变量的起始偏移量
    uint32_t instanceSize; // 实例对象的内存大小(包含所有ivars)
    uint32_t reserved;
    const uint8_t *ivarLayout; // 实例变量的内存布局
    const char *name; // 类名
    method_list_t *baseMethodList; // 编译期确定的原始方法列表
    protocol_list_t *baseProtocols; // 编译期确定的原始协议列表
    const ivar_list_t *ivars; // 编译期确定的实例变量列表
    const uint8_t *weakIvarLayout;
    property_list_t *baseProperties; // 编译期确定的原始属性列表
};
  • 关键原因:
    • class_ro_t是编译期生成的只读结构,一旦原类编译完成,其ivars(实例变量列表)的数量、类型、内存布局(ivarLayout)、实例对象的内存大小(instanceSize)均已固定,无法在运行时修改;
    • 实例对象的内存分配是"编译期确定大小,运行时按固定大小分配":当通过alloc创建实例对象时,Runtime会根据原类class_ro_t->instanceSize分配内存,该内存大小刚好容纳所有实例变量(包括父类的ivars);
    • 若允许分类新增实例变量,需要在运行时修改class_ro_t->ivars,并扩大所有已创建实例对象的内存空间------这会导致严重的内存混乱(已分配的内存地址偏移量失效),且破坏了class_ro_t的只读特性,因此Runtime从设计上禁止了这一操作。
2. 分类的设计初衷:轻量级扩展,不修改原类结构

Category的设计目标是"在不继承原类、不修改原类源码的前提下,扩展功能",属于"轻量级扩展机制":

  • 若允许分类新增实例变量,会导致原类的内存结构被修改,违背"不侵入原类"的设计原则;
  • 若需要新增实例变量,OC提供了其他更合适的方案(如继承、Extension(延展)、关联对象),分类的核心定位是"扩展方法、遵守协议、声明属性",而非修改实例变量。
3. 替代方案:通过关联对象模拟"新增实例变量"

虽然分类不能直接新增实例变量,但可通过Runtime的关联对象(Associated Objects)机制,在运行时给实例对象绑定额外的数据,间接实现"新增实例变量"的效果,核心API如下:

  • objc_setAssociatedObject(id object, const void *key, id value, objc_AssociationPolicy policy):给对象绑定关联数据;

  • objc_getAssociatedObject(id object, const void *key):获取对象的关联数据;

  • objc_removeAssociatedObjects(id object):移除对象的所有关联数据。

  • 代码示例(给UIView分类新增"tapAction"关联属性):

    // UIView+Tap.h
    @interface UIView (Tap)
    @property (nonatomic, copy) void(^tapAction)(UIView *);
    @end

    // UIView+Tap.m
    #import <objc/runtime.h>
    // 定义唯一key(避免冲突,通常用静态全局变量或&静态变量)
    static const void *TapActionKey = @"TapActionKey";
    @implementation UIView (Tap)

    • (void)setTapAction:(void (^)(UIView *))tapAction {
      // 绑定关联对象,策略为OBJC_ASSOCIATION_COPY_NONATOMIC(与@property声明一致)
      objc_setAssociatedObject(self, TapActionKey, tapAction, OBJC_ASSOCIATION_COPY_NONATOMIC);
      }

OC中的字典(NSDictionary/NSMutableDictionary)底层实现原理是什么?

OC中的字典(NSDictionary/NSMutableDictionary)是基于"键值对(key-value)"存储的数据结构,核心设计目标是"快速查找",其底层实现依赖哈希表(Hash Table) ,不同系统版本(iOS 6前后)有核心差异,当前主流版本(iOS 7+)采用优化后的哈希表结构,结合"开放寻址法"解决哈希冲突,具体实现原理如下:

1. 底层核心数据结构:哈希表与bucket数组

字典的底层核心是"哈希表",而哈希表的物理载体是bucket数组(桶数组) ,每个bucket(桶)是存储单个键值对的基本单元,结构定义(简化版)如下:

复制代码
// 单个bucket的结构(包含key、value、哈希值、下一个bucket指针)
struct __NSDictionaryBucket {
    id _key; // 键值对的key
    id _value; // 键值对的value
    NSUInteger _hash; // key的哈希值(缓存,避免重复计算)
    struct __NSDictionaryBucket *_next; // 解决哈希冲突的链表指针(iOS 6及之前用,现在备用)
};
  • 哈希表的核心逻辑:通过key的哈希值计算其在bucket数组中的索引,实现"O(1)级别的快速查找"------理想情况下,无需遍历数组,直接通过索引定位bucket,获取value。
2. 核心流程:哈希计算、索引定位与冲突解决
(1)哈希计算:将key转换为哈希值

字典存储key时,首先会调用key的-hash方法计算哈希值(NSObject的默认实现是返回对象的内存地址,但很多类(如NSString、NSNumber)重写了-hash方法,基于内容计算哈希值)。

  • 关键要求:相同的key必须返回相同的哈希值(若[key1 isEqual:key2]为YES,则key1.hash == key2.hash);不同的key可能返回相同的哈希值(哈希碰撞)。
  • 面试加分点:提及"哈希值的合理性"------好的-hash实现应让哈希值均匀分布,减少哈希碰撞,提升字典查找效率。
(2)索引定位:通过哈希值计算bucket数组下标

得到key的哈希值后,字典会通过"取模运算"将哈希值映射为bucket数组的索引,公式简化为:index = hashValue % bucketArray.count

  • 优化细节:为了提升取模效率,bucket数组的容量(count)通常设计为2的幂次方(如4、8、16、32),此时"取模运算"会被优化为"按位与运算"(index = hashValue & (bucketArray.count - 1)),运算速度更快。
(3)哈希冲突解决:开放寻址法(主流)vs 链表法(旧版)

当两个不同的key计算出相同的索引时,会发生"哈希冲突",OC字典在不同版本采用不同的解决方式:

  • iOS 6及之前:采用"链表法"------每个bucket中通过_next指针指向冲突的下一个bucket,形成链表,查找时需遍历链表直到找到匹配的key;缺点是链表过长时,查找效率退化到O(n)。
  • iOS 7及之后:采用"开放寻址法"------当目标索引对应的bucket已被占用时,按固定步长(如+1)依次查找下一个空闲的bucket,直到找到空闲位置或匹配的key;优势是数据存储在连续的数组中,缓存命中率更高,查找效率更稳定。
    • 查找逻辑:当通过索引定位到bucket后,先判断当前bucket的key是否与目标key相等(通过-isEqual:方法);若相等,直接返回value;若不相等,按步长查找下一个bucket,重复判断,直到找到匹配key或遍历完数组(未找到返回nil)。
3. NSDictionary与NSMutableDictionary的差异:不可变vs可变

两者底层结构一致,但存储策略和扩展性不同:

  • NSDictionary(不可变):
    • 初始化时确定bucket数组的容量(根据初始化时的键值对数量分配,通常预留一定空闲空间);
    • 初始化后,bucket数组的容量不可变,也不能新增、删除键值对;
    • 线程安全(读取操作无需加锁),因为结构不可变,不会出现并发修改问题。
  • NSMutableDictionary(可变):
    • 初始化时分配默认容量(如4),支持动态新增、删除、修改键值对;
    • 扩容机制:当字典的"负载因子"(已使用bucket数量 / 数组总容量)超过阈值(通常为0.75)时,会触发扩容------创建一个容量为原容量2倍的新bucket数组,将原有的所有键值对重新计算哈希值和索引,迁移到新数组中;
    • 线程不安全:并发修改(如多线程同时新增键值对)会导致数据错乱或崩溃,需手动加锁(如@synchronizedNSLock)。
4. 关键特性:key的唯一性与不可变性
  • 唯一性:字典中key是唯一的,若新增已存在的key,NSMutableDictionary会覆盖原有的value,NSDictionary初始化时若有重复key,以最后一个key对应的value为准;
  • 不可变性:字典的key必须是"不可变对象"(如NSString、NSNumber、NSDate),若使用可变对象(如NSMutableString)作为key,修改key的内容后,其哈希值会变化,导致无法通过原key查找到对应的value。

面试关键点:需明确"哈希表+开放寻址法"的核心架构,区分新旧版本的冲突解决方式,说明可变字典的扩容机制;加分点在于提及哈希值与-isEqual:的关系、数组容量设计(2的幂次方)的原因,体现对底层优化细节的理解。

记忆方法:"结构-流程-差异"三层记忆法------第一层记住核心结构(哈希表+bucket数组);第二层梳理核心流程(哈希计算→索引定位→冲突解决),每个步骤对应关键细节(如开放寻址法、扩容阈值);第三层区分不可变与可变的差异(容量是否可变、扩容机制、线程安全),通过"层级递进"强化记忆;同时用"O(1)快速查找"作为核心目标,串联所有设计细节(如哈希计算、容量设计、冲突解决),理解其设计初衷。

字典的key通常是什么类型?NSArray能否作为字典的key?为什么?

字典key的常用类型及核心要求

OC字典(NSDictionary/NSMutableDictionary)的key并非支持所有对象类型,其核心要求是必须遵循"哈希一致性"和"不可变性" ,常用的key类型及原因如下:

常用key类型 优势 底层支撑
NSString 不可变、哈希值基于字符串内容计算(重写了-hash-isEqual:)、使用频率最高 相同字符串内容的NSString对象,-hash返回值相同,-isEqual:返回YES,符合key的唯一性要求
NSNumber 不可变、支持数值类型(int、float、BOOL等)、哈希值基于数值计算 相同数值的NSNumber对象(如@1和@(1))哈希值相同,适合存储数值型key
NSDate 不可变、哈希值基于时间戳计算 适合以时间作为key的场景(如缓存不同时间点的数据)
NSValue 可封装非对象类型(如CGPoint、CGRect)、不可变 适合存储结构体类型的key(如缓存不同坐标的视图数据)

这些类型的共同特点:不可变(创建后无法修改内部状态)正确实现了-hash-isEqual:方法 ------这是字典key的两个核心条件,缺一不可。

NSArray能否作为字典的key?结论:不能(默认情况下)

默认情况下,NSArray(可变数组NSMutableArray同理)不能作为字典的key,核心原因是NSArray不满足字典key的两个核心要求,具体分析如下:

1. 原因一:NSArray未正确实现-hash-isEqual:的一致性(针对内容匹配)

字典判断两个key是否相等的逻辑是:先比较哈希值(-hash),若哈希值不同,则直接判定为不同key;若哈希值相同,再通过-isEqual:方法确认是否真正相等

  • NSArray的-isEqual:实现:判断两个数组的元素数量相同,且对应位置的元素都相等(递归调用元素的-isEqual:),即"内容相等则数组相等";

  • NSArray的-hash实现:基于数组的内存地址计算哈希值 (未重写-hash,继承自NSObject),而非基于数组内容------这会导致"内容相同但内存地址不同的两个NSArray",哈希值不同,字典会判定为两个不同的key,违背key的"内容唯一性"要求。示例:

    NSArray *key1 = @[@"a", @"b"];
    NSArray *key2 = @[@"a", @"b"]; // 内容与key1相同,内存地址不同
    NSDictionary *dict = @{key1: @"value"};
    NSLog(@"%@", dict[key2]); // 输出nil,因为key1和key2哈希值不同,字典认为是不同key

2. 原因二:NSArray(尤其是NSMutableArray)的可变性导致哈希一致性破坏

即使手动重写NSArray的-hash方法(基于内容计算),使其满足"内容相等则哈希值相等",NSArray(或NSMutableArray)仍不适合作为key,核心问题是"可变性":

  • 若使用NSMutableArray作为key,修改数组的元素(如添加、删除、替换元素)后,其内容变化会导致哈希值变化(若-hash基于内容实现);
  • 哈希值变化后,原key在字典中的索引位置会失效,后续无法通过该数组查找到对应的value,字典结构会出现混乱(key存在但查找不到);
  • 即使是不可变的NSArray,其设计初衷是"存储有序元素集合",而非作为key,且未优化哈希计算的效率,频繁作为key会影响字典的查找性能。
特殊情况:自定义不可变数组并重写-hash-isEqual:

理论上,若创建一个自定义的不可变数组类 ,并重写-hash(基于元素内容计算)和-isEqual:(基于元素内容比较),可以作为字典的key,示例如下:

复制代码
// 自定义不可变数组类
@interface ImmutableArrayForKey : NSObject
@property (nonatomic, strong, readonly) NSArray *elements;
- (instancetype)initWithElements:(NSArray *)elements;
@end

@implementation ImmutableArrayForKey
- (instancetype)initWithElements:(NSArray *)elements {
    if (self = [super init]) {
        _elements = [elements copy]; // 确保内部元素不可变
    }
    return self;
}

// 重写-isEqual:,基于元素内容比较
- (BOOL)isEqual:(id)object {
    if (self == object) return YES;
    if (![object isKindOfClass:[ImmutableArrayForKey class]]) return NO;
    return [self.elements isEqual:[(ImmutableArrayForKey *)object elements]];
}

// 重写-hash,基于元素内容计算哈希值
- (NSUInteger)hash {
    return [self.elements hash]; // 复用NSArray的内容哈希计算(需注意NSArray的hash是否基于内容,实际NSArray的hash并非完全基于内容,但此处仅作示例)
}
@end

// 使用示例
ImmutableArrayForKey *key1 = [[ImmutableArrayForKey alloc] initWithElements:@[@"a", @"b"]];
ImmutableArrayForKey *key2 = [[ImmutableArrayForKey alloc] initWithElements:@[@"a", @"b"]];
NSDictionary *dict = @{key1: @"value"};
NSLog(@"%@", dict[key2]); // 输出"value",因为hash和isEqual都基于内容匹配

但这种做法实际意义不大,因为:① 系统已有NSString、NSValue等更适合作为key的类型;② 自定义类的哈希计算效率可能较低;③ 若内部元素是可变对象,仍可能导致哈希一致性破坏。

面试关键点:核心围绕"哈希一致性"和"不可变性"两个要求,解释常用key类型的合理性,以及NSArray不能作为key的根本原因;加分点在于提及"自定义类作为key的条件",体现对key底层要求的深度理解。

记忆方法:"核心条件+反例推导"记忆法------先牢记字典key的两个核心条件(哈希一致、不可变),再推导常用类型(NSString、NSNumber)如何满足这两个条件;对于NSArray,从"哈希实现是否匹配isEqual"和"是否可变"两个反例入手,解释为什么不能作为key,通过"条件→符合/不符合"的逻辑关联强化记忆。

OC中block的内存管理机制是什么?block持有一个局部变量时,block会存放在哪个内存区域?

block的内存管理机制

OC中的block本质是"带有自动变量(局部变量)的匿名函数",其内存管理核心依赖内存区域划分引用计数机制,同时受"变量捕获类型""block是否被强引用"等因素影响,具体可从"内存区域分布""引用计数管理""特殊场景处理"三个维度展开:

1. block的三种内存区域:栈、堆、全局

block的内存区域决定了其生命周期和管理方式,不同内存区域的block有明确的创建条件和特性:

内存区域 创建条件 生命周期 管理方式
栈区(stack block) 未被强引用的局部block(如直接定义在函数中,未赋值给强引用变量) 与所在作用域一致(如函数执行结束后,栈区block被销毁) 系统自动管理(栈区内存自动释放),无需手动管理
堆区(heap block) 被强引用的栈区block(如赋值给__strong修饰的变量、添加到数组中) 受引用计数管理(引用计数为0时释放) 手动管理(ARC下自动插入retain/release,MRC下需手动copy/release)
全局区(global block) 1. 无捕获局部变量的block;2. 捕获的变量是全局变量、静态变量(static)、全局静态变量 程序运行期间一直存在(从程序启动到退出) 系统管理(无需引用计数,不会被释放)
  • 核心转换逻辑:栈区block被强引用时,会自动调用copy方法将其复制到堆区,成为堆区block;堆区block的引用计数为1,后续被其他强引用时,引用计数递增,不再复制(仅增加计数);全局区block无需复制,直接引用即可。
2. 引用计数管理:ARC vs MRC

block的引用计数管理在ARC和MRC下差异较大,核心区别在于"是否自动处理copy/release":

  • MRC(手动引用计数):

    • 栈区block:不能直接强引用(作用域结束后会销毁),若需延长生命周期,必须手动调用copy方法将其复制到堆区,此时引用计数为1;

    • 堆区block:需手动调用release方法释放,引用计数为0时销毁;

    • 错误场景:未调用copy直接将栈区block赋值给强引用变量,作用域结束后block被销毁,后续调用会导致野指针崩溃。

    • 代码示例(MRC):

      void testMRCBlock() {
      // 栈区block(未被强引用)
      void (^stackBlock)(void) = ^{
      NSLog(@"栈区block");
      };

      复制代码
      // 手动copy到堆区
      void (^heapBlock)(void) = [stackBlock copy];
      heapBlock(); // 正常调用
      
      // 手动release堆区block
      [heapBlock release];

      }

  • ARC(自动引用计数):

    • 编译器自动判断block的内存区域,当栈区block被强引用时,自动插入copy指令,将其转为堆区block;

    • 堆区block的引用计数由ARC自动管理(强引用时+1,引用失效时-1),无需手动copy/release;

    • 特殊修饰符:__weak修饰的变量引用堆区block时,不会增加引用计数,避免循环引用;__block修饰的变量若为对象类型,block复制到堆区时,会强引用该变量(需注意循环引用)。

    • 代码示例(ARC):

      void testARCBlock() {
      // 栈区block(未被强引用)
      void (^stackBlock)(void) = ^{
      NSLog(@"栈区block");
      };

      复制代码
      // 强引用,ARC自动copy到堆区
      void (^__strong heapBlock)(void) = stackBlock;
      heapBlock(); // 正常调用
      
      // 无需手动release,ARC自动管理引用计数

      }

3. 特殊场景:block捕获变量对内存管理的影响

block的内存管理还与"捕获的变量类型"相关,不同变量类型的捕获方式不同,进而影响block的内存区域和引用计数:

  • 捕获局部基本数据类型(int、float、BOOL等):
    • 栈区block会直接复制变量的值到block内部(值捕获),不持有变量的引用;
    • 复制到堆区时,变量的值仍存储在block内部,不影响原变量的生命周期,也不会产生循环引用。
  • 捕获局部对象类型(如NSString、NSArray):
    • 栈区block会弱引用该对象(不增加引用计数);
    • 当block被copy到堆区时,会自动强引用该对象(引用计数+1),直到block被销毁,才会释放对该对象的引用(引用计数-1);
    • 风险:若对象同时强引用block,会形成循环引用(如self强引用block,block强引用self),导致两者都无法释放,内存泄漏。
  • 捕获__block修饰的变量:
    • 基本数据类型:__block会将变量包装成一个结构体(__Block_byref_xxx),栈区block持有该结构体的指针,复制到堆区时,block会强引用该结构体;
    • 对象类型:ARC下,__block修饰的对象变量,block复制到堆区时会强引用该对象(与普通对象捕获一致),仍可能产生循环引用(需结合__weak使用)。
  • 捕获全局变量/静态变量:
    • 不会复制变量的值,而是直接引用变量的地址,block无需存储变量内容,因此这类block为全局区block,生命周期与程序一致。
block持有一个局部变量时,block的内存区域

当block持有局部变量时,其内存区域由"是否被强引用"决定,核心规则如下:

1. 未被强引用:栈区(stack block)
  • 场景:block定义在函数内部,仅持有局部变量(基本类型或对象类型),未赋值给任何强引用变量(如__strong修饰的变量、数组、字典等);

  • 原因:局部变量存储在栈区,block捕获局部变量后,若未被强引用,会与局部变量一起存储在栈区,生命周期与所在作用域一致;

  • 示例(ARC):

    void testStackBlock() {
    int a = 10; // 局部基本变量
    NSString *str = @"test"; // 局部对象变量

    复制代码
      // 持有局部变量,未被强引用,为栈区block
      void (^block)(void) = ^{
          NSLog(@"a = %d, str = %@", a, str);
      };
      
      // 打印block地址,可通过地址判断(栈区地址通常较小)
      NSLog(@"block地址:%p", block); // 栈区地址

    }
    // 函数执行结束后,栈区block和局部变量a、str均被销毁

2. 被强引用:堆区(heap block)
  • 场景:持有局部变量的栈区block,被赋值给__strong修饰的变量、添加到NSArray/NSDictionary等容器中,或作为返回值返回;

  • 原因:强引用会触发block的copy操作,将栈区block复制到堆区,同时block会强引用捕获的局部对象变量(基本类型变量仍为值复制),堆区block的生命周期由引用计数管理;

  • 示例(ARC):

    void testHeapBlock() {
    int a = 10;
    NSString *str = @"test";

    复制代码
      // 持有局部变量的栈区block
      void (^block)(void) = ^{
          NSLog(@"a = %d, str = %@", a, str);
      };
      
      // 强引用block,ARC自动copy到堆区
      void (^__strong heapBlock)(void) = block;
      NSLog(@"heapBlock地址:%p", heapBlock); // 堆区地址(比栈区地址大)
      
      // 添加到数组(数组强引用block,同样触发copy)
      NSMutableArray *array = [NSMutableArray array];
      [array addObject:block]; // block被copy到堆区,数组持有其强引用

    }
    // heapBlock和array未释放前,堆区block一直存在

3. 特殊例外:持有全局/静态变量的block(全局区)

若block持有全局变量、静态变量(static),即使同时持有局部变量,只要局部变量是全局/静态类型,block仍为全局区block------因为全局/静态变量存储在全局区,block无需复制变量内容,仅引用地址,因此无需存储在栈区或堆区。

面试关键点:需明确block的三种内存区域及转换逻辑,区分ARC与MRC下的管理差异,重点说明"持有局部变量时,强引用对内存区域的影响";加分点在于结合变量捕获类型(基本类型、对象类型、__block修饰)分析内存管理细节,体现对block底层的深度理解。

记忆方法:"条件-区域-管理"三段式记忆法------先记住block内存区域的三个条件(未强引用→栈区、强引用→堆区、无局部变量/捕获全局变量→全局区),再对应每个区域的管理方式(栈区自动、堆区引用计数、全局区系统管理),最后结合局部变量捕获的场景,强化"强引用触发copy到堆区"的核心逻辑;同时用"生命周期"作为辅助记忆点(栈区随作用域、堆区随引用计数、全局区随程序),串联所有知识点。

如何在block内部修改外部变量?为什么添加特定关键字后可以修改?block的结构是怎样的?

如何在block内部修改外部变量?

默认情况下,block内部无法修改外部的局部变量(基本数据类型或对象类型的指针),需通过特定关键字修饰变量或采用间接方式,具体有三种常用方案,适用场景不同:

1. 方案一:用__block修饰局部变量(推荐,适用于所有局部变量)

__block是OC中专门用于允许block内部修改外部局部变量的关键字,无论变量是基本数据类型还是对象类型,均可通过该关键字实现修改,示例如下:

  • 基本数据类型:

    void testBlockModifyBasicType() {
    __block int count = 0; // 用__block修饰局部变量
    void (^modifyBlock)(void) = ^{
    count = 10; // 可修改,编译通过
    NSLog(@"block内部count:%d", count); // 输出10
    };
    modifyBlock();
    NSLog(@"block外部count:%d", count); // 输出10(外部变量被修改)
    }

  • 对象类型:

    void testBlockModifyObjectType() {
    __block NSMutableArray *array = [NSMutableArray arrayWithObject:@"a"]; // __block修饰
    void (^modifyBlock)(void) = ^{
    [array addObject:@"b"]; // 修改对象内部数据(无需__block也可)
    array = [NSMutableArray arrayWithObject:@"c"]; // 修改对象指针(必须__block)
    NSLog(@"block内部array:%@", array); // 输出@["c"]
    };
    modifyBlock();
    NSLog(@"block外部array:%@", array); // 输出@["c"](指针被修改)
    }

  • 注意:对象类型若仅修改内部数据(如给NSMutableArray添加元素),无需__block修饰;若需修改对象的指针(重新赋值),则必须用__block

2. 方案二:使用全局变量/静态变量(static)

全局变量和静态变量存储在全局区,block捕获时直接引用其地址(而非复制值),因此内部可直接修改,示例如下:

复制代码
// 全局变量
int globalCount = 0;

void testBlockModifyGlobalVar() {
    static int staticCount = 0; // 静态变量
    void (^modifyBlock)(void) = ^{
        globalCount = 20; // 修改全局变量
        staticCount = 30; // 修改静态变量
        NSLog(@"block内部:globalCount=%d, staticCount=%d", globalCount, staticCount); // 20,30
    };
    modifyBlock();
    NSLog(@"block外部:globalCount=%d, staticCount=%d", globalCount, staticCount); // 20,30
}
  • 缺点:全局变量和静态变量生命周期长(程序运行期间存在),容易造成内存泄漏或数据错乱(多线程场景下需加锁),仅适用于全局共享数据的场景。
3. 方案三:通过指针间接修改(适用于基本数据类型)

将局部变量的地址传递给block,block内部通过指针解引用修改变量值,本质是"引用传递"而非"值传递",示例如下:

复制代码
void testBlockModifyByPointer() {
    int num = 5;
    int *numPtr = &num; // 局部变量的指针
    void (^modifyBlock)(void) = ^{
        *numPtr = 15; // 指针解引用,修改原变量值
        NSLog(@"block内部num:%d", *numPtr); // 15
    };
    modifyBlock();
    NSLog(@"block外部num:%d", num); // 15
}
  • 注意:若block被复制到堆区且生命周期长于原变量,原变量被销毁后,指针会变成野指针,解引用会导致崩溃,需确保原变量的生命周期覆盖block的使用周期。
为什么添加__block关键字后可以修改外部变量?

默认情况下block内部无法修改局部变量,核心原因是"值捕获"机制,而__block通过改变变量的存储方式和捕获方式,突破了这一限制:

1. 默认情况:局部变量的"值捕获"与不可修改原因
  • 基本数据类型:block捕获局部变量时,会在block内部创建一个同名的副本,存储变量的值(值捕获),block内部修改的是副本的值,而非原变量,因此外部变量不会变化;同时,编译器为了保证线程安全和内存一致性,会将block内部的副本变量标记为const(只读),直接修改会编译报错。

    • 反编译示例(简化):

      // 原局部变量int count = 0;
      // block内部捕获后生成const副本
      struct __testBlockModifyBasicType_block_impl_0 {
      ...
      const int count; // 副本为const,不可修改
      };

  • 对象类型:block捕获的是对象的指针副本(值捕获指针),内部可通过指针修改对象的内部数据(因为指针指向的对象未变),但无法修改指针本身(指针副本是const的),因此不能给对象重新赋值。

2. __block关键字的作用:变量包装与引用捕获

__block的核心作用是将局部变量包装成一个"可读写的结构体",并让block捕获该结构体的指针(引用捕获),而非变量的值,具体流程如下:

  • 编译期转换:编译器会将__block修饰的变量包装成一个名为__Block_byref_xxx的结构体,结构体内部包含变量的实际值、指向自身的指针(__forwarding)等信息,结构简化如下:

    struct __Block_byref_count_0 {
    void *__isa; // 用于ARC下的内存管理
    __Block_byref_count_0 *__forwarding; // 指向自身(栈区)或堆区的结构体
    int __flags;
    int __size;
    int count; // 变量的实际值
    };

  • 捕获方式变化:block不再捕获变量的值,而是捕获__Block_byref_xxx结构体的指针(非const),因此block内部可通过指针修改结构体中的count值,即原变量的值。

  • 跨内存区域同步:当block被复制到堆区时,__Block_byref_xxx结构体也会被复制到堆区,同时栈区结构体的__forwarding指针会指向堆区的结构体------无论block在栈区还是堆区,通过__forwarding指针都能访问到最新的变量值,保证修改操作的一致性。

block的底层结构

block本质是一个"带有函数指针和捕获变量的OC对象"(因为其结构体包含isa指针,符合OC对象的定义),底层结构由编译器自动生成,核心包含"函数指针、捕获变量、isa指针"三部分,结构定义(简化自objc-runtime-new.h)如下:

复制代码
// block的通用结构体(简化版)
struct __block_impl {
    void *isa; // 指向block的类(如__NSStackBlock__、__NSHeapBlock__、__NSGlobalBlock__)
    int Flags; // 标志位(如是否有copy辅助函数、是否有析构函数)
    int Reserved; // 预留字段,用于兼容
    void *FuncPtr; // 指向block的执行函数(即block{}内部的代码实现)
};

// 具体block的结构体(根据捕获变量动态生成,示例:捕获int变量count)
struct __testBlockModifyBasicType_block_impl_0 {
    struct __block_impl impl; // 基础block结构(包含isa、FuncPtr等)
    struct __testBlockModifyBasicType_block_desc_0 *Desc; // 指向block的描述信息
    __Block_byref_count_0 *count; // 捕获的__block变量(结构体指针)
    
    // 构造函数(编译器自动生成,用于初始化block)
    __testBlockModifyBasicType_block_impl_0(void *fp, struct __testBlockModifyBasicType_block_desc_0 *desc, __Block_byref_count_0 *_count, int flags=0) {
        impl.isa = &__NSStackBlock__; // 初始为栈区block的类
        impl.Flags = flags;
        impl.FuncPtr = fp; // 绑定执行函数
        Desc = desc;
        count = _count; // 绑定捕获的变量结构体指针
    }
};

// block的描述信息结构体
struct __testBlockModifyBasicType_block_desc_0 {
    size_t reserved; // 预留字段
    size_t Block_size; // block结构体的大小
    void (*copy)(struct __testBlockModifyBasicType_block_impl_0*, struct __testBlockModifyBasicType_block_impl_0*); // copy辅助函数(block复制到堆区时调用)
    void (*dispose)(struct __testBlockModifyBasicType_block_impl_0*); // 析构函数(block销毁时调用)
};
结构各部分的核心作用:
  1. __block_impl:block的基础结构,包含OC对象的核心isa指针(决定block的类型,如栈区、堆区、全局区)和FuncPtr(指向block内部代码的执行函数,是block能执行的核心)。
  2. 捕获变量:根据block捕获的变量类型和数量动态添加到结构体中------捕获基本类型变量时,直接存储值(默认const);捕获__block变量时,存储__Block_byref_xxx结构体指针;捕获对象变量时,存储对象指针(ARC下会强引用)。
  3. __block_desc_0:block的描述信息,包含block的大小、copy辅助函数(用于block复制到堆区时,复制捕获的变量,如__Block_byref_xxx结构体)、dispose析构函数(用于block销毁时,释放捕获的变量,如强引用的对象)。
  4. 执行函数(FuncPtr指向的函数):block内部的代码会被编译成一个独立的C函数,函数参数包含block结构体指针(self)和捕获的变量,执行时通过block结构体指针访问捕获的变量。

面试关键点:需明确修改外部变量的三种方案及适用场景,核心解释__block的底层包装机制(结构体+引用捕获),并详细拆解block的底层结构(基础结构、捕获变量、描述信息);加分点在于结合反编译结构说明"值捕获"与"引用捕获"的差异,体现对block编译原理的理解。

记忆方法:"问题-方案-原理-结构"链式记忆法------先记住"block不能修改局部变量"的问题,再对应三种解决方案(__block、全局/静态变量、指针),接着推导__block的底层原理(包装成结构体+引用捕获),最后关联block的整体结构(基础结构+捕获变量+描述信息),通过"问题驱动"串联所有知识点;同时用"isa指针→OC对象""FuncPtr→执行代码""捕获变量→数据存储"三个核心关联,强化对block结构的记忆。

OC中常见的死循环场景有哪些?可能会引起什么崩溃?

OC开发中,死循环的本质是"代码执行流程陷入无限循环,无法退出",常见场景集中在"内存循环引用""逻辑条件错误""多线程同步"三类,不同场景的触发原因和崩溃表现不同,具体如下:

1. 循环引用导致的死循环(最常见,内存层面)

循环引用是指两个或多个对象互相强引用,形成闭环,导致所有对象的引用计数无法降为0,永远无法被释放,本质是"内存层面的死循环"(对象生命周期无限延长),常见场景及示例如下:

(1)block与

能否向一个编译后的类添加实例变量?为什么?

不能直接向编译后的类添加实例变量,核心原因源于OC类的内存结构设计、实例变量的存储机制,以及运行时的限制,具体可从"类的底层结构""实例变量的存储规则""运行时的可行性边界"三个维度展开分析:

1. 类的底层结构:编译期固定的只读核心(class_ro_t)

OC类的底层由Class结构体表示,其核心包含class_ro_t(read-only,只读)和class_rw_t(read-write,可读写)两个关键结构体,两者分工明确:

复制代码
struct objc_class : objc_object {
    Class superclass; // 父类指针
    cache_t cache; // 方法缓存
    class_data_bits_t bits; // 存储类的核心数据(指向class_rw_t)
    
    // 从bits中获取class_rw_t
    class_rw_t *data() {
        return bits.data();
    }
};

// 编译期确定的只读数据
struct class_ro_t {
    uint32_t flags;
    uint32_t instanceStart; // 实例变量的起始偏移量
    uint32_t instanceSize; // 实例对象的内存大小(包含所有实例变量)
    uint32_t reserved;
    const uint8_t *ivarLayout; // 实例变量的内存布局(偏移量、对齐方式)
    const char *name; // 类名
    method_list_t *baseMethodList; // 编译期定义的原始方法列表
    protocol_list_t *baseProtocols; // 编译期遵守的原始协议列表
    const ivar_list_t *ivars; // 编译期定义的原始实例变量列表(核心)
    const uint8_t *weakIvarLayout;
    property_list_t *baseProperties; // 编译期定义的原始属性列表
};

// 运行时可修改的可读写数据
struct class_rw_t {
    uint32_t flags;
    uint32_t version;
    const class_ro_t *ro; // 指向编译期的class_ro_t(只读,不可修改)
    method_list_t *methods; // 运行时添加的方法列表(如分类方法)
    property_list_t *properties; // 运行时添加的属性列表
    protocol_list_t *protocols; // 运行时添加的协议列表
    Class firstSubclass;
    Class nextSiblingClass;
};
  • 关键结论:class_ro_t是编译期生成的只读结构体,包含类的核心原始数据,其中ivars(实例变量列表)、instanceSize(实例对象内存大小)、ivarLayout(实例变量布局)均在编译期固定,运行时无法修改;class_rw_t仅支持动态添加方法、属性、协议,不支持修改class_ro_t中的只读数据。
2. 实例变量的存储规则:内存布局不可动态变更

OC实例对象的内存分配和存储严格依赖编译期确定的规则,这是无法动态添加实例变量的核心技术原因:

  • 实例对象的内存分配:当通过alloc创建实例对象时,Runtime会根据class_ro_t->instanceSize分配固定大小的内存块,该大小刚好容纳所有实例变量(包括父类继承的实例变量),且内存块的地址连续;
  • 实例变量的偏移量:每个实例变量在内存中的位置由ivarLayout确定(编译期计算的固定偏移量),访问实例变量时,通过"对象地址 + 偏移量"直接定位,效率极高;
  • 动态添加的矛盾:若运行时向编译后的类添加实例变量,需要:① 扩大所有已创建实例对象的内存块(从instanceSize扩容);② 重新计算所有实例变量的偏移量(修改ivarLayout);③ 调整所有访问实例变量的代码(更新偏移量引用)------这三项操作在运行时均无法安全完成:
    • 已创建的实例对象内存块是连续分配的,扩大内存会导致与后续内存地址冲突,破坏内存完整性;
    • 偏移量变更会导致原有访问实例变量的代码(如self->ivar)访问到错误的内存地址,引发野指针崩溃;
    • class_ro_t是只读结构,Runtime没有提供修改ivarsinstanceSizeivarLayout的API,从底层禁止了这种操作。
3. 运行时的可行性边界:仅支持"模拟添加",不支持"真正添加"

虽然不能直接添加实例变量,但可通过Runtime的"关联对象(Associated Objects)"机制模拟添加实例变量的效果,但这并非真正意义上的实例变量:

  • 关联对象的本质:关联对象是将额外的数据通过key-value形式绑定到实例对象上,存储在Runtime维护的全局哈希表中,而非实例对象的内存块中;

  • 与真正实例变量的区别:

    对比维度 真正的实例变量 关联对象(模拟)
    存储位置 实例对象的内存块中(连续分配) 全局哈希表中(独立于实例对象内存)
    访问方式 通过偏移量直接访问(效率高) 通过objc_getAssociatedObjectAPI访问(效率较低)
    生命周期 与实例对象一致(对象销毁时自动释放) 需指定关联策略(如OBJC_ASSOCIATION_RETAIN_NONATOMIC),对象销毁时按策略释放
    编译期可见性 编译期可通过@interface声明,编译器检查访问合法性 编译期无声明,需手动管理setter/getter,编译器不检查
  • 代码示例(关联对象模拟添加实例变量):

    // 给UIViewController添加"userID"模拟实例变量
    #import <objc/runtime.h>

    @interface UIViewController (AssociatedVar)
    @property (nonatomic, copy) NSString *userID; // 仅声明属性,无实例变量
    @end

    @implementation UIViewController (AssociatedVar)
    static const void *UserIDKey = &UserIDKey;

    • (void)setUserID:(NSString *)userID {
      // 关联对象,存储到全局哈希表
      objc_setAssociatedObject(self, UserIDKey, userID, OBJC_ASSOCIATION_COPY_NONATOMIC);
      }

    • (NSString *)userID {
      // 读取关联对象
      return objc_getAssociatedObject(self, UserIDKey);
      }
      @end

4. 特殊场景:运行时动态创建类(可添加实例变量)

若类未经过编译(即运行时通过objc_allocateClassPair动态创建的类),则可以添加实例变量------因为这类类的class_ro_t尚未固定,Runtime提供了class_addIvarAPI用于添加实例变量:

复制代码
// 运行时动态创建类并添加实例变量
void createDynamicClass() {
    // 1. 创建动态类(父类为NSObject,类名为"DynamicClass")
    Class DynamicClass = objc_allocateClassPair([NSObject class], "DynamicClass", 0);
    if (!DynamicClass) return;
    
    // 2. 给动态类添加实例变量(类型为NSString*,名称为"dynamicIvar")
    // 注意:必须在objc_registerClassPair之前调用class_addIvar
    if (!class_addIvar(DynamicClass, "dynamicIvar", sizeof(NSString*), log2(sizeof(NSString*)), "@")) {
        NSLog(@"添加实例变量失败");
    }
    
    // 3. 注册类(注册后class_ro_t固定,无法再添加实例变量)
    objc_registerClassPair(DynamicClass);
    
    // 4. 使用动态类
    id instance = [[DynamicClass alloc] init];
    NSString *value = @"test";
    [instance setValue:value forKey:@"dynamicIvar"];
    NSLog(@"dynamicIvar: %@", [instance valueForKey:@"dynamicIvar"]); // 输出"test"
}
  • 关键限制:class_addIvar必须在objc_registerClassPair(注册类)之前调用,注册后类的结构固定,无法再添加实例变量;编译后的类(如项目中通过.h/.m定义的类)本质上已完成注册,因此无法使用class_addIvar添加实例变量。

面试关键点:核心围绕"class_ro_t只读特性"和"实例变量内存布局固定"两个核心原因,明确"编译后类不能直接添加实例变量"的结论;加分点在于区分"真正实例变量"与"关联对象模拟"的差异,以及动态创建类的特殊场景,体现对Runtime底层机制的深度理解。

记忆方法:"结构-存储-边界"三层记忆法------第一层记住类的底层结构(class_ro_t只读+class_rw_t可写),明确实例变量存储在class_ro_t中;第二层理解实例变量的存储规则(内存大小固定+偏移量固定),动态添加会破坏内存完整性;第三层明确运行时边界(编译后类不可加,动态类注册前可加,关联对象是模拟),通过"三层递进"强化记忆;同时用"编译期固定→运行时不可改"作为核心逻辑链,串联所有知识点。

相关推荐
linweidong2 天前
唯品会ios开发面试题及参考答案
ios开发·ios面试·uitableview·nstimer·ios进程·ios线程·swift开发
linweidong3 天前
得物ios开发面试题及参考答案(下)
ios开发·appstore·runloop·自旋锁·ios版本·ios事件·app面试
linweidong9 天前
搜狐ios开发面试题及参考答案
ios面试·nsarray·苹果开发·ios内存·kvo机制·ios设计模式·ios进程
linweidong9 天前
实战救火型 从 500MB 降到 50MB:高频业务场景下的 iOS 内存急救与避坑指南
macos·ios·objective-c·cocoa·ios面试·nstimer·ios面经
linweidong10 天前
猫眼ios开发面试题及参考答案(上)
swift·三次握手·ios面试·nsarray·苹果开发·ios内存·nstimer
linweidong14 天前
网易ios面试题及参考答案(下)
objective-c·swift·ios开发·切面编程·ios面试·苹果开发·mac开发
RollingPin2 个月前
iOS八股文之 网络
网络·网络协议·ios·https·udp·tcp·ios面试
RollingPin2 个月前
iOS八股文之 RunLoop
ios·多线程·卡顿·ios面试·runloop·ios保活·ios八股文
RollingPin2 个月前
iOS八股文之 多线程
ios·多线程·串行并行·gcd·ios面试·同步异步·nsoperation