PromiseKitを使って非同期処理プログラミングのつらみと卒業しよう(導入編)

概要

今のモバイルアプリの開発ではネットワーク通信の技術がほぼマストで必要になっていますね。ModelとViewを切り分けますがModelからViewにデータを渡すときにはクロージャーなりデリゲートなりを利用します。
今では一回のアクションで2つ以上のAPIを叩くことなんてザラにあると思います。そんな時に大抵クロージャーを採用して設計を行いますがAPIを2つ以上叩くときになるとクロージャーの中でさらにクロージャーを叩くコードになったりするわけです。コールバック地獄とも言いますしネスト地獄の要因にもなります。

そんなコールバック地獄のツラミを少しでも軽減させたいときに使えるのがPromiseという技術ですね。
iOSにもこのPromiseが使えるPromiseKitで提供がされています。

今回はそのPromiseKitの使い方についてのレクチャーの記事を翻訳しました。

Getting Started With PromiseKit

こちらの記事です。英文もなかなか読みやすいのでPromiseKitの使い方がわかってから原文を読むと英語の勉強にもなると思いますのでオススメです。

導入

非同期プログラミングは本当の苦痛であり、容易に乱雑なコードになる可能性があります。幸いなことに、iOSでPromises&PromiseKitを使用するより良い方法があります。

非同期プログラミングは、レモンの真の痛みになる可能性があります。あなたが非常に慎重にしていないと、それは簡単に巨大なdelegatesだったり、厄介なcompletionハンドラーであったり長い夜のデバッグコードになったりするのです。しかし、より良い方法があります:それがPromiseです。イベントに基づいて一連のアクションとしてコードを記述させることで、非同期性を犠牲にすることを約束します。 これは、特定の順序で発生しなければならないアクションに対して特に効果的です。このPromiseKitのチュートリアルでは、サードパーティのPromiseKitを使用して非同期コードを整理する方法を学びます。

通常、iOSプログラミングには多くのデリゲートとコールバックが必要です。 あなたは、これらの行に沿って多くのコードを見た可能性があります:

- Y manages X.
- Tell Y to get X.
- Y notifies its delegate when X is available.

Promiseはこの混乱をこのように単純化します:

When X is available, do Y.

楽しいと思いませんか?Promiseはまた、エラーと成功の処理を分けることができます。これにより、さまざまな条件に対応するきれいなコードを書くのが簡単になります。Webサービスへのログイン、認証されたSDK呼び出しの実行、画像の処理と表示など、複雑で多段階のワークフローに適しています。

多くの利用可能なソリューションと実装により、Promiseがより一般的になりつつあります。 このチュートリアルでは、PromiseKitと呼ばれる一般的なサードパーティのSwiftライブラリを使用して約束について学びます。

はじめに

ダウンロードキット

このチュートリアルのプロジェクトWeatherOrNotは、単純な現在の天気アプリです。天気APIとしてOpenWeatherMapを使用しています。このAPIにアクセスするためのパターンと概念を他のどのWebサービスにも翻訳できます。

まず、このチュートリアルの上部または下部にある「素材をダウンロード」ボタンを使用してプロジェクト素材をダウンロードします。

あなたのスタータープロジェクトにはすでにCocoaPodを使ってPromiseKitがバンドルされているため、自分でインストールする必要はありません。これまでにCocoaPodを使用しておらず、それについて学びたい場合は、チュートリアルを読むことができます。ただし、このチュートリアルではCocoaPodについての知識は必要ありません。

WeatherOrNot.xcworkspaceを開くと、プロジェクトが非常に簡単であることがわかります。 .swiftファイルが5つしかありません:

  • AppDelegate.swift:自動生成されたアプリケーションデリゲートファイル。
  • BrokenPromise.swift:スタータープロジェクトの一部をスタブするためのプレースホルダのPromise。
  • WeatherViewController.swift:すべてのユーザー操作を処理するために使用するMain ViewController。 これがPromiseのメインのconsumerになるでしょう。
  • LocationHelper.swiftCoreLocationのラッパーです。
  • WeatherHelper.swift:気象データプロバイダをラップするために使用された最終的なhelper。

The OpenWeatherMap API

気象データと言えば、WeatherOrNotOpenWeatherMapを使用して天気情報を取得します。ほとんどのサードパーティAPIと同様に、これにはサービスにアクセスするために開発者APIキーが必要です。ですが、心配しないでください。 このチュートリアルを完了するのに十分なほど豊富なフリープランがあります。

アプリのAPIキーを取得する必要があります。http://openweathermap.org/appidで取得できます。登録が完了したら、https://home.openweathermap.org/api_keysAPIキーを見つけることができます。

0_api_key-1.png

APIキーをコピーしてWeatherHelper.swiftの上部にあるappID定数に貼り付けます。

試してみましょう。

アプリをビルドして実行します。すべてがうまくいけば、アテネの現在の天気を見るはずです。

まあ、多分...アプリには実際にバグがあります(あなたはすぐにそれを修正します!)ので、UIは少し遅く表示されるかもしれません。

1_build_and_run.png

Understanding Promises

あなたはすでに日常生活の中で「Promise」が何であるかを知っています。 たとえば、このチュートリアルを完了すると冷たいドリンクをお約束することができます。このステートメントには、アクションが完了すると(「このチュートリアルを終了すると」)、将来行われるアクション(「冷たい飲み物をお持ちください」)が含まれています。 Promiseを使用したプログラミングは、あるデータが利用可能であるときに将来何かが起こることが予想される点で似ています。

Promiseは、非同期性を管理することです。コールバックやdelegateなどの伝統的な方法とは異なり、あなたは簡単に非同期アクションのシーケンスを表現するために一緒に約束をチェーンすることができます。また、Promiseは実行ライフサイクルを持つという点でoperationと似ているため、自由にキャンセルすることができます。

PromiseKitのPromiseを作成すると、実行する独自の非同期コードを提供します。非同期作業が完了すると、Promiseのthenブロックが実行されるvaluePromiseを実行します。そのブロックから別のPromiseを返すと、それも同様に実行され、独自のvalueなどで実行されます。途中でエラーが発生した場合は、Optionalのキャッチblockが代わりに実行されます。

たとえば、PromiseKitのPromiseを言い換えると、上記で説明したチュートリアルが終わった時のPromiseは次のようになります。

sample.swift
doThisTutorial()
  .then { haveAColdOne() }
  .catch { postToForum(error) }

What PromiseKit… Promises

PromiseKitはpromiseを迅速に実行します。それは唯一のものではありませんが、最も人気のあるものの1つです。PromiseKitには、promiseを構築するためのブロックベースの構造を提供するだけでなく、多くの一般的なiOS SDKクラスのラッパーや簡単なエラー処理が含まれています。

アクションのpromiseを見るには、BrokenPromise.swiftの関数を見てください:

BrokenPromise.swift
func brokenPromise<T>(method: String = #function) -> Promise<T> {
  return Promise<T>() { seal in
    let err = NSError(
      domain: "WeatherOrNot", 
      code: 0, 
      userInfo: [NSLocalizedDescriptionKey: "'\(method)' not yet implemented."])
    seal.reject(err)
  }
}

これにより、PromiseKitによって提供されるプライマリクラスである新しいジェネリックPromiseが返されます。そのコンストラクタは、3つの可能な結果のうちの1つをサポートする1つのパラメータsealを持つ単純な実行ブロックをとります。

  • seal.fulfill: 希望の値が準備できたら、promiseを果たす。
  • seal.reject: エラーが発生した場合、それを拒否します。
  • seal.resolve: エラーまたは値のいずれかを使用してpromiseを解決します。 ある意味では、fulfillrejectは、 rejectのまわりでヘルパーにあらかじめ分かっています。

brokenPromise(method :)の場合、コードは常にエラーを返します。 このヘルパー関数を使用して、アプリケーションの完成時に作業がまだあることを示します。

Making Promises

リモートサーバーへのアクセスは、最も一般的な非同期処理タスクの1つであり、簡単なネットワークコールが開始するのに適しています。

WeatherHelper.swiftgetWeatherTheOldFashionedWay(coordinate:completion :)を見てください。 このメソッドは、緯度、経度、およびcompletionハンドラーを指定して天気データを取得します。

ただし、completionハンドラーは、successとfailureの両方で実行されます。 エラー処理とその中で成功するためのコードが必要になるため、結果的にクロージャが複雑になります。

ほとんどの場合、アプリはバックグラウンドスレッドでデータタスクの完了を処理するため、バックグラウンドでUIが(偶然に)更新されます!

Promiseはこの事態を助けますか?もちろん!

getWeatherTheOldFashionedWay(coordinate:completion :)の後に次のように追加します:

BrokenPromise.swift
func getWeather(
  atLatitude latitude: Double, 
  longitude: Double
) -> Promise<WeatherInfo> {
  return Promise { seal in
    let urlString = "http://api.openweathermap.org/data/2.5/weather?" +
      "lat=\(latitude)&lon=\(longitude)&appid=\(appID)"
    let url = URL(string: urlString)!

    URLSession.shared.dataTask(with: url) { data, _, error in
      guard let data = data,
            let result = try? JSONDecoder().decode(WeatherInfo.self, from: data) else {
        let genericError = NSError(
          domain: "PromiseKitTutorial",
          code: 0,
          userInfo: [NSLocalizedDescriptionKey: "Unknown error"])
        seal.reject(error ?? genericError)
        return
      }

      seal.fulfill(result)
    }.resume()
  }
}

このメソッドはgetWeatherTheOldFashionedWayのようにURLSessionも使用しますが、completionハンドラを使用する代わりに、ネットワークをPromiseでラップします。

dataTaskのcompletionハンドラで、成功したJSONレスポンスを返す場合、それをWeatherInfoにデコードして、あなたのpromiseをfulfill(果たす)します。

ネットワーク要求のエラーが返ってきた場合は、そのエラーのpromiseをrejedt(拒否)し、他のタイプの障害が発生した場合には一般的なエラーに戻ります。

次に、WeatherViewController.swiftで、handleLocation(city:state:coordinate :)を次のように置き換えます。

WeatherViewController.swift
private func handleLocation(
  city: String?,
  state: String?,
  coordinate: CLLocationCoordinate2D
) {
  if let city = city,
     let state = state {
    self.placeLabel.text = "\(city), \(state)"
  }

  weatherAPI.getWeather(
    atLatitude: coordinate.latitude,
    longitude: coordinate.longitude)
  .done { [weak self] weatherInfo in
    self?.updateUI(with: weatherInfo)
  }
  .catch { [weak self] error in
    guard let self = self else { return }

    self.tempLabel.text = "--"
    self.conditionLabel.text = error.localizedDescription
    self.conditionLabel.textColor = errorColor
  }
}

いい感じですね!promiseを使用することは、doneしてクロージャcatchすることと同じくらい簡単です!

handleLocationのこの新しい実装は、前の実装よりも優れています。 最初に、完了処理は2つの読み易いクロージャに分割されました。成功のためにdone(実行)され、エラーがcatch(検出)されます。 第2に、PromiseKitはデフォルトでメインスレッドでこれらのクロージャを実行するため、バックグラウンドスレッドで誤ってUIを更新することはありません。

Using PromiseKit Wrappers

これはかなり良いですが、PromiseKitはもっとうまくいくでしょう。 Promiseのコードに加えて、PromiseKitには、約束事として表現できる一般的なiOS SDKメソッドの拡張も含まれています。 たとえば、URLSessionのデータタスクメソッドは、completionブロックを使用する代わりにpromiseを返します。

WeatherHelper.swiftでは、新しいgetWeather(atLatitude:longitude :)を次のコードに置き換えます。

WeatherHelper.swift
func getWeather(
  atLatitude latitude: Double, 
  longitude: Double
) -> Promise<WeatherInfo> {
  let urlString = "http://api.openweathermap.org/data/2.5/weather?lat=" +
    "\(latitude)&lon=\(longitude)&appid=\(appID)"
  let url = URL(string: urlString)!

  return firstly {
    URLSession.shared.dataTask(.promise, with: url)
  }.compactMap {
    return try JSONDecoder().decode(WeatherInfo.self, from: $0.data)
  }
}

PromiseKitラッパーの使い方は簡単だと思いませんか?よりきれいですよね!さらに深掘りしていきましょう:

PromiseKitは、URL要求を表す特別なPromiseを返すURLSession.dataTask(_:with :)の新しいオーバーロードを提供します。 data promiseは自動的に基礎となるデータタスクを開始することに注意してください。

次に、PromiseKitのcompactMapを連鎖させて、データをWeatherInfoオブジェクトとしてデコードし、クロージャから戻します。compactMapはこの結果をあなたのためにPromiseにラップするので、将来のpromiseに関連したメソッドを連鎖させることができます。

Adding Location

ネットワーキングが防弾処理されたので、ロケーション機能を見てみましょう。 あなたがアテネを訪れるのに十分な運がなければ、アプリは特に関連性の高いデータを提供していません。 デバイスの現在の場所を使用するようにコードを変更します。

WeatherViewController.swiftでは、updateWithCurrentLocation()を次のように置き換えます。

WeatherViewController.swift
private func updateWithCurrentLocation() {
  locationHelper.getLocation()
    .done { [weak self] placemark in // 1
      self?.handleLocation(placemark: placemark)
    }
    .catch { [weak self] error in // 2
      guard let self = self else { return }

      self.tempLabel.text = "--"
      self.placeLabel.text = "--"

      switch error {
      case is CLError where (error as? CLError)?.code == .denied:
        self.conditionLabel.text = "Enable Location Permissions in Settings"
        self.conditionLabel.textColor = UIColor.white
      default:
        self.conditionLabel.text = error.localizedDescription
        self.conditionLabel.textColor = errorColor
      }
    }
}

上記のコードを見てみましょう:

  1. Core Locationで作業するには、ヘルパー・クラスを使用します。すぐにそれを実装します。 getLocation()の結果は、現在の場所の目印を取得するというpromiseです。
  2. このcatchブロックは、単一のcatchブロック内のさまざまなエラーをどのように処理するかを示しています。 ここでは、単純なswitchを使用して、ユーザーがlocationの権限と他の種類のエラーを許可していないときに、別のメッセージを提供します。

次に、LocationHelper.swiftgetLocation()を次のように置き換えます。

LocationHelper.swift
func getLocation() -> Promise<CLPlacemark> {
// 1
  return CLLocationManager.requestLocation().lastValue.then { location in
// 2
    return self.coder.reverseGeocode(location: location).firstValue
  }
}

これはすでに説明した2つのPromiseKitの概念、つまりSDKのラッパーとチェーンを利用しています。
上のコードでは、

  1. CLLocationManager.requestLocation()は、現在地のpromiseを返します。
  2. 現在地の場所が利用可能になると、あなたのチェーンはそれをCLGeocoder.reverseGeocode(location :)に送ります。これは、逆符号化された場所を提供するPromiseも返します。

Promiseのおかげで、3行のコードで2つの異なる非同期アクションをリンクできます。呼び出し元のcatchブロックがすべてのエラーを処理するため、明示的なエラー処理は必要ありません。

ビルドして実行します。 場所のアクセス許可を受け入れると、アプリはあなたの(シミュレートされた)場所の現在の温度を表示します。 Voilà!

2_build_and_run_with_location.png

Searching for an Arbitrary Location

それはすべてうまくいっていますが、ユーザーが他の場所の温度を知りたい場合はどうなりますか?

WeatherViewController.swiftでは、textFieldShouldReturn(_ :)を次のように置き換えます(欠落しているメソッドについてのコンパイラエラーは無視してください)。

WeatherViewController.swift
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
  textField.resignFirstResponder()
  guard let text = textField.text else { return false }

  locationHelper.searchForPlacemark(text: text)
    .done { placemark in
      self.handleLocation(placemark: placemark)
    }
    .catch { _ in }

  return true
}

これは、他のすべてのpromiseと同じパターンを使用します。placemarkを見つけて、完了したらUIを更新します。

次に、getLocation()の下のLocationHelper.swiftに以下を追加します。

LocationHelper.swift
func searchForPlacemark(text: String) -> Promise<CLPlacemark> {
  return coder.geocode(text).firstValue
}

それは簡単です! PromiseKitにはすでにCLGeocoder拡張機能があり、pracemark付きpromiseを返すplacemarkを見つけることができます。

ビルドして実行します。今回は、検索フィールドの上部に都市名を入力し、Returnキーを押します。 これにより、その名前に最もよくマッチする天気を見つけるはずです。

3_build_and_run_search.png

Threading

今のところ、あなたは1つのことを当然のものとして捉えています。すべてのthenブロックがメインスレッド上で実行されます。 これは、View Controllerの作業のほとんどがUIを更新するため、優れた機能です。 ただし、ユーザーの操作に応答するのが遅くならないように、長期間実行されるタスクをバックグラウンドスレッドで処理するのが最善です。

OpenWeatherMapから天気アイコンを追加して、現在の気象条件を示します。 しかし、生のDataUIImageにデコードすることはとても重いな作業です。あなたはメインスレッドで実行したくないでしょう。

WeatherHelper.swiftに戻り、getWeather(atLatitude:longitude :)の直後に次のメソッドを追加します。

WeatherHelper.swift
func getIcon(named iconName: String) -> Promise<UIImage> {
  let urlString = "http://openweathermap.org/img/w/\(iconName).png"
  let url = URL(string: urlString)!

  return firstly {
    URLSession.shared.dataTask(.promise, with: url)
  }
  .then(on: DispatchQueue.global(qos: .background)) { urlResponse in
    Promise.value(UIImage(data: urlResponse.data)!)
  }
}

ここでは、ロードされたDataからonパラメータを使用してDispatchQueue(on:execute :)に指定することでバックグラウンドキューにUIImageを作ります。PromiseKitは、指定されたキューでthenブロックを実行します。

今、あなたのpromiseはバックグラウンドキューで実行されるので、呼び出し元はメインキューのUI更新を確認する必要があります。

WeatherViewController.swiftに戻り、handleLocation(city:state:coordinate :)内のgetWeather(atLatitude:longitude :)の呼び出しを次のように置き換えます。

WeatherViewController.swift
// 1
weatherAPI.getWeather(
  atLatitude: coordinate.latitude,
  longitude: coordinate.longitude)
.then { [weak self] weatherInfo -> Promise<UIImage> in
  guard let self = self else { return brokenPromise() }

  self.updateUI(with: weatherInfo)

// 2
  return self.weatherAPI.getIcon(named: weatherInfo.weather.first!.icon)
}
// 3
.done(on: DispatchQueue.main) { icon in
  self.iconImageView.image = icon
}
.catch { error in
  self.tempLabel.text = "--"
  self.conditionLabel.text = error.localizedDescription
  self.conditionLabel.textColor = errorColor
}

この呼び出しには3つの微妙な変更があります。

  1. まず、getWeather(atLatitude:経度:)のthenブロックを変更して、VoidではなくPromiseを返します。 これは、getWeatherのpromiseが完了すると、新しいpromiseを返すことを意味します。
  2. ちょうど追加されたgetIconメソッドを使用して、アイコンを得るための新しいpromiseを作成します。
  3. 新しいdoneクロージャーをチェーンに追加します。これは、getIconpromiseが完了したときにメイン・キューで実行されます。
注意: 実際にdoneしたブロックにDispatchQueue.mainを指定する必要はありません。 デフォルトでは、すべてがメインキューで実行されます。 そのことを強調するためにここに含まれています。

これにより、シーケンスの一連の実行ステップにpromiseを組み込むことができます。 1つのpromiseがfulfilledされた後、最後のdoneが完了するかエラーが発生し、代わりにcatchが実行されるまで、次の処理が実行されます。 ネスト化されたcompletionに対するこのアプローチの2つの大きな利点は次のとおりです。

  1. あなたは、単一のチェーンでpromiseを構成します。これは、読みやすく保守しやすいものです。それぞれのthen/doneブロックは、論理と状態が互いに出血しないように、独自のコンテキストを持っています。blockの列は、一段と深い字下げがなければ読みやすくなります。
  2. すべてのエラーを1つの場所で処理します。たとえば、ユーザーログインのような複雑なワークフローでは、1つのステップが失敗した場合に1回の再試行エラーダイアログが表示されます。

ビルドして実行します。 画像アイコンが読み込まれるはずです!

4_build_and_run_with_icon.png

次回に続きます。

参考単語帳

  • wrapper : ラッパー
  • delegate: デリゲート。デザインパターンで使われるもの
  • asynchronicity: 非同期性
  • execute: 実行する
  • fulfill: 果たす