AndroidアプリにUiState導入のススメ

7 min読了の目安(約6800字TECH技術記事

はじめに

この記事は、Android Advent Calender 2020 9日目の記事です。

Androidアプリ開発をする際に悩むこととして、UIの状態の考慮をすることがよくあると思います。
今回は、その際にUiStateの考え方をコードに反映すると、非常にシンプルになりメリットがあると感じたため紹介させていただこうと思います。

UiStateとは

Scott Hurff氏によって提唱されているUiStackの5つの状態のことを指します。
元記事は下記を参照してください。

https://www.scotthurff.com/posts/why-your-user-interface-is-awkward-youre-ignoring-the-ui-stack/

UiStateは、次の5つの状態から成り立ちます。

  • Blank(Empty)
    • 表示するものが何もない状態。はじめて使用したときに何もなかったり、データをクリアしたようなときの状態。
  • Loading
    • 読み込み状態。データを読み込んでいるとき、インターネット接続を待っているとき、または別の画面に移行しているときを示す状態。
  • Partial
    • 部分達成状態。進捗率を示したり、タスクを100%に達成するまでにUIを表示するような際の状態。
  • Error
    • 問題が発生した時の状態。サーバーに接続できないときやアップロードを完了せずに次のステップに進もうとしたときなどに失敗したときの状態。
  • Ideal
    • 理想的な状態。ユーザー側から見て、すべてのコンテンツが揃い、製品として完成されている状態。

UiStateがなぜ必要なのか?

アプリ開発をしているときに、結構見落としがちであるのがUiの状態だと思っています。
アプリ開発をしている際に、要件が複雑になってきたときに「全体のデータがなかったときは?」「このときのエラーの表示については?」「一部のデータが返ってこないときは?」「このパターンの挙動も考えられる、そのときの表示はどうするのか?」、と当初予定していた仕様を満たすために、既存の様々な条件からいろんなパターンがやっているうちにわかってきて、あとから問題が発覚することがあったりします。

そういった問題をある程度事前に解決するために、UiStateについて要件定義もしくは設計段階で考慮を行っておくと、Uiの状態についてきちんと定義し、色々な場合の考慮漏れが格段に減ります。

また、コードにも落とし込んでみると、各状態のときになにをやっているのかが、把握しやすくなります。
実際にAndroidアプリに反映したときにどうなるのかについて、見ていきましょう。

UiStateをAndroidアプリに反映する

※前提として、MVVMの形のアーキテクチャに則っていることを踏まえています

AndroidアプリでUiStateを反映させるためにやることは次の3つです。

  • UiStateを定義する
  • ViewModel側でUiStateとViewの情報(UiData、以降UiDataと表記する)を持つLiveDataを用意する
  • Fragment(もしくはActivity)側にて、各状態について記載する

まずは、次のようにしてUiStateのenum classを作成しましょう。

UiState.kt
enum class UiState {
    IDEAL, PARTIAL, LOADING, BLANK, ERROR
}

UiStateクラスはモジュール分割をした際でも、一番上の階層に一つ用意しておくのが良いと思います。

次に、ViewModel側でUiStateとUiDataをもつLiveDataを用意します。
sampleでは、ListViewModelを例にとって説明させていただきます。ソースコード中の...は省略を意味します。

ListViewModel.kt
class ListViewModel @Inject constructor(
    application: Application,
    private val repository: GitHubRepository
): AndroidViewModel(application) {
   ...
    val uiLive = MutableLiveData<Pair<UiState, UiData?>>()
    private var uiData = UiData(
        list = mutableListOf(),
        isChanged = false
    )
   ...
}

通信が成功したときや失敗したときに、uiLive.postValue(IDEAL to uiData)uiLive.postValue(ERROR to null)のようにしてobserveしている側に通知を行うようにします。
Viewの更新を行うLiveDataを一つにしておけば、このLiveDataに対して通知を行うだけでよくなり流れがシンプルになります。

最後に、Fragment(もしくはActivity)側にて、各状態について記載を行います。
sampleでは、ListFragmentを例にとって説明させていただきます。
最初に、onViewCreatedでViewModelのLiveDataをobserveします。observe内部でViewの更新を行う関数を呼ぶようにします。

ListFragment.kt
@AndroidEntryPoint
class ListFragment: Fragment() {

    private lateinit var binding: FragmentListBinding
    @Inject
    lateinit var viewModel: ListViewModel

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = FragmentListBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        setUp()
    }

    private fun setUp() {
        ...
        viewModel.uiLive.observe(viewLifecycleOwner, Observer {
            updateViews(it.first, it.second)
        })
    }
    ...
}

次に、observe内部でViewの更新を行う関数updateViewsの中で、UiStateの各状態について記載を行います。

ListFragment.kt
@AndroidEntryPoint
class ListFragment: Fragment() {
    ...
    private fun updateViews(uiState: UiState, data: UiData?) {
        when(uiState) {
            BLANK, PARTIAL -> Unit
            ERROR -> {
                binding.progressBar.visibility = View.GONE
            }
            LOADING -> {
                binding.progressBar.visibility = View.VISIBLE
            }
            IDEAL -> {
                val uiData = data ?: throw IllegalArgumentException("Illegal results are being returned. Please review the communication.")
                val list = uiData.list
                binding.progressBar.visibility = View.GONE
                binding.recyclerView.withModels {
                    val carouselList = list.map {
                        ContributorsModel_()
                            .apply {
                                id(CONTRIBUTORS_ID, "${it.id}")
                                listData(it)
                                onRootClickListener { _ ->
                                    Toast.makeText(requireContext(), "Press ${it.name}!", Toast.LENGTH_SHORT).show()
                                }
                            }
                    }

                    customCarousel {
                        val margin12 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 12F, resources.displayMetrics).toInt()
                        val margin16 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 16F, resources.displayMetrics).toInt()
                        id(CAROUSEL_ID)
                        models(carouselList)
                        spanSizeOverride { _, _, _ -> COLUMN1 }
                        numViewsToShowOnScreen(4.5f)
                        padding(Carousel.Padding(0, margin16, 0, margin12, 0))
                    }

                    val gridList = list.take(6)
                    gridList.forEach {
                        grid {
                            id(GRID_ID, "${it.id}")
                            listData(it)
                            spanSizeOverride { _, _, _ -> COLUMN2 }
                            onRootClickListener { _ ->
                                Toast.makeText(requireContext(), "Press ${it.name}!", Toast.LENGTH_SHORT).show()
                            }
                        }
                    }

                    list.forEach {
                        listRow {
                            id(LIST_ROW_ID, "${it.id}")
                            listData(it)
                            isChanged(data.isChanged)
                            spanSizeOverride { _, _, _ -> COLUMN1 }
                            onRootClicked { _ ->
                                Toast.makeText(requireContext(), "Press ${it.name}!", Toast.LENGTH_SHORT).show()
                            }
                        }
                    }
                }
            }
        }
    }
    ...
}

サンプルでは、BlankとPartialの状態がないのでなにもせず、Loadingの状態ではProgressBarを出したいので表示し、ErrorではProgressBarを消すだけにしていので表示せず、Idealではリストを表示するようにしています。

このようにして、各状態についてどのViewの操作を行っているのか一目でわかることから、この状態ではどうするのかという議論も生まれやすくなり、開発側からも状況を把握しやすくなっています。

さいごに

今回は、Uiの状態について悩んでいるAndroid開発者がいたらその解決法の一つになるのではないかと思い紹介させていただきました。

ご紹介させていただいたサンプルアプリの全体のコードは下記リンクより参照できます。

https://github.com/yutaro6547/AndroidUITips

もし他にももっとよいやり方がある場合は、Twitterでメンションつけてでもコメントもらえると嬉しいです!私のTwitterアカウントまでご連絡ください~