iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0
Software Development

Android 鐵人賽: 天命最高 - 陪伴大家一步步打造屬於自己的app系列 第 24

別再用 Map 硬塞資料:用 Kotlin Data Class 重構 GridView

  • 分享至 

  • xImage
  •  

了解,昨天已經介紹過 GridView+自訂 Adapter+ViewHolder,今天不應再重複。DAY 24 改成「資料模型重構」,把原本容易出錯的 MutableMap<String, Any> 改成 Kotlin data class,並加入選取狀態與點擊反白效果。
參考資料:https://kotlinlang.org/docs/data-classes.html

DAY 24|別再用 Map 硬塞資料:用 Kotlin Data Class 重構 GridView

昨天我們完成了 GridView 與自訂 Adapter,成功將圖片與名稱顯示成網格。不過程式目前使用 MutableMap<String, Any> 保存資料,雖然可以執行,但容易出現 Key 拼錯、強制轉型失敗及維護困難等問題。
參考資料:https://kotlinlang.org/docs/collections-overview.html

今天不再介紹 GridView 的基本用法,而是把昨天的程式進行一次正式重構:

  • 使用 data class 建立資料模型。
  • 移除 "name""pic" 字串 Key。
  • 移除 as Int 強制型別轉換。
  • 使用 nameArray.indices 改寫迴圈。
  • 記錄目前選取的項目。
  • 點擊項目後顯示選取外框。
  • 將選取圖片更新到下方預覽區。

參考資料:https://kotlinlang.org/docs/data-classes.html


一、昨天的資料寫法有什麼問題?

昨天使用下面的方式保存資料:

val data = mutableMapOf<String, Any>()

data["name"] = nameArray[i]
data["pic"] = picArray.getResourceId(
    i,
    R.drawable.flower1
)

取出資料時則需要使用:

val name = item["name"].toString()
val pictureResId = item["pic"] as Int

這種寫法存在三個問題。第一,"name""pic" 都只是普通字串,編譯器無法檢查拼字;第二,Value 使用 Any,取出資料時必須自行轉型;第三,資料欄位愈多,Map 的內容就愈難閱讀。
參考資料:https://kotlinlang.org/docs/typecasts.html

例如不小心寫成:

val pictureResId = item["picture"] as Int

程式仍然可能通過編譯,但執行時因為 "picture" 不存在,取到的結果是 null,接著強制轉成 Int 就可能發生例外。
參考資料:https://kotlinlang.org/docs/null-safety.html


二、使用 data class 建立資料模型

我們可以建立一個 FlowerItem,明確定義每一筆資料包含哪些欄位:

package com.example.listview6

import androidx.annotation.DrawableRes

/**
 * GridView 每一個花朵項目的資料模型。
 *
 * @property name 顯示在圖片下方的花朵名稱。
 * @property pictureResId Drawable 圖片資源 ID。
 */
data class FlowerItem(
    val name: String,

    @DrawableRes
    val pictureResId: Int
)

data class 適合用來保存資料。Kotlin 會自動產生 equals()hashCode()toString()componentN()copy() 等常用功能,不需要自己撰寫大量重複程式。
參考資料:https://kotlinlang.org/docs/data-classes.html

@DrawableRes 用來標示 pictureResId 應該接收 Drawable 資源 ID。它不會改變資料型別,但 Android Studio 可以利用這項資訊進行靜態檢查。
參考資料:https://developer.android.com/reference/androidx/annotation/DrawableRes


三、重構前後比較

比較項目 MutableMap 寫法 Data Class 寫法
名稱欄位 item["name"] item.name
圖片欄位 item["pic"] as Int item.pictureResId
拼字檢查 編譯器無法檢查 編譯階段即可檢查
型別安全 使用 Any 每個欄位型別明確
程式可讀性 Key 多時較混亂 資料結構清楚
自動完成 幾乎沒有 Android Studio 可提示
後續維護 容易寫錯 Key 修改欄位時容易追蹤

使用資料模型後,原本的:

holder.itemTextView.text =
    item["name"].toString()

holder.itemImageView.setImageResource(
    item["pic"] as Int
)

可以改成:

holder.itemTextView.text = item.name

holder.itemImageView.setImageResource(
    item.pictureResId
)

新版不再需要字串 Key,也不需要 as Int 強制轉型,程式碼更短且更安全。
參考資料:https://kotlinlang.org/docs/properties.html


四、加入選取狀態

今天除了重構資料模型,也在 Adapter 中增加 selectedPosition,記錄目前被選取的網格位置:

private var selectedPosition: Int = -1

初始值設為 -1,代表尚未選取任何項目。使用者點擊某一格後,再把點擊位置保存起來。
參考資料:https://developer.android.com/reference/android/widget/AdapterView.OnItemClickListener

fun setSelectedPosition(position: Int) {
    selectedPosition = position
    notifyDataSetChanged()
}

notifyDataSetChanged() 會通知 GridView:Adapter 的資料或顯示狀態已經改變,需要重新取得目前畫面上的項目 View。
參考資料:https://developer.android.com/reference/android/widget/BaseAdapter#notifyDataSetChanged()


五、準備選取外框 Drawable

res/drawable 建立以下兩個檔案,分別代表一般狀態與選取狀態。
參考資料:https://developer.android.com/guide/topics/resources/drawable-resource

grid_item_normal.xml

<?xml version="1.0" encoding="utf-8"?>

<shape
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">

    <!-- 一般狀態背景 -->
    <solid android:color="#FFFFFFFF" />

    <!-- 一般狀態使用淺灰色細框 -->
    <stroke
        android:width="1dp"
        android:color="#FFE0E0E0" />

    <!-- 圓角 -->
    <corners android:radius="12dp" />

    <!-- 項目內容與邊框之間的距離 -->
    <padding
        android:left="8dp"
        android:top="8dp"
        android:right="8dp"
        android:bottom="8dp" />

</shape>

grid_item_selected.xml

<?xml version="1.0" encoding="utf-8"?>

<shape
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">

    <!-- 選取後使用淡綠色背景 -->
    <solid android:color="#FFE8F5E9" />

    <!-- 使用 Android 綠色粗框顯示選取狀態 -->
    <stroke
        android:width="3dp"
        android:color="#FF3DDC84" />

    <!-- 圓角 -->
    <corners android:radius="12dp" />

    <!-- 項目內容與邊框之間的距離 -->
    <padding
        android:left="8dp"
        android:top="8dp"
        android:right="8dp"
        android:bottom="8dp" />

</shape>

Shape Drawable 可以使用 XML 定義背景顏色、邊框、圓角與內距,不需要另外製作 PNG 圖片。
參考資料:https://developer.android.com/guide/topics/resources/drawable-resource#Shape


六、修改 item_layout.xml

為了讓整個網格項目可以切換背景,最外層的 LinearLayout 必須設定 ID:

<?xml version="1.0" encoding="utf-8"?>

<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/layout_itemRoot"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="4dp"
    android:background="@drawable/grid_item_normal"
    android:gravity="center"
    android:orientation="vertical"
    android:padding="8dp">

    <!-- 顯示花朵縮圖 -->
    <ImageView
        android:id="@+id/imageView_itemPic"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:contentDescription="@string/app_name"
        android:scaleType="centerCrop"
        android:src="@drawable/flower1" />

    <!-- 顯示花朵名稱 -->
    <TextView
        android:id="@+id/textView_itemName"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="6dp"
        android:gravity="center"
        android:maxLines="1"
        android:text="花朵名稱"
        android:textColor="#FF212121"
        android:textSize="16sp" />

</LinearLayout>

新增的 layout_itemRoot 會一起加入 ViewHolder,讓 Adapter 可以根據 position 切換一般背景與選取背景。
參考資料:https://developer.android.com/reference/android/view/View#setBackgroundResource(int)


七、重構後的 MyAdapter.kt

以下是今天重構後的完整 Adapter。資料來源已經從 MutableMap<String, Any> 改為 List<FlowerItem>,並加入選取狀態。
參考資料:https://developer.android.com/reference/android/widget/BaseAdapter

package com.example.listview6

import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView

/**
 * GridView 使用的自訂 Adapter。
 *
 * DAY 24 重構重點:
 * 1. 將 MutableMap<String, Any> 改成型別安全的 FlowerItem。
 * 2. 移除字串 Key 與 as Int 強制轉型。
 * 3. 加入 selectedPosition 記錄目前選取的位置。
 * 4. 選取項目時切換背景 Drawable。
 *
 * @param myContext 提供版面配置與資源存取所需的 Context。
 * @param listData GridView 顯示的花朵資料。
 */
class MyAdapter(
    private val myContext: Context,
    private val listData: List<FlowerItem>
) : BaseAdapter() {

    /**
     * 目前被選取的項目位置。
     *
     * -1 表示目前沒有任何項目被選取。
     */
    private var selectedPosition: Int = -1

    /**
     * 回傳資料總筆數。
     */
    override fun getCount(): Int {
        return listData.size
    }

    /**
     * 回傳指定位置的 FlowerItem。
     *
     * 將回傳型別直接指定為 FlowerItem,
     * 呼叫端就不需要再進行強制型別轉換。
     */
    override fun getItem(position: Int): FlowerItem {
        return listData[position]
    }

    /**
     * 回傳指定位置的項目 ID。
     *
     * 目前使用 position 作為項目 ID。
     */
    override fun getItemId(position: Int): Long {
        return position.toLong()
    }

    /**
     * 更新目前選取的位置。
     *
     * 更新完成後呼叫 notifyDataSetChanged(),
     * 要求 GridView 重新顯示目前畫面上的項目。
     */
    fun setSelectedPosition(position: Int) {
        selectedPosition = position
        notifyDataSetChanged()
    }

    /**
     * 回傳目前選取的位置。
     */
    fun getSelectedPosition(): Int {
        return selectedPosition
    }

    /**
     * 建立或重複使用 GridView 單一項目的 View。
     */
    override fun getView(
        position: Int,
        convertView: View?,
        parent: ViewGroup?
    ): View {

        val view: View
        val holder: ViewHolder

        if (convertView == null) {

            // 沒有可用的舊 View,載入新的 item_layout
            view = LayoutInflater
                .from(myContext)
                .inflate(
                    R.layout.item_layout,
                    parent,
                    false
                )

            // 建立 ViewHolder 並保存項目中的元件
            holder = ViewHolder(
                itemRootLayout = view.findViewById(
                    R.id.layout_itemRoot
                ),
                itemImageView = view.findViewById(
                    R.id.imageView_itemPic
                ),
                itemTextView = view.findViewById(
                    R.id.textView_itemName
                )
            )

            // 將 ViewHolder 保存到 View 的 tag
            view.tag = holder

        } else {

            // 重複使用 GridView 提供的舊 View
            view = convertView

            // 取回之前保存的 ViewHolder
            holder = view.tag as ViewHolder
        }

        // 使用型別安全的 getItem() 取得資料
        val item = getItem(position)

        // 不再需要 item["name"] 或 toString()
        holder.itemTextView.text = item.name

        // 不再需要 item["pic"] as Int
        holder.itemImageView.setImageResource(
            item.pictureResId
        )

        // 根據目前位置是否被選取,切換項目背景
        if (position == selectedPosition) {
            holder.itemRootLayout.setBackgroundResource(
                R.drawable.grid_item_selected
            )
        } else {
            holder.itemRootLayout.setBackgroundResource(
                R.drawable.grid_item_normal
            )
        }

        return view
    }

    /**
     * 保存 item_layout 中的畫面元件。
     */
    private class ViewHolder(
        val itemRootLayout: LinearLayout,
        val itemImageView: ImageView,
        val itemTextView: TextView
    )
}

getItem() 現在直接回傳 FlowerItem,因此 Android Studio 可以辨識 namepictureResId,也能提供自動完成與型別檢查。
參考資料:https://kotlinlang.org/docs/functions.html#return-values


八、重構後的 MainActivity.kt

MainActivity 也需要將資料清單改成 MutableList<FlowerItem>,點擊時則直接取得 FlowerItem,不再轉換 Map。
參考資料:https://developer.android.com/reference/android/widget/AdapterView

package com.example.listview6

import android.os.Bundle
import android.util.Log
import android.widget.GridView
import android.widget.ImageView
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 啟用 Edge-to-Edge 顯示
        enableEdgeToEdge()

        // 載入主畫面
        setContentView(R.layout.activity_main)

        // 避免畫面內容被狀態列或導覽列遮住
        ViewCompat.setOnApplyWindowInsetsListener(
            findViewById(R.id.main)
        ) { view, insets ->

            val systemBars = insets.getInsets(
                WindowInsetsCompat.Type.systemBars()
            )

            view.setPadding(
                systemBars.left,
                systemBars.top,
                systemBars.right,
                systemBars.bottom
            )

            insets
        }

        // 取得 GridView
        val gridViewData =
            findViewById<GridView>(R.id.gridView_id)

        // 取得顯示選取結果的大圖
        val imageViewPic =
            findViewById<ImageView>(R.id.ImageView_pic)

        // 讀取名稱陣列
        val nameArray =
            resources.getStringArray(R.array.name)

        // 讀取圖片資源陣列
        val picArray =
            resources.obtainTypedArray(R.array.picture)

        // 使用 FlowerItem 建立型別安全的資料清單
        val listData =
            mutableListOf<FlowerItem>()

        /**
         * indices 會取得陣列的有效索引範圍。
         *
         * 例如陣列有 6 筆資料,
         * indices 就是 0..5。
         */
        for (index in nameArray.indices) {

            // 建立 FlowerItem 並加入資料清單
            listData.add(
                FlowerItem(
                    name = nameArray[index],
                    pictureResId = picArray.getResourceId(
                        index,
                        R.drawable.flower1
                    )
                )
            )
        }

        // TypedArray 使用完畢後回收
        picArray.recycle()

        // data class 會自動產生容易閱讀的 toString()
        Log.d(
            "MainActivity",
            "listData = $listData"
        )

        // 建立自訂 Adapter
        val adapter = MyAdapter(
            myContext = this@MainActivity,
            listData = listData
        )

        // 將 Adapter 設定給 GridView
        gridViewData.adapter = adapter

        // 設定 GridView 項目點擊事件
        gridViewData.setOnItemClickListener {
                parent,
                view,
                position,
                id ->

            /**
             * 因為 MyAdapter.getItem() 已經回傳 FlowerItem,
             * 所以可以直接呼叫 adapter.getItem(position),
             * 不再需要將 Any 強制轉型成 MutableMap。
             */
            val selectedItem =
                adapter.getItem(position)

            // 記錄目前選取位置並重新整理 GridView
            adapter.setSelectedPosition(position)

            // 更新下方圖片預覽
            imageViewPic.setImageResource(
                selectedItem.pictureResId
            )

            // 顯示目前選取的花朵名稱
            Toast.makeText(
                this@MainActivity,
                "你選擇了:${selectedItem.name}",
                Toast.LENGTH_SHORT
            ).show()

            // 輸出點擊資訊,方便除錯
            Log.d(
                "MainActivity",
                "position = $position, item = $selectedItem"
            )
        }
    }
}

這裡將原本的 0..nameArray.size - 1 改為 nameArray.indices。後者可以直接取得陣列的有效索引範圍,可讀性較高,也不需要自行進行 size - 1 計算。
參考資料:https://kotlinlang.org/api/core/kotlin-stdlib/kotlin.collections/indices.html


九、點擊事件的差異

重構前需要進行強制轉型:

val item =
    parent?.getItemAtPosition(position)
        as MutableMap<String, Any>

imageViewPic.setImageResource(
    item["pic"] as Int
)

重構後可以直接取得正確型別:

val selectedItem =
    adapter.getItem(position)

imageViewPic.setImageResource(
    selectedItem.pictureResId
)

新版程式不需要 as MutableMap<String, Any>,也不需要 as Int。如果誤寫不存在的欄位,例如 selectedItem.picture,編譯器會直接顯示錯誤,不會等到 App 執行後才當掉。
參考資料:https://kotlinlang.org/docs/typecasts.html


十、data class 的 copy 功能

data class 還會自動提供 copy()。如果未來需要修改其中一筆資料,可以保留原本資料,只改變指定欄位:

val originalItem = FlowerItem(
    name = "向日葵",
    pictureResId = R.drawable.flower2
)

val updatedItem = originalItem.copy(
    name = "黃色向日葵"
)

updatedItem 會保留原本的 pictureResId,只將名稱改成「黃色向日葵」。這種寫法在不可變資料、狀態管理及 Jetpack Compose 中特別常見。
參考資料:https://kotlinlang.org/docs/data-classes.html#copying


十一、data class 的解構功能

data class 也可以進行解構宣告:

val selectedItem = adapter.getItem(position)

val (
    flowerName,
    flowerPicture
) = selectedItem

解構之後便可以分別使用兩個值:

imageViewPic.setImageResource(
    flowerPicture
)

Toast.makeText(
    this,
    "你選擇了:$flowerName",
    Toast.LENGTH_SHORT
).show()

Kotlin 會為 data class 依照建構子的屬性順序產生 component1()component2() 等函式,讓物件可以被解構成多個變數。
參考資料:https://kotlinlang.org/docs/destructuring-declarations.html


十二、完整專案檔案結構

app
└── src
    └── main
        ├── java
        │   └── com.example.listview6
        │       ├── MainActivity.kt
        │       ├── MyAdapter.kt
        │       └── FlowerItem.kt
        │
        └── res
            ├── drawable
            │   ├── flower1.png
            │   ├── flower2.png
            │   ├── flower3.png
            │   ├── flower4.png
            │   ├── flower5.png
            │   ├── flower6.png
            │   ├── grid_item_normal.xml
            │   └── grid_item_selected.xml
            │
            ├── layout
            │   ├── activity_main.xml
            │   └── item_layout.xml
            │
            └── values
                ├── arrays.xml
                └── strings.xml

將資料模型獨立放在 FlowerItem.kt,可以讓 Activity 專心負責畫面流程,Adapter 專心負責資料綁定,資料類別則專心描述資料結構。
參考資料:https://developer.android.com/topic/architecture


十三、執行結果

重構後的 App 外觀與昨天類似,但內部程式已經更加安全。點擊任一花朵後,該項目會顯示綠色外框,下方切換成對應的大圖,並透過 Toast 顯示花朵名稱。
參考資料:https://developer.android.com/reference/android/widget/Toast

這次重構最大的價值並不是新增多少畫面,而是讓錯誤可以在編譯階段被發現。程式規模愈大,明確的資料模型就愈重要。
參考資料:https://kotlinlang.org/docs/data-classes.html


十四、今日重點整理

今天的重點如下:

  1. MutableMap<String, Any> 雖然彈性高,但缺乏型別安全。
  2. data class 可以明確定義資料欄位與型別。
  3. @DrawableRes 可標記圖片資源 ID。
  4. 使用 item.name 取代 item["name"]
  5. 使用 item.pictureResId 取代 item["pic"] as Int
  6. 使用 indices 取得陣列的有效索引。
  7. 使用 selectedPosition 保存目前選取位置。
  8. 呼叫 notifyDataSetChanged() 更新畫面。
  9. 使用 Shape Drawable 建立選取外框。
  10. copy() 可以產生修改部分欄位的新物件。

參考資料:https://kotlinlang.org/docs/data-classes.html


結語

昨天學的是「如何把資料顯示在 GridView」,今天學的是「如何把資料設計得更安全」。這兩件事看起來差不多,但後者才是專案逐漸變大時,能否持續維護的重要關鍵。
參考資料:https://developer.android.com/topic/architecture

透過 FlowerItem,我們成功移除了容易出錯的字串 Key 與強制型別轉換,也加入點擊選取狀態。下一步可以繼續把這個傳統 GridView 範例升級成 RecyclerView+GridLayoutManager,正式進入目前更常見的清單架構。
參考資料:https://developer.android.com/develop/ui/views/layout/recyclerview


上一篇
GridView 自訂 Adapter:用 ViewHolder 打造圖片選擇器
下一篇
RecyclerView × Room:Android 清單顯示與本機資料庫 CRUD
系列文
Android 鐵人賽: 天命最高 - 陪伴大家一步步打造屬於自己的app30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言