📃

【Android】Hilt + Room + Paging 3 + Composeでページネーションを実装する

2024/03/24に公開

はじめに

Androidアプリ開発で Hilt + Room + Paging 3 + Compose を使って構築していく想定で実際に使いそうな部分を試した記事になります。
最終的にユーザー一覧をページングしながら表示できるまでを実装してみたいと思います。

各ライブラリの概要

Hilt

Hilt を使用した依存関係の注入  |  Android デベロッパー  |  Android Developers

Hilt は Dagger の上に構築されている Android 用の依存関係インジェクション ライブラリです。

Codelabは以下になります。

Android アプリでの Hilt の使用  |  Android デベロッパー  |  Android Developers

Room

roomに関しては前回の記事を参照して頂ければと思います。

【Android】Jetpack Room導入~基本的な使い方~シードデータ登録まで

Paging 3

ページング ライブラリの概要  |  Android Developers

データを段階的にロードしページング処理をサポートするJetpackのライブラリ。

Paging 3の主な構成要素

image1

  • Repository Layer
    • PagingSource
      • データソースからデータを取得する方法を定義
      • DBやAPIなど
      • RemoteMediator
        • アプリがキャッシュ データを使い切った際に、ページング ライブラリからのシグナルとして機能
        • APIからの値をローカルDBなどにキャッシュして使う様なパターンで有効
  • ViewModel Layer
    • Pager
      • PagingDataストリームを生成する
      • PagingSourceとPagingConfigを組み合わせて使用し、アプリの要件に応じたページング構成を定義する
  • UI Layer

プロジェクトの作成

  1. 「New Project…」で「Empty Activity」を選択します
    image2

  2. プロジェクト名を「RoomHiltComposeExample」として以下内容で作成します
    image3
    Build configuration language では Gradle Version Catalogs を使うようにしてます

依存関係の追加

gradle/libs.versions.toml に以下を追加し「Sync Project with Gradle Files」を実施ます。

※今回アノテーション プロセッサにKSPを使用しています。

[versions]
room = "2.6.1"
paging = "3.2.1"
ksp = "1.9.10-1.0.13"
hilt = "2.48"
hilt-navigation-compose = "1.2.0"
lifecycle-viewmodel-ktx = "2.7.0"
coil = "2.6.0"

[libraries]
# hilt
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-navigation-compose" }
lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle-viewmodel-ktx" }

# room
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
room-paging = { module = "androidx.room:room-paging", version.ref = "room" }

# paging
paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" }
paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }

# coil (AsyncImageで使用)
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }

[plugins]
ksp-gradle-plugin = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt-android-gradle-plugin = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }

ルートの build.gradlehilt-android-gradle-plugin プラグインを追加します。

plugins {
    alias(libs.plugins.ksp.gradle.plugin) apply false
    alias(libs.plugins.hilt.android.gradle.plugin) apply false
}

app/build.gradle.kts に以下を追加します。

plugins {
    alias(libs.plugins.ksp.gradle.plugin) // 追加
    alias(libs.plugins.hilt.android.gradle.plugin) // 追加
}

// ...

dependencies {
    // hilt
    implementation(libs.lifecycle.viewmodel.ktx)
    implementation(libs.hilt.android)
    ksp(libs.hilt.compiler)
    implementation(libs.hilt.navigation.compose)

    // room
    implementation(libs.room.runtime)
    implementation(libs.room.ktx)
    annotationProcessor(libs.room.compiler)
    ksp(libs.room.compiler)
    implementation(libs.room.paging)
    
    // paging
    implementation(libs.paging.runtime)
    implementation(libs.paging.compose)
    
    // coil
    implementation(libs.coil.compose)
}

再度「Sync Project with Gradle Files」を実施します。

※ 以下の様なエラーが発生する場合は org-jetbrains-kotlin-androidorg-jetbrains-kotlin-android = "1.9.10" に変更すると解決する場合があります。

Unable to find method ''org.jetbrains.kotlin.gradle.plugin.mpp.KotlinAndroidTarget org.jetbrains.kotlin.gradle.plugin.mpp.KotlinJvmAndroidCompilation.getTarget()''
'org.jetbrains.kotlin.gradle.plugin.mpp.KotlinAndroidTarget org.jetbrains.kotlin.gradle.plugin.mpp.KotlinJvmAndroidCompilation.getTarget()'

実装

1. Hilt アプリケーション クラスの作成

Hilt を使用するアプリには、@HiltAndroidApp アノテーションが付けられた [Application](https://developer.android.com/reference/android/app/Application?hl=ja) クラスが含まれている必要があります。今回は MainApplication.kt を以下内容で作成します。

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class MainApplication : Application() {
}

次に AndroidManifest.xmlapplication タグに name を追加します。

<application
    android:name=".MainApplication"
    ...>
</application>

2. @AndroidEntryPointの設定

https://dagger.dev/api/latest/dagger/hilt/android/AndroidEntryPoint.html

DIのEntryPoitとして設定するアノテーションで、ライフサイクルも付与したものに応じて動作します。今回はプロジェクト作成時に作成される MainActivity に付与します。

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
  ...
}

3. Room実装

id、名前、プロフィール画像を持つ users テーブルを実装する想定で進めていこうと思います。

User.kt, UserDao.kt, AppDatabase を作成していきます。

1-1. User.kt

com.example.roomhiltcomposeexample パッケージ配下に data パッケージを追加し User.kt を以下内容で作成します。

package com.example.roomhiltcomposeexample.data

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "users")
data class User(
    @PrimaryKey(autoGenerate = true)
    val id: Int = 0,
    var name: String = "",
    var image: String
)

1-2. UserDao.kt

次に同じ data パッケージ内に UserDao.kt を以下内容で作成します。

package com.example.roomhiltcomposeexample.data

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.Query
import kotlinx.coroutines.flow.Flow

@Dao
interface UserDao {
    @Query("SELECT * from users ORDER BY name ASC")
    fun getUsers(): Flow<List<User>>

    @Query("SELECT * from users WHERE id = :id")
    fun getUser(id: Long): Flow<User>

    @Insert
    suspend fun insert(user: User): Long
}

1-3. AppDatabase.kt

次も同じ data パッケージ内に AppDatabase.kt を以下内容で作成します。

package com.example.roomhiltcomposeexample.data

import androidx.room.Database
import androidx.room.RoomDatabase

@Database(
    entities = [User::class],
    version = 1,
    exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

4. DatabaseModule実装

次に作成した AppDatabase をどうDIするのかを決める DatabaseModule を作成します。

新規に di パッケージを作成し以下内容で DatabaseModule.kt を作成します。

package com.example.roomhiltcomposeexample.di

import android.content.Context
import androidx.room.Room
import com.example.roomhiltcomposeexample.data.AppDatabase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
    @Singleton
    @Provides
    fun provideDatabase(
        @ApplicationContext context: Context
    ) = Room.databaseBuilder(context, AppDatabase::class.java, "app_database")
        .fallbackToDestructiveMigration()
        .build()

    @Singleton
    @Provides
    fun provideUserDao(db: AppDatabase) = db.userDao()
}

ここでは AppDatabase をシングルトンとしてDIする設定と、UserDaoもシングルトンとしてDIされるように実装しています。

5. 試しにちゃんとDIされているか確認

上記の DatabaseModule がちゃんと動作しているか捨てコードで確認してみたいと思います。

MainActivity に以下処理を追加します。

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    // ↓追加
    @Inject
    lateinit var appDatabase: AppDatabase
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // ↓追加
        lifecycleScope.launch {
            appDatabase.userDao().also {
                appDatabase.withTransaction {
                    val id = it.insert(User(name = "taro", image = ""))
                    val user = it.getUser(id).first()
                    Log.d("MainActivity", "userId: ${user.id}, name: ${user.name}")
                }
            }
        }
        // ...
    }
}

実装したら実際にエミュレータを起動し、ログに userIdname が表示されていればOKです。

確認が取れたら追加したコードは削除しておきます。

6. 一覧で表示させるダミーユーザーを登録

一覧で表示させるダミーのユーザー情報をアプリ起動時に一度だけ登録するようにしてみたいと思います。

先ほどの AppDatabase の拡張メソッドとして以下を追加します。

fun AppDatabase.seed(scope: CoroutineScope) {
    scope.launch(Dispatchers.IO) {
        val dao = userDao()
        if (dao.getUsers().first().isNotEmpty()) return@launch
        for (i in 1..100) {
            User(name = "user${i}", image = "https://randomuser.me/api/portraits/thumb/men/${i}.jpg")
                .also { dao.insert(it) }
        }
    }
}

次に MainActivity に以下を追加します。

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    @Inject
    lateinit var appDatabase: AppDatabase

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        appDatabase.seed(lifecycleScope) // ← 追加
        
        // ...
    }
}

ここまできたらシュミレータで実行しAndroidStudioの App Inspection > Database Inspector でちゃんと登録できているか確認してみます。

image4

7. 一覧表示させるUI部分の実装

新規に MainScreen.kt を作成します。まずは内部で定義したダミーデータを一覧表示させるだけのUIを実装します。

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen() {
    val items = mapOf<String, String>(
        "user1" to "https://randomuser.me/api/portraits/thumb/men/1.jpg",
        "user2" to "https://randomuser.me/api/portraits/thumb/men/2.jpg",
        "user3" to "https://randomuser.me/api/portraits/thumb/men/3.jpg",
    ).toList()

    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text("MainScreen")
                }
            )
        },
    ) { innerPadding ->
        LazyColumn(
            modifier = Modifier
                .padding(innerPadding)
        ) {
            items(items = items) {
                Row(
                    horizontalArrangement = Arrangement.Center,
                    verticalAlignment = Alignment.CenterVertically,
                ) {
                    AsyncImage(
                        model = it.second,
                        modifier = Modifier
                            .padding(6.dp)
                            .height(60.dp)
                            .width(60.dp),
                        contentDescription = "Translated description of what the image contains",
                        contentScale = ContentScale.FillBounds,
                    )
                    Text(
                        modifier = Modifier
                            .padding(vertical = 18.dp, horizontal = 8.dp),
                        text = it.first
                    )
                }
                HorizontalDivider()
            }
        }
    }
}

次に MainActivity を以下に修正します。

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    @Inject
    lateinit var appDatabase: AppDatabase

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        appDatabase.seed(lifecycleScope)

        setContent {
            RoomHiltComposeExampleTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    MainScreen() // 作成したMainScreenに置き換え
                }
            }
        }
    }
}

最後に AndroidManifest.xmlandroid.permission.INTERNETpermission を設定しエミュレータで実行してみます。↓の様に一覧表示されればOKです。

image5

8. ViewModelの作成

次にDatabaseから設定した件数分QueryされたデータをUIに渡す部分のViewModelを作成していきます。

まずは UserDao にページング用のQueryを追加します。

@Dao
interface UserDao {
    // ↓追加
    @Query("SELECT * from users")
    fun getUserPages(): PagingSource<Int, User>
}

次に以下内容で MainScreenViewModel を作成します。

@HiltViewModel
class MainScreenViewModel @Inject constructor(private val userDao: UserDao) :
    ViewModel() {
    fun getUsers(): Flow<PagingData<User>> =
        Pager(
            config = PagingConfig(
                pageSize = 10,
                prefetchDistance = 20,
            ),
        ) {
            userDao.getUserPages()
        }.flow.cachedIn(viewModelScope)
}

最後に MainActivity を以下に修正します。

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
    viewModel: MainScreenViewModel = hiltViewModel()
) {
    // ↓に変更
    val items = viewModel.getUsers().collectAsLazyPagingItems()
    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text("MainScreen")
                }
            )
        },
    ) { innerPadding ->
        LazyColumn(
            modifier = Modifier
                .padding(innerPadding)
        ) {
            items(count = items.itemCount) {
                val item = items[it]
                Row(
                    horizontalArrangement = Arrangement.Center,
                    verticalAlignment = Alignment.CenterVertically,
                ) {
                    AsyncImage(
                        model = item?.image,
                        modifier = Modifier
                            .padding(6.dp)
                            .height(60.dp)
                            .width(60.dp),
                        contentDescription = "Translated description of what the image contains",
                        contentScale = ContentScale.FillBounds,
                    )
                    Text(
                        modifier = Modifier
                            .padding(vertical = 18.dp, horizontal = 8.dp),
                        text = item?.name ?: ""
                    )
                }
                HorizontalDivider()
            }
        }
    }
}

これで全ての実装が完了です!エミュレータで実行させてみるとページングできている雰囲気です。

image6

が、これだと本当にページングできているか分からないので、pagingの3.1.0以降に追加されたデバッグ情報をログ表示してくれる以下のコマンドで有効化し確認してみたいと思います。

adb shell setprop log.tag.Paging VERBOSE

上記を実行しログを見てみると、

image7

↑初回30件データが読み込まれていて、

image8
image9

↑スクロールの度に10件データを追加しているのが分かります。

今回の実装分は以下リポジトリにて公開してます

(Star頂けたら励みになります!)

https://github.com/Slowhand0309/RoomHiltComposeExample

この記事は以下の情報を参考にして執筆しました

Discussion