この記事は、Google Developer Expert (GDE) @new_runnable (takahirom) さんに寄稿いただいたゲスト記事です。この「エキスパートに学ぶシリーズ」では、まだ Jetpack Compose を使ったことがないデベロッパー向けに既存アプリで段階的に導入を進める方法や導入するメリットについて GDE の方々よりご紹介いただきます。
みなさんの Android のアプリがまだ Jetpack Compose に移行されていない場合、RecyclerView を使っている部分もあるのではないでしょうか?
RecyclerView は効率的にリストなどのデータを表示する View で、今までの Android アプリの開発の中では頻繁に使われてきました。Jetpack Compose は View ベースの UI と統合することができ、View ベースのアプリに対しても段階的に導入が可能になっています。Jetpack Compose は RecyclerView を使っているアプリにも段階的に導入可能になっています。
RecyclerView から Jetpack Compose に書き直すことでかなりコードを単純化できます。この記事ではRecyclerView による実装をどうやって Jetpack Compose に置き換えていくかを紹介していきます。
置き換えるにはまずは「エキスパートに学ぶシリーズ : 1. Jetpack Compose を既存アプリに導入する」を読んで Jetpack Compose を導入していく必要があります。RecyclerView が使われていない画面であれば「エキスパートに学ぶシリーズ : 2. Jetpack Compose の段階的移行」を読んで、View をうまく移行していくのが良いと思います。
RecyclerView に関しては一気に Jetpack Compose に置き換えできればそれが一番簡単ですが、RecyclerView にたくさんの View が入ってしまっていて、一気に移行できない場合もあります。
そこで、今回は 2 つのステップに分けて移行する方法を紹介します。
簡単な RecyclerView でのコードをまず紹介し、そこからの移行の方法を紹介していきます。
こちらのサンプルは Android Studio の新しいバージョンを教えてくれるようなものをイメージしています。
RecyclerView で実装した場合は以下のような実装になるのではないかと思います。これを Jetpack Compose に置き換えていきます。
少しだけ各要素を紹介しておきます。
data class Article(val id: Int, val title: String)class ArticlesViewModel : ViewModel() { private val _articlesStateFlow = MutableStateFlow( listOf( Article(1, "🦊 Android Studio Arctic Fox is out!"), Article(2, "🐝 Android Studio Bumblebee is out!"), Article(3, "\uD83D\uDC3F️ Android Studio Chipmunk is out!"), Article(4, "\uD83D\uDC2C Android Studio Dolphin is out!"), ) ) val articlesStateFlow: StateFlow<List<Article>> = _articlesStateFlow.asStateFlow()}class MainActivity : ComponentActivity() { private val articlesViewModel by viewModels<ArticlesViewModel>() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) val articlesAdapter = ArticlesAdapter() binding.recyclerView.adapter = articlesAdapter lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { articlesViewModel.articlesStateFlow.collect { articles -> articlesAdapter.submitList(articles) } } } }}class ArticlesAdapter : ListAdapter<Article, ArticlesAdapter.ArticleViewHolder>( DIFF_CALLBACK) { class ArticleViewHolder( private val binding: ItemArticleBinding ) : RecyclerView.ViewHolder(binding.root) { fun bindTo(article: Article) { binding.title.text = article.title } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArticleViewHolder { return ArticleViewHolder(ItemArticleBinding.inflate(LayoutInflater.from(parent.context))) } override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) { holder.bindTo(getItem(position)) } companion object { val DIFF_CALLBACK = object : DiffUtil.ItemCallback<Article>() { override fun areItemsTheSame(oldItem: Article, newItem: Article): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame(oldItem: Article, newItem: Article): Boolean { return oldItem == newItem } } }}activity_main.xml<?xml version="1.0" encoding="utf-8"?><FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/recycler_view" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" /></FrameLayout>item_article.xml<?xml version="1.0" encoding="utf-8"?><LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <TextView android:id="@+id/title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_margin="8dp" android:textAppearance="?attr/textAppearanceHeadline3" tools:text="test" /></LinearLayout>
data class Article(val id: Int, val title: String)
class ArticlesViewModel : ViewModel() {
private val _articlesStateFlow = MutableStateFlow(
listOf(
Article(1, "🦊 Android Studio Arctic Fox is out!"),
Article(2, "🐝 Android Studio Bumblebee is out!"),
Article(3, "\uD83D\uDC3F️ Android Studio Chipmunk is out!"),
Article(4, "\uD83D\uDC2C Android Studio Dolphin is out!"),
)
val articlesStateFlow: StateFlow<List<Article>> = _articlesStateFlow.asStateFlow()
}
class MainActivity : ComponentActivity() {
private val articlesViewModel by viewModels<ArticlesViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
val articlesAdapter = ArticlesAdapter()
binding.recyclerView.adapter = articlesAdapter
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
articlesViewModel.articlesStateFlow.collect { articles ->
articlesAdapter.submitList(articles)
class ArticlesAdapter : ListAdapter<Article, ArticlesAdapter.ArticleViewHolder>(
DIFF_CALLBACK
) {
class ArticleViewHolder(
private val binding: ItemArticleBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bindTo(article: Article) {
binding.title.text = article.title
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArticleViewHolder {
return ArticleViewHolder(ItemArticleBinding.inflate(LayoutInflater.from(parent.context)))
override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) {
holder.bindTo(getItem(position))
companion object {
val DIFF_CALLBACK = object : DiffUtil.ItemCallback<Article>() {
override fun areItemsTheSame(oldItem: Article, newItem: Article): Boolean {
return oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: Article, newItem: Article): Boolean {
return oldItem == newItem
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_height="match_parent"
android:orientation="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</FrameLayout>
item_article.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="vertical">
<TextView
android:id="@+id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:textAppearance="?attr/textAppearanceHeadline3"
tools:text="test" />
</LinearLayout>
この RecyclerView で表示している View の実装を Compose に移行していきましょう。ここでは RecyclerView 自体は置き換えず、中で表示している View を Compose に置き換えます。
まず RecyclerView のバージョンを 1.3.0-rc01、Compose UI のバージョンを 1.2.1 以降を使うようにします。これは Compose と RecyclerView が協調して動作するために必要です。 ※1
build.gradleimplementation 'androidx.recyclervew:recyclerview:1.3.0-rc01'
build.gradle
implementation 'androidx.recyclervew:recyclerview:1.3.0-rc01'
最初にさっそく Jetpack Compose で表示するコンポーズ可能な関数を用意しましょう。引数で Article クラスのインスタンスを受け取り、Text() を利用して内容を表示してあげます。ここでは単純に記事の内容を表示してあげるコンポーズ可能な関数になっています。
ここでは @Preview を使ってこのコンポーズ可能な関数の Preview も表示してあげています。
@Composablefun ArticleRow(article: Article, modifier: Modifier = Modifier) { Column(modifier) { Text( text = article.title, style = MaterialTheme.typography.h3, modifier = Modifier.padding(8.dp) ) }}@Preview@Composablefun ArticleRowPreview() { Mdc3Theme { Surface { ArticleRow( article = Article(1, "Hello ArticleRow"), modifier = Modifier.fillMaxWidth() ) } }}
@Composable
fun ArticleRow(article: Article, modifier: Modifier = Modifier) {
Column(modifier) {
Text(
text = article.title,
style = MaterialTheme.typography.h3,
modifier = Modifier.padding(8.dp)
@Preview
fun ArticleRowPreview() {
Mdc3Theme {
Surface {
ArticleRow(
article = Article(1, "Hello ArticleRow"),
modifier = Modifier.fillMaxWidth()
Android Studio 上の Preview 表示
RecyclerView 内で表示する View として ArticleRow() を表示するため、ArticleRowComposeView を作成します。
ArticleRowComposeView は AbstractComposeView を継承することで RecyclerView 内で View として Jetpack Compose を表示させます。
また、ArticleRowComposeView は Compose の State をフィールドとして持っています。これにより、RecyclerView の Adapter から、この Compose の State に記事データを入れ、コンポーズ可能な関数内でその State を読むことで記事データを Compose で表示させています。
class ArticleRowComposeView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyle: Int = 0// AbstractComposeView を継承する) : AbstractComposeView(context, attrs, defStyle) { // Compose の State をフィールドとして持つ var article by mutableStateOf<Article?>(null) @Composable override fun Content() { Mdc3Theme { // Compose の State をコンポーズ可能な関数内で読む val article = article if (article != null) { ArticleRow( article = article, modifier = Modifier.fillMaxWidth() ) } } }}
class ArticleRowComposeView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0
// AbstractComposeView を継承する
) : AbstractComposeView(context, attrs, defStyle) {
// Compose の State をフィールドとして持つ
var article by mutableStateOf<Article?>(null)
override fun Content() {
// Compose の State をコンポーズ可能な関数内で読む
val article = article
if (article != null) {
article = article,
RecyclerView とつなぎ込んで表示できるようにします。この作った ArticleRowComposeView を RecyclerView の Adapter の実装を変更して表示させます。
具体的には onCreateViewHolder() で ArticleRowComposeView を作成し、ArticleRowComposeView を持つ ViewHolder を作成します。
また Adapter.onBindViewHolder() から bindTo() の実装が呼ばれることにより ArticleRowComposeView に記事データを渡すことで Compose からそのデータを読み取ることができるようになります。
RecyclerView は名前の通り View を再利用 (Recycle) して表示するので、例えばスクロールで onCreateViewHolder() で一度作った ViewHolder とその中にある View を再利用し、 onBindViewHolder() でデータを再度入れる動きになります。
class ArticlesAdapter : ListAdapter<Article, ArticlesAdapter.ArticleViewHolder>( DIFF_CALLBACK) { class ArticleViewHolder( private val articleRowComposeView: ArticleRowComposeView ) : RecyclerView.ViewHolder(articleRowComposeView) { fun bindTo(article: Article) { articleRowComposeView.article = article } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArticleViewHolder { val articleRowComposeView = ArticleRowComposeView(parent.context) return ArticleViewHolder(articleRowComposeView) } override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) { holder.bindTo(getItem(position)) }
private val articleRowComposeView: ArticleRowComposeView
) : RecyclerView.ViewHolder(articleRowComposeView) {
articleRowComposeView.article = article
val articleRowComposeView = ArticleRowComposeView(parent.context)
return ArticleViewHolder(articleRowComposeView)
Jetpack Compose Interop: Using Compose in a RecyclerView (英語) の記事でも触れられているようにスクロール時に毎回 Compose の Composition が破棄 (dispose) されないようにすることでパフォーマンスを改善することができます。
recyclerview:1.3.0-alpha02 より後のバージョンを使っていると RecyclerView と Compose が協調して動作するようになるため、スクロール中に頻繁に破棄されなくなります。
以下のように DisposableEffect を使ってログを出すことで、その挙動を確認することができます。もしスクロールしたときに頻繁に ItemRow xxx DISPOSED のログが出るようであれば、うまく RecyclerView のアップデートができていないか、間違ったタイミングで ComposeView を作ってしまっているなどの可能性があるので記事をみて詳細を確認してみてください。
@Composablefun ArticleRow(article: Article, modifier: Modifier = Modifier) { DisposableEffect(Unit) { Log.d("ArticleRow", "ItemRow ${article.id} composed") onDispose { Log.d("ArticleRow", "ItemRow ${article.id} DISPOSED") } }
DisposableEffect(Unit) {
Log.d("ArticleRow", "ItemRow ${article.id} composed")
onDispose { Log.d("ArticleRow", "ItemRow ${article.id} DISPOSED") }
ここまでで、RecyclerView の中身が Compose に置き換わりました。後は外側の RecyclerView を Jetpack Compose 置き換えることになります。
シンプルにここでは setContent{} を呼び出すことで Activity で Compose を表示するようにしています。この RecyclerView だけ移行したい場合などは、前回の記事が参考になると思います。
この 2 のステップによって、コード量がかなり少なくなっています。理由は XML のレイアウトを消せたことと、RecyclerView の Adapter のコードが LazyColumn に移行されたためです。
...// この 2 つが自動インポートできないことがあるようです。import androidx.compose.foundation.lazy.itemsimport androidx.compose.runtime.getValue...class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { ArticlesScreen() } }}@Composableprivate fun ArticlesScreen(articlesViewModel: ArticlesViewModel = viewModel()) { Mdc3Theme { val articles by articlesViewModel.articlesStateFlow.collectAsState() LazyColumn { items( items = articles, key = { article -> article.id } ) { article -> ArticleRow( article = article, modifier = Modifier.fillMaxWidth() ) } } }}
...
// この 2 つが自動インポートできないことがあるようです。
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.getValue
setContent {
ArticlesScreen()
private fun ArticlesScreen(articlesViewModel: ArticlesViewModel = viewModel()) {
val articles by articlesViewModel.articlesStateFlow.collectAsState()
LazyColumn {
items(
items = articles,
key = { article -> article.id }
) { article ->
今回の実装について解説します。
ArticlesViewModel を viewModel() 関数を使って Jetpack Compose の中から取得しています。この viewModel() 関数を使うには以下の依存関係を追加する必要があります。
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.5.1'
ここでは LazyColumn を利用してリスト表示をしています。LazyColumn は RecyclerView に似ていて必要なときだけ再コンポーズ (Recompose) などの最適化をしてくれます。また key の引数を渡しておくとさらに賢く最適化をしてくれます。詳しくは Use lazy layout keys (英語) で説明されています。
2 つのステップに分けて Jetpack Compose を移行していきました。移行した結果、コードはこの小さなサンプルでも 24 行ほど削減できたようです。また宣言的 UI により直感的にコードが書けるようになるなどのメリットも享受できます。Jetpack Compose に移行するメリットについて気になっている方は Compose を導入する理由にあるので確認してみてください。
git diff main compose --stat 4 files changed, 56 insertions(+), 80 deletions(-)
git diff main compose --stat
4 files changed, 56 insertions(+), 80 deletions(-)
このように Jetpack Compose では Java から Kotlin にしたようにアプリを段階的に移行できるようになっているので、少しずつ進めて開発効率を上げていきましょう。
※1 正確には RecyclerView 1.3.0-alpha02, Compose UI 1.2.0-beta02 以降で動きますが、記事を書いた時点で利用できるできるだけ安定したバージョンを書いています。将来的には RecyclerView 1.3.0 などもリリースされていくと思うので、なるべく新しいバージョンをご利用ください。