Xcode
reactjs
Swift
redux
RxSwift

RxSwiftを投げ出したあなたに贈る、ReduxライクなReSwiftのご提案

More than 1 year has passed since last update.


はじめに

サイバー・バズの@Hakotaです。

弊社の@yuinchirnが執筆した

自社サービス採用へ。本気で作るReact+Reduxのプロジェクト構成で紹介した

プロジェクトにメンバーとして参加しています。

巷ではiOSアプリをRxSwiftで実装するのが流行りのようですが

スマホアプリにもReduxの思想を持ち込むことでよりスケーラブルでテスタブルな実装が可能です。


そもそもReduxって何?

僕もReact.js、Redux、Javascript、ES6がわからない状態で開発に入ったので苦労しました。

わからないなりにスライドにまとめてみたので、参考がてら見てもらえると雰囲気は掴んでもらえるかと思います。

Reduxをレストランで例えると理解が捗った件


ざっくり説明


  • AcitonCreator

    適切なActionを生成する。


  • Aciton

    トリガー兼Stateに持たせたい情報のオブジェクト


  • Middleware

    Reducerの実行前後に処理を追加するための仕組みです


  • Reducer

    Actionの値をもとに、Stateを更新する


  • Store

    アプリケーションのState(状態)を管理



作ったもの

QiitaクライアントアプリQiitenusをサンプルとして作りました。

随時更新してきます。

Icon-App-60x60@2x.png


環境


  • Xcode8

  • Swift 3.0

  • carthage 0.17.2


ライブラリ


Cartfile.


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

新着投稿のフィードをテーブルに表示するため記事用のモデルを作ります。


Article.swift


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"]
}
}



Tag.swift


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"]
}

}



User.swift

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を実装する


QiitaRequest.swift


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
}
}



FeedRequest.swift

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クライアントとして作成します。


ActionCreator.swift


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の名前を大文字スネークケースで記述してます。


FeedAction.swift


import ReSwift

/**
* Feedを取得
*/

struct GET_NEW_FEED: Action {
var response: Array<Article>?
}



Middleware

今回使用した感じだと、特に必要性を感じなかったMiddlewareもとりあえず実装しました。

Requestの結果を元に更に異なったRequestを出したい場合に、こちらに次のRequestを書いておくとネストが深くなることがなく便利です。


FeedMiddleware.swift


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を取得できるようにします。


AppState.swift

import ReSwift

struct AppState: StateType {
var feedState = FeedState()
}



Reducer

Reducerで先程のStateにもたせた、articleListに対して

GET_NEW_FEEDが持っているResponseを渡します。


FeedReducer.swift

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


RootReducers.swift


import ReSwift

var rootReducers = CombinedReducer([
FeedReducer()
])


appStoreに対して、今まで設定してきた内容を渡してあげます。

※rootReducersにReducerを追加し忘れると、動作はするもののMiddlewareやReducerに入ってこず、Stateの値が変わらないので注意です。


ConfigureStore.swift


import ReSwift

var appStore = Store(
reducer:rootReducers,
state: AppState(),
middleware: [
FeedMiddleware
])



Controller

ControllerでStateを観測し、Stateが持っている値を利用して実装していきます。


FeedViewController.swift


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を作成しました


ArticleCell.swift


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)
}

}



FeedViewController.swift


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)
}
}


以上で、フィードを取得することができました。

新着一覧

Simulator Screen Shot 2016.09.21 午後1.14.21.png


まとめと展望


まとめ

今回は、ReSwiftを使ったReduxライクなアーキテクチャを利用した実装を試みました。

記述量は増えるもののStateを意識して実装を行うことで、新しい実装を行おうとした場合に、新規Actionを今の手順で追加していくだけなので新しく参入したメンバーなどにもプロジェクトのルール説明が行いやすいと思います。

また、Web側もAPIとフロントを切り離して開発しているので

スマホアプリを作る際に、APIを別立てすることなく再利用できる点も魅力だと思います。


展望

次回は、SwiftにおけるReactライクな実装であるalexdrone/Renderを用いて

JSXのようにViewの操作をまとめて記述したいと思っています。


おわりに

サイバー・バズでは一緒にサービスを創れるエンジニアを募集しています :)