「作ればわかる!Androidプログラミング」をJetpack Composeで実装してみた

久しぶりに自分専用のAndroidアプリを作りたくなったため、最近のAndroid開発事情を調べてみたところ、

など、色々と学びたくなるような技術を知りました。

ただ、学びながら自分専用のアプリを作り始めても挫折するだろうと思い、まずは学ぶためのアプリを作ることにしました。

何か良い題材がないか本を探したところ、以前お世話になったシリーズの最新刊「作ればわかる!Androidプログラミング Kotlin対応」(金宏 和實 著、翔泳社) が良さそうでした。
作ればわかる!Androidプログラミング Kotlin対応 10の実践サンプルで学ぶAndroidアプリ開発入門(金宏 和實)|翔泳社の本

そこで、同書籍のアイデアを Kotlin + Jetpack Compose で実装してみることにしました。

この記事では、各アプリを実装したときに気づいたことなどをメモとして残しておきます。

なお、メモの内容に誤りがありましたらご指摘ください。

 
目次

 

環境

  • Android Studio 2021.2.1 Patch 1
  • Jetpack Compose 1.1系および1.2系
    • 最初はAndroid Studioでプロジェクトを作成したときにできる1.1系を使用
    • Roomを使うところで1.2系を使用
  • Room 2.4.3
  • Navigation Compose 2.5.1

 

やらないこと

今回の一番の目的は「Jetpack Compose の書き方に慣れる」としたため、今回やらないこととして以下を決めました。

  • Jetpack Composeを使った時の良いディレクトリ構成
    • ディレクトリ構成を真剣に考えるよりも動くものを作る
      • 現時点ではどんな構成が一番良いのかよくわからないため
  • MVVMを使うこと
    • 今回は画面が少ないこと、優先して学びたいことがあったため
  • Kotlinっぽい書き方をすること
    • 今回はKotlinを学ぶのが目的ではないため、Android Studioでエラーやサジェストが出なければOKとした
  • 書籍のアプリと同じデザイン・同じ実装とすること
    • どこまでJetpack Compose で作れるのか分かっていないため、おおよそできればOKとした
    • そのため、ConstraintLayout では作成していない
  • 書籍のアプリをすべて実装すること
    • 自分専用のアプリを作るにあたり、必要そうなアプリのみ実装することにした
    • そのため、センサー系や地図系は実装していない
    • 実装したくなったらそのときに実装する

 
以降、各章で調べたことを置いておきます。

 

4章 ハイ&ローゲーム

トランプの画像について

書籍通り、無料素材倶楽部さんよりトランプの画像をダウンロードしました。ありがとうございました。

なお、書籍の記載にある通り、自分の環境でも文字化けしています。

 

Scaffoldで topBarbottomBarcontent などをいい感じに定義する

参考にしたのは以下です。
androidx.compose.material  |  Android Developers

 
また、レイアウトについては以下の公式ドキュメントも参考にしました。

 

marginは Modifier を使って指定する

ふだんDBに近い生活をしているせいか、 RowColumn の置き方が逆な感覚がありました。

参考にしたドキュメントは以下です。

 

要素の中央寄せは、AlignmentとArrangementを使う

以下を参考にしました。
【Jetpack Compose】 要素を中央に配置する|yasukotelin|note

 

ローカルにある画像の表示は、 Image などで painterResource を使う

トランプ画像の表示が必要なため、以下を参考に painterResource を使いました。

 

状態は rememberrememberSaveable と、 mutableStateOf などで保持する

以下を参考にしました。
状態と Jetpack Compose  |  Android Developers

 

5章 名刺切らしてまして

画面を固定するには、 LocalContextrequestedOrientation を使う

書籍では AndroidManifest.xml で画面の向きを固定していました。

せっかくなので、Jetpack Composeでの画面の向きの固定方法を調べたところ、 LocalContextrequestedOrientation で作れば実装できました。
android - How to force orientation for some screens in Jetpack Compose? - Stack Overflow

 

TextFieldでのキーボード制御は KeyboardOptions を使う

「数字だけのキーボード」など、キーボード制御する方法を調べたところ、 KeyboardOptions を使えば実装できました。
android - How to set the inputType for a TextField in Jetpack Compose - Stack Overflow

 

Jetpack Compose での画面遷移は Navigation Compose を使う

ライブラリの最新のバージョンはこちらで確認できます。
Navigation  |  Android デベロッパー  |  Android Developers

なお、Navigation Compose の使い方については以下が参考になりました。

 

共有プリファレンスの PreferenceManager は、現時点では deprecated な模様

書籍では共有プリファレンスへ保存する際 PreferenceManager を使っていました。ただ、現時点では deprecated な模様です。
PreferenceManagerが@Deprecatedで困った話 - Qiita

そのため、以下を参考に、代替ライブラリを使いました。公式ドキュメントはこのあたりです。

 

TopAppBar の action でオプションメニューを実現する

書籍ではオプションメニューを作っていましたが、今回は TopAppBar の action を使うことにしました。
androidx.compose.material  |  Android Developers)

   

6章 ご飯なんにする?

オプションメニューのネストは、Composableを2つ作る

書籍ではオプションメニューを使っていました。

Jetpack Composeでのオプションメニュー実装を調べたところ、以下の方法のようにメインとネストのメニューを作れば実装できました。
android - What is the better or easier way to create "nested" menus in Jetpack Compose? - Stack Overflow

 

data classの値の更新は copy を使う

選択したメニューは Kotlin の data class として保持することを考えました。

ただ、選択したメニューを変更したときに、data class のプロパティをどのように更新すればよいか調べたところ、以下の回答がありました。
android - Jetpack Compose State: Modify class property - Stack Overflow

また、 MutableState オブジェクトの宣言は3パターンあると知り、setter を取得できる構文 val (value, setValue) = remember { mutableStateOf(default) } もあると知りました。
状態と Jetpack Compose  |  Android Developers

 

長押しイベントは combinedClickableonLongClick で設定できる

書籍では、長押しするとコンテキストメニューを表示するという仕様がありました。

Jetpack Compose で長押しイベントを設定する方法を調べたところ、以下の回答がありました。
kotlin - Button Long Press Listener in Android jetpack compose - Stack Overflow

ただし、公式ドキュメントを見ると、現時点では combinedClickable は Experimental な機能のようです。
Compose 修飾子のリスト  |  Jetpack Compose  |  Android Developers

そのため、Composableで使うときは @OptIn(ExperimentalFoundationApi::class) な指定も合わせて行う必要があるようです。

 

コンテキストメニューDropdownMenu で実装する

上記の onLongClick 時に、 DropdownMenu を表示すれば良さそうです。

 

intent は LocalContext.current の取得後に context.startActivity する

書籍では intent でメールやSMSを呼び出していました。

Composeで intent を使う方法を調べたところ、

  • LocalContext.current で context を取得
  • context を使って context.startActivity する

という流れで実装できました。

 
なお、Composableでない関数で context を使いたい場合は、Composableから context を渡してあげれば良さそうでした。
android - @composable invocations can only happen from the context of an @composable function - Stack Overflow

 

その他

 

10章 若くても血圧は記録せよ

書籍では血圧を記録するためのアプリを作ります。

ただ、自宅には血圧計がないため、今回は体重の記録アプリとしました。なお、プロジェクト名は Blood Pressure のままです。

また、現在のAndroidでデータベースを使うときは Room を使うことが多いようなので、書籍とは異なり Room を使って実装することにしました。

 

KSP版のRoomをインストールする

Roomを使うためには build.gradle に依存関係を追加する必要がありました。
セットアップ | Room を使用してローカル データベースにデータを保存する  |  Android デベロッパー  |  Android Developers

上記のドキュメントを見るとRoomには

  • kapt
  • ksp

の2つの版がありそうでした。

どちらの方を使うのが良いかを調べたところ、以下の記事類を見るに、KSP版のほうが良さそうでした。

QiitaではRoomのサポート状況はExperimentalと書かれていましたが、改めて現時点の対応状況を調べると、以下の通り Official Supported されていました。
Supported libraries | Kotlin Symbol Processing API | Kotlin

そのため、今回はKSP版を使うことにしました。

 
KSP版を使うには、 KSP自体もプロジェクトやアプリの build.gradle に追加する必要があります。
#68 Jetpack ComposeでToDoアプリを作る - Room | Mokelab Blog

KSPの最新バージョンを調べたところ、以下に記載がありました。
Maven Repository: com.google.devtools.ksp » symbol-processing-api

KSP 1.7系では、

  • 1.7.10-1.0.6
  • 1.7.0-1.0.6

の2つのバージョンがありました。

どちらのバージョンを使えばよいか迷ったため、Compose Compiler Versionを見ることにしました。
Pre-release Kotlin Compatibility | Compose to Kotlin Compatibility Map  |  Android Developers

1.3.0は出たばっかりなのか、英語版のみ rc が取れた表記になっていました。今回は出たばっかりのものでハマると大変だと思い、 Compose Compiler Version は 1.2.0 とすることにしました。

そのため、 Compatible Kotlin Version1.7.0 になることから、KSPも 1.7.0-1.0.6 を使えば良さそうでした。

なお、こちらのページでは、日本語版も安定版リリースが 1.3.0 となっています。
Compose Compiler  |  Android デベロッパー  |  Android Developers

 
これでKSPのバージョン指定ができたため、続いてRoomを指定します。Roomは最新バージョン 2.4.3 が使えそうです。
Room  |  Android デベロッパー  |  Android Developers

 

Roomを使うために Entity・DAO・Database を用意し、Applicationを差し替える

公式ドキュメントに従い、

  • Entity
  • DAO
  • Databaseクラス

の3つを用意します。
Room を使用してローカル データベースにデータを保存する  |  Android デベロッパー  |  Android Developers

 
なお、Databaseクラスのインスタンスの生成について、公式ドキュメントでは

val db = Room.databaseBuilder(
            applicationContext,
            AppDatabase::class.java, "database-name"
        ).build()

と書かれていますが、この処理をどこでやるかが気になりました。

Codelabを読むと

アプリに含めるデータベースとリポジトリインスタンスは、それぞれ 1 つのみにする必要があります。そのための簡単な方法は、これらを Application クラスのメンバーとして作成することです。こうすれば、必要な際にはそのたびに作成する代わりに、アプリから取得するだけで済みます。

13. リポジトリとデータベースをインスタンス化する | Android Room とビュー - Kotlin

とあったため、

  • Applicationを継承したクラスを作成し、そこにDatabaseのインスタンス生成を実装する
  • AndroidManifest.xml で、Applicationを差し替える

ようにしました。
Android Jetpack(Room + Compose)でTodoアプリ作ろう【後編】 - Qiita

 

Dateの代わりにLocalDateTimeを使い、型コンバータで変換する

書籍では登録時間として Date() の値をDBに保存しています。

ただ、Dateについて調べると、例えば以下のように java.util.Date は deprecated という記事が散見されました。
ほとんどが非推奨メソッドとなったjava.util.Date、代替手段と非推奨メソッド利用していた場合の問題とは | セキュリティ対策のラック

そこで、今回は Date ではなく LocalDateTime を使うことにしました。
LocalDateTime  |  Android Developers

なお、 LocalDateTimeは API 26 で使えるようになったため、 app/build.gradledefaultConfigminSdk 26 へと変更します。

 
ただ、RoomではLocalDateTimeをそのまま保存することができないことから、型コンバータを使用してDB上はLong型として保存するようにします。
Room を使用して複雑なデータを参照する  |  Android デベロッパー  |  Android Developers

// (略)

class LocalDateTimeConverter {
    @TypeConverter
    fun fromTimeStamp(value: Long?): LocalDateTime? {
        val instant: Instant? = value?.let { Instant.ofEpochSecond(it) }
        return LocalDateTime.ofInstant(instant, ZoneId.systemDefault())
    }

    @TypeConverter
    fun toTimeStamp(value: LocalDateTime?): Long? {
        return value?.atZone(ZoneId.systemDefault())?.toEpochSecond()
    }
}

 
この型コンバータは、Databaseのところでアノテーション @TypeConverters として以下のように指定すれば良いようです。

@Database(entities = [WeightRecord::class], version = 1, exportSchema = false)
@TypeConverters(LocalDateTimeConverter::class)
abstract class MyDatabase: RoomDatabase() {
    abstract fun weightRecordDao(): WeightRecordDao
}

 

DateTimeFormatterを使ってLocalDateTimeを文字列表記にする

書籍だと登録日時を画面に表示しているので、LocalDateTimeを文字列表記にする必要がありました。

方法としては、DateTimeFormatterを使えば良いようです。
Kotlinで日付をフォーマットする方法 | 寝室コンピューティング

 

Entityで id を autoGenerate するならば、Insert時に渡す id の値は 0 にする

Insert時には各項目を埋める必要がありますが、idは自動生成するようにしたため、何の値を渡せばよいか迷いました。

公式ドキュメントによると

If the field type is Long or Int (or its TypeConverter converts it to a Long or Int), Insert methods treat 0 as not-set while inserting the item.

PrimaryKey  |  Android Developers

とあったため、 0 を渡せば良さそうです。

 

Roomでの読み書きは LaunchedEffect + CoroutineScope + withContext で行う

このあたりは理解しきれていないので、今はできた形だけ書いておきます。

以下を参考にして、Roomでの読み書きが LaunchedEffect + CoroutineScope + withContext で行えるようになりました。

var weight by rememberSaveable {
    mutableStateOf("")
}

if (recordId.isNotEmpty()) {
    LaunchedEffect(Unit) {
        CoroutineScope(Dispatchers.Main).launch {
            withContext(Dispatchers.Default) {
                val dao = MyApplication.database.weightRecordDao()
                val record = dao.findById(recordId.toInt())
                weight = record.weight.toString()
            }
        }
    }
}

本来は深掘りしないといけないんですが、今回はこの程度で...

なお、 LaunchedEffect については以下を参考にしました。
LaunchedEffect|サンプルで理解するJetpack Composeの副作用の仕組み

   

書籍では、新規登録と編集は同じ画面を使っていました。

今回は

  • recordId を渡さない場合、新規登録として扱う
  • recordId を渡す場合、編集として扱う

とします。

そのため、Navigation Composeへ recordId を渡す/渡さないで制御できれば良さそうでした。

調べてみると、以下に省略可能な引数の渡し方が書いてありました。
省略可能な引数の追加 | Compose を使用したナビゲーション  |  Jetpack Compose  |  Android Developers

そのため、編集側の定義を

NavHost(navController = navController, startDestination = "main") {
    composable(
        "edit?recordId={recordId}",
        arguments = listOf(navArgument("recordId") { defaultValue = "" })
    ) { backStackEntry ->
        backStackEntry.arguments?.getString("recordId")?.let { it ->
            EditScreen(
                navController = navController,
                setShowSnackBar = setShowSnackBar,
                setMessage = setMessage,
                recordId = it
            )
        }
    }
}

とします。

そして、新規登録ボタンでは

@Composable
fun AddButton(navController: NavController) {
    FloatingActionButton(onClick = { navController.navigate("edit") }) {
        Icon(Icons.Filled.Add, contentDescription = "追加")
    }
}

と、何もクエリパラメータ構文でクエリパラメータなしで定義します。

一方、編集時は

Column(
    modifier = Modifier.clickable {
        navController.navigate("edit?recordId=${weightRecord.id}")  // これ
    }
) {
    weightRecord.recordedAt?.let { Text(text= it.format(dtf)) }
    Text(text="No. ${weightRecord.id}")
    Text(text="Weight: ${weightRecord.weight}")
}

のようにクエリパラメータ付きで定義することで、実現できました。

 

Snackbarは、 LaunchedEffect とともに使う

「登録しました」というメッセージを出すために、今回は Snackbar を使います。

Snackbarの使い方を調べたところ、以下に LaunchedEffect とともに使う例がありました。

そのため、一番上の Composable で

val (message, setMessage) = remember {
    mutableStateOf("")
}

と、新規登録/編集画面から渡されるメッセージを保持し、メイン画面のComposableで

val scaffoldState = rememberScaffoldState()
if (showSnackBar) {
    LaunchedEffect(scaffoldState.snackbarHostState) {
        val result = scaffoldState.snackbarHostState.showSnackbar(
            message = message,
            actionLabel = "閉じる"
        )

        when (result) {
            SnackbarResult.Dismissed -> { setShowSnackBar(false) }
            SnackbarResult.ActionPerformed -> { setShowSnackBar(false) }
        }
    }
}

のように message の中身をSnackbarで表示させることで、やりたいことができました。

 

Compose 1.2.0から、Scaffoldの content に padding パラメータの利用が必須化した

体重記録アプリで初めて 1.2.0 系を使うようにしたところ、

Jetpack Compose: Content padding parameter it is not used

のようなエラーメッセージが出るようになりました。

調べてみたところ、1.2.0系からScaffoldのcontentには padding を渡す必要があるようです。
android - Content padding parameter it is not used - Stack Overflow

そのため、上記の回答通り、 padding を渡すようにしたところ、エラーメッセージは表示されなくなりました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/tsukuwaka_android_with_kotlin