はじめに
今日のAndroid アプリ開発においては, ViewState
を作るのが主流になってきたかと思います.
本シリーズでは, ViewState
をモデリングするにあたって必要な考え方や実際の方法, そしてアプリ要件に合わせた応用的な実装について紹介していきます:
- 非同期なデータの取得状態の表現
- 統一的な Loading, Error 状態の表現
- イベントの表現
今回は第1回として, もっともベーシックな 非同期なデータの取得状態の表現
について解説していきます. 同様の記事は既にたくさんあるので合わせて参照することをおすすめします.
なお, 本シリーズが続く保証はありません… 😿
説明に当たっては, 次のような構成の To-do 管理アプリをベースに解説していきます.
TasksFragment -> TasksViewModel (<-> TaskRepository <-> ...)
<- TasksViewState -
data class Task(
val id: String,
val title: String,
val completedAt: String,
)
class TasksViewModel : ViewModel() {
private val _state = MutableLiveData<TasksViewState>(TasksViewState())
val state: LiveData<TasksViewState> = _state
}
非同期なデータの取得状態の表現
先に結論を述べると, 他の記事にもある通り, 次のようなクラスで表現するのが今のところ無難だろうということです. これは最も基本的なパターンであり, アプリの要件に合わせて改変した上で使用されます.
sealed class AsyncState<out T : Any>(
object Initialized: AsyncState<Nothing>()
object Loading: AsyncState<Nothing>()
data class Success<T : Any>(val value: T): AsyncState<T>()
data class Failure(val cause: Throwable): AsyncState<Nothing>()
)
これ以降は, 上記の実装を使うに至る思考の経路を, 少しだけ遠回りしつつ丁寧に解説していきます. ほとんどの読者にとっては退屈かもしれません.
データの取得はほとんどの場合非同期で行われますが, これがオフラインで完結するのであれば取得にかかる遅延を考慮しなくても大きな問題にはなりません. しかし, 現実にはリモートにあるデータを Web API を用いて取得してアプリで取り扱うことが多いため, それを考慮せざるを得ません.
すなわち, この時点で, ある1つのデータ取得について「データ取得中」と「データ取得成功」の2つの状態を少なくとも扱う必要が出てきます.
最もシンプルに2つの状態を表現するなら, null を許容し, null であれば「データ取得中」、null でなければ「データ取得成功」と表すことができます.
data class TasksViewState(
val tasks: List<Task>? = null
)
// TasksViewModel
state.postValue(state.copy(tasks = null))
val tasks = taskRepository.getAllTasks()
state.postValue(state.copy(tasks = tasks))
// TasksFragment
when {
tasks != null -> {
// データ取得成功
}
else -> {
// データ取得中
}
}
しかし, 実際にはデータの取得が確実に成功する保証がありません. リモートにあるデータの取得には (もちろんリモートでなくても) 往々にしてネットワークの接続不良や不正なリクエストなどによってエラーが起きます. よって, ここで新たな状態「データ取得失敗」を取り扱う必要が出てきました.
さきほどの nullable で無理やり表現するなら, 「データ取得失敗」とは, 「データがないのだから当然 null である」としてもよく, これはおおむね間違いではありません. しかし, 「データ取得中」と混合してしまっては UI 側での状態の区別が難しくなることや, UI 側では「なぜエラーが起きたのか」を元にユーザに詳細な情報を提供したいことがほとんどであるため, ここでは回避的に別の変数を定義することとします.
data class TasksViewState(
val tasks: List<Task>? = null
val tasksError: Throwable? = null
)
// TasksViewModel
state.postValue(state.copy(tasks = null, tasksError = null))
try {
val tasks = taskRepository.getAllTasks()
state.postValue(state.copy(tasks = tasks, tasksError = null))
} catch (e: Throwable) {
state.postValue(state.copy(tasks = null, tasksError = e))
}
// TasksFragment
when {
tasks != null -> {
// データ取得成功
}
taskError != null -> {
// データ取得失敗
}
else -> {
// データ取得中
}
}
無事, 新しい状態「データ取得失敗」を取り扱うことが出来ました. しかし, これでは取得するデータが増えるたびに変数を2つ定義したり, ViewModel で tasks
と tasksError
を更新するのが大変そうです. なにより, ここでは考慮しなくて良いような「tasks が non-null かつ taskError が non-null」といった状態まで ViewState で表現できてしまい, 余計な複雑さを生み出してしまいました. (もちろん, このような状態を表現したいといった要件がありうることは否定しません.)
さて, ここで冒頭に登場した AsyncState
を使う時がきました.
幸い, Kotlin には sealed class
という便利な機能があるため, これを活用することとします.
sealed class AsyncState<out T : Any>(
object Initialized: AsyncState<Nothing>()
// データ取得中
object Loading: AsyncState<Nothing>()
// データ取得成功
data class Success<T : Any>(val value: T): AsyncState<T>()
// データ取得失敗
data class Failure(val cause: Throwable): AsyncState<Nothing>()
)
さて, Initialized
という状態が新しく登場していることに気づくかと思います. これは, ViewState
で実際にプロパティを定義する際に活躍します. 具体的には, 次のように non-null でプロパティを定義できるようになり, UI 側で初期状態として取り扱うことができます.
data class TasksViewState(
val tasks: AsyncState<List<Task>> = AsyncState.Initialized,
)
// TasksViewModel
state.postValue(state.copy(tasks = AsyncState.Loading))
try {
val tasks = taskRepository.getAllTasks()
state.postValue(state.copy(tasks = AsyncState.Success(tasks)))
} catch (e: Throwable) {
state.postValue(state.copy(tasks = AsyncState.Failure(e)))
}
// TasksFragment
when (state.tasks) {
is AsyncState.Initialized -> {
// データ取得前
}
is AsyncState.Loading -> {
// データ取得中
}
is AsyncState.Success -> {
// データ取得成功
}
is AsyncState.Failure -> {
// データ取得失敗
}
}
sealed class
のおかげで, 定義した4つの状態以外の表現を制限できるため考えることが減り, UI 側の実装がシンプルになります. また, 各状態での UI 状態の変化が明確になり可読性の向上にも貢献してくれます.
AsyncState の表現パターン
ここからは, AsyncState
応用的なアプリ要件とその実装パターンを一部紹介してみます.
あくまで一例であり, 無限に実装パターンがあることをご承知おきください.
通常の読み込みとリフレッシュを区別したい
👇 単純にリフレッシュ状態を別に作る
sealed class AsyncState {
// ...
object Refreshing : AsyncState<Nothing>()
// ...
}
// TasksFragment
when (state.tasks) {
// ...
is AsyncState.Loading -> {
// 通常の Loading を制御する
}
is AsyncState.Refreshing -> {
// SwipeRefreshLayout を制御する
}
}
👇 SwipeRefreshLayout
の状態に依存してみる
// TasksFragment
if (state.tasks is AsyncState.Loading) {
if (!swipeRefreshLayout.isRefreshing) {
// SwipeRefreshLayout はユーザ操作起因で実行され
// 同時に UI の状態(isRefreshing)が変わる性質を利用する.
// SwipeRefreshLayout がトリガーされていなければ通常の Loading を表示し
// そうでなければ既に SwipeRefreshLayout が表示されているため何もしない.
}
} else {
if (!swipeRefreshLayout.isRefreshing) {
swipeRefreshLayout.isRefreshing = false
} else {
}
}
AsyncState の他の状態の扱いをどのようにするかにも依りますが, 取得成功した時のデータ(
AsyncState.Success
)を表示しつつ Loading を表示する場合はもう少し考えることがあります.
読み込み状態に進捗状態を持たせたい
👇 シンプルに Loading
に持たせてみる
sealed class AsyncState {
// ...
// progress in 1..100
data class Loading(val progress: Int) : AsyncState<Int>()
// ...
}
// TasksFragment
if (state.tasks is AsyncState.Loading) {
progressBar.isVisible = true
progressBar.progress = state.tasks.progress
}
エラーにより多くの情報を持たせたい
アプリのエラーハンドリングの設計次第ですが, 私の場合は次のような UI レベルでエラーを取り扱いやすくするためのクラスを扱うことにしているため, それぞれ次のようになります.
data class AppError(
val cause: Throwable,
val type: Type,
) {
// エラーの種類
enum Type {
Network,
InvalidRequest,
ServerError,
Unexpected,
// ...
}
}
sealed class AsyncState<out T : Any> {
// ...
data class Failure(val cause: AppError): AsyncState<T>()
// ...
}
まとめ
ViewState
をモデリングするにあたって, 最も使用頻度の高い APIなどから取得した非同期のデータ取得状態の表現について, AsyncState
を使うアプローチを紹介しました.
命名は違えど AsyncState
のような sealed class
を用いた実装パターンは, データの取得状態を明確に表現でき, かつ UI 側でもその恩恵を受けることができるのでおすすめです.
さて, 今回 ViewState
で扱ったプロパティはたった1つですが, もしこれが5個, 10個と増えていったらどうなるでしょうか? もちろん, ViewState のモデリング次第ではそのような事態にはならないかもしれませんが…
次回は, プロパティごとの Loading や Failure の状態を統一的に扱う方法についての実装例を紹介します.