Help us understand the problem. What is going on with this article?

Redux+RxSwift+Realm+RxDataSourcesで日記アプリ

Reduxを使ったアプリ開発をしてみたかったのですが、いまいちReduxについてわかってないことが多かったので実際にアプリを作ってみました。
今回、簡単なアプリなのでRxDataSourcesを使う必要はなかったのですが、実際にReduxとRxDataSourcesを使った場合、どのように書くのかを知りたかったので使っています。
コードについては、SwiftやRx、Realmなどすべて独学でやっているので至らない点もあると思います。。。

完成形

日記一覧 日記詳細(編集)

ファイル構造

Entities: Realmのモデル
Model: RxDataSourcesのSectionModel
Store: ReduxのStore
Actions: ReduxのAction
States: ReduxのState
Reducers: ReduxのReducer

以下のような構成で作りました

.
├── Actions
│   └── Action.swift
├── AppDelegate.swift
├── Base.lproj
│   ├── LaunchScreen.storyboard
│   └── Main.storyboard
├── Entities
│   └── DiaryItem.swift
├── Info.plist
├── Model
│   └── DiarySectionModel.swift
├── Reducers
│   └── Reducer.swift
├── States
│   └── State.swift
├── Store
│   └── RxStore.swift
├── View
│   └── DiaryCell.swift
└── ViewControllers
    ├── DiaryDetailViewController.swift
    └── DiaryListViewController.swift

ReduxとRealmとRxDataSourcesの部分

Entities: Realmのモデルたち
RxDataSourcesのモデルとしても使いたいのでIdentifiableTypeを継承しています。

DiaryItem.swift
import Foundation
import RealmSwift
import RxDataSources

class DiaryItem: Object {
    @objc dynamic var id = UUID().uuidString
    @objc dynamic var date = Date()
    @objc dynamic var text = ""
    @objc dynamic var createdAt = Date()
    @objc dynamic var updatedAt = Date()

    override static func primaryKey() -> String? {
        return "id"
    } 
}

extension DiaryItem: IdentifiableType {
    typealias Identity = String

    var identity: String {
        return id
    }
}

Model: RxDataSourcesのSectionModel
ItemTypeがDiaryItem(Realmのモデル)を想定しています。

DiarySectionModel.swift
import Foundation
import RxDataSources

struct DiarySectionModel<ItemType: IdentifiableType> {
    var header: String
    var items: [ItemType]

    init(header: String, items: [Item]) {
        self.header = header
        self.items = items
    }
}

extension DiarySectionModel: SectionModelType {
    typealias Item = ItemType

    init(original: DiarySectionModel<ItemType>, items: [ItemType]) {
        self.header = original.header
        self.items = items
    }
}

Store: ReduxのStore
今回は、StateをObservableな型として扱いたかったのでRxStoreというクラスを作成しました。
画面ごとにStateを管理しようと思ったのでAnyStateTypeには画面ごとのStateの型が入る想定です。

RxStore.swift
import Foundation
import ReSwift
import RxSwift
import RxCocoa

class RxStore<AnyStateType>: StoreSubscriber where AnyStateType: StateType {

    private let store: Store<AnyStateType>
    private let stateRelay: BehaviorRelay<AnyStateType>

    var state: AnyStateType { return stateRelay.value }
    lazy var stateObservable: Observable<AnyStateType> = {
        return self.stateRelay.asObservable()
    }()

    init(store: Store<AnyStateType>) {
        self.store = store
        self.stateRelay = BehaviorRelay(value: store.state)
        self.store.subscribe(self)
    }

    deinit {
        self.store.unsubscribe(self)
    }

    func newState(state: AnyStateType) {
        stateRelay.accept(state)
    }

    func dispatch(_ action: Action) {
        store.dispatch(action)
    }

}

Actions: ReduxのActionたち
DiaryListViewController内のTableViewを更新するためにreloadをおいています。
ActionCreatorやMiddlewareでRealm内のオブジェクトを更新するような設計であればreloadが必要なくなると思います。(この構成にしたあとに気づきました。。。)

Action.swift
import ReSwift

// DiaryListViewControllerで使うAction
enum DiaryAction: Action {
    case reload
    case delete(diary: DiaryItem)
}

// DiaryDetailViewControllerで使うAction
enum DiaryDetailAction: Action {
    case create
    case set(diary: DiaryItem)
    case update(text: String)
}

States: ReduxのStateたち

State.swift
import RealmSwift
import ReSwift
import RxDataSources

// DiaryListViewControllerのState
struct DiaryState: StateType {
    var sectionModel: [DiarySectionModel<DiaryItem>] = []
    var realm = try! Realm()
}

// DiaryDetailViewControllerのState
struct DiaryDetailState: StateType {
    var diary = DiaryItem()
    var realm = try! Realm()
}

Reducers: ReduxのReducerたち
今回はアプリ全体のStateを統括するReducerは作りませんでした。

Reducer.swift
import Foundation
import ReSwift
import RealmSwift

// DiaryListViewControllerのReducer
struct DiaryReducer {
    static func reduce(_ action: Action, state: DiaryState?) -> DiaryState {
        var state = state ?? DiaryState()
        guard let action = action as? DiaryAction else { return state }
        state.realm.beginWrite()
        switch action {
        case .reload: break
        case .delete(let diary):
            state.realm.delete(diary)
        }
        try! state.realm.commitWrite()
        let diaries = state.realm.objects(DiaryItem.self).sorted(byKeyPath: "updatedAt", ascending: false)
        state.sectionModel = [DiarySectionModel(header: "header", items: diaries.map({ $0 }))]
        return state
    }
}

// DiaryDetailViewControllerのReducer
struct DiaryDetailReducer {
    static func reduce(_ action: Action, state: DiaryDetailState?) -> DiaryDetailState {
        var state = state ?? DiaryDetailState()
        guard let action = action as? DiaryDetailAction else { return state }
        state.realm.beginWrite()
        switch action {
        case .create:
            state.realm.add(state.diary)
        case .set(let diary):
            state.diary = diary
        case .update(let text):
            state.diary.text = text
            state.diary.updatedAt = Date()
        }
        try! state.realm.commitWrite()
        return state
    }
}

View

image.png

DiaryCell.swift
import UIKit

class DiaryCell: UITableViewCell {

    @IBOutlet private weak var diaryDateLabel: UILabel!
    @IBOutlet private weak var updatedLabel: UILabel!

    func configure(_ diary: DiaryItem) {
        diaryDateLabel.text = diary.text
        let formatter = DateFormatter()
        formatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "ydMMM", options: 0, locale: Locale.current)
        updatedLabel.text = formatter.string(from: diary.updatedAt)
    }

}

ViewController

まず、日記の一覧を表示する画面のViewControllerです。
こちら、プログラム内に説明を記述しました。

DiaryListViewController.swift
import UIKit
import RxSwift
import RxCocoa
import ReSwift
import RxDataSources
import RealmSwift

class DiaryListViewController: UIViewController {

    @IBOutlet private weak var diaryListView: UITableView!
    @IBOutlet private weak var addDiaryButton: UIBarButtonItem!

    // Store
    private let store = RxStore(store: Store<DiaryState>(reducer: DiaryReducer.reduce, state: nil))
    private let disposeBag = DisposeBag()

    // diaryListViewのdataSource
    private let dataSource = RxTableViewSectionedReloadDataSource<DiarySectionModel<DiaryItem>>(configureCell: { (_, tableView, indexPath, result) -> UITableViewCell in
        let cell = tableView.dequeueReusableCell(withIdentifier: "DiaryCell", for: indexPath) as! DiaryCell
        cell.configure(result)
        return cell
    }, canEditRowAtIndexPath: { _, _ in
        return true
    })

    override func viewDidLoad() {
        super.viewDidLoad()

        bind()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        // realmの更新を確認
        store.dispatch(DiaryAction.reload)
    }

    private func bind() {
        // diaryListViewにdataSourceを適用
        store.sectionModel
            .bind(to: diaryListView.rx.items(dataSource: dataSource))
            .disposed(by: disposeBag)

        // Cellがスワイプで削除されたときの処理
        diaryListView.rx.itemDeleted
            .subscribe({ [unowned self] event in
                guard let indexPath = event.element else { return }
                self.store.dispatch(DiaryAction.delete(diary: self.dataSource.sectionModels[indexPath.section].items[indexPath.item]))
            })
            .disposed(by: disposeBag)

        // Cell押下時の画面遷移の処理
        diaryListView.rx.itemSelected
            .subscribe({ [unowned self] event in
                guard let indexPath = event.element else { return }
                self.performSegue(withIdentifier: "UpdateDiarySegue", sender: self.dataSource.sectionModels[indexPath.section].items[indexPath.item])
            })
            .disposed(by: disposeBag)

        diaryListView.rx.setDelegate(self).disposed(by: disposeBag)

        // 右上の追加ボタン押下時の処理
        addDiaryButton.rx.tap
            .subscribe({ [unowned self] _ in
                self.performSegue(withIdentifier: "CreateDiarySegue", sender: nil)
            })
            .disposed(by: disposeBag)
    }

    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        guard let vc = segue.destination as? DiaryDetailViewController else { return }
        if segue.identifier == "CreateDiarySegue" {
            vc.diary = nil
        } else if segue.identifier == "UpdateDiarySegue" {
            guard let diary = sender as? DiaryItem else { return }
            vc.diary = diary
        }
    }

}

extension DiaryListViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return 64.0
    }
}

extension RxStore where AnyStateType == DiaryState {
    // stateのsectionModelをObservableに変更
    var sectionModel: Observable<[DiarySectionModel<DiaryItem>]> {
        return stateObservable.map({ $0.sectionModel })
    }
}

次に日記を記述する画面のViewControllerです。

DiaryDetailViewController.swift
import UIKit
import RxSwift
import ReSwift

class DiaryDetailViewController: UIViewController {

    @IBOutlet private weak var diaryTextView: UITextView!

    // DiaryListViewControllerから渡されるパラメータ
    var diary: DiaryItem?

    // Store
    private let store = RxStore(store: Store<DiaryDetailState>(reducer: DiaryDetailReducer.reduce, state: nil))
    private let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()

        setup()
        diaryTextView.text = store.state.diary.text
        bind()
    }

    private func setup() {
        if let diary = diary {
            // diaryがある場合はdiaryをDiaryDetailStateにセット
            store.dispatch(DiaryDetailAction.set(diary: diary))
        } else {
            // diaryがない場合はDiaryを作成
            store.dispatch(DiaryDetailAction.create)
        }
    }

    private func bind() {
        // TextViewを記述するたびにRealmに保存
        diaryTextView.rx.text
            .subscribe({ [unowned self] event in
                self.store.dispatch(DiaryDetailAction.update(text: event.element! ?? ""))
            })
            .disposed(by: disposeBag)
    }

}

終わりに

Realmへの保存の処理については、ActionCreatoreやMiddlewareに書いたほうが良いみたいですが最初わからないまま書き始めたのでこのような形になりました。。。
全体のコードはGithubに載せているので参考にしたい方は参考にしてみてください。

https://github.com/azuma317/ReduxDiary

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away