47
43

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2016-10-12

はじめに

サイバー・バズの@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の操作をまとめて記述したいと思っています。

おわりに

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

47
43
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
47
43

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?