[Swift] はじめてのCombine | Apple製の非同期フレームワークを使ってみよう

こんにちは。きんくまです。
今回はiOS13から使えるようになったCombineをやってみました!

Combineって何?

イベントの発行と購読をすることができるフレームワークです。
非同期処理の中で使えばスッキリと書くことができるので、コールバック地獄とか死のピラミッドとかがおきなくなります。

Appleが元ネタにしたと明言しているのかどうかはわかりませんが、似たOSSライブラリにRxSwiftがあります。
ReactiveX/RxSwift: Reactive Programming in Swift - GitHub

また最初に書いたのですが、iOS 13以降に使えるフレームワークなのでiOS 12以下は使えないことにご注意ください。
iOS 13から使えるようになったSwift UIとセットで使うと良さそうですね。

さて、Combineには大事な要素が3つあります。

  • Publishers -> イベントの発行者です
  • Subscribers -> イベントの購読者です
  • Operators -> 流れてくる値を加工することができます

コードをみた方が理解が速いのでサンプルコードをのせます

// PublisherとOperator
let publisher = Future<String, APIError> { promise in
    promise(.success("Hello Combine!"))

    //エラーの場合はこっち
    //promise(.failure(APIError(description: "なんかエラーだって")))
}
.map {
    $0 + " This is map operator"
}

// sinkでsubscribe(購読)している
let cancellable = publisher.sink(receiveCompletion: { completion in
    switch completion {
    case .finished:
        print("finished")
    case .failure(let error):
        print("error \(error.description)")
    }
}, receiveValue: { message in
    print("received message: \(message)")
})

struct APIError: Error {
    var description: String
}

// 出力
// received message: Hello Combine! This is map operator
// finished
  • 最初のFutureから.mapの手前までで、Publisherを作成
  • .mapはOperatorです。Publisherが渡す値を加工しています。(今回は文字列を追加で連結)
  • publisher.sinkが購読しているところです。

上のサンプルコードでは、同じファイルに書いていますが、実際には、Publisher+Operator処理と、Subscribe処理は別ファイルに書くことになると思います。

そのときに大事なことなのですが、.sinkで返ってくる値は非同期処理が終わるまで、classやstructのプロパティとしてどこかに保存しておいてください。そうしないと、メモリが解放されてしまって処理が途中で止まってしまい、結果が返ってきません。(ハマりました、、、)

Future と Just

Publisherは自分で1から作ることもできますが、公式で用意してくれたものがあるのでそちらを使います。

Future

一番はじめに紹介したものです。非同期で値を返すことが可能です。
Future

  • 値を1つ発行してfinish
  • エラーを発行

のどちらかをすることができます。

値はGenericsになっていますので、最初にどの値を発行するかを設定しておきます。
エラーの型はError型でも、自分で定義したErrorプロトコルに準拠した型でもよいです。

// 数値
Future<Int, APIError>

// こんなのがあったら
struct Company {
    var name: String
}
Future<Company, APIError>

// エラーが複数あって使い分けたければ
struct SampleError: Error {
    var description: String
}
struct APIError: Error {
    var description: String
}
Future<Company, Error>

成功する場合は.successを返して、失敗した場合は.failureを返します

Future<String, APIError> { promise in
    let hasError = true //適当なフラグだと思って
    if hasError {
        //エラーの場合はこっち
        promise(.failure(APIError(description: "なんかエラーだって")))
    } else {
        //成功したので値を返す
        promise(.success("Hello Combine!"))
    }
}

Just

Just

  • 非同期ではなく、値をすぐに発行してfinishします
  • エラーは発行できません
let cancellable = Just("Hello Just!")
    .sink(receiveCompletion: { completion in
        switch completion {
        case .finished:
            print("finished")
        }
    }, receiveValue: { message in
        print("message: \(message)")
    })

エラーを発行しないので、sinkのときにreceiveCompletionを書かない書き方もできます。

let cancellable = Just("Hello Just!")
    .sink { message in
        print("message: \(message)")
    }

非同期処理が使えないJustなのですが、catch Operatorの中でデフォルト値を設定するのに使えます。

let publisher = Future<String, APIError> { promise in
    promise(.failure(APIError(description: "えらーなんだ")))
}
.catch { error in
    Just("There is no message")
}
.eraseToAnyPublisher()

let cancellable = publisher.sink { value in
    print("value: \(value)")
}

// 出力
// value: There is no message

もう少し詳しく書くと、こんな感じにエラーが入ってきたところをデフォルトメッセージに変換してるのがわかると思います。

let publisher = Future<String, APIError> { promise in
    promise(.failure(APIError(description: "えらーなんだ")))
}
.catch { error -> Just<String> in
    print("catched error \(error)")
    return Just("There is no message")
}
.eraseToAnyPublisher()

let cancellable = publisher.sink { value in
    print("value: \(value)")
}

// 出力
// catched error APIError(description: "えらーなんだ")
// value: There is no message

ここで、eraseToAnyPublisherというOperatorがありました。これは型を簡単に書けるためのものです。
これがないとpublisherは下のようなすごく面倒くさそうな型になりますが、

let publisher: Publishers.Catch<Future<String, APIError>, Just<String>>

eraseToAnyPublisherをすることで型が見やすくなります。(これは例が悪いのですが、他のはもっと見やすくなります)

let publisher: AnyPublisher<String, Just<Output>.Failure>

その他のPublisher

公式のリファレンスを見ると他にもいろんなPublisherがあるので、調べても良いと思います。

Convenience Publishers

  • Deferred
  • Empty
  • Fail
  • Record

sink と assign

Publisherを購読するのに、sinkとassignが使えます。

sink

sinkは2種類あって、何度も出てきてますがエラーがある場合

let pub = Future<String, Error> { promise in
    promise(.success("hello"))
}

let cancellable = pub.sink(receiveCompletion: { completion in
    switch completion {
    case .finished:
        break
    case .failure(let error):
        print("error \(error)")
    }
}, receiveValue: { value in
    print("value \(value)")
})

エラーがない場合はこうやってかけます。(下の例ではcatch内でJustで返しているのでエラーが出ない)

let pub = Future<String, Error> { promise in
    promise(.failure(APIError(description: "Mmmm. There is an error")))
}.catch { error in
    Just("エラー時のデフォルトメッセージ")
}

let cancellable = pub.sink { message in
    print("message \(message)")
}

assign

assignはエラーが出ないPublisherのときに、あるオブジェクトのプロパティに直接値を代入できます。

class MessageContainer {
    var message: String

    init() {
        message = "initial message"
    }
}

let pub = Future<String, Error> { promise in
    promise(.success("hello assign"))
}.catch { error in
    Just("エラー時のデフォルトメッセージ")
}

let obj = MessageContainer()
print("message before: \(obj.message)")
let cancellable = pub.assign(to: \.message, on: obj)
print("message after: \(obj.message)")

// 出力
// message before: initial message
// message after: hello assign

NotificationCenter.Publisher と URLSession.dataTaskPublisher

さきほどの Future と Just は新規作成された型だったのですが、もともとFoundationで使われているものにもPublisherプロパティが追加されたものがいくつかあります。

ここでは NotificationCenter.Publisher と URLSession.dataTaskPublisher を紹介します。

NotificationCenter.Publisher

新しいNotification.Nameを設定します。

extension Notification.Name {
    static let blogPublished = Notification.Name("com.sampleapp.blogPublished")
}

sinkまで定義したAnyCancellableを作っておきます。

let notificationCancellabele = NotificationCenter.default.publisher(
    for: .blogPublished,
    object: nil
).sink { notification in
    print("notification: \(notification)")
    if let info = notification.userInfo,
       let name = info["name"] {
        print("info \(name)")
    }
}

イベントを発行してみます。

let info = ["name": "ken"]
let note = Notification(name: .blogPublished, userInfo: info)
NotificationCenter.default.post(note)

出力されました!!

notification: name = com.sampleapp.blogPublished, object = nil, userInfo = Optional([AnyHashable("name"): "ken"])
info ken

URLSession.dataTaskPublisher

URLSessionのPublisherなのですが、最後なので実践的にSwiftUIと合わせてみます。

MVVMアーキテクチャにしてみました。

View

import SwiftUI
import Combine

struct ContentView: View {

    @ObservedObject var companyViewModel: CompanyViewModel

    init(companyViewModel: CompanyViewModel) {
        self.companyViewModel = companyViewModel
    }

    var body: some View {
        VStack {
            Text("\(companyViewModel.name)")
            Spacer()
            Button(action: {
                self.companyViewModel.loadEmployee()
            }, label: {
                Text("start load")
            })
        }
        .frame(height: 100)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(
            companyViewModel: CompanyViewModel(name: "No Data")
        )
    }
}

ViewModel

import Foundation
import Combine

class CompanyViewModel: ObservableObject {
    @Published var name: String
    var companyRepository: CompanyRepository
    var cancellable: AnyCancellable?

    init(name: String, companyRepository: CompanyRepository = CompanyRepositoryImpl()) {
        self.name = name
        self.companyRepository = companyRepository
    }

    func loadEmployee() {
        cancellable = companyRepository.loadEmployees()
            .receive(on: RunLoop.main)
            .sink(receiveCompletion: { completion in
            switch completion {
            case .failure(let error):
                if let apiError = error as? APIError {
                    print("error: \(apiError.description)")
                }
            case .finished:
                print("employee finished")
            }
        }, receiveValue: { employee in
            self.name = employee.arguments.name
        })
    }
}

Repository

import Foundation
import Combine

protocol CompanyRepository {
    func loadEmployees() -> AnyPublisher<Employee, Error>
}

class CompanyRepositoryImpl: CompanyRepository {    
    func loadEmployees() -> AnyPublisher<Employee, Error> {
        let urlStr = "https://postman-echo.com/get?name=Taro"
        let result = URLSession.shared.dataTaskPublisher(for: URL(string: urlStr)!)
            .tryMap({ data, response -> Data in
                guard let httpRes = response as? HTTPURLResponse else {
                    throw APIError(description: "http response not found")
                }
                if (200..<300).contains(httpRes.statusCode) == false {
                    throw APIError(description: "Bad Http Status Code")
                }
                return data
            })
            .decode(type: Employee.self, decoder: JSONDecoder())
            .eraseToAnyPublisher()

        return result
    }
}

Model

import Foundation

struct APIError: Error {
    var description: String
}

struct Employee: Codable {
    var arguments: Args

    enum CodingKeys: String, CodingKey {
        case arguments = "args"
    }

    struct Args: Codable {
        var name: String
    }
}

SceneDelegateを一部書き換え

let contentView = ContentView(
    companyViewModel: CompanyViewModel(name: "No Data")
)

実行結果です。最初はこんな感じにボタンが表示されています。

ボタンを押すとAPIが走ってラベルが変わります

まとめ

今回はPublisherを中心にご紹介しました。もし次回があれば、Subjectsとかもやってみたいと思います。

参考

Using Combine

URLSession and the Combine framework

最後に宣伝! Developers.IO 2019でSwift UIを話します

弊社が2019年11月1日(金)に、技術イベント「Developers.IO 2019」を開催します。

【11/1(金)東京】国内最大規模の技術フェス!Developers.IO 2019 東京開催!AWS、機械学習、サーバーレス、SaaSからマネジメントまで50を越えるセッション数!

そこで私もSwift UIのセッションを話すことになりました。

これからはじめるSwiftUI 〜iOSアプリ開発の新スタイル・新スタンダード〜

SwiftUIをやったことがない方に向けた初心者向けの内容になります。もしよろしければご参加くださいまし。