導入作業
導入を始めるにあたって、まずどの画面から手を付けるか検討しました。PayPayフリマには、検証用ビルドのときのみソースセットに含まれる、開発者向けのデバッグメニュー画面が存在します。デバッグメニュー画面では、開発用環境にダミー商品をボタン1つで出品する機能や、特定のAPIからの通信レスポンスを任意のJSONに差し替えるインターセプター機能、RemoteConfigの設定値を異なる値に差し替える機能など、さまざまな検証用機能が用意されています。この画面はリリースビルドではソースセットから除外されるため、プロダクトに影響を与えることがありませんし、実装もごくシンプルなものだったため試験導入にはうってつけで、最終的なコード量も少ないのでチームメンバーが見たときにも理解しやすいだろうという理由からでした。
実際のデバッグメニュー画面のソースコードはお見せできませんので、ここでは単純化したサンプル実装をもとに、どのように導入したのか説明します。
1. 最新版のAndroid Studioをインストールする
普段からCanaryチャンネルでアップデートしているため、実際にはこの作業は行っていません。皆さんがJetpack Composeを試す際には、少なくともArctic Fox以降のAndroid Studioがインストールされていることを確認してください。なお、2021年12月時点での最新版のAndroid StudioはChipmunkです。
2. Gradleを設定する
Kotlinのバージョンが1.5.31
以上であることを確認してください。
plugins { id 'org.jetbrains.kotlin:android' version '1.5.31' }
UTFDateFormatException
が発生してビルドが失敗してしまう場合は、まず間違いなく1.5.31になっていません。おそらく推移的依存関係によって古いバージョンが使用されていると思われます。わたしたちの場合、ktlintが最新版でなかったことが原因で少しハマってしまいました。
こうしたときにはgradlew :app:dependenceis
で実際に使用されている依存ライブラリを出力して、推移的依存関係によって古いバージョンに依存していないか確認してみてください。
その後、モジュールのbuild.gradleを設定します。
android { ... compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = '1.8' useIR = true } buildFeatures { compose true } composeOptions { kotlinCompilerExtensionVersion '1.0.5' kotlinCompilerVersion '1.5.31' } packagingOptions { resources { excludes += '/META-INF/{AL2.0,LGPL2.1}' } } } dependencies { ... implementation 'androidx.activity:activity-compose:1.3.1' implementation 'androidx.compose.material:material:1.0.5' implementation 'androidx.compose.animation:animation:1.0.5' implementation 'androidx.compose.ui:ui-tooling:1.0.5' implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.4.0' implementation "androidx.compose.runtime:runtime-livedata:1.0.5" implementation "com.google.android.material:compose-theme-adapter:1.1.1" implementation "com.google.accompanist:accompanist-appcompat-theme:0.16.0" androidTestImplementation 'androidx.compose.ui:ui-test-junit4:1.0.5' }
1.0.5
や1.5.31
の箇所は、それぞれJetpack ComposeとKotlinの使用するバージョンに差し替えてください。
3. ViewHoldeを置き換える
もともとのデバッグメニュー画面の実装は、次のようなものでした。
class DebugFragment : Fragment() { override fun onCreateView( ... ): View = RecyclerView(inflater.context).apply { layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT) layoutManager = LinearLayoutManager(...) val debugMenuItems = listOf( createMenu1(), createMenu2(), ... ) adapter = DebugAdapter(debugMenuItems) } fun createMenu1() = DebugMenu( label = "メニュー1", value = { debugPreference.getBoolean("menu1", false).toString() }, onClick = { val current = debugPreference.getBoolean("menu1", false) debugPreference.edit().putBoolean("menu1", !current).apply() refresh() } ) fun createMenu2() = DebugMenu( label = "メニュー2", onClick = { findNavController().navigate(...) } ) private fun refresh() { (view as? RecyclerView)?.adapter?.notifyDataSetChanged() } data class DebugMenu( val label: String, val value: () -> String = { "" }, val onClick: () -> Unit ) }
これはリリースするプロダクトであれば適切な実装とは言えません。しかしデバッグ画面の実装としては、デバッグメニューごとの処理が一箇所にまとまっているため、必要な機能を素早く実装でき、不要になったら漏れなく消すことができる点が気に入っています。
Jetpack Composeは1つのView単位から移行できるということなので、まずはそれを試してみました。
以下は、もとのデバッグメニューのViewHolder
です。
class DebugMenuVH(view: View) : RecyclerView.ViewHolder(view) { val label: TextView = view.findViewById(R.id.text_debug_menu_label) val value: TextView = view.findViewById(R.id.text_debug_menu_value) fun bind(item: DebugMenu) { label.text = item.label value.text = item.value(itemView.context) itemView.setOnClickListener { item.onClick() } } }
これを、次のように修正しました。
class DebugMenuVH(view: View) : RecyclerView.ViewHolder(view) { // ComposeをホストするView val content: ComposeView = view.findViewById(R.id.compose_debug_menu) fun bind(item: DebugMenu) { // 以下、Compose関数 content.setContent { MaterialTheme { DebugMenuRow(item) } } } } @Composable fun DebugMenuRow(item: DebugFragment.DebugMenu) { // horizontalなLinearLayoutのようなレイアウト Row( Modifier .fillMaxWidth() .background(Color.White) .clickable { item.onClick() } .padding(horizontal = 24.dp, vertical = 16.dp) ) { Text( text = item.label, modifier = Modifier.weight(1f) ) Text(text = item.value()) } }
ComposeView
は、setContent()
にCompose関数を渡すことでUIを構築することができるViewです。
このViewによって既存のレイアウトのごく一部だけからComposeを導入することが可能となっており、ViewベースのUIフレームワークとの高い相互運用性が確保されています。
ただ、このように実装可能であるということと、その実装方法が適切であるということは違います。使い方によってはパフォーマンスに大きな影響があるので注意してください。実際、上の実装のようにRecyclerViewのすべてのViewHolderごとにComposeView
を持たせると、パフォーマンスはひどく悪化するでしょう。
そこで、続く項ではデバッグ画面全体をComposeにしてみましょう。
4. リストを作成する
デバッグ画面のように表示するリストの項目数が固定で少ない場合には、Column()
というverticalなLinearLayout
のようなレイアウトを使用しても問題ないでしょうが、商品検索画面のように項目数が不明で数も非常に多い場合にはLazyColumn()
を使用します。LazyColumnは、ビューポートに表示されるアイテムのみをコンポーズして配置するレイアウトです。
override fun onCreateView( ... ): View = ComposeView(inflater.context).apply { layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) // これだとうまく動かない val debugMenuItems = listOf( createMenu1(), createMenu2() ) setContent { MaterialTheme { LazyColumn { items(debugMenuItems) { debugMenu -> DebugMenuRow(item = debugMenu) } } } } }
実行して「メニュー1」をタップすると、すぐにこれではダメだとわかります。debugMenuItems
は固定の値なので、表示が切り替わりません。
Compose関数は引数として受け取る状態が変更されると、自動的に再実行されて表示が更新されます。このとき、変更を検知される「状態」とは、State<T>
インターフェイスの変数のことです。
そこで、debugMenuItems
をState
にして、DebugMenuRowがタップされると自動的に更新されるように修正しましょう。
なお、今回のサンプルコードではSharedPreferencesを使用しましたが、2021年8月に安定版がリリースされたDataStoreへの移行が推奨されています。DataStoreでは型安全に実装することができますし、値をFlowで返しますので、状態をStateとしてobserveできます。
このためComposeに移行するのであれば、SharedPreferencesもDataStoreに移行すると良いでしょう。
このデバッグ画面の、完成版のソースコードは以下のようになります。
class DebugFragment : Fragment() { private val debugPreferences: SharedPreferences get() = requireContext().getSharedPreferences( "debug_pref", Context.MODE_PRIVATE ) override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View = ComposeView(inflater.context).apply { layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) val refreshMenu: () -> List<DebugMenu> = { listOf( createMenu1(), createMenu2() ) } setContent { var debugMenuItems: List<DebugMenu> by mutableStateOf(refreshMenu()) MaterialTheme { LazyColumn { items(debugMenuItems) { debugMenu -> DebugMenu( item = debugMenu, onValueChanged = { debugMenuItems = refreshMenu() } ) } } } } } private fun createMenu1() = DebugMenu( label = "メニュー1", value = { debugPreferences.getBoolean("menu_1", false).toString() }, onClick = { val current = debugPreferences.getBoolean("menu_1", false) debugPreferences.edit().putBoolean("menu_1", !current).apply() } ) private fun createMenu2() = DebugMenu( label = "メニュー2", value = { "" }, onClick = { // findNavController().navigate(R.id.some_screen) } ) data class DebugMenu( val label: String, val value: () -> String = { "" }, val onClick: () -> Unit ) } @Composable fun DebugMenu(item: DebugFragment.DebugMenu, onValueChanged: () -> Unit = {}) { Row( Modifier .fillMaxWidth() .background(Color.White) .clickable { item.onClick() onValueChanged() } .padding(horizontal = 24.dp, vertical = 16.dp) ) { Text( text = item.label, modifier = Modifier.weight(1f) ) Text(text = item.value()) } }
なお、本来ならばこのような状態はViewModelが持ち、Fragment側はその値を参照するだけで、ViewModelに「タップされた」などのイベントを送るようにするべきです。今回はデバッグ画面であることや、サンプルコードとしての簡潔さのためにこのような作りとしました。