こんにちは。和尚です!
明日はクリスマスイブ? 今年も残すところ数日。。。一年ってあっという間ですね(ジャネーの法則)
さてさて、今回のブログですがApple公式の非同期フレームワークである「Combine」を使った実際の機能の組み方についての紹介ブログとなります。
SwiftUIでアプリを作り始めたはいいけど、Web APIの呼び出し方がわからなかったり、フォームバリデーションってどうやってやるんだろう。と思ってる人は多いと思います。実際にSwifUIの参考書はUIの作り方までしか紹介していないものも多く、そのような参考書を買った場合、リリースできるようなアプリを作成することは難しいです。このブログはそういった悩めるSwiftUI初心者層向けの記事となります。
この記事を読んで得られるもの
- Combineについての基礎知識
- WebAPIの呼び出し方の基礎
- フォームバリデーションの作り方の基礎
知識 編
まずはCombineについて知っていきましょう。
Combineとは
CombineはAppleが公式で提供する非同期イベントを処理するフレームワークです。XCode11から利用可能になった新しいフレームワークで導入可能なターゲットバージョンはSwiftUI同様に13.0以降になります。
Combineは「Publisher」「Subscriber」「Operator」の3要素からなり、値を提供するPublisher、Publisherが提供した値を購読するSubscriberの2者間におけるデータの受け渡しがCombineの主な役割となります。一方、OperatorはPublisherが提供する値を受け取り、役割に応じて値を変化させたものを最終的にSubscriberへと流す、中間役職となります。
感覚的にはPublisherはテレビ局のような存在だと思ってください。そしてテレビを見る人がSubscriber、テレビがOperatorです。Publisherは番組を配信(Publisher)していて、それを人(Subscriber)がテレビを通して見ます(購読)。テレビは音量や色などを調節(Operator)して人に映像を届けます。
Combineを使う理由
Combineを利用することで、イベント毎に処理を一元化することができたり、運命のピラミッド(ネストされたクロージャ)やdelegateなどの複雑で追いにくいものを排除することが出来ます。その結果コードの読み取りや保守が容易になるといったメリットがあります。
またSwiftUIを使ってアプリを作る場合は、ObservableObject Protocolや@PublishedなどのProperty wrapperなど、Combineの機能の一部をCombineを知らない人であっても知らず知らずのうちに実は使用しています。そのためCombineはSwiftUIでアプリを作ろうと思った場合、習得が必須のフレームワークなのです。
余談 >> 今回の件とあまり関係はありませんが、XCode13からはasync/awaitが使えるようになった為、運命のピラミット問題はより解消しやすくなりました。Xcode13~13.1はOSのターゲットバージョン15以上、XCode13.2以降からは13以上でasync/awaitが利用できるようになっています。
Combineの利用例
最初に記述したものも含めて、SwiftUIアプリではどういったところでCombineを利用するのか一覧で並べてみました。今回は基礎編の後に下記の中から最初に記述した2つを抜粋してコードと共に簡単に紹介していきます。
- Web APIをコールする時
- フォームのバリデーションチェックをする時
- NotificationCenterの通知をキャッチする時
- 定期処理をするとき(Timerの利用)
- DelegateがあるクラスをPublisher化したい時(例:位置情報取得など)
など
基礎 編
Publisherは公式が提供してくれているものが多くあります。その中でも基本的な「Subject」「Future」「Just」について見ていきましょう。
Subject
Subjectは`send(_:)`を呼び出すことにより、値を注入することができるPublisherです。
Subjectの一種である「PassthroughSubject」を例にとって、PublisherおよびそのPublisherの一部であるSubjectがどういう動きをするのか見ていきましょう。
import Combine var cancellable: AnyCancellable? let subject = PassthroughSubject<Int, Never>() // ① cancellable = subject.sink { num in // ② print(num) } subject.send(1) // ③ subject.send(2) cancellable?.cancel() // ④ subject.send(3)
■出力結果
1 2
① これがSubject(Publisher)です。このPublisherはSubjectのため、前述した通り`send`メソッドを利用することが可能になりました。また、PassthroughSubjectは型を2つ指定することが可能で一つは送るデータの型、もう一つはエラーになります。しかし今回はエラーを送らないため、2つ目にはNeverを指定しています。
② sink
メソッドを使用してPublisherを購読します。購読中に値が送られてくるとsinkメソッドの引数であるreciveCompletionやreceiveValueというコールバックメソッドが実行されます。また、①の2つ目の型にNeverを指定すると今回のサンプルのようにreciveCompletionを省略することが可能です。今回は値が流れてきた時に標準出力しています。
③ send
メソッドを使って引数の値をSubscriberに送っています。値の送信に成功すると②のsink(receiveValue: Int)に値が送られるため、②で書いた通り引数に設定した値が標準出力されます。
④ 購読をキャンセルします。ここでキャンセルされたため、この後に書いている`subject.send(3)`は実行されていません。(AnyPublisherに関しては、このあと紹介するWebAPIの部分で説明しています)
Future
Futureは非同期で一つの値を生成して出力するか失敗するPublisherです。
従来のClosureで処理していた非同期関数を、Futureに置き換えてみましょう。
▼これまでの書き方
import Foundation print("2秒後に「HOGE」が出力されます") performAsyncAction { print("HOGE") } func performAsyncAction(completionHandler: @escaping () -> Void) { DispatchQueue.main.asyncAfter(deadline:.now() + 2) { completionHandler() } }
▼ Combineを使った書き方
import Foundation import Combine var cancellable: AnyCancellable? print("2秒後に「HOGE」が出力されます") cancellable = performAsyncActionAsFuture().sink() { _ in print("HOGE") } func performAsyncActionAsFuture() -> Future<Void, Never> { return Future() { promise in DispatchQueue.main.asyncAfter(deadline:.now() + 2) { promise(Result.success(())) } } }
▼(おまけ) Acync/Awaitを使った書き方
import Foundation import Combine import _Concurrency // Playgroundで実行させる時に必要です。 print("2秒後に「HOGE」が出力されます") Task { let result = await performAsyncActionAsAsyncAwait() print(result) } func performAsyncActionAsAsyncAwait() async -> String { await Task.sleep(2000000000) return "HOGE" }
Just
Justは一つの値を即時出力し、終了するPublisherです。
import Combine let subscribe: Just<Int> = Just(1) subscribe.sink { num in print(num) }
他にも「Fail」や「AnyPublisher」など、よく使うPublisherがあるので、使う際は抑えておきましょう。
実装 編
WebAPIをコールしてみよう
FoundationにXcode11から、`dataTaskPublisher(for: _)`が追加されました。こちらは従来あったdataTaskのPublisherバージョンとなります。今回はネットで見つけたフリーのWebAPIをコールしてみて、どうやってデータを取得しViewへ反映しているのか順を追って見ていきましょう。
■ 利用するWebAPI (ジョークを返してくれる)
https://icanhazdadjoke.com/api
Model作成
まず初めにドキュメントを確認して、レスポンスモデルを作成していきます。コールして値を取得したあとは使いやすいようにdecodeするのが一般的です。decodeする処理を後々行うのでModelはCordable
プロトコルに準拠させましょう。
struct JokeResponse: Codable { var id: String var joke: String var status: Int }
ViewModel作成
次はWebAPIをコールして、取得した値を保持するViewModelを作成していきます。
import Foundation import Combine final class WebAPIViewModel: ObservableObject { @Published var joke: String = "" private var cancellables: [AnyCancellable] = [] deinit { cancellables.forEach { cancellable in cancellable.cancel() } } func fetchJoke() { guard let url = URL(string: "https://icanhazdadjoke.com/") else { retu } var urlRequest = URLRequest(url: url) urlRequest.setValue("application/json", forHTTPHeaderField: "Accept") URLSession.shared.dataTaskPublisher(for: urlRequest) .tryMap { (data: Data, _: URLResponse) in return data } .decode(type: JokeResponse.self, decoder: JSONDecoder()) .receive(on: DispatchQueue.main) .sink(receiveCompletion: { completion in switch completion { case .finished: print("Finish.") case .failure(let error): print(error) } }, receiveValue: { [weak self] response in self?.joke = response.joke }) .store(in: &cancellables) } }
ViewModelの中身を一つずつ見ていきましょう。
■ @Published
@Published var joke: String = ""
`@Published`を付けたプロパティの値が変わると、Viewの再レンダリングが行われます。Viewに書く`@State`のViewModel版だと思ってください。@State
と違うのは、@Published
をつけたプロパティはpublisherを提供してくれるということです。
具体的にどういうことかというと、これまでプロパティの値が変更があった場合はdidSetなどを使って処理を書いていましたが、プロパティの値が変更されたらSubscriberに変更した値を流してくれるPublisherが提供してくれる機能が備わったことで、これからは以下のような書き方が可能になります。
@Published var isOpen: Bool = false func subscribeIsOpen() { $isOpen.sink { value in print(value) } }
■ AnyCancellable
private var cancellables: [AnyCancellable] = [] deinit { cancellables.forEach { cancellable in cancellable.cancel() } }
AnyCancellableは購読をキャンセルするときに利用します。AnyCancellabeにはcancel()
メソッドが生えており、このメソッドを呼び出すことでSubscriberはPublisherの購読を終了することができます。
Subscriberは購読するときにAnyCancellable配列に.store(in: &cancellables)
という形で自身を入れておくことができます(store
メソッドはAnyCancellableのインスタンスメソッドです)。今回はViewModelのdeinit時に全ての購読を解除するように記載しました。
また個別で解除したい場合もあるでしょう。その時はstore
メソッドを使わずに、以下のように書くことができます。
import Foundation import Combine final class WebAPIViewModel: ObservableObject { @Published var joke: String = "" private var jokeCancellable: AnyCancellable? deinit { jokeCancellable?.cancel() } func fetchJoke() { guard let url = URL(string: "https://icanhazdadjoke.com/") else { return } var urlRequest = URLRequest(url: url) urlRequest.setValue("application/json", forHTTPHeaderField: "Accept") jokeCancellable = URLSession.shared.dataTaskPublisher(for: urlRequest) .tryMap { (data: Data, _: URLResponse) in return data } .decode(type: JokeResponse.self, decoder: JSONDecoder()) .receive(on: DispatchQueue.main) .sink(receiveCompletion: {completion in switch completion { case .finished: print("Finish.") case .failure(let error): print(error) } }, receiveValue: { [weak self] response in self?.joke = response.joke }) } }
sink
メソッドやこの後出てくる`assign`メソッドはAnyCancellableを戻り値として返します。
■ WebAPIの呼び出し(購読)
URLSession.shared.dataTaskPublisher(for: urlRequest) .tryMap { (data: Data, _: URLResponse) in return data } .decode(type: JokeResponse.self, decoder: JSONDecoder()) .receive(on: DispatchQueue.main) .sink(receiveCompletion: {completion in switch completion { case .finished: print("Finish.") case .failure(let error): print(error) } }, receiveValue: { [weak self] response in self?.joke = response.joke }) .store(in: &cancellables)
URLSession.shared.dataTaskPublisher(for: urlRequest)
最初に紹介した`dataTaskPublisher(for: _)`メソッドはPublisherです。引数にURLまたはURLRequestを入れて使うことができます。
.tryMap { (data: Data, _: URLResponse) in return data }
Operatorです。先ほど軽く説明しましたが、Operatorは値を途中で書き換える中間役職です。ここでは、このOperator通過後にresponse bodyをdecodeする処理が入るため、その際に必要なdataのみを下層に流すように挟みこんでいます。
.decode(type: JokeResponse.self, decoder: JSONDecoder())
JSONで渡ってきたデータを先ほど作成したJokeResponse
モデルへとデコードしてくれるメソッドです。
.receive(on: DispatchQueue.main)
このメソッドはこれ以降の処理のスレッドを変更してくれます。UIの変更はMainスレッドで行うため引数に`DispatchQueue.main`を指定しています。
※receive
メソッドとは反対の役割をしてくれるsubscribe
メソッドもあります。receive
メソッドが下流のスレッドを変更してくれるのに対して、`subscribe`メソッドは上流のスレッドを変更してくれます。
このメソッドのおかげでUI変更の箇所を`DispatchQueue.main.async`で囲う必要がなくなります。
.sink(receiveCompletion: { completion in switch completion { case .finished: print("Finish.") case .failure(let error): print(error) } }, receiveValue: { [weak self] response in self?.joke = response.joke })
.sink
メソッドはPublisherのインスタンスメソッドになっており、呼び出すことで購読が開始されます。第一引数の`receiveCompletion`はWebAPIのコールが完了した時、もしくはエラーが発生したときに呼び出されます。エラーも無く値が取得できた際は第二引数の`receiveValue`に値が入ってくれます。
値が取得できたら、jokeの値を変更しましょう。jokeは@Publihsedが付いたプロパティのため、Viewが再レンダリングされます。
View作成
最後にViewを作成して、ViewModelをバインドしていきます。
import SwiftUI struct WebAPIView: View { @StateObject private var viewModel = WebAPIViewModel() var body: some View { VStack { Text("ジョーク") Text(viewModel.joke) // ボタンを押す度に新しいジョークを取得します Button(action: viewModel.fetchJoke) { Text("取得") } } } }
以上がWebAPIをコールする手順となります。
実際に導入する際にはAPIClientなどを作成することをお勧めします。
フォームバリデーションを組んでみよう
TextFiledにバリデーション処理を入れる方法を見ていきましょう。今回はtextFiledの値が10文字以内というバリデーションをつけてみます。
ViewModel作成
import Foundation import Combine final class FormValidationViewModel: ObservableObject { @Published var text: String = "" @Published var isValidText: Bool = true var resultText: String { return isValidText ? "○" : "×" } private var cancellables: [AnyCancellable] = [] init() { subscribeTextField() } deinit { cancellables.forEach { cancellable in cancellable.cancel() } } func subscribeTextField() { $text.receive(on: DispatchQueue.main) .map { value in return value.count < 11 } .assign(to: \.isValidText, on: self) .store(in: &cancellables) } }
バリデーション処理を行うには、先ほどWebAPIの部分で説明した@Publisherd
の付いたプロパティがPublisherを提供するという機能に着目して実装してきます。
どういった形で`@Published`が付いたプロパティの購読をするかというと、プロパティ名の先頭に$
マークをつけることでPublisherとして利用することが可能です。あとはOperatorやスレッドをreceive
メソッドを使って変更して、sink
メソッドもしくはassign
メソッドを利用して購読しましょう。
.assign(to: \.isValidText, on: self)
先ほどWebAPIのときに軽く紹介しましたが、このメソッドも.sink
同様にPublisherのインスタンスメソッドで、呼び出されると購読を開始します。sinkと異なる点は@Publishedが付いたプロパティに直接バインドすることができます。今回は`@Published var isValidText`とバインドさせているため、値が流れてくる度に自動でisValidTextの値が変わってくれます。
View作成
import SwiftUI struct FormValidationView: View { @StateObject private var viewModel = FormValidationViewModel() var body: some View { VStack { Text("10文字以内 \(viewModel.resultText)") TextField("", text: $viewModel.text) } } }
終わりに
実際に2つの導入方法を紹介していきましたが、いかがだったでしょうか?
Combineが使えるようになると、アプリで出来る幅がグッと広がります。これからはSwiftUIの時代にどんどんなっていくと思うので、早めに習得してしまいましょう!
続きをお楽しみに!
投稿者プロフィール
- '96年生まれのiOSエンジニア
最新の投稿
- 技術開発2022年10月11日iOS16をサポートしよう!
- iOS2021年12月23日Combine初心者講座 -SwiftUIの相棒を使いこなそう-
- 開発・便利ツール2021年9月14日Growth by CaseStudy②「社内MTGの効率化〜slackコマンドの有効活用〜」
- 技術開発2021年8月20日Apple TV (tvOS) アプリについて