画面状態を抽象化してテスタブル設計ライフ
を送ろう
kikuchy
Who?
@kikuchy
菊池紘
株式会社Diverse (ミクシィグループ)
テストの大切さは知っているけれど腰が重い
突然ですが
こんなコードに出くわしたことはないですか
var page = 1
var isLoading = false
var hasBeenReachedPageEnd = false
var isInitialLoading = true
fun showData(data: Data) {
page++
hasBeenReachedPageEnd = checkPageEnd(data)
adapter.setDataAndNotifyChanged(data)
}
if (!isLoading && !hasBeenReachedPageEnd) {
repository
.getData()
.subscribe(object: Observer {
override fun onNext(data: Data) { showData(data) }
override fun onStart() { isLoading = true;
showProggress(isInitialLoading) }
override fun onComplete() { isLoading = false;
hideProgress(isInitialLoading);
isIlitialLoading = false }
})
}
複数フラグで状態管理= 終わりの始まり
booleanのフラグが3つあるとして、とり得る状態は本当に8通りな
のか?
実際は5通りくらいだったりする
あり得ない状態を考慮できてしまうのは混乱の元
いつ/どこでフラグいじる?問題
どこで変更するの?
どこで参照するの?
いま変更して大丈夫なの?
Activity / Fragmentに生えたメンバ変数は実質グローバル変数
こんな地獄を防ぐには
状態を列挙
状態の操作をできる場所を制限
状態が正しく切り替わっているか確認
状態をオブジェクトで管理して
挙動のテストを書く!
ちゃんと考えよう
前提
APIからとってきた情報を表示するアプリ
‑> 通信状況≒ 画面の状態
暗黙的に期待されている機能は洗い出せているとする
読み込み中の表示
再読込
ページング
失敗時の表示
作例
DroidKaigi 2017のStargazersを表示する
https://github.com/kikuchy/ScreenStateIsModelSample
いきなりページングする画面のことを考えるのはしんどいので
簡単な状態のものからステップアップして考えていきます
状態
ページングなし、再取得なし、読んだ情報を表示するだけの画面
読み込みしていない[初期状態]
読み込み中
読み込み成功(with 読み込めたデータ)
読み込み失敗(with エラー内容)
sealed class State {
// 読み込みしていない
object NeverFetched : State()
// 読み込み中
object Fetching : State()
// 読み込み成功
data class Success(val data: Data) : State()
// 読み込み失敗
data class Failure(val error: Throwable) : State()
}
状態遷移
遷移を考える
どの状態からどの状態へ遷移可能なのか
どんな操作によって遷移が起こるのか
操作を受け付けられない状態であったら無視することにす
る
図
+--------------+
| NeverFetched |
+--------------+
|
| fetch
v
+----------+
| Fetching |
+----------+
|
| (読み込みが終わったら自動で遷移)
+---------------------
| |
v v
+---------------+ +----------------+
| Success(data) | | Failure(Error) |
+---------------+ +----------------+
状態の管理場所を作る
状態遷移モデルなので、 モデルと呼ぶ
ステートマシンと呼んだ方がしっくり来る人もいる
状態の変更はモデル外部で受け取ってもらう必要がある
今回はRxJavaを使用
自前でObserverパターンを実現できればそれでも良い
モデル内部では現在の状態を保持している必要がある
通知も同時にできるBehaviorSubjectが適任
class Model(private val repository: Repository) {
// 状態を保持するメンバ代わり。通知役も兼ねる。
private val stateHolder =
BehaviorSubject
.createDefault<State>(State.NeverFetched)
// 外にはObservableとして公開
val stateObservable: Observable<State>
get() = stateHolder
private fun fetchRepo() {
// 読み込みが始まるときに、読み込み中状態にする。
stateHolder.onNext(State.Fetching)
repository.getData().subscribe({ data ->
// 読み込み成功
stateHolder.onNext(State.Success(data))
}, { error ->
// 読み込み失敗
stateHolder.onNext(State.Failure(error))
})
}
fun fetch() {
// NeverFetched状態でなければ要求を無視する
when (stateHolder.value) {
is State.NeverFetched -> fetchRepo()
else -> return
}
}
}
図のとおりになっているはず
+--------------+
| NeverFetched |
+--------------+
|
| fetch
v
+----------+
| Fetching |
+----------+
|
| (読み込みが終わったら自動で遷移)
+---------------------
| |
v v
+---------------+ +----------------+
| Success(data) | | Failure(Error) |
+---------------+ +----------------+
ステップアップ
再読込もページングもする画面の状態
読み込みしていない[初期状態]
読み込み中 (with ページ番号、今までに読み込んだアイテム)
読み込み成功(with ページ番号、アイテム+ 新しいアイテム)
読み込み失敗(with ページ番号、エラー、アイテム)
ページ終端(with アイテム+ 新しいアイテム)
読み込みに失敗してページ終端に到達することはない
操作
次のページを読み込む
読み込みに成功したらアイテムを増やし、ページ番号を増やす
再読込
抱えてるアイテムを破棄して、ページ番号を1に戻す
感覚はわかったと思うのでコードは省略
テスト
ちゃんと遷移できているかをテスト
画面にどう反映するかはモデルの関心事ではない
テストすることはそこまで多くないから気が重くならない
※もちろん、モデルの複雑さによります
画面とつなぐ
使ってみたかったのでLiveDataでイベントを流す
RxLifrcycleを使えればそれでもよい
LiveDataのObserverが表示を制御するようにする
RecyclerViewのAdapter君
エラー用のSnackBar表示する君
読み込み中インジケーター表示する君
など
個別なので管理がすごく楽
よかったこと
基本的なところは楽にテスト書ける
画面を作る前に動作確認できた
テストするうちに抜けていた状態に気づくこともある
どこに新しい機能を置けば良いのか悩まなくていい
少なくとも、状態と表示、どちらのことなのかは悩まないはず
1ファイルあたりのコード少なくて感動する
モデルがライフサイクルを超えて生き残ってくれれば、Activity /
Fragmentが表示された瞬間に以前の状態が復元される
各種アーキテクチャパターンに発展可能
大変だったこと
クラスは多い
べた書きに比べれば初期開発の時間はかかる
慣れないと大変
暗黙的に期待されている状態を洗い出すのが一番大変
githubのREST API辛くないすか
ヘッダのパース面倒とか
rate limit引っかかって死ぬとか
Activity / Fragmentが重くてお困りの方
まずは画面の状態を別クラスで
管理するところから始めてみては?
ちなみに
このアーキテクチャは弊社サービスYYCのiOS版(開発中)から
ヒントを得て作りました
Special thanks iOSチームのみなさま
https://github.com/kikuchy/ScreenStateIsModelSample
画面状態を抽象化してテスタブル設計ライフを送ろう

画面状態を抽象化してテスタブル設計ライフを送ろう