背景
在开发中,实现一个带重试机制的异步任务是一个常见需求。最近,我在实现一个启动页数据加载的功能时,对重试逻辑的实现方式进行了深入的思考和实践,并踩了一些典型的坑。本文旨在记录两种截然不同的实现思路,分析其优劣,并反思为什么开发者有时会选择一个更复杂甚至错误的方案。
两种实现方案
我们以一个通用的重试辅助方法 executeRetryableTask 为例,对比两种实现。
方案一:复杂的 Block 自引用(有缺陷)
这种方案试图将重试逻辑封装在一个自包含的 Block 单元内,通过 “weak/strong dance” 的变体来避免循环引用。
// 方案一:复杂的 Block 自引用
- (void)executeRetryableTask:(RetryableApiCallBlock)apiCall
maxRetries:(NSInteger)maxRetries
retryInterval:(NSTimeInterval)retryInterval
completion:(RetryCompletionBlock)completion {
__block NSInteger currentRetryCount = 0;
void (^__block __weak weakAttemptBlock)(void); // 弱引用指针
void (^attemptBlock)(void); // 局部变量,存储在栈上
attemptBlock = ^{
void (^strongAttemptBlock)(void) = weakAttemptBlock; // 试图从弱引用恢复强引用
apiCall(^(id responseObject) { /* ... 成功 ... */ },
^(NSError *error) {
currentRetryCount++;
if (currentRetryCount >= maxRetries) { /* ... 最终失败 ... */ }
else {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(retryInterval * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
if (strongAttemptBlock) { // Bug 所在:strongAttemptBlock 在此会是 nil
strongAttemptBlock();
}
});
}
});
};
weakAttemptBlock = attemptBlock;
attemptBlock();
}
方案二:简单的异步递归
这种方案利用简单的递归调用,通过参数传递状态,代码直观且健壮。
// 方案二:简单的异步递归
- (void)executeRetryableTask:(RetryableApiCallBlock)apiCall
maxRetries:(NSUInteger)maxRetries
retryInterval:(NSTimeInterval)retryInterval
completion:(RetryCompletionBlock)completion {
apiCall(^(id responseObject) {
if (completion) {
completion(responseObject, nil);
}
}, ^(NSError *error) {
if (maxRetries > 1) {
DLog(@"任务失败, %.1f秒后重试 (剩余次数: %lu)", retryInterval, (unsigned long)maxRetries - 1);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(retryInterval * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
// 关键:直接递归调用 self 的方法
[self executeRetryableTask:apiCall
maxRetries:maxRetries - 1 // 递减重试次数
retryInterval:retryInterval
completion:completion];
});
} else {
DLog(@"任务达到最大重试次数。最终错误: %@", error);
if (completion) {
completion(nil, error);
}
}
});
}
对比与分析
| 对比维度 | 方案一 (复杂 Block) | 方案二 (简单递归) |
|---|---|---|
| 核心思想 | 将重试封装为自管理的“任务单元” | 通过递归调用完成重试 |
| 状态管理 | 内部 __block 变量 (currentRetryCount) | 函数参数传递 (maxRetries - 1) |
| 代码复杂度 | 高。理解 Block 生命周期和引用关系较为困难。 | 低。逻辑清晰,符合直觉。 |
| 健壮性 | 差。存在致命的生命周期 Bug,无法工作。 | 高。代码正确、健壮且易于维护。 |
| 主要动机 | 规避 perceived 的循环引用,追求状态的内部封装。 | 直接解决问题,追求代码的简洁明了。 |
反思:为什么会设计出有缺陷的方案一?
这是一个值得深思的问题。选择一个更复杂、甚至有问题的方案,往往源于以下几点:
对“循环引用”的过度警惕和理解偏差(最核心原因)
- 在方案二中,
dispatch_after的 Block 里直接使用了[self ...],这构成了self -> block -> self的引用链,是典型的循环引用模式。 - 开发者看到这个模式后,可能会立即判定它会导致内存泄漏,从而本能地去寻找一个“不引用 self”的方案,方案一看起来正好满足此要求。
- 然而,这个判断是片面的。方案二中的循环引用是暂时性的。该引用链只在重试的延迟期间存在。一旦
dispatch_after的 Block 执行完毕,它自身就会被释放,对self的强引用也随之解除,并不会造成永久的内存泄漏。对这个临时性的本质认识不清,是导致选择错误方案的根本原因。
- 在方案二中,
对“Weak/Strong Dance”模式的机械套用
weak/strong技巧是解决 Block 循环引用的标准武器,但它有其适用前提:Block 本身必须被一个外部对象强引用着(如类的属性)。- 在方案一中,
attemptBlock只是一个局部变量,存储在栈上。当executeRetryableTask方法返回后,它就会被销毁。此时,任何对它的弱引用都会失效(变为nil)。 - 开发者机械地套用了
weak/strong模式,却没有考虑到 Block 本身的生命周期问题,导致了方案的失败。这警示我们,理解工具的原理比单纯记住用法更重要。
对“状态封装”的过度追求
- 方案一试图将所有状态(如重试次数)封装在 Block 内部,使其成为一个“自包含”的单元,这在设计上似乎更内聚。
- 相比之下,方案二通过函数参数来传递状态,略显“暴露”。
- 对这种设计美学的偏好,也可能引导开发者走向更复杂的实现,而忽略了简单方案的有效性。
总结与启示
深入理解原理,而非机械套用模式:对循环引用的理解不应停留在“看到
self就用weak”的表层。要分析引用链的完整生命周期,判断它是永久性的还是临时性的。警惕局部 Block 的生命周期:在异步回调中使用定义在栈上的局部 Block 时要格外小心。它的生命周期可能不足以支撑回调的执行。如果需要,应使用
.copy将其移到堆上,并妥善管理其生命周期。拥抱简单性 (Embrace Simplicity):当一个简单、直接的方案(如方案二)能够正确解决问题时,不要因为一些理论上的“不完美”(如临时的循环引用)而轻易否定它。奥卡姆剃刀原理在这里同样适用:“如无必要,勿增实体”。
实践是检验真理的唯一标准:对于不确定的方案,编写测试用例或在 dealloc 中添加日志来验证其行为,是避免此类 Bug 的最有效手段。
这次探索提醒我,作为开发者,不仅要积累“做什么”的知识,更要深入理解“为什么这么做”,才能在复杂的场景下做出正确的技术决策。