14-14.【模块化与包管理】为什么 protocol + default implementation 有时会破坏模块边界?如何避免?

6 阅读3分钟

在 Swift 中,Protocol + Default Implementation(协议 + 默认实现) 是一把双刃剑。虽然它提供了强大的“混入”(Mixin)能力,但在多模块架构下,它常会引发逻辑泄露(Logic Leakage)和依赖污染,从而破坏你精心设计的模块边界。


1. 为什么它会破坏模块边界?

A. 隐式的逻辑侵入

当你为一个定义在底层模块(如 Core)的协议提供了默认实现时,任何遵循该协议的模块都会自动获得这段逻辑。

  • 风险:如果默认实现中引用了某个特定的第三方库或上层模块的功能,底层模块就会被迫产生不该有的依赖。

B. 静态分发与“接口假象”

这是最坑的一点。如果在协议定义中没有声明某个方法,但在扩展(Extension)中写了默认实现,该方法是静态分发的。

  • 边界破坏:当下层模块通过协议调用该方法时,它实际运行的是扩展里的代码。如果扩展逻辑是针对特定业务设计的,这就导致底层模块在无意中执行了上层模块的私有逻辑,形成了隐性耦合

C. “强制性”能力赋予

默认实现有时会给不该具备某种能力的类型强制赋予了能力。

  • 例子:如果你给 BaseViewModel 协议增加了一个默认的日志保存实现。那么,所有的子 ViewModel 都会带上这个行为。即使某个轻量级的“隐私模式”模块不希望有日志行为,它也无法在不修改协议的情况下完全剥离。

2. 如何避免?

A. 限制扩展的作用域 (Constrained Extensions)

不要定义全局的默认实现。利用 where 子句将实现限制在特定范围内。

  • 做法:只有满足特定条件的类型才获得该能力。

Swift

extension MyProtocol where Self: UIViewController {
    // 只有在 UI 模块中,且是 VC 时才具备此逻辑,
    // 这样就不会污染 Core 层的纯逻辑对象。
    func showCommonAlert() { ... }
}

B. 将实现搬移到“中介者”或“适配器”

不要在协议扩展里写复杂的逻辑。扩展应该只做简单的转发,或者仅作为能力的标记。

  • 做法:如果逻辑复杂,定义一个专门的服务类,在默认实现中通过依赖注入(DI)调用。

C. 遵循“接口隔离原则”(ISP)

如果发现默认实现变得臃肿,说明协议承载了太多职责。

  • 方案:将协议拆分为更小的单元。

    • Loadable
    • Retryable
    • Logger
  • 模块可以根据需要组合(Composition)这些小协议,而不是被迫继承一个带有臃肿默认实现的巨型协议。

D. 使用“特化模块”存放扩展

如果你想为 Core 模块的协议提供针对 SwiftUI 的优化,不要写在 Core 模块里。

  • 做法:创建一个名为 Core+SwiftUI 的粘合模块(Glue Module)。在这个模块里对协议进行扩展。这样,只有同时引用了这两个模块的 UI 层能看到默认实现,核心逻辑层依然保持纯净。

3. 一个典型的错误 vs 修正

❌ 错误:在底层定义了涉及上层的逻辑

Swift

// 在 Core 模块
protocol DataService { }
extension DataService {
    func logError() { 
        // 这里的 FirebaseLogger 属于上层库,导致 Core 依赖了 Firebase
        FirebaseLogger.shared.log("Error") 
    }
}

✅ 修正:解耦默认实现

Swift

// 在 Core 模块
protocol DataService {
    var errorHandler: ((Error) -> Void)? { get }
}

// 在 App 层(或 Glue 模块)
extension DataService {
    func logError(error: Error) {
        // 这里的逻辑由外部注入或通过闭包回调,Core 层不感知 Firebase
        errorHandler?(error)
    }
}

总结:黄金法则

如果你正在写一个协议的 extension,请问自己: “如果我把这段代码删掉,这个模块还能独立运行吗?” 如果答案是否定的,说明你正在通过默认实现跨越不该跨越的边界。