この記事はUzabase Advent Calendar 2021の18日目の記事です。
はじめに
Androidで一覧系を作るとき面倒ですよね。RecylcerViewなんですがそれがめんどくさい。
なので、2019年ぐらいから流行り出したGroupieやEpoxyを使う人が多いのではないでしょうか?
だがしかし! 同僚が「@ko2icが作った仕組みの方がgroupieよりも簡単」という発言を信じて、仕組みを公開しようと思いますw
(読んでみて違うと思った方、先に謝っておきます。すいません。)
実は、Groupieが流行る前に作った仕組みなのですが、NewsPicksというデザインの凝ったアプリで洗練させていった感じです。
大体のアプリではこの仕組みを使うことで生産性は上がる気がしています。
まずは、簡単なこんなUIを作ります。(赤字は対応するViewModelを書いているだけで、実際のアプリにあるわけではありません)
Groupieでは
レイアウトでRecyclerViewを定義し、
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
それにGroupieAdapter
のインスタンスを設定します
val adapter = GroupieAdapter()
binding.recyclerView.adapter = adapter
リストのアイテムになるViewHolderとViewModelを作って
class HeaderViewHolder(private val viewModel: HeaderViewModel) : BindableItem<ListItemMainHeaderBinding>() {
override fun getLayout(): Int = R.layout.list_item_main_header
override fun bind(viewBinding: ListItemMainHeaderBinding, position: Int) {
viewBinding.viewModel = viewModel
}
}
class ContentViewHolder(private val viewModel: CommentItemViewModel) : BindableItem<ListItemMainContentBinding>() {
override fun getLayout(): Int = R.layout.list_item_main_content
override fun bind(viewBinding: ListItemMainContentBinding, position: Int) {
viewBinding.viewModel = viewModel
}
}
object HeaderViewModel {
val title = "Sample Title"
}
class CommentItemViewModel(dto: CommentItemDto) {
val email: String = dto.email
val body: String = dto.body
}
一覧に表示するアイテムを設定していく感じです。ViewModelのLiveDataのitemsに非同期で取得した値が設定されて、Fragmentでobserveしている箇所でGroupieのオブジェクトに追加していく感じです。
viewModel.items.observe(viewLifecycleOwner, Observer {
val commentItemViewModels = it.map { ContentViewHolder(CommentItemViewModel(it)) }
val section = Section()
section.setHeader(HeaderViewHolder(HeaderViewModel))
section.addAll(commentItemViewModels)
adapter.add(section)
})
参考までにViewModelはこんな感じです。
private val _items = MutableLiveData<List<CommentItemDto>>()
val items: LiveData<List<CommentItemDto>> get() = _items
private fun fetchList() {
CommentRepository().fetchComments().onEach {
_items.value = it
}.catch { cause ->
// TODO エラー処理
throw cause
}.onCompletion {
isLoadingForRefresh.set(false)
}.launchIn(viewModelScope)
}
微妙だと思っている点
上記のような追加処理をFragmentで書くところが微妙かなと。BindingはViewModelで完結させたいんです。GroupieだとFragmentにロジックが入るんです。
上記の例は単純なので記述量は少ないですが、複雑な一覧になればなるほど、ロジックが膨れます。
Fragmentでは、ViewModelの値をそのまま渡すだけで終わらせたい。
なぜなら、ViewModelに処理を寄せれば、多くのデグレをViewModelだけの単体テストで検知できるからです。
もちろん、GroupieのようにViewに依存してる処理はViewModelに書きたくないですよね。
(上記の実装は、かなり端折ってますが、自作の場合と比べるとViewHolderを作るところやovserveしている部分が自作のより記述量が多いです。)
ということで、以下を満たす仕組みを作ってました。
- ViewModelで非同期処理が完結できる
- ViewModelで表示したい値をすべて設定できる(Fragmentに余計な処理をなるべくしたくない)
- ViewModelがViewに依存しない(当たり前だけど)
- ViewModelの単体テストで多くのデグレ検知ができる
さらにいうとこの仕組みを作ると以下も簡単に余計な依存もなく対応できます。
- Recycler In Recycler (RecylcerViewのネスト)をしたい
- Pull to Refreshの仕組みに入れたい
- ページングの仕組みに入れたい
ということで、自作が有利になるような表を書いときますw
Groupie | 自作 | |
---|---|---|
Adapter実装 | 不要 | 一つだけ必要 ライブラリ化すれば不要 |
ViewHolder不要 | 必要 | 不要 |
Fragmentでの記述量 | 多 | 中 |
Recyler in Recyler作成時の記述量 ネストの方もViewHolderが必要とかで Fragmentの記述量が多い |
多 | 少 |
ページングなどの機能拡張 | 面倒 | 簡単 |
仕組み
ライブラリ化してないので、クラスを書いていきます。ライブラリ化したら記述不要になります。
まずは、CollectionItemViewModel
というリストのアイテムひとつひとつを表すインターフェイスを定義します。
interface CollectionItemViewModel
次にItemViewTypeProvider
というレイアウトxmlとViewModelをマッピングするインターフェイスを作ります。
interface ItemViewTypeProvider {
fun getLayoutRes(modelCollectionItem: CollectionItemViewModel): Int
fun getBindingVariableId(modelCollectionItem: CollectionItemViewModel) = BR.viewModel
}
あとは、自作のRecyclerViewAdapter
クラスを作ります。
open class RecyclerViewAdapter<T>(
private val list: ObservableArrayList<T>,
private val viewTypeProvider: ItemViewTypeProvider,
private val onPostBindViewListener: ((T, ViewGroup) -> Unit)? = null
) : RecyclerView.Adapter<RecyclerViewAdapter.ItemViewHolder>()
where T : CollectionItemViewModel {
private val loadingLayoutResId = R.layout.list_item_loading
init {
list.addOnListChangedCallback(object :
ObservableList.OnListChangedCallback<ObservableList<T>>() {
override fun onChanged(viewModels: ObservableList<T>) {
notifyDataSetChanged()
}
override fun onItemRangeChanged(
viewModels: ObservableList<T>,
positionStart: Int,
itemCount: Int
) {
notifyItemRangeChanged(positionStart, itemCount)
}
override fun onItemRangeInserted(
viewModels: ObservableList<T>,
positionStart: Int,
itemCount: Int
) {
notifyItemRangeInserted(positionStart, itemCount)
}
override fun onItemRangeMoved(
viewModels: ObservableList<T>,
fromPosition: Int,
toPosition: Int,
itemCount: Int
) {
notifyItemMoved(fromPosition, toPosition)
}
override fun onItemRangeRemoved(
viewModels: ObservableList<T>,
positionStart: Int,
itemCount: Int
) {
notifyItemRangeRemoved(positionStart, itemCount)
}
})
}
private var inflater: LayoutInflater? = null
fun getItem(position: Int) = list.elementAtOrNull(position)
override fun getItemCount() = list.count()
override fun getItemViewType(position: Int): Int {
val item = list[position]
if (item is LoadingItemViewModel) {
return loadingLayoutResId
}
return viewTypeProvider.getLayoutRes(item)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
val inflater = this.inflater ?: from(parent.context)
if (viewType == loadingLayoutResId) {
val binding =
DataBindingUtil.inflate<ListItemLoadingBinding>(inflater, viewType, parent, false)
return ItemViewHolder(binding)
}
val binding = DataBindingUtil.inflate<ViewDataBinding>(inflater, viewType, parent, false)
return ItemViewHolder(binding)
}
override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
val item = list[position]
holder.binding.setVariable(viewTypeProvider.getBindingVariableId(item), item)
holder.binding.executePendingBindings()
val onPostBindViewListener = this.onPostBindViewListener
if (onPostBindViewListener != null) {
onPostBindViewListener(item, holder.itemView as ViewGroup)
}
}
class ItemViewHolder(val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root) {
}
}
使い方
一覧のアイテムになるViewModelにCollectionItemViewModel
を実装します。
object HeaderViewModel : CollectionItemViewModel {
val title = "Sample Title"
}
class CommentItemViewModel(dto: CommentItemDto): CollectionItemViewModel {
val email: String = dto.email
val body: String = dto.body
}
ViewModelに対応するレイアウトを作ります。ViewModelごとに違うデザインのレイアウトがあるという状態です。
<?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="viewModel"
type="ko2ic.sample.ui.HeaderViewModel"/>
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="24sp"
android:text="@{viewModel.title}"
android:layout_margin="8dp"
tools:text="タイトル"
/>
</LinearLayout>
</layout>
<?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="viewModel"
type="ko2ic.sample.ui.CommentItemViewModel"/>
</data>
<androidx.cardview.widget.CardView
android:layout_gravity="center"
android:layout_width="match_parent"
android:layout_height="100dp"
android:layout_margin="8dp"
app:cardCornerRadius="4dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp">
<TextView
android:id="@+id/email"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@{viewModel.email}"
android:textSize="16sp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="aaaaaaa"
/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@{viewModel.body}"
app:layout_constraintTop_toBottomOf="@id/email"
android:layout_margin="8dp"
tools:text="body"
/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
</layout>
Fragmentで ItemViewTypeProvider
の実装をし、adapterに自作のRecyclerViewAdapter
のインスタンスを設定します。
class MainFragment : Fragment(R.layout.main_fragment) {
companion object {
fun newInstance() = MainFragment()
}
private lateinit var viewModel: MainViewModel
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val binding = MainFragmentBinding.bind(view)
viewModel = ViewModelProvider(this)[MainViewModel::class.java]
binding.viewModel = viewModel
(activity as? AppCompatActivity)?.supportActionBar?.title = "RecyclerView Sample"
val itemViewTypeProvider = object : ItemViewTypeProvider {
override fun getLayoutRes(modelCollectionItem: CollectionItemViewModel): Int {
return when (modelCollectionItem) {
is HeaderViewModel -> R.layout.list_item_main_header
is CommentItemViewModel -> R.layout.list_item_main_content
else -> throw IllegalArgumentException("Unexpected layout")
}
}
}
binding.recyclerView.adapter = RecyclerViewAdapter(viewModel.viewModels, itemViewTypeProvider)
}
}
ポイントは、以下のようにViewModelに対応したXmlレイアウトファイルを指定するところです。
is HeaderViewModel -> R.layout.list_item_main_header
で、Fragmentで直接定義しているViewModel(ここではMainViewModel
)で非同期処理を実行し、val viewModels = ObservableArrayList<CollectionItemViewModel>()
にそれぞれのデザインごとのViewModelに変換して格納します。
ここのプロパティviewModels
がRecyclerViewAdapter
の引数に渡しているものになります。
Groupieのときと比べるとこのクラスで一覧のアイテムを全て設定していて、GroupieなどのViewに関連する余計なクラスにも依存していないことがわかると思います。
class MainViewModel : ViewModel() {
val viewModels = ObservableArrayList<CollectionItemViewModel>()
init {
fetchList()
}
private fun fetchList() {
CommentRepository().fetchComments().onEach {
val commentItemViewModels = it.map { CommentItemViewModel(it) }
viewModels.add(HeaderViewModel)
viewModels.addAll(commentItemViewModels)
}.catch { cause ->
// TODO エラー処理
throw cause
}.launchIn(viewModelScope)
}
}
これで、以下が実現できているのがなんとなくわかると思います。
FragmentではViewModelとレイアウトファイルのマッピングをしているだけで、ほとんどの処理はViewModelにあるので以下が実現できています。
- ViewModelで非同期処理が完結できる
- ViewModelで表示したい値をすべて設定できる
- ViewModelがViewに依存しない(当たり前だけど)
- ViewModelの単体テストで多くのデグレ検知ができる
RecylerViewのネスト方法(RecylerView In RecylerView)
ネストするのも簡単です。どういう場合にネストしたくなるかとういうのは様々ですが、縦スクロールの中に一つのアイテムだけが横スクロールできるデザインのときが多いのではないでしょうか。
こんなUIです。赤字で囲んでいる箇所は横スクロールできる部分です。
仕組み
念の為、どこでその仕組みがあるかというと自作のRecyclerViewAdapter
のコンストラクタの3番目の引数で実現しています。
open class RecyclerViewAdapter<T>(
private val list: ObservableArrayList<T>,
private val viewTypeProvider: ItemViewTypeProvider,
private val onPostBindViewListener: ((T, ViewGroup) -> Unit)? = null <- ここ
これは外側のRecyclerViewAdapterのBindが終わったあと(外側のRecyclerView)に、何かしらの処理したい場合に動作するリスナーで以下の箇所で動作します。
override fun onBindViewHolder(holder: ItemViewHolder, position: Int) {
val item = list[position]
holder.binding.setVariable(viewTypeProvider.getBindingVariableId(item), item)
holder.binding.executePendingBindings()
val onPostBindViewListener = this.onPostBindViewListener
if (onPostBindViewListener != null) {
onPostBindViewListener(item, holder.itemView as ViewGroup) <- ここ
}
}
実装方法
まずは、縦のRecylerViewに追加するViewModelを追加します。このクラスは、横スクロールのアイテムが格納されるval viewModels = ObservableArrayList<ImageItemViewModel>()
を持ちます。
class ImageMainViewModel(private val scope: CoroutineScope) : CollectionItemViewModel {
val viewModels = ObservableArrayList<ImageItemViewModel>()
init {
fetchList()
}
private fun fetchList() {
ImageRepository().fetchImages().onEach {
val imageItemViewModels = it.map { ImageItemViewModel(it.url) }
viewModels.addAll(imageItemViewModels)
}.catch { cause ->
// TODO エラー処理
print(cause)
}.launchIn(scope)
}
}
横スクロールのアイテムのViewModelも作成します。
class ImageItemViewModel(val url: String) : CollectionItemViewModel
対応するレイアウトを作成します。
<?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="viewModel"
type="ko2ic.sample.ui.ImageMainViewModel" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layoutManager="LinearLayoutManager"
tools:listitem="@layout/list_item_image" />
</LinearLayout>
</layout>
<?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="viewModel"
type="ko2ic.sample.ui.ImageItemViewModel" />
</data>
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp">
<ImageView
android:layout_width="52dp"
android:layout_height="52dp"
android:scaleType="centerCrop"
app:imageUrl="@{viewModel.url}" />
</FrameLayout>
</layout>
ItemViewTypeProvider
でレイアウトXMLとViewModelをマッピングします。
val itemViewTypeProvider = object : ItemViewTypeProvider {
override fun getLayoutRes(modelCollectionItem: CollectionItemViewModel): Int {
return when (modelCollectionItem) {
is HeaderViewModel -> R.layout.list_item_main_header
is CommentItemViewModel -> R.layout.list_item_main_content
+ is ImageMainViewModel -> R.layout.list_item_image_main
+ is ImageItemViewModel -> R.layout.list_item_image
else -> throw IllegalArgumentException("Unexpected layout")
}
}
RecyclerViewAdapter
コンストラクタの第三引数で、横スクロールのRecyclerViewに設定する処理を記述します。
binding.recyclerView.adapter = RecyclerViewAdapter(viewModel.viewModels, itemViewTypeProvider)
{ modelCollectionItem, view ->
when (modelCollectionItem) {
is ImageMainViewModel -> {
val imageMainBinding =
DataBindingUtil.findBinding<ListItemImageMainBinding>(view)
imageMainBinding?.recyclerView?.adapter = RecyclerViewAdapter(
modelCollectionItem.viewModels,
itemViewTypeProvider
)
}
}
}
これで完成です。余計なViewHolderなどを作らずに実装できました。
Pull To Refreshを入れる
これは、この仕組みというよりBindingAdapter
を利用します。ただ、自作のRecyclerViewAdapter
では、ほとんどの処理をActivityやFragmentからViewModelの持っていきたいという考え方があり、それを後押しする仕組みになると思います。
isLoadingForRefresh
とonRefresh()
メソッドを追加します。isLoadingForRefresh
は、onCompletion
でfalseになるようにします。
val isLoadingForRefresh = ObservableField(false)
fun onRefresh() {
viewModels.clear()
isLoadingForRefresh.set(true)
fetchList()
}
private fun fetchList() {
CommentRepository().fetchComments().onEach {
・・・
}.catch { cause ->
・・・
}.onCompletion {
isLoadingForRefresh.set(false) // <-ここ追加
}.launchIn(viewModelScope)
}
@BindingAdapter
でリフレッシュ処理が動くようにします。
@BindingAdapter(value = ["onRefresh", "refreshColors"])
fun SwipeRefreshLayout.onRefresh(listener: SwipeRefreshLayout.OnRefreshListener, @ColorInt vararg colors: Int) {
setColorSchemeColors(*colors)
setOnRefreshListener(listener)
}
レイアウトでRecyclerViewをSwipeRefreshLayout
で囲みます。app:refreshing
とapp:refreshColors
が、BindingAdapterのそれぞれのメソッド引数に渡り動作します。
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
app:onRefresh="@{viewModel::onRefresh}"
app:refreshColors="@{@intArray/refresh_color}"
app:refreshing="@{viewModel.isLoadingForRefresh}">
<androidx.recyclerview.widget.RecyclerView
・・・
これだけでPull To Refreshは完成です。
ページング(すいません、最後まで書いてません)
ページングについても同じような仕組みを使えば実現できます。
さらにいうと、自作のRecyclerViewAdapter
は、CollectionItemViewModel
のリストを要求するので、ページングの処理をより抽象的に記述できるので、より便利に実装できます。
RecyclerViewAdapter
の以下の箇所で実現しています。
override fun getItemViewType(position: Int): Int {
val item = list[position]
if (item is LoadingItemViewModel) { <- ここ
return loadingLayoutResId
}
return viewTypeProvider.getLayoutRes(item)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemViewHolder {
val inflater = this.inflater ?: from(parent.context)
if (viewType == loadingLayoutResId) { <- ここ
val binding =
DataBindingUtil.inflate<ListItemLoadingBinding>(inflater, viewType, parent, false)
return ItemViewHolder(binding)
}
これをViewModelとDataBindingを使って簡単に実装できるように仕組み化しています。
これ以上書くとさらにすごい分量になり時間も費やすことになるので、要望がたくさんあったらライブラリ化しようと思います。
このブログに書いたコードはgithubに置いてあるので気になる方は確認をしてください。
https://github.com/ko2ic/RecyclerView4DataBindingSample
終わりに
知ってる人は知っていると思いますが、僕はFlutterが最高と思っています。Android開発はFlutterに駆逐されるのではないかと思っています。
そうでなかった場合、宣言的UIなJetpack Composeで実装していくのが普通になっていくのかもしれません。
ただ、SwiftUIもそうですが、正直まだまだな印象が僕にはあります。(Flutterは洗練されてはいますが、現場によっては採用できないケースも多いかなと)
そんなときにいままでの実装の中でも生産性を上げる実装を作ることはまだまだできると思い投稿してみました。
いまさら、Androidで、しかもComposeじゃないのかよ。と思った人もいるかも。というかそういう人はこんな記事は読まない?
ただ、古いコードにも改善の余地は色々ある気がしています。同じように生産性をさらに上げるためのAndroidの投稿が増えるといいなと思っています。
NewsPicksでは、新しいコードでも古いコードでもガンガン生産性を上げる仕組みを作りたい、もしくは使いたい人を募集しています!
連絡お待ちしております !