MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

解析Objective-C Block的内存管理与注意事项

2024-08-173.5k 阅读

一、Objective - C Block 简介

在 Objective - C 中,Block 是一种带有自动变量(局部变量)的匿名函数。它允许我们将一段代码像对象一样进行传递和使用,极大地增强了代码的灵活性和简洁性。例如,我们经常在 GCD(Grand Central Dispatch)中使用 Block 来定义异步执行的任务:

dispatch_async(dispatch_get_main_queue(), ^{
    // 这里的代码将在主线程异步执行
    NSLog(@"This is a block executed asynchronously on the main queue.");
});

在这个例子中,大括号内的代码就是一个 Block,它作为参数传递给 dispatch_async 函数,指定了要在主线程异步执行的任务。

二、Block 的内存管理基础

(一)Block 的存储类型

  1. 栈上的 Block(NSStackBlock) 当 Block 在函数内部定义时,默认情况下它是存储在栈上的。栈上的 Block 生命周期与定义它的函数栈帧相关。一旦函数返回,栈上的 Block 就会被销毁。例如:
void testStackBlock() {
    int value = 10;
    void (^stackBlock)() = ^{
        NSLog(@"Value in stack block: %d", value);
    };
    stackBlock();
}

testStackBlock 函数中定义的 stackBlock 就是一个栈上的 Block。当 testStackBlock 函数执行完毕,stackBlock 占用的栈空间会被释放。 2. 堆上的 Block(NSMallocBlock) 我们可以通过调用 copy 方法将栈上的 Block 复制到堆上。堆上的 Block 有自己独立的生命周期,可以在函数返回后继续存在。例如:

void (^createHeapBlock())() {
    int value = 20;
    void (^stackBlock)() = ^{
        NSLog(@"Value in stack block: %d", value);
    };
    void (^heapBlock)() = [stackBlock copy];
    return heapBlock;
}

createHeapBlock 函数中,stackBlock 最初是栈上的 Block,通过 copy 操作将其复制到堆上得到 heapBlock,并返回 heapBlock。这样即使 createHeapBlock 函数返回,heapBlock 依然可以被正常使用。 3. 全局的 Block(NSGlobalBlock) 当 Block 不捕获任何自动变量时,它会被存储在全局区,称为全局 Block。全局 Block 的生命周期与程序相同。例如:

void (^globalBlock)() = ^{
    NSLog(@"This is a global block.");
};

这里的 globalBlock 没有捕获任何自动变量,所以它是一个全局 Block,在程序启动时就存在,直到程序结束才销毁。

(二)Block 对捕获变量的内存管理

  1. 自动变量的捕获 Block 可以捕获其定义时所在作用域内的自动变量。当 Block 捕获自动变量时,它会在内部创建这些变量的副本。例如:
void testCapture() {
    int num = 30;
    void (^block)() = ^{
        NSLog(@"Captured num: %d", num);
    };
    num = 40;
    block();
}

在这个例子中,block 捕获了 num 变量。尽管在 block 定义后 num 的值被修改为 40,但 block 内部打印的依然是捕获时 num 的值 30,因为它捕获的是 num 的副本。 2. __block 修饰符 有时候我们希望在 Block 内部修改被捕获的自动变量的值。这时就需要使用 __block 修饰符。例如:

void testBlockModification() {
    __block int num = 50;
    void (^block)() = ^{
        num = 60;
    };
    block();
    NSLog(@"Modified num: %d", num);
}

在这个例子中,通过 __block 修饰 num 变量,使得 block 内部可以修改 num 的值。最终打印的结果是 60。从内存管理角度看,__block 修饰的变量在被 Block 捕获时,其内存管理会变得更复杂。__block 修饰的变量在栈上创建,但当被 Block 捕获时,会被移动到堆上,以确保在 Block 的生命周期内可以被正确访问和修改。

三、Block 在不同场景下的内存管理

(一)作为方法参数的 Block

  1. ARC 下的内存管理 在自动引用计数(ARC)环境下,当 Block 作为方法参数传递时,ARC 会自动处理 Block 的内存管理。例如,在 UIView 的动画方法中经常使用 Block:
UIView *view = [[UIView alloc] initWithFrame:CGRectMake(100, 100, 100, 100)];
[view setBackgroundColor:[UIColor redColor]];
[self.view addSubview:view];
[UIView animateWithDuration:1.0 animations:^{
    view.alpha = 0.5;
} completion:^(BOOL finished) {
    if (finished) {
        [view removeFromSuperview];
    }
}];

在这个例子中,传递给 animateWithDuration:animations:completion: 方法的两个 Block 由 ARC 自动管理内存。ARC 会确保在合适的时机对 Block 进行 retainrelease 操作,开发者无需手动干预。 2. MRC 下的内存管理 在手动引用计数(MRC)环境下,情况会有所不同。当 Block 作为方法参数传递时,我们需要手动管理 Block 的引用计数。例如:

void performBlockWithCompletion(void (^completionBlock)()) {
    // 手动 retain Block
    [completionBlock retain];
    // 模拟一些异步操作
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        // 执行 Block
        completionBlock();
        // 手动 release Block
        [completionBlock release];
    });
}

在这个例子中,performBlockWithCompletion 方法接收一个 Block 参数。在方法内部,我们手动对 Block 进行 retain 操作,以确保在异步执行 Block 期间 Block 不会被释放。执行完 Block 后,再手动 release Block。

(二)Block 作为类的属性

  1. ARC 下的内存管理 在 ARC 下,当 Block 作为类的属性时,我们通常使用 copy 修饰符来声明属性。例如:
@interface MyClass : NSObject
@property (nonatomic, copy) void (^myBlock)();
@end

@implementation MyClass
- (void)executeBlock {
    if (self.myBlock) {
        self.myBlock();
    }
}
@end

在这个例子中,myBlock 属性使用 copy 修饰。这是因为在 ARC 下,当 Block 赋值给属性时,通过 copy 操作可以确保 Block 被复制到堆上,从而避免栈上 Block 生命周期结束导致的问题。 2. MRC 下的内存管理 在 MRC 下,同样建议使用 copy 修饰 Block 属性。并且在类的 dealloc 方法中,需要手动释放 Block。例如:

@interface MyClass : NSObject
@property (nonatomic, copy) void (^myBlock)();
@end

@implementation MyClass
- (void)dealloc {
    [_myBlock release];
    [super dealloc];
}
@end

dealloc 方法中,手动 release myBlock,以避免内存泄漏。

四、Block 内存管理中的注意事项

(一)循环引用问题

  1. 强引用循环 当 Block 捕获对象类型的自动变量且该变量对 Block 有强引用时,就容易产生强引用循环。例如:
@interface MyObject : NSObject
@property (nonatomic, copy) void (^myBlock)();
@end

@implementation MyObject
- (void)setupBlock {
    self.myBlock = ^{
        NSLog(@"Object description: %@", self);
    };
}
@end

在这个例子中,MyObjectsetupBlock 方法中,self.myBlock 捕获了 self。同时,self 又对 myBlock 有强引用(通过属性),这样就形成了强引用循环。如果不解决这个问题,MyObject 实例和 myBlock 都无法被释放,导致内存泄漏。 2. 解决强引用循环 (1)使用 __weak 修饰符(ARC 环境) 在 ARC 环境下,我们可以使用 __weak 修饰符来打破强引用循环。例如:

@interface MyObject : NSObject
@property (nonatomic, copy) void (^myBlock)();
@end

@implementation MyObject
- (void)setupBlock {
    __weak typeof(self) weakSelf = self;
    self.myBlock = ^{
        __strong typeof(weakSelf) strongSelf = weakSelf;
        if (strongSelf) {
            NSLog(@"Object description: %@", strongSelf);
        }
    };
}
@end

在这个例子中,首先通过 __weak 修饰符创建一个弱引用 weakSelf。在 Block 内部,再通过 __strong 修饰符创建一个强引用 strongSelf。这样在 Block 执行期间,strongSelf 会保持对象的强引用,确保对象不会被释放。而 weakSelf 不会增加对象的引用计数,从而打破了强引用循环。 (2)使用 __block 修饰符(MRC 环境) 在 MRC 环境下,可以使用 __block 修饰符来解决强引用循环。例如:

@interface MyObject : NSObject
@property (nonatomic, copy) void (^myBlock)();
@end

@implementation MyObject
- (void)setupBlock {
    __block MyObject *blockSelf = self;
    self.myBlock = ^{
        NSLog(@"Object description: %@", blockSelf);
        blockSelf = nil;
    };
}
@end

在这个例子中,通过 __block 修饰 blockSelf,使得在 Block 内部可以将 blockSelf 置为 nil。当 blockSelf 置为 nil 时,就打破了强引用循环。但需要注意的是,在 ARC 环境下,__block 修饰的对象不会自动释放,可能会导致内存泄漏,所以在 ARC 下不推荐使用 __block 来解决循环引用问题。

(二)Block 内部对对象的持有

  1. 对象的强持有 Block 对捕获的对象类型自动变量是强持有。例如:
@interface MyObject : NSObject
@end

@implementation MyObject
@end

void testBlockObjectCapture() {
    MyObject *obj = [[MyObject alloc] init];
    void (^block)() = ^{
        NSLog(@"Object in block: %@", obj);
    };
    // 在 Block 定义后,obj 的引用计数会增加
    block();
    [obj release];
}

在这个例子中,block 捕获了 obj,从而对 obj 进行强持有。在 Block 定义后,obj 的引用计数会增加。当 block 执行完毕,obj 的引用计数不会自动减少,直到手动 release obj。 2. 避免不必要的对象持有 在编写 Block 时,应尽量避免不必要的对象捕获。例如,如果 Block 内部不需要访问某个对象的属性或方法,就不应该捕获该对象。这样可以减少内存占用和潜在的内存管理问题。例如:

@interface MyObject : NSObject
@property (nonatomic, assign) int value;
@end

@implementation MyObject
@end

void testUnnecessaryCapture() {
    MyObject *obj = [[MyObject alloc] init];
    obj.value = 10;
    // 这里 Block 只需要使用 value,而不是整个对象
    void (^block)() = ^{
        NSLog(@"Value: %d", obj.value);
    };
    // 可以改为只捕获 value
    int value = obj.value;
    void (^betterBlock)() = ^{
        NSLog(@"Value: %d", value);
    };
    [obj release];
}

在这个例子中,最初的 block 捕获了整个 obj 对象。而实际上只需要 objvalue 属性,所以可以改为捕获 value 变量,避免对 obj 对象的不必要持有。

(三)Block 的生命周期与对象的生命周期

  1. 确保 Block 生命周期与对象匹配 当 Block 作为对象的属性或与对象的生命周期紧密相关时,需要确保 Block 的生命周期与对象的生命周期相匹配。例如,如果一个对象在其 dealloc 方法中执行某个 Block,那么这个 Block 必须在对象销毁前一直存在。
@interface MyObject : NSObject
@property (nonatomic, copy) void (^cleanupBlock)();
@end

@implementation MyObject
- (void)dealloc {
    if (self.cleanupBlock) {
        self.cleanupBlock();
    }
    [super dealloc];
}
@end

在这个例子中,MyObjectcleanupBlockdealloc 方法中执行。所以在设置 cleanupBlock 时,要确保 cleanupBlockMyObject 对象销毁前不会被提前释放。 2. 注意 Block 执行时机对内存管理的影响 Block 的执行时机也会影响内存管理。例如,当 Block 被延迟执行时,可能需要考虑在 Block 执行前相关对象的状态。例如:

@interface MyObject : NSObject
@property (nonatomic, strong) NSString *name;
@end

@implementation MyObject
@end

void testDelayedBlock() {
    MyObject *obj = [[MyObject alloc] init];
    obj.name = @"Test Name";
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        NSLog(@"Object name: %@", obj.name);
    });
    [obj release];
}

在这个例子中,dispatch_after 延迟执行 Block。如果在 Block 执行前 obj 被释放,那么 Block 中访问 obj.name 就会导致野指针错误。所以在这种情况下,需要确保在 Block 执行期间 obj 依然有效,或者在 Block 内部进行适当的空指针检查。

五、深入理解 Block 的内存管理原理

(一)Block 的结构

  1. Block 结构体 在底层,Block 是一个结构体。其结构体大致定义如下(简化版):
struct Block_descriptor {
    unsigned long int reserved;
    unsigned long int size;
    void (*copy)(void *dst, void *src);
    void (*dispose)(void *);
};

struct Block_layout {
    void *isa;
    int flags;
    int reserved;
    void (*invoke)(void *, ...);
    struct Block_descriptor *descriptor;
    // 捕获的变量
};

isa 指针指向 Block 的类,flags 用于存储一些标志信息,invoke 是指向 Block 实际执行代码的函数指针,descriptor 指向一个描述 Block 信息的结构体,包括 Block 的大小、copydispose 函数等。捕获的变量存储在结构体的末尾。 2. 不同存储类型 Block 的结构差异 栈上的 Block(NSStackBlock)其 isa 指针指向栈上 Block 的类。当调用 copy 方法将栈上 Block 复制到堆上时,会创建一个新的堆上 Block 结构体,isa 指针会指向堆上 Block 的类。全局 Block(NSGlobalBlock)由于存储在全局区,其结构相对简单,不需要进行 copydispose 操作,descriptor 中的 copydispose 函数指针为 NULL

(二)Block 的内存管理函数

  1. copy 函数 当调用 Block 的 copy 方法时,会调用 descriptor 中的 copy 函数。对于栈上的 Block,copy 函数会将 Block 从栈上复制到堆上,并对捕获的变量进行相应的处理。例如,如果捕获的是对象类型变量,会对其进行 retain 操作。对于已经在堆上的 Block,copy 函数通常只是增加引用计数。
  2. dispose 函数 当 Block 的引用计数降为 0 时,会调用 descriptor 中的 dispose 函数。dispose 函数会对 Block 捕获的变量进行清理,例如对对象类型变量进行 release 操作。通过理解 copydispose 函数的工作原理,我们可以更好地掌握 Block 的内存管理机制。

六、实际应用中的内存管理优化

(一)减少 Block 的创建频率

  1. 复用 Block 在一些情况下,我们可以复用 Block 而不是每次都创建新的 Block。例如,在一个循环中,如果每次循环执行的 Block 逻辑相同,可以将 Block 定义在循环外部。例如:
void testBlockReuse() {
    void (^printMessageBlock)() = ^{
        NSLog(@"This is a reused block.");
    };
    for (int i = 0; i < 10; i++) {
        printMessageBlock();
    }
}

在这个例子中,printMessageBlock 在循环外部定义,避免了在每次循环中创建新的 Block,从而减少了内存分配和释放的开销。 2. 使用全局 Block 如果某个 Block 在程序的多个地方使用且不捕获自动变量,可以将其定义为全局 Block。这样可以避免重复创建 Block 带来的内存开销。例如:

void (^globalPrintBlock)() = ^{
    NSLog(@"This is a global block.");
};

void function1() {
    globalPrintBlock();
}

void function2() {
    globalPrintBlock();
}

在这个例子中,globalPrintBlock 是一个全局 Block,在 function1function2 中都可以直接使用,无需每次创建新的 Block。

(二)优化 Block 捕获的变量

  1. 只捕获必要的变量 正如前面提到的,应尽量只捕获 Block 内部真正需要的变量。避免捕获不必要的对象,减少内存占用和潜在的循环引用风险。例如,如果一个 Block 只需要某个对象的一个属性值,就直接捕获该属性值而不是整个对象。
  2. 合理选择捕获变量的修饰符 在 ARC 环境下,对于对象类型变量,如果希望避免强引用循环,使用 __weak 修饰符捕获对象。在 MRC 环境下,如果需要在 Block 内部修改捕获的对象,使用 __block 修饰符,但要注意手动管理对象的引用计数,避免内存泄漏。

(三)分析和检测内存问题

  1. 使用 Instruments Xcode 提供的 Instruments 工具可以帮助我们分析和检测内存问题。例如,使用 Instruments 中的 Allocations 工具可以查看 Block 的内存分配和释放情况,找出潜在的内存泄漏。使用 Leaks 工具可以直接检测出内存泄漏点,包括由于 Block 导致的内存泄漏。
  2. 代码审查 在代码审查过程中,重点检查 Block 的定义和使用,特别是捕获变量的情况以及是否存在循环引用。通过仔细审查代码,可以在开发过程中及时发现和解决内存管理问题,提高代码的稳定性和性能。

通过深入理解 Objective - C Block 的内存管理原理和注意事项,并在实际应用中进行优化,我们可以编写出高效、稳定且内存管理良好的代码。无论是在 ARC 还是 MRC 环境下,掌握这些知识对于开发高质量的 iOS 和 macOS 应用都至关重要。