![[Swift] はじめてのCombine | Apple製の非同期フレームワークを使ってみよう](https://devio2023-media.developers.io/wp-content/uploads/2019/09/190926_combine_catch.png)
[Swift] はじめてのCombine | Apple製の非同期フレームワークを使ってみよう
この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
こんにちは。きんくまです。
今回は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
- 非同期ではなく、値をすぐに発行して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があるので、調べても良いと思います。
- 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とかもやってみたいと思います。
参考
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をやったことがない方に向けた初心者向けの内容になります。もしよろしければご参加くださいまし。














