XMLで書かれたUIをJetpack Composeで書き換える手順の紹介

はじめに

ブランドソリューション開発本部フロントエンド部FAANSの山田(@yshogo87)です。

本投稿では、すでにXMLで書かれたレイアウトをJetpack Composeにリファクタリングした理由とその手順について紹介します。

リファクタリングする画面の問題点

FAANSでは「コーデ閲覧数、送客数、売上数」を表示する画面があります。

impression

この画面はUIの状態が一元管理されておらず状態がViewのみにしかないことで、機能の追加修正時に不具合を作り込みやすく、リリースまでに時間がかかるという問題がありました。

UIの状態変更が一元管理されていない

この画面ではUIの状態変更が複数の異なるソースコードから行われていて、一元管理されていませんでした。UIの状態を変えている場所は大きく分けると2つです。

現在のUIの状態変更が別のUIから行われる

この画面は1つのXMLファイルにViewPagerとRecyclerViewがあり、ViewPagerのグラフタップや横スワイプで下のRecyclerViewの情報も変わる仕様になっています。

impression_edit

この仕様のため、AdapterクラスにFragmentのBindingを渡して直接別のRecyclerViewの状態を変更する実装でした。

// Adapterクラス
class CoordinateImpressionsGraphAdapter(
    private val binding: FragmentCoordinateImpressionsBinding,
    private val viewModel: CoordinateImpressionsActionDelegateImpl,
    diffCallBack: DiffCallBack = DiffCallBack()
) : RecyclerView.Adapter<CoordinateImpressionsGraphBindingHolder>() {
    override fun onBindViewHolder(holder: CoordinateImpressionsGraphBindingHolder, position: Int) {
        ・
        ・
        ・
        // グラフのタップイベント
        chart.setOnChartValueSelectedListener(object : OnChartValueSelectedListener {
            override fun onValueSelected(e: Entry, h: Highlight?) {
                // 別のRecyclerViewを直接更新している
                (binding.impressionsDetail.adapter as CoordinateImpressionDetailAdapter).submitList(initialBrandList)
            }
            override fun onNothingSelected() {}
        })
        ・
        ・
        ・
    }
}

このようにAdapterクラスから別のUIの状態を変更され、現在のUIの状態がViewにしか保持されていないことが問題でした。UIの状態がViewだけに存在すると、状態の把握・管理することが難しくなり実装に時間を要していました。

UIの状態変更が複数のLiveDataから行われる

ViewModelからFragmentにイベントやUIの状態変更時にLiveDataを使っていました。これらのLiveDataは「UIの状態変更すること」と「イベントをFragmentに伝えること」の2つの役割があって区別されていませんでした。

class CoordinateImpressionsViewModel @Inject constructor(
    private val coordinateImpressionsUseCase: CoordinateImpressionsUseCase
) : ViewModel() {
    ・
    ・
    ・
    ・
    private val _navigatePopStack = MutableLiveData<Event<Unit>>()
    val navigatePopStack: LiveData<Event<Unit>>
        get() = _navigatePopStack
    private val _coordinateImpressions = MutableLiveData<WearCoordinateImpressions>()
    val coordinateImpressions: LiveData<WearCoordinateImpressions>
        get() = _coordinateImpressions
    private val _hasError = MutableLiveData<ErrorType>()
    val hasError: LiveData<ErrorType>
        get() = _hasError
    private val _isLoading = MutableLiveData<Boolean>()
    val isLoading: LiveData<Boolean>
        get() = _isLoading
    private val _currentPosition = MutableLiveData<Int>()
    val currentPosition: LiveData<Int>
        get() = _currentPosition
    
    ・
    ・
    ・
    ・
}

UIの状態を変更するLiveDataが分かれているので、状態を変更する処理を追って確認しながら実装していく必要があるため、機能追加や仕様変更に時間を要していました。

UIの状態を一元管理するようにする

「コーデ閲覧数、送客数、売上数」を表示する画面はFAANSアプリのメイン機能であり、今後も機能追加されることが予想されることから以前より、リファクタリングを検討していました。PMチームなどにも現状の問題点を共有して、タスクを調整し時間をとってリファクタリングすることとなりました。

「現在のUIの状態変更が別のUIから行われること」と「複数のLiveDataからUIの状態が変更されること」の2点の問題を解決するためにUIの状態変更を一元管理するように修正します。

LiveDataを1つにし、Jetpack Composeに変更するリファクタリングを行う

UIの状態変更を一元管理するために、データの流れを整理するようにします。また、「UIの状態変更が複数のLiveDataから行われる」という問題を解決するために、LiveDataは1つにするようにします。

そしてFAANS Androidアプリで以前よりJetpack Composeを導入しているため、UIもXMLファイルからJetpack Composeに変更していきます。

UIの状態を一元管理する

リファクタリング方針の紹介

「UIの状態変更を一元管理」するために「単方向データフロー」の設計パターンを取り入れることにしました。データの流れを単方向にすることで明確にし、状態の把握・管理をしやすくしていきます。

UIの状態管理する2つのクラスを作成します。

  • State
    • 画面の状態を保持する (例:プログレスのON/OFF、Userのデータなど)
  • Action
    • FragmentからのイベントをActionとしてViewModelに伝える (例:投稿ボタンをタップ、PullToRefreshイベントなど)

architecture

LiveDataはStateの1つにします。また、今回の実装でLiveDataをStateFlowに変更しています。

UIはStateの更新を監視し、Stateの情報に従ってUIが構築するように実装していきます。そして、Stateの状態を変更したい場合はActionを使って変更をViewModelに伝えてViewModelの中でStateを更新します。このようにすることで、「UIはStateの変更だけを監視」と「ViewModelはStateを変更を行う」ようになり、UIの状態を一元管理できるようになります。

設計に沿ってリファクタリング

前節で紹介した設計に従ってリファクタリングしていきます。

下記は、「ViewModelはState更新」「UIはStateの購読」をするソースコードです。

ViewModelでは、APIリクエストの結果をStateに渡していてUIはリクエストの結果があれば画面に反映しています。そしてユーザーのタップのイベントは、Actionを使ってViewModelで受けとりStateを更新しています。

class SummaryViewModel(
    repository: HomeRepository,
) {
    private val _state = MutableStateFlow(State.Initial)
    val state: StateFlow<State> = _state
 
    init {
        fetchSummaryData()
    }
    fun dispatchAction(action: Action) {
        viewModelScope.launch {
            try {
                when (action) {
                    is Action.UpdateSummary -> {
                        _state.value = _state.copy(
                            content = it
                        )
                    }
                }
            } catch (e: Throwable) {
                // エラー処理
            }
        }
    }
    private fun fetchSummaryData() {
        viewModelScope.launch {
            _state.update { _state.value.copy(isLoading = true) }
            when (val result = repository.getSummaryData()) {
                is ResultWrapper.Sucess -> {
                    _state.value = _state.copy(
                        content = result.value
                    )
                }
                is ResultWrapper.Error -> {
                   // エラー処理
                }
            }
            _state.update { _state.value.copy(isLoading = false) }
        }
    }
}
 
sealed class Action {
    object UpdateSummary : Action()
}
 
data class State(
    val userInfo: UserInfo? = null,
    val content: Content? = null,
    val isLoading: Boolean = false,
) {
    companion object {
        val Initial = State()
    }
}

Fragment側でStateを購読します。

class SummaryFragment : Fragment() {
    private val viewModel: SummaryViewModel by viewModels()
    
    private lateinit var binding: FragmentSummaryBinding
    private lateinit var pagerAdapter: GraphAdapter
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = FragmentSummaryBinding.inflate(inflater, container, false)
        pagerAdapter = GraphAdapter()
        return binding.root
    }
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        lifecycleScope.launchWhenStarted {
            viewModel.state.collectLatest { state ->
                // Stateの情報に従ってUIの状態を変えていく
                setSummaryView(state)
            }
        }
    }
    private fun setSummaryView(summary: State) {
        if (summary.contentt != null) {
            binding.containerGraph.post {
                val newList = pagerAdapter.summaryList.toMutableList()
                newList.add(summary.list)
                pagerAdapter.list = newList.toList()
                pagerAdapter.notifyItemRangeChanged(newList.size)
            }
        }
        binding.loadingPanel.isVisible = state.isLoading
        binding.graphLoadingPanel.isVisible = state.isLoading
}

他のファイルにあるUIの状態変更処理は、Fragmentに集約してActionとしてViewModelに伝えるようにしていきます。 例えば、Adapterクラスにある状態変更処理は、onTapGraphItem のようなコールバックをパラメータとして渡してFragmentで受け取り、Action.UpdateSummary(data) としてViewModelに伝えています。

// Adapterの実装
class CoordinateImpressionsGraphRecyclerAdapter(
    private val onTapGraphItem: (CoordinateImpressionDetail) -> Unit,
    diffCallBack: DiffCallBack = DiffCallBack()
) : RecyclerView.Adapter<CoordinateImpressionsGraphBindingHolder>() {
    ・
    ・
    ・
    private fun setUpGraphView(chart: BarChart, axis: BarChart, data: WearCoordinateImpressions) {
    ・
    ・
    ・
        // グラフタップイベント
        chart.setOnChartValueSelectedListener(object : OnChartValueSelectedListener {
            override fun onValueSelected(e: Entry, h: Highlight?) {
                ・
                ・
                ・
                // タップイベントをコールバックとして渡す
                onTapGraphItem(date.coordinateImpressionDetail)
            }
            override fun onNothingSelected() {}
        })
    }
    ・
    ・
    ・
}
// Fragmentの実装
class SummaryFragment : Fragment() {
    private val viewModel: SummaryViewModel by viewModels()
    
    private lateinit var binding: FragmentSummaryBinding
    private lateinit var pagerAdapter: GraphAdapter
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        binding = FragmentSummaryBinding.inflate(inflater, container, false)
        pagerAdapter = GraphAdapter()
        recyclerAdapter = SummaryAdapter(
            onTapGraphItem = { data ->
                // ViewModelを経由してStateの情報を更新する
                viewModel.dispatchAction(Action.UpdateSummary(data))
            }
        )
        return binding.root
    }
    ・
    ・
    ・
    private fun inflateSummaryView(summary: Contents?) {
        if (summary != null) {
            binding.containerGraph.post {
                val newList = adapter.summaryList.toMutableList()
                newList.add(summary.list)
                adapter.list = newList.toList()
                adapter.notifyItemRangeChanged(newList.size)
                binding.loadingPanel.visibility = View.GONE
                binding.graphLoadingPanel.visibility = View.GONE
            }
        }
    }
}

このようにUIの状態はすべてStateが保持していて、UIの状態変更はViewModelを経由してStateを更新するようにします。 そしてStateを購読している箇所で、Stateの状態に従ってUIを構築するように修正します。 以上で、UIの状態変更を一元管理するリファクタリングを行いました。

Stateの情報に従ってJetpack Composeでレイアウトを書いていく

状態の一元管理とJetpack Composeの相性が良い

Stateの情報でUIの構築するリファクタリングで当初の「UIの状態変更を一元管理するようにする」という目的は達成できています。 ただ、Jetpack Composeでも単方向データフローパターンを推奨しています。 https://developer.android.com/jetpack/compose/architecture?hl=ja#udf このため、XMLで書かれた現在のレイアウトもJetpack Composeで書き換えもスムーズに行えます。

Jetpack Composeへの書き換え

XMLでUIを作る場合、findViewById() などを用いてUIウィジェットを取得し、view.isVisible = trueのように操作することでUIの状態を変更するのが一般的です。このように手動で操作すると、UIの更新を忘れがちでエラーが発生しやすくなります。Jetpack Composeは宣言的UIフレームワークであり、この手動でUIを操作する複雑さを回避できます。 XMLを用いた場合のサンプルコードでも手動でUIを操作している箇所があり、Jetpack Composeで書き換えることでこのような手動でのUIの操作をなくします。また、RecyclerViewによるリストもJetpack Composeではより少ないコード量で実現でき可読性が向上します。

class SummaryFragment : Fragment() {
    private val viewModel: SummaryViewModel by viewModels()
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?,
    ): View {
        return ComposeView(requireContext()).apply {
            setViewCompositionStrategy(
                ViewCompositionStrategy.DisposeOnLifecycleDestroyed(viewLifecycleOwner),
            )
            setContent {
                SummaryRoute()
            }
        }
    }
}
@Composable
fun SummaryRoute(
    viewModel: SummaryViewModel = viewModel()
) {
    // Stateの購読を行う
    val lifecycleOwner = LocalLifecycleOwner.current
    val viewState by remember(viewModel.state, lifecycleOwner) {
        viewModel.state.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED)
    }.collectAsState(State.Initial)
    SummaryScreen(
        viewState = viewState,
        viewActionDispatcher = { action ->
            viewModel.dispatchAction(action)
        }
    )
}
@Composable
fun SummaryScreen(
    viewState: State,
    viewActionDispatcher: (Action) -> Unit = { _ -> },
) {
    // Stateの情報に従ってUIを構築する
    Scaffold(
        backgroundColor = MaterialTheme.colors.surface,
        topBar = {
            TopAppBar(
                title = {
                    Text(
                        text = "ToolBar",
                        fontWeight = FontWeight.ExtraBold,
                        color = MaterialTheme.colors.onSurface
                    )
                },
            )
        }
    ) {
        // Stateの状態に従ってプログレスのON/OFFを切り替える
        if (viewState.isLoading) {
            Progress()
        } else {
            // Adapterクラスは必要なくリストにデータを渡すのみ
            Contents(
                userInfo = viewState.userInfo,
                content = viewState.contents
            )
        }
    }
}
@Composable
fun Contents(
    userInfo: User,
    contents: Contents
) {
    LazyColumn {
        ・
        ・
        ・
    }
}

まとめ

今回、XMLで書かれたレイアウトをJetpack Composeで書き換えました。 リファクタリングによって実装者がUIの状態を管理しやすくなり、想定外の状態変更が起こりづらく機能追加や仕様変更が容易になりました。 また、Jetpack Composeを使ったことでStateの更新で自動的にUIを変更することができるので実装が楽になりました。ソースコードの量も削減でき、可読性を向上させることができました。

最後に

ZOZOではAndroidエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください。

hrmos.co

カテゴリー