24
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

UIStateの基本とさまざまな工夫 

Last updated at Posted at 2023-07-02

この記事は

日々の開発の中で「これも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を作成すると良いです。

スクリーンショット 2023-06-09 1.22.46.png

なぜ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. ユーザーがまだ1つもchallengesに取り組んでいない場合(NotCompleted
    表示するもの

    • タイトル(dialogTopic
    • challengesの数(challengesCount
  2. ユーザーがchallengesに取り組んだ場合(Completed
    表示するもの

    • タイトル(dialogTopic
    • challengesの数(challengesCount
    • ユーザーが取り組んだchallengesの数(completedChallengesCount
  3. 未課金の場合(Locked
    表示するもの

    • 鍵アイコンを表示
    • タイトル(dialogTopic
    • challengesの数(challengesCount

このように、ほとんど同じようなUIではありますがパターンによって微妙に表示させる内容が変わります。
こういった場合、どういうUIStateを作成するのが効率的でしょうか。

状態を複数もつということになると、data classだけではその状態を表現することができません。
そういった場合には、sealed classを使うことによって表示するカードそれぞれに過不足なく必要なデータを渡してあげられます。

次のサンプルコードはsealed classdata 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に取り組んだかどうかの判断ができないとCompletedNotCompletedの出し分けもできないのでcompletedChallengesCountというフラグも必要そうです。

例えばcompletedChallengesCountが0の場合はユーザーがchallengesに未着手なのでNotCompletedの状態のカードを表示させることになりそうです。

あとは、data classを出し分ける際に、生成するdata classのコンストラクタに渡す引数としてdialogTopicchallengesCountも必要になりそうです。

これらを踏まえて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の状態というものをどのような切り口で考えるかによっても構成が大きく異なりますが、今回は一般的な形から基本をベースにした応用的な工夫まで紹介いたしました。
この記事がみなさんの日々の開発に少しでも貢献できれば幸いです。

24
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?