Takahiro Octopress Blog

-1から始める情弱プログラミング

Bond, SwiftBondを使ってみよう!

| Comments

はじめに

さて、今回はiOSで MVVM アーキテクチャで重宝されているReactiveKit/Bond(SwiftBond)について勉強したいと思います。
趣味で個人iOSアプリを作成する分にはそこまで大規模アプリになることも少ないとは思うものの、仕事で大人数で1つのアプリを開発することは当然あることでしょう。
そんなときに備えて知識を向上させたいと思います。
(今まで MVC で済んできたこともあり、良い機会なので MVVM を勉強したいと思っています。)

MVVMとは

まず、 MVVM とはそもそも何なのでしょうか?
MVVM とは Model View ViewModel の略です。

Wikipediaを見ると、それぞれ

  • Model
    • そのアプリケーションが扱う領域のデータと手続き (ビジネスロジック)を表現する要素
    • データの格納に永続的な記憶の仕組み(データベース)やサーバ側との通信ロジックなど
  • View
    • ユーザに見せるためのアウトプット描画およびユーザ入力を受け取る(UIへの入力とUIからの出力を担当する)要素
    • データバインディング機構を通して自動的に描画
    • View そのものに複雑なロジックや状態を持たない
  • ViewModel
    • Viewを描画するための状態の保持と、Viewから受け取った入力を適切な形に変換してModelに伝達する役目を持つ

と書かれています。
他の方のブログ記事を見ても、上記からそう遠くはない印象です。

Bond, SwiftBondとは

Bond は昔は SwiftBond と呼ばれていたようですが、今はGitHub上でも ReactiveKit の一部として提供されているようです。
元々、 SwiftBondReactiveKit も開発者は同じなので、どこかのタイミングで取り込まれたんですかね…。
この BondMVVM で言うところのデータバインディング機構を実現するために利用します。

サンプルを作ってみよう

今回のサンプルで扱う機能は下記です。

  • Google Mapに現在地周辺のレストランをマッピングする
  • レストラン情報はホットペッパーAPIの周辺レストラン検索APIから取得する

続いてXcode上のフォルダ構成は下記にします。

1
2
3
4
5
6
7
8
9
SampleApp
├── Model
    └── HotpepperAPI.swift
├── ViewModel
    └── HotpepperAPIViewModel.swift
├── View
    └── ViewController.swift
├── AppDelegate.swift
└── Main.storyboard

また、今回は Google Maps SDK for iOS と ホットペッパーのAPIを利用します。
これらのAPIキーを公式案内を元に取得して、 Info.plist と同じ階層に作成した key.plist に追加します。

ホットペッパーAPIキー

さて下準備は済んだので、各ファイルの実装を見ていきましょう。

AppDelegate.swift

Google Maps SDK for iOS を利用するために AppDelegate.swift に下記処理を実装します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import UIKit
import GoogleMaps

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

  var window: UIWindow?

  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    // Override point for customization after application launch.

    if let path = Bundle.main.path(forResource: "key", ofType: "plist") {
      if let dic = NSDictionary(contentsOfFile: path) as? [String: Any] {
        if let apiKey = dic["googleMapsApiKey"] as? String {
          GMSServices.provideAPIKey(apiKey)
        }
      }
    }

    return true
  }
  ...
}

HotpepperAPI.swift

ホットペッパーのグルメサーチAPIを利用する Model ファイルです。
今回はサンプルということもあって、ほとんど複雑な処理はありません。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// HotpepperAPI.swift
import Foundation
import CoreLocation
import Alamofire
import SwiftyJSON

/**
 ホットペッパーAPI
 */
class HotpepperAPI {
  /// API Key
  private var apiKey: String = String()
  /// ホットペッパーAPIのベースURL
  private let baseURL: String = "https://webservice.recruit.co.jp/hotpepper/gourmet/v1/"

  /// 初期化処理
  init() {
    if let path = Bundle.main.path(forResource: "key", ofType: "plist") {
      if let dic = NSDictionary(contentsOfFile: path) as? [String: Any] {
        if let apiKey = dic["hotpepperApiKey"] as? String {
          self.apiKey = apiKey
        }
      }
    }
  }

  /**
   ホットペッパーグルメサーチAPI

   - parameter coordinate: 位置
   - parameter completion: レストラン情報を返却するcallback
   */
  func searchRestaurant(coordinate: CLLocationCoordinate2D, completion: @escaping ((JSON) -> Void)) {
    let parameters = ["key": self.apiKey, "format": "json", "lat": coordinate.latitude, "lng": coordinate.longitude, "range": 2] as [String : Any]
    Alamofire.request(baseURL, method: .get, parameters: parameters, encoding: URLEncoding.default, headers: nil).responseJSON { response in
      let json = JSON(response.result.value as Any)
      let result = json["results"]["shop"]

      completion(result)
    }
  }
}

HotpepperAPIViewModel.swift

ModelであるHotpepperAPI.swiftViewであるViewController.swiftを繋ぐViewModelファイルです。
Viewからの入力受付を想定してsearchRestaurantメソッドを用意しています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// HotpepperAPIViewModel.swift
import Foundation
import ReactiveKit
import Bond
import SwiftyJSON
import CoreLocation

/// 通信の各状態をEnumで表現
enum RequestState {
  case none
  case requesting
  case finish
  case error
}

/// HotpepperAPIのViewModelクラス
final class HotpepperAPIViewModel {

  var items: ObservableArray<JSON> = ObservableArray([])
  let requestState = Observable<RequestState>(.none)
  let hotpepperAPI = HotpepperAPI.init()

  var finishSearchRestaurant: Signal<[JSON]?, NoError> {
    return self.requestState.map({ (requestState) -> [JSON]? in
      if requestState == .finish {
        return self.items.array
      }
      return nil
    })
  }

  func searchRestaurant(coordinate: CLLocationCoordinate2D) {
    self.requestState.next(RequestState.requesting)
    hotpepperAPI.searchRestaurant(coordinate: coordinate, completion: { (result) in
      guard let resultArray = result.array else {
        return
      }
      self.items = ObservableArray(resultArray)
      self.requestState.next(RequestState.finish)
    })
  }
}

上記では、searchRestaurantの中で先程紹介したModelHotpepperAPIクラスにアクセスしてサーバ通信を委託しています。
また、返却された値を受け取ってrequestStateの状態を変えることが、finishSearchRestaurantの処理のトリガーになっています。
finishSearchRestaurantでは、RequestStateの状態がfinishになったときのみ正しい値を返却し、それ以外はnilを返却しています。

ViewController.swift

最後に View に当たる ViewController.swift について見ていきます。
iOSでファットになりがちな ViewControllerView の定義に則って実装していくというのがキーになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// ViewController.swift
import UIKit
import GoogleMaps
import SwiftyJSON
import RealmSwift

class ViewController: UIViewController {
  /// マップビュー
  @IBOutlet weak var mapView: GMSMapView!
  /// 検索ボタン
  @IBOutlet weak var searchButton: UIButton!
  /// 現在地
  internal var currentLocation: CLLocationCoordinate2D?
  /// ViewModel
  internal var hotpepperAPIVM = HotpepperAPIViewModel()

  override func viewDidLoad() {
    super.viewDidLoad()

    // データバインディング機構の設定処理
    self.setUpBind()
  }

  ...

  private func setUpBond() {
    // 検索ボタンをタップ(.touchUpInside)したときに呼び出される処理
    _ = self.searchButton.reactive.tap.observeNext { _ in
      self.hotpepperAPIVM.searchRestaurant(coordinate: self.currentLocation!)
    }
    // finishSearchRestaurantから値が返却されるときに呼び出される処理
    _ = self.hotpepperAPIVM.finishSearchRestaurant.ignoreNil().observeNext(with: { (searchShops) in
      for searchShop in searchShops {
        // Google Mapへのマッピング処理
        // 省略
      }
    })
  }
}

上記のように実装することで、データバインディング機構を View に実装することができます。
ポイントは上記ソース内コメントに書いた通りですが、筆者が苦戦したのは、

  • _ = から始めなかったためSwift3の静的解析で怒られた
  • observeNext 内で nil 判定してしまっていたが、 ignoreNil という便利なものがある

の2点です。

まとめ

さて如何でしたでしょうか?
筆者的にもまだまだ理解しきれていないところがあり、引き続き勉強する必要があると感じています。
特にエラーハンドリング周りでは ReactiveKit を利用することで、
うまく書けるようになるのではという期待感があるのでもう少し見ていきたいと思っています。
と言ったところで本日はここまで。

参考

Comments