こんにちは。和尚です!

明日はクリスマスイブ? 今年も残すところ数日。。。一年ってあっという間ですね(ジャネーの法則)

さてさて、今回のブログですが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)です。このPublisherSubjectのため、前述した通り`send`メソッドを利用することが可能になりました。また、PassthroughSubjectは型を2つ指定することが可能で一つは送るデータの型、もう一つはエラーになります。しかし今回はエラーを送らないため、2つ目にはNeverを指定しています。

②  sinkメソッドを使用してPublisherを購読します。購読中に値が送られてくるとsinkメソッドの引数であるreciveCompletionreceiveValueというコールバックメソッドが実行されます。また、①の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をコールしてみよう

FoundationXcode11から、`dataTaskPublisher(for: _)`が追加されました。こちらは従来あったdataTaskPublisherバージョンとなります。今回はネットで見つけたフリーの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エンジニア