[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
- 非同期ではなく、値をすぐに発行して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をやったことがない方に向けた初心者向けの内容になります。もしよろしければご参加くださいまし。