データ型の組み合わせで表現するListItem

2022-07-15
AndroidKotlinアーキテクチャ
    はじめに
    RecyclerViewの実装
    4種類のカードを実装する
    プロパティをインターフェースに抽出
    Containerをプロパティにしてまとめる
    Genericsを使用してデータをまとめる
    データ生成時にまとめる
    おわりに

はじめに

タイトルと画像のあるカードのリストを作ります。
それぞれ2種類のデータ型があり、組み合わせて4種類のカードを作ります。
完成品はgithubに上げてあります

ソース1ソース2
タイトル文字列StringResource
画像UrlDrawableResource

RecyclerViewの実装

基本となるRecyclerViewを実装していきます。
まずはgradleを編集してViewBindingを有効化して、Glideも追加しておきます。
manifestを編集してインターネット権限を追加します。

plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
compileSdk 32
defaultConfig {
applicationId "com.shirobutton.carddecks"
minSdk 26
targetSdk 32
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
viewBinding = true
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.3.0'
implementation 'com.google.android.material:material:1.4.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation "com.github.bumptech.glide:glide:4.11.0"
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.shirobutton.carddecks">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.CardDecks"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

今回注目するのはデータクラスとViewHolderなので、
ほかは適当にRecyclerViewを実装します。

data class CardListItem(
val title: String,
val imageUrl: String
)
class CardListViewHolder(
private val binding: CardListItemViewBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: CardListItem) {
binding.title.text = item.title
Glide.with(binding.image)
.load(item.imageUrl)
.into(binding.image)
}
companion object {
fun create(parent: ViewGroup): CardListViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val binding = CardListItemViewBinding.inflate(
layoutInflater,
parent,
false
)
return CardListViewHolder(binding)
}
}
}
<?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="wrap_content">
<ImageView
android:id="@+id/image"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:adjustViewBounds="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@tools:sample/avatars" />
<TextView
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:textSize="34sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/image"
app:layout_constraintTop_toTopOf="parent"
tools:text="Title" />
</androidx.constraintlayout.widget.ConstraintLayout>
class CardListAdapter : ListAdapter<CardListItem, CardListViewHolder>(diffCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
CardListViewHolder.create(parent)
override fun onBindViewHolder(holder: CardListViewHolder, position: Int) =
holder.bind(getItem(position))
companion object {
private val diffCallback = object : DiffUtil.ItemCallback<CardListItem>() {
override fun areItemsTheSame(
oldItem: CardListItem,
newItem: CardListItem
): Boolean = oldItem == newItem
override fun areContentsTheSame(
oldItem: CardListItem,
newItem: CardListItem
): Boolean = oldItem == newItem
}
}
}
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
bindRecyclerView(binding)
}
private fun bindRecyclerView(binding: ActivityMainBinding) {
val adapter = CardListAdapter()
adapter.submitList(createData())
binding.root.adapter = adapter
binding.root.layoutManager = LinearLayoutManager(this)
}
private fun createData() =
List(3) {
CardListItem(
title = it.toString(),
imageUrl = "https://placehold.jp/100x100.png"
)
}
}
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent" />

4種類のカードを実装する

現在はタイトルが文字列で画像がURLのカードを使用していますね。
なので他3種類のカードを作りたいと思います。
まずはタイトルがStringResourceのカードを作ってみます。

CardItemを異なる種類に対応するためにclassからinterfaceに変更します。
抽象であれば様々な具象に対応できるためですね。

sealed interface CardListItem {
val title: String
val imageUrl: String
override fun equals(other: Any?): Boolean
}
data class CardTypeA(
override val title: String,
override val imageUrl: String
) : CardListItem
private fun createData() =
List(3) {
CardTypeA(
title = it.toString(),
imageUrl = "https://placehold.jp/100x100.png"
)
}

ではタイトルがStringResourceのカードを追加してみます。
単純にデータ型を追加して、それをViewHolderのgetTitleで利用します。

sealed interface CardListItem {
val imageUrl: String
override fun equals(other: Any?): Boolean
}
data class CardTypeA(
val title: String,
override val imageUrl: String
) : CardListItem
data class CardTypeB(
@StringRes val titleResId: Int,
override val imageUrl: String
) : CardListItem
class CardListViewHolder(
private val binding: CardListItemViewBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: CardListItem) {
binding.title.text = getTitle(item)
Glide.with(binding.image)
.load(item.imageUrl)
.into(binding.image)
}
private fun getTitle(item: CardListItem) =
when (item) {
is CardTypeA -> item.title
is CardTypeB -> binding.root.context.getString(item.titleResId)
}
companion object {
fun create(parent: ViewGroup): CardListViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val binding = CardListItemViewBinding.inflate(
layoutInflater,
parent,
false
)
return CardListViewHolder(binding)
}
}
}
private fun createData() =
List(3) {
CardTypeA(
title = it.toString(),
imageUrl = "https://placehold.jp/100x100.png"
)
} + CardTypeB(
titleResId = R.string.title,
imageUrl = "https://placehold.jp/100x100.png"
)
}
<resources>
<string name="app_name">CardDecks</string>
<string name="title">ResourceTitle</string>
</resources>

この調子で画像がDrawableResourceのカードも作ります。
データクラスの名前が思いつかなかったのでちょっと適当です。
createDataがごちゃごちゃしてきたのでついでにリファクタリングもします。

sealed interface CardListItem {
override fun equals(other: Any?): Boolean
}
data class CardTypeA(
val title: String,
val imageUrl: String
) : CardListItem
data class CardTypeB(
@StringRes val titleResId: Int,
val imageUrl: String
) : CardListItem
data class CardTypeC(
val title: String,
@DrawableRes val imageResId: Int
) : CardListItem
data class CardTypeD(
@StringRes val titleResId: Int,
@DrawableRes val imageResId: Int
) : CardListItem
class CardListViewHolder(
private val binding: CardListItemViewBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: CardListItem) {
bindTitle(item)
bindImage(item)
}
private fun bindTitle(item: CardListItem) {
val context = binding.root.context
binding.title.text = when (item) {
is CardTypeA -> item.title
is CardTypeC -> item.title
is CardTypeB -> context.getString(item.titleResId)
is CardTypeD -> context.getString(item.titleResId)
}
}
private fun bindImage(item: CardListItem) =
when (item) {
is CardTypeA -> loadImage(item.imageUrl)
is CardTypeC -> setImageResource(item.imageResId)
is CardTypeB -> loadImage(item.imageUrl)
is CardTypeD -> setImageResource(item.imageResId)
}
private fun loadImage(imageUrl: String) {
Glide.with(binding.image)
.load(imageUrl)
.into(binding.image)
}
private fun setImageResource(@DrawableRes drawableResId: Int) =
binding.image.setImageResource(drawableResId)
companion object {
fun create(parent: ViewGroup): CardListViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val binding = CardListItemViewBinding.inflate(
layoutInflater,
parent,
false
)
return CardListViewHolder(binding)
}
}
}
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
bindRecyclerView(binding)
}
private fun bindRecyclerView(binding: ActivityMainBinding) {
val adapter = CardListAdapter()
adapter.submitList(createData())
binding.root.adapter = adapter
binding.root.layoutManager = LinearLayoutManager(this)
}
companion object {
private const val STRING_TITLE = "StringTitle"
private const val TITLE_RES_ID = R.string.title
private const val IMAGE_URL = "https://placehold.jp/100x100.png"
private const val IMAGE_RES_ID = R.drawable.ic_launcher_background
private fun createData() =
listOf(
CardTypeA(
title = STRING_TITLE,
imageUrl = IMAGE_URL
),
CardTypeB(
titleResId = TITLE_RES_ID,
imageUrl = IMAGE_URL
),
CardTypeC(
title = STRING_TITLE,
imageResId = IMAGE_RES_ID
),
CardTypeD(
titleResId = TITLE_RES_ID,
imageResId = IMAGE_RES_ID
)
)
}
}

DrawableResourceのカードも
タイトルが文字列の場合とStringResourceの場合の2種類ありますね。
これで4種類のカードができました。

ソース1ソース2
タイトル文字列StringResource
画像UrlDrawableResource

プロパティをインターフェースに抽出

CardListViewHolderを見るとデータクラスで判別していてなんだか冗長的です。
同じデータ型を示しているプロパティをまとめられそうですね。
例えば文字列のタイトルであればそのままtitleをtextに入れればよくて、
反対に、StringResourceであれば、getStringを使えばいいですね。

これを区別するためのInterfaceを追加します。
とりあえずタイトルについてまとめます。
文字列であればStringTitleContainer、
StringResourceであれば、StringResourceTitleContainerを実装します。

CardListViewHolderでTitleContainerごとに処理を分けられていい感じですね。

sealed interface TitleContainer
sealed interface StringTitleContainer : TitleContainer {
val title: String
}
sealed interface StringResourceTitleContainer : TitleContainer {
@get:StringRes
val titleResId: Int
}
sealed interface CardListItem : TitleContainer {
override fun equals(other: Any?): Boolean
}
data class CardTypeA(
override val title: String,
val imageUrl: String
) : CardListItem, StringTitleContainer
data class CardTypeB(
@StringRes override val titleResId: Int,
val imageUrl: String
) : CardListItem, StringResourceTitleContainer
data class CardTypeC(
override val title: String,
@DrawableRes val imageResId: Int
) : CardListItem, StringTitleContainer
data class CardTypeD(
@StringRes override val titleResId: Int,
@DrawableRes val imageResId: Int
) : CardListItem, StringResourceTitleContainer
private fun bindTitle(item: TitleContainer) {
val context = binding.root.context
binding.title.text = when (item) {
is StringTitleContainer -> item.title
is StringResourceTitleContainer -> context.getString(item.titleResId)
}
}

画像についても同じようにまとめます。

sealed interface ImageContainer
sealed interface ImageUrlContainer : ImageContainer {
val imageUrl: String
}
sealed interface ImageResourceContainer : ImageContainer {
@get:DrawableRes val imageResId: Int
}
sealed interface CardListItem : TitleContainer, ImageContainer {
override fun equals(other: Any?): Boolean
}
data class CardTypeA(
override val title: String,
override val imageUrl: String
) : CardListItem, StringTitleContainer, ImageUrlContainer
data class CardTypeB(
@StringRes override val titleResId: Int,
override val imageUrl: String
) : CardListItem, StringResourceTitleContainer, ImageUrlContainer
data class CardTypeC(
override val title: String,
@DrawableRes override val imageResId: Int
) : CardListItem, StringTitleContainer, ImageResourceContainer
data class CardTypeD(
@StringRes override val titleResId: Int,
@DrawableRes override val imageResId: Int
) : CardListItem, StringResourceTitleContainer, ImageResourceContainer
private fun bindImage(item: ImageContainer) =
when (item) {
is ImageUrlContainer -> loadImage(item.imageUrl)
is ImageResourceContainer -> setImageResource(item.imageResId)
}

これでViewHolderは冗長的ではなくなりましたね。
Cardのタイプごとに何かしら意味があればこれで十分だと思います。
それぞれのタイプでデータクラスを作成しておけば、
後々TypeAのみに追加するプロパティなどの個別の変更がしやすいです。

では、逆にタイプごとに分かれている意味はなく、
プロパティの追加見込みもないなど、
わざわざクラスに分ける意味がない場合はどうでしょうか。

Containerをプロパティにしてまとめる

クラスを分ける意味がない場合は、
冗長的なクラスを複数書くのは大変なので、一つのクラスで表現したいところです。
いくつか表現方法があります。

まずはContainerをクラスに継承させるのではなく、
Containerをクラスのプロパティにしてみます。
これなら器になるクラスを一つ用意するだけでよさそうです。

ContainerがInterfaceなのでClassにする必要がありますね。

data class CardListItem(
val titleContainer: TitleContainer,
val imageContainer: ImageContainer
)
fun bind(item: CardListItem) {
bindTitle(item.titleContainer)
bindImage(item.imageContainer)
}
sealed interface TitleContainer
class StringTitleContainer(val title: String) : TitleContainer
class StringResourceTitleContainer(@StringRes val titleResId: Int) : TitleContainer
sealed interface ImageContainer
class ImageUrlContainer(val imageUrl: String) : ImageContainer
class ImageResourceContainer(@DrawableRes val imageResId: Int) : ImageContainer
private fun createData() =
listOf(
CardListItem(
StringTitleContainer(STRING_TITLE),
ImageUrlContainer(IMAGE_URL)
),
CardListItem(
StringResourceTitleContainer(TITLE_RES_ID),
ImageUrlContainer(IMAGE_URL)
),
CardListItem(
StringTitleContainer(STRING_TITLE),
ImageResourceContainer(IMAGE_RES_ID)
),
CardListItem(
StringResourceTitleContainer(TITLE_RES_ID),
ImageResourceContainer(IMAGE_RES_ID)
)
)

データクラスが一つになってすっきりしましたね。

Containerにロジックを移す

蛇足ですが、Containerにロジックを移すことで、
ViewHolder内のContainerの条件分岐を解消できます。
インターフェースを使用したポリモーフィズムの表現です。

sealed interface TitleContainer {
fun getString(context: Context): String
}
class StringTitleContainer(private val title: String) : TitleContainer {
override fun getString(context: Context) = title
}
class StringResourceTitleContainer(@StringRes private val titleResId: Int) : TitleContainer {
override fun getString(context: Context) = context.getString(titleResId)
}
sealed interface ImageContainer {
fun into(imageView: ImageView)
}
class ImageUrlContainer(private val imageUrl: String) : ImageContainer {
override fun into(imageView: ImageView) {
Glide.with(imageView)
.load(imageUrl)
.into(imageView)
}
}
class ImageResourceContainer(@DrawableRes private val imageResId: Int) : ImageContainer {
override fun into(imageView: ImageView) = imageView.setImageResource(imageResId)
}
class CardListViewHolder(
private val binding: CardListItemViewBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: CardListItem) {
binding.title.text = item.titleContainer.getString(binding.root.context)
item.imageContainer.into(binding.image)
}
companion object {
fun create(parent: ViewGroup): CardListViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val binding = CardListItemViewBinding.inflate(
layoutInflater,
parent,
false
)
return CardListViewHolder(binding)
}
}
}

ViewHolderがとても小さくなりましたね。
ついでに委譲を使用してContainerを隠ぺいしてみましょう。

data class CardListItem(
private val titleContainer: TitleContainer,
private val imageContainer: ImageContainer
) : TitleContainer by titleContainer, ImageContainer by imageContainer
class CardListViewHolder(
private val binding: CardListItemViewBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: CardListItem) {
binding.title.text = item.getString(binding.root.context)
item.into(binding.image)
}
companion object {
fun create(parent: ViewGroup): CardListViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val binding = CardListItemViewBinding.inflate(
layoutInflater,
parent,
false
)
return CardListViewHolder(binding)
}
}
}

これでViewHolderはCardItemの中にContainerが入っていることが分からなくなりましたね。
ViewHolderはContainerごとに条件分岐する必要がなくなったので、
Containerが大量に増えてもViewHolderを編集する必要はありません。

ただ、Interfaceで変更があった場合に大量のConainterを修正する必要があります。
Containetが増える予定がなく、処理をViewHolderでのみ使うのであれば、
ViewHolderで条件分岐したほうがシンプルかもしれません。

あと、Containerという名前なのにロジックが入っているので名前を変えたほうがいいかもしれませんね。

Genericsを使用してデータをまとめる

こちらのほうがメジャーかもしれません。
複数のデータを扱うときはGenericsを使うことも多いです。

ResourceIDがただのInt型として扱われてしまうので、
value classを使用して別の型として表現します。

data class CardListItem<T, U>(
val titleResource: T,
val imageResource: U
)
class CardListAdapter : ListAdapter<CardListItem<*, *>, CardListViewHolder>(diffCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
CardListViewHolder.create(parent)
override fun onBindViewHolder(holder: CardListViewHolder, position: Int) =
holder.bind(getItem(position))
companion object {
private val diffCallback = object : DiffUtil.ItemCallback<CardListItem<*, *>>() {
override fun areItemsTheSame(
oldItem: CardListItem<*, *>,
newItem: CardListItem<*, *>
): Boolean = oldItem == newItem
override fun areContentsTheSame(
oldItem: CardListItem<*, *>,
newItem: CardListItem<*, *>
): Boolean = oldItem == newItem
}
}
}
class CardListViewHolder(
private val binding: CardListItemViewBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: CardListItem<*, *>) {
bindTitle(item.titleResource)
bindImage(item.imageResource)
}
private fun <T> bindTitle(titleResource: T) {
val context = binding.root.context
binding.title.text = when (titleResource) {
is String -> titleResource
is StringResourceId -> context.getString(titleResource.value)
else -> throw IllegalArgumentException()
}
}
private fun <T> bindImage(imageResource: T) =
when (imageResource) {
is Uri -> loadImage(imageResource)
is DrawableResourceId -> setImageResource(imageResource.value)
else -> throw IllegalArgumentException()
}
private fun loadImage(imageUrl: Uri) {
Glide.with(binding.image)
.load(imageUrl)
.into(binding.image)
}
private fun setImageResource(@DrawableRes drawableResId: Int) =
binding.image.setImageResource(drawableResId)
companion object {
fun create(parent: ViewGroup): CardListViewHolder {
val layoutInflater = LayoutInflater.from(parent.context)
val binding = CardListItemViewBinding.inflate(
layoutInflater,
parent,
false
)
return CardListViewHolder(binding)
}
}
}
@JvmInline
value class StringResourceId(@StringRes val value: Int)
fun Int.asStringResource() = StringResourceId(this)
@JvmInline
value class DrawableResourceId(@DrawableRes val value: Int)
fun Int.asDrawableResource() = DrawableResourceId(this)
companion object {
private const val STRING_TITLE = "StringTitle"
private val TITLE_RES_ID = R.string.title.asStringResource()
private val IMAGE_URL = "https://placehold.jp/100x100.png".toUri()
private val IMAGE_RES_ID = R.drawable.ic_launcher_background.asDrawableResource()
private fun createData() =
listOf(
CardListItem(
STRING_TITLE,
IMAGE_URL
),
CardListItem(
TITLE_RES_ID,
IMAGE_URL
),
CardListItem(
STRING_TITLE,
IMAGE_RES_ID
),
CardListItem(
TITLE_RES_ID,
IMAGE_RES_ID
)
)
}

これでも、DataClassは一つですみますね。
だたGenericsは上限を設定しない限り、Any?なので、
使用する側で条件分岐する必要があります。
今回は想定外の型だとエラーするようにしていますが、
プレースホルダーを表示すると丁寧ですね。

データ生成時にまとめる

今回は画像データを含むリストなので難しいですが、
テキストデータのみなどコストが低い場合は
データ生成時に一つのデータ型にまとめる方法もシンプルです。
StringResourceを使うならあらかじめgetStringするということですね。

あまり関係ないですが、あらかじめgetStringする場合は、
生成タイミングによってOSの言語設定が変わっても文字列が変わらなかったりします。

おわりに

結局のところどれがいいとかではなくて、
一長一短なのでこういう種類があるなという感じです。

個人的には委譲やポリフォーフィズムは好きですが、
デメリットももちろんありますし、
簡単な実装であればデータと処理を離すほうが主流になりつつあるのかなと思います。

© 2022 シロボタン - Context Composition