今まではRxSwiftを用いて非同期に値の変化を監視して処理を行う実装をしてきましたが、iOS13からCombine Frameworkが使えるようになり同等の処理が行えるようになったので現在勉強をしています。
勉強していく中で、RxSwift
のRelay、bind関数を作ってみたので今回紹介します。
検証環境
- Xcode 11.2.1
- Swift 5.1
- RxSwift 5.0.1
背景
元々RxSwift
のbind関数
を利用していたコードをCombine
で書き換えた所、RxSwift
のbind関数
の方が読みやすい(その書き方に慣れているせいか)と感じたので、【Combine
でも同じ形式で呼び出せるようできないか?】というのがきっかけとなります。
ではまずRxSwift
で実装した例を元に、Combine
を使った書き方に書き直してみましょう。
具体例
姓
と名
を入力する2つの入力フォームがあり、登録ボタンをタップすると入力した文字列を出力するコードを書いていきます。
※bind関数を使った例を書きたいので、登録ボタンタップ時にUITextField
インスタンスのtext
プロパティを参照すればいいじゃんというツッコミはお控えください。
RxSwiftで書いた場合
以下のように記載できます。
各入力フォームのテキストを表すControlProperty
の値がbind(to:)関数
で対応するRelay
にバインドされるという直感的なコードです。
import RxCocoa
import RxRelay
import RxSwift
import UIKit
struct NameRegisterInputData {
let firstNameTextRelay = BehaviorRelay<String>(value: "")
let lastNameTextRelay = BehaviorRelay<String>(value: "")
}
final class NameRegisterViewController: UIViewController {
@IBOutlet private var firstNameTextField: UITextField!
@IBOutlet private var lastNameTextField: UITextField!
private let input = NameRegisterInputData()
private let disposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
setupDataBinding()
}
private func setupDataBinding() {
let firstNameTextControlProperty = firstNameTextField.rx.textInput.text.orEmpty
let lastNameTextControlProperty = lastNameTextField.rx.textInput.text.orEmpty
firstNameTextControlProperty.bind(to: input.firstNameTextRelay).disposed(by: disposeBag)
lastNameTextControlProperty.bind(to: input.lastNameTextRelay).disposed(by: disposeBag)
}
@IBAction private func tapRegisterButton() {
print("Input Data: \(input.firstNameTextRelay.value) \(input.lastNameTextRelay.value)")
}
}
Combineで書いた場合
Combine
でbind処理を実現する場合にはassign(to:on:)関数
を利用します。
この関数では、更新するインスタンスを第二引数として指定し、そのインタンスの更新したいプロパティをKeyPath
として第一引数に指定します。
import Combine
import UIKit
class NameRegisterInputData {
var firstNameText = ""
var lastNameText = ""
}
final class NameRegisterViewController: UIViewController {
@IBOutlet private var firstNameTextField: UITextField!
@IBOutlet private var lastNameTextField: UITextField!
private let input = NameRegisterInputData()
private var cancellables: Set<AnyCancellable> = []
override func viewDidLoad() {
super.viewDidLoad()
setupDataBinding()
}
private func setupDataBinding() {
let firstNameTextPublisher = firstNameTextField.textPublisher.orEmpty
let lastNameTextPublisher = lastNameTextField.textPublisher.orEmpty
firstNameTextPublisher.assign(to: \.firstNameText, on: input).store(in: &cancellables)
lastNameTextPublisher.assign(to: \.lastNameText, on: input).store(in: &cancellables)
}
@IBAction private func tapRegisterButton() {
print("Input Data: \(input.firstNameText) \(input.lastNameText)")
}
}
// 以下は、UITextFieldの入力文字列を監視するためのExtension
extension UITextField {
var textPublisher: AnyPublisher<String?, Never> {
NotificationCenter.default
.publisher(for: UITextField.textDidChangeNotification, object: self)
.map { $0.object as! UITextField }
.map { $0.text }
.prepend(text)
.eraseToAnyPublisher()
}
}
extension AnyPublisher where Output == String?, Failure == Never {
var orEmpty: AnyPublisher<String, Never> {
map { $0 ?? "" }.eraseToAnyPublisher()
}
}
assign(to:on:)関数
を用いた記述がまどろっこしく感じたので、Combine
でもRxSwift
のようにbind(to:)関数
のように呼び出せるようにしていきます。
コードリーディング
Combine版のRelay,bind関数
を実装する前に、RxSwift
のコードを読んでどんな機能を提供しているのか改めて確認してみましょう。
RxSwiftのbind関数
bind関数は、ObservableType
の拡張関数として定義され、引数としてPublishRelay
またはBehaviorRelay
型のインスタンスを指定し、Disposable
型のインスタンス変数を返却していることが分かります。
RxSwift
のObservableTypeはプッシュ型のシーケンスを表します。これはCombine
のPublisherが該当します。
同様にDisposableはdispose関数
が定義され、この関数を呼び出す事で現在購読中の処理を終了する事ができます。こちらは、Combine
のCancellableが似たような立ち位置で存在します。
RxSwiftのRelay
しかし、PublishRelay
またはBehaviorRelay
に対応する定義はCombine
に現在ありません。
なので、Relay
についてもRxSwift
のコードを読んでみましょう。
PublishRelay、BehaviorRelayともにSubject
をラップしている事が分かります。
Subject
のonEvent関数
を内部的に呼び出すaccept関数
の実装もしています。
また、BehaviorRelay
に関しては、BehaviorSubject
と同様に購読時に現在の値を受け取れる性質があるため、イニシャライザとして初期値を設定できるようになっています。
現在値もComputed Propertyとして定義され、取得できるようです。
Combine
ではPublishSubject
、BehaviorSubject
に対応する型として、PassthroughSubject、CurrentValueSubjectという型が定義されています。
これらの情報を用いて実装できそうですね!
次の章では実際にコードリーディングした内容を元に実装をしていきます。
実装
Relay
まずRelay
プロトコルを作ります。
Publisher
プロトコルを継承しますが、エラーイベントは発生させないのでFailure
にはNever
という条件を設定します。
RxSwift
同様にaccept
関数を定義しました。
import Combine
@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
public protocol Relay: Publisher where Failure == Never {
func accept(_ event: Self.Output)
}
続いてPassthroughRelay
の実装です。
※CombineのSubject名に合わせてPublishRelay
ではなくPassthroughRelay
としました。
先程のRelay
プロトコルに適合させ、RxSwift
同様にPassthroughSubject
のインスタンスを内部で保持します。
あとはaccept関数
を実装しつつ、Publisher
プロトコルに適合させます。
import Combine
@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
public final class PassthroughRelay<Output>: Relay {
private let subject = PassthroughSubject<Output, Never>()
public init() {}
public func accept(_ event: Output) {
subject.send(event)
}
public func receive<S>(subscriber: S) where S: Subscriber, S.Failure == Never, Output == S.Input {
subject.receive(subscriber: subscriber)
}
}
続いてCurrentValueRelay
の実装です。
※CombineのSubject名に合わせてBehaviorRelay
ではなくCurrentValueRelay
としました。
先程のRelay
プロトコルに適合させ、RxSwift
同様にCurrentValueSubject
のインスタンスを内部で保持します。
PassthroughSubject
同様の実装をしつつ、初期値が設定できるイニシャライザの定義と、現在値を取得することができるComputed Propertyの定義も行いました。
import Combine
@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
public final class CurrentValueRelay<Output>: Relay {
private let subject: CurrentValueSubject<Output, Never>
public var value: Output {
subject.value
}
public init(_ value: Output) {
subject = CurrentValueSubject(value)
}
public func accept(_ event: Output) {
subject.send(event)
}
public func receive<S>(subscriber: S) where S: Subscriber, S.Failure == Never, Output == S.Input {
subject.receive(subscriber: subscriber)
}
}
bind関数
作成したPassthroughSubject
とCurrentValueRelay
を利用してbind関数
を実装していきます。
RxSwift
のObservableType
に対応するものがCombine
のPublisher
であるため、Publisher
のextension
として実装していきます。
また、エラーイベントをbind関数
では受け取らないようにするために、Failure
にはNever
という条件を設定しました。
実装の実体についてはRxSwift
の実装を参考に実装しています。
import Combine
@available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
public extension Publisher where Self.Failure == Never {
func bind(to relays: CurrentValueRelay<Output>...) -> AnyCancellable {
bind(to: relays)
}
func bind(to relays: CurrentValueRelay<Output?>...) -> AnyCancellable {
map { $0 as Output? }.bind(to: relays)
}
private func bind(to relays: [CurrentValueRelay<Output>]) -> AnyCancellable {
sink { value in
relays.forEach { $0.accept(value) }
}
}
func bind(to relays: PassthroughRelay<Output>...) -> AnyCancellable {
bind(to: relays)
}
func bind(to relays: PassthroughRelay<Output?>...) -> AnyCancellable {
map { $0 as Output? }.bind(to: relays)
}
private func bind(to relays: [PassthroughRelay<Output>]) -> AnyCancellable {
sink { value in
relays.forEach { $0.accept(value) }
}
}
}
使ってみる
開発したCombine版Relay
を使って先程のassign(to:on:)関数
をbind(to:)
関数に変更してみました。
すると、RxSwiftで書いた場合のようにより直感的なコードで記述できるようになりました。
import Combine
import UIKit
struct NameRegisterInputData {
let firstNameTextRelay = CurrentValueRelay<String>("")
let lastNameTextRelay = CurrentValueRelay<String>("")
}
final class NameRegisterViewController: UIViewController {
@IBOutlet private var firstNameTextField: UITextField!
@IBOutlet private var lastNameTextField: UITextField!
private let input = NameRegisterInputData()
private var cancellables: Set<AnyCancellable> = []
override func viewDidLoad() {
super.viewDidLoad()
setupDataBinding()
}
private func setupDataBinding() {
let firstNameTextPublisher = firstNameTextField.textPublisher.orEmpty
let lastNameTextPublisher = lastNameTextField.textPublisher.orEmpty
firstNameTextPublisher.bind(to: input.firstNameTextRelay).store(in: &cancellables)
lastNameTextPublisher.bind(to: input.lastNameTextRelay).store(in: &cancellables)
}
@IBAction private func tapRegisterButton() {
print("Input Data: \(input.firstNameTextRelay.value) \(input.lastNameTextRelay.value)")
}
}
// 以下は、UITextFieldの入力文字列を監視するためのExtension
extension UITextField {
var textPublisher: AnyPublisher<String?, Never> {
NotificationCenter.default
.publisher(for: UITextField.textDidChangeNotification, object: self)
.map { $0.object as! UITextField }
.map { $0.text }
.prepend(text)
.eraseToAnyPublisher()
}
}
extension AnyPublisher where Output == String?, Failure == Never {
var orEmpty: AnyPublisher<String, Never> {
map { $0 ?? "" }.eraseToAnyPublisher()
}
}
まとめ
今回bind関数
をCombine
で実装するために、RxSwift
のコードリーディングを行いました。
RxSwift
でどのようにbind関数・Relay
の実装がされているのかを知る機会となり、非常に面白かったです。
また、現状はNotificationCenter
でCombine
の機能が利用できるもののRxCocoa
ほどUIイベントのハンドリングは行えないので、RxCocoa
のコードリーディングをしながらUIイベントのハンドリングもより簡単に行えるように実装をできればと考えています。
明日は@sadashiさんの「Android View Bind Library 比較」です。お楽しみに!