今までは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 比較」です。お楽しみに!