RecyclerViewで「グリッド表示をスムーズにスクロール」する方法について、まとめます。
画像ファイルをグリッド表示する場合は、スムーズなスクロールを行うための工夫が必要です。
その工夫について紹介します。
※環境:Android Studio Ladybug | 2024.2.1 Patch 1
Kotlin 2.0.0
Compose Compilerプラグイン 2.0.0
androidx.recyclerview:recyclerview:1.1.0
目次
スクロール出来ない・遅い原因
別の記事で、画像ファイルのグリッド表示を行いました。
※「RecyclerView:画像ファイルをグリッド表示」を参照
しかし、記事のサンプルは、問題があります。グリッド表示はできますが、スムーズなスクロールが出来ません。
膨大なフレームのスキップを起こし、最後にANR(Application Not Responding)となって、アプリが落ちます。
Skipped 129 frames! The application may be doing too much work on its main thread. Skipped 139 frames! The application may be doing too much work on its main thread. ANR in com.example.res.recyclerview (com.example.res.recyclerview/.PhotoListActivity)
この原因は次の通りです。
- (1)画像サイズが大きく、ファイルアクセスに時間がかかる
- (2)画像サイズが大きく、JPEG⇒Bitmap変換に時間がかかる
- (3)上記により、Mainスレッドが重くなり、フレーム処理が間に合わない
原因を排除して問題を解決するために、2つの対策を実施します。
対策1:画素データのサンプリング
「画素データのサンプリング」を行うと、原因(1)と(2)が排除されます。
サンプリングの様子
JPEGは不可逆の画像圧縮データなので、JPEG(データ)⇒Bitmap(画像)へ変換するデコード処理が必要になります。
通常のデコード処理は全ての画素(ピクセル)を復号します。
ただし、サンプリング用いると、飛び飛びの画素を復号の対象にします。
例えば、「sampling ⇐ 4」にした場合の対象は「桃色●」の画素です。
JPEGデータのアクセスと復号処理が、対象画素に関連するデータのみに限定されるため、処理量が格段に少なくなります。「sampling ⇐ 4」ならば、およそ1/16です。
サンプリングの決め方
サンプリングを行うと、Bitmapの縦横サイズは「1/サンプリング値」になります。例えば「sampling ⇐ 4」にした場合は「1/4」です。
また、デコードアリゴリズムの関係で、サンプリング値は最も近い2のべき乗(1,2,4,8,16,32 …)になるように、端数が切捨てられます(ドキュメントに記載)。
ですので、サンプリング値は次の条件を満たす値にします。
- ・表示サイズより大きいBitmapサイズになる
- ・表示サイズに最も近いBitmapサイズになる
- ・2のべき乗
- ※表示サイズ:サンプルのImageViewサイズ
決められたサンプリング値は「Bitmapサイズ=表示サイズ」になりません。表示サイズへのスケーリングは、GPUが行ってくれます。
サンプリング値をプログラムで表現すると次のようになります。
fun getSampling(srcSize: Int, dstSize: Int): Int { val _Sampling = srcSize / dstSize return when { _Sampling < 2 -> 1; _Sampling < 4 -> 2; _Sampling < 8 -> 4; _Sampling < 16-> 8; _Sampling < 32-> 16; _Sampling < 64-> 32; else -> 64; } }
サンプリングの実装
「画素データのサンプリング」を行ったグリッド表示は、次のようになります。
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_photo_list) ... val _folder = "4000x3000" val _row = 4 val _photos = assets.list(_folder) val _items = List(100) { PhotoData(it, "${_folder}/${_photos!![it]}") } val _sampling = getSampling(4000, 320 / _row) // 画面サイズ:320x480 val _adapter = PhotoAdapter1a(_items, _sampling) val _manager = GridLayoutManager(this, _row) val _photoList = findViewById<RecyclerView>(R.id.rcyPhotoList) _photoList.adapter = _adapter _photoList.layoutManager = _manager } }
class PhotoAdapter( val items: List<PhotoData>, val sampling: Int ) : RecyclerView.Adapter<PhotoAdapter.PhotoViewHolder>() { // ----- ビューホルダー ------------------------------------------ class PhotoViewHolder(val view: View) : RecyclerView.ViewHolder(view) { var id = -1 val photo = view.findViewById<ImageView>(R.id.imgPhoto) } // ----- アダプター本体 ------------------------------------------ override fun getItemCount(): Int { return items.size } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) : PhotoViewHolder { val _view = LayoutInflater.from(parent.context) .inflate(R.layout.photo_item, parent, false) return PhotoViewHolder(_view) } override fun onBindViewHolder(holder: PhotoViewHolder, position: Int) { holder.id = items[position].id holder.photo.setImageResource(android.R.drawable.ic_menu_gallery) val _filename = items[position].filename val _iStream = holder.view.context.assets.open(_filename) val _options = BitmapFactory.Options().apply { inSampleSize = sampling } val _bitmap = BitmapFactory.decodeStream(_iStream, null, _options) holder.photo.setImageBitmap(_bitmap) } }
サンプルの結果
フレームのスキップは起こりますが、スキップされるフレーム数は格段に減りました。
また、ANRでアプリが落ちなくなりました。消費メモリーが少なくなったためです。
Skipped 30 frames! The application may be doing too much work on its main thread. Skipped 30 frames! The application may be doing too much work on its main thread.
まだ、スクロールになっていません。これを改善するには、次にあげる「ワーカースレッドで分散処理」が必要になります。
対策2:ワーカースレッドで分散処理
「ワーカースレッドで分散処理」を行うと、原因(3)が排除されます。
分散処理の様子
デコード処理は重いので、メインスレッドで行うとスレッドを独占してしまいます。
フレームのトリガが来てもメインスレッドに空きがないため、フレーム処理が出来ません。その結果、フレームをスキップします(左図)。
ワーカースレッドを起動してデコード処理を任せれば、メインスレッドに空きができて、フレーム処理が出来ます(右図)。
分散処理の実装
「ワーカースレッドで分散処理」を行ったグリッド表示は、次のようになります。
※「画素データのサンプリング」を含む
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_photo_list) ... val _folder = "4000x3000" val _row = 4 val _photos = assets.list(_folder) val _items = List(100) { PhotoData(it, "${_folder}/${_photos!![it]}") } val _sampling = getSampling(4000, 320 / _row) // 画面サイズ:320x480 val _adapter = PhotoAdapter(_items, lifecycleScope, _sampling) val _manager = GridLayoutManager(this, _row) val _photoList = findViewById<RecyclerView>(R.id.rcyPhotoList) _photoList.adapter = _adapter _photoList.layoutManager = _manager } }
デコードをワーカースレッドで行い、ImageViewの設定をメインスレッドへ投げています(ImageView#postを利用)。
スレッドはDefaultプールから取得するので、並列処理のスレッド数は端末のCPUコア数を超えません。
class PhotoAdapter( val items: List<PhotoData>, val scope: CoroutineScope, val sampling: Int ) : RecyclerView.Adapter<PhotoAdapter.PhotoViewHolder>() { // ----- ビューホルダー ------------------------------------------ class PhotoViewHolder(val view: View) : RecyclerView.ViewHolder(view) { var id = -1 val photo = view.findViewById<ImageView>(R.id.imgPhoto) } // ----- アダプター本体 ------------------------------------------ override fun getItemCount(): Int { return items.size } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) : PhotoViewHolder { val _view = LayoutInflater.from(parent.context) .inflate(R.layout.photo_item, parent, false) return PhotoViewHolder(_view) } override fun onBindViewHolder(holder: PhotoViewHolder, position: Int) { holder.id = items[position].id holder.photo.setImageResource(android.R.drawable.ic_menu_gallery) // 初期画像 scope.launch(Dispatchers.Default) { val _filename = items[position].filename val _iStream = holder.view.context.assets.open(_filename) val _options = BitmapFactory.Options().apply { inSampleSize = sampling } val _bitmap = BitmapFactory.decodeStream(_iStream, null, _options) holder.itemView.post { holder.photo.setImageBitmap(_bitmap) } } } }
サンプルの結果
フレームのスキップもANRも起こりません。スムーズなスクロールになりました。
なお、一瞬、「白黒の山」が表示されるのは、初期画像を指定しているからです。初期画像は無くても良いです。無ければ元画像に新画像が上書きされます(元画像:GPU側に転送済みの画像)。
関連記事: