見出し画像

既存AndroidプロジェクトにJetpack Composeを導入し始めた話

はじめに


こんにちは、アスオさんです。『トラックカーナビ』のAndroid/iOSアプリの開発・運用を担当しています。
『トラックカーナビ』はトラックドライバーのためのトラック専用カーナビアプリで、Android版、iOS版それぞれあるのですが、ここではAndroid版の既存の開発環境に「Jetpack Compose」を導入し始めたお話をしようと思います。

なぜ、導入しようと思ったのか


最初にリリースしたAndroid版はファーストリリースから6年が経過し、コードやレイアウトXMLは肥大化、複雑化し、限られたリソースで対応する中で、アプリの開発スピード、品質の維持に限界を感じ始めていました。
そこで、「少ないコード、パワフルなツール、直感的な Kotlin API を使用し、UI 開発を簡素化し、加速できる」との謳い文句で始まる魅力的な開発ツールキットJetpack Composeを導入し、今後新規での機能追加は原則全てComposeで記述し、既存コードも徐々に置き換えていくことで、開発速度アップ、品質の向上、コードの可読性・保守性の向上を実現できないかと考えました。

とにかくやってみた


本当に移行できるのかや、メリット、デメリットなど細かいところは置いておいて、まずはComposeを肌で感じるため、とにかくやってみることにしました。次の順序で触れていきたいと思います。

  • 環境構築

  • 既存機能のComposeへの書き換え手順

  • レイアウト プレビュー

  • レイアウト テスト

既存プロジェクトへの導入


基本構成

Jetpack Compose + MVVM + Hilt
いろいろとWeb上で調べてみたところ、Googleの公式サイトをはじめ、この構成が、最も推奨されているようでしたのであまり深く考えず、これで行くことにしました。

修正・追加箇所

それでは実際に既存のプロジェクトに修正を加えて行きます。APIレベルは31で導入しています。

root / build.gradle

buildscript {
    ext.kotlin_version = '1.7.0'
    // https://developer.android.com/jetpack/androidx/releases/compose?hl=ja
    ext.compose_version = '1.2.0'
    ...
    dependencies {
        ...
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
        classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
        ...


app / build.gradle

apply plugin: 'dagger.hilt.android.plugin'
...
android {
    ...
    buildFeatures {
        // Enables Jetpack Compose for this module
        compose true
    }
    ...
    composeOptions {
        kotlinCompilerExtensionVersion "$compose_version"
    }
    ...
}
dependencies {
    ...
    // Dagger Hilt
    implementation "com.google.dagger:hilt-android:2.42"
    kapt "com.google.dagger:hilt-android-compiler:2.42"
    /**
     * For Jetpack Compose
     */
    // Compose Compiler
    implementation "androidx.compose.compiler:compiler:$compose_version"
    // Integration with activities
    implementation "androidx.activity:activity-compose:1.5.1"
    // Compose Material Design
    implementation "androidx.compose.material:material:1.1.1"
    // Animations
    implementation "androidx.compose.animation:animation:1.1.1"
    // Tooling support (Previews, etc.)
    implementation "androidx.compose.ui:ui-tooling:1.1.1"
    // Integration with ViewModels
    implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1"
    // When using a MDC theme
    implementation "com.google.android.material:compose-theme-adapter:1.1.10"
    // When using a AppCompat theme
    implementation "com.google.accompanist:accompanist-appcompat-theme:0.16.0"
    // KTX 拡張機能のリスト
    implementation "androidx.fragment:fragment-ktx:1.5.1"
    // Constraintlayout for Compose
    // https://developer.android.com/jetpack/androidx/releases/constraintlayout?hl=ja
    implementation "androidx.constraintlayout:constraintlayout-compose:1.0.1"
    // Compose用Pager
    // https://google.github.io/accompanist/pager/
    implementation "com.google.accompanist:accompanist-pager:0.23.1"
    // Compose のツール
    // https://developer.android.com/jetpack/compose/tooling?hl=ja
    debugImplementation "androidx.compose.ui:ui-tooling:1.1.1"
    implementation "androidx.compose.ui:ui-tooling-preview:1.1.1"
    ...
}

これでビルドが通れば、既存コードはそのまま動作しつつ、新たにComposeでの記述も可能になります。

ハマったところ

導入当時、公式ページ「Jetpack Compose をアプリに追加する」に記載されている通りに実装してもビルドがなかなか通らず、かなりハマりました。とりあえず、Googleのサポートライブラリを最新にして、手探りで色々組み合わせを変えながら、ビルドが通るところを探したのが上記の組み合わせになります。最新のバージョンではまた変わっている可能性がありますのでご注意ください。

既存機能をComposeで書き換えてみる


ライブカメラ

最初に『トラックカーナビ』の既存機能である「ライブカメラ」をComposeで書き換えてみることにしました。
ライブカメラとは路面や混雑状況を確認するために、一般道や高速、サービスエリア、パーキングエリアなどにあらかじめ設置された定点カメラのライブ画像を見ることができる機能です。画面下部がPagerになっており、左右のスワイプでエリア内のカメラを移動することができます。
また「画像を見る」をタップすることでダイアログ上にカメラ画像を表示することができ、下のスライダーにより現在〜過去と時系列に画像を切り替えることができます。

ライブカメラ機能

Before

元々のコードの構成はざっとこんな感じです。
メイン画面、ダイアログのそれぞれのFragment内でレイアウトXMLをinflateして、findViewByIdして処理を実装しています。通信などデータの読み込みも管理クラスにまとめてはいるものの、それぞれの実装箇所でバラバラに呼び出している状況でした。

// コード
java
│   // ライブカメラ地点詳細画面(本体)
├── LiveCameraSelectSpotFragment.kt
│   // ライブカメラ地点詳細画面(下部Pager)
├── LiveCameraSelectSpotPage.kt
├── dialog
│    │   // カメラ画像コマ送りダイアログ
│    ├── LiveCameraSelectSpotDialogFragment.kt
│    │   // 画像コマ送りレイアウト
│    └── LiveCameraSelectSpotImageOperationLayout.kt
│   // ライブカメラ地点検索リクエスト管理クラス
└── LiveCameraSpotRequestManager.kt

// レイアウトXML
res
│    
└── layout
    │   // ライブカメラ地点詳細画面(本体)レイアウトXML
    ├── livecamera_select_spot_fragment.xml
    │   // ライブカメラ地点詳細画面(下部Pager)レイアウトXML
    ├── livecamera_widget_select_spot_page.xml
    │   // カメラ画像コマ送りダイアログレイアウトXML
    └── livecamera_select_spot_dialog_layout.xml
     

After

Compose書き換え後の構成はこんな感じです。MVVM(Model-View-ViewModel)に綺麗に分けられました。ただ、結論から言いましてほぼ書き直しに近い作業になりました。

├── model
│   ├── LiveCameraSelectSpotRepository.kt -------(1)
│   ├── LiveCameraSelectSpotRepositoryImpl.kt ---(2)
│   └── LiveCameraSelectSpotRepositoryModule.kt--(3)
├── viewmodel
│   ├── LiveCameraSelectSpotViewModel.kt --------(4)
│   ├── LiveCameraSelectSpotDialogViewModel.kt
│   ├── ILiveCameraSelectSpotViewModel.kt -------(5)
│   └── ILiveCameraSelectSpotDialogViewModel.kt
└── view
    ├── LiveCameraSelectSpotFragment.kt ---------(6)
    ├── LiveCameraSelectSpotMainView.kt ---------(7)
    ├── LiveCameraSelectSpotPageView.kt ---------(8)
    └── LiveCameraSelectSpotDialog.kt -----------(9)
MVVM

ここからは実際に行ったComposeへの書き換え作業を上の(1)〜(11)のコードを順を追って説明していこうと思います。

Hiltの使用

今回「Hilt」を使用するため、予め、Compose化の対象が配置されている既存のApplication、Activityに次のようにアノテーションを付与します。

@HiltAndroidApp
class TruckApplication : Application() { ... }
@AndroidEntryPoint
class TruckActivity : AppCompatActivity() { ... }

Modelの実装

まずは、通信やDB、ファイルの入出力などをmodel下のRepositoryに処理を切り出します。本来はRemoteDataSourceやLocalDataSourceなど、もう少し、処理を細かく切り出した方がいいのですが、とりあえず今回は移行ということで単純に全てここに集めてしまいます。他、データクラス等もmodelに配置します。

(1) LiveCameraSelectSpotRepository(インターフェイス)

interface LiveCameraSelectSpotRepository {

    suspend fun requestSpot(cameraId: String): HttpRequestResult<LiveCameraAreaDataList?>

    suspend fun requestImage(imageUrl: String): HttpRequestResult<Bitmap>

    suspend fun getSpotHistory(id: String): LiveCameraData?
    ...


(2) LiveCameraSelectSpotRepositoryImpl(実装)
ここで@ApplicationContext修飾子が登場しますが、これはHiltが事前定義しているもので、この修飾子を付与するだけで、ApplicationのContextを自動的に注入することができます。

class LiveCameraSelectSpotRepositoryImpl @Inject constructor(
    @ApplicationContext private val context: Context
) : LiveCameraSelectSpotRepository {

   override suspend fun requestSpot(cameraId: String): HttpRequestResult<LiveCameraAreaDataList?> {
       // 通信等でデータ等を取得する処理を実装
       ...
   }

   override suspend fun requestImage(imageUrl: String): HttpRequestResult<Bitmap> {
       // 通信等で画像等を取得する処理を実装
       ...
   }

    override suspend fun getSpotHistory(id: String): LiveCameraData? =
       // DB等より参照履歴する処理を実装
       ...
   }
   ...


(3) LiveCameraSelectSpotRepositoryModule(Hiltモジュール)
Repositoryを自動生成する方法を記述することで、HiltによってViewModel生成時に自動的にインスタンスが注入されます。

@Module
@InstallIn(ViewModelComponent::class)
class LiveCameraSelectSpotRepositoryModule {

    /**
     * [LiveCameraSelectSpotRepository]のDIに用いられるインスタンスを生成して返す
     */
    @Provides
    fun provideLiveCameraSelectSpotRepository(liveCameraSelectSpotRepositoryImpl: LiveCameraSelectSpotRepositoryImpl): LiveCameraSelectSpotRepository {
        return liveCameraSelectSpotRepositoryImpl
    }
}

ViewModelの実装

ViewModelの主な役割としては「ViewとModelを接続する」「Viewのデータや状態を保持する」と範囲が広いため、主要な処理はここに実装することになります。必要なデータはRepositoryより取得し、加工して、状態(データ)をViewに伝えます。また、Viewからのイベントを処理して必要に応じてRepositoryに出力し、また状態(データ)をViewに返します。

(4) LiveCameraSelectSpotViewModel(メイン画面のViewModel)
ViewModelには@HiltViewModelアノテーションを付与します。コンストラクタには@Injectアノテーションを付与し、上で定義したRepositoryを自動的に生成できるようにします。

@HiltViewModel
class LiveCameraSelectSpotViewModel @Inject constructor(
    private val repository: LiveCameraSelectSpotRepository
) : ViewModel(), DefaultLifecycleObserver, ILiveCameraSelectSpotViewModel {
    ...

メイン画面のViewModelではmainUiState(MutableState)にViewの状態を保持します。ComposeのView側では常にこのmainUiStateを監視することで変更を察知して表示を更新(再コンポーズ)します。これによりViewModel側ではmainUiStateのvalueに値を設定するだけで表示を更新することができます。

    /**
     * メインのViewの状態を表すsealed class
     */
    sealed class MainUiState {

        /**
         * 初期状態
         */
        object Initial : MainUiState()

        /**
         * 準備完了
         */
        data class Ready(val position: Int, val pages: ArrayList<PageData>) : MainUiState()

        /**
         * 読み込み中
         */
        data class Loading(val position: Int, val pages: ArrayList<PageData>) : MainUiState()
    }

    override val mainUiState: MutableState<MainUiState> = mutableStateOf(MainUiState.Initial)
    ...
   override fun onInit(...) {
        ...
        // Viewに状態を伝える
        mainUiState.value = MainUiState.Ready(selectedPosition, pages)
    }

状態通知のみでは制御できない部分、 Fragmentを操作する際(画面遷移など)や、シーケンシャルな処理が必要な場合などに利用するための仕組みも用意しておきます。ここではSharedFlow利用します。今回、割愛していますが、地図表示ライブラリの操作にも利用しています。

    /**
     * アクションのsealed class
     */
    sealed class Action {

        /**
         * 地点選択アクション
         */
        data class SelectSpot(val position: Int, val spotInfo: LiveCameraSpotInfo, val isOnePoint: Boolean) : Action()

        /**
         * 地図マーカー追加アクション
         */
        data class AddMapMarker(val spotData: LiveCameraSpotInfo?) : Action()

        /**
         * ページ戻るアクション
         */
        object Back : Action()
        ...
    }

    private val event = MutableSharedFlow<Action>()
    val action: SharedFlow<Action> get() = event
    ...
    /**
     * ページ戻るアクション送信
     * LiveCameraSelectSpotFragment#receiveAction()で受信されます
     */
    override fun onArrowBackTapped() {
        viewModelScope.launch {
            event.emit(Action.Back)
        }
    }

(5) ILiveCameraSelectSpotViewModel(インターフェイス)
上のViewModelの実装で気がついたかとは思うのですが、ViewModelはインターフェイスを実装しています。これは特に必須ではなく、この後説明します「レイアウト プレビュー」や「レイアウト テスト」でテスト用の仮のViewModelを用意する際に都合が良かったため実装しています。通常は不要です。

interface ILiveCameraSelectSpotViewModel {
    val mainUiState: MutableState<LiveCameraSelectSpotViewModel.MainUiState>

    val pageUiStateList: ArrayList<MutableState<LiveCameraSelectSpotViewModel.PageUiState>>

    ...

    fun onInit(...)

    fun onInitPage(position: Int)

    fun onArrowBackTapped()

    ...
}

Viewの実装

Composeのレイアウトの実装に入っていきます。まずは本体であるFragmentを実装し、その中にComposeのレイアウトを記述していきます。もともとはダイアログ用のFragmentも存在していましたが、Compose化によりViewとして表示できるようになったため、不要となりました。

(6) LiveCameraSelectSpotFragment
Fragmentには@AndroidEntryPointアノテーションを付与する必要があります。
ポイントとしては先に説明した、メイン画面とダイアログのViewModelをそれぞれ by viewModels() で取得し、また、lifecycle.addObserver()することでFragmentのライフサイクルをViewModelにも通知できるようにしておくことです。
主役のComposeのレイアウトは onCreateView() 内に setContent にて実装します。
ここでは地図表示の操作も含みますが、別レイヤーのViewで直接このFragmentには含まないため、割愛しています。

@AndroidEntryPoint
class LiveCameraSelectSpotFragment : BaseFragment(), OnFabEventListener {
    private val viewModel: LiveCameraSelectSpotViewModel by viewModels()
    private val viewModelForDialog: LiveCameraSelectSpotDialogViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        lifecycle.addObserver(viewModel)
        lifecycle.addObserver(viewModelForDialog)
        receiveAction()
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        return ComposeView(requireContext()).apply {
            setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
            setContent {
                AppCompatTheme {
                    // メイン画面(Compose)
                    LiveCameraSelectSpotMainView(modifier = Modifier.fillMaxSize(), viewModel)
                    // ダイアログ(Compose)
                    LiveCameraSelectSpotDialog(viewModelForDialog)
                }
            }
        }
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        ...
        viewModel.onInit(...)
    }

    ...

    /**
     * イベント受信
     */
    private fun receiveAction() {
        // アクション処理(本体)
        lifecycleScope.launch {
            // 画面生成直後から受信
            repeatOnLifecycle(Lifecycle.State.CREATED) {
                viewModel.action.collect {
                    when (it) {
                        is LiveCameraSelectSpotViewModel.Action.SelectSpot -> {
                            viewModelForDialog.onShow(it.spotInfo.id)
                        }
                        ...
                        is LiveCameraSelectSpotViewModel.Action.Back -> {
                            ...
                        }
                    }
                }
            }
        }
        // アクション処理(ダイアログ)
        lifecycleScope.launch {
            // 画面開始直後から受信
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModelForDialog.action.collect {
                    when (it) {
                        LiveCameraSelectSpotDialogViewModel.Action.LoadingPage -> {
                            ...
                        }
                        is LiveCameraSelectSpotDialogViewModel.Action.UpdatePage -> {
                            ...
                        }
                    }
                }
            }
        }
    }
    ...
}

(7) LiveCameraSelectSpotMainView(本体View)
Composeのレイアウトはその関数に@Composableアノテーションが必要となります。
下記のように引数にはviewModelを渡して、そのmainUiStateを監視してレイアウトの表示状態を更新します。また、タップ、スワイプなどイベント発生時にはviewModel経由でイベントをViewModel側で処理させます。

@Composable
fun LiveCameraSelectSpotMainView(modifier: Modifier, viewModel: ILiveCameraSelectSpotViewModel) {
    // 状態監視
    val mainUiState: LiveCameraSelectSpotViewModel.MainUiState by viewModel.mainUiState
    var title = ""
    var position = 0
    var timeString = ""
    var pages = ArrayList<LiveCameraSelectSpotViewModel.PageData>()
    when (val state: LiveCameraSelectSpotViewModel.MainUiState = mainUiState) { // なぜかスマートキャストが働かないため一旦valしてます
        LiveCameraSelectSpotViewModel.MainUiState.Initial -> {
            title = ""
            position = 0
        }
        is LiveCameraSelectSpotViewModel.MainUiState.Ready -> {
            title = state.pages[state.position].spotInfo.roadName ?: ""
            pages = state.pages
            position = state.position
        }
        ...
    }
    // レイアウト
    ConstraintLayout(modifier = modifier) {
        val (time, pager) = createRefs()
        // ツールバー
        MapTopAppBar(...)
        if (pages.size > 0) {
            // コンテンツ部
            Box(...) {
                ...
            }
            // 地点詳細ページャー
            val pagerState = rememberPagerState(position)
            LaunchedEffect(pagerState) {
                snapshotFlow { pagerState.currentPage }.collect { currentPage ->
                    // ページスワイプ時処理
                    viewModel.onPageSelect(currentPage)
                }
            }
            HorizontalPager(
                count = pages.size,
                state = pagerState,
                modifier = 
                ...
            ) { pagePosition ->
                // 本体ページView
                LiveCameraSelectSpotPageView(
                    modifier = Modifier
                        .fillMaxSize()
                        .selectable(
                            selected = true,
                            onClick = {
                                // タップ時処置
                                viewModel.onSpotSelect(pagePosition)
                            }
                        )
                    ...
                    viewModel,
                    ...
                )
                // ページ初期化処理
                viewModel.onInitPage(pagePosition)
            }
        }
    }
}

(8) LiveCameraSelectSpotPageView(本体ページView)
上記、本体View内のHorizontalPagerの各ページのレイアウトになります。uiStateを各ページ毎に管理する必要があるため、リストで保持しています。

@Composable
fun LiveCameraSelectSpotPageView(modifier: Modifier, viewModel: ILiveCameraSelectSpotViewModel, position: Int, orientation: Int) {
    // ページ毎に状態監視
    val uiState: LiveCameraSelectSpotViewModel.PageUiState by viewModel.pageUiStateList[position]
    val pageData: LiveCameraSelectSpotViewModel.PageData
    val showsProgress: Boolean
    val showsImage: Boolean
    val showsErrorImage: Boolean
    when (val state: LiveCameraSelectSpotViewModel.PageUiState = uiState) {
        LiveCameraSelectSpotViewModel.PageUiState.Initial -> {
            // 描画せずに抜ける
            return
        }
        ...
        is LiveCameraSelectSpotViewModel.PageUiState.CameraImageSuccess -> {
            pageData = state.pageData
            showsProgress = false
            showsImage = pageData.thumbnailImage != null
            showsErrorImage = !showsImage
        }
        ...
    }
    // レイアウト
    Box(
        modifier = modifier
    ) {
        Row(
                ...
        ) {
            Box(
                    ...
            ) {
                if (showsProgress) {
                    // 読み込み中表示
                    Box(
                        ...
                    ) {
                        CircularProgressIndicator(
                            ...
                        )
                    }
                } else if (showsImage) {
                    // カメラ画像表示
                    pageData.thumbnailImage?.let {
                        Image(
                            ...
                            bitmap = it.asImageBitmap(),
                            contentScale = ContentScale.Crop
                        )
                    }
                } else if (showsErrorImage) {
                    // エラー画像表示
                    Image(
                        ...
                        painter = painterResource(id = ...),
                        contentDescription = null
                    )
                }
            }
            Column(
                modifier = Modifier
                    ...
            ) {
                // 地点情報
                ...
            }
        }
        // 「画像を見る」ボタン
        ...
    }
} 

(9) LiveCameraSelectSpotDialog(ダイアログ)
ダイアログも状態を監視して表示を切り替えるだけなのでとてもシンプルになりました。表示する場合はViewModelから状態を更新するだけです。

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun LiveCameraSelectSpotDialog(viewModel: ILiveCameraSelectSpotDialogViewModel) {
    // 状態監視
    val uiState: LiveCameraSelectSpotDialogViewModel.UiState by viewModel.uiState
    var spotInfo: LiveCameraSpotInfo? = null
    var cameraImage: Bitmap? = null
    var showsProgress = false
    var showsNoImage = false
    var showsImage = false
    var showsSlider = false
    when (val state: LiveCameraSelectSpotDialogViewModel.UiState = uiState) {
        LiveCameraSelectSpotDialogViewModel.UiState.Dismiss -> {
            // 表示しない、つまり、Dismiss状態
            return
        }
        ...
        is LiveCameraSelectSpotDialogViewModel.UiState.ImageSuccess -> {
            spotInfo = state.spotInfo
            cameraImage = state.image
            showsProgress = false
            showsNoImage = false
            showsImage = true
            showsSlider = true
        }
        is LiveCameraSelectSpotDialogViewModel.UiState.ImageFailure -> {
            spotInfo = state.spotInfo
            showsProgress = false
            showsNoImage = true
            showsImage = false
            showsSlider = false
        }
        ...
    }
    // レイアウト
    Dialog(
        onDismissRequest = {
            viewModel.onDismiss()
        },
        properties = DialogProperties(
            dismissOnBackPress = true,
            dismissOnClickOutside = true,
            usePlatformDefaultWidth = false
        )
    ) {
        Column(
            // 名称
            Text(
                text = spotInfo?.spotName ?: "",
                ...
            )
            // 住所
            Text(
                text = spotInfo?.location ?: "",
                ...
            )
            Column(
                modifier = Modifier
                    ...
            ) {
                Box(
                    ...
                ) {
                    if (showsNoImage) {
                        // 画像なし表示
                        Box(
                            ...
                        ) {
                            Image(
                                ...
                                painter = painterResource(id = ...),
                                contentDescription = null
                            )
                        }
                    }
                    if (showsProgress) {
                        // 読み込み中表示
                        CircularProgressIndicator(
                            ...
                        )
                    }
                    if (showsImage && cameraImage != null) {
                        // カメラ画像表示
                        Image(
                            ...
                            bitmap = cameraImage.asImageBitmap(),
                            contentScale = ContentScale.Fit,
                            contentDescription = null
                        )
                    }
                }
                // スペース
                Spacer(
                    ...
                )
                if (showsSlider) {
                    // スライダー表示
                    var sliderPosition by remember { mutableStateOf(LiveCameraSelectSpotDialogViewModel.PROGRESS_MAX) }
                    Slider(modifier = Modifier
                        ...
                    )
                }
            }
            // 閉じるボタン
            Box(
                ...
            ) {
                Text(
                   ...
                )
            }
        }
    }
}   

まとめ

  1. Hiltを使用するため、必要な箇所にHiltアノテーションを付与する。

  2. 既存の処理から通信やDBなどデータの入出力処理をRepositoryに切り出す。

  3. ViewModelにその他レイアウトなど表示以外のデータの保持など主な処理を全て移行し、リスナーなどの通知を全て廃止して状態(State)に保持するようにする。

  4. レイアウトXMLからComposeのレイアウトコードに書き直し、ViewModelの保持する状態(State)を監視してレイアウトが更新されるようにする。

  5. シーケンシャルな処理はViewModelからSharedFlow等でFragmentへ通知する。

ハマったところ

ComposeのPager(HorizontalPager)はまだ正式版ではないため、バグが潜んでいそうでした。画面回転時にスクロール位置が中途半端な位置になってしまったり、ページ切り替えのイベントが予期しないタイミングで呼ばれることがありました。

所感

かなり大変な作業になりますが、コード量はかなり減り、可読性も向上、無駄な処理がなくなるため、パフォーマンスも向上したように思います。やる価値は十分にあると思いました。既存コードからの移行、特にレイアウト部分はほぼ書き直しになりますが、元々RepositoryパターンやViewModelを導入しているプロジェクトであればそこまで大変ではないのかなと思います。
今回のような場合でも、Repository部分などは最初から気合入れずに、最初は、とにかく既存の使える処理はそのまま持ってくれば良いと思います。ViewModelも無い状態からなので大変ですが、それでもロジック部分2割ぐらいは使えると思います。
新機能の追加であれば躊躇なく、Jetpack Composeを採用すべきと思います。

レイアウト プレビューしてみる


次にJetpack Composeの強みの一つであるレイアウト プレビューを作成してみようと思います。サンプルは引き続き、上で書き換えたライブカメラのメイン画面を使います。

プレビューの作成

上で実装したComposeのレイアウト「LiveCameraSelectSpotMainView」をプレビューしてみたいと思います。Composeの関数の上に@Previewアノテーションを付加すればいいのですが、引数があるため、エラーになってしまいます。

@Preview
@Composable
fun LiveCameraSelectSpotMainView(modifier: Modifier, viewModel: ILiveCameraSelectSpotViewModel) {
{
    ...
}

そこで、引数のない、プレビュー用の関数を定義し、その中から任意の引数を設定してプレビューしたい関数を呼んであげるようにします。

@Preview
@Composable
private fun LiveCameraSelectSpotMainViewPreview() {
    LiveCameraSelectSpotMainView(...) // プレビュー用の仮の引数を指定
}

引数にViewModelが必要ですが、予めインターフェイスにしてあるため、そのインターフェイスを実装したプレビュー用のクラスを用意し、プレビューの確認に必要なプロパティやメソッドに仮の確認用データなどを設定して実装します。

private class LiveCameraSelectSpotViewModelForPreview(context: Context) : ILiveCameraSelectSpotViewModel {
    override val mainUiState: MutableState<LiveCameraSelectSpotViewModel.MainUiState> = mutableStateOf(LiveCameraSelectSpotViewModel.MainUiState.Ready(0, ...))
    override val pageUiStateList: ArrayList<MutableState<LiveCameraSelectSpotViewModel.PageUiState>> = ArrayList<MutableState<LiveCameraSelectSpotViewModel.PageUiState>>().apply { ... }
    ...

    override fun onInit(...) {
    }

    ...
}

これでプレビュー可能となりました。

@Preview
@Composable
private fun LiveCameraSelectSpotMainViewPreview() {
    LiveCameraSelectSpotMainView(
        Modifier.fillMaxSize(),
        LiveCameraSelectSpotViewModelForPreview(LocalContext.current)
    )
}

プレビューの表示

一度ビルドを行ってから、①のDesignタブでプレビュー表示に切り替えることができます。②のインタラクティブモードはプレビュー上でクリックやスワイプなどUIの操作感を確認でき、③のデプロイでは簡易的なインストールで、実際のデバイス上で UI を試すことができます。コードを修正した場合は④によりプレビューを更新します。

レイアウト プレビュー表示

さまざまなデバイス上での表示

@Previewアノテーションを複数付与することでさまざまなデバイス上での表示を同時に確認することも可能です。
縦画面、横画面、スマートフォン、タブレットなど画面サイズによってデザインに違いがある場合などかなり使えそうです。

@Preview(name = "スマフォ", device = Devices.NEXUS_6)
@Preview(name = "7インチタブレット", device = Devices.NEXUS_7_2013)
@Preview(name = "10インチタブレット", device = Devices.NEXUS_10)
@Preview(name = "スマフォ横(カスタム)", device = Devices.AUTOMOTIVE_1024p, widthDp = 720, heightDp = 360)
@Preview(name = "デフォルト", device = Devices.DEFAULT, widthDp = 720, heightDp = 360)
@Preview(name = "PIXEL_XL", device = Devices.PIXEL_XL, showSystemUi = true)
@Composable
private fun LiveCameraSelectSpotMainViewPreview() {
    LiveCameraSelectSpotMainView(
        Modifier.fillMaxSize(),
        LiveCameraSelectSpotViewModelForPreview(LocalContext.current)
    )
}
レイアウト プレビュー表示(複数デバイス)

所感

正直、このレイアウト プレビューは使えると感じました。ビルドが必要ですが、差分ビルドらしく、気にならないほどの実行時間でした。
今回は移行でしたが、新規の場合は実装しながら、ちょくちょく確認して、デバイスにインストールすることなく、完成形まで持っていけそうです。かなりの工数削減が期待できます。

レイアウト テストしてみる


最後にのCompose レイアウトのテストにチャレンジしてみたいと思います。サンプルは引き続き、ライブカメラのメイン画面を使います。

事前準備

まずは、テストを実行できる環境を整えます。app下のbuild.gradle ファイルに次のように追加します。

app / build.gradle

android {
    ...

    defaultConfig {
        ...
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }
    ...
}
...
dependencies {
    ...
    // Espresso
    androidTestImplementation "androidx.test.espresso:espresso-core:3.4.0"
    // Test rules and transitive dependencies:
    androidTestImplementation "androidx.compose.ui:ui-test-junit4:1.1.1"
    // Needed for createComposeRule, but not createAndroidComposeRule:
    debugImplementation "androidx.compose.ui:ui-test-manifest:1.1.1"
}

次にテスト用の画面を準備しておきます。

app /src / debug / AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest
    xmlns:android="http://schemas.android.com/apk/res/android"
    package="...">
    <application>
        <activity
            android:name=".TestActivity"
            android:exported="false" />
    </application>
</manifest>

app / src / debug / java / … / TestActivity.kt

@AndroidEntryPoint
class TestActivity : ComponentActivity()

UIテストを書く

まずは、先ほど準備したテスト用の画面(TestActivity)にテストしたいComposeの関数をセットします。ここではLiveCameraSelectSpotMainViewをテストしますが、引数にViewModelが必要となります。これを構築するために必要なパラメーターはHiltによって自動的に注入されますので下のように記述するたけで済みます。これでテストする対象の画面の準備ができました。

app / src / androidTest / java / … / LiveCameraSelectSpotViewUiTest.kt

class LiveCameraSelectSpotViewUiTest {

    @get:Rule
    val composeTestRule = createAndroidComposeRule<TestActivity>()

    @Before
    fun setup() {
        composeTestRule.setContent {
            LiveCameraSelectSpotMainView(
                Modifier.fillMaxSize(),
                composeTestRule.activity.viewModels<LiveCameraSelectSpotViewModel>().value
            )
        }
    }
}

テストの記述ですが、その前に、テストで操作したいButtonなどのアイテム(ノードと呼びます)を指定できるようにしておく必要があります。操作したいButtonなどに一意に判定できるTextなどが設定されていればいいのですが、PagerやImageなどTextを持たないものは予めレイアウトに仕込んでおく必要があります。例えば。ModifierのtestTagやsemanticsのcontentDescriptionが使えます。どのようなものが用意されているのかは
公式のテスト早見表をご覧ください。

testTagを使う方法
composeTestRule.onNodeWithTag("SpotPager")

HorizontalPager(
    count = pages.size,
    state = pagerState,
    modifier = Modifier
        .testTag("SpotPager")
        ...
) { pagePosition ->
   ...
}

semanticsのcontentDescriptionを使う方法
composeTestRule.onAllNodesWithContentDescription("CameraImage1")

Image(
    modifier = Modifier
        .semantics { contentDescription = "CameraImage1" }
        ...
    painter = painterResource(id = R.drawable.XXXX),
    contentDescription = null
)

いよいよテスト部分を書いてみます。
テストの流れとしてはファインダー(onNode…)でテストするノードを選択し、必要に応じてアクション(perform…)で操作をシミュレートし、アサーション(assert…)で結果を確認します。
今回ライブカメラをサンプルとして使用していますが、テキスト入力やボタンタップなどの操作がないため、あまりUIテストには向かない機能なのかもしれません。ちょっと強引なのですが、実際にカメラ画像を読み込ませる処理を動作させて、画像が表示されることを確認するテストを書いてみたいと思います。

まずは、1地点のみの例です。ViewModelの表示処理開始のメソッドonInit()にカメラ地点情報を渡して、処理を開始させ、通信で画像を受信して表示するまで最大3秒間(ほぼ3秒以内で終わるため)待ちます。画像を表示するためのパーツが出現したら読み込みが成功したことなのでこのテストをパスしたことにします。

class LiveCameraSelectSpotViewUiTest {

    @get:Rule
    ...

    @Before
    ...
    
    @Test
    fun showOneSpotTest() {
        // カメラ地点情報より処理開始(画像読み込み)
        composeTestRule.activity.viewModels<LiveCameraSelectSpotViewModel>().value.onInit(カメラ地点情報, ...)
        // カメラ画像が読み込まれるまで待つ
        composeTestRule.waitUntil(3000) {
            composeTestRule.onAllNodesWithContentDescription("CameraImage0").fetchSemanticsNodes().size == 1
        }
        // カメラ画像が表示されていれば成功
        composeTestRule.onNodeWithContentDescription("CameraImage0").assertIsDisplayed()
    }
}

次にこちらはUIテストらしく、ちょっと操作が加わります。複数地点のカメラ地点情報を同じように、ViewModelの表示処理開始のメソッドonInit()に渡して、処理を開始させるのですが、初期ページの画像読み込み成功で完了ではなく、ページ分スワイプして繰り返し、全て成功したらこのテストをパスしたことにするというものです。このテストが意味あるものかどうかは置いといて、画面操作が加わるのでちょっと本格的です。

class LiveCameraSelectSpotViewUiTest {

    @get:Rule
    ...

    @Before
    ...

    @Test
    fun showOneSpotTest() {
        ...
    }

    @Test
    fun cameraPageSwipeTest() {
        val tag = "CameraImage"
        // 複数地点のカメラ情報(リストデータ)を作成(4ページ分)
        val spotList = ArrayList<LiveCameraSpotInfo>().apply {
            add(...)
            add(...)
            add(...)
            add(...)
        }
        // リストデータを読み込ませて表示開始
        composeTestRule.activity.viewModels<LiveCameraSelectSpotViewModel>().value.onInit(spotList, ...)
        // ページ分繰り返す
        for (selectIndex in 0..3) {
            if (selectIndex != 0) {
                // 1ページ(初期表示)目以外はスワイプし次のページへ
                composeTestRule.onNodeWithTag("SpotPager").performTouchInput { swipeLeft() }
            }
            // カメラ画像が読み込まれるまで待つ
            composeTestRule.waitUntil(5000) {
                composeTestRule.onAllNodesWithContentDescription("$tag$selectIndex").fetchSemanticsNodes().size == 1
            }
            // カメラ画像が表示されていれば成功
            composeTestRule.onNodeWithContentDescription("$tag$selectIndex").assertIsDisplayed()
        }
}

UIテストの実行

さて、テストは書けましたので実際に実行してみたいと思います。
実行するにはコードの左端にあるライン番号上の実行ボタン(緑の再生マーク)をクリックします。classの横にあるものは全てのテストの一括実行、各テストメソッドの横にあるものはメソッドごとの個別実行になります。

テスト実行ボタン

では実際にテスト実行してみます。一括テストです。予め、テスト用デバイスを接続しておくか、エミュレータを起動しておきます。

テストの一括実行

軽くビルド(部分ビルド?)が走り、接続しているテスト用のデバイスにインストールが始まりました。

テストのデプロイ
  • デバイスの画面上でアプリが立ち上がり、1テストあたり2、3秒でUIテストが走り、終了次第アプリは自動的に終了、そしてテスト結果を出力しました。2つのテストともにパスしています。

テスト結果(成功)

例えば圏外などにして、画像の受信を失敗するようにすると、当然ながらテストにパスできず失敗との結果を出力します。

テスト結果(失敗)

ハマったところ

Build Variantが純粋なdebugでないとテストできませんでした、『トラックカーナビ』では開発用に複数デバッグ用(setDebuggable true)のビルドタイプを用意しているのですがこちらでは動いてくれませんでした。生成時にデフォルトで作成されるdebugでの実行が必要でした。
また、ノードを選択する際にtestTagがonNodeWithTag()で見つからないことがありました。ノードの階層が深かったからかもしれないのですが、なぜか、onAllNodesWithContentDescription()では見つかりました。

所感

今回ライブカメラでテストしてみましたが、正直あまりメリットを感じませんでした。ユーザー操作の多い機能、入力項目が多かったり、入力チェックが必要なものや、入力状態によってパーツが有効・無効になったり変化するような機能では有効だと思いました。
また、表面上のUIだけでなく、本動作時とほぼ同等の環境下で動かせるので、同じ操作を高速で繰り返すなどの耐久試験的な使い方も可能なのかなと思いました。

さいごに


いかがだったでしょうか、私もJetpack Composeはまだまだ勉強中の身で、とにかく、一通り触れてみようということでチャレンジする中、いろいろな知見が得られましたので、この記事を書かせていただきました。
これから既存プロジェクトへの導入を検討されている方々の一助になれば幸いです。