この記事は公開されてから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をやったことがない方に向けた初心者向けの内容になります。もしよろしければご参加くださいまし。