Android kotlin MVVM + Room + Retrofit2 + Paging3 + ksp でAndroid Hiltを利用したアプリ作成2

本記事は、RoomでDI及び、Paging3を利用した実装方法です。

前回の記事からの続きになります。

https://qiita.com/android_aim_for_the_top/items/5413bb98f60c8728f316


Roomのクラスを修正したので、差分ファイルです。
ディレクトリ（room、repository）は前回と同じです。
<dl>
  <dt>room</dt>
  <dd>
    <dl>
      <dt>repository</dt>
      <dd>UserRepository.kt</dd>
      <dd>UserSecondRepository.kt</dd>
      <dd>UserSecondRepositoryImpl.kt</dd>
      <dd>RoomRepositoryModule.kt</dd>
    </dl>
  </dd>
</dl>


# 1.Paging3の実装
前回説明したAPIモジュール、PagingSourceなどは割愛します。
ActivityからViewModelを呼び出し、PagingDataAdapterに値を設定するところまでを説明します。

検索ビューと結果を表示するレイアウトです。
```activity_qiita.xml
<?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クラスで利用する）

```view_holder_qiita_paging_adapter_item.xml
<?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ライブラリーはこちらを参照

https://developer.android.com/jetpack/androidx/releases/paging?hl=ja#kts


https://developer.android.com/topic/libraries/architecture/paging/v3-migration?hl=ja



```QiitaPagingAdapter.kt
/**
 * 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のクラスです。

```QiitaActivity.kt
@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クラスにしています。
```User.kt
@Entity(tableName = "user")
data class User(
    @PrimaryKey(autoGenerate = false)
    val id: Int,

    @ColumnInfo(name = "user_name")
    val userName: String,

    @ColumnInfo
    val password: String

)

```

Daoクラス（クエリーを実行するメソッド）です。
```UserDao.kt
@Dao
interface UserDao {

    /**
     * アカウント検索
     */
    @Query("SELECT * FROM user WHERE user_name = :userName AND password = :password")
    suspend fun getUser(userName: String, password: String): List<User>

}
```

RoomDatabaseのクラスです。
```AppDatabase.kt
@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にしています。）
```DatabaseModule.kt
@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()
    }


}
```


クエリーを実行結果を取得するメソッドのインタフェースです。
```UserSecondRepository.kt
interface UserSecondRepository {

    suspend fun getUserSecond(userName: String, password: String): List<User>

}
```


クエリー実行結果を取得するメソッドを実装しているクラスです。
UserSecondRepositoryインタフェースを実装しています。
リクエストするクエリーは、DAOインタフェースのメソッドを呼んでいます。
```UserSecondRepositoryImpl.kt
@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のクラスです。
```RoomRepositoryModule.kt
@Module
@InstallIn(SingletonComponent::class)
abstract class RoomRepositoryModule {

    @Binds
    abstract fun provideUserSecondRepository(
        userSecondRepository: UserSecondRepositoryImpl
    ): UserSecondRepository

}
```


ViewModelからRoomモジュールの呼び出しとデータ取得です。
```MainViewModel.kt
@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のメソッドを実行すれば動作します。


以上で説明は終わりになります。
ご意見ご要望があれば、お気軽にお知らせください。