はじめに
サイバー・バズの@Hakotaです。
弊社の@yuinchirnが執筆した
自社サービス採用へ。本気で作るReact+Reduxのプロジェクト構成で紹介した
プロジェクトにメンバーとして参加しています。
巷ではiOSアプリをRxSwiftで実装するのが流行りのようですが
スマホアプリにもReduxの思想を持ち込むことでよりスケーラブルでテスタブルな実装が可能です。
そもそもReduxって何?
僕もReact.js、Redux、Javascript、ES6がわからない状態で開発に入ったので苦労しました。
わからないなりにスライドにまとめてみたので、参考がてら見てもらえると雰囲気は掴んでもらえるかと思います。
ざっくり説明
-
AcitonCreator
適切なActionを生成する。 -
Aciton
トリガー兼Stateに持たせたい情報のオブジェクト -
Middleware
Reducerの実行前後に処理を追加するための仕組みです -
Reducer
Actionの値をもとに、Stateを更新する -
Store
アプリケーションのState(状態)を管理
作ったもの
Qiitaクライアントアプリ**『Qiitenus』**をサンプルとして作りました。
随時更新してきます。
環境
- Xcode8
- Swift 3.0
- carthage 0.17.2
ライブラリ
github "ReSwift/ReSwift" "2.1.0"
github "onevcat/Kingfisher" "3.0.1"
github "Hearst-DD/ObjectMapper" "2.0.0"
github "Alamofire/Alamofire" "4.0.0"
github "ReactKit/SwiftTask" "swift/3.0"
github "SVProgressHUD/SVProgressHUD"
実装
Qiitaの新着投稿を取得する実装部分をつかって解説をしたいと思います。
全体構成
├── project
│ ├── View
│ ├── API (各種Requestを追加していきます)
│ ├── Controller
│ ├── Extentions
│ ├── Model (ObjectMapperを使ったモデル群)
│ ├── ReSwift
│ │ ├── Actions (各種Actionを追加していきます)
│ │ ├── Middleware (各種Middleware)
│ │ ├── Reducers (各種Reducer)
│ │ ├── States (各種State)
│ │ ├── ConfigureStore.swift
│ │ └── RootReducers.swift
│ │
│ └── AppDelegate.swift
Model
新着投稿のフィードをテーブルに表示するため記事用のモデルを作ります。
import ObjectMapper
struct Article {
var id: String?
var isPrivate: Bool?
var title: String?
var body: String?
var renderedBody: String?
var url: String?
var tags: [Tag]?
var user: User?
var coEditing: Bool?
var createdAt: String?
var updatedAt: String?
}
extension Article: Equatable {}
func ==(lhs: Article, rhs: Article) -> Bool {
return lhs.id == rhs.id
}
extension Article: Mappable {
init?(map: Map) {
// mapping(map)
}
mutating func mapping(map: Map) {
id <- map["id"]
isPrivate <- map["private"]
title <- map["title"]
body <- map["body"]
renderedBody <- map["rendered_body"]
url <- map["url"]
tags <- map["tags"]
user <- map["user"]
coEditing <- map["coediting"]
createdAt <- map["created_at"]
updatedAt <- map["updated_at"]
}
}
import ObjectMapper
struct Tag {
var name: String?
var versions: [String]?
}
extension Tag: Mappable {
init?(map: Map) {}
mutating func mapping(map: Map) {
name <- map["name"]
versions <- map["versions"]
}
}
import ObjectMapper
struct User {
var permanentId: String?
var id: String?
var name: String?
var location: String?
var profileImageUrl: String?
var twitterScreenName: String?
var facebookId: String?
var linkedInId: String?
var githubAccountName: String?
var itemsCount: Int?
var followeesCount: Int?
var followersCount: Int?
var organization: String?
var websiteUrl: String?
var description: String?
}
extension User: Mappable {
init?(map: Map) {}
mutating func mapping(map: Map) {
permanentId <- map["permanent_id"]
id <- map["id"]
name <- map["name"]
location <- map["location"]
profileImageUrl <- map["profile_image_url"]
twitterScreenName <- map["twitter_screen_name"]
facebookId <- map["facebook_id"]
linkedInId <- map["linkedin_id"]
githubAccountName <- map["github_login_name"]
itemsCount <- map["items_count"]
followeesCount <- map["followees_count"]
followersCount <- map["followers_count"]
organization <- map["organization"]
websiteUrl <- map["website_url"]
description <- map["description"]
}
}
extension User {
func fetchUserName() -> String {
return name ?? ""
}
}
API
APIKitのようなAPIクライアントを実装していきます。
こちらを参考させていただきました。
Alamofire + SwiftTask + ObjectMapperでAPIKitのようなAPIClientを実装する
import UIKit
import ObjectMapper
import Alamofire
protocol QiitaRequestType {
associatedtype Response
var baseURL : URL { get }
var method : HTTPMethod { get }
var path : String { get }
var parameters : [String : AnyObject] { get }
var HTTPHeaderFields : [String : String] { get }
func responseFromObject(_ object: AnyObject, URLResponse: HTTPURLResponse) -> Response?
func errorFromObject(_ object: AnyObject, URLResponse: HTTPURLResponse) -> Error?
}
extension QiitaRequestType {
var baseURL: URL {
return URL(string: "https://qiita.com/api/v2")!
}
var HTTPHeaderFields : [String : String] {
return [:]
}
func errorFromObject(_ object: AnyObject, URLResponse: HTTPURLResponse) -> Error? {
return nil
}
}
extension QiitaRequestType where Response: Mappable {
func responseFromObject(_ object: AnyObject, URLResponse: HTTPURLResponse) -> Response? {
guard let model = Mapper<Response>().map(JSONObject: object) else {
return nil
}
return model
}
}
import UIKit
import ObjectMapper
import Alamofire
class FeedRequest {
/**
* 新着フィードを取得する
*/
struct GetNewFeed: QiitaRequestType {
var page : Int = 1
var perPage : Int = 10
typealias ResponseComponent = Article
typealias Response = [ResponseComponent]
var path: String {
return "/items"
}
var method: HTTPMethod {
return .get
}
var parameters : [String : AnyObject] {
return [
"page" : self.page as AnyObject,
"per_page" : self.perPage as AnyObject
]
}
var HTTPHeaderFields : [String : String] {
return [:]
}
func responseFromObject(_ object: AnyObject, URLResponse: HTTPURLResponse) -> Response? {
guard let model = Mapper<ResponseComponent>().mapArray(JSONObject: object) else {
return nil
}
return model
}
}
}
前述のスライドに登場したActionCreatorをAPIクライアントとして作成します。
import Alamofire
import SwiftTask
import ObjectMapper
public enum APIError : Error {
case connectionError(NSError)
case invalidResponse(AnyObject?)
case parseError(AnyObject?)
}
struct ActionCreator {
static func callApi<T : QiitaRequestType>(_ request : T) -> Task<Float, T.Response, APIError> {
let endPoint: String = request.baseURL.absoluteString+request.path
let params: [String : Any] = request.parameters
let headers: [String:String] = request.HTTPHeaderFields
let method: HTTPMethod = request.method
let encoding: ParameterEncoding = (method == .get) ? URLEncoding.default : JSONEncoding.default
let task = Task<Float, T.Response, APIError> { progress, fulfill, reject, configure in
let req = Alamofire.request(endPoint, method: method, parameters: params, encoding: encoding, headers: headers)
.validate { request, response, data in
return .success
}
.responseJSON { response in
debugPrint(response)
if let error = response.result.error {
reject(.connectionError(error as NSError))
return
}
if let object = response.result.value, let URLResponse = response.response {
guard let model: T.Response = request.responseFromObject(object as AnyObject, URLResponse: URLResponse) else {
reject(.parseError(object as AnyObject?))
return
}
fulfill(model)
}else {
reject(.invalidResponse(nil))
}
}
print(req)
print(req.debugDescription)
}
return task
}
}
Action
いよいよReSwiftの実装部分に入ります。
Feed用のActionを作ります。
APIからのResponseを必要とするActionには、responseを持たせてあげます。
Actionだけstructの名前を大文字スネークケースで記述してます。
import ReSwift
/**
* Feedを取得
*/
struct GET_NEW_FEED: Action {
var response: Array<Article>?
}
Middleware
今回使用した感じだと、特に必要性を感じなかったMiddlewareもとりあえず実装しました。
Requestの結果を元に更に異なったRequestを出したい場合に、こちらに次のRequestを書いておくとネストが深くなることがなく便利です。
import ReSwift
let FeedMiddleware: Middleware = { dispatch, getState in
return { next in
return { action in
switch action {
case is GET_NEW_FEED:
print("MMMMMMMMMMMMMM")
print("GET_NEW_FEED")
print("MMMMMMMMMMMMMM")
break
default:
break
}
return next(action)
}
}
}
State
Feed関連の状態を管理するStateを作ります。
ページャー用にpageNumberをもたせたり
refresh用にフラグをもたせたり
記事の配列をもたせたり…。
import ReSwift
struct FeedState: StateType {
var pageNumber: Int = 1
var articleList: [Article]?
var isRefresh: Bool = false
}
AppStateという名前でアプリのStateをつくります。
AppState内に各種Stateを追加し、AppStateを観測をすることで
各種Stateを取得できるようにします。
import ReSwift
struct AppState: StateType {
var feedState = FeedState()
}
Reducer
Reducerで先程のStateにもたせた、articleListに対して
GET_NEW_FEEDが持っているResponseを渡します。
import ReSwift
struct FeedReducer: Reducer {
typealias ReducerStateType = AppState
public func handleAction(action: Action, state: AppState?) -> AppState {
var state = state ?? AppState()
switch action {
case let act as GET_NEW_FEED :
print("RRRRRRRRRRRRRR")
print("GET_NEW_FEED")
print("RRRRRRRRRRRRRR")
state.feedState.articleList = act.response
break
default:
break
}
return state
}
}
Conf
import ReSwift
var rootReducers = CombinedReducer([
FeedReducer()
])
appStoreに対して、今まで設定してきた内容を渡してあげます。
※rootReducersにReducerを追加し忘れると、動作はするもののMiddlewareやReducerに入ってこず、Stateの値が変わらないので注意です。
import ReSwift
var appStore = Store(
reducer:rootReducers,
state: AppState(),
middleware: [
FeedMiddleware
])
Controller
ControllerでStateを観測し、Stateが持っている値を利用して実装していきます。
import UIKit
import ReSwift
import Kingfisher
class FeedViewController: UIViewController, StoreSubscriber {
// 注意 AppStateを選択すること
typealias StoreSubscriberStateType = AppState
@IBOutlet weak var feedTable: UITableView!
var feedList: [Article]?
override func viewDidLoad() {
super.viewDidLoad()
feedTable.delegate = self
feedTable.dataSource = self
loadContents()
}
override func viewWillAppear(_ animated: Bool) {
// appStoreの観測開始
appStore.subscribe(self)
}
override func viewWillDisappear(_ animated: Bool) {
// appStoreの観測を停止
appStore.unsubscribe(self)
}
/**
reactのライフサイクルにあるcomponentWillReceivePropsのようなもの
Actionが発行されて、stateが更新されると呼ばれる
- parameter state: 更新されたstate
*/
func newState(state: AppState) {
if state.feedState.articleList != nil {
feedList = state.feedState.articleList
feedTable.reloadData()
}
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
}
func loadContents() {
// ActionCreatorでRequestを指定し、結果を元にAction発行
ActionCreator.callApi(FeedRequest.GetNewFeed(page: 1, perPage: 10))
.success{ result in
// 新着のFeed情報のActionを発行
appStore.dispatch(GET_NEW_FEED(response: result))
}.failure{ error in
print("error:", error)
}
}
}
同ファイル内で、Extensionで分けてTableを管理します。
ViewにはCustomCellとして、ArticleCellを作成しました
import UIKit
class ArticleCell: UITableViewCell {
@IBOutlet weak var userNameLabel: UILabel!
@IBOutlet weak var profileImage: UIImageView!
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var tagLabel: UILabel!
override func awakeFromNib() {
super.awakeFromNib()
}
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
}
}
extension FeedViewController: UITableViewDelegate, UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return feedList != nil ? feedList!.count : 0
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return 160
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell: ArticleCell = tableView.dequeueReusableCell(withIdentifier: "ArticleCell", for: indexPath) as! ArticleCell
// feedListが値を持ってる場合
if feedList != nil {
let url = URL(string: feedList![(indexPath as NSIndexPath).row].user!.profileImageUrl!)
cell.profileImage.kf_setImage(with: url)
cell.userNameLabel.text = feedList![(indexPath as NSIndexPath).row].user!.id
cell.titleLabel.text = feedList![(indexPath as NSIndexPath).row].title
var tagValues: String! = ""
for tag in feedList![(indexPath as NSIndexPath).row].tags! {
tagValues.append(tag.name!)
tagValues.append(",")
}
cell.tagLabel.text = tagValues
}
return cell
}
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
return 0
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let vc = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "WebViewC") as! WebViewC
vc.urlStr = feedList![(indexPath as NSIndexPath).row].url
self.navigationController?.pushViewController(vc, animated: true)
}
}
以上で、フィードを取得することができました。
新着一覧 |
---|
まとめと展望
まとめ
今回は、ReSwiftを使ったReduxライクなアーキテクチャを利用した実装を試みました。
記述量は増えるもののStateを意識して実装を行うことで、新しい実装を行おうとした場合に、新規Actionを今の手順で追加していくだけなので新しく参入したメンバーなどにもプロジェクトのルール説明が行いやすいと思います。
また、Web側もAPIとフロントを切り離して開発しているので
スマホアプリを作る際に、APIを別立てすることなく再利用できる点も魅力だと思います。
展望
次回は、SwiftにおけるReactライクな実装であるalexdrone/Renderを用いて
JSXのようにViewの操作をまとめて記述したいと思っています。