概要
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. asyncBinding
にbind(_: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
を利用する場合は、BindingTarget
をactor
毎に分けると良さそうだ。
@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)
}
}