之前有使用過 ListView 顯示直向清單,今天進一步改用 GridView,把圖片與名稱排列成網格。使用者點選其中一個項目後,畫面下方的 ImageView 便會立即顯示被選取的大圖。本篇也會實作自訂 BaseAdapter,並利用 ViewHolder 與 convertView 改善捲動效能。
參考資料:https://developer.android.com/reference/android/widget/GridView
今天的 App 包含以下功能:
strings.xml 讀取名稱陣列。MyAdapter 顯示每個網格項目。ViewHolder 快取元件。GridView 項目後,更新下方的大圖。TypedArray.recycle() 釋放資源。GridView 是一種二維、可捲動的網格元件,每一格的畫面內容由 Adapter 提供,因此非常適合製作相簿、商品列表、功能選單或縣市小吃圖片牆。
參考資料:https://developer.android.com/reference/android/widget/GridView
今天的資料顯示流程如下:
flowchart TD
A["字串與圖片資源陣列"] --> B["建立 listData"]
B --> C["建立 MyAdapter"]
C --> D["綁定 GridView"]
D --> E["MyAdapter.getView()"]
E --> F["顯示圖片與名稱"]
F --> G["使用者點選項目"]
G --> H["更新下方 ImageView 大圖"]
Adapter 的主要工作是充當「資料」與「畫面元件」之間的橋梁。GridView 不需要知道資料從哪裡來,只會向 Adapter 詢問資料數量、指定位置的資料,以及該位置應該顯示的 View。
參考資料:https://developer.android.com/reference/android/widget/Adapter

這次使用 MutableList<MutableMap<String, Any>> 保存網格資料,每一筆資料包含兩個欄位:
| Key | 資料內容 | 資料型別 |
|---|---|---|
name |
圖片名稱 | String |
pic |
Drawable 圖片資源 ID | Int |
程式中的一筆資料概念如下:
val data = mutableMapOf<String, Any>()
data["name"] = "第一張圖片"
data["pic"] = R.drawable.flower1
因為同一個 Map 中同時存放 String 和 Int,所以 Value 型別宣告為 Any。取出資料時,必須再轉換成正確型別,例如 item["pic"] as Int。
參考資料:https://kotlinlang.org/docs/collections-overview.html
MainActivity 負責取得畫面元件、載入名稱與圖片資源、建立資料清單、設定 Adapter,以及處理項目點擊事件。
參考資料:https://developer.android.com/reference/android/app/Activity
// 原始範例參考:
// https://chatgpt.com/s/m_6a840fa4d51881918ba37cc3810692e5
package com.example.listview6
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.AdapterView
import android.widget.GridView
import android.widget.ImageView
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,讓 App 畫面可以延伸至系統狀態列與導覽列區域
enableEdgeToEdge()
// 載入主畫面 XML
setContentView(R.layout.activity_main)
// 取得系統狀態列與導覽列的 Insets,
// 並設定為主畫面的 Padding,避免內容被系統列遮住
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
}
// Teacher Version
// 取得畫面中的 GridView
val gridViewData =
findViewById<GridView>(R.id.gridView_id)
// 取得用來顯示選取結果的大圖 ImageView
val imageViewPic =
findViewById<ImageView>(R.id.ImageView_pic)
// 從 strings.xml 取得名稱字串陣列
val nameArray =
resources.getStringArray(R.array.name)
// 從資源陣列取得圖片資源
val picArray =
resources.obtainTypedArray(R.array.picture)
// 建立 GridView 使用的資料清單
// 每一筆 MutableMap 都會保存 name 與 pic
val listData =
mutableListOf<MutableMap<String, Any>>()
// 依照名稱陣列的數量逐筆建立資料
for (i in 0..nameArray.size - 1) {
// 建立一筆可修改的 Map
val data =
mutableMapOf<String, Any>()
// 放入圖片名稱
data["name"] = nameArray[i]
// 從圖片資源陣列取得指定位置的 Drawable 資源 ID
// 如果該位置無法取得圖片,就使用 flower1 作為預設圖片
data["pic"] =
picArray.getResourceId(
i,
R.drawable.flower1
)
// 將這筆資料加入總清單
listData.add(data)
}
// TypedArray 使用完畢後必須回收,
// 讓系統可以重新使用其內部資源
picArray.recycle()
// 在 Logcat 輸出建立完成的資料,方便除錯
Log.d(
"main",
"list data = $listData"
)
/**
* 建立自訂 MyAdapter。
*
* this@MainActivity:
* 提供 Adapter 所需的 Context,
* 讓 Adapter 可以取得版面配置與其他資源。
*
* listData:
* GridView 的資料來源,
* 每一筆資料都包含圖片名稱與圖片資源 ID。
*/
val adapter = MyAdapter(
myContext = this@MainActivity,
listData = listData
)
// 將自訂 Adapter 指定給 GridView
gridViewData.adapter = adapter
// 設定 GridView 項目的點擊監聽器
gridViewData.onItemClickListener =
object : AdapterView.OnItemClickListener {
override fun onItemClick(
parent: AdapterView<*>?,
view: View?,
position: Int,
id: Long
) {
// 根據被點選的位置,
// 從 Adapter 取得該格對應的資料
val item =
parent?.getItemAtPosition(position)
as MutableMap<String, Any>
// 取出圖片資源 ID,
// 並更新下方的大圖 ImageView
imageViewPic.setImageResource(
item["pic"] as Int
)
}
}
}
}
resources.getStringArray() 可以讀取 <string-array>;resources.obtainTypedArray() 則可以讀取包含 Drawable 資源的陣列。由於 TypedArray 是共用的系統資源,使用完畢後應呼叫 recycle()。
參考資料:https://developer.android.com/reference/android/content/res/Resources
名稱陣列通常可以放在:
app/src/main/res/values/strings.xml
範例:
<resources>
<string name="app_name">GridViewDemo</string>
<!-- GridView 顯示的名稱陣列 -->
<string-array name="name">
<item>花朵 1</item>
<item>花朵 2</item>
<item>花朵 3</item>
<item>花朵 4</item>
<item>花朵 5</item>
<item>花朵 6</item>
</string-array>
</resources>
將文字放入字串資源,可以讓畫面文字與程式邏輯分離,也方便日後製作繁體中文、英文或日文等多語系版本。
參考資料:https://developer.android.com/guide/topics/resources/string-resource
圖片陣列可以建立在:
app/src/main/res/values/arrays.xml
完整範例:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- GridView 使用的圖片資源陣列 -->
<array name="picture">
<item>@drawable/flower1</item>
<item>@drawable/flower2</item>
<item>@drawable/flower3</item>
<item>@drawable/flower4</item>
<item>@drawable/flower5</item>
<item>@drawable/flower6</item>
</array>
</resources>
名稱陣列與圖片陣列的位置必須互相對應,例如 name[0] 對應 picture[0]。如果兩個陣列的數量不一致,可能取得預設圖片,或因索引位置超出範圍而發生錯誤。
參考資料:https://developer.android.com/guide/topics/resources/more-resources#TypedArray
Android 已經提供 ArrayAdapter、SimpleAdapter 等現成配接器,但今天的每一格同時包含一張圖片與一段文字,而且還要控制 View 的重用,因此自行繼承 BaseAdapter 會有比較高的自由度。
參考資料:https://developer.android.com/reference/android/widget/BaseAdapter
繼承 BaseAdapter 後,主要必須實作以下四個方法:
| 方法 | 功能 |
|---|---|
getCount() |
回傳資料總筆數 |
getItem(position) |
回傳指定位置的資料 |
getItemId(position) |
回傳指定位置的 ID |
getView(position, convertView, parent) |
建立或重用每個項目的 View |
其中最重要的是 getView()。每當 GridView 準備顯示某一格時,就會呼叫它取得該位置所需要的 View。
參考資料:https://developer.android.com/reference/android/widget/Adapter#getView(int,%20android.view.View,%20android.view.ViewGroup)
以下是今天使用的完整自訂 Adapter,程式中已保留並加強 ViewHolder 與 View 重用機制的註解。
參考資料: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.TextView
/**
* 自訂配接器 MyAdapter。
*
* 功能:
* 將 listData 中的名稱與圖片資料,
* 綁定到 item_layout.xml 的 ImageView 與 TextView。
*/
class MyAdapter(
private val myContext: Context,
private val listData: MutableList<MutableMap<String, Any>>
) : BaseAdapter() {
/**
* 回傳資料總筆數。
*
* GridView 會依照這個數字,
* 判斷總共有多少個項目需要顯示。
*/
override fun getCount(): Int {
return listData.size
}
/**
* 取得指定位置的資料物件。
*
* position:
* 使用者點選或 GridView 要求顯示的資料位置。
*/
override fun getItem(position: Int): Any? {
return listData[position]
}
/**
* 取得指定位置的項目 ID。
*
* 目前沒有資料庫的固定 ID,
* 所以直接將 position 轉換為 Long。
*/
override fun getItemId(position: Int): Long {
return position.toLong()
}
/**
* 建立或重用 GridView 中的單一項目 View。
*
* position:
* 目前要顯示的資料位置。
*
* convertView:
* 系統提供的舊 View。
* 如果不為 null,代表可以直接重複利用。
*
* parent:
* 這個項目最後會加入的父容器,也就是 GridView。
*/
override fun getView(
position: Int,
convertView: View?,
parent: ViewGroup?
): View {
// 最後要回傳給 GridView 的項目畫面
val view: View
// 保存 ImageView 與 TextView 參照的 ViewHolder
val holder: ViewHolder
if (convertView == null) {
// convertView 為 null:
// 目前沒有可以重複使用的項目畫面,
// 因此需要載入新的 item_layout.xml
view = LayoutInflater
.from(myContext)
.inflate(
R.layout.item_layout,
parent,
false
)
// 只在建立新 View 時呼叫 findViewById
holder = ViewHolder(
itemImageView =
view.findViewById(
R.id.imageView_itemPic
),
itemTextView =
view.findViewById(
R.id.textView_itemName
)
)
// 將 ViewHolder 保存到 View 的 tag 中
// 下次重用這個 View 時,就不需要再次 findViewById
view.tag = holder
} else {
// convertView 不為 null:
// 表示系統提供了一個離開畫面的舊 View,
// 可以直接拿來顯示新的資料
view = convertView
// 從 tag 取回之前保存的 ViewHolder
holder = view.tag as ViewHolder
}
// 取得目前 position 對應的資料
val item = listData[position]
// 將名稱設定到 TextView
holder.itemTextView.text =
item["name"].toString()
// 將圖片資源 ID 設定到 ImageView
holder.itemImageView.setImageResource(
item["pic"] as Int
)
// 將完成資料綁定的 View 回傳給 GridView
return view
}
/**
* ViewHolder 用來保存 item_layout 中的元件參照。
*
* 這樣可以避免 GridView 捲動時,
* 不斷重複呼叫 findViewById。
*/
private class ViewHolder(
val itemImageView: ImageView,
val itemTextView: TextView
)
}
LayoutInflater.inflate() 會把 item_layout.xml 轉換為真正的 View 物件。第三個參數設定為 false,代表目前先不要直接加入父容器,後續會由 GridView 自行管理。
參考資料:https://developer.android.com/reference/android/view/LayoutInflater
ViewHolder 的主要目的,是保存每一格中 ImageView 與 TextView 的參照。第一次建立項目時執行 findViewById(),之後把 ViewHolder 放進 view.tag;當 View 被重用時,再從 tag 把它取出來。
參考資料:https://developer.android.com/reference/android/view/View#setTag(java.lang.Object)
核心程式如下:
if (convertView == null) {
view = LayoutInflater
.from(myContext)
.inflate(R.layout.item_layout, parent, false)
holder = ViewHolder(
itemImageView =
view.findViewById(R.id.imageView_itemPic),
itemTextView =
view.findViewById(R.id.textView_itemName)
)
view.tag = holder
} else {
view = convertView
holder = view.tag as ViewHolder
}
如果沒有使用 ViewHolder,getView() 每次被呼叫時都必須重新執行 findViewById()。資料不多時可能感覺不明顯,但大量圖片快速捲動時,重複尋找元件會增加處理負擔。
參考資料:https://developer.android.com/reference/android/view/View#findViewById(int)
假設畫面一次只能顯示六個項目,即使資料有一百筆,Android 也不需要一次建立一百個 View。當最上方項目滑出螢幕後,它的 View 可以透過 convertView 交給後面即將進入畫面的項目重複使用。
參考資料:https://developer.android.com/reference/android/widget/Adapter#getView(int,%20android.view.View,%20android.view.ViewGroup)
流程可以整理成:
flowchart TD
A["呼叫 getView()"] --> B{"convertView 是否為 null?"}
B -- "是" --> C["載入 item_layout"]
C --> D["建立 ViewHolder"]
D --> E["保存到 view.tag"]
B -- "否" --> F["重用 convertView"]
F --> G["從 view.tag 取回 ViewHolder"]
E --> H["綁定目前 position 的資料"]
G --> H
H --> I["回傳 View"]
這種設計可以減少 View 建立次數,也可以減少 findViewById() 的執行次數,讓傳統 ListView 或 GridView 的捲動更加順暢。
參考資料:https://developer.android.com/reference/android/view/View#getTag()
每一個網格項目需要一個版面配置,檔案可以建立在:
app/src/main/res/layout/item_layout.xml
完整範例:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
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:text="圖片名稱"
android:textColor="#212121"
android:textSize="16sp" />
</LinearLayout>
ImageView 的 scaleType="centerCrop" 會維持圖片比例並填滿顯示範圍,超出的部分會被裁切;如果希望完整顯示圖片,可以改成 fitCenter。
參考資料:https://developer.android.com/reference/android/widget/ImageView.ScaleType
主畫面上方放置 GridView,下方放置顯示選取結果的 ImageView。
參考資料:https://developer.android.com/develop/ui/views/layout/declaring-layout
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<!-- 顯示圖片與名稱的網格 -->
<GridView
android:id="@+id/gridView_id"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:columnWidth="120dp"
android:gravity="center"
android:horizontalSpacing="8dp"
android:numColumns="auto_fit"
android:stretchMode="columnWidth"
android:verticalSpacing="8dp" />
<!-- 顯示目前選取的大圖 -->
<ImageView
android:id="@+id/ImageView_pic"
android:layout_width="match_parent"
android:layout_height="220dp"
android:layout_marginTop="12dp"
android:contentDescription="@string/app_name"
android:scaleType="fitCenter"
android:src="@drawable/flower1" />
</LinearLayout>
android:numColumns="auto_fit" 會依照畫面寬度及 columnWidth 自動計算欄數,因此在不同尺寸的手機或平板上,網格可以自行調整。
參考資料:https://developer.android.com/reference/android/widget/GridView#attr_android:numColumns
GridView 的單一項目點擊事件可透過 onItemClickListener 處理:
gridViewData.onItemClickListener =
object : AdapterView.OnItemClickListener {
override fun onItemClick(
parent: AdapterView<*>?,
view: View?,
position: Int,
id: Long
) {
val item =
parent?.getItemAtPosition(position)
as MutableMap<String, Any>
imageViewPic.setImageResource(
item["pic"] as Int
)
}
}
position 是使用者點選的資料位置,從零開始計算。程式透過 getItemAtPosition(position) 取得對應資料,再將 pic 轉換成圖片資源 ID,最後交給 setImageResource() 更新大圖。
參考資料:https://developer.android.com/reference/android/widget/AdapterView.OnItemClickListener
以下程式會取得圖片資源陣列:
val picArray =
resources.obtainTypedArray(R.array.picture)
使用完成後呼叫:
picArray.recycle()
TypedArray 內部會使用由系統維護的共用資源。呼叫 recycle() 後,該物件便可以重新被系統利用;回收後不能繼續讀取裡面的內容,因此必須先把圖片資源 ID 存入 listData,最後才能回收。
參考資料:https://developer.android.com/reference/android/content/res/TypedArray#recycle()
目前使用 MutableMap<String, Any> 儲存資料,雖然彈性很高,但取出圖片時需要手動執行 as Int。如果 Key 寫錯或資料型別不正確,就可能在執行階段發生 ClassCastException。
參考資料:https://kotlinlang.org/docs/typecasts.html
較安全的方式是建立資料類別:
/**
* GridView 每一格所使用的資料模型。
*/
data class GridItem(
val name: String,
val pictureResId: Int
)
資料清單便可以改成:
val listData = mutableListOf<GridItem>()
for (i in nameArray.indices) {
listData.add(
GridItem(
name = nameArray[i],
pictureResId = picArray.getResourceId(
i,
R.drawable.flower1
)
)
)
}
使用 data class 後,資料欄位具有明確型別,不需要依靠 "name"、"pic" 等字串 Key,也不必頻繁執行型別轉換,程式會更安全且更容易維護。
參考資料:https://kotlinlang.org/docs/data-classes.html
今天從一般清單前進到圖文網格,完成了 GridView 與自訂 BaseAdapter 的整合。真正重要的並不只是把圖片顯示出來,而是理解 Adapter 如何根據 position 提供資料,以及 convertView 如何回收離開螢幕的 View。
參考資料:https://developer.android.com/reference/android/widget/AdapterView
本日核心觀念如下:
GridView 負責排列網格。BaseAdapter 負責提供資料與每格畫面。getView() 負責建立或重用項目 View。convertView 是可以重複使用的舊 View。ViewHolder 快取元件參照。view.tag 保存並取回 ViewHolder。onItemClickListener 處理格子點擊。TypedArray.recycle() 回收圖片陣列資源。這套概念不只適用於圖片選擇器,也可以延伸成商品列表、相簿、餐點選單、縣市小吃圖鑑與功能入口選單。雖然新專案通常會優先採用 RecyclerView 或 Jetpack Compose,但理解 GridView 的 Adapter 與 View 回收機制,仍然有助於建立 Android 清單元件的基礎觀念。
參考資料:https://developer.android.com/develop/ui/views/layout/recyclerview
透過今天的實作,我們已經將「資料陣列 → 自訂 Adapter → GridView → 使用者點擊 → 更新大圖」串成完整互動流程,也進一步認識 ViewHolder 與 convertView 的效能優化方式。下一步可以將資料改為型別安全的 data class,再進一步升級成 RecyclerView + GridLayoutManager。
參考資料:https://developer.android.com/develop/ui/views/layout/recyclerview#customizing
完整官方參考資料: