本記事は、RoomでDI及び、Paging3を利用した実装方法です。
前回の記事からの続きになります。
Roomのクラスを修正したので、差分ファイルです。
ディレクトリ(room、repository)は前回と同じです。
- room
-
- repository
- UserRepository.kt
- UserSecondRepository.kt
- UserSecondRepositoryImpl.kt
- RoomRepositoryModule.kt
1.Paging3の実装
前回説明したAPIモジュール、PagingSourceなどは割愛します。
ActivityからViewModelを呼び出し、PagingDataAdapterに値を設定するところまでを説明します。
検索ビューと結果を表示するレイアウトです。
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
>
<data>
<import type="android.view.View" />
<variable
name="activity"
type="com.jp.ui.activity.QiitaActivity" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
>
<!-- ヘッダー、ボディー、フッター レイアウト -->
<androidx.appcompat.widget.LinearLayoutCompat
android:id="@+id/activity_qiita_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:background="@color/white"
android:visibility="visible"
>
<TextView
android:id="@+id/activity_qiita_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#333333"
android:textSize="20sp"
android:textStyle="bold"
android:text="Qiita検索画面"
tools:text="タイトル"
/>
<View
android:layout_width="match_parent"
android:layout_height="12dp"/>
<!-- iconifiedByDefaultをfalseにして、常に検索の入力欄が表示されている状態にする -->
<androidx.appcompat.widget.SearchView
android:id="@+id/activity_qiita_search_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:iconifiedByDefault="false"
app:queryHint="キーワード検索"
android:layout_gravity="center"
android:layout_marginHorizontal="8dp"
/>
</androidx.appcompat.widget.LinearLayoutCompat>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/activity_qiita_recycler_view"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constrainedHeight="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/activity_qiita_header"
app:layout_constraintBottom_toTopOf="@+id/activity_qiita_footer"
android:scrollbars="vertical"
android:fadeScrollbars="false"
>
</androidx.recyclerview.widget.RecyclerView>
<androidx.appcompat.widget.LinearLayoutCompat
android:id="@+id/activity_qiita_footer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/activity_qiita_recycler_view"
app:layout_constraintEnd_toEndOf="parent"
android:orientation="horizontal"
android:background="@color/white"
android:visibility="gone"
>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#333333"
android:textSize="20sp"
android:textStyle="bold"
android:text="フッター"
tools:text="フッター"
/>
</androidx.appcompat.widget.LinearLayoutCompat>
<ProgressBar
android:id="@+id/paginationProgressBar"
style="?attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="invisible"
android:background="@android:color/transparent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
リストに表示するアイテムのレイアウトです。(後ほど説明するAdapterクラスで利用する)
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="data"
type="com.jp.api.data.Product" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
>
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:strokeWidth="1dp"
app:cardElevation="1dp"
android:layout_margin="8dp"
>
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Media -->
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/view_holder_qiita_paging_data_adapter_item_image"
android:layout_width="match_parent"
android:layout_height="194dp"
android:scaleType="centerCrop"
android:contentDescription="content_description_media"
/>
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- Title, secondary and supporting text -->
<androidx.appcompat.widget.AppCompatTextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceHeadline6"
android:lines="2"
android:ellipsize="end"
android:text="@{data.title}"
tools:text="title"
/>
<androidx.appcompat.widget.AppCompatTextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAppearance="?attr/textAppearanceBody2"
android:textColor="?android:attr/textColorSecondary"
tools:text="2023/12/31"
/>
<androidx.appcompat.widget.AppCompatTextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="supporting_text"
android:textAppearance="?attr/textAppearanceBody2"
android:textColor="?android:attr/textColorSecondary"
tools:text="supporting_text"
android:visibility="gone"
/>
</androidx.appcompat.widget.LinearLayoutCompat>
<!-- Buttons -->
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/view_holder_qiita_paging_data_adapter_item_detail"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="詳しく"
style="?attr/borderRound"
tools:text="action_1"
/>
<com.google.android.material.button.MaterialButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="action_2"
style="?attr/borderlessButtonStyle"
android:visibility="invisible"
/>
<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
>
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/view_holder_qiita_paging_data_adapter_item_favorite"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:scaleType="centerCrop"
android:contentDescription="content_description_media"
android:layout_gravity="end"
/>
</androidx.appcompat.widget.LinearLayoutCompat>
</androidx.appcompat.widget.LinearLayoutCompat>
</androidx.appcompat.widget.LinearLayoutCompat>
</com.google.android.material.card.MaterialCardView>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
Paging3を利用する為のPagingDataAdapterクラスです。(DiffUtilを利用)
Pagingライブラリーはこちらを参照
/**
* Paging を利用したリスト
*/
class QiitaPagingAdapter @Inject constructor() :
PagingDataAdapter<Product, RecyclerView.ViewHolder>(DiffCallback) {
/**
* レイアウト設定
*/
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val layoutInflater: LayoutInflater = LayoutInflater.from(parent.context)
val binding: ViewHolderQiitaPagingAdapterItemBinding
= ViewHolderQiitaPagingAdapterItemBinding.inflate(layoutInflater, parent, false)
return QiitaPagingAdapterItemVH(binding)
}
/**
* データバインディング処理
*/
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val current: Product? = getItem(position)
if(holder is QiitaPagingAdapterItemVH){
val vh: QiitaPagingAdapterItemVH = holder
current?.let {
vh.bind(it)
}
}
}
////////////////////////////////////////////////
// クラス内に作成されるシングルトンのことです。
////////////////////////////////////////////////
companion object {
private val DiffCallback = object : DiffUtil.ItemCallback<Product>(){
//このメソッドでは引数で渡される2つのオブジェクトoldItem、newItemが同じアイテムを表すかどうかを判定する処理を記述します。
//一般的にはIDの比較、または同じインスタンスであるかなどの比較処理を行います。
override fun areItemsTheSame(oldItem: Product, newItem: Product): Boolean {
return oldItem == newItem
}
//このメソッドはareItemsTheSame()がtrueを返す場合、
// つまり引数で渡される2つのオブジェクトが同じアイテムを表していると判定された場合にのみ呼び出されます。
//データの差分を行う
//このメソッドでfalseを返す場合Viewは再構築せずに再利用されます。
override fun areContentsTheSame(
oldItem: Product,
newItem: Product
): Boolean {
return oldItem.id == newItem.id
}
}
}
//////////////////////////////////////////////////////
// inner class
//////////////////////////////////////////////////////
//内部クラスが外側のクラスへの参照を持った状態にするには、内部クラスに innrer 修飾子を付与する
/**
* ViewHolder
*/
inner class QiitaPagingAdapterItemVH(private val binding: ViewHolderQiitaPagingAdapterItemBinding)
: RecyclerView.ViewHolder(binding.root){
private lateinit var favorite: AppCompatImageView
private lateinit var deteil: MaterialButton
fun bind(data: Product){
binding.data = data
Log.d("", "### data : ${data.title} ###")
binding.also {
//アイテムタップした時のイベント処理などを実装する。
}
//TODO このメソッドは、レイアウトがlayoutタグで定義していないとエラーになる。
//変更内容をすぐに反映させる
binding.executePendingBindings()
}
}
}
Activity画面です。ViewModelの呼び出し、Adapterにセットするクラスです。
アノテーションがAndroidEntryPointのクラスです。
@AndroidEntryPoint
class QiitaActivity() : AppCompatActivity() {
lateinit var binding: ActivityQiitaBinding
// ViewModel
private val viewModel: QiitaViewModel by viewModels()
private lateinit var appContext: Context
private lateinit var searchView: androidx.appcompat.widget.SearchView
private lateinit var progressBar: ProgressBar
private lateinit var recyclerView: RecyclerView
//TODO Injectの変数をprivateにするとエラーになる。
//Dagger does not support injection into private fields
@Inject
lateinit var adapter: QiitaPagingAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d("", "### QiitaActivity onCreate ###")
//TODO レイアウトをlayoutタグで定義していないとエラーになる。
binding = DataBindingUtil.setContentView(this, R.layout.activity_qiita)
appContext = applicationContext
// adapter = QiitaPagingAdapter()
//プロパティを設定するapply,also
//also -> AndroidだとViewの初期化処理等
binding.also {
searchView = it.activityQiitaSearchView
progressBar = it.paginationProgressBar
recyclerView = it.activityQiitaRecyclerView
}
searchView.also {
it.setOnQueryTextListener(SearchViewListener())
}
recyclerView.also {
it.layoutManager = LinearLayoutManager(appContext)
it.adapter = adapter
//これを設定すると表示されない?? 原因を調査する
// it.setHasFixedSize(true)
}
}
////////////////////////////////////////////////////////////////////////////////////////////////
// inner class
////////////////////////////////////////////////////////////////////////////////////////////////
//内部クラスが外側のクラスへの参照を持った状態にするには、内部クラスに innrer 修飾子を付与する
private inner class SearchViewListener() : androidx.appcompat.widget.SearchView.OnQueryTextListener {
// 検索が実行されたタイミングで実行される
//TODO 検索ワードをDBにカテゴリーとして登録し、お気に入り画面に絞り込み条件として使用するようにしたい。
override fun onQueryTextSubmit(query: String): Boolean {
Log.d("", "### QiitaActivity onQueryTextSubmit ###")
//検索処理実行
// viewModel.getQiitaData(query = "kotlin")
// viewModel.getQiitaPagingData(query = "kotlin")
viewModel.getQiitaPagingData(query = query)
//TODO ここでコルーチン監視をしているのは、初回画面起動時のローディング処理が走ってしまうのを避ける為
// observeUI()
observeUIPaging()
return false
}
// 文字が入力されたタイミングで実行される
override fun onQueryTextChange(newText: String?): Boolean {
Log.d("", "### QiitaActivity onQueryTextChange ###")
return false
}
}
private fun observeUI(){
//監視
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED){
viewModel.myUiState.collectLatest {
when(it){
is Resource.Loading -> {
//初回画面表示時にローディング処理を行わないようにする為
Toast.makeText(appContext ,"QiitaActivity Loading", Toast.LENGTH_SHORT).show()
}
is Resource.Success -> {
hideProgressBar()
Toast.makeText(appContext ,"QiitaActivity Success", Toast.LENGTH_SHORT).show()
//画面更新
}
is Resource.Error -> {
Toast.makeText(appContext ,"QiitaActivity Error", Toast.LENGTH_SHORT).show()
}
else -> {
}
}
}
}
} //lifecycleScope
}
private fun observeUIPaging(){
//監視
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED){
viewModel.myUiStatePagingData.collectLatest {
adapter.submitData(it)
}
}
} //lifecycleScope
}
private fun hideProgressBar() {
progressBar.visibility = View.INVISIBLE
}
private fun showProgressBar() {
progressBar.visibility = View.VISIBLE
}
}
2.Room実装
ここから、RoomモジュールのDIを利用した実装方法の説明になります。
entityクラス(テーブル定義)です。
ログイン画面を想定した、ユーザー名、パスワードのカラムを持つentityクラスにしています。
@Entity(tableName = "user")
data class User(
@PrimaryKey(autoGenerate = false)
val id: Int,
@ColumnInfo(name = "user_name")
val userName: String,
@ColumnInfo
val password: String
)
Daoクラス(クエリーを実行するメソッド)です。
@Dao
interface UserDao {
/**
* アカウント検索
*/
@Query("SELECT * FROM user WHERE user_name = :userName AND password = :password")
suspend fun getUser(userName: String, password: String): List<User>
}
RoomDatabaseのクラスです。
@Database(
entities = [
User::class,
Qiita::class,
],
version = AppDatabase.DATABASE_VERSION
)
abstract class AppDatabase() : RoomDatabase() {
/**
* TODO テーブル追加、カラム追加した場合は、DATABASE_VERSIONを上げる
*/
companion object {
const val DATABASE_VERSION: Int = 1
}
//////////////////////////////////////////////////////////////
//DAO
//////////////////////////////////////////////////////////////
abstract fun userDao(): UserDao
}
Roomのモジュール(データベース生成、DAO生成)
アノテーションがModuleのクラスです。(このアプリでは、objectにしています。)
@InstallIn(SingletonComponent::class)
@Module
object DatabaseModule {
/**
* DB 生成
*/
@Provides
@Singleton
fun provideDatabase(@ApplicationContext appContext: Context): AppDatabase {
return Room.databaseBuilder(
appContext,
AppDatabase::class.java,
"app_database"
)
// Wipes and rebuilds instead of migrating if no Migration object.
// Migration is not part of this codelab.
.fallbackToDestructiveMigration()
.build()
}
////////////////////////////////////////////////////////////////////////
// DAO生成
////////////////////////////////////////////////////////////////////////
/**
* UserDao 生成
*/
@Provides
@Singleton
fun provideUserDao(database: AppDatabase): UserDao {
return database.userDao()
}
}
クエリーを実行結果を取得するメソッドのインタフェースです。
interface UserSecondRepository {
suspend fun getUserSecond(userName: String, password: String): List<User>
}
クエリー実行結果を取得するメソッドを実装しているクラスです。
UserSecondRepositoryインタフェースを実装しています。
リクエストするクエリーは、DAOインタフェースのメソッドを呼んでいます。
@Singleton
class UserSecondRepositoryImpl @Inject constructor(
private val userDao: UserDao,
) : UserSecondRepository {
override suspend fun getUserSecond(userName: String, password: String): List<User> {
return userDao.getUser(userName = userName, password = password)
}
}
UserSecondRepositoryクラスをバインドするクラスになります。
アノテーションがBindsのクラスです。
@Module
@InstallIn(SingletonComponent::class)
abstract class RoomRepositoryModule {
@Binds
abstract fun provideUserSecondRepository(
userSecondRepository: UserSecondRepositoryImpl
): UserSecondRepository
}
ViewModelからRoomモジュールの呼び出しとデータ取得です。
@HiltViewModel
class MainViewModel @Inject constructor(
private val userSecondRepository: UserSecondRepository,
) : ViewModel() {
companion object {
private val TAG: String = MainViewModel::class.simpleName.toString()
}
//一般的に、クラスのカプセル化を意識して、MutableLiveDataプロパティを非公開にし、LiveDataプロパティのみを公開します。
//StateFlow
private val _myUiState: MutableStateFlow<Resource<List<User>>>
= MutableStateFlow(Resource.Loading(false))
val myUiState: StateFlow<Resource<List<User>>>
get() = _myUiState.asStateFlow()
fun searchUserSecond(userName: String, password: String){
viewModelScope.launch(Dispatchers.IO) {
Log.d(TAG, "### searchUserSecond searchUser ###")
try {
_myUiState.value = Resource.Loading(true)
val user = userSecondRepository.getUserSecond(userName = userName, password = password)
_myUiState.value = Resource.Success(user)
}catch (error: Exception){
Log.d(TAG, "### error.toString : $error ###")
_myUiState.value = Resource.Error(message = error.toString())
}
}
}
}
以上が、Android HiltでDIを実装したクラスになります。
あとは、適宜Activity、FragmentからViewModelのメソッドを実行すれば動作します。
以上で説明は終わりになります。
ご意見ご要望があれば、お気軽にお知らせください。