【Android】Hilt + Room + Paging 3 + Composeでページネーションを実装する
はじめに
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の主な構成要素
- Repository Layer
-
PagingSource
- データソースからデータを取得する方法を定義
- DBやAPIなど
-
RemoteMediator
- アプリがキャッシュ データを使い切った際に、ページング ライブラリからのシグナルとして機能
- APIからの値をローカルDBなどにキャッシュして使う様なパターンで有効
-
PagingSource
- ViewModel Layer
-
Pager
- PagingDataストリームを生成する
- PagingSourceとPagingConfigを組み合わせて使用し、アプリの要件に応じたページング構成を定義する
-
Pager
- UI Layer
-
PagingDataAdapter
- RecyclerView の専用アダプター
- Composeを使用している場合
-
PagingDataAdapter
プロジェクトの作成
-
「New Project…」で「Empty Activity」を選択します
-
プロジェクト名を「RoomHiltComposeExample」として以下内容で作成します
※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.gradle
に hilt-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-android
を org-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.xml
の application
タグに 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
を作成していきます。
User.kt
1-1. 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
)
UserDao.kt
1-2. 次に同じ 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
}
AppDatabase.kt
1-3. 次も同じ 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}")
}
}
}
// ...
}
}
実装したら実際にエミュレータを起動し、ログに userId
と name
が表示されていれば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
でちゃんと登録できているか確認してみます。
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.xml
に android.permission.INTERNET
の permission
を設定しエミュレータで実行してみます。↓の様に一覧表示されればOKです。
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()
}
}
}
}
これで全ての実装が完了です!エミュレータで実行させてみるとページングできている雰囲気です。
が、これだと本当にページングできているか分からないので、pagingの3.1.0以降に追加されたデバッグ情報をログ表示してくれる以下のコマンドで有効化し確認してみたいと思います。
adb shell setprop log.tag.Paging VERBOSE
上記を実行しログを見てみると、
↑初回30件データが読み込まれていて、
↑スクロールの度に10件データを追加しているのが分かります。
今回の実装分は以下リポジトリにて公開してます
(Star頂けたら励みになります!)
Discussion