使用 Swinject 实现更好的面向协议编程

今天,我们一起探讨一个对构建高质量、可维护、可测试的 iOS 应用至关重要的主题:面向协议编程 (Protocol-Oriented Programming, POP) ,以及如何在实际业务场景中进行抽象思维锻炼,以及如何利用依赖注入(DI)框架 Swinject 将其威力发挥到最大。

文章将分为四个部分:

  1. 思维的转变:从传统的编程范式演进到面向协议编程,并辩证地看待它与面向对象的关系。

  2. 进阶实战:通过三个从易到难的真实案例,深度锻炼和塑造抽象思维。

  3. 实践的利器:结合 Swinject,将面向协议编程在项目中高效落地。

  4. 未来的展望:在 Swift Concurrency 等新技术浪潮下,POP 的地位与发展。


第一部分:思维的转变 ------ 从具体实现到抽象契约

曾经的方式:面向具体类型的编程

假设我们有一个 UserManager,它需要从网络获取用户信息。一个直接的实现可能如下:

swift 复制代码
// 一个具体的网络服务实现

class APINetworkService {

    func fetchUserData(userId: String, completion: @escaping (Data?) -> Void) {

        // 实现网络请求逻辑...
        print("Fetching data for \(userId) from the real API...")
        // ...
    }

}


// 用户管理器直接依赖于具体的网络服务类

class UserManager {

    private let networkService = APINetworkService() // 直接实例化依赖
    
    func loadUser(id: String) {

        networkService.fetchUserData(userId: id) { data in
            // 处理数据...
        }

    }

}

这种写法在项目初期看起来简单直接,但随着业务复杂度的提升,其弊端会逐渐暴露:

弊端分析:

  1. 高度耦合 (Tight Coupling)
  • UserManagerAPINetworkService 像被胶水粘在了一起。UserManager 不仅知道它需要一个"网络服务",它还明确知道这个服务必须是 APINetworkService 类型。

  • 如果未来我们需要替换成 GraphQLNetworkService 或者其他服务,就必须修改 UserManager 的内部代码。这违反了开闭原则(对扩展开放,对修改关闭)。

  1. 极差的可测试性 (Poor Testability)
  • 如何为 UserManager 编写单元测试?当我们调用 loadUser(id:) 时,它会发起真实的 APINetworkService 网络请求。这会导致单元测试变得缓慢、不稳定,并且依赖外部环境。

  • 我们无法轻易地"伪造"(Mock)一个网络服务来模拟成功、失败、超时等各种场景。

  1. 灵活性与扩展性受限 (Limited Flexibility & Extensibility)
  • 想象一下,如果 APINetworkService 是一个第三方 SDK 里的 final class,我们就无法通过继承来修改或扩展其行为了。

  • 对于值类型(Structs, Enums),继承模型完全不适用,这使得我们无法为它们统一行为。

面向对象 vs. 面向协议 比较

在转向 POP 之前,我们必须清晰地认识到它与我们所熟悉的面向对象编程(OOP)之间的关系。它不是一场革命,而是一次演进。

关注点 经典 OOP 方式 (以继承为中心) 面向协议编程 (POP) 方式 (以组合为中心)
关系模型 "是一个" (Is-A)Dog 是一个 Animal。强调的是身份和分类。 "能做什么" (Can-Do / Has-A)BirdPlane 都能 Fly。强调的是能力和行为。
代码复用 通过继承。子类继承父类的属性和方法。 通过协议扩展组合。不同类型可以遵守同一个协议,共享默认实现。
类型限制 主要用于引用类型 (Class) 可用于引用类型 (Class)值类型 (Struct, Enum),适用性更广。
灵活性 单一继承。一个类只能有一个父类,可能导致臃肿的基类(Massive Base Class)问题。 多重遵守。一个类型可以遵守多个协议,按需组合能力,更加灵活。
耦合性 子类与父类紧密耦合,父类的改动会影响所有子类。 遵守协议的类型与协议本身是松耦合的。依赖方只关心契约,不关心实现。

需要强调的是面向对象开发和面向协议开发并不是完全对立的,需要结合自身业务进行选择取舍。在现代 Swift 开发中,它们可以是相辅相成、协同工作的。

  • OOP 的 class 仍然是状态管理的核心 :当你需要一个具有身份(在内存中有唯一地址)、可以被共享和修改的实例时,class 是不二之选。例如,UIViewControllerUIView、以及需要管理复杂状态的服务对象。

  • POP 为 class 插上抽象的翅膀 :我们可以让这些类去遵守协议。这样,既利用了 class 的状态管理能力,又享受了 POP 带来的解耦和灵活性。

一个绝佳的例子:

一个 OrderViewController (Class) 需要展示加载动画。我们可以定义一个 LoadingIndicatorPresenter 协议。

swift 复制代码
protocol LoadingIndicatorPresenter {

    func showLoading()

    func hideLoading()

}

extension LoadingIndicatorPresenter where Self: UIViewController {

    // 为所有 UIViewController 提供一个默认实现

    func showLoading() { /* 在 self.view 上添加一个 aSpinner... */ }

    func hideLoading() { /* 移除 aSpinner... */ }
}

// OrderViewController 是一个 Class,但它通过遵守协议获得了新能力

class OrderViewController: UIViewController, LoadingIndicatorPresenter {

    // ...
    func placeOrder() {
        self.showLoading() // 直接使用协议扩展带来的能力
        // ...
    }

}

在这里,OrderViewController 依然是一个 OOP 世界里的 class,但它通过遵守 LoadingIndicatorPresenter 协议,以一种可组合、可复用的方式获得了新功能。这就是 OOP 和 POP 的完美结合。

核心转变 :从"这个对象是什么类型?"转变为"这个对象能做什么?"。


第二部分:实战

案例一:可插拔的日志系统

场景:应用需要记录日志。初期我们可能只打印到控制台。但很快,产品要求将重要日志写入文件,并将错误日志上报到服务器。

糟糕的设计 :在日志记录函数中使用大量的 if-elseswitch

swift 复制代码
// 糟糕的设计

class Logger {

    func log(message: String, level: LogLevel) {

        // 输出到控制台
        print("[\(level)] \(message)")

        // 如果是重要日志
        if level == .warning || level == .error {
            // 写入文件逻辑...
        }

        // 如果是错误日志
        if level == .error {
            // 上报服务器逻辑...
        }
    }
}

问题 :每增加一个日志输出目标(比如新的分析平台),都必须修改 Logger 类的内部代码,违反了开闭原则。

抽象思维的塑造

  1. 识别核心行为:日志系统的本质是什么?是把一条日志消息"发送到一个目的地"。这个"目的地"是可变的。

  2. 定义契约 (Protocol) :我们可以将"目的地"抽象为一个协议 LogDestination

swift 复制代码
protocol LogDestination {

    // 每个目的地都需要知道如何处理一条日志消息
    func write(message: String)

    }
  1. 创建具体实现:为每个目的地创建一个遵守协议的具体类。
swift 复制代码
class ConsoleDestination: LogDestination {

    func write(message: String) {
        print(message)
    }

}

class FileDestination: LogDestination {

    func write(message: String) {
        // 写入文件的逻辑...
    }

}

class RemoteAPIDestination: LogDestination {

    func write(message: String) {
        // 上报服务器的逻辑...
    }

}
  1. 组合与解耦 :改造 Logger,让它不关心具体的目的地实现,只持有一组遵守 LogDestination 协议的对象。
swift 复制代码
class Logger {

    private let destinations: [LogDestination]

    // 通过构造函数注入所有目的地
    init(destinations: [LogDestination]) {
        self.destinations = destinations
    }
    
    func log(message: String, level: LogLevel) {
        let formattedMessage = "[\(level)] \(message)"
        destinations.forEach { $0.write(message: formattedMessage) }
    }

}

// 使用时:
let logger = Logger(destinations: [ConsoleDestination(), FileDestination()])

优势 :现在,如果我们想增加一个新的日志目的地(如 SentryDestination),我们只需要创建一个新的类遵守 LogDestination 协议,然后在初始化 Logger 时将它加入数组即可。Logger 类本身的代码完全不需要改动。这就是开闭原则的完美体现,代码的可维护性和扩展性得到了质的飞跃。

案例二:动态列表页面

场景:构建一个复杂的首页或详情页,页面由多种不同样式的卡片(用户信息、帖子列表、广告、推荐商品等)组成,并且这些卡片的顺序和种类可能由服务器动态决定。

典型实现 :在 UITableViewDataSource 中使用一个巨大的 switchif-else if 链,根据 indexPath.row 来判断应该显示哪种 UITableViewCell

swift 复制代码
// 糟糕的设计

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    switch components[indexPath.row] {
    case let user as UserProfile:
        let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell") as! UserCell
        cell.configure(with: user)
        return cell
    case let post as Post:
        // ... dequeue PostCell
    case let ad as Ad:
        // ... dequeue AdCell
        // ... 以后每加一种类型,都要修改这里
    }
}

问题ViewController 与所有具体的 CellViewModel 类型紧密耦合。每当 UI 设计师想增加一种新的卡片类型,或者调整卡片顺序的逻辑,开发者都必须深入到这个庞大的 switch 语句中进行修改,极易出错。

抽象思维的塑造

  1. 识别核心抽象:页面上的每一个卡片,无论内容和样式如何,都可以被看作一个"可渲染的组件 (Renderable Component)"。

  2. 定义契约 (Protocol):这个"组件"需要告诉我们什么信息?

  • 它对应哪种 UITableViewCell

  • 它如何配置这个 Cell

swift 复制代码
// 定义一个可以配置 Cell 的协议

protocol CellConfigurator {

    static var reuseId: String { get }

    func configure(cell: UIView)

}

// ViewModel 遵守这个协议

class UserViewModel: CellConfigurator {

    static let reuseId: String = "UserCell"

    let name: String

    let avatarUrl: URL

    //...

    func configure(cell: UIView) {

        guard let cell = cell as? UserCell else { return }

        cell.nameLabel.text = name

        //...
    }

}

// 我们还需要一个更高层次的抽象来连接 ViewModel 和 View

protocol ScreenComponent {

    var configurator: CellConfigurator { get }

}

struct UserComponent: ScreenComponent {

    let configurator: CellConfigurator

    init(user: User) {
        self.configurator = UserViewModel(user: user)
    }

}
  1. 数据驱动 UIViewController 只持有一个抽象的组件数组,它不再关心具体的类型。
swift 复制代码
class DynamicListViewController: UIViewController, UITableViewDataSource {

    private var components: [CellConfigurator] = [] // 数组里是抽象的配置器

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

    let configurator = components[indexPath.row]

    // 动态获取 reuseId
    let cell = tableView.dequeueReusableCell(withIdentifier: type(of: configurator).reuseId, for: indexPath)

    // 调用配置方法
    configurator.configure(cell: cell)
    
    return cell

    }
}

优势ViewController 完全与具体的 CellViewModel 解耦。它变成了一个通用的组件渲染引擎。

  • 添加新卡片 :只需创建一个新的 ViewModel 遵守 CellConfigurator,创建一个新的 Cell,然后将新的 ViewModel 实例添加到 components 数组中。ViewController 一行代码都不用改

  • 调整布局:服务器返回不同的组件数组,UI 自动随之改变。

  • 可测试性 :我们可以轻松地创建 MockCellConfigurator 来测试 ViewController 的渲染逻辑,而无需依赖真实的 UIView

案例三:可替换的 A/B 测试与功能开关系统

场景:应用需要集成一个功能开关(Feature Flag)或 A/B 测试系统。这个系统决定了某个新功能(比如新的支付流程)是否对当前用户可见。后端可能会使用不同的供应商(如 Firebase Remote Config, LaunchDarkly, 或自研系统)。

典型实现:在业务代码中硬编码对特定 SDK 的调用。

swift 复制代码
// 糟糕的设计

import FirebaseRemoteConfig

class CheckoutViewModel {

    func shouldShowNewPaymentFlow() -> Bool {

        // 与 Firebase 强耦合
        return RemoteConfig.remoteConfig().configValue(forKey: "enable_new_payment_flow").boolValue

    }

}

问题 :如果公司决定从 Firebase 迁移到 LaunchDarkly,你需要找到代码中所有调用 RemoteConfig 的地方,逐一修改。这是一个巨大的、风险极高的重构任务。业务逻辑和第三方服务实现被死死地绑在了一起。

抽象思维的塑造

  1. 分离关注点 :业务逻辑的关注点是"某个功能是否开启? ",它不应该关心这个决策是"如何做出的?"。

  2. 定义契约 (Protocol) :我们将"功能决策"这个能力抽象成一个 FeatureProvider 协议。

swift 复制代码
// 定义一个安全的、类型化的功能枚举

enum Feature {

    case newPaymentFlow

    case redesignedHomePage

}

// 定义核心契约

protocol FeatureProvider {

    func isFeatureEnabled(_ feature: Feature) -> Bool

}
  1. 创建具体实现:为每个第三方服务或本地默认值创建遵守协议的适配器(Adapter)。
swift 复制代码
import FirebaseRemoteConfig

class FirebaseFeatureProvider: FeatureProvider {

    func isFeatureEnabled(_ feature: Feature) -> Bool {

        let key = feature.rawValue // e.g., "newPaymentFlow"

        return RemoteConfig.remoteConfig().configValue(forKey: key).boolValue
    }

}

import LaunchDarkly

class LaunchDarklyFeatureProvider: FeatureProvider {

    func isFeatureEnabled(_ feature: Feature) -> Bool {

        let key = feature.rawValue

        return LDClient.get()!.boolVariation(forKey: key, defaultValue: false)
    }

}

// 一个用于开发或测试的默认提供者

class LocalDefaultFeatureProvider: FeatureProvider {

    func isFeatureEnabled(_ feature: Feature) -> Bool {
        switch feature {
        case .newPaymentFlow: return true // 在开发时默认开启
        default: return false
        }
    }

}
  1. 注入抽象 :在整个应用中,所有需要检查功能开关的地方,都依赖于抽象的 FeatureProvider 协议。
swift 复制代码
class CheckoutViewModel {

    private let featureProvider: FeatureProvider

    // 通过 DI 注入
    init(featureProvider: FeatureProvider) {
        self.featureProvider = featureProvider
    }

    func shouldShowNewPaymentFlow() -> Bool {
        // 只与抽象协议对话,完全不知道背后是 Firebase 还是其他
        return featureProvider.isFeatureEnabled(.newPaymentFlow)
    }

}

优势

  • 平台无关性CheckoutViewModel 和其他业务模块变得完全与 A/B 测试平台无关。

  • 无痛切换 :更换 A/B 测试供应商,现在只需要编写一个新的 YourNewFeatureProvider,然后在 DI 容器(如 Swinject)中将注册的实现从 FirebaseFeatureProvider 改为 YourNewFeatureProvider所有业务代码都无需改动

  • 健壮性与可测试性 :在单元测试中,我们可以注入一个 LocalDefaultFeatureProvider 来精确控制功能的开启与关闭,从而测试 UI 在不同分支下的表现,这在之前是极其困难的。


第三部分:强强联合 ------ Swinject 赋能面向协议编程

当项目规模扩大,手动创建和传递这些 LogDestination, ScreenComponent, FeatureProvider 会变得非常繁琐。这正是 Swinject 发挥作用的地方。

使用 Swinject 注册 FeatureProvider

swift 复制代码
container.register(FeatureProvider.self) { _ in

    #if DEBUG

    // 调试版本使用本地默认值
    return LocalDefaultFeatureProvider()

    #else

    // 正式版本使用 Firebase
    return FirebaseFeatureProvider()
    
    #endif

}.inObjectScope(.container)


container.register(CheckoutViewModel.self) { r in
    CheckoutViewModel(featureProvider: r.resolve(FeatureProvider.self)!)
}

// 在使用处,Swinject 自动完成了所有工作
let viewModel = container.resolve(CheckoutViewModel.self)!

引入第三方框架的权衡与弊端

Swinject 虽然强大,但引入任何第三方框架都需要经过审慎的评估。可以根据团队对框架的熟悉度自行选择。

  1. 学习曲线与维护成本 (Learning Curve & Maintenance)
  • 团队新成员需要时间学习 Swinject 的概念,如 Container, Resolver, Assembly, ObjectScope 等。

  • 框架本身需要跟进 Swift 的版本更新,这可能带来额外的维护工作。

  1. "魔法"代码与可追溯性 (Magic Code & Traceability)
  • container.resolve(UserManager.self)! 看起来像"魔法"。对于初学者,很难一眼看出 UserManagernetworkService 究竟是从哪里来的。

  • 相比于清晰的构造函数调用,这种隐式的依赖解析在调试时可能会增加一层复杂性。

  1. 运行时崩溃风险 (Runtime Crash Risk)
  • 如果在注册时出现遗漏或类型错误,resolve 时强制解包(!)会导致运行时崩溃 。虽然 Swinject 提供了安全的解析方式(resolve 返回可选值),但在实践中,为了方便,强制解包很常见。这牺牲了一部分 Swift 强大的编译时安全检查。
  1. 过度设计的风险 (Risk of Over-engineering)
  • 对于小型、简单的项目,引入一个 DI 框架可能是一种过度设计。手动注入可能已经足够,且代码更直观。

Swinject 是 POP 的"放大器",尤其适合中大型项目。它极大地简化了复杂的依赖管理,但使用时需清楚其带来的成本,并在团队内建立起统一的使用规范。请在仔细评估后,选择是否选择这个框架,也可以选择自己实现一个简易的依赖注入框架。


第四部分:展望未来 ------ POP 在并发时代下的新角色

Apple 近年来在技术上最大的变革无疑是 Swift Concurrency 的引入,包括 async/await, Task, 以及 Actor。一个自然而然的问题是:这些新技术是否会削弱甚至取代 POP 的地位?

我的答案是:不仅不会,反而会使 POP 的重要性更加凸显。它们是相辅相成的关系,而非替代关系。

POP 解决的是架构和抽象 层面的问题:"代码应该如何组织,组件之间如何通信?"

Swift Concurrency 解决的是执行和数据同步 层面的问题:"代码应该如何执行,如何保证并发安全?"

它们在不同的维度上提升代码质量,并且可以完美结合。

POP 与 Swift Concurrency 的协同工作

  1. 协议可以定义异步接口

前面的 NetworkService 协议可以很自然地进化成异步版本:

swift 复制代码
// 协议定义了异步的契约

protocol NetworkService {

    func fetchUserData(userId: String) async throws -> Data

}

// 实现类使用 async/await

class APINetworkService: NetworkService {

    func fetchUserData(userId: String) async throws -> Data {

        let url = URL(string: "https://api.example.com/users/\(userId)")!

        let (data, _) = try await URLSession.shared.data(from: url)

        return data

    }

}

消费者(如 UserManager)依然依赖于 NetworkService 协议,它根本不需要知道底层的实现是基于 URLSessionasync API 还是老旧的 completionHandler。POP 的抽象能力在这里依然完美生效。

  1. Actor 可以遵守协议

Actor 是为了解决并发环境下的数据竞争问题而生的。一个 Actor 同样可以遵守协议,从而将它线程安全的特性隐藏在抽象的接口之后。

swift 复制代码
protocol CacheService {

    func store(data: Data, forKey key: String) async

    func retrieve(forKey key: String) async -> Data?

}

// Actor 作为一个具体的、线程安全的实现
actor InMemoryCache: CacheService {

    private var storage: [String: Data] = [:]
    
    func store(data: Data, forKey key: String) {
        storage[key] = data
    }

    func retrieve(forKey key: String) -> Data? {
        storage[key]
    }
}

使用时, 只需要通过 DI 容器获取一个 CacheService 的实例,它完全不必关心这个实例背后是一个 Actor 还是一个使用 NSLock 的普通 class。这再次证明了 POP 在隔离复杂性方面的强大能力。

新的技术发展路径

新的技术发展路径并非是"用 Concurrency 替代 POP",而是演进为一种 "面向协议的、并发安全的架构" (Protocol-Oriented, Concurrency-Safe Architecture)

面向协议编程 (POP) 不是一个时髦的口号,它是一种深刻的思维转变,引导我们构建出低耦合、高内聚、易测试、易扩展的软件系统。通过对日志系统、动态UI、功能开关 等真实案例的剖析,我们看到,POP 的核心在于识别变化、封装抽象,这是提升代码品味、写出传世佳作的必经之路。

Swinject 等 DI 框架是实现 POP 的强大工具,它将我们从繁琐的手动依赖管理中解放出来。

最后,以 async/awaitActor 为代表的 Swift Concurrency 并不是 POP 的竞争者,而是它的新伙伴。二者的结合,让我们能够以前所未有的优雅和安全,构建出兼具优秀架构和卓越性能的现代化 iOS 应用。

相关推荐
SoFlu软件机器人8 小时前
秒级构建消息驱动架构:描述事件流程,生成 Spring Cloud Stream+RabbitMQ 代码
分布式·架构·rabbitmq
之墨_9 小时前
【大语言模型入门】—— Transformer 如何工作:Transformer 架构的详细探索
语言模型·架构·transformer
Jacob023414 小时前
为什么现在越来越多项目选择混合开发?从 WebAssembly 在无服务器中的表现说起
架构·rust·webassembly
乌恩大侠14 小时前
USRP X440 和USRP X410 直接RF采样架构的优势
5g·fpga开发·架构·usrp·usrp x440·usrp x410
俞凡16 小时前
[大厂实践] Netflix 时间序列数据抽象层实践
架构
老纪的技术唠嗑局16 小时前
走出三大困境,深耕五大场景 | 好未来AI业务探索OceanBase实现降本86%
运维·数据库·架构
张人玉17 小时前
C#分层架构
开发语言·架构·c#
昵称为空C17 小时前
Jdk21优雅处理异步任务
java·后端·架构
zkmall20 小时前
ZKmall开源商城架构工具链:Docker、k8s 部署与管理技巧
docker·架构·开源