LoginSignup
10
13

More than 1 year has passed since last update.

既存の Android アプリの UI を Jetpack Compose で書き換えた際の知見をまとめてみる

Posted at

概要

View & Fragment で画面を20ほど作っていた既存のアプリを1ヶ月かけて Jetpack Compose で書き換えたので、その際の進め方や気づいたことをまとめてみます。

Jetpack Compose を使う意義や具体的な書き方については別の記事をお読みください。

モチベーション

Jetpack Compose 移行する前と移行した後 を読んでやってみたくなったのがきっかけでした。特にビルド時の最適化のパフォーマンスが向上するという点に興味を持ちました。

最大のポイントは Compose がほとんどのデベロッパー指標で良好な(または中立的な)影響を示していることです。この点と Compose でデベロッパーの生産性が大幅に向上することを踏まえれば、いろいろ考えなくても、Compose が Android の UI 開発の未来であることはわかります。

書き換えのステップ

大まかに書くと以下の3ステップで進めました。

  1. View & Fragment で作っている画面を ComposeView & Fragment に書き換え
  2. 1で作り変えた Fragment から ComposeView の中身を別のファイルのトップレベル関数に移動
  3. Activity をコピペし、2の Composable 関数をコールして画面を描画するよう修正
  4. 必要なくなったレイアウトファイルや AppCompat や Material Design Components(View の方)の依存を削除

インターネットの記事だと View 単位での置き換えから始めている事例があり、勉強を兼ねて進めるならそれもありだとは思います。
私は早く進めたかったので View 単位での置き換えはせず、Fragment の contentView に ComposeView を使って、その中で画面全体を描画する形式に置き換えていくことにしました。

実際に業務で担当するアプリの置き換えを進めていく場合、よほどの事情がない限りは「全部置き換え終わってからリリース」をしないように計画を立てた方が良いと思います。
1をやっている最中なら施策の実装や定期リリースを一緒にやることができますし、
また、2や3の段階でも置き換え中のコードがリリースの成果物から参照できないようにしておけば、通常の施策と並行して進められるでしょう。

なお、Jetpack Compose を依存に含めると最適化なしでメソッド数が2万以上増えます。
Single Dex ギリギリを維持していて、起動時間削減のためにどうしても Single Dex を維持したい場合は、置き換えの最終段階までブランチを分けておく等の配慮が必要になりそうです。すでに Multi Dex でリリースをしていて、起動時間がそこまでネックにならないならこの点は気にする必要はありません。

Recompose での自動書き換え

最初のうちは既存の layout.xml で書いた View を Compose でどう書いたらいいのかまったく見当もつかないと思います。

私はまず Recompose を使って、既存の Layout XML を自動変換することにより、どのように対応するのかを見ていくことにしました。
Recompose は Gradle スクリプトと Android Studio プラグインの形式で提供されていて、前者はリポジトリーをクローンして Gradle コマンドで動かすことができます。

簡単なファイルのコンバート

試しに layout というフォルダーを作って、そこにレイアウト XML を置いてコマンドを実行します。

$ gradlew recompose-cli:run --args="../layouts/item_planning_poker.xml"

同じフォルダーに item_planning_poker.kt というファイルが出力されます。その中身は以下の通りです。

// layout() {
    // data() {
        // variable()
    // }
    Card(modifier = Modifier.width(350.dp).fillMaxHeight()) {
        Text(text = "", fontSize = 240.dp, modifier = Modifier.fillMaxWidth().fillMaxHeight())
    }
// }

dimen は置き換え必要

ちょっと複雑なファイルを変換させてみようと思ったら、以下のエラーが出ました。

Could not translate file: ..\layouts\activity_main.xml
 - Unknown size value: @dimen/menu_button_size

dimen リソースを使っている箇所は全部ハードコードしてから変換をかけないとダメそうです。

割と複雑なファイルのコンバート

CoordinatorLayout 等を使った、比較的複雑なレイアウトファイルはどんな感じでコンバートされるのかを見てみました。

// layout() {
    Box(modifier = Modifier.fillMaxWidth().fillMaxHeight()) {
        Image(modifier = Modifier.fillMaxWidth().fillMaxHeight())
        // androidx.coordinatorlayout.widget.CoordinatorLayout(modifier = Modifier.fillMaxWidth().fillMaxHeight()) {
            // androidx.fragment.app.FragmentContainerView(modifier = Modifier.fillMaxWidth().fillMaxHeight())
            // ViewStub(modifier = Modifier.fillMaxWidth())
            // ViewStub(modifier = Modifier.fillMaxWidth().height(112.dp))
            // com.google.android.material.bottomappbar.BottomAppBar(modifier = Modifier.fillMaxWidth()) {
                Box(modifier = Modifier.fillMaxWidth().fillMaxHeight()) {
                }
            // }
            // com.google.android.material.floatingactionbutton.FloatingActionButton(modifier = Modifier.width(44.dp).height(44.dp))
        // }
    }
// }

このツールの結果をそのまま使うというよりは、既存の View と Compose での書き方を勉強するのに使って、
最終的には手直しを入れていくのが良さそうだと感じました。

1. View & Fragment で作っている画面を ComposeView & Fragment に置き換え

このタイミングで覚えておくと良いことをいくつか書いておきます。

「Compose のコンポーネントは状態を持たず、別に状態を持つ仕組み(ViewModel 等)か変数が必要になる」というキーになる考え方があるのですが、
これについては実際に書いてみて理解を深めた方が良いと思います。もしくは参考のところに書いた書籍を読んでみてください。

既存リソースの利用

これまで XML で定義してきた各種のリソースは以下の関数を使うことで引き続き利用できます。

  • Text で string リソースを使いたい:stringResource(stringId: Int)
  • Image で drawable リソースを使いたい:painterResource(drawableId: Int)
  • dimen リソースを使いたい:dimentionResource(dimenId: Int)
  • color リソースを使いたい:colorResource(colorId: Int)

なお、色については Theme で定義する方式の方が楽なので、そちらを用いることをお勧めします。

テーマ作成

こんな感じでテーマを定義すれば、いちいちあちこちで色を設定する必要がなくなります。
そして、ダークモードが正式対応になる前の OS でもフラグの渡し方を変えてやればダークモードの表示確認ができるようになります。

@Composable
fun AppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val preferenceApplier = PreferenceApplier(LocalContext.current)
    val colors =
        if (darkTheme)
            darkColors(
                primary = /* TODO: Color */,
                surface = Color(0xFF0F0F0F),
                background = Color(0xFF0F0F0F),
                onPrimary = /* TODO: Color */,
                onSurface = Color(0xFFF0F0F0),
                onBackground = Color(0xFFF0F0F0)
            )
        else
            lightColors(
                primary = /* TODO: Color */,
                surface = Color(0xFFF0F0F0),
                background = Color(0xFFF0F0F0),
                onPrimary = /* TODO: Color */,
                onSurface = Color(0xFF000B00),
                onBackground = Color(0xFF000B00)
            )

    MaterialTheme(
        colors = colors,
        typography = Typography(),
        shapes = Shapes(),
        content = content
    )
}

Compose 対応の ColorPicker

探したところ以下のライブラリーがありました。高さを明示的に設定しないと表示崩れを起こします。

Android Jetpack Compose Color Picker

Compose の Color を ColorInt にする

toArgb() を使えば変換できます。

bgColor.value.toInt() // x
bgColor.toArgb() // 〇

参考

How to Convert androidx.compose.ui.graphics.Color to android.graphics.Color (int)

既存の View の流用

私のアプリで使っていた範囲では、以下の View は Compose に対応するものがなかったので AndroidView で囲うことにより使用しました。

  • DatePicker
  • WebView
  • ZXing Embedded

また、ViewPager も Compose 標準には対応するものがないので、そこはスワイプで画面を切り替える機能を諦めて、タブのタップで画面を切り替えるようにしました。

Compose で Markdown を表示

どうやったら良いのかを調べたところ、compose-richtext というのがあるようです。

View の CoordinatorLayout を Compose から動かす

私のアプリの場合、Activity が持っている AppBar を、CoordinatorLayout と NestedScrollView を使って、
Fragment のコンテンツのスクロールに合わせて出たり引っ込んだりする実装をしていました。

Fragment の中身を ComposeView で実装し直した場合、
従来の Activity & CoordinatorLayout & AppBar の箇所を動かすにはどうしたら良いのか調べたところ、
Chris Banes 氏が Interop を書いていました。

ViewInteropNestedScrollConnection.kt

使うには Compose と AndroidX 両方の依存が必要です。
これをコンテンツ側の Composable の Modifier.nestedScroll に設定してやれば、Activity 側の CoordinatorLayout が動くようになります。

Surface(
    modifier = Modifier
        .nestedScroll(rememberViewInteropNestedScrollConnection())
) {

全部の置き換えが終わるまではこれで AppBar を動かしていました。

参考

Android Compose - Use traditional View with ComposeView

remember を使うと出るエラー

インターネットのサンプルコードだと State を by で取得しているものが多いのですが、
それを使おうとすると以下のコンパイルエラーが頻繁に出ます。

'TypeVariable(T)' has no method 'getValue(Nothing?, KProperty<*>)' and thus it cannot serve as a delegate

この場合は以下の import を書けばよいらしいです。

import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

.value を書くのが面倒でないなら、val countState = remember { mutableStateOf(1) } のように = で初期化した方が変数を val にできます。
私はこのスタイルの方を主に使いました。

Composable を引数で渡す

Composable を引数で渡すには以下のように書けば良いようです。

fun replace(composable: @Composable() () -> Unit) {

参考

How to pass children in Jetpack Compose to a custom composable?

画面を開いたら TextField にフォーカスを当てる

FocusRequester を使います。

参考

Request Focus on TextField in jetpack compose

2. 1で作り変えた Fragment から ComposeView の中身を別のファイルのトップレベル関数に移動

この際に注意すべき点としては以下が挙げられます

  • Fragment のライフサイクルに依存した処理の書き換え:例えば、onPause でやっていた状態保存処理や onDetach でやっていた破棄処理などは、LifecycleCallback や DisposableEffect 等を使って書き換える必要があります
  • Fragment のフィールドに依存した処理の書き換え:主なところだと Activity Result を使う処理を書くのに activity-compose の rememberLauncherForActivityResult が必要となります
  • Fragment のライフサイクルで Observe していた LiveData の処理:Compose で書き換えた場合は LiveData よりも State を使うことが多くなるので、その場合はそんなに問題ないのですが、LiveData を使って状態や値ではなくイベントをやり取りしているようなケースでは、observer のライフサイクルに注意する必要があります。上手く書かないとイベントを多重受信してしまいます

3. Activity をコピペし、2の Composable 関数をコールして画面を描画するよう修正

従来 FragmentManager で切り替えをしていた箇所を、
NavHost と NavHostController で画面切り替えを実装するように修正していくことになると思います。
作業途中の Activity は元々の Activity とは別に作っておいた方が良いでしょう。

参考

作り変えの際に困ったこと

OptionMenu の移行

Activity や Fragment では menu リソースを使って OptionMenu を追加することができます。
これを Compose でやるにはどうしたらよいのか調べたところ、そのまま使うことはどうやらできそうにないとわかりました。
仕方ないので、OptionMenu のアイコンを表示した Icon に DropdownMenu を Box で重ねることでそれっぽいものを作ることにしました。

OptionMenu については、そもそもその機能を OptionMenu という形で提供するのが本当に適切なのか?については考え直した方が良いかもしれません。
本来、画面の機能は画面内のコンポーネントで提供すべき、という考えがこうした実装の変更に繋がっているのではと思いました。

val openOptionMenu = remember { mutableStateOf(false) }

Box(
    modifier = Modifier
        .width(32.dp)
        .clickable { openOptionMenu.value = true }
) {
    Icon(
        painterResource(id = R.drawable.ic_option_menu),
        contentDescription = stringResource(id = R.string.title_option_menu),
        tint = tint
    )

    DropdownMenu(
        expanded = openOptionMenu.value,
        onDismissRequest = { openOptionMenu.value = false }) {
        optionMenuItems.forEach {
            DropdownMenuItem(onClick = {
                openOptionMenu.value = false
                it.action()
            }) {
                Text(it.text)
            }
        }
    }
}
data class OptionMenu(
    @StringRes val titleId: Int,
    val action: () -> Unit
)

DropdownMenu のパフォーマンス改善

DropdownMenu は表示時にすべてのアイテムを生成するので、要素数が多く(50以上?)なると表示が遅くなります。
これを回避するにはどうしたら良いかというと、単に DropdownMenu の中身を LazyColumn にすればOKです。
LazyColumn なら表示される分だけ生成されるので、全体の要素数がいくらになろうがストレスなく表示可能です。

RecyclerView の ItemTouchHelper

これに相当する仕組みは現時点だと存在しないので、自分で swipeable を工夫して実装するか、OSS を使うしかなさそうです。

ModalBottomSheetLayout を使った際の問題

The initial value must have an associated anchor.

ModalBottomSheetLayout のコンテンツを後込め式にするとこの例外が出ます。
空っぽの Box を置いて、それに Modifier.defaultMinSize(1.dp, 1.dp) を指定すれば回避できます。

ModalBottomSheetLayout(
    sheetContent = {
        Box(modifier = Modifier.defaultMinSize(1.dp, 1.dp)) {
            anyContent()
        }
    },

Jetpack-Compose ModalBottomSheetLayout throws java.lang.IllegalArgumentException with initial state “Hidden”

A MonotonicFrameClock is not available in this CoroutineContext. Callers should supply an appropriate MonotonicFrameClock using withContext.

これは普通の CoroutineScope で ModalBottomSheetState を切り替えようとすると出てくるので、
rememberCoroutineScope を使う必要があります。

LazyRow の要素を上方向にスワイプしたら消す

LazyColumn の場合は SwipeToDismiss を使えば良いのですが、LazyRow にはそれがないので自作する必要があります。
尤も、swipeable を使ったところ比較的簡単に実装できました。
なお、swipeable だけだとアクションを検知するだけで画面の描画はやってくれないので、
offset を使って描画処理を実装してやる必要があります。この辺は scrollable と同様でした。

今回は上方向にスワイプしたら消す処理にしたいので、アイテムの高さを -1 掛けしたものを anchor に渡します。

あとは State を作る際に通常のコンストラクターを使って remember しないようにする必要がありました。

Compose のこういった処理の実装は公式ドキュメントを読むと大体できてしまうのが素晴らしいと感じます。

+    val sizePx = with(LocalDensity.current) { dimensionResource(R.dimen.list_item_height).toPx() }
+    val anchors = mapOf(0f to 0, -sizePx to 1)
+
     LazyRow(state = state, contentPadding = PaddingValues(horizontal = 4.dp)) {
         itemsIndexed(tabs) { position, tab ->
+                val swipeableState = SwipeableState(initialValue = 0, confirmStateChange = {
+                    if (it == 1) {
+                        callback.closeTabFromTabList(callback.tabIndexOfFromTabList(tab))
+                        refresh(callback, tabs)
+                    }
+                    true
+                })
//...
                 Box(
                     modifier = Modifier
//...
+                        .offset { IntOffset(0, swipeableState.offset.value.roundToInt()) }
+                        .swipeable(
+                            swipeableState,
+                            anchors = anchors,
+                            thresholds = { _, _ -> FractionalThreshold(0.75f) },
+                            resistance = ResistanceConfig(0.5f),
+                            orientation = Orientation.Vertical

参考

WebView のスクロールとページ内検索

BottomAppBar 等に nestedScroll で「スクロールしたら引っ込む」という実装をしたい場合、以下のコードで実装できます。

val bottomBarHeight = 48.dp
val bottomBarHeightPx = with(LocalDensity.current) {
    bottomBarHeight.roundToPx().toFloat()
}
val bottomBarOffsetHeightPx = remember { mutableStateOf(0f) }

val nestedScrollConnection = remember {
    object : NestedScrollConnection {
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
            val delta = available.y
            val newOffset = bottomBarOffsetHeightPx.value + delta
            bottomBarOffsetHeightPx.value = newOffset.coerceIn(-bottomBarHeightPx, 0f)
            return Offset.Zero
        }
    }
}

Scaffold(
    bottomBar = {
        BottomAppBar(
            modifier = Modifier
                .height(bottomBarHeight)
                .offset {
                    IntOffset(
                        x = 0,
                        y = -1 * bottomBarOffsetHeightPx.value.roundToInt()
                    )
                }
        ) {

中身の Compose では verticalScroll もしくは scrollable の Modifier を設定すればOKです。

……ただし、困ったことが1つあり、verticalScroll を WebView の親になる AndroidView で使った場合、
スクロールイベントが AndroidView の方で使用されてしまうので、 WebView の方で仕込んでいるスクロールが動かなくなります。
具体的には pageUp / pageDown / find です。

私の場合は以下のように scrollable を使って WebView を flingScroll させることにしました。
こうすれば Compose で nestedScroll のイベントを起こしつつページ内検索が使えます。

val baseOffset = 120.dp.value.toInt()

val webView = WebView(context)

AndroidView(
    factory = {
        webView
    },
    modifier = Modifier
        .scrollable(
            state = rememberScrollableState { delta ->
                webView.flingScroll(0, -delta.toInt() * baseOffset)
                delta
            },
            Orientation.Vertical
        )
)

スクロールができる View を AndroidView で動かしたい場合はこうした注意が必要です。

テキスト編集時の追加メニュー

現時点では TextField にテキスト編集用のメニューを独自に追加することはできないようです。
私のアプリでは Markdown を書くための便利なメニューを大量に追加していて、それらがないとアプリを使う意味がなくなるほどだったので、
仕方なく AndroidView で EditText を囲う形で実装しました。

NavHost のバックキー処理を無効化する

NavHost のバックキー処理が邪魔だと思ったので、NavHostController の enableOnBackPressed を false に設定して無効化しました。

navigationController.enableOnBackPressed(false)

NavHost は Entry が無限に積み上がっていくので、上手く実装しないと面倒な挙動を引き起こしそうです。

リリースビルド推奨

LazyColumn のスクロールが重たいと思ったら、Compose は元々デバッグビルドだと重くなるものらしいです。
あとは listState を items 内で使わない、キーを設定する、などの tips があるという話でした。

ドッグフーディングする際はリリースビルドを使った方が良さそうです。

終わりに

私が自分のアプリを View から Jetpack Compose に書き換えた際に得られた知見を述べました。やってみたところ、Compose を使えば自然と良い設計になるようにツールキット自体が作られていると感じますし、描画のパフォーマンスも良くなったように思います。

既存アプリの Jetpack Compose 移行の参考になりましたら幸いです。

参考

インターネット記事

書籍

まだ書籍での情報は多くないようでした。

10
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
13