STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

STORES 決済 Androidチーム式 Composeルールブック!

STORES 決済 Androidチームは今、絶賛Compose中です!

こんにちは、STORES 決済 Androidチームのみっちゃんです。

最近、決済AndroidチームではUIをAndroid ViewからJetpack Composeに移行し始めました。まだ始めたばかりで、チーム全員がComposeに精通しているわけではないため、Composeを書く際に生じた疑問をその都度チーム内で話し合い、ルールを決めています。このブログでは、その過程で決まったチーム内のComposeルールの一部を公開します!

Comopse移行 チーム内ルール一覧

1. Eventの扱い

公式が提供するアーキテクチャに従い、Jetpack ComposeでUIを構成する場合、UI要素とViewModelの間にStateとEventの二つの概念が存在します。

StateとEvent

私たちのチームでは、まずプロダクト内でどのようなものをEventと考え、実際にコード上でどのようにEventを扱うかを決めました。

そもそもEventとは

ドキュメント中では、Eventに関して次のように説明されています。

Event: Part of the UI generates an event and passes it upward, such as a button click passed to the ViewModel to handle; or an event is passed from other layers of your app, such as indicating that the user session has expired.

イベント:UIの一部がイベントを生成しそのイベントを上位に渡します。例えば、ボタンクリックイベントがViewModelに渡されて処理されたり、セッション有効期限切れを示すイベントがアプリの他の層から渡されたりします。

例えば、ボタンクリックなどのユーザー操作をEventとして捉え、onClickHogeHogeのようなメソッドを用いて発生したEventを検知し、ViewModelに伝達すると記述されています。つまり、ユーザー操作によってUI側から発生し、ViewModelに伝達したい情報をEventと考えることができます。

また、上記のような説明に加えて、画面の変更を伴わないものは全てEventであると考えることもできます。

例えばToastやDialogの表示は、画面に表示される文言を変えるような変更とは異なり、常に最新の状態を保っている必要がありません。したがってワンショットのイベントと考えることができます。

まとめると、

  • イベントとは
    • UI要素から生じてViewModelへ伝達したい情報
    • 画面の変更を伴わないもの
    • ワンショットなデータ・通知
      • ToastやDialog、画面遷移 など

こんな感じの認識です。

どのように扱うか

前述の通り、Eventはワンショットであるため、StateFlowよりもSharedFlowでの扱いが向いています。StateFlowは初期値が必要で、イベントを発火した後にnullを代入してリセットしないと、最後の値を保持し続けてしまいます。リセット処理を忘れると、画面回転などのコンフィギュレーションチェンジの後にもう一度イベントが発火してしまうバグが発生します。一方、SharedFlowは初期値を持たないので、イベントが終わった後にnullを代入してキャンセルする必要がありません。

私たちのチームでは最初、EventをStateFlowで扱っていましたが、Composeへの移行を進める中で、nullを代入してイベントをリセットするのが面倒だという結論に至り、SharedFlowで扱うように変更しました。

実際に STORES 決済 アプリのヘルプ画面を例にとって考えてみましょう。 例えばこの画面のサービスガイドをタップするとweb上のサービスガイドページへ遷移し、電話にてお問い合わせをタップすると端末内の電話アプリを開き、メールにてお問い合わせを開くとメールアプリを開きます。

ヘルプ画面

この、サービスガイドページに遷移させる、電話アプリを開く、などのアクションはEventと捉えることができます。私たちのチームでは、これらのEventをsealed classでまとめ、SharedFlowで扱うようにしました。

sealed class HelpUiEvent {
    data class OpenUrl(val url: String) : HelpUiEvent()
    data class OnCall(val phoneNumber: Uri) : HelpUiEvent()
    data class SendEmail(val emailUri: ((Context) -> Uri)) : HelpUiEvent()
}
// ViewModel
class HelpViewModel : ViewModel() {
    private var _uiEvent = MutableSharedFlow<HelpUiEvent>() 
    val uiEvent = _uiEvent.asSharedFlow()
    
    ...
    
    fun onEmailSupportClicked() {
        viewModelScope.launch {
            _uiEvent.emit(HelpUiEvent.SendEmail(createEmailUri()))
        }
    }

そしてsealed classでEventをまとめたことによってwhen文で網羅的な処理ができるようになりました。

// Composableの中
LaunchedEffect(viewModel, lifecycleOwner) {
        viewModel.uiEvent
            .flowWithLifecycle(lifecycleOwner.lifecycle)
            .onEach { event ->
               // TIPS: when文で網羅的に処理できる
                when (event) {
                    is HelpUiEvent.OpenUrl -> 
                    is HelpUiEvent.OnCall -> 
                    is HelpUiEvent.SendEmail -> 
                }
            }.launchIn(this)

🚨注意🚨
Android Developersの記事で、Eventを扱う際にSharedFlowを使うのはアンチパターンであると紹介されているのもあったりします。 (参考記事:ViewModel: One-off event antipatterns

SharedFlow以外でEventを扱い、かつwhenで網羅的に処理を制御したい場合はこちらの記事:ViewModelイベントの実装 を参考にすると良さそうです。

2. Padding VS Spacer

ComposeでUIを構成するとき、余白を実装するのにPaddingを設定する方法とSpacerを置く方法の2パターンが考えられます。これについてはこちらの記事で詳細に説明しておりますので是非ご参考ください。

product.st.inc

この記事の内容を簡単に要約しておくと次の通りです。

Padding

  • コンポーザブルとその親の間にスペースが必要な場合に使う
  • clickableの領域を考慮する場合に使う

Spacer

  • ColumnやRowなどのレイアウトコンテナ内の兄弟間にスペースを追加する必要がある場合に使う
    • 行や列の中にある2つのコンポーザブルの間に、それぞれのパディングを調整することなくスペースを追加したい場合に便利

3. 二重コロン演算子を使おう

Composeでコードを書くと、その性質上ラムダが多くなりがちです。ラムダがネストするとコードの可読性が低下するため、それを回避するために積極的に二重コロン演算子(::)を使用して、メソッド参照を行いましょう!

HogeScreen(
     uiState = uiState,
     onPhoneEnquiriesClick = viewModel::onCallSupportClicked,
     onEmailEnquiriesClick = viewModel::onEmailSupportClicked,
)

4. Previewはprivateで宣言しよう

私たちのチームでは、Previewをpublicで宣言していると他の画面のPreviewがサジェストされて少し煩わしいというのと、そもそもComposeのPreviewに関わらずそのクラスでしか使われていないメソッドならばその証明としてprivateをつけておいた方がコード上お行儀が良いのではという考えもあり、Previewはprivateで宣言するというルールを作りました。

注意すべき点として、Roborazziなどのスクリーンショットテストを導入したい場合はPreviewをpublicにしておく必要があります

@Preview(showBackground = true)
@Composable
private fun HogeItemPreview() {
    Theme {
        HogeItem(...)
    }
}

5. Previewの作成単位

皆さんのチームではPreviewの作成単位についてチームで合意をとっていますか? 私の周りでアンケートをとった感じだとルールがあるチームはあまりないように思えました。 皆さん各々のPreview観に従って作成しているのですかね。

私たちのチームはまだComposeに慣れていない人も多いというのもあって、ばらつきが出ないようにPreviewの作成単位を決めました。基本的には、Component・Section・Screenの全ステップでPreviewを作ります

全ステップでPreviewを作成することによって、どの画面で使われているどんなUIなのかすぐに分かるので、チームのデバッグパフォーマンスが向上すると思います。

ComponentやSectionのステップでは、作成したComponentごとにPreviewを作成し、正常系や異常系も含めて考えられるパターンを全て網羅します。パターンが多い場合は、適宜PreviewParameterなどを利用して表示を工夫します。

皆さんのチームにはどんなルールがありますか

まだまだ私たちのCompose移行の旅は始まったばかりなので、今後も何か問題があればその都度チームで話し合い、最適なチーム内Composeルールを追加していくつもりです。

皆さんのチームはどんなルールでCompose移行を進めていますか?
よかったらコメントやこの記事を引用してXなどで教えてください^-^)ノシ