admin 管理员组文章数量: 887021
理解引用计数
Objective-C 使用引用计数来管理内存:每个对象都有个可以递增或递减的计数器。如果想使某个对象继续存活,那就递增其引用计数:用完了之后,就递减其计数。计数变为 0时,就可以把它销毁。
在ARC中,所有与引用计数有关的方法都无法编译(由于 ARC 会在编译时自动插入内存管理代码,因此在编译时,所有与引用计数相关的方法都会被 ARC 替换为适当的代码)。
引用计数的工作原理
在引用计数架构下,对象有个计数器,用以表示当前有多少个事物想令此对象继续存活下去。这在 Objective-C 中叫做 “保留计数”也可以叫 “引用计数”。NSObject 协议声明了下面三个方法用于操作计数器,以递增或递减其值:
- Retain 递增保留计数。
- release 递减保留计数。
- autorelease 待稍后清理 “自动释放池”时,再递减保留计数。
查看保留计数的方法叫做 retainCount,不推荐使用。
对象创建出来时,其保留计数至少为 1。若想令其继续存活,则调用 retain 方法。要是某部分代码不再使用此对象,不想令其继续存活,那就调用 release 或 autorelease 方法。最终当保留计数归零时,对象就回收了,也就是说系统会将其占用的内存标记为 “可重用”。此时,所有指向该对象的引用也都变得无效。
应用程序在其生命周期中会创建很多对象,这些对象都相互联系着。例如,表示个人信息的对象会引用另一个表示人名的字符串对象,还可能会引用其他个人信息对象,比如在存放朋友的 set 中就是如此。这些相互关联的对象就构成了一张 “对象图”。对象如果持有指向其他对象的强引用,那么前者就 “拥有” 后者。对象想令其所引用的那些对象继续存活,就可将其 “保留”。等用完了之后,再释放。
按 “引用树” 回溯,那么最终会发现一个 “根对象”。在 iOS 应用程序中,它是 UIApplication 对象。是应用程序启动时创建的单例。
如下面这段代码:
NSMutableArray *array = [[NSMutableArray alloc] init];
NSNumber *number = [[NSNumber alloc] initWithInt:1337];
[array addObject:number];
[number release];
//不能假设number对象一定存活
NSLog(@"number = %@", number);//***
//因为对象所占的内存在 “解除分配”之后,只是放回 “可用内存池”。如果执行 NSLog 时尚未覆写对象内存,那么该对象仍然有效,这时程序不会崩溃。
//因过早释放对象而导致的 bug 很难调试。
[array release];
在上面这段代码中:创建了一个可变数组 array,然后创建了一个 NSNumber 对象 number,并将其添加到数组中,数组也会在 number 上调用retain 方法,以期继续保留此对象,这时number的引用计数至少为2。接着,我们试着通过 release 方法释放了 number 对象,因为数组对象还在引用着number对象,因此它仍然存活,但是不应该假设它一定存活。最后,通过调用 release 方法释放了数组 array,确保了内存的正确管理。上面这段代码在ARC中是无法编译的,因为调用了release方法。
调用者通过 alloc 方法表达了想令该对象继续存活下去的意愿,不过并不是说对象此时的保留计数必定是1。在 alloc 或 “initWithInt:” 方法的实现代码中,也许还有其他对象也保留了此对象(如初始化过程中的委托调用、通知其他对等),所以,其保留计数可能会大于 1。
为避免在不经意间使用了无效对象,一般调用完 release 之后都会清空指针。这就能保证不会出现可能指向无效对象的指针,这种指针通常称为 “悬挂指针”。
可以这样编写代码来防止此情况发生:
NSNumber *number = [[NSNumber alloc] initWithInt:1337];
[array addObject:number];
[number release];
number = nil;
属性存取方法中的内存管理
刚才那个例子中的数组通过在其元素上调用 retain 方法来保留那些对象。不光是数组,其他对象也可以保留别的对象,这一般通过访问 “属性”来实现,而访问属性时,会用到相关实例变量的获取方法及设置方法。若属性为 “strong 关系”,则设置的属性值会保留。假设有个名叫 foo 的属性由名为 _foo 的实例变量所实现,那么,该属性的设置方法会是这样:
- (void)setFoo:(id)foo {
[foo retain];
[_foo release];
_foo = foo;
}
当设置属性 foo 的新值时,属性的设置方法会依次执行以下操作:
- 保留新值:使用 retain 方法保留新值,确保其在设置方法之外仍然可用。
- 释放旧值:使用 release 方法释放旧值,以减少其保留计数,并在不再需要时释放内存。
- 更新实例变量:将实例变量 _foo 的引用指向新值。
上面这些操作的顺序很重要。如果在保留新值之前就释放了旧值,并且旧值和新值指向同一个对象,那么在释放旧值时可能会导致对象被系统回收,而在后续保留新值时,该对象已经不存在了。这就会导致 _foo 成为一个悬挂指针,即指向已经释放的内存空间,这样的指针是无效的,并且可能导致应用程序崩溃或出现其他问题。
自动释放池
自动释放池(autorelease)是 Objective-C 内存管理的重要特性之一。
简单来说它允许开发者推迟对象的释放时间,通常在下一个事件循环中才执行释放操作。
比如说有这个代码:
- (NSString *)stringValue {
NSString *str = [[NSString alloc] initWithFormat:@"I am this: %@", self];
return str;
}
在上面这段代码中,str对象在stringValue方法的作用域中被alloc出来,因此它的引用计数为1,但是因为它的作用域是stringValue方法,因此我们期望的是它在该方法结束前引用计数应为0,但是因为缺少了释放操作,因此该str对象的引用计数为1比期望值多1。
但是我们又不能直接在stringValue方法中将str对象释放,否则还没等方法返回,系统就把该对象回收了。这里应该用 autorelease,它会在稍后释放对象,从而给调用者留下了足够长的时间,使其可以在需要时先保留返回值。换句话说,此方法可以保证对象在跨越 “方法调用边界”(method callboundary)后一定存活。
- 因此我们需要改写 stringValue 方法,使用 autorelease 来释放对象:
- (NSString *)stringValue {
NSString *str = [[NSString alloc] initWithFormat:@"I am this: %@", self];
return [str autorelease];
}
改写后使用了 autorelease 方法将str对象放入自动释放池中,以延迟其释放时间。这样,在方法返回时,对象的引用计数会保持预期的值,而不会多出 1。
可以像下面这样使用 stringValue 方法返回的字符串对象:
NSString *str = [self stringValue];
NSLog(@"The string is: %@", str);
在第一段代码中,NSString *str = [self stringValue]; 返回的字符串对象 str 是一个被放入自动释放池的对象。因此,尽管没有显式地调用 retain 方法,但在 NSLog(@“The string is: %@”, str); 之后,str 对象的引用计数不会被减少。这是因为该对象在自动释放池中,直到下一个事件循环才会被释放。
但是,如果你需要在稍后持有这个对象,比如将它设置给一个实例变量,那么你需要手动增加其引用计数,以防止在自动释放池释放时对象被释放,比如像这样,假设在另一个地方创建了一个 ExampleClass 的实例,并希望将stringValue返回的对象设置为该实例的 instanceVariable:
ExampleClass *exampleObject = [[ExampleClass alloc] init];
NSString *str = [exampleObject stringValue];
exampleObject.instanceVariable = str;
NSLog(@"The instance variable is: %@", exampleObject.instanceVariable);
则需要确保 str 对象不会在自动释放池被释放时被释放。因此,我们需要手动增加 str 对象的引用计数,以确保它不会被过早释放:
//手动增加引用计数
exampleObject.instanceVariable = [str retain];
//......
//并且在稍后手动释放
[exampleObject.instanceVariable release];
保留环
用引用计数机制时,经常要注意的一个问题就是 “保留环”,也就是呈环状相互引用的多个对象。这将导致内存泄漏,因为循环中的对象其保留计数不会降为 0。对于循环中的每个对象来说,至少还有另外一个对象引用着它。图里的每个对象都引用了另外两个对象之中的一个。在这个循环里,所有对象的保留计数都是 1。
在垃圾收集环境中,通常将这种情况认定为 “孤岛”。此时,垃圾收集器会把三个对象全都回收走。而在 Objective-C 的引用计数架构中,则享受不到这一便利。通常采用 “弱引用”来解决此问题,或是从外界命令循环中的某个对象不再保留另外一个对象。这两种办法都能打破保留环,从而避免内存泄漏。
- 引用计数机制通过可以递增递减的计数器来管理内存。对象创建好之后,其保留计数至少为 1。若保留计数为正,则对象继续存活。当保留计数降为 0 时,对象就被销毁了。
- 在对象生命期中,其余对象通过引用来保留或释放对象。保留于释放操作分别会递增及递减保留计数。
以ARC简化引用计数
引用计数这个概念相当容易理解。需要执行保留与释放操作的地方也很容易就能看出来。所以 Clang 编译器项目带有一个 “静态分析器”。用于指明程序里引用计数出问题的地方。
比如再拿上一条中的这段代码举例子:
if ([self shouldLogMessage]) {
NSString *str = [[NSString alloc] initWithFormat:@"I am this: %@", self];
NSLog(@“str = %@”, str);
}
这段代码中,alloc增加了str对象的引用计数,但是在这个if块的作用域中,它却缺少了释放操作,因此str对象的引用计数比预期值多1,导致了内存泄漏。因为上述这些规则很容易表述,所以计算机可以简单地将其套用在程序上,从而分析出有内存泄漏问题的对象。这正是 “静态分析器” 要做的事。
静态分析器还有更为深入的用途。既然可以查明内存管理问题,那么应该也可以根据需要,预先加入适当的保留或释放操作以避免这些问题。自动引用计数的思路就是源于此。
因此假如使用了ARC,它就会自动将代码改写为这样:
if ([self shouldLogMessage]) {
NSString *str = [[NSString alloc] initWithFormat:@"I am this: %@", self];
NSLog(@“str = %@”, str);
[message release];
}
使用 ARC 时一定要记住,引用计数实际上还是要执行的,只不过保留与释放操作现在是由 ARC 自动为你添加。
由于 ARC 会自动执行 retain、release 、autorelease 等操作,所以直接在 ARC 下调用这些内存管理方法是非法的。具体来说,不能调用下列方法:
- retain
- release
- autorelease
- dealloc
直接调用上述任何方法都会产生编译错误,因为 ARC 要分析何处应该自动调用内存管理方法,所以如果手工调用的话,就会干扰其工作。
实际上,ARC 在调用这些方法时,并不通过普通的 Objective-C 消息派发机制,而是直接调用其底层 C 语言版本。这样做性能更好,因为保留及释放操作需要频繁执行,所以直接调用底层函数能节省很多 CPU 周期。
使用 ARC 时必须遵循的方法命名规则
ARC 将内存管理语义在方法名中表示出来确立为硬性规定。
简单地体现在方法名上。若方法名以下列词语开头,则其调用上述四种方法的那段代码要负责释放方法所返回的对象:
- alloc
- new
- copy
- mutableCopy
若方法名不以上述四个词语开头,则表示其所返回的对象并不归调用者所有。 在这种情况下,返回的对象会自动释放,所以其值在跨越方法调用边界后依然有效。要想使对象多存活一段时间,必须令调用者保留它才行。
(我自己简单理解就是使用 “alloc”、“new”、“copy” 或者 “mutableCopy” 开头的方法,方法内部的引用计数要自己手动管理(release或者autorelease等),而不使用这四个开头的方法的引用计数就是自动管理的)。
除了会自动调用 “保留” 与 “释放” 方法外,使用 ARC 还有其他好处,它可以执行一些手工操作很难甚至无法完成的优化,例如,在编译器,ARC 会把能够互相抵消的retain、release、autorelease 操作约简。如果发现在同一对象上执行了多次 “保留” 与 “释放” 操作,那么 ARC 有时可以成对地移除这两个操作。
ARC 也包含运行期组件。此时所执行的优化很有意义,大家看到之后就会明白为何以后的代码都应该用 ARC 来写了。前面讲到,某些方法在返回对象前,为其执行了 autorelease 操作,而调用方法的代码可能需要将返回的对象保留,比如像下面这种情况就是如此:
_myPerson = [EOCPerson personWithName:@"Bob smith"];
调用 “personWithName:” 方法会返回新的 EOCPerson 对象,而此方法在返回对象之前,为其调用了 autorelease 方法。由于实例变量是个强引用,所以编译器在设置其值的时候还需要执行一次保留操作。因此,前面那段代码与下面这段手工管理引用计数的代码等效:
EOCPerson *tmp = [EOCPerson personWithName:@"Bob Smith"];
_myPerson = [tmp retain];
此时应该能看出来, “personWithName:” 方法里面的 autorelease 与上段代码中的 retain 都是多余的。为提升性能,可将二者删去。但是,在 ARC 环境下编译代码时,必须考虑 “向后兼容性”(backward compatibility),以兼容那些不使用 ARC 的代码。
在 ARC 环境下,编译器会尽可能地优化代码,以提高性能和效率。在处理方法中返回自动释放的对象时,编译器可以通过一些特殊的函数来优化代码,从而避免不必要的 autorelease 和 retain 操作,提升代码的执行效率。
具体来说,在方法中返回自动释放的对象时,编译器会替换对 autorelease 方法的调用,改为调用 objc_autoreleaseReturnValue 函数。这个函数会检查方法返回后即将执行的代码,如果发现需要在返回的对象上执行 retain 操作,那么就会设置一个标志位,而不会立即执行 autorelease 操作。类似地,如果调用方法的代码需要保留返回的自动释放对象,那么编译器会将 retain 操作替换为 objc_retainAutoreleasedReturnValue 函数,该函数会检查之前设置的标志位,如果已经设置,则不会执行 retain 操作。
objc_autoreleaseReturnValue 函数检测方法调用者是否会立刻保留对象要根据处理器来定。
变量的内存管理语义
ARC 也会处理局部变量与实例变量的内存管理。默认情况下,每个变量都是指向对象的强引用。
在编写设置方法(setter)时,使用 ARC 会简单一些。如果不用 ARC ,那么需要像下面这样来写:
- (void)setObject:(id)object {
[_object release];
_object = [object retain];
}
但是在这段代码中,如果新值和实例变量已有的值相同,那么在执行设置方法时会出现问题。具体来说,当新值和旧值相同时,首先会调用 [_object release] 来释放旧值,此时如果旧值只有当前对象在引用,那么旧值的引用计数会减少为0。接着,会调用 [object retain] 来保留新值,但是此时旧值的内存已经被释放掉了,再次对其执行保留操作就会导致访问已释放的内存,从而引发应用程序崩溃。
使用 ARC 之后,就不可能发生这种疏失了。在 ARC 环境下,与刚才等效的设置函数可以这么写:
- (void)setObject:(id)object {
_object = object;
}
ARC 会用一种安全的方式来设置:先保留新值,再释放旧值,最后设置实例变量。用了 ARC 之后,根本无须考虑这种 “边界情况”。
- 在应用程序中,可用下列修饰符来改变局部变量与实例变量的语义:
__strong: 默认语义,保留此值。
__unsafe_unretained: 不保留此值,这么做可能不安全,因为等到再次使用变量时,其对象可能已经回收了。
__weak: 不保留此值,但是变量可以安全使用,因为如果系统把这个对象回收了,那么变量也会自动清空。
__autoreleasing: 把对象 “按引用传递” (pass by reference)给方法时,使用这个特殊的修饰符。此值在方法返回时自动释放。
比方说,想令实例变量的语义与不使用 ARC 时相同,可以运用 __weak 或 __unsafe_unretained 修饰符:
@interface EOCClass : NSObject {
__weak id _weakObject;
__unsafe_unretained id _unsafeUnretainedObject;
}
@end
我们经常会给局部变量加上修饰符,用以打破由“块”,所引入的“保留环”。块会自动保留其所捕获的全部对象,而如果这其中有某个对象又保留了块本身,那么就可能导致 “保留环”。可以用 __weak 局部变量来打破这种 “保留环”:
NSURL *url = [NSURL URLWithString:@"http://www.example/"];
EOCNetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
EOCNetworkFetcher *__weak weakFetcher = fetcher;
[fetcher startWithCompletion:^(BOOL success) {
NSLog(@"Finished fetching from %@", weakFetcher.url);
}];
在这段代码中,我们使用了 __weak 修饰符来声明一个局部变量 weakFetcher,它指向 fetcher 对象。通过使用 __weak 修饰符,我们避免了块对 fetcher 对象的强引用,从而打破了潜在的保留环。
ARC 如何清理实例变量
ARC 也负责对实例变量进行内存管理。要管理其内存,ARC 就必须在 “回收分配给对象的内存”(deallocate)(也称为 “释放/回收/解除分配(内存)”) 时生成必要的清理代码。凡是具备强引用的变量,都必须释放,ARC 会在 dealloc 方法中插入这些代码。当手动管理引用计数时可以这样自己来编写 dealloc 方法:
- (void)dealloc {
[_foo release];
[_bar release];
[super dealloc];
}
这段代码做了以下几件事情:
- 释放 _foo 实例变量:调用 release 方法来减少对 _foo 对象的引用计数,如果引用计数为0,则会释放 _foo 对象所占用的内存。
- 释放 _bar 实例变量:同样地,调用 release 方法来减少对 _bar 对象的引用计数,如果引用计数为0,则会释放 _bar 对象所占用的内存。
- 调用 super 的 dealloc 方法:调用父类的 dealloc 方法来执行一些必要的清理工作,确保对象的内存被正确释放。
使用 ARC 之后,不需要再编写这种 dealloc 方法。不过,如果有非 Objective-C 的对象,比如 CoreFoundation 中的对象或是由 malloc() 分配在堆中的内存,那么仍然需要清理。然而不需要像原来那样调用超类的 dealloc 方法。前文说过,在 ARC 下不能直接调用 dealloc。ARC 会自动在 .cxx_destruct 方法中生成代码并运行此方法,而在生成的代码中会自动调用超类的 dealloc 方法。ARC 环境下,dealloc 方法可以像这样写:
- (void)dealloc {
CFRelease(_coreFoundationObject);
free(_heapAllocatedMemoryBlob);
}
可以使用CFRelease() 函数来释放CoreFoundation 对象。
可以使用 free() 函数来释放通过 malloc() 函数分配在堆上的内存块_heapAllocatedMemoryBlob
覆写内存管理方法
不使用 ARC 时,可以覆写内存管理方法。比方说,在实现单例类的时候,因为单例不可释放,所以我们经常覆写 release 方法,将其替换为 “空操作”(no-op)。但在 ARC 环境下不能这么做,因为会干扰到 ARC 分析对象生命期的工作。而且,由于开发者不可调用及覆写这些方法,所以 ARC 能够优化 retain、release、autorelease 操作,使之不经过 Objective-C 的消息派发机制。优化后的操作,直接调用隐藏在运行期程序中的 C 函数。
- 有 ARC 之后,程序员就无须担心内存管理问题了。使用 ARC 来编程,可省去类中的许多 “样板代码”。
- ARC 管理对象生命期的办法基本上就是:在合适的地方插入 “保留” 及 “释放”操作。
在 ARC 环境下,变量的内存管理语义可以通过修饰符指明,而原来需要手工执行 “保留” 及 “释放”操作。 - 由方法所返回的对象,其内存管理语义总是通过方法名来体现。ARC 将此确定为开发者必须遵守的规则。
- ARC 只负责管理 Objective-C 对象的内存。尤其要注意: CoreFoundation 对象不归 ARC 管理,开发者必须适时调用 CFRetain/CFRelease。
在dealloc方法中只释放引用并解除监听
对象在经历其生命期后,最终会为系统所回收,这时就要执行 dealloc 方法了。在每个对象的生命期内,此方法仅执行一次,也就是当保留计数降为 0 的时候。然而具体何时执行,则无法保证。也可以理解成: 我们能够通过人工观察保留操作与释放操作的位置。来预估此方法何时即将执行。但实际上,程序库会以开发者察觉不到的方式操作对象,从而使回收对象的真正时机和预期的不同。你决不应该自己调用 dealloc 方法,运行期系统会在适当的时候调用它。而且,一旦调用过 dealloc 之后,对象就不再有效了,后续方法调用均是无效的。
在 dealloc 方法中主要就是释放对象所拥有的引用。对象所拥有的其他非 Objective-C 对象也要释放。比如 CoreFoundation 对象就必须手工释放,因为它们是由纯C 的API 所生成的。
在 dealloc 方法中,通常还要做一件事,那就是把原来配置过低观测行为都清理掉,比如消息通知的回收。
delloc应该这样写:
- (void)dealloc {
CFRelease(coreFoundationObject);
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
如果手动管理引用计数而不使用 ARC 的话,那么最后还需调用 “[super dealloc]”。ARC 会自动执行此操作。
开销较大或系统内部稀缺的资源不应该于 dealloc 中释放引用。像是文件描述符、套接字、大块内存等,都属于这种资源。不能指望 dealloc 方法必定会在某个特定的时机调用,因为有一些无法预料的东西可能也持有此对象。
比方说,如果某对象管理着连接服务器所用的套接字,那么也许就需要这种 “清理方法”。此对象可能要通过套接字连接到数据库。对于对象所属的类,其接口可以这样写:
#import <Foundation/Foundation.h>
@interface EOCServerConnection : NSObject
- (void)open:(NSString *)address;
- (void)close;
@end
这段代码提供了两个方法:
- open: 方法:用于打开连接到服务器的套接字。它需要一个字符串类型的参数 address,表示服务器的地址。
- close 方法:用于关闭当前打开的连接。
这个类的设计允许用户通过 open: 方法打开连接,然后使用完成后通过 close 方法关闭连接
在清理方法而非 dealloc 方法中清理资源还有个原因,就是系统并不保证每个创建出来的对象的 dealloc 都会执行。极个别情况下,当应用程序终止时,仍有对象处于存活状态,这些对象没有收到 dealloc 消息。由于应用程序终止之后,其占用的资源也会返还给操作系统,所以实际上这些对象也就等于是消亡了。不调用 dealloc 方法是为了优化程序效率。而这也说明系统未必会在每个对象上调用其 dealloc 方法。
如果对象管理着某些资源,那么在 dealloc 中也要调用 “清理方法”,以防止开发者忘了清理这些资源。
在系统回收对象之前,必须调用 close 以释放其资源,否则 close 方法就失去了意义了,因此,没有适时调用 close 方法就是编程错误,我们应该在 dealloc 中补上这次调用,以防泄漏内存。下面举例说明 close 与 dealloc 方法如何来写:
- (void)close {
/*clean up resources*/
_closed = YES;
}
- (void)dealloc {
if (!_closed) {
NSLog(@"ERROR: close was not called before dealloc!");
[self close];
}
}
编写 dealloc 方法时还需要注意,不要在里面随便调用其他方法。
调用dealloc 方法的那个线程会执行“最终的释放操作”,令对象的保留计数降为 0,而某些方法必须在特定的线程里(比如主线程里)调用才行。若在 dealloc 里调用了那些方法,则无法保证当前这个线程就是那些方法所需的线程。通过编写常规代码的方式,无论如何都没办法保证其会安全运行在正确的线程上,因为对象处于 “正在回收的状态”,为了指明此状况,运行期系统已经改动了对象内部的数据结构。
在 dealloc 里也不要调用属性的存取方法,因为有人可能会覆写这些方法,并与其中做一些无法在回收阶段安全执行的操作。此外,属性可能正处于 “键值观测” (KVO) 机制的监控之下,该属性的观察者(observer) 可能会在属性值改变时 “保留” 或使用这个即将回收的对象。这种做法会令运行期系统的状态完全失调,从而导致一些莫名其妙的错误。
- 在 dealloc 方法里,应该做的事情就是释放指向其他对象的引用,并取消原来订阅的“键值观测”(KVO)或 NSNOtificationCenter 等通知,不要做其他事情。
- 如果对象持有文件描述符等系统资源,那么应该专门编写一个方法来释放此种资源。这样的类要和其使用者约定: 用完资源后必须调用 close 方法。
- 执行异步任务的方法不应该在 dealloc 里调用; 只能在正常状态下执行的那些方法也不应在 dealloc 里调用,因为此时对象已处于正在回收的状态了。
编写“异常安全代码” 时留意内存管理问题
许多时下流行的编程语言都提供了 “异常”这一特性。在当前的运行期系统中,C++ 与 Objective-C 的异常相互兼容,也就是说,从其中一门语言里抛出的异常能用另外一门语言所编写的 “异常处理程序”来捕获。
Objective-C 的错误模型表明,异常只应在发生严重错误后抛出,不过有时仍然需要编写代码来捕获并处理异常。比如使用 Objective-C++ 来编码时,或是编码中用到了第三方程序库而此程序库所抛出的异常又不受你控制时,就需要捕获及处理异常了。此外,有些系统库也会用到异常,比如,在使用 “键值观测”(KVO)功能时,若想注销一个尚未注册的“观察者”,便会抛出异常。
在 try 块中,如果先保留了某个对象,然后在释放它之前又抛出了异常,那么,除非 catch 块能处理此问题,否则对象所占内存就将泄漏。
异常处理例程将自动销毁对象,然而在手动管理引用计数时,销毁工作有些麻烦。以下面这段使用手工引用计数的 Objective-C 代码为例:
@try {
EOCSomeClass *object = [[EOCSomeClass alloc] init];
[object doSomethingThatMayThrow];
[object release];
}
@catch (...) {
NSLog(@"Whoops, there was an error. Oh well...");
}
这段代码使用了 Objective-C 中的异常处理机制,尝试执行一些可能会抛出异常的代码,并在发生异常时捕获并处理它。具体来说:
@try { … } @catch (…) { … } 是 Objective-C 中的异常处理语法。@try 块用于包含可能会抛出异常的代码,而 @catch 块则用于捕获异常并进行处理。
在 @try 块中,首先创建了一个 EOCSomeClass 类的对象 object,然后调用了 doSomethingThatMayThrow 方法。这个方法可能会抛出异常。
在 @try 块的最后,调用了 [object release] 方法来释放 object 对象。这表明代码的编写者使用了手动内存管理。
如果在 @try 块中的代码抛出了异常,那么异常处理流程会跳转到对应的 @catch 块中。在这个例子中,@catch 块中的代码会执行,它打印了一条错误日志。由于 @catch 块的参数是 …,表示捕获所有类型的异常,因此无论什么类型的异常都会被捕获并处理。
但如果 doSomethingThatMayThrow 抛出异常了呢?由于异常会令执行过程终止并跳至catch 块,因而其后的那行 release 代码不会运行。在这种情况下,如果代码抛出异常,那么对象就泄漏了。这么做不好。解决方法是使用 @finally 块,无论是否抛出异常,其中代码都保证会运行,且只运行一次。比方说,刚才那段代码可改写如下:
EOCSomeClass *object;
@try {
object = [[EOCSomeClass alloc] init];
[object doSomethingThatMayThrow];
}
@catch (...) {
NSLog(@"Whoops, there was an error. Oh well...");
}
@finally {
[object release];
}
在 ARC 环境下,问题会更严重。下面这段使用 ARC 的代码与修改前的那段代码等效:
@try {
EOCSomeClass *object = [[EOCSomeClass alloc] init];
[object doSomethingThatMayThrow];
}
@catch (...) {
NSLog(@"Whoope, there was an error. Oh well...");
}
在 ARC 下,由于不能手动调用 release 方法来释放对象,因此无法像在手动管理内存时那样将释放操作放在 @finally 块中。而且,ARC 不会自动处理异常导致的内存释放,因为这需要添加大量的额外代码来跟踪待清理的对象,并在抛出异常时释放它们,这可能会影响程序的性能并增加应用程序的大小。
虽然在 Objective-C 代码中,抛出异常通常是在应用程序必须因异常状况而终止时才发生的,但默认情况下 ARC 并不会为异常处理添加额外的代码。这是因为在应用程序即将终止时,是否会发生内存泄漏已经无关紧要了。因此,默认情况下,ARC 不会为异常处理添加额外的代码。如果需要在 ARC 环境下处理异常并进行自动内存管理,可以通过开启 -fobjc-arc-exceptions 编译器标志来实现。
但最重要的是:在发现大量异常捕获操作时,应考虑重构代码。
- 捕获异常时,一定要注意将 try 块所创立的对象清理干净。
- 在默认情况下,ARC 不生成安全处理异常所需的清理代码。开启编译器标志后,可以生成这种代码,不过会导致应用程序变大,而且会降低运行效率。
以弱引用避免保留环
对象图里经常会出现一种情况,就是几个对象都以某种方式互相引用,从而形成“环”。这种情况通常会泄漏内存,因为最后没有别的东西会引用环中的对象。这样的话,环里的对象就无法为外界所访问了,但对象之间尚有引用,这些引用使得它们都能继续存活下去,而不会为系统所回收。最简单的保留环由两个对象构成,它们互相引用对方。
例如这里有两个类:
#import <Foundation/Foundation.h>
@class EOCClassA;
@class EOCClassB;
@interface EOCClassA : NSObject
@property (nonatomic, strong) EOCClassB *other;
@end
@interface EOCClassB : NSObject
@property (nonatomic, strong) EOCClassA *other;
@end
保留环会导致内存泄漏。如果只剩一个引用还指向保留环中的实例,而现在又把这个引用移除,那么整个保留环就泄漏了。
避免保留环的最佳方式就是弱引用。这种引用经常用来表示 “非拥有关系”。将属性声明为 unsafe_unretained 即可。修改刚才那段范例代码:
#import <Foundation/Foundation.h>
@class EOCClassA;
@class EOCClassB;
@interface EOCClassA : NSObject
@property (nonatomic, strong) EOCClassB *other;
@end
@interface EOCClassB : NSObject
@property (nonatomic, unsafe_unretained) EOCClassA *other;
@end
修改之后,EOCClassB 实例就不再通过 other 属性来拥有 EOCClassA 实例了。属性特质 (attribute) 中的 unsafe_unretained 一词表明,属性值可能不安全,而且不归此实例所拥有。如果系统已经把属性所指的那个对象回收了,那么在其上调用方法可能会使应用程序崩溃。由于本对象并不保留属性对象,因此其有可能为系统所回收。
还可以使用weak 属性特质,刚刚的代码还可以修改为:
@property (nonatomic,weak) EOCClassA *other;
unsafe_unretained 与 weak 属性,在其所指的对象回收以后表现出来的行为不同。当指向 EOCClassA 实例的引用移除后,unsafe_unretained 属性仍然指向那个已经回收的实例,而 weak 属性则指向 nil。
一般来说,如果不拥有某对象,那就不要保留它,当然 collection 例外。有时,对象中的引用会指向另外一个并不归自己拥有的对象,比如 Delegate 模式就是这样。
- 将某些引用设为 weak,可避免出现 “保留环”。
- weak 引用可以自动清空,也可以不自动清空。自动清空(autonilling)是随着 ARC 而引入的新特性,由运行期系统来实现。在具备自动清空功能的弱引用上,可以随意读取其数据,因为这种引用不会指向已经回收过的对象。
以“自动释放池块”降低内存峰值
由前面的内容知道:自动释放池用于存放那些需要稍后某个时刻释放的对象。
创建自动释放池所用语法如下:
@autorelease {
/...
}
通常只有一个地方需要创建自动释放池,那就是在 main 函数里。比如说iOS程序的main函数一般这样写:
int main(int argc, char *argv[]) {
@autoreleasepool {
return UIApplicationMain(argc, argv, nil, @"EOCAppDelegate");
}
}
从技术角度看,不是非得有个“自动释放池块”才行。因为块的末尾恰好就是应用程序的终止处,而此时操作系统会把程序所占的全部内存都释放掉。这个池可以理解成最外围捕捉全部自动释放对象所用的池。
下面这段代码中的花括号定义了自动释放池的范围。自动释放池于左花括号处创建,并于对应的右花括号处自动清空。位于自动释放池范围内的对象,将在此范围末尾处收到 release 消息。自动释放池可以嵌套。系统在自动释放对象时,会把它放到最内层的池里。比方说:
@autoreleasepool {
NSString *string = [NSString stringWithFormat:@"1= %i", 1];
@autoreleasepool {
NSNumber *number = [NSNumber numberWithInt:1];
}
}
这段代码创建了两个自动释放池。第一个自动释放池从第 1 行开始,到第 10 行结束。在此范围内,字符串 string 被创建,并且在自动释放池的末尾被释放。
在第 5 行到第 8 行之间的范围内,又创建了一个新的自动释放池。在这个内层自动释放池中,数字 number 被创建,然后在内层自动释放池的末尾被释放。
注意,内层自动释放池的范围是嵌套在外层自动释放池的范围内的。因此,number 对象的释放操作会在外层自动释放池的范围结束时执行,而 string 对象的释放操作则会在整个自动释放池的范围结束时执行。
有如下代码:
for (int i = 0; i< 100000; i++) {
[self doSomethingWithInt:i];
}
如果 “doSomethingWithInt:”方法要创建临时对象,那么这些对象很可能会放在自动释放池里。即便这些对象在调用完方法之后就不再使用了,它们也依然处于存活状态,因为目前还在自动释放池里,等待系统稍后将其释放并回收。然而,自动释放池要等线程执行下一次事件循环时才会清空。即执行 for 循环时,会持续有新的对象创建出来,并加入自动释放池中。所有这种对象都要等 for 循环执行完才会释放。这样一来,在执行 for 循环时,应用程序所占内存量就会持续上涨,而等到所有临时对象都释放后,内存用量又会突然下降。
或者比如说要从数据库中读数据:
NSArray *databaseRecords = /*...*/;
NSMutableArray *people = [NSMutableArray new];
for (NSDictionary *record in databaseRecords) {
EOCPerson *person = [[EOCPerson alloc] initWithRecord:record];
[people addObject:person];
}
若记录有很多条,则内存中也会有很多不必要的临时对象,它们本来应该提早回收的。增加一个自动释放池即可解决此问题。如果把循环内的代码包裹在“自动释放池块”中,那么在循环中自动释放的对象就会放在这个池,而不是线程的主池里面。例如:
NSArray *databaseRecords = /*...*/;
NSMutableArray *people = [NSMutableArray new];
for (NSDictionary *record in databaseRecords) {
@autoreleasepool {
EOCPerson *person = [[EOCPerson alloc] initWithRecord:record];
[people addObject:person];
}
}
新增的自动释放池块可以减少内存峰值,因为系统会在块的末尾把某些对象回收掉。
是否应该用池来优化效率,完全取决于具体的应用程序。所以尽量不要建立额外的自动释放池。
有一种老式写法,是使用 NSAutoreleasePool 对象。但是这种写法并不会在每次执行 for 循环时都清空池,通常用来创建那种偶尔要清空的池,采用随着 ARC 所引入的新语法,可以创建出更为 “轻量级”的自动释放池。现在可以改用自动释放池块把 for 循环中的语句包起来,这样的话,每次执行循环时都会建立并清空自动释放池。
- 自动释放池排布在栈中,对象收到 autorelease 消息后,系统将其放入最顶端的池里。
- 合理运用自动释放池,可降低应用程序的内存峰值。
- @autoreleasepool 这种新式写法能创建出更为轻便的自动释放池。
用“僵尸对象”调试内存管理问题
Cocoa 提供了 “僵尸对象”(Zombie Object)这个非常方便的功能。启用这项调试功能之后,运行期系统会把所有已经回收的实例转化成特殊的“僵尸对象”,而不会真正回收它们。这种对象所在的核心内存无法重用,因此不可能遭到覆写。僵尸对象收到消息后,会抛出异常,其中准确说明了发送过来的消息,并描述了回收之前的那个对象。僵尸对象是调试内存管理问题的最佳方式。给僵尸对象发送消息后,控制台会打印消息,而应用程序则会终止。
也可以在Xcode 里打开此选项,这样的话,Xcode 在运行应用程序时会自动设置环境变量。开启方法:编辑应用程序的 Scheme,在对话框左侧选择 “Run”,然后切换至 “Diagnostics” 分页,最后勾选 “Enable Zombie Objects” 选项。
僵尸对象的工作原理涉及Objective-C的运行时程序库、Foundation框架和CoreFoundation框架的实现代码。当系统即将回收对象时,如果启用了僵尸对象功能,会执行一个额外的步骤,即将对象转化为僵尸对象而不是立即回收。
僵尸类是从名为 NSZombie 的模板类里复制出来的。这些僵尸类没有多少事情可做,只是充当一个标记。
僵尸类的作用会在消息转发例程中体现出来。 NSZombie 类(以及所有从该类拷贝出来的类)并未实现任何方法。此类没有超类,因此和 NSObject 一样,也是个“根类”,该类只有一个实例变量,叫做 isa ,所有 Objective-C 的根类都必须有此变量。由于这个轻量级的类没有实现任何方法,所以发给它的全部消息都要经过 “完整的消息转发机制”。
- 系统在回收对象时,可以不将其真的回收,而是把它转化为僵尸对象。通过环境变量 NSZombieEnable 可开启此功能。
- 系统会修改对象的 isa 指针,令其指向特殊的僵尸类,从而使该对象变为僵尸对象。僵尸类能够相应所有的选择子,响应方式为:打印一条包含消息内容及其接收者的消息,然后终止应用程序。
不要使用retainCount
每个对象的引用计数都有一个计数器,其值表明还有多少个其他对象想令此对象继续存活。
NSObject 协议中定义了下列方法,用于查询对象当前的保留计数:
- (NSUInteger)retainCount;
然而 ARC 已经将此方法废弃了。如果在 ARC 中调用,编译器就会报错。但问题在于,保留计数的绝对值一般都与开发者所应留意的事情完全无关。即便只在调试时才能调用此方法,通常也还是无所助益的。
因为它返回的保留计数只是某个给定时间点上的值。该方法并未考虑到系统会稍后把自动释放池清空,因而不会将后续的释放操作从返回值里减去,这样的话,此值就未必能真实反映实际的保留计数了。
开发者在期望系统于某处回收对象时,应该确保没有尚未抵消的保留操作,也就是不要令保留计数大于期望值。在这种情况下,如果发现某对象的内存泄漏了,那么应该检查还有谁仍然保留这个对象,并查明其为何没有释放此对象。
即便只为调试,此方法也不是很有用。由于对象可能处在自动释放池中,所以其保留计数未必如想象般精确。那到底何时才应该用 retainCount 呢?最佳答案是:绝对不要用,尤其考虑到苹果公司在引入 ARC 之后已正式将其废弃,就更不应该用了。
- 对象的保留计数看似有用,实则不然,因为任何给定时间点上的“绝对保留计数”(absolute retain count)都无法反映对象生命期的全貌。
- 引入 ARC 之后,retainCount 方法就正式废止了,在 ARC 下调用该方法会导致编译器报错。
理解“块”这一概念
块可以实现闭包,它与函数类似,只不过是直接定义在另一个函数里的,和定义它的那个函数共享同一个范围内的东西。块用 “^” 符号来表示,后面跟着一对花括号,括号里面是块的实现代码。
块的强大之处是:在声明它的范围里。所有变量都可以为其所捕获。这也就是说,那个范围里的全部变量,在块里依然可用。比如,下面这段代码所定义的块,就使用了块以外的变量:
int additional = 5;
int (^addBlock)(int a, int b) = ^(int a, int b){
return a + b + addItional;
};
int add = addBlock(2, 5);
默认情况下,为块所捕获的变量,是不可以在块里修改的。声明变量的时候可以加上 __block 修饰符,这样就可以在块内修改了。例:
NSArray *array = @[@0, @1, @2, @3, @4, @5];
__block NSInteger count = 0;
[array enumerateObjectsUsingBlock:^(NSNumber *number, NSUInteger idx, BooL *stop) {
if([number compare:@2] == NSOrderedAscending) {
count++;
}
}];
这段范例代码也演示了 “内联块”的用法。传给 “numerateObjectsUsingBlock:” 方法的块并未先赋给局部变量,而是直接内联在函数调用里了。这样可以把所有业务逻辑都放在一处。
如果块所捕获的变量是对象类型,那么就会自动保留它。系统在释放这个块的时候,也会将其一并释放。块本身可视为对象。块本身也和其他对象一样,有引用计数。当最后一个指向块的引用移走之后,块就回收了。回收时也会释放块所捕获的变量,以便平衡捕获时所执行的保留操作。
如果将块定义在 OC 类的实例方法中,那么除了可以访问类的所有实例变量之外,还可以使用 self 变量。块总能修改实例变量,所以在声明时无须加 _ _block 。不过,如果通过读取或写入操作捕获了实例变量,那么也会自动把 self 变量一并捕获了,因为实例变量是与 self 所指代的实例关联在一起的。就是说:在OC类的实例方法中使用块时可以轻松地访问和操作当前实例的变量和方法而不必过多关注__block的使用。这使得在块内部处理实例变量变得更加方便。
例:
@interface EOCClass
- (void)anInstanceMethod {
void (^someBlock)() = ^{
_anInstanceVariable = @"Something";
NSlog(@"_anInstanceVariable = %@", _anInstanceVariable);
};
}
@end
如果某个 EOCClass 实例正在执行 anInstanceMethod 方法,那么 self 变量就指向此实例。由于块里没有明确使用 self 变量,所以很容易就会忘记 self 变量其实也为块所捕获了。直接访问实例变量和通过 self 来访问是等效的。
self 也是个对象,因而块在捕获它时也会将其保留。如果 self 所指代的那个对象同时保留了块,那么这种情况通常就会导致 “保留环”。
块的内部结构
每个 OC 对象都占据着某个内存区域。每个对象所占的内存区域也有大有小。块本身也是对象,在存放块对象的内存区域中,首个变量是指向 Class 对象的指针isa。其余内存里含有块对象正常运转所需要的各种信息。
在内存布局中,最重要的就是 invoke 变量,这是个函数指针,指向块的实现代码。descriptor 变量是指向结构体的指针,每个块里都包含此结构体,其中声明了块对象的总体大小,还声明了 copy 与 dispose 这两个辅助函数所对应的函数指针。
块还会把它所捕获的所有变量都拷贝一份。这些拷贝放在 descriptor 变量后面,捕获了多少个变量,就要占据多少内存空间。
全局块、栈块及堆块
定义块的时候,其所占的内存区域是分配在栈中的。这就是说,块只在定义它的那个范围内有效。这里有段代码:
void (^block)();
if () {
block = ^{
NSLog(@"Block A");
};
} else {
block = ^{
NSLog(@"Block B");
};
}
block();
这个代码问题在于,块的生命周期与其定义的范围有关。在这里,两个块都分配在栈内存中,而栈内存的生命周期通常与包含它的作用域相关。一旦超出 if 或 else 的作用域,栈上的块可能会被释放,而且内存区域可能被覆写。
这样的代码在编译时通常能够通过,但在运行时可能会出现问题,因为 block() 调用时,块的定义范围可能已经结束,栈上的内存可能已经被其他变量或数据覆写。
为解决此问题,可以通过调用copy方法。这样的话,就可以把块从栈复制到堆了。拷贝后的块,可以在定义它的那个范围之外使用。而且,一旦复制到堆上,块就成了带引用计数的对象了。后续的复制操作都不会真的执行复制,只是递增块对象的引用计数。而“分配在栈上的块”则无须明确释放,因为栈内存本来就会自动回收。
应该改正为:
void (^block)();
if () {
block = [^{
NSLog(@"Block A");
} copy];
} else {
block = [^{
NSLog(@"Block B");
} copy];
}
block();
还有一类块叫做 “全局块”。这种块不会捕捉任何状态,运行时也无须有状态来参与。块所使用的整个内存区域在编译期已经完全确定了,因此,全局块可以声明在全局内存里,而不需要在每次用到的时候于栈中创建。
全局块的拷贝操作是个空操作,因为全局块决不可能为系统所回收。这种块实际上相当于单例。下面是个全局块:
void (^block)() = ^{
NSLog(@"This is a block");
}
这完全是种优化技术
- 块是C、C++、Objective-C 中的词法闭包。
- 块可接受参数,也可返回值。
- 块可以分配在栈或堆上,也可以是全局的。分配在栈上的块可拷贝到堆里,这样的话,就和标准的 Objective-C 对象一样,具备引用计数了。
为常用的块类型创建typedef
每个块都具备其“固有类型”,因而可将其赋给适当类型的变量。这个类型由块所接受的参数及其返回值组成。
与其他类型的变量不同,在定义块变量时,要把变量名放在类型之中,而不要放在右侧。这种语法非常难记,也非常难读。鉴于此,我们应该为常用的块类型起个别名。
为了隐藏复杂的块类型,需要用到C 语言中名为 “类型定义”的特性。typedef 关键字用于给类型起个易读的别名。
举个例子,不使用typedef的话是这样声明一个块:
void (^sumBlock)(int, int) = ^(int a, int b) {
int sum = a + b;
NSLog(@"Sum: %d", sum);
};
使用typedef:
// 使用 typedef 创建块类型别名
typedef void (^SumBlock)(int, int);
// 使用块类型别名声明块变量
SumBlock sumBlock = ^(int a, int b) {
int sum = a + b;
NSLog(@"Sum: %d", sum);
};
这次代码读起来就顺畅多了:与定义其他变量时一样,变量类型在左边,变量名在右边。可见使用 typedef 可以将复杂的块类型声明简化为易读的别名,使代码更加清晰、易懂。通过项特性,可以把使用块的 API 做得更为易用些。
定义方法参数所用的块类型语法,又和定义变量时不同。若能把方法签名中的参数类型写成一个词,那读起来就顺口多了。于是,可以给参数类型起个别名,然后使用词名称来定义:
//创建一个名为EOCCompletionHandler的块类型别名
typedef void (^EOCCompletionHandler) (NSData *data, NSError *error);
//在方法签名中,使用了先前创建的块类型别名EOCCompletionHandler作为参数类型
//startWithCompletionHandler方法接受一个块作为参数,而这个块的类型就是 EOCCompletionHandler
- (void)startWithCompletionHandler:(EOCCompletionHandler)completion;
现在看上去就简单多了,而且易于理解。
使用类型定义还有个好处,就是重构块的类型签名时会很方便。
比如给块再加一个参数,那么只需要修改类型定义语句即可:
typedef void (^EOCCompletionHandler) (NSData *data, NSTimeInterval duration, NSError *error);
修改之后,凡是使用了这个类型定义的地方都会无法编译而且报的是同一种错误,于是开发者可据此逐个修复。
最好在使用块类型的类中定义这些 typedef,而且还应该把这个类的名字加在由 typedef 所定义的新类型名前面,这样可以阐明块的用途。还可以用 typedef 给同一个块签名类型创建数个别名。
比如Accounts 框架:
typedef void (^ACAccountStoreSaveCompletionHandler)(BOOL success, NSError *error);
如果有好几个类都要执行相似但各有区别的异步任务,而这几个类又不能放入同一个继承体系,那么,每个类就应该有自己的 completion handler 类型。这几个 completion handler 的签名也许完全相同,但最好还是在每个类里各自定义一个别名,而不要同一个名称。反之,若这些类能纳入同一个继承中,则应该将类型定义语句放在超类中,以供各子类使用。
- 以 typedef 重新定义块类型,可令块变量用起来更加简单。
- 定义新类型时应遵从现有的命名习惯,勿使其名称与别的类型相冲突。
- 不妨为同一个块签名定义多个类型别名。如果要重构的代码使用了块类型的某个别名,那么只需要修改相应 typedef 中的块签名即可,无须改动其他 typedef。
用handler块降低代码分散程度
为用户界面编码时,一种常用的范式就是 “异步执行任务”。这种范式的好处在于:处理用户界面的显示及触摸操作所用的线程,不会因为要执行 I/O 或网络通信这类耗时的任务而阻塞。这个线程通常称为主线程。假设把执行异步任务的方法做成同步的,那么在执行任务时,用户界面就变得无法响应用户输入了。某些情况下,如果应用程序在一定时间内无响应,那么就会自动终止。iOS 系统上的应用程序就是如此,“系统监控器”在发现某个应用程序的主线程已经阻塞了一段时间之后,就会令其终止。
I/O 操作(输入/输出操作):
输入操作(Input): 从外部设备或文件中读取数据到计算机系统中。例如,从键盘读取用户输入、从磁盘读取文件等。
输出操作(Output): 将计算机系统中的数据发送到外部设备或存储到文件中。例如,将数据写入显示器显示、将结果写入磁盘文件等。
I/O 操作可能涉及到慢速的设备(如硬盘、网络),因此在进行这些操作时,系统可能需要等待一段时间。
网络通信:
指在不同计算机或设备之间传递数据的过程。
通过网络连接,计算机系统可以通过一系列协议进行数据的发送和接收,包括传输层协议(如TCP或UDP)和应用层协议(如HTTP、FTP等)。
网络通信包括从客户端到服务器的请求和响应,文件传输,远程过程调用(RPC)等。
在移动应用或网络应用中,常见的 I/O 操作和网络通信包括:
读写本地文件: 通过文件系统进行读写,例如保存应用程序数据、读取配置文件等。
数据库操作: 与本地或远程数据库进行数据交互,例如存储和检索用户信息。
HTTP 请求和响应: 通过网络协议进行数据传输,例如从服务器获取数据、上传文件等。
Socket 编程: 直接在网络上建立连接,进行实时通信,例如聊天应用、在线游戏等。
异步方法在执行完任务之后,需要以某种手段通知相关代码。实现此功能有很多方法。常用的技巧是设计一个委托协议,令关注此事件的对象遵从该协议。对象成为 delegate 之后,就可以在相关事件发生时(例如某个异步任务执行完毕时)得到通知了。委托模式有个缺点:如果类要分别使用多个获取器下载不同数据,那么就得在 delegate 回调方法里根据传入的获取器参数来切换。这么写代码,不仅会令 delegate 回调方法变得很长,而且还要把网络数据获取器对象保存为实例变量,以便在判断语句中使用。
然而如果改用块来写的话,代码会更清晰。块可以令这种 API 变得更紧致,同时令开发者调用起来更加方便。改用块来写的好处是:无须保存获取器,也无须在回调方法里切换。每个 completion handler 的业务逻辑,都是和相关的获取器对象一起来定义的。
比如像这样:
#import <Foundation/Foundation.h>
// 定义块类型
typedef void(^EOCNetworkFetcherCompletionHandler)(NSData *data);
@interface EOCNetworkFetcher : NSObject
- (id)initWithURL:(NSURL *)url;
// 使用块作为参数的方法
- (void)startWithCompletionHandler:(EOCNetworkFetcherCompletionHandler)handler;
@end
@implementation EOCNetworkFetcher
- (void)startWithCompletionHandler:(EOCNetworkFetcherCompletionHandler)handler {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSData *data = [NSData dataWithContentsOfURL:[NSURL URLWithString:@"http://www.example"]];
dispatch_async(dispatch_get_main_queue(), ^{
if (handler) {
handler(data);
}
});
});
}
@end
用块写出来的代码显然更为整洁。而且,由于块声明在创建获取器的范围里,所以它可以访问此范围内的全部变量。
这种写法还有其他用途,比如,现在很多基于块的 API 都使用块来处理错误。这又分为两种办法。可以分别用两个处理程序来处理操作失败的情况和操作成功的情况。也可以把处理失败情况所需的代码,与处理正常情况所用的代码,都封装到同一个 completion handler 块里。如果想采用两个独立的处理程序,那么可以这样设计 API:
#import <Foundation/Foundation.h>
// 声明块类型
typedef void(^EOCNetworkFetcherCompletionHandler)(NSData *data);
typedef void(^EOCNetworkFetcherErrorHandler)(NSError *error);
@interface EOCNetworkFetcher : NSObject
// 初始化方法声明
- (instancetype)initWithURL:(NSURL *)url;
// 方法声明,接受两个块作为参数
- (void)startWithCompletionHandler:(EOCNetworkFetcherCompletionHandler)completion failureHandler:(EOCNetworkFetcherErrorHandler)failure;
@end
调用方式如下:
EOCNetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
[fetcher startWithCompletionHander:^(NSData *data) {
// Handle success
} failureHandler:^(NSError *error) {
// Handle failure
}];
另一种风格则像下面这样,把处理成功情况和失败情况所用的代码全放在一个块里:
#import <Foundation/Foundation.h>
// 声明块类型,接受两个参数:NSData 和 NSError
typedef void(^EOCNetworkFetcherCompletionHandler)(NSData *data, NSError *error);
@interface EOCNetworkFetcher : NSObject
// 初始化方法声明
- (instancetype)initWithURL:(NSURL *)url;
// 方法声明,接受一个块作为参数
- (void)startWithCompletionHandler:(EOCNetworkFetcherCompletionHandler)completion;
@end
调用方式如下:
// 创建网络获取器实例
EOCNetworkFetcher *fetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
[fetcher startWithCompletionHandler:^(NSData *data, NSError *error) {
if (error) {
// 处理失败情况
NSLog(@"Error occurred: %@", error);
} else {
// 处理成功情况
NSLog(@"Data received: %@", data);
}
}];
这种写法的缺点是:由于全部逻辑都写在一起,所以会令块变得比较长且比较复杂。然而也有好处,那就是更为灵活。比方说,在传入错误信息时,可以把数据也传进来。而且还有一个优点是调用 API 的代码可能会在处理成功响应的过程中发现错误。
总体来说,笔者建议使用同一个块来处理成功与失败情况,苹果公司似乎也是这样设计其 API 的。
有时需要在相关时间点执行回调操作,这种情况也可以使用 Handler 块。
基于 handler 来设计 API 还有个原因,就是某些代码必须运行在特定的线程上。例如NSNotificationCenter 就属于这种 API,它提供了一个方法,调用者可以经由此方法来注册想要接收的通知,等到相关事件发生时,通知中心就会执行注册好的那个块。调用者可以指定某个块应该安排在哪个执行队列里。
- (id)addObserverForName:(NSString *)name object:(id)object queue:(NSOperationQueue *)queue usingBlock:(void(^)(NSNotification *))block;
此处传入的 NSOperationQueue 参数就表示出触发通知时用来执行块代码的那个队列。这是个“操作队列”,而非“底层 GCD 队列”,不过两者语义相同。
- 在创建对象时,可以使用内联的 handler 块将相关业务逻辑一并声明。
- 在有多个实例需要监控时,如果采用委托模式,那么经常需要根据传入的对象来切换,而若改用 handler 块来实现,则可直接将块与相关对象放在一起。
- 设计 API 时如果用到了 handler 块,那么可以增加一个参数,使调用者可通过此参数来决定应该把块安排在哪个队列上执行。
用块引用其所属对象时不要出现保留环
使用块时,若不仔细思量,则很容易导致“保留环”。比如说下面这个例子:
// EOCNetworkFetcher.h
#import <Foundation/Foundation.h>
typedef void(^EOCNetworkFetcherCompletionHandler)(NSData *data);
@interface EOCNetworkFetcher : NSObject
@property (nonatomic, strong, readonly) NSURL *url;
- (instancetype)initWithURL:(NSURL *)url;
- (void)startWithCompletionHandler:(EOCNetworkFetcherCompletionHandler)completion;-
@end
// EOCNetworkFetcher.m
#import "EOCNetworkFetcher.h"
@interface EOCNetworkFetcher ()
@property (nonatomic, strong, readwrite) NSURL *url;
@property (nonatomic, copy) EOCNetworkFetcherCompletionHandler completionHandler;
@property (nonatomic, strong) NSData *downloadedData;
@end
@implementation EOCNetworkFetcher
- (instancetype)initWithURL:(NSURL *)url {
if ((self = [super init])) {
_url = url;
}
return self;
}
- (void)startWithCompletionHandler:(EOCNetworkFetcherCompletionHandler)completion {
self.completionHandler = completion;
// Start the request
// Request sets downloadedData property
// When request is finished, p_requestCompleted is called
}
- (void)p_requestCompleted {
if (_completionHandler) {
_completionHandler(_downloadedData);
}
}
@end
某个类可能会创建这个网络请求的实力,并用其从url中下载数据:
// EOCClass.m
@implementation EOCClass {
EOCNetworkFetcher *_networkFetcher;
NSData *_fetchedData;
}
- (void)downloadData {
NSURL *url = [[NSURL alloc] initWithString:@"http://www.example/something.dat"];
_networkFetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
[_networkFetcher startWithCompletionHandler:^(NSData *data) {
NSLog(@"Request URL %@ finished", _networkFetcher.url);
_fetchedData = data;
}];
}
@end
在上面这段代码中:当 EOCClass 类的实例调用 downloadData 方法时,它会创建一个 EOCNetworkFetcher 的实例对象 _networkFetcher。
在 _networkFetcher 的 startWithCompletionHandler: 方法中,传入了一个块作为参数,该块会捕获 self(即 EOCClass 实例),因为它需要设置 EOCClass 实例中的 _fetchedData 实例变量。
这样,块持有了 EOCClass 实例,EOCClass 实例持有了 _networkFetcher 实例,而 _networkFetcher 实例又持有了传入的块,形成了保留环。
要打破保留环也很容易:要么令 _networkFetcher 实例变量不再引用获取器,要么令获取器的 completionHandler 属性不在持有 handler 块。在这个例子中,应该等 completion handler 块执行完毕后,再去打破保留环,以便使获取器对象在 handler 块执行期间保持存活状态。比方说,completion handler 块的代码可以这么修改:
[_networkFetcher startWithCompletionHandler:^(NSData *data) {
NSLog(@"Request for URL %@ finished", _networkFetcher.url);
_fetchedData = data;
_networkFetcher = nil;
];
一般来说,只要适时清理掉环中的某个引用,即可解决此问题,然而,未必总有这种机会。若是 completion handler 一直不运行,那么保留环就无法打破,于是内存就会泄漏。
如果 completion handler 块所引用的对象最终又引用了这个块本身,那么就会出现另一种形式的保留环。
举个例子:
- (void)downloadData {
NSURL *url = [[NSURL alloc] initWithString:@"http://www.example/something.dat"];
EOCNetworkFetcher *networkFetcher = [[EOCNetworkFetcher alloc] initWithURL:url];
[networkFetcher startWithCompletionHandler:^(NSData *data) {
NSLog(@"Request URL %@ finished", networkFetcher.url);
_fetchedData = data;
}];
}
在上面这个例子中,downloadData 方法创建了一个 EOCNetworkFetcher 的实例 networkFetcher,并设置了其 startWithCompletionHandler 方法的 completion handler 块。
在 completion handler 块中,使用了 networkFetcher 实例的 url 属性。由于块内部引用了 networkFetcher 实例,因此块会保留这个实例。
反过来,networkFetcher 实例也持有了 completion handler 块,因为 networkFetcher 的属性 completionHandler 是一个 copy 类型的块属性。
因此,形成了一个保留环:networkFetcher 持有 completion handler 块,而 completion handler 块又持有 networkFetcher 实例。这会导致 networkFetcher 实例和其相关的对象无法被释放,从而造成内存泄漏。
这个问题可以这样解决:只需要将 p_requestCompleted 方法按如下方式修改即可:
- (void)p_requestCompleted {
if (_completionHandler) {
_completionHandler(_downloadedData);
}
self.completionHandler = nil;
}
这样一来,只要下载请求执行完毕,保留环就解除了,而获取器对象也将会在必要时为系统所回收。
注意,要在 start 方法中把 completion handler 作为参数传进去。假如把 completion handler 暴露为获取器对象的公共属性,那么就不便在执行完下载请求之后直接将其清理掉了,因为既然已经把 handler 作为属性公布了,那就意味着调用者可以自由使用它,若是此时又在内部将其清理掉的话,则会破坏“封装语义”。在这种情况下要想打破保留环,只有一个办法可用,那就是强迫调用者在 handler 代码里自己把 compleionHandler 属性清理干净。
- 如果块所捕获的对象直接或间接地保留了块本身,那么就得当心保留环问题。
- 一定要找个适当的时机解除保留环,而不能把责任推给API 的调用者。
多用派发队列,少用同步锁
派发队列
在 Objective-C 中,派发队列是一种用来管理任务执行的队列系统。它基于GCD框架,用于异步执行任务,可以在串行或并发的队列上执行任务。派发队列可以是串行队列或并发队列。
- 串行队列:串行队列中的任务一个接一个按顺序执行,每个任务执行完毕后才会执行下一个任务。
- 并发队列:并发队列中的任务可以同时执行,不需要等待前一个任务完成。
同步锁
同步锁是一种用于控制并发访问共享资源的机制。在多线程环境下,当多个线程同时访问某个共享资源时,可能会出现数据竞争的情况,导致程序出现不确定的行为或错误。同步锁可以确保在某个线程修改共享资源时,其他线程不会同时进行修改,从而保证数据的一致性和正确性。
常见的同步锁机制包括:
@synchronized
块:使用@synchronized
关键字创建临界区,确保同一时间只有一个线程能够访问临界区中的代码。NSLock
类:NSLock
是 Foundation 框架中提供的一个锁对象,可以使用lock
和unlock
方法来实现对临界区的加锁和解锁操作。dispatch_semaphore
信号量:通过信号量来实现对临界区的控制,可以使用dispatch_semaphore_wait
和dispatch_semaphore_signal
函数来实现加锁和解锁操作。NSRecursiveLock
类:NSRecursiveLock
是NSLock
的子类,允许同一个线程多次对锁进行加锁操作,可以解决递归调用时可能出现的死锁问题。
在 GCD 出现之前,如果有多个线程要执行同一份代码,通常要使用锁来实现某种同步机制,有两种办法,第一种是采用内置的 “同步块”:
- (void)synchronizedMethod {
@synchronized(self) {
//。。。
}
}
这种写法会根据给定的对象,自动创建一个锁,并等待块中的代码执行完毕。执行到这段代码结尾处,锁就释放了。但是若是在 self 对象上频繁加锁,那么程序可能要等另一段与此无关的代码执行完毕,才能继续执行当前代码,这样做其实并没有必要。
另一个办法是直接使用 NSLock 对象:
_lock = [[NSLock alloc] init];
- (void)synchronizedMethod {
[_lock lock];
//...
[_lock unlock];
}
它可以创建一个 NSLock 对象 _lock,然后在 synchronizedMethod 方法中,使用 lock 方法来获取锁,然后执行安全的代码。执行完安全代码后,通过调用 unlock 方法释放锁。这种方式提供了更多的控制能力,可以更灵活地管理锁的获取和释放
也可以使用 NSRecursiveLock 这种 “递归锁”(重入锁),线程能够多次持有该锁,而不会出现死锁现象。
为什么要多用派发队列,少用同步锁
使用同步锁的这几种方法有其缺陷。比方说,在极端情况下,同步块会导致死锁,另外,其效率也不见得很高,而如果直接使用锁对象的话,一旦遇到死锁,就会非常麻烦。
因此我们可以使用 GCD ,它能以更简单、更高效的形式为代码加锁。
在之前的学习中,我们学习过atomic 特质,它是用于修饰属性的原子性,并且该特性是与锁这个机制紧密相连的,而GCD与锁的机制不同,它是通过队列和调度机制来管理任务的执行,而不需要显式地使用锁来保护共享数据,因此使用GCD不需要依赖属性的原子性。
而再回顾一下atomic特质,它可以指定属性的存取方法。而开发者如果想自己来编写访问方法的话,那么通常会这样写:
- (NSString *)someString {
@synchronized(self) {
return _someString;
}
}
- (void)setSomeString:(NSString *)someString {
@synchronized(self) {
_someString = someString;
}
}
刚才说过,滥用 @synchronized(self) 会很危险,因为所有同步块都会彼此抢夺同一个锁。这么做虽然能提供某种程度的 “线程安全”(thread safety),但却无法保证访问该对象时绝对是线程安全的。
所以要用一种简单而高效的办法代替同步块或锁对象,那就是使用 “串行同步队列”。将读取操作及写入操作都安排在同一个队列里,即可保证数据同步:
_syncQueue = dispatch_queue_create("com.effectiveobjectivec.syncQueue", NULL);
-(NSString *)someString {
__block NSString *localSomeString;
dispatch_sync(_syncQueue, ^{
localSomeString = _someString;
});
return localSomeString;
}
- (void)setSomeString:(NSString *)someString {
dispatch_sync(_suncQueue, ^{
_someString = someString;
});
}
在上面这段代码中,创建了一个串行同步队列 _syncQueue,用于处理对 someString 属性的读取和写入操作。
在someString 方法中,通过调用 dispatch_sync 将读取操作安排在 _syncQueue 中执行。在串行队列中使用 dispatch_sync 可以确保读取操作按顺序执行,并且在读取操作完成之前阻塞当前线程。读取操作执行完毕后,将结果赋值给 localSomeString 变量,然后返回。
setSomeString: 方法中,同样使用 dispatch_sync 将写入操作安排在 _syncQueue 中执行。写入操作也会按照顺序执行。把设置操作与获取操作都安排在序列化的队列里执行,这样的话,所有针对属性的访问操作就都同步了。
设置代码也可以这样写:
- (void)setSomeString:(NSString *)someString {
dispatch_async(_syncQueue, ^{
_someString = someString;
});
}
上面这段代码用异步派发替代了同步派发,意味着设置方法 setSomeString: 中的实例变量 _someString 的操作会在一个后台线程上执行,而不会阻塞当前线程。这可以提高设置方法的执行速度,并使得调用者不必等待设置操作完成。
但这么改有个坏处:这种写法可能比原来慢,因为执行异步派发时,需要拷贝块。若拷贝块所用的时间明显超过执行块所花的时间,则这种写法将比原来更慢。
- 多个获取方法可以并发执行,而获取方法与设置方法之间不能并发执行
利用这个特点,还能写出更快一些的代码来,这次不用串行队列,而改用并发队列:
_syncQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
- (NSString *)someString {
__block NSString *localSomeString;
dispatch_sync(_syncQueue, ^{
localSomeString = _someString;
});
return localSomeString;
}
- (void)setSomeString:(NSString *)someString {
dispatch_async(_syncQueue, ^{
_someString = someString;
});
}
在上面这段代码中:_syncQueue 是一个并发队列,通过 dispatch_get_global_queue 函数创建,它允许多个任务同时在不同的线程上执行。
someString 方法用 dispatch_sync 函数将读取操作添加到 _syncQueue 中,并且等待这个操作完成后再返回结果。这确保了在多个线程同时调用 someString 方法时,能够安全地读取 _someString 的值。
setSomeString: 方法使用 dispatch_async 函数将写入操作添加到 _syncQueue 中,这样就可以确保多个设置方法之间不会并发执行,保证了数据的一致性和安全性。
像现在这样写代码,还无法正确实现同步。所有读取操作与写入操作都会在同一个队列上执行,不过由于是并发队列,所以读取与写入操作可以随时执行。而我们恰恰不想让这些操作随意执行。此问题用一个简单的 GCD 功能即可解决,它就是栅栏。
在并发队列中,栅栏块的作用是确保在其前面的任务执行完毕后,才会执行栅栏块,而在其后的任务则会等待栅栏块执行完毕后才能继续执行。
下列函数可以向队列中派发块,将其作为栅栏使用:
dispatch_barrier_async(dispatch_queue_t queue, dispatch_block_t block);
dispatch_barrier_sync(dispatch_queue_t queue, dispatch_block_t block);
上面这段代码向并发队列中添加栅栏块,保证了栅栏块之前的任务并发执行,而栅栏块本身及其后的任务则是顺序执行的。这样,可以确保写入操作在读取操作之后进行,从而避免了并发读取与写入操作导致的数据同步问题。
使用栅栏具体的实现代码:
_syncQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
- (NSString *)someString {
__block NSString *localSomeString;
dispatch_sync(_syncQueue, ^{
localSomeString = _someString;
});
return localSomeString;
}
- (void)setSomeString:(NSString *)someString {
//通过 dispatch_barrier_async 函数将一个栅栏块提交到 _syncQueue 中执行
dispatch_barrier_async(_syncQueue, ^{
_someString = someString;
});
}
在这个并发队列中,读取操作是用普通的块来实现的,而写入操作则是用栅栏块来实现的。读取操作可以并行,但写入操作必须单独执行,因为它是栅栏块。
这种做法肯定比使用串行队列要快。注意,设置函数也可以改用同步的栅栏块来实现。
- 派发队列可用来表述同步语义(synchronization semantic),这种做法要比使用 @synchronized 块或 NSLock 对象更简单。
- 将同步与异步派发结合起来,可以实现与普通加锁机制一样的同步行为,而这么做却不会阻塞执行异步派发的线程。
- 使用同步队列及栅栏块,可以令同步行为更加高效。
多用GCD,少用performSelector系列方法
什么是performSelector
performSelector 是 Objective-C 中的一个方法,用于在对象上调用指定的方法,并且可以延迟执行或在指定的线程上执行。
- (nullable id)performSelector:(SEL)aSelector;
它会在当前线程中调用指定的方法 aSelector,如果方法有返回值,则返回该返回值;如果方法没有返回值,则返回 nil。它相当于直接调用选择子:[object selectorName];
还有一个带有 withObject: 参数的版本,可以传递一个参数给指定的方法:
- (nullable id)performSelector:(SEL)aSelector withObject:(nullable id)anObject;
这种编程方式极为灵活,经常可用来简化复杂的代码。
为何要多用GCD
但是使用performSelector的特性的代价是,如果在 ARC 下编译代码,那么编译器会发出如下警示信息:
warning: performSelector may cause a leak because its selector
is unknown [-Warc-performSelector-leaks]
原因在于,编译器并不知道将要调用的选择子是什么,因此,也就不了解其方法签名及返回值,甚至连是否有返回值都不清楚。而且,由于编译器不知道方法名,所以就没办法运用 ARC 的内存管理规则来判定返回值是不是应该释放。鉴于此,ARC 采用了比较谨慎的做法,就是不添加释放操作。然而这么做可能导致内存泄漏,因为方法在返回对象时可能已经将其保留了。
有如下代码:
SEL selector;
if (/*some condition*/) {
selector = @selector(newObject);
} else if (/*some other condition*/) {
selector = @selector(copy);
} else {
selector = @selector(someProperty);
}
id ret = [object performSelector:selector];
if (selector == @selector(newObject) || selector == @selector(copy)) {
[ret release]; // 手动释放返回的对象
}
此代码中如果调用的是两个选择子之一,那么 ret 对象应由这段代码来释放,而如果是第三个选择子,则无须释放。不仅在 ARC 环境下应该如此,而且在非 ARC 环境下也应该这么做,这样才算严格遵循了方法的命名规范。如果不使用 ARC,那么在前两种情况下需要手动释放 ret 对象,而在后一种情况下则不需要释放。这个问题很容易忽视,而且就算用静态分析器,也很难侦测到内存泄漏。performSelector 系列的方法之所以要谨慎使用,这就是其中一个原因。
少使用performSelector系列方法的另一个原因在于:该系列方法返回值只能是 void 或对象类型。尽管所要执行的选择子也可以返回 void,但是 performSelector 方法的返回值类型毕竟是 id。如果想返回整数或浮点数等类型的值,那么就需要执行一些复杂的转换操作了,而这种转换很容易出错。
performSelector 还有如下几个版本,可以在发消息时顺便传递参数:
- (id)performSelector:(SEL)selector withObject:(id)object;
- (id)performSelector:(SEL)selector withObject:(id)objectA withObject:(id)objectB;
比方说,可以用下面这个版本来设置对象中名为 value 的属性值:
id object = /*an object with a property called value */;
id newValue = /*new value for the property */;
[object performSelector:@selector(setValue:) withObject:newValue];
由于参数类型是 id,所以传入的参数必须是对象才行。如果选择子所接受的参数是整数或浮点数,那就不要采用这些方法了。
performSelector 系列方法还有个功能,就是可以延后执行选择子,或将其放在另一个线程上执行。下面列出了此方法中一些更为常用的版本:
- (void)performSelector:(SEL)selector withObject:(id)argument afterDelay:(NSTimeInterval)delay;
- (void)performSelector:(SEL)selector onThread:(NSThread *)thread withObject:(id)argument waitUntilDone:(BOOL)wait;
- (void)performSelectorOnMainThread:(SEL)selector withObject:(id)argument waitUntilDone:(BOOL)wait;
但是这些方法都无法处理带有两个参数的选择子。能够指定执行线程的那些方法也不是特别通用。如果要用这些方法,就得把许多参数都打包到字典中,然后在受调用的方法里将其提取出来,这样会增加开销。而且还可能出 bug。
所以就需要改用其他替代方案,使它不受这些限制。
最主要的替代方案就是使用块,可以通过在 GCD 中使用 block 来实现。延后执行可以用 dispatch_after 来实现,在另一个线程上执行任务则可通过 dispatch_sync 及 dispatch_async 来实现。
比如要要延后执行某项任务,我们应该:
dispatch_time_t time = dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5.0 * NSEC_PER_SEC));
dispatch_after(time, dispatch_get_main_queue(), ^{
[self doSomething];
});
要把任务放在主线程上执行应该:
dispatch_async(dispatch_get_main_queue(), ^{
[self doSomething];
});
- performSelector 系列方法在内存管理方面容易有疏失。它无法确定将要执行的选择子具体是什么,因而ARC 编译器也就无法插入适当的内存管理方法。
- performSelector 系列方法所能处理的选择子太过局限了,选择子的返回值类型及发送给方法的参数个数都受到限制。
- 如果想把任务放在另一个线程上执行,那么最好不要用 performSelector 系列方法,而是应该把任务封装到块里,然后调用GCD 的相关方法来实现。
版权声明:本文标题:Effective Objective-C 学习(三) 内容由网友自发贡献,该文观点仅代表作者本人, 转载请联系作者并注明出处:http://www.freenas.com.cn/jishu/1726412781h956126.html, 本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容,一经查实,本站将立刻删除。
发表评论