SHOEISHA iD

※旧SEメンバーシップ会員の方は、同じ登録情報(メールアドレス&パスワード)でログインいただけます

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

アプリケーション開発の最新トレンド

既存のAndroidアプリに「Jetpack Compose」を導入する際の勘所~PayPayフリマの場合

  • X ポスト
  • このエントリーをはてなブックマークに追加

導入作業

 導入を始めるにあたって、まずどの画面から手を付けるか検討しました。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.51.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>インターフェイスの変数のことです。

 そこで、debugMenuItemsStateにして、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に「タップされた」などのイベントを送るようにするべきです。今回はデバッグ画面であることや、サンプルコードとしての簡潔さのためにこのような作りとしました。

次のページ
開発に使ってみた所感

この記事は参考になりましたか?

  • X ポスト
  • このエントリーをはてなブックマークに追加
アプリケーション開発の最新トレンド連載記事一覧

もっと読む

この記事の著者

森 洋之(ヤフー株式会社)(モリ ヒロユキ)

ヤフー株式会社 ヤフオク!カンパニー開発本部 サービス開発部 黒帯 Android 技術1982年広島県生まれ。Androidアプリの個人開発などを経て、2012年ヤフー株式会社入社。「ヤフオク!」などのAndroidアプリ開発に携わる。2014年10月にAndroid技術の分野で黒帯に認定される。...

※プロフィールは、執筆時点、または直近の記事の寄稿時点での内容です

この記事は参考になりましたか?

この記事をシェア

  • X ポスト
  • このエントリーをはてなブックマークに追加
CodeZine(コードジン)
https://codezine.jp/article/detail/15404 2022/02/08 11:00

おすすめ

アクセスランキング

アクセスランキング

イベント

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

新規会員登録無料のご案内

  • ・全ての過去記事が閲覧できます
  • ・会員限定メルマガを受信できます

メールバックナンバー

アクセスランキング

アクセスランキング