1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Android開発30日間マスターシリーズ - Day 9:RecyclerViewとAdapter - モダンなリスト実装とデータバインディング

Last updated at Posted at 2025-09-10

1. RecyclerViewが必要な理由とその優位性

現代のモバイルアプリでは、SNSのフィード、ショッピングリスト、ニュース記事など、大量のデータを効率的に表示することが求められます。RecyclerViewは、このような要求に応える高性能なリスト表示コンポーネントです。

従来のListViewとの比較

特徴 ListView RecyclerView
メモリ効率 ViewHolderパターンが任意 ViewHolderパターンが必須
レイアウト柔軟性 縦スクロールのみ 縦横、グリッド、カスタム配置
アニメーション 基本的なもののみ 豊富なアニメーション機能
パフォーマンス 大量データで低下 大量データでも高性能

RecyclerViewの主な利点

  • ビューの再利用: 画面に表示される分だけViewを作成し、スクロール時に再利用
  • メモリ最適化: 大量データでもメモリ使用量を一定に保持
  • アニメーション: アイテムの追加・削除・移動アニメーションを標準サポート
  • カスタマイズ性: 独自のLayoutManagerで複雑なレイアウトも実現可能

2. RecyclerViewの核心アーキテクチャ

RecyclerViewは以下の4つの主要コンポーネントで構成されます:

📱 RecyclerView本体

  • リスト表示のコンテナ
  • スクロール処理とビューの再利用を管理

🔗 Adapter

  • データとUIの橋渡し役
  • データの変更をUIに反映
  • ViewHolderの作成と管理

📐 LayoutManager

  • アイテムの配置方法を決定
  • スクロール処理の制御

🎞️ ViewHolder

  • 個々のアイテムビューの参照を保持
  • ビューの再利用を効率化

3. 実践的な実装:完全なサンプル

ステップ1: データクラスの定義

// データを表すクラス
data class Product(
    val id: Int,
    val name: String,
    val price: Int,
    val imageUrl: String? = null,
    val description: String = ""
)

ステップ2: アイテムレイアウトの作成

<!-- res/layout/item_product.xml -->
<com.google.android.material.card.MaterialCardView 
    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="wrap_content"
    android:layout_margin="8dp"
    app:cardElevation="4dp"
    app:cardCornerRadius="8dp">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal"
        android:padding="16dp">

        <ImageView
            android:id="@+id/imageProduct"
            android:layout_width="64dp"
            android:layout_height="64dp"
            android:layout_marginEnd="16dp"
            android:scaleType="centerCrop"
            android:background="@color/grey_200"
            android:contentDescription="商品画像" />

        <LinearLayout
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:orientation="vertical">

            <TextView
                android:id="@+id/textProductName"
                style="@style/TextAppearance.Material3.HeadlineSmall"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:maxLines="2"
                android:ellipsize="end" />

            <TextView
                android:id="@+id/textPrice"
                style="@style/TextAppearance.Material3.BodyLarge"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginTop="4dp"
                android:textColor="?attr/colorPrimary" />

            <TextView
                android:id="@+id/textDescription"
                style="@style/TextAppearance.Material3.BodyMedium"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginTop="8dp"
                android:maxLines="3"
                android:ellipsize="end"
                android:textColor="?attr/colorOnSurfaceVariant" />

        </LinearLayout>

    </LinearLayout>

</com.google.android.material.card.MaterialCardView>

ステップ3: ViewBinding対応のViewHolder

class ProductViewHolder(private val binding: ItemProductBinding) : 
    RecyclerView.ViewHolder(binding.root) {
    
    fun bind(product: Product, onItemClick: (Product) -> Unit) {
        binding.apply {
            textProductName.text = product.name
            textPrice.text = "¥${product.price.formatWithComma()}"
            textDescription.text = product.description
            
            // 画像の読み込み(Glideなどを使用)
            product.imageUrl?.let { url ->
                Glide.with(itemView.context)
                    .load(url)
                    .placeholder(R.drawable.placeholder_image)
                    .error(R.drawable.error_image)
                    .into(imageProduct)
            }
            
            // クリックイベントの設定
            root.setOnClickListener { onItemClick(product) }
        }
    }
}

// 価格のフォーマット用拡張関数
private fun Int.formatWithComma(): String {
    return "%,d".format(this)
}

ステップ4: モダンなAdapter実装

class ProductAdapter(
    private var products: List<Product>,
    private val onItemClick: (Product) -> Unit
) : RecyclerView.Adapter<ProductViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProductViewHolder {
        val binding = ItemProductBinding.inflate(
            LayoutInflater.from(parent.context), 
            parent, 
            false
        )
        return ProductViewHolder(binding)
    }

    override fun onBindViewHolder(holder: ProductViewHolder, position: Int) {
        holder.bind(products[position], onItemClick)
    }

    override fun getItemCount(): Int = products.size

    // データの更新メソッド
    fun updateProducts(newProducts: List<Product>) {
        products = newProducts
        notifyDataSetChanged() // より効率的にはDiffUtilを使用
    }
    
    // 個別アイテムの追加
    fun addProduct(product: Product) {
        val mutableProducts = products.toMutableList()
        mutableProducts.add(product)
        products = mutableProducts
        notifyItemInserted(products.size - 1)
    }
    
    // 個別アイテムの削除
    fun removeProduct(position: Int) {
        if (position in 0 until products.size) {
            val mutableProducts = products.toMutableList()
            mutableProducts.removeAt(position)
            products = mutableProducts
            notifyItemRemoved(position)
        }
    }
}

ステップ5: Activity/Fragmentでの設定

class MainActivity : AppCompatActivity() {
    
    private lateinit var binding: ActivityMainBinding
    private lateinit var productAdapter: ProductAdapter
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        
        setupRecyclerView()
        loadSampleData()
    }
    
    private fun setupRecyclerView() {
        productAdapter = ProductAdapter(emptyList()) { product ->
            // アイテムクリック時の処理
            showProductDetail(product)
        }
        
        binding.recyclerViewProducts.apply {
            adapter = productAdapter
            layoutManager = LinearLayoutManager(this@MainActivity)
            
            // パフォーマンス最適化
            setHasFixedSize(true)
            
            // アイテム間のスペース設定
            addItemDecoration(
                DividerItemDecoration(
                    this@MainActivity, 
                    DividerItemDecoration.VERTICAL
                )
            )
        }
    }
    
    private fun loadSampleData() {
        val products = listOf(
            Product(1, "MacBook Pro 14インチ", 248000, description = "M3チップ搭載の高性能ノートPC"),
            Product(2, "iPhone 15 Pro", 159800, description = "最新のA17 Proチップ搭載"),
            Product(3, "AirPods Pro", 39800, description = "アクティブノイズキャンセリング機能付き"),
            Product(4, "iPad Air", 92800, description = "M2チップ搭載の軽量タブレット")
        )
        
        productAdapter.updateProducts(products)
    }
    
    private fun showProductDetail(product: Product) {
        // 商品詳細画面への遷移
        val intent = Intent(this, ProductDetailActivity::class.java)
        intent.putExtra("product_id", product.id)
        startActivity(intent)
    }
}

4. DiffUtilで効率的なデータ更新

大量データを扱う場合、notifyDataSetChanged()は非効率です。DiffUtilを使用することで、変更のあった部分のみを更新できます:

class ProductDiffCallback(
    private val oldList: List<Product>,
    private val newList: List<Product>
) : DiffUtil.Callback() {
    
    override fun getOldListSize(): Int = oldList.size
    override fun getNewListSize(): Int = newList.size
    
    override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        return oldList[oldItemPosition].id == newList[newItemPosition].id
    }
    
    override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
        return oldList[oldItemPosition] == newList[newItemPosition]
    }
}

// Adapter内での使用
fun updateProductsWithDiff(newProducts: List<Product>) {
    val diffCallback = ProductDiffCallback(products, newProducts)
    val diffResult = DiffUtil.calculateDiff(diffCallback)
    
    products = newProducts
    diffResult.dispatchUpdatesTo(this)
}

5. 異なるレイアウトパターン

グリッドレイアウト

// GridLayoutManagerの使用
binding.recyclerViewProducts.layoutManager = GridLayoutManager(this, 2) // 2列のグリッド

水平スクロール

// 水平スクロールのLinearLayoutManager
binding.recyclerViewProducts.layoutManager = LinearLayoutManager(
    this,
    LinearLayoutManager.HORIZONTAL,
    false
)

カスタムアイテムデコレーション

class GridSpacingItemDecoration(
    private val spanCount: Int,
    private val spacing: Int
) : RecyclerView.ItemDecoration() {
    
    override fun getItemOffsets(
        outRect: Rect,
        view: View,
        parent: RecyclerView,
        state: RecyclerView.State
    ) {
        val position = parent.getChildAdapterPosition(view)
        val column = position % spanCount
        
        outRect.left = spacing - column * spacing / spanCount
        outRect.right = (column + 1) * spacing / spanCount
        
        if (position < spanCount) {
            outRect.top = spacing
        }
        outRect.bottom = spacing
    }
}

// 使用例
binding.recyclerViewProducts.addItemDecoration(
    GridSpacingItemDecoration(2, resources.getDimensionPixelSize(R.dimen.grid_spacing))
)

6. パフォーマンス最適化のベストプラクティス

ViewBindingの活用

  • findViewById()の繰り返し呼び出しを削減
  • 型安全性の向上
  • null安全性の確保

プリロード設定

// 画面外のアイテムも事前に準備
recyclerView.setItemViewCacheSize(20)
recyclerView.recycledViewPool.setMaxRecycledViews(0, 20)

固定サイズの最適化

// アイテムサイズが固定の場合のパフォーマンス向上
recyclerView.setHasFixedSize(true)

画像読み込みの最適化

// Glideでのメモリ効率的な画像読み込み
Glide.with(context)
    .load(imageUrl)
    .override(200, 200) // リサイズ
    .centerCrop()
    .diskCacheStrategy(DiskCacheStrategy.RESOURCE)
    .into(imageView)

7. エラーハンドリングとユーザビリティ

空状態の処理

private fun updateEmptyState() {
    binding.apply {
        if (productAdapter.itemCount == 0) {
            recyclerViewProducts.visibility = View.GONE
            textEmptyState.visibility = View.VISIBLE
            textEmptyState.text = "商品が見つかりません"
        } else {
            recyclerViewProducts.visibility = View.VISIBLE
            textEmptyState.visibility = View.GONE
        }
    }
}

プルトゥリフレッシュの実装

<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
    android:id="@+id/swipeRefreshLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerViewProducts"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
binding.swipeRefreshLayout.setOnRefreshListener {
    loadProducts() // データを再読み込み
    binding.swipeRefreshLayout.isRefreshing = false
}

「プル トゥ リフレッシュ」(Pull To Refresh)とは、タッチパネルのデバイスで、画面コンテンツを下に引っ張って離すことで最新の情報に更新するUIパターンです。メールアプリやTwitterアプリ、ウェブブラウザなどで広く利用されている、ユーザーが自らの操作で画面を「引っ張ってリフレッシュする」機能です。

8. まとめ

✅ 今回学んだ重要なポイント

  • RecyclerView: Android開発における標準的なリスト表示コンポーネント
  • ViewBinding: 型安全でパフォーマンス効率的なビューアクセス
  • Adapter Pattern: データとUIを効率的に結びつける設計パターン
  • DiffUtil: 差分更新による高性能なデータ更新
  • 最適化技法: メモリ効率とパフォーマンスの向上方法

🚀 実践的な応用

RecyclerViewをマスターすることで、以下のような実用的なUIを実装できます:

  • SNSフィード: 動的コンテンツの効率的な表示
  • ECサイト: 商品一覧のグリッド表示
  • チャットアプリ: メッセージリストの実装
  • ニュースアプリ: 記事一覧の表示

📚 次のステップ

  • 複数ViewType: 異なるレイアウトを持つリストアイテム
  • ページング: Paging 3を使った大量データの効率的な読み込み
  • リアルタイム更新: LiveDataFlowとの連携
  • アニメーション: カスタムItemAnimatorの実装

次回は、これまでに学んだUIコンポーネントを活用した画面間ナビゲーションの実装について詳しく解説します。


参考リンク

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?