この記事はSwift (その2) Advent Calendar 2018 の8日目です。
はじめに
状態管理のことを考えなくて済むのでReduxが好きで、最近はSwiftで実装するときはとりあえずReSwiftを使うようにしている。
インターフェースとしては本家javascriptのReduxとほぼ同等で違和感なく使えるのだが、
javascriptと違ってReactやVueなどが無く、現時点では適当なライブラリも見当たらないので無駄な再描画を避けるためには自力でどうにかしないといけない。
前後のState全体が同じならsubscribeする際に skipRepeats()
すれば済む話だが、Stateの一部だけを使って描画したいViewがあるとそれではうまくいかない。
というわけで、どうするのが楽か考えてみた。
考えたことその1
要はVirtualDOMみたいにViewの状態を比較して必要な描画だけを出来るようにすればいいはず。
ということでよくあるViewModelにstateを保存しておいて、値に変化があったときだけRxSwiftでViewに変化を伝えるようにすればいい?
// Intの状態変化をViewに伝えたい場合
// 擬似コード。実際には動かない。
import RxSwift
class ViewModel {
private let cache = BehaviorRelay<Int>(value: 0)
var cacheObservable: Observable<Int> {
return cache.asObservable() // 外部から値を設定できないように、Observableだけ公開しておく
}
// storeにsubscribeしているViewControllerから呼んでもらうメソッド
func updateWithState(state: State) {
guard state.value != cache.value else { return } // これで再描画を避けられるはず
cache.accept(state.value)
}
}
class View: UIView {
let viewModel = ViewModel()
init() {
super.init()
viewModel.cacheObservable.subscribe(onNext: {[weak self] value in
self.update(with: value)
}).disposed(by: disposeBag)
}
private func update(with value: Int) {
// 何かする
}
}
考えたことその2
その1の方法では一応ViewModel作ったけど、ViewControllerからはViewもViewModelも見えているのであまり意味がない気がする。
View自体に状態を持たせてしまってもいいのでは?
class View: UIView {
private var cache: Int? // 外部には絶対見せない
// storeにsubscribeしているViewControllerから呼んでもらうメソッド
func updateWithState(state: State) {
guard state.value != cache else { return }
update(with: state.value)
}
private func update(with value: Int) {
// 何かする
}
}
結局
その2が良いかな、という結論になった。
で、上記の内容を書きやすくするために以下のようなUIKitのExtensionを書いてみた。
import UIKit
protocol AvoidRerender: class {
associatedtype TargetState: Equatable
// この記事を書いている途中で、これがinternalなのはまずいことに気付いた。が、現状どうしようもない。
var state: TargetState? { get set }
func update(with state: TargetState?)
func customAction(state: TargetState?)
}
extension AvoidRerender where Self: UIViewController {
func update(with state: TargetState?) {
guard state != self.state else { return }
self.state = state
customAction(state: state)
self.view.setNeedsLayout()
}
}
extension AvoidRerender where Self: UIView {
func update(with state: TargetState?) {
guard state != self.state else { return }
self.state = state
customAction(state: state)
setNeedsLayout()
}
}
使うときはこんなふうにする
import ReSwift
struct CounterState: StateType {
var count = 0
}
extension CounterState: Equatable {
static func == (lhs: CounterState, rhs: CounterState) -> Bool {
return lhs.count == rhs.count
}
}
extension ViewController: StoreSubscriber, AvoidRerender {
typealias TargetState = CounterState
typealias StoreSubscriberStateType = CounterState
// protocol extension内で StoreSubscriber を実装して、 newState もデフォルト実装してしまっていいかも
func newState(state: CounterState) {
update(with: state)
}
func customAction(state: CounterState?) {
print("customAction called")
guard let state = state else {
valueLabel.text = ""
return
}
valueLabel.text = "\(state.count)"
}
}
これで、stateではなくEquatableを実装してあるclassなりstructなら何にでも対応出来る。
作った成果物はここ
https://github.com/yamazaki-sensei/avoid-rerender
作ってみて
冷静になって考えてみると
- 大して楽にはならない
- 内部の状態のscopeがinternalという致命的な欠陥を抱えている
とは言え、それ用にUIView
やUIViewController
のサブクラスを作ってアプリ全体で使うのはイケてない - やってることは結局Equatableな2つの値を比較して抜けるかどうか判断しているだけ
- UITableViewやUICollectionViewなど、DataSource系のUIに適用するのが難しそう
- もしかして単純なUILabelとかならデフォルトで再描画を避ける機構が入ってたりして(未調査)
といろいろ課題は見つかり、広く使われているライブラリが出回っていない理由がなんとなくわかった気がした。
終わりに
VirtualDOMは便利だ。