のほほん停留所

びぼうろく

Combine × propertyWrapperでイベント発行・購読するオブジェクトの定義をまとめる

Swift 5.2

Combineでは継続的にイベント発行するオブジェクト PassthroughSubjectを活用することが多いが、このオブジェクトを公開するとイベントの購読のみならず外部からのイベント発行も可能になってしまう。これを回避するためにPassthroughSubjectをAnyPublisherに変換して、イベント購読のみ可能なオブジェクトを用意する必要がある。

import Combine

struct OrdinaryCombine {
 
    let subject: AnyPublisher<String, Never>
    private let _subject: PassthroughSubject<String, Never>

    init() {
        self.subject = _subject.eraseToAnyPublisher()
    }
  
    func print() {
        _subject.accept("Hello, world")
    }
}

var cancellables: [AnyCancellable] = []

let combine = OrdinaryCombine()

combine.subject
    .sink { value in
        print(value) // Hello, world
    }
    .store(in: &cancellables)

combine.print()

PassthroughSubject x AnyPublisherこの方法だと毎回2つのプロパティを定義しなくてはならず、また関連がコンストラクタでのみ記載されており、命名を揃えることで関連を明確にして可読性を向上するなどの工夫が求められレビューコストが上がってしまう。

RelayWrapper (Combine × propertyWrapper)

import Combine

public typealias PublishWrapper<T> = RelayWrapper<AnyPublisher<T, Never>, T>

@propertyWrapper
public struct RelayWrapper<Wrapped, Element> {

    public let wrappedValue: Wrapped
    public let accept: (Element) -> Void

    init(wrapped: Wrapped, accept: @escaping (Element) -> Void) {
        self.wrappedValue = wrapped
        self.accept = accept
    }
}

public extension RelayWrapper where Wrapped == AnyPublisher<Element, Never> {
    init() {
        let relay = PassthroughSubject<Element, Never>()
        self.init(wrapped: relay.eraseToAnyPublisher(), accept: { relay.send($0) })
    }
}

RelayWrapper (Combine × propertyWrapper)

propertyWrapperを活用して定義したRelayWrapperを活用することで、内部的にPassthroughSubjectを保持して、外部にはAnyPublisherのみを公開することが可能になる。内部で保持しているPassthroughSubjectはRelayWrapper型のプロパティが宣言されているクラスのみで参照可能なので、外部からのイベント発行は出来ない。

import Combine

struct AdvancedCombine {

    @PublishWrapper()
    var subject: AnyPublisher<String, Never>

    func print() {
        _subject.accept("Hello, world")
    }
}

var cancellables: [AnyCancellable] = []

let combine = AdvancedCombine()

combine.subject
    .sink { value in
        print(value) // Hello, world
    }
    .store(in: &cancellables)

combine.print()

RelayWrapperの活用AdvancedCombine内では_subject: RelayWrapper<AnyPublisher<String, Never>, String>にアクセスしてイベント発行・購読が出来るが、AdvancedCombine外ではsubject: AnyPublisher<String, Never>のみしかアクセスできずイベント購読のみしか出来ない。また、イベント発行とイベント購読のオブジェクトの命名がずれずにレビューなどで可読性を担保する必要がなくなる。


(おまけ) RxRelay + RxSwiftへの応用

RelayWrapperをPublishRelay → Observable, BehaviorRelay → RxPropertyにも応用することが出来る。

import RxRelay
import RxSwift

public typealias PublishWrapper<T> = RelayWrapper<Observable<T>, T>
public typealias BehaviorWrapper<T> = RelayWrapper<Property<T>, T>

@propertyWrapper
public struct RelayWrapper<Wrapped, Element> {

    public let wrappedValue: Wrapped

    public let accept: (Element) -> Void

    init(wrapped: Wrapped, accept: @escaping (Element) -> Void) {
        self.wrappedValue = wrapped
        self.accept = accept
    }
}

public extension RelayWrapper where Wrapped == Observable<Element> {
    init() {
        let relay = PublishRelay<Element>()
        self.init(wrapped: relay.asObservable(), accept: { relay.accept($0) })
    }
}

public extension RelayWrapper where Wrapped == Property<Element> {
    init(value: Element) {
        let relay = BehaviorRelay(value: value)
        self.init(wrapped: Property(relay), accept: { relay.accept($0) })
    }
}