20201227のAndroidに関する記事は5件です。

【Android】RecyclerView で無限スクロールを実装する

やりたいこと

RecyclerView で作ったリストに無限スクロールを実装してみます。

API でデータセットを取得してリスト表示するような機能を想定しています。

画面生成時にリストを 10 件しておき、リストの下端までスクロールしたタイミングで 10 件ずつ追加で表示するサンプルアプリを作っていきます。

最終的に作成したアプリは Github で公開していますので参考にしていただければと思います。

https://github.com/yudai0308/infinite_scroll_sample

実装手順

1. レイアウトファイル作成

RecyclerView のレイアウトと、リストに表示させる1行分のレイアウトを作成します。

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <ProgressBar
        android:id="@+id/progress_bar"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        android:visibility="invisible"/>

</androidx.constraintlayout.widget.ConstraintLayout>
list_item.xml
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/list_item"
    android:layout_width="match_parent"
    android:layout_height="80dp"
    android:gravity="center_vertical"
    android:padding="16dp"
    android:textSize="20sp">
</TextView>

2. ビューホルダーとアダプターの作成

リストに表示させるデータを保持するビューホルダーと、ビューホルダーを管理してリストを画面表示するロジックを担うアダプターを作成します。

MyAdapter.kt
class MyAdapter(private val listData: MutableList<String>) : RecyclerView.Adapter<MyAdapter.MyViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
        val inflater = LayoutInflater.from(parent.context)
        val textView = inflater.inflate(R.layout.list_item, parent, false) as TextView
        return MyViewHolder(textView)
    }

    override fun onBindViewHolder(holder: MyViewHolder, position: Int) {
        holder.textView.text = listData[position]
    }

    override fun getItemCount(): Int {
        return listData.size
    }

    /**
     * リストデータを追加して画面に反映させるメソッド。
     */
    fun add(listData: List<String>) {
        this.listData += listData
        notifyDataSetChanged()
    }

    class MyViewHolder(val textView: TextView): RecyclerView.ViewHolder(textView)
}

MyAdapter クラス内に add() メソッドを作成しています。

リスト下端までスクロールしたタイミングでデータを追加で取得するので、取得したデータをこのメソッドに渡してリストを更新するようにします。

3. 無限スクロールの実装

MainActivity に無限スクロールの実装を行います。

MainActivity.kt
class MainActivity : AppCompatActivity() {

    /**
     * API から取得するデータセットを想定したプロパティ。
     */
    private val dataSet = mutableListOf<String>()

    /**
     * API に問い合わせ中は true になる。
     */
    private var nowLoading = false

    private lateinit var myAdapter: MyAdapter

    private val handler = Handler()

    private val progressBar by lazy { findViewById<ProgressBar>(R.id.progress_bar) }

    init {
        // 仮想データセットを生成。
        for (i in 0..99) { dataSet.add("Number $i") }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val listData = runBlocking { fetch(0) }
        myAdapter = MyAdapter(listData as MutableList<String>)
        findViewById<RecyclerView>(R.id.recycler_view).also {
            it.layoutManager = LinearLayoutManager(this)
            it.adapter = myAdapter
            it.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL))
            it.addOnScrollListener(InfiniteScrollListener())
        }
    }

    /**
     * API でリストデータを取得することを想定したメソッド。
     */
    private suspend fun fetch(index: Int): List<String> {
        // API 問い合わせの待ち時間を仮想的に作る。
        handler.post { progressBar.visibility = View.VISIBLE }
        delay(3000)
        handler.post { progressBar.visibility = View.INVISIBLE }

        return when (index) {
            in 0..90 -> dataSet.slice(index..index + 9)
            in 91..99 -> dataSet.slice(index..99)
            else -> listOf()
        }
    }

    /**
     * API でリストデータを取得して画面に反映することを想定したメソッド。
     */
    private suspend fun fetchAndUpdate(index: Int) {
        val fetchedData = withContext(Dispatchers.Default) {
            fetch(index)
        }

        // 取得したデータを画面に反映。
        if (fetchedData.isNotEmpty()) {
            handler.post { myAdapter.add(fetchedData) }
        }
        // 問い合わせが完了したら false に戻す。
        nowLoading = false
    }

    /**
     * リストの下端までスクロールしたタイミングで発火するリスナー。
     */
    inner class InfiniteScrollListener : RecyclerView.OnScrollListener() {
        override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
            super.onScrolled(recyclerView, dx, dy)

            // アダプターが保持しているアイテムの合計
            val itemCount = myAdapter.itemCount
            // 画面に表示されているアイテム数
            val childCount = recyclerView.childCount
            val manager = recyclerView.layoutManager as LinearLayoutManager
            // 画面に表示されている一番上のアイテムの位置
            val firstPosition = manager.findFirstVisibleItemPosition()

            // 何度もリクエストしないようにロード中は何もしない。
            if (nowLoading) {
                return
            }

            // 以下の条件に当てはまれば一番下までスクロールされたと判断できる。
            if (itemCount == childCount + firstPosition) {
                // API 問い合わせ中は true となる。
                nowLoading = true
                GlobalScope.launch {
                    fetchAndUpdate(myAdapter.itemCount)
                }
            }
        }
    }
}

init {...} について

今回は実際に API へ問い合わせは行いませんので、その代わりに文字列型のリストデータを作成しています。

onCreate() について

fetch() メソッドで API からリストデータを取得し、そのデータを引数として MyAdapter オブジェクトを作成しています。

また、RecyclerView にレイアウトマネージャー、アダプター、デコレーション(区切り線)、スクロールリスナー(無限スクロール)を設定しています。

fetch() について

API に問い合わせてリストデータを 10 件取得することを想定したメソッドです。インデックスを引数として取るので、仮に引数が 20 だった場合、20 ~ 29 のデータを返却します。

取得までにかかる時間を delay() で仮想的に実装しています。待ち時間は ProgressBar を表示させて、さらにそれっぽくしてます。

fetchAndUpdate() について

API からのデータ取得と、取得したデータを画面に反映させる処理を一緒に行うメソッドです。InfiniteScrollListener から呼び出して使います。

InfiniteScrollListener について

無限スクロールの実装部分です。

変数 itemCount、childCount、firstPosition の役割はコメントに書かれているとおりです。条件式にあるとおり itemCount == childCount + firstPosition のとき、下端までスクロールされたと判断できます。

Untitled Diagram.jpg

下端までスクロールされてデータを取得する際、変数 nowLoading を ture にします。データを取得している途中でもユーザーが上下にスクロールすると onScrolled() が発火してしまい、同じデータを何度もリクエストしてしまいます。これを防止するためにリクエスト中は変数 nowLoading を true にして制御します。その後データ取得が完了したタイミングで false に戻します。

以上で冒頭の GIF と同じ無限スクロールが実装できたと思います。

参考

RecyclerViewでスクロールが一番下まで行ったらロードする実装

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Kotlin の Coroutine で suspend 関数を理解する

先日、ドリーム・アーツでは、売場ノートというアプリを Android に対応させた。

全面的に Coroutine を使って非同期処理を書いたので、そのとき理解のために実験した内容について備忘がてら書いておく。

前提

Kotlin 1.4.21
Android Studio 4.1.1

dependencies {
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
}

Coroutine の動作をおさらいする

launch とか async/await がどういう順番で呼び出されるかといったことはここでは話題とはせず、Coroutine の利用シーンに絞って、Android アプリ内でどのようにコードが動くのかといったことを記述する。

Coroutine の典型的な利用シーンとして、何らかのユーザーの操作をトリガーとして HTTP をリクエストしてそのレスポンスでビューを更新するというものがある。

AsyncTask による旧来の非同期処理パターン

Android において従来この手のパターンは、伝統的に AsyncTask を利用してきた。

System.out.println("1. Begin: thread=${Thread.currentThread().name}")

object : AsyncTask<Void, Int, String?>() {

    override fun doInBackground(vararg params: Void?): String? {
        System.out.println("2-1. AsyncTask started: thread=${Thread.currentThread().name}")
        Thread.sleep(1000)
        return "OK"
    }

    override fun onPostExecute(result: String?) {
        System.out.println("2-2. AsyncTask ended: thread=${Thread.currentThread().name}")
    }
}.execute(null)

System.out.println("3. End: thread=${Thread.currentThread().name}")

これを実行してみた結果、以下のように Logcat に出力される。

I/System.out: 1. Begin: thread=main
I/System.out: 3. End: thread=main
2-1. AsyncTask started: thread=AsyncTask #1
2-2. AsyncTask ended: thread=main

AsyncTask#doInBackground が実行されるのは、ThreadPoolExecutorで管理されるバックグラウンドスレッドであって、メインスレッドで動くUIの操作に影響を及ぼさない。

onPostExecute(result) をオーバーライドすると、このメソッドはメインスレッドで呼び出されるのでここでUIを操作することができるのである。

この方法の問題点は、AsyncTaskのライフサイクルが、アクティビティやフラグメントのライフサイクルと切り離されているため、タスク完了時にはすでにビューは存在しないといった場合もあり、そういった状態を適切に扱うのは細心の注意が必要である。

Coroutie による非同期処理パターン

画面の回転などによる設定の変更に追随するために、アクティビティやフラグメントといったUIコントローラーでデータを保持するのではなく、ViewModelでデータを保持することが一般的だ。androidx.lifecycle:lifecycle-viewmodel-ktx によって、ViewModel にはインスタンスごとに専用の CoroutineScope である viewModelScope が提供されている。

この CoroutineScope を利用すれば、画面のライフサイクルにあわせて自動的に

System.out.println("1. Begin: thread=${Thread.currentThread().name}")

viewModel.viewModelScope.launch(Dispatchers.Main) {
    // 上の行で Dispatchers.Main を指定しているが、指定しなくてもメインスレッドになる

    withContext(Dispatchers.IO) {
        System.out.println("2-1. AsyncTask started: thread=${Thread.currentThread().name}")
        Thread.sleep(1000)
    }

    System.out.println("2-2. AsyncTask ended: thread=${Thread.currentThread().name}")
}

System.out.println("3. End: thread=${Thread.currentThread().name}")

これを実行してみた結果は以下の通りである。

I/System.out: 1. Begin: thread=main
I/System.out: 3. End: thread=main
I/System.out: 2-1. AsyncTask started: thread=DefaultDispatcher-worker-1
I/System.out: 2-2. AsyncTask ended: thread=main

ViewModel の仕組みと、Coroutine によって、非同期処理用にクラスやインスタンスを作成せずとも自然な流れで処理を記述することが可能になる。

suspend 関数とは何なのか

上の Coroutine のコードを suspend 関数を用いて別メソッドに切り出してみる。

private fun testCoroutine() {
    System.out.println("1. Begin: thread=${Thread.currentThread().name}")

    viewModel.viewModelScope.launch {
        asyncTask() // suspend
        System.out.println("2-2. AsyncTask ended: thread=${Thread.currentThread().name}")
    }

    System.out.println("3. End: thread=${Thread.currentThread().name}")
}

private suspend fun asyncTask() {
    System.out.println("2-1. AsyncTask started: thread=${Thread.currentThread().name}")
    Thread.sleep(1000)
}

このコードを実行すると以下のようにログに出力され、メインスレッドはブロッキングしてしまいUIが固まる。

I/System.out: 1. Begin: thread=main
I/System.out: 2-1. AsyncTask started: thread=main
I/System.out: 2-2. AsyncTask ended: thread=main
I/System.out: 3. End: thread=main

suspend を付けたからといって、自動的にバックグラウンドスレッドになるわけではない
バックグラウンドスレッドでの実行にしたい場合はやはり明示的にコンテキストを指定する必要がある。以下のように書くことができる。

private suspend fun asyncTask() = withContext(Dispatchers.IO) {
    System.out.println("2-1. AsyncTask started: thread=${Thread.currentThread().name}")
    Thread.sleep(1000)
}

では、suspend 関数とは何なのか?
suspend 関数とは非同期処理のための仕組みであって、「別の suspend 関数を呼び出すために使用する」のである。

HTTP でサーバー側のAPIを呼び出すようなケースを考えてみる。
典型的なコードは以下のようなものになるだろう。

private suspend fun callAPI(): SomeData? {
    // HTTPリクエストのためのパラメーターを生成する
    val req = buildRequest()

    // HTTPリクエストを発行する
    val res = sendRequest(req) // suspend 関数

    // HTTPレスポンスで取得したデータ(JSONなど)を解析する
    return parseResponse(res)
}

これらの処理のうち、HTTPでの通信や、ストレージへの保存、ストレージからの読み込みはIOを伴う処理で、CPUの計算に比べて比較にならないくらい遅い。

だから非同期で処理したい。

従来はスレッドプール内のワーカースレッドにこのコードブロック全体を渡してそこで処理して、Future などで結果を受け取るようなやり方か、Rxな仕組みで非同期をどうしても意識せざるを得ない書き方を強制されてきた。

実はCoroutine を用いると、suspend 関数の内部では、別の suspend 関数を呼び出す箇所まででタスクを分割して、そのタスクを順次処理していくということが行われる。

private suspend fun callAPI(): SomeData? {

    // -------- コードブロック[1] ここから
    // HTTPリクエストのためのパラメーターを生成する
    val req = buildRequest()

    // HTTPリクエストを発行する
    val res = sendRequest(req) // suspend 関数
    // -------- コードブロック[1] ここまで


    // -------- コードブロック[2] ここから
    // HTTPレスポンスで取得したデータ(JSONなど)を解析する
    return parseResponse(res)
    // -------- コードブロック[2] ここまで
}

一連の流れのように見えるが、細かいタスクに分割されて Coroutine のコンテキストスコープのスレッドで順次実行される。

この様子は実際に上のようなコードを書いてみてデバッガーでブレークポイントを置いてみるとわかり易い。

suspend 関数の呼び出しを超えて次の行へステップ実行できないはずだ。

そして呼び出した先の suspend 関数は、さらに別の suspend 関数を呼び出すときに同様にブロックに分割されて、そこで処理を保留する。(suspend する)

呼び出していった先、これ以上別の suspend 関数を呼び出さないところまでたどり着いて、そのブロックが終わったらコールスタック上で保留されているブロックを順番に実行していく。

そしてそれらが終わったらさらに上の suspend 関数で保留されたブロックも実行する。

つまり、Rx なら then とかで書いていたブロックをそのままひと続きの流れで書くことが可能になる。

Coroutine を使った標準パターン

以上のことを理解すれば、以下のコードでメインスレッドをブロックすることなく、時間のかかる呼び出しの結果をメインスレッドでそのまま扱うことができることがわかる。

private fun testCoroutine() {
    viewModel.viewModelScope.launch {
        // ここはメインスレッドで呼び出される

        // asyncTask() は suspend 関数なので、呼び出してもメインスレッドをブロックしない
        val result = asyncTask()

        // asyncTask() が終了したら、メインスレッドでこのブロックが実行される
        viewModel.textValue.value = result
    }
}

private suspend fun asyncTask(): String = withContext(Dispatchers.IO) {
    // ここは DefaultDispatcher-worker-X というバックグラウンドスレッドで呼び出される
    Thread.sleep(1000)
    return@withContext "RESULT"
}

まとめ

Coroutine を使うことで、やっていることに集中できるコードになり、AsyncTask とか Rx とか何をやってたのだろうという気分になれたのはよかった。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

KotlinでRoomデータベース

KotlinでRoomデータベースを使用してみたので、覚書。

関連

続編:
RoomデータベースとListView
https://qiita.com/clipbord/items/0d8b7f973e3f5b277827

RoomデータベースをListViewで表示するように作成したもの。

全ソース

https://github.com/CoffCookie/memo

gradle

Roomとkaptが必要だったため、2つを指定。
最新版は下記

Room:
https://developer.android.com/training/data-storage/room?hl=ja

kapt:
https://kotlinlang.org/docs/reference/kapt.html

私の場合、kaptはversion指定すると上手く行かなかったため、version指定なしで作成している。

app/build.gradle
plugins {
...
    id 'org.jetbrains.kotlin.kapt'
}

dependencies {
...
    def room_version = "2.2.5"
    implementation "androidx.room:room-runtime:$room_version"
    kapt "androidx.room:room-compiler:$room_version"

    // optional - Kotlin Extensions and Coroutines support for Room
    implementation "androidx.room:room-ktx:$room_version"

    // optional - Test helpers
    testImplementation "androidx.room:room-testing:$room_version"
    implementation "com.google.android.material:material:1.3.0-beta01"
}

データベースのテーブル(フィールド名)を定義

Memo.kt
...
@Entity
class Memo (
    @PrimaryKey(autoGenerate = true) val id: Int,
    var text: String?
)

データベースにアクセスする際に使用するメソッドを格納

MemoDao.kt
...
@Dao
interface MemoDao {
    @Query("Select * From memo")
    fun getAll() : List<Memo>

    @Insert
    fun insert(memo: Memo)

    @Update
    fun update(memo: Memo)

    @Delete
    fun delete(memo: Memo)
}

データベースの作成とインスタンスの取得

MemoDatabase.kt
...
//データベースの作成
@Database(entities = [Memo::class], version = 2)
abstract class MemoDatabase:RoomDatabase() {
    abstract fun memoDao(): MemoDao

    companion object{
        private var INSTANCE: MemoDatabase? = null
        private val lock = Any()

        fun getInstance(context: Context): MemoDatabase{
            synchronized(lock){
                if (INSTANCE == null) {
                    //作成したデータベースのインスタンス取得
                    INSTANCE = Room.databaseBuilder(
                        context.applicationContext,
                        MemoDatabase::class.java, "Memodata.db"
                    )
                        .allowMainThreadQueries()
                        .build()
                }
                return INSTANCE!!
            }
        }
    }
}

View作成

activity_main.xml
...
    <TextView
        android:id="@+id/memoView"
        android:layout_width="320dp"
        android:layout_height="wrap_content"
        android:text="@string/memo_view"
        android:textAppearance="@style/TextAppearance.AppCompat.Large"
        tools:layout_editor_absoluteX="43dp"
        tools:layout_editor_absoluteY="64dp" />

    <EditText
        android:id="@+id/memoText"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="24dp"
        android:ems="10"
        android:inputType="textPersonName"
        android:text="@string/memo_text"
        app:layout_constraintStart_toStartOf="@+id/memoView"
        app:layout_constraintTop_toBottomOf="@+id/memoView" />

    <Button
        android:id="@+id/saveButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:text="@string/save_button"
        app:layout_constraintStart_toStartOf="@+id/memoText"
        app:layout_constraintTop_toBottomOf="@+id/memoText" />
...

処理部分の作成

データベースの処理部分に絞って記載したかったため、
表示部分は簡易的に作成している。

MainActivity.kt
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        //Viewの定義
        val saveButton: Button = findViewById(R.id.saveButton)
        val memoText: TextView = findViewById(R.id.memoText)
        val memoView: TextView = findViewById(R.id.memoView)
        //データベースの定義
        val database = MemoDatabase.getInstance(this)
        val memoDao = database.memoDao()

        //保存ボタン押下時の処理
        saveButton.setOnClickListener{
            //エンティティにデータ設定
            val newMemo = Memo(0,memoText.text.toString())
            //データベースに保存
            memoDao.insert(newMemo)
            //データをQueryで取得して、データベースの最後の要素をmemoViewに出力
            val memoList = memoDao.getAll()
            memoView.text = memoList[memoList.size-1].text


        }
    }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Jetpack Compose] 1.0.0-alpha09から変わったこと。

おすすめ

前はBetaバージョンを使ってましたが、
最近のバージョンにはCanary Buildに変わって使いましょう。
ComposeのPreviewはAndroud studio Beta Buildバージョンでは使わなくなりました。

image.png

Lib Version

image.png

https://mvnrepository.com/artifact/androidx.compose.ui/ui?repo=google
https://androidx.tech/artifacts/compose.ui/ui-geometry/

ChangeLog

Blog- Medium

今の時点の意見

  • Jetpack Composeを勉強していますけど、やはりUIはFlutterの方が早いし、強いと感じでいます。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Dart】Mapのvalueを使ってソートする

Mapのvalueでソートしたい事があったので調査しました。

SplayTreeMap class
SplayTreeMapのfromにソートしたいmapとcompareを渡せばソートできるみたいです。

下記のようなオブジェクトを作成しました。属性idでソートする予定。

Object
class Employee{
  int id;
  String name;
  Employee(this.id,this.name);
}

最初の並び順は下記の様な感じです。

unSort
  Map<String,Employee> map = Map<String,Employee>();
  map['hoge'] = new Employee(1, "sato");
  map['huga'] = new Employee(3, "tanaka");
  map['piyo'] = new Employee(2, "yamashita");

  map.forEach((key, value) {
    print("key:"+key+" | id:"+value.id.toString()+" | name:"+value.name);
  });

出力

key:hoge | id:1 | name:sato
key:huga | id:3 | name:tanaka
key:piyo | id:2 | name:yamashita

ソート後のMapを作成し再度出力すると…

sorted
final sortedMap = SplayTreeMap.from(
    map, (a, b) => map[a].id.compareTo(map[b].id));

sortedMap.forEach((key, value) {
  print("key:"+key+" | id:"+value.id.toString()+" | name:"+value.name);
});

出力

flutter: key:hoge | id:1 | name:sato
flutter: key:piyo | id:2 | name:yamashita
flutter: key:huga | id:3 | name:tanaka

オホ
できましたね。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む