Combine 自定义 Subject:构建专属的响应式事件源

Combine 内置的 PassthroughSubjectCurrentValueSubject 覆盖了大多数事件传递与状态管理场景。但当你的需求超出"直通"和"记忆"时------比如你希望发送的值必须通过验证、在一定时间内去重、或者自动聚合------自定义 Subject 便能让你将这些逻辑封装在可复用的发布者内部,让外部调用者依然以熟悉的 .send() 方式交付数据,而无需在每个订阅点重复过滤逻辑。

本章将带你理解 Subject 协议的本质,学习如何基于现有 Subject 快速定制出符合业务需求的专属事件源,并给出多个可直接投入生产的实战案例。

1. Subject 协议:一半 Publisher,一半指令

Subject 是一个协议,它同时继承了 PublisherAnyObject。这意味着 Subject 必然是引用类型(class)。它额外要求实现两个方法:

swift 复制代码
public protocol Subject: AnyObject, Publisher {
    func send(_ value: Self.Output)
    func send(completion: Subscribers.Completion<Self.Failure>)
}

这就赋予了 Subject 双重身份:

  • 作为 Publisher:可以被任意数量的订阅者订阅。
  • 作为 指令入口 :外部代码可以主动调用 send() 向所有订阅者广播值或完成事件。

Combine 内置的两个 Subject ------ PassthroughSubject(不保存历史值)和 CurrentValueSubject(保存并暴露最新值)------ 已经实现了这套机制。自定义 Subject 通常就是在这两个内建类的之上包装一层逻辑。

2. 为什么需要自定义 Subject

你可能会问:既然操作符如 filterdebounceremoveDuplicates 都能在管道中完成同样的功能,为什么要把逻辑塞进 Subject 里?

场景驱动答案:

  • 可复用的事件入口 :当你有一个全局事件总线(Event Bus),希望所有 .send() 进入的值自动跳过连续重复,就可以用 DistinctSubject 直接替换 PassthroughSubject,而不必在每个订阅处重复书写 removeDuplicates()
  • 业务规则强制:例如一个"订单提交 Subject",必须保证提交的订单金额大于 0,否则直接丢弃。这个规则应该内聚在 Subject 内部,防止调用方忘记校验。
  • 性能优化 :在高频事件(如加速度计数据)中,如果发送方不加以节制,下游即使使用 throttle 也可能被淹没。在 Subject 层直接限流可以减轻整体管道压力。
  • 清晰度与一致性 :将定制逻辑放在 Subject 中,让数据源的定义更加语义化。一个 DebouncedTextSubject 一看就知道它会自动防抖,不必每次在多个管道中复制相同的防抖配置。

3. 自定义 Subject 的通用模式

自定义 Subject 并不需要你重新实现底层的订阅、背压、线程安全等复杂机制。标准做法是在内部持有一个内建 Subject(通常是 PassthroughSubjectCurrentValueSubject ,将所有订阅转发给它,同时在 send() 方法中插入自己的处理逻辑。

以下是一个空白的自定义 Subject 骨架:

swift 复制代码
class CustomSubject<Output, Failure: Error>: Subject {
    // 内部真正负责发布的对象
    private let innerSubject: PassthroughSubject<Output, Failure>

    init() {
        innerSubject = PassthroughSubject()
    }

    // 转发 send
    func send(_ value: Output) {
        // 在这里加入自定义逻辑
        // ...
        innerSubject.send(value)
    }

    // 转发 completion
    func send(completion: Subscribers.Completion<Failure>) {
        innerSubject.send(completion: completion)
    }

    // 转发订阅
    func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input {
        innerSubject.receive(subscriber: subscriber)
    }
}

要点:

  • 必须实现 receive(subscriber:) 将订阅者桥接到内部 Subject。
  • send(_:)send(completion:) 是外部调用入口,也是加工逻辑的注入点。
  • 内部 Subject 负责所有线程安全与背压细节,你只需聚焦业务规则。

4. 实战案例一:带验证的 Subject

在许多表单场景中,需要保证输入值满足特定条件才能被接受。ValidatedSubjectsend 时执行闭包验证,拒绝非法值。

swift 复制代码
class ValidatedSubject<Output, Failure: Error>: Subject {
    private let subject = PassthroughSubject<Output, Failure>()
    private let validator: (Output) -> Bool

    init(validator: @escaping (Output) -> Bool) {
        self.validator = validator
    }

    func send(_ value: Output) {
        guard validator(value) else { return }  // 验证不通过,直接丢弃
        subject.send(value)
    }

    func send(completion: Subscribers.Completion<Failure>) {
        subject.send(completion: completion)
    }

    func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input {
        subject.receive(subscriber: subscriber)
    }
}

使用示例:

swift 复制代码
let nameSubject = ValidatedSubject<String, Never> { name in
    name.count >= 3 && name.count <= 20
}

nameSubject.sink { print("✅ \($0)") }
          .store(in: &cancellables)

nameSubject.send("AB")        // 被丢弃
nameSubject.send("Alice")     // ✅ Alice
nameSubject.send("Long name exceeding twenty chars")  // 被丢弃

5. 实战案例二:防抖 Subject

在搜索框场景中,我们希望仅当用户停止输入一段时间后才发送最终值。虽然可以用操作符 .debounce,但若多个 ViewModel 都需要同样的防抖行为,一个 DebouncedSubject 可以直接作为数据源。

swift 复制代码
class DebouncedSubject<Output, Failure: Error>: Subject {
    private let subject = PassthroughSubject<Output, Failure>()
    private var workItem: DispatchWorkItem?
    private let dueTime: DispatchTimeInterval
    private let queue: DispatchQueue

    init(dueTime: DispatchTimeInterval = .milliseconds(300),
         queue: DispatchQueue = .main) {
        self.dueTime = dueTime
        self.queue = queue
    }

    func send(_ value: Output) {
        workItem?.cancel()
        let item = DispatchWorkItem { [weak self] in
            self?.subject.send(value)
        }
        workItem = item
        queue.asyncAfter(deadline: .now() + dueTime, execute: item)
    }

    func send(completion: Subscribers.Completion<Failure>) {
        workItem?.cancel()
        subject.send(completion: completion)
    }

    func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input {
        subject.receive(subscriber: subscriber)
    }
}

使用示例:

swift 复制代码
let searchSubject = DebouncedSubject<String, Never>(dueTime: .milliseconds(500))

searchSubject.sink { print("搜索: \($0)") }
            .store(in: &cancellables)

searchSubject.send("S")
searchSubject.send("Sw")
searchSubject.send("Swi")
searchSubject.send("Swift") // 仅此值将在 0.5 秒后被发送

6. 实战案例三:去除连续重复值的 Subject

DistinctSubject 比较相邻两个值,若相同则拒绝发送。内部可以基于 Equatable 或自定义比较闭包。

swift 复制代码
class DistinctSubject<Output: Equatable, Failure: Error>: Subject {
    private let subject = PassthroughSubject<Output, Failure>()
    private var lastValue: Output?

    func send(_ value: Output) {
        guard value != lastValue else { return }
        lastValue = value
        subject.send(value)
    }

    func send(completion: Subscribers.Completion<Failure>) {
        subject.send(completion: completion)
    }

    func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input {
        subject.receive(subscriber: subscriber)
    }
}

7. 实战案例四:限流 Subject

在高频数据源(如传感器)中,可能需要强制最小发送间隔。ThrottledSubject 记录上次发送时间,若距上次不足指定间隔则丢弃当前值。

swift 复制代码
class ThrottledSubject<Output, Failure: Error>: Subject {
    private let subject = PassthroughSubject<Output, Failure>()
    private var lastSendTime: Date = .distantPast
    private let minimumInterval: TimeInterval

    init(minimumInterval: TimeInterval) {
        self.minimumInterval = minimumInterval
    }

    func send(_ value: Output) {
        let now = Date()
        guard now.timeIntervalSince(lastSendTime) >= minimumInterval else { return }
        lastSendTime = now
        subject.send(value)
    }

    func send(completion: Subscribers.Completion<Failure>) {
        subject.send(completion: completion)
    }

    func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input {
        subject.receive(subscriber: subscriber)
    }
}

8. 实战案例五:聚合 Subject

某些场景下,希望收集一定数量的值后进行一次聚合运算再发出。AggregatingSubject 内部维护缓冲区,达到阈值时调用聚合闭包产生一个结果并清空缓冲区。

swift 复制代码
class AggregatingSubject<Output, Failure: Error>: Subject {
    private let subject = PassthroughSubject<Output, Failure>()
    private let aggregation: ([Output]) -> Output
    private let maxBufferSize: Int
    private var buffer: [Output] = []

    init(maxBufferSize: Int, aggregation: @escaping ([Output]) -> Output) {
        self.maxBufferSize = maxBufferSize
        self.aggregation = aggregation
    }

    func send(_ value: Output) {
        buffer.append(value)
        if buffer.count >= maxBufferSize {
            flush()
        }
    }

    func send(completion: Subscribers.Completion<Failure>) {
        flush()  // 确保最后残留的值也被聚合
        subject.send(completion: completion)
    }

    private func flush() {
        guard !buffer.isEmpty else { return }
        let result = aggregation(buffer)
        buffer.removeAll()
        subject.send(result)
    }

    func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input {
        subject.receive(subscriber: subscriber)
    }
}

使用:

swift 复制代码
let sumSubject = AggregatingSubject<Int, Never>(maxBufferSize: 3, aggregation: { $0.reduce(0, +) })

sumSubject.sink { print("聚合和: \($0)") }
         .store(in: &cancellables)

sumSubject.send(1)  // 缓冲
sumSubject.send(2)  // 缓冲
sumSubject.send(3)  // 输出: 聚合和: 6 (1+2+3)
sumSubject.send(4)  // 缓冲
sumSubject.send(5)  // 缓冲(未满,暂不输出)
// 手动完成或超过阈值才会输出下一批

9. 最佳实践

  • 简单优先:如果内置 Subject 或常规操作符就能满足需求,不要自定义 Subject。自定义 Subject 适用于需要强制封装或高频复用的业务规则。
  • 线程安全 :自定义 Subject 内部通常依赖一个串行的 PassthroughSubject,它本身是线程安全的。但如果你在 send 中添加了额外共享可变状态(如缓冲区、计时器),务必保证这些状态的访问在同一队列或使用锁保护。
  • 内存管理 :Subject 是引用类型,需注意避免循环引用。当 Subject 持有外部闭包时,使用 [weak self] 打破循环。
  • 完成的传递 :当外部调用 send(completion:) 时,通常应刷新内部缓冲区(如果有),再向下游传递完成事件。
  • 定制与测试:自定义 Subject 应该能像普通 Subject 一样在单元测试中通过发送值来验证行为。这使得业务规则的单元测试变得极为简单。
  • 避免重新发明轮子 :大部分时间可以通过组合操作符 (debounce, throttle, removeDuplicates, buffer 等) 实现类似功能。只有当"主动发送"的入口也需要这些限制时,才必须用自定义 Subject。

10. 总结

Combine 的 Subject 协议为开发者打开了高度定制的事件入口。通过将一个内建 Subject 包裹在自定义类中,我们可以赋予 send 方法验证、限流、防抖、去重甚至聚合能力,从而在数据源头就应用业务约束。这不仅让调用方免于编写重复的过滤逻辑,更通过封装提升了代码的语义和可维护性。

掌握自定义 Subject 的编写,意味着你可以将任何复杂的发布规则凝固成可复用的组件,让 Combine 管道的前端和后端一样优雅、可控。

相关推荐
90后的晨仔2 小时前
Combine 架构模式:构建响应式应用的蓝图
ios
90后的晨仔2 小时前
Combine 高级实践:多线程调度、调试与测试
ios
人月神话Lee4 小时前
【图像处理】饱和度——颜色的浓淡与灰度化
ios·ai编程·图像识别
王飞飞不会飞5 小时前
iOS卡顿查找和定位-ProFile
ios·性能优化
敲代码的鱼5 小时前
NFC读卡能力 支持安卓/iOS/鸿蒙 UTS插件
android·ios·uni-app
sweet丶9 小时前
iOS应用启动过程深度分析与优化实践
ios
largecode12 小时前
企业名称能在来电显示吗?号码显示公司名服务打通多终端展示
android·xml·ios·iphone·xcode·webview·phonegap
MonkeyKing12 小时前
iOS Core Animation 渲染架构详解:Render Server 与 Commit Transaction
ios
MonkeyKing12 小时前
iOS Auto Layout 原理详解:Cassowary 算法、性能问题与优化
ios