AndroidX LiveData,ViewModel, Room,コルーチンを使ってRecycler Viewにリストを表示させる(Kotlin サンプルコードあり)

最初に、私がどういう感じでAndroidの今の開発をやってるかというと、Android2.3~6 になったころぐらいが一番開発をしていて、一旦Androidの開発からは引退してたんですが、最近舞い戻ってきたというところです。

なので、LiveDataとかViewModelとか、実はRecevler Viewも今回初めて実装します。Android Roomとかはもちろん初めてですね。(実はKotlinも初めてです…汗)
というわけで、しばらくやってなかったけど、基本的なことはわかってる。最近のAPIがわからないという人にちょうどいいぐらいの解説を交えて、自分の勉強のためにもまとめを書いておこうと思います。

参考にしたのはこちら様のサイトです。

Android Architecture Components 初級 ( MVVM + LiveData + Coroutines 編 )
https://qiita.com/Tsutou/items/69a28ebbd69b69e51703

Githubにもソースコードがあって、大変助かりました。┌o ペコッ
しかし、これはJSONでデータを受け取って処理するやつですね。
私がやりたかったのは、端末のDBからデータとってきてリストにしたかったことです。なので、Android Roomを使いたかったんです。
しかし、これも後日、とってもいいサンプルを見つけました。

Android Room with a View – Kotlin
https://codelabs.developers.google.com/codelabs/android-room-with-a-view-kotlin/#0

これも、Githubにソースコードがあって、大変参考になります。このCodeLabシリーズは非常にクォリティ高いと思います。
それにしても、もう、この記事書く意味ないじゃんワロタ。になりそうでしたが、まぁ英語だし、読むのが骨が折れる人がいると思うので、やっぱり書いておきます。

サンプルなので、複雑なことはなるべく省きます。 また、Roomがなんなのかとか、LiveDataがなんなのかなどの、基本的な説明はしませんがので、そこんとこヨロシク!でお願いします 。

MainActivityだけで全部やります。んで、さんざん他人様のGithubを参考にしてきたので、もちろん私もGithubにこのソースコードを上げておきます。
いろいろ至らないところがあると思いますが、ご指摘ください。

なお、作ったアプリのスクショはこんな感じです。

RoomとLiveDataを使ってタスクをRecyclerViewでリスト表示するアプリ

アプリの目的としては、タスクをDBに追加して、それをRecevler Viewでリストみたいに表示する、ということをやります。

では、前置きがかなり長くなりましたが、まずタスク本体です。Roomでやる場合の決まり事がいっぱいあって、その通りに作ります。

//Task.kt

package jp.onlineconsultant.listsample

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


@Entity(tableName = "task")
data class Task(
@PrimaryKey(autoGenerate = true)
var id: Int? = 0,
var content: String?,
val from_user_id: Int?,
val to_user_id: Int?,
var limit: String?,
var purpose_id: Int?,
var created: String,
var updated: String
) {

override fun toString(): String {

val task_name:String
if(content != null){
task_name = content!!
}else{
task_name = "無題"
}
return task_name
}
}

idが auto generate でprimary keyです。

DBの設定を作ります。

//AppDatabase.kt

package jp.onlineconsultant.listsample.data.database

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import jp.onlineconsultant.listsample.Task
import jp.onlineconsultant.taskadmin.data.dao.TaskDao


@Database(entities = [Task::class],
        version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract val taskDao : TaskDao
}

private lateinit var INSTANCE: AppDatabase
/**
 * Instantiate a database from a context.
 */
fun getDatabase(context: Context): AppDatabase {
    synchronized(AppDatabase::class) {
        if (!::INSTANCE.isInitialized) {
            INSTANCE = Room
                .databaseBuilder(
                        context.applicationContext,
                        AppDatabase::class.java,
                        "task-admin"
                )
                .fallbackToDestructiveMigration()
                .build()
        }
    }
    return INSTANCE
}

@Database(entities = [Task::class],
version = 1) この数字がDBのバージョンの数字ですが、Task.ktなどでプロパティの変更をしたりする場合も、このバージョンを上げないといけません。

//TaskDao.kt

package jp.onlineconsultant.taskadmin.data.dao

import androidx.lifecycle.LiveData
import androidx.room.*
import jp.onlineconsultant.listsample.Task


@Dao
interface TaskDao {

    @Query("SELECT * FROM task ORDER By id DESC LIMIT 30")
    suspend fun taskListNotLiveData(): List<Task>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertOne(task: Task)

    @Delete
    fun delete(task: Task)
    
}

この3つで、なんとデータベースの作成が終わりです。前はSQLiteOpenHelperとか使って、SQL書いてテーブルとか作ってましたが、ずいぶんわかりやすくなりました。

Roomってしゅごい…。

次に、repositoryというファイルを作ります。が、これは必須ではないです。Daoとやり取りするのに、便利な関数をここに入れておく感じなんですかね。


//TaskRepository.kt

package jp.onlineconsultant.listsample.data.repository

import android.content.Context
import android.util.Log
import android.widget.Toast
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.map
import jp.onlineconsultant.listsample.R
import jp.onlineconsultant.listsample.Task
import jp.onlineconsultant.taskadmin.data.dao.TaskDao


class TaskRepository(val taskDao: TaskDao) {


suspend fun getTaskList(): List<Task> {

try {

val list = taskDao.taskListNotLiveData()
return list


} catch (cause: Throwable) {

Log.e("TaskRepository", cause.toString())
val list = emptyList<Task>()
return list
}


}

suspend fun addTask(context:Context, task:Task) {

try {

taskDao.insertOne(task)

} catch (cause: Throwable) {

Log.e("TaskRepository", cause.toString())
Toast.makeText(context, "task_cannot_create_db_error" + cause.toString(), Toast.LENGTH_LONG).show();

}
}

}

データベースからデータを取ってくるのはこれぐらいです。

suspend ってついているところが、コルーチンになってます。コルーチン。素晴らしい機能です。ViewModelと連動して、いい感じになりますので、後で説明します。

次に、UIをやりましょう。

いきなりですが、話の都合上、Activityを作ります。

//MainActivity.kt
package jp.onlineconsultant.listsample

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import android.widget.Button
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.facebook.stetho.Stetho
import jp.onlineconsultant.listsample.data.database.getDatabase
import jp.onlineconsultant.listsample.data.repository.TaskRepository
import jp.onlineconsultant.listsample.databinding.ActivityMainBinding


class MainActivity : AppCompatActivity() {

    private lateinit var taskListViewModel: TaskListViewModel
    private lateinit var taskListAdapter: TaskListAdapter
    private lateinit var binding: ActivityMainBinding

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


        Stetho.initialize(
            Stetho.newInitializerBuilder(applicationContext)
                .enableDumpapp(Stetho.defaultDumperPluginsProvider(applicationContext))
                .enableWebKitInspector(Stetho.defaultInspectorModulesProvider(applicationContext))
                .build()
        )


        setContentView(R.layout.activity_main)

        val database =
            getDatabase(this)
        val repository =
            TaskRepository(database.taskDao)

        taskListViewModel = ViewModelProviders.of(this, TaskListViewModel.Factory(repository))
            .get(TaskListViewModel::class.java)

        taskListAdapter = TaskListAdapter(taskClickCallback)


        //dataBinding用のレイアウトリソース
        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)

        binding.apply {
            taskList.adapter = taskListAdapter
        }

        setRecycleView(binding)

        val button: Button = findViewById(R.id.button)
        button.setOnClickListener {

            taskListViewModel.onButtonClicked(this)

        }

        observeViewModel(taskListViewModel)


    }

    private fun setRecycleView(binding: ActivityMainBinding) {
        val recyclerView: RecyclerView = binding.root.findViewById(R.id.task_list);
        //このレイアウトマネージャーとかの定義がないと、RecyclerViewが表示されない
        recyclerView.setHasFixedSize(true); // RecyclerViewのサイズを維持し続ける
        recyclerView.setLayoutManager(LinearLayoutManager(this));
        recyclerView.setItemAnimator(DefaultItemAnimator());

        // RecyclerView自体の大きさが変わらないことが分かっている時、
        // このオプションを付けておくと、パフォーマンスが改善されるらしい
        recyclerView.setHasFixedSize(true);
    }

    private val taskClickCallback = object : TaskClickCallback {
        override fun onClick(task: Task) {
            //適宜実装してください
        }
    }

    private fun observeViewModel(viewModel: TaskListViewModel) {

        val taskObserver = Observer<List<Task?>?> { tasks ->

            tasks?.let { taskListAdapter.setTaskList(it) }

        }

        viewModel.taskListLiveData.observe(this, taskObserver)


    }

}

次に、レイアウトファイルです。

DataBindingの世界になってから、レイアウトファイルをどうやってうまく作るかは結構問題になってきますね。。。

<!-- activity_main.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"
>

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity"
>


<androidx.recyclerview.widget.RecyclerView
android:id="@+id/task_list"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:contentDescription="タスクリスト"
app:layout_constraintTop_toTopOf="parent"
tools:layout_editor_absoluteX="-16dp"
/>

<Button
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="タスクを追加"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/task_list"
/>

</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

タスクが一個もないと、タスクをそもそも表示させられないので、タスクを追加できる仕様になってます。追加できるタスクは後で出てきますが、決め打ちのリストからランダムです。

んで、RecyclerViewのアダプタを作ろうとして、
「一体、どうやってリストのデータをBindingするのか…」
そこに詰まりました。

データはDBから非同期で取得されるので、RecyclerViewが作られるときには、データがないんですよ…。

昔だったら、この辺も自作して、オブザーバーとか自分で作ったり、Handlerとか作ったり、AsyncTaskでインターフェース作って画面を更新したりなどなどしていました。

しかし、そんなんならLiveDataとか意味ないじゃん… ってなりまして。検索したところ、冒頭で紹介したQiitaに助けられました。
一旦、作ったLiveDataにDBから値を取得後、値をPostすればいいんです。

んで、そいういうことをやるViewModelを作ります。ViewModelは、ビューにデータを投げます。
例えば、タスクリストを今作ってますが、タスクリストはいろんなActivity、いろんなFragmentで使われる可能性がありますよね。その辺の使いまわしを、今までは自作していたのが、Androidのプラットフォームでこの使いまわしができるようになったのです。


//TaskListViewModel.kt

package jp.onlineconsultant.listsample

import android.content.Context
import android.util.Log
import android.widget.Toast
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import jp.onlineconsultant.listsample.data.repository.TaskRepository
import kotlinx.coroutines.launch
import java.util.*
import kotlin.collections.ArrayList

class TaskListViewModel constructor(private val repository: TaskRepository) : ViewModel() {

    val TAG: String = "TaskListViewModel"
    var taskListLiveData = MutableLiveData<List<Task>>()

    init {
        loadTaskList()
    }


    private fun loadTaskList() {
        viewModelScope.launch {
            try {

                Log.d(TAG, "viewModelScope.launch ")
                val listNotLiveData = repository.getTaskList()

                // メンバー変数のLiveDataにこの値を送っている これが大事!!ここは、LiveDataを送るのではなく、リスト形式を送るのがミソ
                taskListLiveData.postValue(listNotLiveData)


            } catch (e: Exception) {

                Log.e(TAG, "データ取得中にエラー " + e)

            }
        }
    }

    fun onButtonClicked(context: Context) {

        //ダミーのタスクリスト
        val task1 = Task(null, "ジョギングする", 1, 1, "2020/02/30", null, "2020/01/30", "2020/01/30");
        val task2 = Task(null, "麻雀する", 1, 1, "2020/02/10", null, "2020/01/30", "2020/01/30");
        val task3 = Task(null, "牛乳を買う", 1, 1, "2020/02/15", null, "2020/01/30", "2020/01/30");
        val task4 = Task(null, "人狼をする", 1, 1, "2020/02/20", null, "2020/01/30", "2020/01/30");

        var task_array = ArrayList<Task>()
        task_array.add(task1)
        task_array.add(task2)
        task_array.add(task3)
        task_array.add(task4)

        viewModelScope.launch {

            val index = Random().nextInt(task_array.size) // ランダムに選択された 0 以上 4 未満の整数
            val result = task_array.get(index)
            addTaskDb(context, result)

            //これはやらないくてもいいのかと思ってましたが、タスクリストをリフレッシュして表示するのに必要です。
            loadTaskList()
        }
    }


    suspend fun addTaskDb(context: Context, task: Task) {
        try {

            repository.addTask(context, task)

        } catch (cause: Throwable) {
            // If anything throws an exception, inform the caller
            Log.e("TaskViewModel", "エラー データベースでデータ追加できない")
            Toast.makeText(context, "データベースエラーで更新できませんでした!", Toast.LENGTH_LONG).show();
        }
    }



    //引数が必要な時は、Factoryが必要
    class Factory(private val repository: TaskRepository) :
        ViewModelProvider.NewInstanceFactory() {
        @Suppress("UNCHECKED_CAST")
        override fun <T : ViewModel> create(modelClass: Class<T>): T {
            return TaskListViewModel(repository) as T
        }
    }

}

ViewModel中の、

viewModelScope.launch {

というのが素晴らしいです。前述で、コルーチンがいい感じになるのを説明しますと書きましたが、ここでかなり真価を発揮します。我々Android開発者の悩み。それは、メインスレッドでDB接続だとかネット接続とかできないので、別スレッドにしたり、AsyncTaskにしたりしてコールバック地獄になり、かなり面倒だったということ。

それが、ViewModelScopeを使ってコルーチンのメソッド呼ぶだけでいいんですよ。ありがたや!

ちな、最初はコルーチンっていいなぁと思ってたんだけど、コルーチンってコルーチンの中からしか呼べないんです。え?ってなりますよね。最初の呼び出し元は、結局Asyncかとか
viewModelScope.launch {} から呼び出さないといけない…みたいですね(参考はこちら)。とりあえず画面にデータ渡す系は、これからは
viewModelScope.launch {}
を使えばよいのです。

RecyclerViewのアダプタを作ります。実のところ、私、アダプタ作るの嫌いなんですよ。Androidで、なんかちょっと複雑なビュー作る時、アダプタ作らないといけないケースが多いですが。仕方ないんですかね(´ω`)。

ここで紹介するのは極めて簡単なアダプタです。一行分のレイアウトファイルをアサインします。

//TaskListAdapter.kt
package jp.onlineconsultant.listsample

import android.util.Log
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.recyclerview.widget.RecyclerView
import jp.onlineconsultant.listsample.databinding.TaskListItemBinding

class TaskListAdapter(private val taskClickCallback: TaskClickCallback?) :
    RecyclerView.Adapter<TaskListAdapter.TaskViewHolder>() {

    private var taskList: List<Task?>? = null

    fun setTaskList(taskList: List<Task?>?) {

        this.taskList = taskList

        //これ大事。ないと、データ追加後に画面が更新されません。
        notifyDataSetChanged()

    }

    override fun onCreateViewHolder(parent: ViewGroup, viewtype: Int): TaskViewHolder {
        val binding =
            DataBindingUtil.inflate(
                LayoutInflater.from(parent.context),
                R.layout.task_list_item, parent,
                false
            ) as TaskListItemBinding


        return TaskViewHolder(binding)
    }


    override fun getItemCount(): Int {
        return taskList?.size ?: 0
    }

    open class TaskViewHolder(val binding: TaskListItemBinding) :
        RecyclerView.ViewHolder(binding.root)

    override fun onBindViewHolder(holder: TaskListAdapter.TaskViewHolder, position: Int) {
        holder.binding.task = taskList?.get(position) //task_list_item.xmlの中のtask
        holder.binding.executePendingBindings()
    }
}

次は、タスク1行分のレイアウトファイルです。

<?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">

    <data>

        <variable
            name="task"
            type="jp.onlineconsultant.listsample.Task" />

    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <androidx.cardview.widget.CardView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            app:cardUseCompatPadding="true">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            android:padding="5dp">

            <TextView
                android:id="@+id/content"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:contentDescription="内容"
                android:text="@{task.content}"
                android:textSize="14dp"
                android:textStyle="bold" />

            <TextView
                android:id="@+id/limit"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:contentDescription="期限"
                android:text="@{task.limit.toString()}"
                android:textSize="14dp" />
        </LinearLayout>
        </androidx.cardview.widget.CardView>
    </LinearLayout>

</layout>

これで、完成です!

いやー。長かった。上記のでできるかと思いますが、Stringファイルとか、AndroidManifestとか、大事な大事なGradleのファイルが必要、という方は、ぜひ下記のGithubに置いてありますので、ご覧ください。

https://github.com/AkikoGoto/list_sample

ご指摘などあれば、バシバシお寄せください。Githubじゃなくって、こちらのブログのほうにご返信いただければありがたいです。m(_ _)m

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です