Skip to content

Instantly share code, notes, and snippets.

@fumiyasac
Last active April 12, 2019 06:39
Show Gist options
  • Save fumiyasac/ec55537729e55300f9204b2d4b74ef27 to your computer and use it in GitHub Desktop.
Save fumiyasac/ec55537729e55300f9204b2d4b74ef27 to your computer and use it in GitHub Desktop.
RxSwiftでの実装練習の記録ノート(後編:DriverパターンとAPIへの通信を伴うMVVM構成のサンプル例) ref: https://qiita.com/fumiyasac@github/items/da762ea512484a8291a3
enum APIKey: String {
case foresquare_clientid = "自分のクライアントID"
case foresquare_clientsecret = "自分のシークレット"
}
target 'RxSwiftPracticeNote' do
use_frameworks!
# RxSwift使用時に必要なライブラリ
pod 'RxSwift'
pod 'RxCocoa'
・・・(省略)・・・
# 便利ライブラリ(Chapter2で使用)
pod 'RxAlamofire/RxCocoa'
pod 'ObjectMapper'
# 便利ライブラリ(Chapter3で使用)
pod 'SwiftyJSON'
pod 'FoursquareAPIClient'
pod 'SDWebImage'
end
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)
}
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)
}
}
/**
* リクエストで受け取った結果を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に変換する
}
}
/**
* 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"]
}
}
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()
}
}
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
}
}
}
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
}
}
//RxTableViewDataSourceTypeのメソッドを拡張して設定する
public func tableView(_ tableView: UITableView, observedEvent: Event<Element>) {
switch observedEvent {
case .next(let value):
self.venues = value
tableView.reloadData()
case .error(_):
()
case .completed:
()
}
}
self.venueSearchTableView.rx.items(dataSource: self.venueDataSource)
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