この記事は
日々の開発の中で「これもUI Stateなの?!」とか「こんな書き方ができるんだ!」というものが増えてきたのでその知見をまとめたものです。
UI Stateって何?
UIの状態を表すひとかたまりのclassと考えます。
私が思ってた UI State
少し前まで私が思っていたUI Stateは次のようなイメージです。
- ComposeでUIを実装した時に使うやつ
- Android Viewの時は使わない!
- その画面に表示する全てのデータ(UIの状態)を1つのクラスにまとめたもの
-
HogeUiState
みたいな命名のdata class
とかsealed class
的なの
-
しかし、実際はAndroid Viewの場合でもUI Stateの概念は有効に活用させることができます。
なぜAndroid Viewの場合でもUI Stateが使える?
もしこの記事を読んでいる皆さんの中に、かつての私のように
- ComposeでUIを実装した時に使うやつ
- Android Viewの時は使わない!
このような考えを持っている方がいることを想定して、少し説明させてください。
Android Viewで良いUI StateとダメなUI State
結論から言うと、
Composeの場合によく見るような1つの画面の状態を全て1つのクラスにまとめるようなUI Stateの作り方はAndroid Viewには向いていません。しかし、画面の中の一部のUIの状態をUI Stateとして管理することはAndroid Viewにおいても非常に有効
です。
少し見づらい図で申し訳ありませんが、下の図の場合について考えてみましょう。
これは私が個人開発で考えたアプリの画面で、2%くらい実装してすぐやめちゃったやつです。
Composeで実装する場合、このホーム画面に表示する全てのデータの状態を1つのクラス(UI State)として管理しても良いですが、Android Viewの場合は、1つの画面のUI状態を1つのクラスにまとめてしまうのは危険です。代わりに、例えば図のPROFILE CARD
のように、ひとかたまりのUIに対してUI Stateを作成すると良いです。
なぜAndroid Viewでは1つの画面に表示するデータの状態をUI Stateとして全て1つのクラスにまとめるのが推奨されないか
Android Viewの特徴
AndroidViewの場合、AndroidView自体が状態を持つ ことになります。
このことを ステートフル(stateful) といいます。
Android ViewであるTextViewにsetText(hoge)した時のことを考えてみてください。このとき、TextViewは表示内容textをクラス内部に覚えています。Compose登場以前のViewは状態をもつこと(ステートフルであること)が当たり前でした。
状態の二重管理
Android Viewのようにステートを内部で持つViewシステムの場合、UIStateでの状態管理とAndroid Viewでの状態管理で 状態の二重管理 に注意しなければなりません。
1画面全体のUI状態を1つのUIStateクラスにまとめることは便利ですが、UIStateでの状態管理とAndroid Viewでの状態管理で、どちらの状態が正であるのか管理の問題が発生します。
ロジックとしてはUIStateがもつ状態が正であるべきですが、表示されるUIを考えるとAndroid Viewで表示している情報こそが正となるからです。
レンダリングコスト
さらにレンダリングコストの問題もあるでしょう。例えばAndroid Viewでbinding.hogeText.text = hogeなどのように書かれている場合、変数hogeの中の値が変化していなくても更新されてしまうようなレンダリングの仕組みになっているので、UIStateの中に値が変化していないプロパティが含まれていたとしても、UIState全体で更新処理が行われてしまいます。
しかし、たくさんあるコンポーネントを全てViewModelでハンドリングするのは大変です。
なので全画面ではなく、あるまとまったUIの状態をUIStateとして管理する分では効果を発揮します。
Composeの特徴
一方Composeの場合、Compose自体は状態を持っていません。
これを ステートレス(stateless) といいます。
Compose自体が状態を持っていないので、UIStateに状態を管理させています。
だからAndroidViewと違ってComposeはUIStateと相性がいいのです。
また、Composeの場合だとAndroidViewと違い、Composable
の引数に渡された値が変化した時のみ再レンダリングされるような仕組みになっています。
そのため、1画面のUI状態をUI Stateとして1つのクラスにまとめたとしても、過剰な更新が起こりません。
基本!:一番簡単なUIState
世界で一番カンタンなUIStateをみていきましょう。
例えばプロフィール画面のツイートのUIStateを考えてみます。
- アイコン
- ユーザー名
- ユーザーID
- ツイート内容
- ツイート投稿時間
- コメント数
- いいね数
- リツイート数
- (ツイートアクティビティ数)
などがこのツイートアイテムのもっている情報です。
これをUIStateにしたものがこちらです。
data class TweetItemUiState(
val userIcon: Hoge,
val userName: String,
val userId: String,
val tweet: String,
val postDate: String,
val replyCount: String,
val reTweetCount: String,
val favorirteCount: String,
) {
}
(🚨 このサンプルコードでは、ツイートアクティビティ数は無視しています)
至って単純です。ただ、そのUIに必要なデータの状態を1つのクラスとしてまとめているだけです。
このように作られたUIStateは、データに変更があった場合ViewModelで更新されます。
class HomeViewModel: viewModel() {
private val _uiState: MutableStateFlow<TweetItemUiState>
val uiState: StateFlow = _uiState
init {
// リプの数を更新する
_uiState.update{ it.copy( replyCount = replyCount ) }
}
}
(🚨 このサンプルコードはあくまで例なので、現実的な実装ではありません)
世界で一番カンタンなUIStateはそのUIに表示するデータの状態を1つのクラスにまとめただけのものです。
これを基本としてもう少しUIStateを活用できる事例を紹介していきます。
中級!:データに関する処理はデータの近くに寄せる
以前にもQiitaで書いた内容なのですが、データを加工するロジックをUIState内に置くことによって、例えばこのUIStateを他の場所でも使い回したい場合に、ロジックごと使い回すことができます。
逆に言えば、データを加工するロジックをUIState内に置かない場合、他の場所でもUIStateを使いまわしたいけど、そのUIStateが持っているデータに関連するロジックはコピペして持ってくることになります。
そうすると同じロジックが複数の場所に散らばり、仮にそのロジックに変更が入った場合複数の場所からそのロジックを探し出して修正しなければなりません。修正漏れがあれば、バグにつながります。
例えば、基本編のUIStateはpostDate
を持っていますが、ツイートアイテムに表示する時は実際の時刻ではなく、今から何時間/ 何日前のツイートなのかを表示しています。
例として、取得してきた情報をUI上に表示するために、postDate
を加工して現在時刻から何時間前なのかを表示するロジックをUIStateに実装してみます。
data class TweetItemUiState(
val userIcon: Hoge,
val userName: String,
val userId: String,
val tweet: String,
val postDate: String,
val replyCount: String,
val reTweetCount: String,
val favorirteCount: String,
) {
private val now = LocalDateTime.now()
val hoursAgo = Duration.between(postDate, now).toHours()
}
(🚨 雰囲気でコードを書いています)
参考:Androidで「ロジックとデータの一体化」を実践してみた
発展!:UIとしては同じだけど必要なデータが微妙に違う時にできる工夫
例えば次のようなカードを出しわけて画面に表示する場合を考えます。
この画面には3パターンのカードがあります。
上から順番にみていきましょう。
-
ユーザーがまだ1つもchallengesに取り組んでいない場合(
NotCompleted
)
表示するもの- タイトル(
dialogTopic
) - challengesの数(
challengesCount
)
- タイトル(
-
ユーザーがchallengesに取り組んだ場合(
Completed
)
表示するもの- タイトル(
dialogTopic
) - challengesの数(
challengesCount
) - ユーザーが取り組んだchallengesの数(
completedChallengesCount
)
- タイトル(
-
未課金の場合(
Locked
)
表示するもの- 鍵アイコンを表示
- タイトル(
dialogTopic
) - challengesの数(
challengesCount
)
このように、ほとんど同じようなUIではありますがパターンによって微妙に表示させる内容が変わります。
こういった場合、どういうUIStateを作成するのが効率的でしょうか。
状態を複数もつということになると、data classだけではその状態を表現することができません。
そういった場合には、sealed class
を使うことによって表示するカードそれぞれに過不足なく必要なデータを渡してあげられます。
次のサンプルコードはsealed class
とdata class
の両方を活用して複数の状態をもつ場合のUIStateを実装しています。
sealed interface Item
sealed class DialogCardItem: Item {
// sealed class内の全ての子クラスで使いたいプロパティはabstractにする
abstract val dialogTopic: String
abstract val challengesCount: Int
data class Completed(
// abstractで宣言したプロパティはoverrideして使う
override val dialogTopic: String,
override val challengesCount: Int,
val completedChallengesCount: Int,
): DialogCardItem()
data class NotCompleted(
override val dialogTopic: String,
override val challengesCount: Int,
): DialogCardItem()
data class Locked(
override val dialogTopic: String,
override val challengesCount: Int,
): DialogCardItem()
companion object {
// create()はView側で呼び出す
fun create(
isLocked: Boolean,
dialogTopic: String,
challengesCount: Int,
completedChallengesCount: Int,
): DialogCardItem {
return if (isLocked) {
Locked(
dialogTopic,
challengesCount,
)
} else {
if (completedChallengesCount == 0) {
NotCompleted(
dialogTopic,
challengesCount,
)
} else {
Completed(
dialogTopic,
challengesCount,
completedChallengesCount,
)
}
}
}
}
}
上記のサンプルコードについてもう少し詳細に解説していきます。
まず3つの状態(Locked
, NotCompleted
, Completed
)自体はそれぞれdata class
で表現したいです。
data class Completed(
/* nothing */
): DialogCardItem()
data class NotCompleted(
/* nothing */
): DialogCardItem()
data class Locked(
/* nothing */
): DialogCardItem()
ですが、Locked
, NotCompleted
, Completed
自体は全く別のUI状態ではなく、表示するデータが少し異なるだけでほとんど同じです。例えば、タイトル(challengesCount
)やchallengesの数(dialogTopic
)はどの状態のカードでもほしいデータです。
なので、3つのdata class
を1つのsealed class
で管理し、sealed class
のプロパティとしてタイトル(challengesCount
)とchallengesの数(dialogTopic
)をabstractで定義し、子のdata class
でそれらを継承します。これで必要なプロパティを必ずdata class
内で定義しないといけないようにさせることができます。要するに必須データの実装漏れが防げます。
sealed interface Item
sealed class DialogCardItem: Item {
// sealed class内の全ての子クラスで使いたいプロパティはabstractにする
abstract val dialogTopic: String
abstract val challengesCount: Int
data class Completed(
// abstractで宣言したプロパティはoverrideして使う
override val dialogTopic: String,
override val challengesCount: Int,
val completedChallengesCount: Int,
): DialogCardItem()
data class NotCompleted(
override val dialogTopic: String,
override val challengesCount: Int,
): DialogCardItem()
data class Locked(
override val dialogTopic: String,
override val challengesCount: Int,
): DialogCardItem()
}
あとはこの3つの状態をどのようなロジックで出し分けるかです。
まず、課金済みか未課金状態かどうかを出し分けるために、isLocked
というBooleanのフラグが必要になりそうです。
あとは、ユーザーがchallengesに取り組んだかどうかの判断ができないとCompleted
とNotCompleted
の出し分けもできないのでcompletedChallengesCount
というフラグも必要そうです。
例えばcompletedChallengesCount
が0の場合はユーザーがchallengesに未着手なのでNotCompleted
の状態のカードを表示させることになりそうです。
あとは、data class
を出し分ける際に、生成するdata class
のコンストラクタに渡す引数としてdialogTopic
とchallengesCount
も必要になりそうです。
これらを踏まえてcreateメソッドを以下のように実装し、data class
の出しわけを行います。
companion object {
// create()はView側で呼び出す
fun create(
isLocked: Boolean,
dialogTopic: String,
challengesCount: Int,
completedChallengesCount: Int,
): DialogCardItem {
return if (isLocked) {
Locked(
dialogTopic,
challengesCount,
)
} else {
if (completedChallengesCount == 0) {
NotCompleted(
dialogTopic,
challengesCount,
)
} else {
Completed(
dialogTopic,
challengesCount,
completedChallengesCount,
)
}
}
}
}
createメソッドはstaticメソッドとしてcompanion object内で作成し、View側(Activity/Fragment)から呼び出しを行います。
次のコードはサンプルコードなので、適当なデータを詰めているわけですが、createメソッドの使い方としてはこんな感じです。
View側からcreateを呼び出して必要なデータを引数に渡します。
// Fragment
val items = listOf(
DialogAdapter.DialogCardItem.create(
isLocked = false,
dialogTopic = "At Hotel in Paris",
challengesCount = 2,
completedChallengesCount = 1,
topicImage = "hogehoge"
),
DialogAdapter.DialogCardItem.create(
isLocked = false,
dialogTopic = "On Sunday",
challengesCount = 2,
completedChallengesCount = 2,
topicImage = "hogehoge"
),
DialogAdapter.DialogCardItem.create(
isLocked = true,
dialogTopic = "Picnic with friends",
challengesCount = 2,
completedChallengesCount = 0,
topicImage = "hogehoge"
)
)
binding.dialogRecycler.adapter = DialogAdapter(items)
そうするとcreateメソッド内で、受け取った引数をもとにどのdata class
を出すのかを判定します。
⏬ createメソッドの中身
fun create(
isLocked: Boolean,
dialogTopic: String,
challengesCount: Int,
completedChallengesCount: Int,
): DialogCardItem {
// 課金済みかどうか
return if (isLocked) {
Locked(
dialogTopic,
challengesCount,
)
} else {
// challengesに着手したかどうか
if (completedChallengesCount == 0) {
NotCompleted(
dialogTopic,
challengesCount,
)
} else {
Completed(
dialogTopic,
challengesCount,
completedChallengesCount,
)
}
}
}
このようにして、複数の状態の出しわけを行いたい場合のUIStateではsealed class
内に状態に応じたdata class
を用意し、出しわけのロジック自体はcompanion object
にstaticメソッドとして持たせるという方法があります。
最後に
今回はさまざまな場面でのUIStateの活用について記事を書いてみましたが、いかがでしたでしょうか。
UIStateは奥が深く、UIの状態というものをどのような切り口で考えるかによっても構成が大きく異なりますが、今回は一般的な形から基本をベースにした応用的な工夫まで紹介いたしました。
この記事がみなさんの日々の開発に少しでも貢献できれば幸いです。