はじめに
今回はClean Swiftを用いた具体的な例について見ていきたいと思います。
題材として、下記のような要件を持つアプリを扱います。
アプリの要件
要件としては、下記の通りです。
- 現在地を中心にマップを表示する
- 検索ボタンをタップして、現在地周辺のレストラン情報を取得する
- マップに表示されたレストランのマーカをタップすると、レストラン情報の概要ウィンドウが表示される
フォルダ構成
フォルダ構成は以下のようになっています。
※ 今回のアプリの名称を CleanFoodLogger
とします。
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
| CleanFoodLogger
├── Views
│ └── ViewController+Alert.swift
├── Models
│ ├── Restaurant.swift
│ └── CustomGMSMarker.swift
├── Workers
│ └── HotpepperWorker.swift
├── Services
│ └── HotpepperAPI.swift
├── Scenes
│ └── MapView
│ ├── View
│ │ ├── CustomInfoWindow.xib
│ │ └── CustomInfoWindow.swift
│ ├── MapViewController.swift
│ ├── MapInteractor.swift
│ ├── MapModels.swift
│ ├── MapPresenter.swift
│ ├── MapRouter.swift
│ └── MapWorker.swift
├── AppDelegate.swift
├── Main.storyboard
├── Assets.xcassets
└── key.plist
|
それぞれの構成の意味について説明します。
Views
Scene
に寄らない共通ビュー系を格納します。
ViewController+Alert.swift
UIViewController
を拡張する形で実装
- 共通アラート表示処理を実装
Models
Scene
に寄らないモデル系を格納します。
Restaurant.swift
- APIを通して取得したレストラン情報を格納するモデル
CustomGMSMarker
GMSMarker
を継承したカスタムクラス
- マーカにショップ情報を追加して持たせたモデル
Workers
Scene
に寄らない Clean Swift
で言うところの Worker
系を格納します。
※ 今回は、レストラン情報を取得するのにホットペッパーAPIを利用しています。
HotpepperWorker.swift
- ホットペッパーAPI管理マネージャに当たる
Services/HotpepperAPI.swift
を通じて取得したレストラン情報を扱う
Services
Scene
に寄らない管理マネージャ系の共通処理を扱います。
HotpepperAPI.swift
- ホットペッパーAPIを用いた具体的なロジックを実装
Scenes
ここは画面単位に Clean Swift
のテンプレートを当て込んだ構成になります。
今回は簡単なサンプルなので1画面しかありません。
よって、 Scenes
に格納しているのも MapView
1つになります。
MapView
の配下は Clean Swift
テンプレート一式になっています。
アプリの実装
続いて具体的なアプリの実装について見ていきます。
データフローとしては、下記の通りです。
ではまずは、共通系の処理から説明します。
Models
この後の Services
や Workers
にも出てくるので、まずは Models
から説明します。
Restaurant.swift
ホットペッパーAPIで取得したレストラン情報の一部を抜粋して格納するため、下記のような構成になっています。
今回は ==
で比較する処理を利用している箇所はありませんが、 Clean Store を参考にしたので、そのまま残しています。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| import Foundation
struct Restaurant: Equatable {
var id: String
var name: String
var category: String
var imageURL: String
var latitude: Double
var longitude: Double
}
func ==(lhs: Restaurant, rhs: Restaurant) -> Bool {
return lhs.id == rhs.id
&& lhs.name == rhs.name
&& lhs.category == rhs.category
&& lhs.imageURL == rhs.imageURL
&& lhs.latitude == rhs.latitude
&& lhs.longitude == rhs.longitude
}
|
CustomGMSMarker.swift
マーカをタップした際に、ショップ情報を表示する InfoWindow
を表示する要件があるため、 GMSMarker
クラスを継承して、ショップ情報を追加しています。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| import Foundation
import GoogleMaps
class CustomGMSMarker: GMSMarker {
public var id: String!
public var name: String!
public var category: String!
public var imageURL: String!
/// 初期化
override init() {
super.init()
}
}
|
Workers
Services
の説明に移る前に、Protocol
を提供している Workers
について見ていきます。
HotpepperWorker.swift
これは、
- API処理を扱うための
Worker
クラスを提供
- 外部へのインターフェースを定義したプロトコルの定義
の役目を果たしています。
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
| import Foundation
// MARK: - Hotpepper worker
class HotpepperWorker {
var hotpepper: HotpepperProtocol
init(hotpepper: HotpepperProtocol) {
self.hotpepper = hotpepper
}
func fetchRestaurants(latitude: Double, longitude: Double, completionHandler: @escaping ([Restaurant], HotpepperError?) -> Void) {
hotpepper.fetchRestaurants(latitude: latitude, longitude: longitude) { (restaurants, error) in
DispatchQueue.main.async {
completionHandler(restaurants, error)
}
}
}
}
// MARK: Hotpepper API
protocol HotpepperProtocol {
// MARK: CRUD operations
func fetchRestaurants(latitude: Double, longitude: Double, completionHandler: @escaping ([Restaurant], HotpepperError?) -> Void)
}
// MARK: - CRUD operation errors
enum HotpepperError: Equatable, Error {
case CannotFetch(String)
}
func ==(lhs: HotpepperError, rhs: HotpepperError) -> Bool {
switch (lhs, rhs) {
case (.CannotFetch(let a), .CannotFetch(let b)) where a == b: return true
default: return false
}
}
|
Services
Worker
で定義された外部へのインターフェースの挙動を実装したクラスに当たります。
HotpepperAPI.swift
今回は HotpepperProtocol
を継承した HotpepperAPI
内で実際にホットペッパーAPIを叩いて処理しています。
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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
| import Foundation
import Alamofire
import SwiftyJSON
class HotpepperAPI: HotpepperProtocol {
/// API Key
private var apiKey: String = String()
/// Geocoding APIのベースURL
private let baseURL: String = "https://webservice.recruit.co.jp/hotpepper/gourmet/v1/"
/// 初期化処理
// key.plistに定義したAPIKeyを取得してセット
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
}
}
}
}
// HotpepperWorker.swift内のHotpepperProtocolインターフェースの具体的な処理
func fetchRestaurants(latitude: Double, longitude: Double, completionHandler: @escaping ([Restaurant], HotpepperError?) -> Void) {
let parameters = ["key": self.apiKey, "format": "json", "lat": latitude, "lng": longitude, "range": 3] as [String : Any]
Alamofire.SessionManager.default.requestWithoutCache(baseURL, method: .get, parameters: parameters, encoding: URLEncoding.default, headers: nil).responseJSON { response in
var restaurants: [Restaurant] = [Restaurant]()
if response.result.isFailure {
let defaultErrorMessage = "レストラン情報を取得できませんでした。"
completionHandler([], HotpepperError.CannotFetch(response.result.error?.localizedDescription ?? defaultErrorMessage))
return
}
let json = JSON(response.result.value as Any)
guard let shops = json["results"]["shop"].array else {
let defaultErrorMessage = "レストラン情報を取得できませんでした。"
completionHandler([], HotpepperError.CannotFetch(response.result.error?.localizedDescription ?? defaultErrorMessage))
return
}
for shop in shops {
let id = shop["id"].string ?? "ID不明"
let name = shop["name"].string ?? "ショップ名不明"
let category = shop["genre"]["name"].string ?? "カテゴリ不明"
let imageURL = shop["photo"]["mobile"]["l"].string ?? ""
let latitude = atof(shop["lat"].string ?? "0")
let longitude = atof(shop["lng"].string ?? "0")
let restaurant = Restaurant(id: id, name: name, category: category, imageURL: imageURL, latitude: latitude, longitude: longitude)
restaurants.append(restaurant)
}
completionHandler(restaurants, nil)
}
}
}
// Clean Swiftとは無関係ですが、キャッシュなしリクエストをAlamofireを通して実装する処理
extension Alamofire.SessionManager {
@discardableResult
open func requestWithoutCache(
_ url: URLConvertible,
method: HTTPMethod = .get,
parameters: Parameters? = nil,
encoding: ParameterEncoding = URLEncoding.default,
headers: HTTPHeaders? = nil)
-> DataRequest {
do {
var urlRequest = try URLRequest(url: url, method: method, headers: headers)
urlRequest.cachePolicy = .reloadIgnoringCacheData // <<== Cache disabled
let encodedURLRequest = try encoding.encode(urlRequest, with: parameters)
return request(encodedURLRequest)
} catch {
print(error)
return request(URLRequest(url: URL(string: "http://example.com/wrong_request")!))
}
}
}
|
MapView
ここから重要な Clean Swift
を使った実装に入っていきます。
今回は共通 Worker
のみ利用しているため、 MapWorker.swift
は省略します。
また、画面遷移の処理もないため、 MapRouter.swift
についても省略します。
MapModels.swift
Clean Swiftで今回扱う 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
| import UIKit
enum Map {
// MARK: Init mapView
enum Init {
struct Request {
var latitude: Double
var longitude: Double
}
struct Response {
var latitude: Double
var longitude: Double
}
struct ViewModel {
var latitude: Double
var longitude: Double
var zoomLevel: Float
}
}
// MARK: Search restaurants
enum Search {
struct Request {
var latitude: Double
var longitude: Double
}
struct Response {
var restaurants: [Restaurant]
}
struct ViewModel {
var restaurants: [Restaurant]
}
}
}
|
Init
- マップ画面の初期描画時に「現在地を中心としたマップ位置に移動する」際に利用
Search
MapInteractor.swift
ViewController
から受け取った依頼を Worker
を経由して取得した値を Presenter
に渡します。
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
| import UIKit
protocol MapBusinessLogic {
func initMapView(request: Map.Init.Request)
func searchRestaurants(request: Map.Search.Request)
}
protocol MapDataStore {
}
class MapInteractor: MapBusinessLogic, MapDataStore {
var presenter: MapPresentationLogic?
var worker = HotpepperWorker(hotpepper: HotpepperAPI())
private var initView: Bool = false
// MARK: Init mapView
func initMapView(request: Map.Init.Request) {
if !initView {
let response = Map.Init.Response(latitude: request.latitude, longitude: request.longitude)
presenter?.presentInitMapView(response: response)
initView = true
}
}
// MARK: Search restaurants
func searchRestaurants(request: Map.Search.Request) {
worker.fetchRestaurants(latitude: request.latitude, longitude: request.longitude) { (restaurants, error) in
let response = Map.Search.Response(restaurants: restaurants)
self.presenter?.presentSearchedRestaurants(response: response)
}
}
}
|
initMapView
- APIやローカルDBを利用する必要がないため、
Worker
は利用していません
- 初回だけ、実行すれば良い処理なので内部で定義した
initView
でハンドリングしています
searchRestaurants
- ホットペッパーAPIによるデータ取得は
HotpepperWorker
に任せています
HotpepperWorker
を介して取得したデータを Map.Search.Response
形式に変換
- それを
MapPresenter
に渡しています
MapPresenter.swift
Interactor
から受け取ったデータを表示形式に変換して、ViewController
に描画指示を出します。
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
| import UIKit
protocol MapPresentationLogic {
func presentInitMapView(response: Map.Init.Response)
func presentSearchedRestaurants(response: Map.Search.Response)
}
class MapPresenter: MapPresentationLogic {
weak var viewController: MapDisplayLogic?
private let zoomLevel: Float = 16.0
// MARK: Present init mapView
func presentInitMapView(response: Map.Init.Response) {
let latitude = response.latitude
let longitude = response.longitude
let viewModel = Map.Init.ViewModel(latitude: latitude, longitude: longitude, zoomLevel: zoomLevel)
viewController?.displayInitMap(viewModel: viewModel)
}
// MARK: Present searched restaurants
func presentSearchedRestaurants(response: Map.Search.Response) {
let restaurants = response.restaurants
let viewModel = Map.Search.ViewModel(restaurants: restaurants)
if restaurants.count > 0 {
viewController?.displaySearchedSuccess(viewModel: viewModel)
return
}
viewController?.displaySearchedFailure(viewModel: viewModel)
}
}
|
presentInitMapView
- 「現在地を中心としたマップ位置に移動する」ために
ViewController
に緯度/経度/ズームレベルを渡します
presentSearchedRestaurants
- レストラン情報の有無で
ViewController
に出す指示を変えています
- 今回はシンプルな実装のため、
Map.Search.Response
から Map.Search.ViewModel
に変換はありません
MapViewController.swift
最後に ViewController
について説明します。
下記で
Interactor
に具体的な処理内容(表示ロジック)を問い合わせる
Presenter
からの指示を受けて、最適な View
を描画する
を実現しています。
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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
| import UIKit
import GoogleMaps
protocol MapDisplayLogic: class {
func displayInitMap(viewModel: Map.Init.ViewModel)
func displaySearchedSuccess(viewModel: Map.Search.ViewModel)
func displaySearchedFailure(viewModel: Map.Search.ViewModel)
}
class MapViewController: UIViewController, MapDisplayLogic {
var interactor: MapBusinessLogic?
var router: (NSObjectProtocol & MapRoutingLogic & MapDataPassing)?
@IBOutlet weak var mapView: GMSMapView!
var locationManager: CLLocationManager?
// MARK: Object lifecycle
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
setup()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}
// MARK: Setup
private func setup() {
let viewController = self
let interactor = MapInteractor()
let presenter = MapPresenter()
let router = MapRouter()
viewController.interactor = interactor
viewController.router = router
interactor.presenter = presenter
presenter.viewController = viewController
router.viewController = viewController
router.dataStore = interactor
}
// MARK: Routing
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let scene = segue.identifier {
let selector = NSSelectorFromString("routeTo\(scene)WithSegue:")
if let router = router, router.responds(to: selector) {
router.perform(selector, with: segue)
}
}
}
// MARK: View lifecycle
override func viewDidLoad() {
super.viewDidLoad()
configureMapView()
configureLocationManager()
}
// MARK: Configuration
func configureMapView() {
// GoogleMapの初期化
mapView.isMyLocationEnabled = true
mapView.mapType = GMSMapViewType.normal
mapView.settings.compassButton = true
mapView.settings.myLocationButton = true
mapView.settings.compassButton = true
mapView.delegate = self
}
func configureLocationManager() {
// 位置情報関連の初期化
self.locationManager = CLLocationManager()
self.locationManager?.desiredAccuracy = kCLLocationAccuracyBest
self.locationManager?.requestWhenInUseAuthorization()
self.locationManager?.startUpdatingLocation()
self.locationManager?.delegate = self
}
// MARK: Init mapView
func displayInitMap(viewModel: Map.Init.ViewModel) {
// 初期描画時のマップ中心位置の移動
let coordinate = CLLocationCoordinate2D(latitude: viewModel.latitude, longitude: viewModel.longitude)
let camera = GMSCameraPosition.camera(withTarget: coordinate, zoom: viewModel.zoomLevel)
mapView.camera = camera
}
// MARK: Search restaurants
func searchRestaurants() {
guard let latitude = mapView.myLocation?.coordinate.latitude, let longitude = mapView.myLocation?.coordinate.longitude else {
return
}
let request = Map.Search.Request(latitude: latitude, longitude: longitude)
interactor?.searchRestaurants(request: request)
}
func displaySearchedSuccess(viewModel: Map.Search.ViewModel) {
let restaurants = viewModel.restaurants
for restaurant in restaurants {
putMarker(restaurant: restaurant)
}
}
func displaySearchedFailure(viewModel: Map.Search.ViewModel) {
showAlert(title: "確認", message: "周辺にレストランは見つかりませんでした。") {
}
}
@IBAction func tappedSearchButton(_ sender: Any) {
searchRestaurants()
}
// MARK: Other
private func putMarker(restaurant: Restaurant) {
let marker = CustomGMSMarker()
marker.id = restaurant.id
marker.name = restaurant.name
marker.category = restaurant.category
marker.imageURL = restaurant.imageURL
marker.position = CLLocationCoordinate2D(latitude: restaurant.latitude, longitude: restaurant.longitude)
marker.icon = UIImage(named: "RestaurantIcon")
marker.appearAnimation = GMSMarkerAnimation.pop
marker.map = mapView
}
}
extension MapViewController: CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
// マップの初期描画
if let coordinate = locations.last?.coordinate {
let request = Map.Init.Request(latitude: coordinate.latitude, longitude: coordinate.longitude)
interactor?.initMapView(request: request)
}
}
}
extension MapViewController: GMSMapViewDelegate {
func mapView(_ mapView: GMSMapView, markerInfoWindow marker: GMSMarker) -> UIView? {
guard let cMarker = marker as? CustomGMSMarker else {
return nil
}
cMarker.tracksInfoWindowChanges = true
let view = CustomInfoWindow(frame: CGRect(x: 0, y: 0, width: 250, height: 265))
view.setup(name: cMarker.name, category: cMarker.category, imageURL: cMarker.imageURL)
return view
}
}
|
configureMapView
/ configureLocationManager
- 最低限必要な
ViewController
上での設定処理
- ここで
startUpdatingLocation
を実行することで現在地の更新を開始
didUpdateLocations
→ displayInitMap
- 初期起動時は
mapView.myLocation
から現在地の即時取得ができないため、startUpdatingLocation
を利用しています
- 現在地が取得できたタイミングで
didUpdateLocations
を通るため、 Interactor
にマップ中心位置の移動を依頼しています
- 位置を移動させるか否かは
ViewController
では判断しません
tappedSearchButton
→ searchRestaurants
→ displaySearchedSuccess
/ displaySearchedFailure
- 検索ボタンをタップした時に
searchRestaurants
を呼び出しています
Interactor
にレストラン情報の検索を依頼しています
Presenter
から displaySearchedSuccess
または displaySearchedFailure
の描画指示を受けて描画します
Presenter
から返却された Map.Search.ViewModel
を利用して putMarker
を実行することでマップにマーカをプロットします。
displaySearchedFailure
では、失敗したことをアラート表示することで表現しています
GMSMapViewDelegate
まとめ
さて如何でしたでしょうか?
次回は今回扱ったサンプルを拡張する形で実装し、説明していきたいと思います。
因みに、本記事のソースは CleanFoodLoggerにて公開しています。
※ バージョン 1.0
を参照してください。
ということで本日はここまで。