0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AsyncSequenceでReactiveSwiftライクなバインディングの仕組みを作ってみた

Last updated at Posted at 2025-03-21

概要

ReactiveSwiftではViewなどへ値をバインドする時、<~演算子を使うことで以下のような雰囲気で簡単にバインドできる。

disposable += label.reactive.text <~ viewModel.text

一方、Combineを使った場合は以下のようになり、記述量が増える。

viewModel.text.receive(on: DispatchQueue.main).sink { [weak self] in
    self?.label.text = $0
}
.store(in: &self.cancellables)

また、AsyncSequenceを使う場合も一々for文を書く必要があり面倒である。

Task { @MainActor in
    for try await text in viewModel.text {
        self.label.text = text
    }
}

そこで今回AsyncSequenceを使ってReactiveSwiftのようなバインドができる仕組みを作成してみた。

最終的には以下のようにバインドできるものを目指す。

disposable += label.reactive.bind(\.text) <~ viewModel.text

方法

ReactiveSwift本家ではKeyPathを使わずに全てのプロパティを自前実装しているが、個人で実装するには困難なため、今回はKeyPathを利用する方法を採用した。

1. NSObjectにasyncBindingプロパティを作成

label.reactiveのreactive部分を実現するため、まずAsyncBindingExtensionsProviderというプロトコルを定義する。

public protocol AsyncBindingExtensionsProvider {}

extension AsyncBindingExtensionsProvider {
    public var asyncBinding: AsyncBinding<Self> {
        AsyncBinding(self)
    }
}

次に、NSObjectをextensionでAsyncBindingExtensionsProviderに準拠させる。

extension NSObject: AsyncBindingExtensionsProvider {}

この仕組みについては「Targeted Extensions」で検索すれば良い解説が多数存在するため、ここでは割愛する。

2. asyncBindingbind(_:ReferenceWritableKeyPath<Base, Value>)を作成

"1."でasyncBindingのプロパティで返している値のAsyncBindingで、ReferenceWritableKeyPathを使ってバインド先を指定できるようにしている。

public struct AsyncBinding<Base> {
    public let base: Base

    public init(_ base: Base) {
        self.base = base
    }

    @MainActor
    public func bind<Value>(_ keyPath: ReferenceWritableKeyPath<Base, Value>)
        -> AsyncBindingTarget<Base, Value>
    {
        AsyncBindingTarget(base: self.base, keyPath: keyPath)
    }
}

3. <~演算子の作成

Swiftには<~という演算子はないため、新たに演算子を宣言する。
==+のような左右から値が挟むような演算子を作る場合はinfix operator 〇〇と書くことで宣言できる。

infix operator <~

4. BindingTargetを作成

<~演算子を用いて実際にバインドを行う仕組みを実装する。
今回はViewへのバインドを前提としているため、MainActorを指定している。
別のactorを利用する場合は、BindingTargetactor毎に分けると良さそうだ。

@MainActor
public struct AsyncBindingTarget<Base: Sendable, Value>: Sendable
where Base: AnyObject {
    public weak var base: Base?
    public let keyPath: ReferenceWritableKeyPath<Base, Value>

    public init(base: Base, keyPath: ReferenceWritableKeyPath<Base, Value>) {
        self.base = base
        self.keyPath = keyPath
    }

    public static func <~ <RHS>(lhs: AsyncBindingTarget<Base, Value>, rhs: RHS)
        -> Disposable where RHS: AsyncSequence, RHS.Element == Value
    {
        Task { @MainActor in
            for try await value in rhs {
                lhs.base?[keyPath: lhs.keyPath] = value
            }
        }
    }
}

5. disposableの作成

ReactiveSwiftではCombineのAnyCancellableのようなものとしてDisposableが用意されている。
Combineと異なりReactiveSwiftのDisposableは使わなくても良い設計になっているが、今回はTaskを破棄する必要がある関係上必要となってくる。

CompositeDisposableはCombineのSet<AnyCancellable>にあたるものだが、+=を使った値の追加が便利なため実装しておく。(実際にはSetのextensionで定義しても良い)

public protocol Disposable {
    func dispose()
}

extension Task: Disposable {
    public func dispose() {
        cancel()
    }
}

public final class CompositeDisposable {
    private var disposables: [Disposable] = []

    deinit {
        dispose()
    }

    func add(_ disposable: Disposable) {
        disposables.append(disposable)
    }

    func dispose() {
        disposables.forEach { $0.dispose() }
        disposables.removeAll()
    }

    static func += (lhs: CompositeDisposable, rhs: Disposable) {
        lhs.add(rhs)
    }
}

実際の利用例

利用例として、MVVMパターンにおけるViewとViewModelのバインド部分を作成した。
雰囲気が掴めれば良いと思うのでMVVMとしてはかなり適当である。
実際に使う時、直接AsyncSequenceを使うのは煩雑なため、ViewModel側ではCombineを用い、バインド時にvaluesを利用してAsyncSequenceに変換している。

class ViewController: UIViewController {
    @IBOutlet private weak var label: UILabel!

    private let viewModel = ViewModel()

    private let disposables = CompositeDisposable()

    override func viewDidLoad() {
        super.viewDidLoad()

        disposables += label.asyncBinding.bind(\.text) <~ viewModel.countPublisher.values
    }

    @IBAction private func incrementDidTap(_ sender: Any) {
        viewModel.increment()
    }

    @IBAction private func decrementDidTap(_ sender: Any) {
        viewModel.decrement()
    }
}

class ViewModel {
    private let count = CurrentValueSubject<Int, Never>(0)

    func increment() {
        count.value += 1
    }

    func decrement() {
        count.value -= 1
    }

    var countPublisher: AnyPublisher<String?, Never> {
        count.map { $0.description }.eraseToAnyPublisher()
    }
}

完成したコード

以下が完成したコード全体である。

public protocol AsyncBindingExtensionsProvider {}

extension AsyncBindingExtensionsProvider {
    public var asyncBinding: AsyncBinding<Self> {
        AsyncBinding(self)
    }
}

extension NSObject: AsyncBindingExtensionsProvider {}

public struct AsyncBinding<Base> {
    public let base: Base

    public init(_ base: Base) {
        self.base = base
    }

    @MainActor
    public func bind<Value>(_ keyPath: ReferenceWritableKeyPath<Base, Value>)
        -> AsyncBindingTarget<Base, Value>
    {
        AsyncBindingTarget(base: self.base, keyPath: keyPath)
    }
}

infix operator <~

@MainActor
public struct AsyncBindingTarget<Base: Sendable, Value>: Sendable
where Base: AnyObject {
    public weak var base: Base?
    public let keyPath: ReferenceWritableKeyPath<Base, Value>

    public init(base: Base, keyPath: ReferenceWritableKeyPath<Base, Value>) {
        self.base = base
        self.keyPath = keyPath
    }

    public static func <~ <RHS>(lhs: AsyncBindingTarget<Base, Value>, rhs: RHS)
        -> Disposable where RHS: AsyncSequence, RHS.Element == Value
    {
        Task { @MainActor in
            for try await value in rhs {
                lhs.base?[keyPath: lhs.keyPath] = value
            }
        }
    }
}

public protocol Disposable {
    func dispose()
}

extension Task: Disposable {
    public func dispose() {
        cancel()
    }
}

public final class CompositeDisposable {
    private var disposables: [Disposable] = []

    deinit {
        dispose()
    }

    func add(_ disposable: Disposable) {
        disposables.append(disposable)
    }

    func dispose() {
        disposables.forEach { $0.dispose() }
        disposables.removeAll()
    }

    static func += (lhs: CompositeDisposable, rhs: Disposable) {
        lhs.add(rhs)
    }
}
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?