Last active
April 12, 2019 06:39
-
-
Save fumiyasac/ec55537729e55300f9204b2d4b74ef27 to your computer and use it in GitHub Desktop.
RxSwiftでの実装練習の記録ノート(後編:DriverパターンとAPIへの通信を伴うMVVM構成のサンプル例) ref: https://qiita.com/fumiyasac@github/items/da762ea512484a8291a3
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
enum APIKey: String { | |
case foresquare_clientid = "自分のクライアントID" | |
case foresquare_clientsecret = "自分のシークレット" | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
target 'RxSwiftPracticeNote' do | |
use_frameworks! | |
# RxSwift使用時に必要なライブラリ | |
pod 'RxSwift' | |
pod 'RxCocoa' | |
・・・(省略)・・・ | |
# 便利ライブラリ(Chapter2で使用) | |
pod 'RxAlamofire/RxCocoa' | |
pod 'ObjectMapper' | |
# 便利ライブラリ(Chapter3で使用) | |
pod 'SwiftyJSON' | |
pod 'FoursquareAPIClient' | |
pod 'SDWebImage' | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
client.request(path: "venues/search", parameter: parameter) { | |
[weak self] data, error in | |
//データの取得と参照に関するチェックをする | |
guard let strongSelf = self, let data = data else { return } | |
//APIのJSONを解析する | |
let json = JSON(data: data) | |
let venues = strongSelf.parse(venuesJSON: json["response"]["venues"]) | |
//パースしてきたjsonの値を通知対象にする | |
//(参考)RxSwiftの動作を深く理解する | |
//http://qiita.com/k5n/items/643cc07e3973dd1fded4 | |
observer.on(.next(venues)) | |
observer.on(.completed) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
client.request(path: "venues/search", parameter: parameter) { | |
result in | |
switch result { | |
case .success(let data): | |
//APIのJSONを解析する | |
let json = try! JSON(data: data) | |
let venues = self.parse(venuesJSON: json["response"]["venues"]) | |
//パースしてきたjsonの値を通知対象にする | |
//(参考)RxSwiftの動作を深く理解する | |
//http://qiita.com/k5n/items/643cc07e3973dd1fded4 | |
observer.on(.next(venues)) | |
observer.on(.completed) | |
case .failure(let error): | |
observer.onError(error) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* リクエストで受け取った結果をDriverに変換するための部分 | |
* ViewModel層に該当する部分 | |
*/ | |
struct RepositoriesViewModel { | |
/** | |
* オブジェクトの初期化に合わせてプロパティの初期値を決定したいのでlazy varにする | |
* | |
* (参考)Swiftのlazyの使い所 | |
* http://blog.sclap.info/entry/swift-how-to-use-lazy | |
*/ | |
lazy var rx_repositories: Driver<[Repository]> = self.fetchRepositories() | |
//監視対象のメンバ変数 | |
fileprivate var repositoryName: Observable<String> | |
//監視対象の変数初期化処理(イニシャライザ) | |
init(withNameObservable nameObservable: Observable<String>) { | |
self.repositoryName = nameObservable | |
} | |
/** | |
* GithubAPIへアクセスしてデータを取得してViewController側のUI処理とバインドするためにDriverに変換をする処理 | |
* (※データ取得にはRxAlamofireを使用) | |
*/ | |
fileprivate func fetchRepositories() -> Driver<[Repository]> { | |
/** | |
* Observableな変数に対して、「.subscribeOn」→「.observeOn」→「.observeOn」...という形で数珠つなぎで処理を実行 | |
* 処理の終端まで無事にたどり着いた場合には、ObservableをDriverに変換して返却する | |
*/ | |
return repositoryName | |
//処理Phase1: 見た目に関する処理 | |
.subscribeOn(MainScheduler.instance) //メインスレッドで処理を実行する | |
.do(onNext: { response in | |
//ネットワークインジケータを表示状態にする | |
UIApplication.shared.isNetworkActivityIndicatorVisible = true | |
}) | |
//処理Phase2: 下記のAPI(GithubAPI)のエンドポイントへRxAlamofire経由でのアクセスをする | |
.observeOn(ConcurrentDispatchQueueScheduler(qos: .background)) //バックグラウンドスレッドで処理を実行する | |
.flatMapLatest { text in | |
//APIからデータを取得する | |
return RxAlamofire | |
.requestJSON(.get, "https://api.github.com/users/\(text)/repos") | |
.debug() | |
.catchError { error in | |
//エラー発生時の処理(この場合は値を持たせずにここで処理を止めてしまう) | |
return Observable.never() | |
} | |
} | |
//処理Phase3: ModelクラスとObjectMapperで定義した形のデータを作成する | |
.observeOn(ConcurrentDispatchQueueScheduler(qos: .background)) //バックグラウンドスレッドで処理を実行する | |
.map { (response, json) -> [Repository] in | |
//APIからレスポンスが取得できた場合にはModelクラスに定義した形のデータを返却する | |
if let repos = Mapper<Repository>().mapArray(JSONObject: json) { | |
return repos | |
} else { | |
return [] | |
} | |
} | |
//処理Phase4: データが受け取れた際の見た目に関する処理とDriver変換 | |
.observeOn(MainScheduler.instance) //メインスレッドで処理を実行する | |
.do(onNext: { response in | |
UIApplication.shared.isNetworkActivityIndicatorVisible = false //ネットワークインジケータを非表示状態にする | |
}) | |
.asDriver(onErrorJustReturn: []) //Driverに変換する | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* GithubのAPIより取得する項目を定義する(ObjectMapperを使用して表示したいものだけを抽出してマッピングする) | |
* Model層に該当する部分 | |
*/ | |
import ObjectMapper | |
class Repository: Mappable { | |
//表示する値を変数として定義 | |
var identifier: Int! | |
var html_url: String! | |
var name: String! | |
//イニシャライザ | |
required init?(map: Map) {} | |
//ObjectMapperを利用したデータのマッピング | |
func mapping(map: Map) { | |
identifier <- map["id"] | |
html_url <- map["html_url"] | |
name <- map["name"] | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import UIKit | |
import RxSwift | |
import RxCocoa | |
import ObjectMapper | |
import RxAlamofire | |
/* | |
【Chapter2】GithubのAPIを利用してuser名を検索してリポジトリ一覧をUITableViewに一覧表示をするプラクティス | |
このサンプルを作成する上での参考資料 | |
----------- | |
・通信+便利ライブラリを使用してにRxSwiftを使用した「View層 + Model層 + ViewModel層」のサンプル | |
----------- | |
【写したサンプルコード(一部だけカスタマイズ)】 | |
・解説(英語) | |
http://www.thedroidsonroids.com/blog/ios/rxswift-examples-4-multithreading/ | |
・リポジトリ ※他にもサンプルたくさんある | |
https://github.com/DroidsOnRoids/RxSwiftExamples/tree/master/Libraries%20Usage/RxAlamofireExample/RxAlamofireExample | |
(参考)Webアプリケーション開発者から見た、MVCとMVP、そしてMVVMの違い | |
http://qiita.com/shinkuFencer/items/f2651073fb71416b6cd7 | |
(参考)MVVM入門(objc.io #13 Architecture 日本語訳) | |
http://qiita.com/FuruyamaTakeshi/items/6c4404f1fd61e3fa4eb7 | |
【GithubのAPIについて】 | |
https://developer.github.com/guides/getting-started/ | |
*/ | |
class RepositoryListController: UIViewController { | |
//UIパーツの配置 | |
@IBOutlet weak var nameSearchBar: UISearchBar! | |
@IBOutlet weak var repositoryListTableView: UITableView! | |
@IBOutlet weak var tableViewBottomConstraint: NSLayoutConstraint! | |
//disposeBagの定義 | |
let disposeBag = DisposeBag() | |
//ViewModelのインスタンス格納用のメンバ変数 | |
var repositoriesViewModel: RepositoriesViewModel! | |
//検索ボックスの値変化を監視対象にする(テキストが空っぽの場合はデータ取得を行わない) | |
var rx_searchBarText: Observable<String> { | |
return nameSearchBar.rx.text | |
.filter { $0 != nil } | |
.map { $0! } | |
.filter { $0.characters.count > 0 } | |
.debounce(0.5, scheduler: MainScheduler.instance) //0.5秒のバッファを持たせる | |
.distinctUntilChanged() | |
} | |
override func viewDidLoad() { | |
super.viewDidLoad() | |
//RxSwiftでの処理に関する部分をまとめたメソッドを実行 | |
setupRx() | |
//RxSwiftを使用しない処理に関する部分をまとめたメソッド実行 | |
setupUI() | |
} | |
//ViewModelを経由してGithubの情報を取得してテーブルビューに検索結果を表示する | |
func setupRx() { | |
/** | |
* メンバ変数の初期化(検索バーでの入力値の更新をトリガーにしてViewModel側に設置した処理を行う) | |
* | |
* (フロー1) → 検索バーでの入力値の更新が「データ取得のトリガー」になるので、ViewModel側に定義したfetchRepositories()メソッドが実行される | |
* (フロー2) → fetchRepositories()メソッドが実行後は、ViewModel側に定義したメンバ変数rx_repositoriesに値が格納される | |
* | |
* 結果的に、githubのアカウント名でのインクリメンタルサーチのようになる | |
*/ | |
repositoriesViewModel = RepositoriesViewModel(withNameObservable: rx_searchBarText) | |
/** | |
*(UI表示に関する処理の流れの概要) | |
* | |
* リクエストをして結果が更新されるたびにDriverからはobserverに対して通知が行われ、 | |
* driveメソッドでバインドしている各UIの更新が働くようにしている。 | |
* | |
* (フロー1) → テーブルビューへの一覧表示 | |
* (フロー2) → 該当データが0件の場合のポップアップ表示 | |
*/ | |
//リクエストした結果の更新を元に表示に関する処理を行う(テーブルビューへのデータ一覧の表示処理) | |
repositoriesViewModel | |
.rx_repositories | |
.drive(repositoryListTableView.rx.items) { (tableView, i, repository) in | |
let cell = tableView.dequeueReusableCell(withIdentifier: "RepositoryCell", for: IndexPath(row: i, section: 0)) | |
cell.textLabel?.text = repository.name | |
cell.detailTextLabel?.text = repository.html_url | |
return cell | |
} | |
.addDisposableTo(disposeBag) | |
//リクエストした結果の更新を元に表示に関する処理を行う(取得したデータの件数に応じたエラーハンドリング処理) | |
repositoriesViewModel | |
.rx_repositories | |
.drive(onNext: { repositories in | |
//データ取得ができなかった場合だけ処理をする | |
if repositories.count == 0 { | |
let alert = UIAlertController(title: ":(", message: "No repositories for this user.", preferredStyle: .alert) | |
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) | |
//ポップアップを閉じる | |
if self.navigationController?.visibleViewController is UIAlertController != true { | |
self.present(alert, animated: true, completion: nil) | |
} | |
} | |
}) | |
.addDisposableTo(disposeBag) | |
} | |
//キーボードのイベント監視の設定 & テーブルビューに付与したGestureRecognizerに関する処理 | |
//この部分はRxSwiftの処理ではないので切り離して書かれている形? | |
func setupUI() { | |
/** | |
* 2017/01/14: 補足事項 | |
* | |
* Notification周りやGesture周りもRxでの記載が可能 | |
* | |
* (記載例) | |
* ---------- | |
* Notification: | |
* ---------- | |
* NotificationCenter.default.rx.notification(.UIKeyboardWillChangeFram) ... | |
* NotificationCenter.default.rx.notification(.UIKeyboardWillHide) ... | |
* | |
* ---------- | |
* Gesutre: | |
* ---------- | |
* let tap = UITapGestureRecognizer(target: self, action: #selector(tableTapped(_:))) | |
* let didTap = stap.rx.event ... | |
* | |
* → NotificationやGestureに関しても、このような記述をすることでObservableとして利用可能! | |
* | |
* (さらに参考になった資料)【RxSwift入門】普段使ってるこんなんもRxSwiftで書けるんよ | |
* http://qiita.com/ikemai/items/8d3efcc71ea9db340484 | |
* | |
* RxKeyboard: | |
* https://github.com/RxSwiftCommunity/RxKeyboard/blob/master/Sources/RxKeyboard.swift | |
*/ | |
//テーブルビューにGestureRecognizerを付与する | |
let tap = UITapGestureRecognizer(target: self, action: #selector(tableTapped(_:))) | |
repositoryListTableView.addGestureRecognizer(tap) | |
//キーボードのイベントを監視対象にする | |
//Case1. キーボードを開いた場合のイベント | |
NotificationCenter.default.addObserver( | |
self, | |
selector: #selector(keyboardWillShow(_:)), | |
name: NSNotification.Name.UIKeyboardWillShow, | |
object: nil) | |
//Case2. キーボードを閉じた場合のイベント | |
NotificationCenter.default.addObserver( | |
self, | |
selector: #selector(keyboardWillHide(_:)), | |
name: NSNotification.Name.UIKeyboardWillHide, | |
object: nil) | |
} | |
//キーボード表示時に発動されるメソッド | |
func keyboardWillShow(_ notification: Notification) { | |
//キーボードのサイズを取得する(英語のキーボードが基準になるので日本語のキーボードだと少し見切れてしまう) | |
guard let keyboardFrame = (notification.userInfo?[UIKeyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else { return } | |
//一覧表示用テーブルビューのAutoLayoutの制約を更新して高さをキーボード分だけ縮める | |
tableViewBottomConstraint.constant = keyboardFrame.height | |
UIView.animate(withDuration: 0.3, animations: { | |
self.view.updateConstraints() | |
}) | |
} | |
//キーボード非表示表示時に発動されるメソッド | |
func keyboardWillHide(_ notification: Notification) { | |
//一覧表示用テーブルビューのAutoLayoutの制約を更新して高さを元に戻す | |
tableViewBottomConstraint.constant = 0.0 | |
UIView.animate(withDuration: 0.3, animations: { | |
self.view.updateConstraints() | |
}) | |
} | |
//メモリ解放時にキーボードのイベント監視対象から除外する | |
deinit { | |
NotificationCenter.default.removeObserver(self) | |
} | |
//テーブルビューのセルタップ時に発動されるメソッド | |
func tableTapped(_ recognizer: UITapGestureRecognizer) { | |
//どのセルがタップされたかを探知する | |
let location = recognizer.location(in: repositoryListTableView) | |
let path = repositoryListTableView.indexPathForRow(at: location) | |
//キーボードが表示されているか否かで処理を分ける | |
if nameSearchBar.isFirstResponder { | |
//キーボードを閉じる | |
nameSearchBar.resignFirstResponder() | |
} else if let path = path { | |
//タップされたセルを中央位置に持ってくる | |
repositoryListTableView.selectRow(at: path, animated: true, scrollPosition: .middle) | |
} | |
} | |
override func didReceiveMemoryWarning() { | |
super.didReceiveMemoryWarning() | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import UIKit | |
import SwiftyJSON | |
//アイコンのサイズに関する定数 | |
//(参考)https://developer.foursquare.com/docs/responses/category | |
let kCategoryIconSize = 88 | |
/** | |
* ForesquareAPIから取得した情報に関する定義(Model層に該当) | |
* | |
* (参考)【iOS Swift入門 #255】独自クラスでログ出力(description)を実装する | |
* http://swift.swift-studying.com/entry/2015/09/14/090849 | |
*/ | |
struct Venue: CustomStringConvertible { | |
let venueId: String | |
let name: String | |
let address: String? | |
let latitude: Double? | |
let longitude: Double? | |
let state: String? | |
let city: String? | |
let categoryIconURL: URL? | |
//取得データの詳細に関する変数 | |
var description: String { | |
return "<venueId=\(venueId)" | |
+ ", name=\(name)" | |
+ ", address=\(address)" | |
+ ", latitude=\(latitude), longitude=\(longitude)" | |
+ ", state=\(state)" | |
+ ", city=\(city)" | |
+ ", categoryIconURL=\(categoryIconURL)>" | |
} | |
//イニシャライザ(取得したForesquareAPIからのレスポンスに対して必要なものを抽出する) | |
init(json: JSON) { | |
//ForesquareAPIからのレスポンスで主要情報を取得する(SWiftyJSONを使用) | |
self.venueId = json["id"].string ?? "" | |
self.name = json["name"].string ?? "" | |
self.address = json["location"]["address"].string | |
self.latitude = json["location"]["lat"].double | |
self.longitude = json["location"]["lng"].double | |
self.state = json["location"]["state"].string ?? "" | |
self.city = json["location"]["city"].string ?? "" | |
//ForesquareAPIからのレスポンスでカテゴリーを元にしてアイコンのURLを作成する(SWiftyJSONを使用) | |
if let categories = json["categories"].array, categories.count > 0 { | |
let iconPrefix = json["categories"][0]["icon"]["prefix"].string ?? "" | |
let iconSuffix = json["categories"][0]["icon"]["suffix"].string ?? "" | |
let iconUrlString = String(format: "%@%d%@", iconPrefix, kCategoryIconSize, iconSuffix) | |
self.categoryIconURL = URL(string: iconUrlString) | |
} | |
else { | |
self.categoryIconURL = nil | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Foundation | |
import RxSwift | |
import SwiftyJSON | |
import FoursquareAPIClient | |
//Foresquareのベニュー情報を取得用のクライアント部分(実際のデータ通信部分) | |
class VenuesAPIClient { | |
//クエリ文字列を元に検索を行う | |
func search(query: String = "") -> Observable<[Venue]> { | |
//Observable戻り値に対して | |
return Observable.create{ observer in | |
//APIクライアントへのアクセス用の設定 | |
let client = FoursquareAPIClient( | |
clientId: APIKey.foresquare_clientid.rawValue, | |
clientSecret: APIKey.foresquare_clientsecret.rawValue | |
) | |
//検索用のパラメータの設定(暫定的に東京メトロ新大塚駅にしています) | |
let parameter: [String : String] = [ | |
"ll": "35.7260747,139.72983", | |
"query": query | |
] | |
//クライアントへのアクセス | |
client.request(path: "venues/search", parameter: parameter) { | |
[weak self] data, error in | |
//データの取得と参照に関するチェックをする | |
guard let strongSelf = self, let data = data else { return } | |
//APIのJSONを解析する | |
let json = JSON(data: data) | |
let venues = strongSelf.parse(venuesJSON: json["response"]["venues"]) | |
//パースしてきたjsonの値を通知対象にする | |
//(参考)RxSwiftの動作を深く理解する | |
//http://qiita.com/k5n/items/643cc07e3973dd1fded4 | |
observer.on(.next(venues)) | |
observer.on(.completed) | |
} | |
//この取得処理を監視対象からはずすための処理(自信ない...) | |
return Disposables.create {} | |
} | |
} | |
//Venue.swift(Model層)で定義した形で取得した値を格納する | |
fileprivate func parse(venuesJSON: JSON) -> [Venue] { | |
var venues = [Venue]() | |
for (key: _, venueJSON: JSON) in venuesJSON { | |
venues.append(Venue(json: JSON)) | |
} | |
return venues | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//RxTableViewDataSourceTypeのメソッドを拡張して設定する | |
public func tableView(_ tableView: UITableView, observedEvent: Event<Element>) { | |
switch observedEvent { | |
case .next(let value): | |
self.venues = value | |
tableView.reloadData() | |
case .error(_): | |
() | |
case .completed: | |
() | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
self.venueSearchTableView.rx.items(dataSource: self.venueDataSource) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import UIKit | |
import RxSwift | |
import RxCocoa | |
import SwiftyJSON | |
import FoursquareAPIClient | |
class VenueViewModel { | |
private(set) var venues = BehaviorRelay<[Venue]>(value: []) | |
・・・(途中省略)・・・ | |
//APIクライアント経由で情報を取得する | |
func fetch(query: String = "") { | |
//APIクライアントのメソッドを実行する | |
client.search(query: query) | |
.subscribe { [weak self] result in | |
//結果取得ができた際には、APIクライアントの変数:venuesに結果の値を入れる | |
switch result { | |
case .next(let value): | |
self?.venues.accept(value) | |
case .error(let error): | |
print(error) | |
case .completed: | |
() | |
} | |
} | |
.disposed(by: disposeBag) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment