iOS
Swift
iOS13

【iOS】Combineフレームワークまとめ(2019/6/18更新)


  • 2019/6/14 Combine in URLSessionについて追記しました。

  • 2019/6/18 Xcode11 Beta2より@PublishedDataTaskPublisherが補完されるようになりました。
    ドキュメントもできていましたのでリンクも追記しています。

※まだベータ版ですので今後変更点などを追って更新していきたいと思います。


Apple公式の非同期処理を扱うフレームワークの登場

iOS13で新しく追加されたフレームワークの一つに

Combineフレームワークがあります。

これは非同期の処理などを扱うためのフレームワークで

今まではサードライブラリで実装しているものが多くありましたが

今回Appleの正式なものとして登場し注目を浴びています。

私もRxSwiftを業務で使用しているので

何となくそれっぽく使えそうだなと思っていましたが

ちょっと見てみると似ているようで違う部分もあり

今回は今後学んでいくための最初のきっかけとして

WWDC2019の動画を中心にCombineフレームワークについてどういうものか調べてみました。

WWDC2019動画 Introducing Combine

https://developer.apple.com/videos/play/wwdc2019/722

WWDC2019動画 Combine in Practice

https://developer.apple.com/videos/play/wwdc2019/721

まずはIntroducing Combineから見ていきます。


Combineフレームワークとは?

発表の中では

時間を超えて値を処理するための統一的で宣言的なAPI

と言っています。

スクリーンショット 2019-06-08 12.35.10.png

ドキュメントでは

イベントを処理するオペレーターを組み合わせて

非同期のイベント処理を扱いやすくしたもの

とあります。

https://developer.apple.com/documentation/combine


4つの特徴

Combineフレームワークは

Swiftを活かした4つの特徴があります。


ジェネリック

ボイラープレートを減らすことができると同時に

一度処理を書いてしまえばあらゆる型に適応することができます。


型安全

コンパイラがエラーのチェックをしてくれるため

ランタイムエラーのリスクを軽減できます。


Compositionファースト

コア概念が小さくて非常にシンプルで理解しやすく

それを組み合わせてより大きな処理を扱っていきます。


リクエスト駆動

必要な時にのみ機能するようになっているため

パフォーマンスや効率的なメモリ管理の機会を提供することができます。


主要な3つコンセプト

Combineには3つの主要なコンセプトがあります。


  • Publishers

  • Subscribers

  • Operators


Publishers

値やエラーが時系列にどのように生成されるのかを定義します。

値型で定義され

SubscriberPublisherに登録することができます。

発表のスライドでは

Publisherプロトコルは下記のように定義されています。

スクリーンショット 2019-06-08 12.58.09.png


receive(subscriber:)とsubscribe(_:)

ここがちょっとわかっていないのですが

2019/6/8の時点ですと

ドキュメント上やコードの定義では下記のメソッドが

requiredになっています。

receive(subscriber:)

https://developer.apple.com/documentation/combine/publisher/3229093-receive

しかし

ドキュメントなどを見てみると

This function is called 

to attach the specified Subscriber
to this Publisher by subscribe(_:)

と記載があり

実際にAPIとして使用するものとしては

subscribe(_:)

https://developer.apple.com/documentation/combine/publisher/3204756-subscribe

になります。

Outputが出力される値で

Failureがエラー時に出力される型で

OutputSubscriberのInputの型と

FailureSubscriberFailureと一致する必要があります。

https://developer.apple.com/documentation/combine/publisher

もしかしたら今後変わるのかもしれないですね。

例として

NotificationCenterextensionが紹介されており

エラーが発生しないためFailureにはNeverが指定されています。


extension NotificationCenter {
struct Publisher: Combine.Publisher {
typealias Output = Notification
typealias Failure = Never
init(center: NotificationCenter, name: Notification.Name, object: Any? = nil)
}
}

※ 上記の例はコンパイルが通らないのですが、

このメソッド(struct)が結構他のセッションでも使われているのにも関わらず

実装が見つからないので

勝手に脳内補完してみると下記のようになりました。

間違っていたらご指摘ください🙏🏻

extension NotificationCenter {

struct Publisher: Combine.Publisher {
typealias Output = Notification
typealias Failure = Never

let center: NotificationCenter
let name: Notification.Name
let object: Any?

init(center: NotificationCenter, name: Notification.Name, object: Any? = nil) {
self.center = center
self.name = name
self.object = object
}

func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input {
subscribe(subscriber)
}
}

func publisher(for name: Notification.Name, object: Any? = nil) -> Publisher {
return Publisher(center: self, name: name, object: object)
}
}


Subscribers

Publisherから出力された値や

出力の最後の完了イベントを受け取ります。

Subscriberは参照型です。

Subscriberプロトコルは下記のように定義されています。

スクリーンショット 2019-06-08 13.04.26.png

https://developer.apple.com/documentation/combine/subscriber

3つのメソッドがは下記のようになっています。


receive(subscription:)

Subscriber

Publisherへの登録が成功して値をリクエストすることができる

ことを伝えます。

receive(subscription:)

https://developer.apple.com/documentation/combine/subscriber/3213655-receive


receive(_:)

Publisherから出力される実際の値を

Subscriberに伝えます。

receive(_:)

https://developer.apple.com/documentation/combine/subscriber/3213653-receive


receive(completion:)

Publisherの値の出力が完了(これ以上値を出力しない)ということを

Subscriberに伝えます。

これは正常な場合もエラーが起きた場合もあります。

receive(completion:)

https://developer.apple.com/documentation/combine/subscriber/3213654-receive

例として

SubscribersextensionAssignクラスが紹介されていました。

これはRootの中のInputPublisherから受け取った値を適用します。


extension Subscribers {
class Assign<Root, Input>: Subscriber, Cancellable {
typealias Failure = Never
init(object: Root, keyPath: ReferenceWritableKeyPath<Root, Input>)
}
}

※ NotificationCenterの時と同様にこちらも

一部脳内補完をしました。


extension Subscriber {
func assign<Value>(to keyPath: ReferenceWritableKeyPath<Self, Value>) -> Subscribers.Assign<Self, Value> {
return Subscribers.Assign<Self, Value>.init(object: self, keyPath: keyPath)
}
}


よくある処理のパターン

スクリーンショット 2019-06-08 13.29.46.png

図にある通りですが



  1. SubscriberPublisherに紐づく


  2. SubscriberPublisherから登録完了のイベントを受け取る


  3. SubscriberPublisherへリクエストを送りイベントを受け取りが開始される


  4. SubscriberPublisherから値を受け取る(繰り返し)


  5. SubscriberPublisherから出力終了のイベントを受け取る


Operators

上記で示した2つの例を使ってOperatorsの説明をしています。

スクリーンショット 2019-06-08 13.53.11.png

Publisher側ではNotificationを出力しているにも関わらず

Subscriber側ではIntが来ることを想定しているため

型が合わずにコンパイルエラーになります。

※そもそもコンパイルエラーというのは気にせずにいきましょう。

スクリーンショット 2019-06-08 13.51.19.png

ここの隙間を埋めるのがOperatorsです。

※ Operatorsは特別な名前ではなく

複数のオペレーターを表す単語として使用されています。

OperatorsはPublisherに適用され

出力された値を変化させる振る舞いが定義されています。

上流のPublisherに登録し

出力された値を受け取って変換し

結果を下流のSubscriberに結果を送ります。

Operatorsは値型です。

OperatorsはPublishersというenumの中でstructで定義されています。

https://developer.apple.com/documentation/combine/publishers

Combineフレームワークの特徴でもある

Compositionファーストを実現するためにも

Operatorsを活用した組み合わせを行っていきます。

スクリーンショット 2019-06-08 14.11.25.png

さらにOperatorsは

Swiftのよく利用されるメソッドにシンタックスに合わせた

インスタンスメソッドも定義されております。

https://developer.apple.com/documentation/combine/publisher

図にすると下記の様な関係で

Intのような一つの値を扱う型のメソッドは

CombineのFutureという型(あとで出てきます)に同じようなメソッドがあり

Arrayのような複数の値を扱う型のメソッドが

CombineのPublisher型にも存在するということだそうです。

スクリーンショット 2019-06-08 14.42.53.png


非同期処理のOperators

次に非同期処理時にPublisherを組み合わせる

OperatorsとしてZipCombineLatestが紹介されていました。


Zip

複数のPublisherの結果を待ってタプルとして一つにまとめます。

すべてのPublisherから出力結果を受け取る必要があります。

スクリーンショット 2019-06-08 14.19.25.png

Zip

https://developer.apple.com/documentation/combine/publishers/zip


CombineLatest

複数のPublisherの出力を一つの値にします。

どれかの新しい値を受け取ったら新しい結果を出力します。

すべてのPublisherから出力結果を受け取る必要があります。

最後の値を保持します。

CombineLatest

https://developer.apple.com/documentation/combine/publishers/combinelatest

次にCombine in Practiceのセッションを見ていきます。


より詳細を見る

こちらのセッションでは

具体例を交えてCombineフレームワークの使い方をより詳細に見ています。

例は通知で受け取ったMagicTrickというクラスの値から

nameというStringのプロパティを取り出すというような話です。

受け取る値はJSON形式で

JSONDecoderdecodeできるものとします。

内容は特に関係なく

出力される型に注目していきます。

※ コンパイルは通りません


Publisher

最初の通知で受け取るOutputNotificationです。

失敗はないのでFailureNeverです。


let trickNamePublisher = NotificationCenter.default.publisher(for: .newTrickDownloaded)

// Notification Output
// Never Failure

次にmapOutputDataに変換します。

FailureNeverのままです。


let trickNamePublisher = NotificationCenter.default.publisher(for: .newTrickDownloaded)
.map { notification in
return notification.userInfo?["data"] as! Data
}
// Data Output
// Never Failure

次にOutputMagicTrickクラスに変換します。

この際にdecodeでエラーが発生する可能性があるため

FailureErrorになります。

tryMapは引数に渡される関数が

エラーになる可能性のある処理の場合に用いられます。

https://developer.apple.com/documentation/combine/publisher/3204772-trymap

let trickNamePublisher = NotificationCenter.default.publisher(for: .newTrickDownloaded)

.map { notification in
return notification.userInfo?["data"] as! Data
}
.tryMap { data in
let decoder = JSONDecoder()
try decoder.decode(MagicTrick.self, from: data)
}
// MagicTrick
// Error

ちなみにdecodePublisherextensionとして定義されているため

下記のようにも書けます。

https://developer.apple.com/documentation/combine/publisher/3204703-decode


let trickNamePublisher = NotificationCenter.default.publisher(for: .newTrickDownloaded)
.map { notification in
return notification.userInfo?["data"] as! Data
}
.decode(MagicTrick.self, JSONDecoder())
// MagicTrick
// Never

ここでエラーが発生する可能性があり

エラー処理を扱うOperatorsが紹介されています。

スクリーンショット 2019-06-08 15.32.26.png

assertNoFailure()の場合はエラーの際にFatal errorが発生します。

https://developer.apple.com/documentation/combine/publisher/3204686-assertnofailure

スクリーンショット 2019-06-08 15.36.17.png

catchの場合は自分でエラーの処理方法を決められます。

今回のケースですとJustで新たにPublisherを返すようにしています。

catch

https://developer.apple.com/documentation/combine/publisher/3204690-catch

Just

https://developer.apple.com/documentation/combine/publishers/just

スクリーンショット 2019-06-08 15.37.35.png


let trickNamePublisher = NotificationCenter.default.publisher(for: .newTrickDownloaded)
.map { notification in
return notification.userInfo?["data"] as! Data
}
.decode(MagicTrick.self, JSONDecoder()
.catch {
return Just(MagicTrick.placeholder)
}

// MagicTrick
// Never

これまでの流れが下記のようになっています。

スクリーンショット 2019-06-08 15.44.06.png

ここで一つ問題が出てきました。

エラーが発生した場合にPublisherは出力を止めてしまいます。

今回のケースでは

エラーが発生しても出力を続けて欲しいとします。

その場合はflatMapを用います。

flatMap

https://developer.apple.com/documentation/combine/publisher/3204712-flatmap

FlatMap

https://developer.apple.com/documentation/combine/publishers/flatmap

flatMapは受け取った全ての値を

既存のもしくは新しい一つのPublisherに変換します。

flatMapを使うことでエラーが発生してとしても

新しいPublisherを生成して出力を継続することが可能になります。

dataJustで包み

新しいPublisherを生成しています。


let trickNamePublisher = NotificationCenter.default.publisher(for: .newTrickDownloaded)
.map { notification in
return notification.userInfo?["data"] as! Data
}
.flatMap { data in
return Just(data)
.decode(MagicTrick.self, JSONDecoder()
.catch {
return Just(MagicTrick.placeholder)
}
}

// MagicTrick
// Never

こうすることでエラーが発生しないままになります。

ではここからnameを取り出します。


let trickNamePublisher = NotificationCenter.default.publisher(for: .newTrickDownloaded)
.map { notification in
return notification.userInfo?["data"] as! Data
}
.flatMap { data in
return Just(data)
.decode(MagicTrick.self, JSONDecoder()
.catch {
return Just(MagicTrick.placeholder)
}
}
.publisher(for: \.name)

// String
// Never

次にSubscriberが値を受け取るタイミングを指定します。

今回の場合はメインスレッドで受け取るようにしていますが

UIの更新などはメインスレッドで行う必要があります。


let trickNamePublisher = NotificationCenter.default.publisher(for: .newTrickDownloaded)
.map { notification in
return notification.userInfo?["data"] as! Data
}
.flatMap { data in
return Just(data)
.decode(MagicTrick.self, JSONDecoder()
.catch {
return Just(MagicTrick.placeholder)
}
}
.publisher(for: \.name)
.receive(on: RunLoop.main)
// String
// Never

receive(on:options:)

https://developer.apple.com/documentation/combine/publisher/3204743-receive

これはドキュメントの例の方がイメージしやすいかもしれません。

PublisherはバックグラウンドでJSONを処理し

SubscriberはメインスレッドでUIの更新を行います。


let jsonPublisher = MyJSONLoaderPublisher() // Some publisher.
let labelUpdater = MyLabelUpdateSubscriber() // Some subscriber that updates the UI.

jsonPublisher
.subscribe(on: backgroundQueue)
.receiveOn(on: RunLoop.main)
.subscribe(labelUpdater)

最終的なPublisherの処理の流れです。

スクリーンショット 2019-06-08 16.32.56.png


Subscriber

次に受け取り側を見ていきます。

前述しましたが

Subscriberは主に3つのものを受け取ります。


  • 正確に一度きりの登録完了の通知

  • 出力された値(0回以上)

  • 出力の終了またはエラーの通知


Subscriberの種類

下記のようなものが挙げられていました。


  • keyPath

  • Sinks

  • Subjects

  • SwiftUI

Publisherの例の続きを見ていきます。


let trickNamePublisher = ... // Publisher of <String, Never>

let canceller = trickNamePublisher.assign(to: \.someProperty, on: someObject)

canceller.cancel()

assignonで指定したクラスの中のtoPublisherからの値を設定します。

assign(to:on:)

https://developer.apple.com/documentation/combine/publisher/3235801-assign

注目点としては

戻り値のcancellerCancellableプロトコルに適合したインスタンスを返却します。

Cancellable

https://developer.apple.com/documentation/combine/cancellable

cancelメソッドを呼ぶことで

メモリから解放することができます。

このように手動でcancelメソッドを呼ぶこともできますが

忘れてしまってメモリが解放されないままでいるリスクもあります。

そこでAnyCancellableというクラスが用意されており

deinitが呼ばれた際に自動でcancelメソッドを呼びます。

AnyCancellable

https://developer.apple.com/documentation/combine/anycancellable

もう一つsinkというメソッドが紹介されています。

これは中の値に対して処理の途中で何かを行いたい場合に使用します。

sink

https://developer.apple.com/documentation/combine/publisher/3235806-sink


let canceller = trickNamePublisher.sink { trickName in
// Do Something with trickName
}


Subjects

次にSubjectsについて紹介されています。

https://developer.apple.com/documentation/combine/subject

SubjectsPublisherSubscriberの間のような存在で

複数のSubscriberに値を出力できます。

スクリーンショット 2019-06-08 17.08.33.png


send(_:)

Subscriberに値を出力します。

send(_:)

https://developer.apple.com/documentation/combine/subject/3213648-send


send(completion:)

Subscriberに出力終了の通知を送ります。

send(completion:)

https://developer.apple.com/documentation/combine/subject/3213649-send

2種類のSubjectがあります。


PassthroughSubject

値を保持せずに来たものをそのまま出力します。

PassthroughSubject

https://developer.apple.com/documentation/combine/passthroughsubject


CurrentValueSubject

最後に出力した値を一つ保持しつつ値を出力します。

後からsubscribeした場合の初期値に利用するためです。

CurrentValueSubject

https://developer.apple.com/documentation/combine/currentvaluesubject


Subjectの例

下記のようにsubscribeしたり

sendで値の出力もできます。


let canceller = trickNamePublisher.assign(to: \.someProperty, on: someObject)

let magicWordsSubject = PassthroughSubject<String, Never>()

trickNamePublisher.subscribe(magicWordsSubject)

let canceller = magicWordsSubject.sink { value in
// do something with the value
}

magicWordsSubject.send("Please")

同じPublishersubscribeするSubscriberが多い場合は

share使うことで参照型に変換でき

値の共有をすることもできます。


let sharedTrickNamePublisher = trickNamePublisher.share()

share

https://developer.apple.com/documentation/combine/publisher/3204754-share


SwiftUI

SwiftUIはSubscriberを持っているため

利用側はPublisherを設定するだけで

値の更新などを行うことができるようになっています。


BindableObjectプロトコル

BindableObjectPublisherプロトコルに適合した型の

didChangeというプロパティを持っています。

スクリーンショット 2019-06-08 18.43.10.png

BindableObject

https://developer.apple.com/documentation/swiftui/bindableobject

下記はスライドの例です。

※コンパイルはできません


class WizardModel : BindableObject {
var trick: WizardTrick { didSet { didChange.send() }
var wand: Wand? { didSet { didChange.send() }

let didChange = PassthroughSubject<Void, Never>()
}

struct TrickView: View {
@ObjectBinding var model: WizardModel
var body: some View {
Text(model.trick.name)
}
}

trickwandの変更されると

didChangesendを送ると

それに応じてSwiftUIが@ObjectBinding var model: WizardModelを更新し

Text(model.trick.name)に更新結果が反映されます。

※SwiftUIの詳細に関しては割愛させていただきます。

#既存のアプリに適応する

以上のように

Combineの特徴を例を挙げて

見てきました。

最後により詳細な例を通して

もう少し深く見ていきます。

スクリーンショット 2019-06-09 3.49.17.png


内容

ユーザ名とパスワードとパスワード確認用のテキストフィールドがあり

全ての項目が妥当な値になるとユーザ登録ボタンが有効になるようにします。


var password: String = ""
@IBAction func passwordChanged(_ sender: UITextField) {
password = sender.text ?? ""
}

var passwordAgain: String = ""
@IBAction func passwordAgainChanged(_ sender: UITextField) {
passwordAgain = sender.text ?? ""
}

これまでにUIKitですと

上記のような形で変更を監視して

値に変更を反映するためには

さらにUIViewの更新処理を自分で書く必要がありました。

これをPublisherを使用する形へ変更します。


@Published var password: String = ""
@IBAction func passwordChanged(_ sender: UITextField) {
password = sender.text ?? ""
}

@Published var passwordAgain: String = ""
@IBAction func passwordAgainChanged(_ sender: UITextField) {
passwordAgain = sender.text ?? ""
}


@Published

これはSwift5.1のProperty Wrappersという機能を使って

@Publishedというアノテーションを適用することで

プロパティをPublisherにすることができます。

Property Wrappersについては下記をご参照ください。

https://github.com/DougGregor/swift-evolution/blob/property-wrappers/proposals/0258-property-wrappers.md

https://forums.swift.org/t/pitch-3-property-wrappers-formerly-known-as-property-delegates/24961

※現在下記のようなissueがあり

Xcode上で色々調べることができない状態にあります。

The Foundation integration for the Combine framework is unavailable. 

The following Foundation and Grand Central Dispatch integrations
with Combine are
unavailable: KeyValueObserving, NotificationCenter, RunLoop, OperationQueue, Timer, URLSession, DispatchQueue, JSONEncoder, JSONDecoder,
PropertyListEncoder, PropertyListDecoder,
and the @Published property wrapper. (51241500)

※ 2019/6/18追記

Xcode11 Beta2より補完が効くようになりました。

The Foundation integration for the Combine framework is now available. 

The following Foundation and Grand Central Dispatch integrations
with Combine are available:
KeyValueObserving, NotificationCenter, RunLoop, OperationQueue, Timer,
URLSession, DispatchQueue, JSONEncoder, JSONDecoder,
PropertyListEncoder, PropertyListDecoder,
and the @Published property wrapper. (51241500)

ドキュメントもあります。

Published

https://developer.apple.com/documentation/combine/published

FailureNeverPublisherです。

https://developer.apple.com/documentation/combine/published/failure

下記のようにPublisherとしての機能が活用できます。


@Published var password: String = ""
self.password = "1234" // The published value is `1234`

let currentPassword: String = self.password
let printerSubscription = $password.sink {
print("The published value is '\($0)'") // The published value is 'password'
}
self.password = "password"

アプリの例では

パスワードとパスワード確認用のテキストフィールドを

組み合わせたバリデーションが必要になります。

この時にCombineLatestを活用します。

https://developer.apple.com/documentation/combine/publishers/combinelatest

https://developer.apple.com/documentation/combine/publisher/3204695-combinelatest

スクリーンショット 2019-06-09 4.19.34.png


@Published var password: String = ""
@Published var passwordAgain: String = ""
var validatedPassword: CombineLatest<Published<String>, Published<String>, String?> {
return CombineLatest($password, $passwordAgain) { password, passwordAgain in
guard password == passwordAgain, password.count > 8 else { return nil }
return password
}
}

CombineLatestは上記で記載の通り

passwordpasswordAgainが更新されると

クロージャの処理が実行されます。

また処理には続きがあり

正しいパスワードかどうかのチェックを行います。


@Published var password: String = ""
@Published var passwordAgain: String = ""
var validatedPassword: Map<CombineLatest<Published<String>, Published<String>, String?>> {
return CombineLatest($password, $passwordAgain) { password, passwordAgain in
guard password == passwordAgain, password.count > 8 else { return nil }
return password
}
.map { $0 == "password1" ? nil : $0 }
}

mapを使用していることでさらに型が変化しました。

map

https://developer.apple.com/documentation/combine/publisher/3204718-map

Map

https://developer.apple.com/documentation/combine/publishers/map

ここで問題が発生します。

このままですと他のPublisherと型が合わず

処理を組み合わせることが難しいです。

そこでeraseToAnyPublisher()を使います。


@Published var password: String = ""
@Published var passwordAgain: String = ""
var validatedPassword: AnyPublisher<String?, Never> {
return CombineLatest($password, $passwordAgain) { password, passwordAgain in
guard password == passwordAgain, password.count > 8 else { return nil }
return password
}
.map { $0 == "password1" ? nil : $0 }
.eraseToAnyPublisher()
}

ドキュメントにはまだ何も記載がありませんが

これを使うことで型をAnyPublisherにすることができます。

https://developer.apple.com/documentation/combine/publisher/3241548-erasetoanypublisher

こうすることで

他の処理とのさらなるCompositionが可能になります。

下記のような処理の流れになります。

スクリーンショット 2019-06-09 4.37.04.png


非同期処理

次にユーザ名について見ていきます。


@Published var username: String

ユーザ名は利用可能かをサーバに確認する必要があります。

そのため一文字一文字に変化するたびに

サーバに問い合わせるのはちょっと処理が重くなってしまいます。

そこでサーバに問い合わせる間隔を少し長くするために

Debounceを活用します。


Debounce

Debounceは一定の間隔後にイベントが発行するように

処理を遅らせることができます。

debounce

https://developer.apple.com/documentation/combine/publisher/3204702-debounce

Debounce

https://developer.apple.com/documentation/combine/publishers/debounce

下記のように使います。


@Published var username: String
var validatedUsername: Debounce<Published<String>, Never> {
return $username
.debounce(for: 0.5, scheduler: RunLoop.main)
}

上記の例は0.5秒の間はイベントが発行されません。

さらにイベントの発行をメインスレッドで実行されるように

RunLoop.mainを指定しています。

さらに値が変わっていないのにサーバに処理を送るのも

効率がよくありません。

そこでRemoveDuplicatesを活用します。

removeDuplicates

https://developer.apple.com/documentation/combine/publisher/3204745-removeduplicates

RemoveDuplicates

https://developer.apple.com/documentation/combine/publishers/removeduplicates


RemoveDuplicates

これを使用するとイベントを発行しようとする際に

前回と同じ値であった場合はイベントを発行しなくなります。


@Published var username: String
var validatedUsername: RemoveDuplicates<Published<String>, Never> {
return $username
.debounce(for: 0.5, scheduler: RunLoop.main)
.removeDuplicates()
}

先ほどと同様に型を合わせるために

eraseToAnyPublisher()を使います。


@Published var username: String
var validatedUsername: AnyPublisher<String?, Never> {
return $username
.debounce(for: 0.5, scheduler: RunLoop.main)
.removeDuplicates()
.eraseToAnyPublisher()
}

次にサーバへの通信を考えます。

非同期処理から新しいPublisherを返すため

flatMapを使います。


@Published var username: String
var validatedUsername: <String, Never> {
return $username
.debounce(for: 0.5, scheduler: RunLoop.main)
.removeDuplicates()
.flatMap { username in
// 下記のような非同期処理でコールバックが返ってくることを想定
// func usernameAvailable(_ username: String, completion: (Bool) -> Void)
}
}

ここで非同期処理からPublisherを返すために

Futureを使います。

Future

https://developer.apple.com/documentation/combine/publishers/future


@Published var username: String
var validatedUsername: AnyPublisher<String?, Never> {
return $username
.debounce(for: 0.5, scheduler: RunLoop.main)
.removeDuplicates()
.flatMap { username in
return Future { promise in
self.usernameAvailable(username) { available in
promise(.success(available ? username : nil))
}
}
}
}

nilを返すために最終的な型は

AnyPublisher<String?, Never>になっています。

promiseは下記のような形になっており

処理結果をResultに包んだコールバックです。


(Result<Output, Failure>) -> Void

これまでの流れは下記のようになります。

スクリーンショット 2019-06-09 5.16.11.png

最後に

validatedPasswordvalidatedUsername

CombineLatestを使って

Create Accountボタンの有効無効を切り替えられるようにします。


var validatedCredentials: AnyPublisher<(String, String)?, Never> {
return CombineLatest(validatedUsername, validatedPassword) { username, password in
guard let uname = username, let pwd = password else { return nil }
return (uname, pwd)
// ここでチェックを行う
}
.eraseToAnyPublisher()
}

最終的には下記のように使います。


@IBOutlet var signupButton: UIButton!
var signupButtonStream: AnyCancellable?
override func viewDidLoad() {
super.viewDidLoad()
self.signupButtonStream = self.validatedCredentials
.map { $0 != nil }
.receive(on: RunLoop.main)
.assign(to: \.isEnabled, on: signupButton)
}

validatedCredentialsでチェックを行ったあとに

メインスレッドで値を受け取り

signupButtonisEnabledプロパティにその値を適用しています。

最終的な全体の処理の流れです。

スクリーンショット 2019-06-09 5.27.19.png

今回の例を通して


  • 小さな要素をカスタムのPublisherに構成する


  • Publisherを少しづつ適応する


  • @Publishedをプロパティに適用してPublisherにする


  • Futureを使ってコールバックとPublisherを組み合わせる

ことを見てきました。


Combine in URLSession

他の例として

Advances in Networking, Part 1のセッション内ではURLSessionを使った例が紹介されていました。

こちらもCombineフレームワークの機能をたくさん使用しています。

Advances in Networking, Part 1

https://developer.apple.com/videos/play/wwdc2019/712/

※ セッションでも言及していましたが

現時点ではないメソッドなども使用しており

Combineフレームワークの正式版には

導入予定のものとして紹介されています。


Combineを用いた通信の流れ

Subscriberがリクエストを送ると

実際にPublisherに伝わるまでに

後ろから順番にOperatorsが適用されます。

そして通信が完了したPublisherが値を送り出し

リクエストとは逆の順番にOperatorsが適用されて

Subscriberにまで値が届けられます。

スクリーンショット 2019-06-14 2.54.38.png

セッションでは検索窓にキーワードを入力して

検索APIを呼び出す例が紹介されています。

スクリーンショット 2019-06-14 2.47.38.png


Sink

Subscriberの1つで

リクエストを何回も行うことができます。

sink(下記はPublishers.Futureのものです)

https://developer.apple.com/documentation/combine/publishers/future/3236194-sink

Sink

https://developer.apple.com/documentation/combine/subscribers/sink


DataTaskPublisher

スクリーンショット 2019-06-14 3.13.21.png

セッションで紹介されていましたが

現時点ではまだありません。

おそらくこういう形だろうということで

サンプルを紹介されているものもあります。

https://gist.github.com/yamoridon/16c1cc70ac46e50def4ca6695ceff772

https://dev.classmethod.jp/smartphone/iphone/use-combine-for-http-networking/

※ 2019/6/18追記

ドキュメントができていました。

dataTaskPublisher(for:)

https://developer.apple.com/documentation/foundation/urlsession/3329708-datataskpublisher

URLSession.DataTaskPublisher

https://developer.apple.com/documentation/foundation/urlsession/datataskpublisher

FailureにはURLErrorというstructが使われています。

https://developer.apple.com/documentation/foundation/urlerror

セッションでは

セルの中のイメージを非同期で取得する例が使われています。

※ 一部省略しています。かつ一部補完もしています。


class MenuItemTableView: UITableViewCell {

var subscriber: AnyCancellable?

override func prepareForReuse() {
subscriber?.cancel()
}
}

enum PubsocketError: Error {
case invalidServerResponse
}

// ↓ UITableViewControllerの中

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

var request = URLRequest(url: menuItem.heighResImageURL)
cell.subscriber = pubSession.dataTaskPublisher(for: request)
.tryCatch { error -> URLSession.DataTaskPublisher in
guard error.networkUnavailableReason == .constrained else {
throw error
}
return pubSession.dataTaskPublisher(for: menuItem.lowResImageURL)
}
.tryMap { data, response -> UIImage in
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw PubsocketError.invalidServerResponse
}
return image
}
.retry(1)
.replaceError(with: "sample")
.receive(on: DispatchQueue.main)
.assign(to: \.itemImageView.image, on: cell)
}


TryMap

tryMapはエラーを投げる可能性のあるmapです。

tryMap

https://developer.apple.com/documentation/combine/publishers/output/3210207-trymap

TryMap

https://developer.apple.com/documentation/combine/publishers/trymap


Retry

エラーが起きた時に再通信する回数を制限します。

retry

https://developer.apple.com/documentation/combine/publishers/future/3208063-retry

Retry

https://developer.apple.com/documentation/combine/publishers/retry


ReplaceError

replaceErrorはエラーを指定の要素に変換します。

replaceError(with:)

https://developer.apple.com/documentation/combine/publishers/output/3210183-replaceerror

ReplaceError

https://developer.apple.com/documentation/combine/publishers/replaceerror

tryCatchについては

ドキュメントにまだありませんでしたので

見つけ次第載せたいと思います。


AnyCancellableの活用

Combineフレームワークによって

一連の流れで処理を書くことができることも

十分なメリットですが

AnyCancellableを活用することで

画面から見えなくなった非同期処理を中断することができ

よくある異なったセルに異なった画像が表示される

といった不具合が起きなくなっています。

これまでですとOperationQueueを作成して

cancelメソッドを呼ぶなどが必要でしたが

それが不要になりました。


まとめ

CombineフレームワークについてWWDC2019の動画を中心に見ていきました。

スライドにも出てきていましたが

Combineの全体の構成としては


  • enum Publishers名前空間にある多数のstruct

  • enum Subscribers名前空間にある多数のstruct

  • Subjects

  • 90以上の共通のOperators(インスタンスメソッド)

となっており

全ての処理がstructで構成されている点が

小さい部品を組み合わせていくCompositionファーストを

強く意識しているところなのかなと感じました。

また

Swiftの標準ライブラリや広く使われてきたライブラリと

クラスやメソッド名などが似ているものがたくさんあることで

導入への抵抗が少なくなっていることも良いなと感じています。

(プログラミングの世界で共通的に使用されている単語だからということもあるとは思いますが)

これから使う機会は多くあるのかなと思っていますが

どういう所にどのように活用していくか

考えていく必要はあると思っています。

今回出てこなかったものはまだまだたくさんありますし

ドキュメントに記載のないものなど

今後変わってくるものもあります。

まだまだ未知な可能性をたくさん含んでいる

新しいフレームワークに今後も注目していきたいですね!

間違いなどございましたらご指摘頂けますと幸いです:bow_tone1: