見出し画像

Jetpack Composeで作る移動エコ活アプリ『moveco<ムブコ>』の開発について

こんにちは、胡椒です。ナビタイムジャパンでアプリ版『NAVITIME Travel』を担当しています。

はじめに - 『moveco<ムブコ>』について

『moveco<ムブコ>』は、エコなスポットやエコな移動、貯めたマイルはエコなギフトと交換できる移動エコ活アプリです。

iOS版が先行してリリースされ、2023/06/27にAndroid版がリリースされました。Android版の開発において、Jetpack Composeを主軸とした一通りのアプリの設計を行いましたので、その時の経験や技術的な要素についてご紹介いたします。



Android版『moveco<ムブコ>』の設計について

アーキテクチャー

『moveco<ムブコ>』のアーキテクチャーは概ねAndroid Developersにて推奨されているアーキテクチャーに従っています。

ごく簡単に示すならば MVVMで、Model部分をUseCase-Repository-Infraのレイヤーに分割したものになっています。
各レイヤーはインターフェースを通して疎通する仕組みとなっていて、UseCase以降は原則Suspend関数になっています。
また、各レイヤーの依存性を解決する方法として、DaggerHiltを採用しています。各レイヤーの説明に関しては、一般的な内容のためUIレイヤーにのみ絞って紹介します。

UIレイヤー

アプリのUIは一部の例外を除き、全てJetpack Composeで作成されています。UIに表示するためのデータを管理する役割として、ViewModelとUiModelクラスを作成します。NavigationComponentsを採用しているため、1Activity多Fragmentの構造になっています。画面に相当するFragmentに対して、関連するViewModelとUiModelクラスが紐づきます。UiModelクラスはFlowで監視することで、内容が更新されたら画面も更新される仕組みになっています。

これらの仕組みを採用することにより、画面の表示要素に関する内容はUiModelに集中させることができ、ViewModelの役割が散逸しにくくなります。以下はUiModelの作成例です。

@Immutable
sealed interface SampleUiModel {

    object Loading : SampleUiModel

    data class Loaded(
        val sampleMile: Int? = null,
        val sampleLabel: String? = null
    ) : SampleUiModel {
        companion object {
            fun from(
                rs: SampleResponse,
            ): SampleUiModel {
                return Loaded(
                    currentMile = rs.mile,
                    sampleLabel = rs.label
                )
            }
        }
    }
}

UiModelは画面内のデータの塊ごとに分けるようにしています。
データの分け方はさまざまな考え方がありますが、『moveco(ムブコ)』のホーム画面を例にするならば、月間のマイルや移動距離などにまつわるデータはMonthlyUiModel、保有マイルや移動マイルなど、ユーザー個人にまつわるデータはUserDataUiModelといった形で分割をするようにしています。

UiModelの作成例

マルチモジュール

上記で紹介したViewModel - UseCase - Repository - Infraというレイヤーは、それぞれモジュールが分かれています。UIやViewModelなど、機能にまつわるコードは、現在1つのモジュールにまとめています。
モジュールから別のモジュールを参照するようにするには、参照したいモジュールの依存関係を明示する必要があります。これにより UI > ViewModel > UseCase > Repository > Infra という参照の順番を強制させる効果が期待できます。
また、『moveco(ムブコ)』の場合、位置情報の測位サービスについては独立性が高いため、サービスごと別モジュールに分離しています。全てのモジュールと依存関係を図示すると概ね以下のようになります。

モジュールと依存関係

今のモジュール分割方法だと、機能モジュールが肥大化してしまうため、主要機能に応じてモジュールを細分化しても良いかもしれません。CommonはいわゆるUtilクラスなど、どうしてもモジュール間で共用したいものを配置する役割のため作成しました。

開発における工夫点

コンセプト

Android版『moveco(ムブコ)』の設計における目標は「新規にジョインしたメンバーがスムーズに開発に入れること」です。メンバーによってAndroid開発の経験やスキルセットは様々です。それに対して、昨今のAndroidアプリ開発において使われる技術や必要とされる知識の幅は肥大し続けている印象があります。これらの要素によって新しいメンバーが開発に入れるようになるまでの負担をなるべく減らしたいと思い設計しました。

コンセプトに対する工夫点 - 開発の順番

今回の開発において本格的に開発が始まるまでに私が事前に開発を進められる期間がありました。
事前の開発では、アプリで共通で使われるであろう箇所、UiModelクラスからの値の読み出し等の書き方が共通するであろう箇所に集中して開発しました。共通系のコード開発に関しては特に、Jetpack Composeの採用の効果があったポイントであると感じています。

コンセプトに対する工夫点 - なるべく標準の書き方になるようにする

コードの効率化については様々な考え方があると思います。今回の開発においては、なるべく『moveco(ムブコ)』のプロダクトコードでのみ使われるような書き方や拡張はなるべく使わない方針で実装しました。メンバーが各自でAndroid Developer等を調べるのみで実装が可能になることを狙っています。各レイヤーからの値の受け渡し処理などの効率化がしやすい箇所についても同様です。

Jetpack Composeにおける工夫点 - リソースの参照方法

コンポーザブル関数としてcolorやtypographyなどをMovecoThemeとして用意をすることでUIにて簡単に参照することができます。これによってダークテーマの対応や、アプリ内でのフォントの出しわけなどが容易になりました。以下はコード例です。

@Composable
fun MovecoTheme(
    colors: MovecoPalette = movecoPallete(),
    typography: MovecoTypography = movecoTypography(),
    children: @Composable () -> Unit
) {
    CompositionLocalProvider {
        MaterialTheme(
            colors = colors.materialColors,
            typography = typography.materialTypography
        ) {
            CompositionLocalProvider(
                content = children
            )
        }
    }
}

object MovecoTheme {
    val colors: MovecoPalette
        @Composable
        @ReadOnlyComposable
        get() = movecoPallete() // Colorの一覧を並べたクラスを用意

    val typography: MovecoTypography
        @Composable
        @ReadOnlyComposable
        get() = movecoTypography() // TextStyleの一覧を並べたクラスを用意

    val dimension: MovecoDimension
        @Composable
        @ReadOnlyComposable
        get() = movecoDimension()
}

Jetpack Composeにおける工夫点 -  共通ビューの作成

各画面における要素についてはできるだけ細分化し、共通化できるように進めました。この際、modifierを引数に入れられるようにしておくことで、利用箇所に合わせた細かな変更が可能になるため、より汎用的なビューにすることができます。また、これらの共通ビューではプレビューを作成しておくことが特に重要であると感じました。

Jetpack Composeにおける工夫点 - 拡張modifierの作成

前述のコンセプトに反してしまうのですが、デザインの再現をするにあたり、1から実装するのが難しいUIについては積極的に拡張modifierを作成しました。特に『moveco(ムブコ)』の場合、グラスモーフィズムを採用しておりインナーシャドウやブラーなどの表現を使いやすいものにする必要がありました。従来はShapeDrawable等を活用することで再現する必要があり、可読性や実装難度に少なからず影響を及ぼしていましたが、Jetpack Composeになることで、これらの用途でdrawIntoCanvas等が使用され、手順を追いやすくなったため、凝ったUIも比較的扱いやすくなったのではないかと思います。

Jetpack Composeにおける工夫点 - Tips

ここまでは一般的な内容になりますが、さらに『moveco(ムブコ)』開発時ならではの細かい工夫点をいくつか紹介します。

iOSのNavigationBarライクなツールバー
ツールバーとステータスバーをスクロール時のみ表示するようなものです。

ステータスバーとツールバーがスクロール時のみ浮き上がる

1.ステータスバーを透明化する処理を入れます。(Accompanistを使用しています)

val systemUiController = rememberSystemUiController()
val isDark = isSystemInDarkTheme()
SideEffect {
    systemUiController.setStatusBarColor(
        color = Color.Transparent,
        darkIcons = !isDark,
    )
}       

2.Material3のTopAppBarを使用してツールバー部分のレイアウトを作成します scrollBehaviorとcolorsが主に見た目に影響してきます

CenterAlignedTopAppBar(
    title = { //省略 },
    navigationIcon = { //省略 },
    actions = { //省略 },
    colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
        containerColor = // Transparent,
        scrolledContainerColor = // AnyColor
    ),
    scrollBehavior = pinnedScrollBehavior
)

3.Scaffoldのcontent内に画面のビューと2のTopAppBarを入れます。

Scaffold {
    it.calculateTooPadding()
    AnyScreen(
              modifier = Modifier.nestedScroll(pinnedScrollBehavior.nestedScrollConnection)
        )
    CenterAlignedTopAppBar(//省略)
}

画像のブラー処理
(こちらについては最終的にサードパーティ製ライブラリを使用して解決したという話になります)
modifierにはblurが存在しますが、Android12以降のみの対応となっています。そのためOS別に処理を分ける必要がありますが、それらの差分を解決するため、landscapistを採用しました。coilなどの一般的な画像表示ライブラリに対応しているため容易に採用できるかと思います。

Flowを用いたグリッド表示
画面サイズや要素の数に関わらず指定した列数のグリッド表示をしたいといった場面があるかと思います。そういった場面で使えるのがFlowRowです。画面サイズの取得と組み合わせることで、良い感じのグリッド表示ができます。『moveco(ムブコ)』の例ではAccompanistを使用しておりますが、現在は正式版のFlowRowがリリースされていますので、そちらを利用することをお勧めします。

『moveco(ムブコ)』チャレンジ画面からの引用、5列で固定されるようになっています
// (横幅 - 両端のmargin) / 5 することで5列になるように調整
val itemSize = (LocalConfiguration.current.screenWidthDp.dp - 72.dp) / 5

// 任意のViewでaspectRatioを1:1にすることでグリッド表示になるように
Surface(
    modifier = Modifier
        .size(itemSize)
        .aspectRatio(1F / 1F)
        )
) {}

測位サービスにおける工夫点

『moveco(ムブコ)』は移動に応じたマイルの付与が行われるサービスですので、位置情報の測位を継続的に行う必要があります。
今回はForegroundServiceでの常時測位を実施するようにしましたが、そこで問題となるのはバッテリー消費です。常時測位でのバッテリー消費に特に影響を及ぼすのはGPSの精度でした。移動ログのみを目的とするのであれば単純にGPSの精度を下げればバッテリー消費を抑えることが可能ですが、『moveco(ムブコ)』の場合は移動手段の推定も機能の一つですので、安易に精度を下げるべきではありませんでした。

そこで、位置情報やActivityRecognitionAPIの移動推定を合わせた独自の判定方法で滞在中(移動していない)かどうかを判定し、滞在中はGPSの精度を一時的に下げるようにしました。これにより、ある程度の移動手段の推定精度を維持しつつ、常に高精度である場合と比べて20%~30%程度のバッテリー消費量の削減に成功しました。

Jetpack Composeについて

メリット - ビューの拡張のしやすさ

Android公式でビューが用意されており、普段の実装ではそれらを使うことになるかと思いますが、アプリによっては細かく拡張する必要があるかと思います。従来は熟練者でなければ内部の実装を参考にカスタムビューを作成するといったことは難しかったのではないかと思います。Jetpack Composeでは内部の実装もコードで記述されているので、比較的容易に内部実装を参考にビューを拡張することができるようになりました。『moveco(ムブコ)』の場合は、地図画面上のボトムシートについて、背後のビューをタップ可能にする必要があったため、既存のBottomSheetScaffoldを独自に拡張して使用する等の活用をしています。
これは簡易なカスタムビューでも同様の効果が得られると思います。以下、マイル(数値)を入れられる簡単なTextViewの拡張を例に挙げます。出来上がるのは画像のようなビューです。

SampleMileView

従来のやり方
カスタムビューを作成します

レイアウトXML

<androidx.cardview.widget.CardView
        android:id="@+id/content"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content">

    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <ImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_vertical"
            android:src="@drawable/sample_mile_icon" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_vertical"
            android:text="@{uiModel.mile}"
            tool:text="10000" />
    </LinearLayout>
</androidx.cardview.widget.CardView>

コード

class SampleMileView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {

    val binding = SampleMileViewBinding.inflate(LayoutInflater.from(context), this, true)

    fun setMile(mile: Int) {
        binding.uiModel = SampleMileViewUiModel.from(mile)
    }
}

上記カスタムビューを利用箇所のレイアウトXMLに当てはめます

<com.navitime.view.SampleMileView
    android:id="@+id/mile"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:mile="@{uiModel.mile}" />

Jetpack Composeの場合
ビュー部分のコンポーザブル関数を作成します

@Composable
fun SampleMileView(
    modifier: Modifier,
    mile: Int
) {
    Row(
        modifier = modifier,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Icon(
            painter = painterResource(
                id = R.drawable.sample_mile_icon,
            ),
            contentDescription = "Mile Icon",
        )
        Text(text = "$mile")
    }
}

上記コンポーザブル関数を利用画面のコンポーザブル関数に当てはめます

SampleMileView(
    modifier = Modifier,
    mile = uiModel.mile
)

上記のように、簡単なカスタムビューでも種類の違うファイル間でのやり取りが発生する上、独自の実装知識が必要になるため、実装しにくいものでしが、Jetpack Composeの場合は全てのコンポーザブル関数として扱うことができるため、より直感的にビューを拡張できるようになっています。

メリット - データバインディング、MVVMとの親和性

一般的なMVVMではデータバインディングの仕組みと併用することが多いかと思います。AndroidにもDataBindingライブラリが存在していますが、レイアウトXMLと組み合わせた使用シーンの場合、BindingAdapterなどを使用することが多くなり、どうしても低凝集なコードになりがちでした。

従来のUIとViewModelの関係性

Jetpack Composeの場合、DataBindingを使用する必要がなくなる上、UiModelと組み合わせることで画面を表現するためのコードの凝集度を高めつつもコードが肥大化しにくくできるようになったのではないかと思います。

Jetpack Composeの場合

デメリット - ライブラリ更新による影響

Jetpack Composeは現行で開発が進んでいるプロダクトであるため、バージョンが変わると内部の実装が大幅に変わっていることがあります。そのため、ライブラリのアップデートをした際に思わぬエラーが発生することが多々ありました。特にExperimentalなAPIやAccompanistの採用は、慎重に判断する必要があると思います。

デメリット - UI層の肥大化

メリットの部分で述べたこととは真逆になってしまいますが、役割の分離を明確に行わないと、UI上で何でもできるようになったため、いわゆるFatActivity, FatFragmentが容易に発生します。特にJetpack ComposeにおいてはUiModelの作成はより重要になったと言えると思います。
(UiModelに関しては本稿「UIレイヤー」の章を参照ください)

終わりに

開発全体を通して、Jetpack Composeの拡張性の高さや宣言的UIの優位性を感じつつも、現行で開発が進んでいる技術であること特有の苦しみもありました。それでもチームメンバーの協力のお陰で無事にリリースすることができました。この場を借りて深く感謝します。