背景

在开发中,实现一个带重试机制的异步任务是一个常见需求。最近,我在实现一个启动页数据加载的功能时,对重试逻辑的实现方式进行了深入的思考和实践,并踩了一些典型的坑。本文旨在记录两种截然不同的实现思路,分析其优劣,并反思为什么开发者有时会选择一个更复杂甚至错误的方案。

两种实现方案

我们以一个通用的重试辅助方法 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 的循环引用,追求状态的内部封装。直接解决问题,追求代码的简洁明了。

反思:为什么会设计出有缺陷的方案一?

这是一个值得深思的问题。选择一个更复杂、甚至有问题的方案,往往源于以下几点:

  1. 对“循环引用”的过度警惕和理解偏差(最核心原因)

    • 在方案二中,dispatch_after 的 Block 里直接使用了 [self ...],这构成了 self -> block -> self 的引用链,是典型的循环引用模式。
    • 开发者看到这个模式后,可能会立即判定它会导致内存泄漏,从而本能地去寻找一个“不引用 self”的方案,方案一看起来正好满足此要求。
    • 然而,这个判断是片面的。方案二中的循环引用是暂时性的。该引用链只在重试的延迟期间存在。一旦 dispatch_after 的 Block 执行完毕,它自身就会被释放,对 self 的强引用也随之解除,并不会造成永久的内存泄漏。对这个临时性的本质认识不清,是导致选择错误方案的根本原因。
  2. 对“Weak/Strong Dance”模式的机械套用

    • weak/strong 技巧是解决 Block 循环引用的标准武器,但它有其适用前提:Block 本身必须被一个外部对象强引用着(如类的属性)。
    • 在方案一中,attemptBlock 只是一个局部变量,存储在栈上。当 executeRetryableTask 方法返回后,它就会被销毁。此时,任何对它的弱引用都会失效(变为 nil)。
    • 开发者机械地套用了 weak/strong 模式,却没有考虑到 Block 本身的生命周期问题,导致了方案的失败。这警示我们,理解工具的原理比单纯记住用法更重要
  3. 对“状态封装”的过度追求

    • 方案一试图将所有状态(如重试次数)封装在 Block 内部,使其成为一个“自包含”的单元,这在设计上似乎更内聚。
    • 相比之下,方案二通过函数参数来传递状态,略显“暴露”。
    • 对这种设计美学的偏好,也可能引导开发者走向更复杂的实现,而忽略了简单方案的有效性。

总结与启示

  1. 深入理解原理,而非机械套用模式:对循环引用的理解不应停留在“看到 self 就用 weak”的表层。要分析引用链的完整生命周期,判断它是永久性的还是临时性的。

  2. 警惕局部 Block 的生命周期:在异步回调中使用定义在栈上的局部 Block 时要格外小心。它的生命周期可能不足以支撑回调的执行。如果需要,应使用 .copy 将其移到堆上,并妥善管理其生命周期。

  3. 拥抱简单性 (Embrace Simplicity):当一个简单、直接的方案(如方案二)能够正确解决问题时,不要因为一些理论上的“不完美”(如临时的循环引用)而轻易否定它。奥卡姆剃刀原理在这里同样适用:“如无必要,勿增实体”。

  4. 实践是检验真理的唯一标准:对于不确定的方案,编写测试用例或在 dealloc 中添加日志来验证其行为,是避免此类 Bug 的最有效手段。

这次探索提醒我,作为开发者,不仅要积累“做什么”的知识,更要深入理解“为什么这么做”,才能在复杂的场景下做出正确的技术决策。