40
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

食べログAdvent Calendar 2019

Day 18

RxSwiftのbind関数をCombineで実装してみた

Last updated at Posted at 2019-12-17

今まではRxSwiftを用いて非同期に値の変化を監視して処理を行う実装をしてきましたが、iOS13からCombine Frameworkが使えるようになり同等の処理が行えるようになったので現在勉強をしています。

勉強していく中で、RxSwiftRelaybind関数を作ってみたので今回紹介します。

検証環境

  • Xcode 11.2.1
  • Swift 5.1
  • RxSwift 5.0.1

背景

元々RxSwiftbind関数を利用していたコードをCombineで書き換えた所、RxSwiftbind関数の方が読みやすい(その書き方に慣れているせいか)と感じたので、【Combineでも同じ形式で呼び出せるようできないか?】というのがきっかけとなります。

ではまずRxSwiftで実装した例を元に、Combineを使った書き方に書き直してみましょう。

具体例

を入力する2つの入力フォームがあり、登録ボタンをタップすると入力した文字列を出力するコードを書いていきます。
※bind関数を使った例を書きたいので、登録ボタンタップ時にUITextFieldインスタンスのtextプロパティを参照すればいいじゃんというツッコミはお控えください。:bow:

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型のインスタンス変数を返却していることが分かります。

RxSwiftObservableTypeはプッシュ型のシーケンスを表します。これはCombinePublisherが該当します。
同様にDisposabledispose関数が定義され、この関数を呼び出す事で現在購読中の処理を終了する事ができます。こちらは、CombineCancellableが似たような立ち位置で存在します。

RxSwiftのRelay

しかし、PublishRelayまたはBehaviorRelayに対応する定義はCombineに現在ありません。
なので、RelayについてもRxSwiftのコードを読んでみましょう。

PublishRelayBehaviorRelayともにSubjectをラップしている事が分かります。
SubjectonEvent関数を内部的に呼び出すaccept関数の実装もしています。

また、BehaviorRelayに関しては、BehaviorSubjectと同様に購読時に現在の値を受け取れる性質があるため、イニシャライザとして初期値を設定できるようになっています。
現在値もComputed Propertyとして定義され、取得できるようです。

CombineではPublishSubjectBehaviorSubjectに対応する型として、PassthroughSubjectCurrentValueSubjectという型が定義されています。

これらの情報を用いて実装できそうですね!:grinning:
次の章では実際にコードリーディングした内容を元に実装をしていきます。

実装

Relay

まずRelayプロトコルを作ります。
Publisherプロトコルを継承しますが、エラーイベントは発生させないのでFailureにはNeverという条件を設定します。
RxSwift同様にaccept関数を定義しました。

Relay.swift
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プロトコルに適合させます。

PassthroughRelay.swift
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の定義も行いました。

CurrentValueRelay.swift
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関数

作成したPassthroughSubjectCurrentValueRelayを利用してbind関数を実装していきます。
RxSwiftObservableTypeに対応するものがCombinePublisherであるため、Publisherextensionとして実装していきます。
また、エラーイベントをbind関数では受け取らないようにするために、FailureにはNeverという条件を設定しました。

実装の実体についてはRxSwiftの実装を参考に実装しています。

Publisher+Bind.swift
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の実装がされているのかを知る機会となり、非常に面白かったです。

また、現状はNotificationCenterCombineの機能が利用できるもののRxCocoaほどUIイベントのハンドリングは行えないので、RxCocoaのコードリーディングをしながらUIイベントのハンドリングもより簡単に行えるように実装をできればと考えています。

明日は@sadashiさんの「Android View Bind Library 比較」です。お楽しみに!

40
11
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
40
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?