Day 21 我們練習了 Android 的 Menu 與 Navigation;Day 22 的內容明顯再往前跨了一大步。
今天的教材其實可以串成一條很完整的 Android 學習路線:
ListView 顯示資料 → Intent 傳遞 → Sensor 感知環境 → GridView + 自訂 Adapter → ViewHolder 效能最佳化 → 自訂 Spinner → SQLite CRUD。

Day 22 可以分成五大實作:
Day 22
│
├─ ① ListView
│ ├─ 花朵 / 貓咪資料
│ ├─ SimpleAdapter
│ └─ Intent → DisplayActivity
│
├─ ② Android Sensor
│ ├─ Light 光線感測器
│ ├─ Proximity 接近感測器
│ └─ Accelerometer 加速度感測器
│ └─ OpenGL ES 3D 地球
│
├─ ③ GridView
│ ├─ BaseAdapter
│ ├─ MyAdapter
│ └─ ViewHolder
│
├─ ④ Spinner
│ ├─ SpinnerItem
│ └─ 自訂 ArrayAdapter
│
└─ ⑤ SQLite
├─ INSERT
├─ SELECT
├─ UPDATE
└─ DELETE
這已經不是單一 Widget 的練習,而是開始碰到 Android App 很重要的三個核心:
資料怎麼顯示?裝置資料怎麼取得?資料怎麼永久保存?
今天第一個範例先從熟悉的 ListView 開始。
主畫面有:
private lateinit var listViewData: ListView
private lateinit var btnFlower: Button
private lateinit var btnCat: Button
啟動 App 後預設:
// 預設載入花朵資料
loadData("flower")
使用者可以按:
[ FLOWER ] [ CAT ]
切換資料。
按 Flower:
btnFlower.setOnClickListener {
loadData("flower")
}
按 Cat:
btnCat.setOnClickListener {
loadData("cat")
}
因此同一個 ListView 不需要建立兩套畫面,只要改變 Adapter 的資料來源即可。
loadData() 先判斷資料類型:
private fun loadData(type: String) {
val nameArray: Array<String>
val picArray: TypedArray
if (type == "flower") {
nameArray =
resources.getStringArray(
R.array.flower_names
)
picArray =
resources.obtainTypedArray(
R.array.flower_images
)
} else {
nameArray =
resources.getStringArray(
R.array.cat_names
)
picArray =
resources.obtainTypedArray(
R.array.cat_images
)
}
這裡開始把資料與程式分開。
概念是:
strings.xml / arrays.xml
│
├── flower_names
├── flower_images
├── cat_names
└── cat_images
↓
loadData()
↓
ListView
接著建立:
val listData =
mutableListOf<Map<String, Any>>()
每一筆資料包含:
name → 名稱
pic → Drawable Resource ID
程式:
for (i in nameArray.indices) {
val data =
mutableMapOf<String, Any>()
data["name"] =
nameArray[i]
data["pic"] =
picArray.getResourceId(
i,
R.drawable.flower1
)
listData.add(data)
}
// ★ TypedArray 使用完畢後釋放
picArray.recycle()
因此資料會類似:
[
{
name = "Flower 1",
pic = R.drawable.flower1
},
{
name = "Flower 2",
pic = R.drawable.flower2
}
]
今天第一種 Adapter 使用:
val adapter =
SimpleAdapter(
this,
listData,
R.layout.item_layout,
arrayOf(
"name",
"pic"
),
intArrayOf(
R.id.textView_itemName,
R.id.imageView_itemPic
)
)
listViewData.adapter = adapter
整個關係非常重要:
listData
│
├─ name ─────→ textView_itemName
│
└─ pic ──────→ imageView_itemPic
↓
item_layout.xml
↓
ListView
Adapter 就像資料與 UI 之間的橋樑。
今天並沒有停在「顯示列表」。
點擊某一筆資料後:
listViewData.setOnItemClickListener {
_, _, position, _ ->
val flowerName =
listData[position]["name"]
as String
val flowerPic =
listData[position]["pic"]
as Int
val intent =
Intent(
this,
DisplayActivity::class.java
).apply {
putExtra(
"FLOWER_NAME",
flowerName
)
putExtra(
"FLOWER_PIC",
flowerPic
)
}
startActivity(intent)
}
流程:
ListView
↓
點 Flower / Cat
↓
取得 position
↓
name + pic
↓
Intent.putExtra()
↓
DisplayActivity
DisplayActivity 再接收:
val flowerName =
intent.getStringExtra(
"FLOWER_NAME"
)
val flowerPic =
intent.getIntExtra(
"FLOWER_PIC",
R.drawable.flower1
)
顯示:
textView.text = flowerName
imageView.setImageResource(
flowerPic
)
最後 Back:
buttonBack.setOnClickListener {
finish()
}
這正好把前面學過的 Intent 和今天的 ListView 串起來。
第二部分開始進入今天非常有趣的主題:
程式先取得:
sensorManager =
getSystemService(
SENSOR_SERVICE
) as SensorManager
再取得手機支援的所有 Sensor:
val sensorData =
sensorManager.getSensorList(
Sensor.TYPE_ALL
)
然後把:
sensor.type
sensor.name
sensor.vendor
加入 List:
for (sensor in sensorData) {
listData.add(
"${sensor.type} : " +
"${sensor.name} - " +
"${sensor.vendor}"
)
}
甚至直接把 Sensor 數量放在標題:
title =
"Sensor number : ${listData.size}"
所以不同手機執行,結果可能不同。
點擊 Sensor 後:
val sensor =
sensorData.get(position)
when (sensor.type) {
Sensor.TYPE_LIGHT -> {
val intent =
Intent(
this@MainActivity,
LightActivity::class.java
)
startActivity(intent)
}
Sensor.TYPE_PROXIMITY -> {
val intent =
Intent(
this@MainActivity,
ProxiActivity::class.java
)
startActivity(intent)
}
Sensor.TYPE_ACCELEROMETER -> {
val intent =
Intent(
this@MainActivity,
AccActivity::class.java
)
startActivity(intent)
}
}
因此:
Sensor List
│
├── LIGHT
│ ↓
│ LightActivity
│
├── PROXIMITY
│ ↓
│ ProxiActivity
│
└── ACCELEROMETER
↓
AccActivity
LightActivity 使用:
lightSensor =
sensorManager.getDefaultSensor(
Sensor.TYPE_LIGHT
)
在 Activity 回到前景時開始監聽:
override fun onResume() {
super.onResume()
lightSensor?.let {
sensorManager.registerListener(
this,
it,
SensorManager.SENSOR_DELAY_NORMAL
)
}
}
離開時停止:
override fun onPause() {
super.onPause()
sensorManager.unregisterListener(
this
)
}
這個觀念很重要:
onResume()
↓
開始監聽 Sensor
onPause()
↓
停止監聽 Sensor
不要讓 App 離開畫面後還一直收 Sensor 資料。
Sensor 數值改變:
override fun onSensorChanged(
event: SensorEvent?
) {
val value =
event?.values?.get(0)
val data =
"Lux value = " +
"${event?.values?.get(0)}"
textViewData.text = data
接著教材設定:
if (value != null) {
if (value < 50) {
imageViewLight.setImageResource(
R.drawable.dark_light
)
} else {
imageViewLight.setImageResource(
R.drawable.light_light
)
}
}
因此今天這個範例的判斷邏輯是:
Lux
│
value < 50 ?
↙ ↘
YES NO
↓ ↓
dark_light light_light
↓ ↓
暗圖 亮圖
📸 【圖 1:Light Sensor Lux 值與亮/暗圖片切換】
另一個 Sensor:
Sensor.TYPE_PROXIMITY
取得方式:
proxiSensor =
sensorManager.getDefaultSensor(
Sensor.TYPE_PROXIMITY
)
程式還先檢查手機到底有沒有:
if (proxiSensor == null) {
textViewProxiData.text =
"此手機沒有 Proximity Sensor"
} else {
textViewProxiData.text =
"感測器:${proxiSensor?.name}\n" +
"Maximum Range:" +
"${proxiSensor?.maximumRange}"
}
這點很重要,因為不是所有 Android 裝置都一定具有相同感測器。
取得:
val distance =
event.values[0]
val maxRange =
event.sensor.maximumRange
今天程式的判斷:
val isNear =
distance < maxRange
接近:
if (isNear) {
imageViewProxiPic.setImageResource(
android.R.drawable.presence_video_busy
)
imageViewProxiPic.scaleX = 1.5f
imageViewProxiPic.scaleY = 1.5f
textViewProxiData.text =
"狀態:靠近 (Near)\n" +
"Sensor Value:$distance\n" +
"Maximum Range:$maxRange"
}
遠離:
else {
imageViewProxiPic.setImageResource(
android.R.drawable.presence_video_online
)
imageViewProxiPic.scaleX = 1.0f
imageViewProxiPic.scaleY = 1.0f
textViewProxiData.text =
"狀態:遠離 (Far)\n" +
"Sensor Value:$distance\n" +
"Maximum Range:$maxRange"
}
視覺上就是:
手離開手機
↓
Far
↓
1.0 倍
手靠近手機
↓
Near
↓
1.5 倍
📸 【圖 2:Proximity Sensor Near / Far 實機效果】
接下來是:
Sensor.TYPE_ACCELEROMETER
也就是加速度感測器。
取得:
accSensor =
sensorManager.getDefaultSensor(
Sensor.TYPE_ACCELEROMETER
)
當數值改變:
val x = event.values[0]
val y = event.values[1]
val z = event.values[2]
畫面顯示:
textViewAccData.text =
String.format(
Locale.US,
"X: %7.2f\n" +
"Y: %7.2f\n" +
"Z: %7.2f",
x,
y,
z
)
📸 【圖 3:使用你提供的「三維空間的方向與移動」圖】
你提供的三維方向圖非常適合放在這一段。

可以先用最基本的概念理解:
X → 左右
Y → 上下
Z → 前後/垂直螢幕方向
在今天程式中,三軸數值不只是顯示出來,還拿去控制 UI。
例如:
imageViewAccPic.rotation =
-x * 5
X 改變會影響圖片旋轉。
Z 則拿來控制縮放:
var scaleFactor =
(z / 9.8f) + 0.5f
if (scaleFactor < 0.3f)
scaleFactor = 0.3f
if (scaleFactor > 2.0f)
scaleFactor = 2.0f
imageViewAccPic.scaleX =
scaleFactor
imageViewAccPic.scaleY =
scaleFactor
所以手機的實體動作開始直接變成 UI 動作。
今天還加入:
private lateinit var ivUp: ImageView
private lateinit var ivDown: ImageView
private lateinit var ivLeft: ImageView
private lateinit var ivRight: ImageView
private lateinit var ivZin: ImageView
private lateinit var ivZout: ImageView
並設定:
private val showThreshold =
4.0f
判斷:
private fun updateArrowVisibility(
x: Float,
y: Float,
z: Float
) {
ivLeft.alpha =
if (x > showThreshold)
1.0f
else
0.0f
ivRight.alpha =
if (x < -showThreshold)
1.0f
else
0.0f
ivUp.alpha =
if (y > showThreshold)
1.0f
else
0.0f
ivDown.alpha =
if (y < -showThreshold)
1.0f
else
0.0f
val zDiff =
z - 9.8f
ivZout.alpha =
if (zDiff > showThreshold)
1.0f
else
0.0f
ivZin.alpha =
if (zDiff < -showThreshold)
1.0f
else
0.0f
}
也就是把 Sensor 數值轉成:
X → LEFT / RIGHT
Y → UP / DOWN
Z → Z IN / Z OUT
今天這份 AccActivity 最特別的地方,是又加入:
GLSurfaceView
並設定:
glView.setEGLContextClientVersion(2)
renderer =
EarthRenderer(this)
glView.setRenderer(renderer)
glView.renderMode =
GLSurfaceView.RENDERMODE_CONTINUOUSLY
也就是:
程式自己建立一顆球:
createSphere(
1.2f,
30,
30
)
再載入:
textureId =
loadTexture(
context,
R.drawable.earth_map
)
把世界地圖貼到球體表面。
於是:
Sphere
+
earth_map
↓
3D Earth 🌍
Sensor 事件裡:
renderer.updateRotation(
x,
y
)
Renderer:
fun updateRotation(
x: Float,
y: Float
) {
rotationY -=
x * 0.5f
rotationX -=
y * 0.5f
}
繪製時:
Matrix.rotateM(
modelMatrix,
0,
rotationX,
1f,
0f,
0f
)
Matrix.rotateM(
modelMatrix,
0,
rotationY,
0f,
1f,
0f
)
所以資料流非常漂亮:
手機實體移動
↓
Accelerometer
↓
X / Y / Z
↓
onSensorChanged()
↓
updateRotation()
↓
rotationX / rotationY
↓
OpenGL Matrix
↓
3D 地球旋轉 🌍
這已經不只是一般 Android Widget,而是開始碰到 感測器 + 3D Graphics 的整合。
下一個範例使用:
GridView
資料仍然是:
val nameArray =
resources.getStringArray(
R.array.name
)
val picArray =
resources.obtainTypedArray(
R.array.picture
)
建立:
val listData =
mutableListOf<
MutableMap<String, Any>
>()
然後:
for (i in 0..nameArray.size - 1) {
val data =
mutableMapOf<String, Any>()
data.put(
"name",
nameArray[i]
)
data.put(
"pic",
picArray.getResourceId(
i,
R.drawable.flower1
)
)
listData.add(data)
}
picArray.recycle()
不同的是,這次不使用 SimpleAdapter。
而是:
val adapter =
MyAdapter(
myContext =
this@MainActivity,
listData =
listData
)
gridViewData.adapter =
adapter
因為我們開始想更精細地控制:
資料
↓
item_layout
↓
View
所以:
class MyAdapter(
private val myContext: Context,
private val listData:
MutableList<
MutableMap<String, Any>
>
) : BaseAdapter()
必須實作:
override fun getCount(): Int
override fun getItem(
position: Int
): Any?
override fun getItemId(
position: Int
): Long
override fun getView(
position: Int,
convertView: View?,
parent: ViewGroup?
): View?
其中最重要的是:
getView()因為每一個 Grid item 都是在這裡建立與綁定資料。
如果每次 getView() 都:
view.findViewById<ImageView>(
R.id.imageView_itemPic
)
view.findViewById<TextView>(
R.id.textView_itemName
)
列表一直滑動時,就會重複尋找 View。
因此今天加入:
private class ViewHolder(
val itemImageView: ImageView,
val itemTextView: TextView
)
第一次:
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
}
view.tag = holder 到底是什麼?這張就是今天最重要的筆記之一:

第一次建立 View:
convertView == null
↓
inflate item_layout
↓
findViewById()
↓
建立 ViewHolder
↓
view.tag = holder
等這個 View 被回收再拿回來:
convertView != null
↓
不用重新 inflate
↓
不用重新 findViewById
↓
holder = view.tag as ViewHolder
↓
直接使用
最重要的一句就是:
view.tag = holder
可以把它想成:
在 View 身上貼一張標籤,標籤裡保存這個 View 對應的 Holder。
另一張圖把效能差異畫得更清楚:

沒有 Holder:
Item 1 → findViewById()
Item 2 → findViewById()
Item 3 → findViewById()
Item 4 → findViewById()
...
使用 Holder:
第一次
↓
findViewById()
↓
Holder
↓
存起來
之後
↓
直接拿 Holder
↓
綁資料
今天教材中的生活化概念也很好理解:
沒有 Holder
= 每次都重新找工具
使用 Holder
= 先準備好工具箱
下次直接拿來使用
所以主要好處是:
findViewById()
convertView 重複使用 View這也是後來 RecyclerView 的 ViewHolder 思想中非常重要的基礎概念。今天的 MyAdapter 正是透過 view.tag 保存及取回 Holder。
GridView 點擊:
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
)
}
}
因此:
GridView
┌────┬────┬────┐
│ 🌷 │ 🌹 │ 🌻 │
├────┼────┼────┤
│ 🌼 │ 🌺 │ 🌸 │
└────┴────┴────┘
↓ 點擊
┌─────────────┐
│ 大圖片 │
└─────────────┘
下一個範例把 Spinner 升級。
先定義資料模型:
data class SpinnerItem(
val name: String,
val image: Int
)
一筆 Spinner 資料不再只是:
"Ice Cream"
而是:
SpinnerItem
├── name
└── image
主畫面:
val nameArray =
resources.getStringArray(
R.array.nameArray
)
val picArray =
resources.obtainTypedArray(
R.array.picArray
)
val dataList =
mutableListOf<SpinnerItem>()
接著:
for (i in 0 until nameArray.size) {
val resId =
picArray.getResourceId(
i,
R.drawable.icecream
)
val data =
SpinnerItem(
nameArray[i],
resId
)
dataList.add(data)
}
picArray.recycle()
今天又自己寫了一個 Adapter:
class SpinnerArrayAdapter(
val myContext: Context,
val listData: List<SpinnerItem>
) : ArrayAdapter<SpinnerItem>(
myContext,
0,
listData
)
一般顯示:
override fun getView(
position: Int,
convertView: View?,
parent: ViewGroup
): View {
val view =
LayoutInflater
.from(myContext)
.inflate(
R.layout.spinner_layout,
parent,
false
)
return bindData(
view,
position
)
}
下拉後:
override fun getDropDownView(
position: Int,
convertView: View?,
parent: ViewGroup
): View {
val view =
LayoutInflater
.from(myContext)
.inflate(
R.layout.spinner_dropdown_item,
parent,
false
)
return bindData(
view,
position
)
}
因此 Spinner:
未展開
┌────────────────┐
│ 🍦 Ice Cream ▼ │
└────────────────┘
展開
┌────────────────┐
│ 🍦 Ice Cream │
│ 🍰 Cake │
│ 🍩 Donut │
│ 🍪 Cookie │
└────────────────┘
設定:
val adapter =
SpinnerArrayAdapter(
this,
dataList
)
spinnerData.adapter =
adapter
點選:
spinnerData.onItemSelectedListener =
object :
AdapterView.OnItemSelectedListener {
override fun onItemSelected(
parent: AdapterView<*>?,
view: View?,
position: Int,
id: Long
) {
val item =
parent
?.getItemAtPosition(
position
) as SpinnerItem
textViewData.text =
"Select : ${item.name}"
}
override fun onNothingSelected(
parent: AdapterView<*>?
) {
}
}
資料流:
resources
↓
SpinnerItem
↓
List<SpinnerItem>
↓
SpinnerArrayAdapter
↓
Spinner
↓
position
↓
SpinnerItem
↓
item.name
Day 22 最後進入非常重要的:
前面資料都是:
App 開啟
↓
記憶體資料
↓
App 關掉
↓
資料可能消失
SQLite 則是:
App
↓
SQLite
↓
customer.db
↓
資料持久保存
建立:
class DBHelper(
myContext: Context
) : SQLiteOpenHelper(
myContext,
DATABASE_NAME,
null,
DATABASE_VERSION
)
設定:
companion object {
const val DATABASE_NAME =
"customer.db"
const val DATABASE_VERSION =
1
const val TABLE_NAME =
"user_table"
}
SQL:
CREATE TABLE user_table (
_id INTEGER PRIMARY KEY,
name TEXT,
email TEXT,
phone TEXT,
created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
資料表可以畫成:
user_table
┌─────┬───────┬─────────────┬──────────┬──────────────┐
│ _id │ name │ email │ phone │ created_time │
├─────┼───────┼─────────────┼──────────┼──────────────┤
│ 1 │ Alex │ ... │ ... │ ... │
│ 2 │ Amy │ ... │ ... │ ... │
└─────┴───────┴─────────────┴──────────┴──────────────┘
CRUD 是資料庫最基本的四個動作:
C → Create → INSERT
R → Read → SELECT
U → Update → UPDATE
D → Delete → DELETE
今天全部都有做到。
先取得:
val name =
etName.text.toString().trim()
val email =
etEmail.text.toString().trim()
val phone =
etPhone.text.toString().trim()
建立:
val cv =
ContentValues()
cv.put(
"name",
name
)
cv.put(
"email",
email
)
cv.put(
"phone",
phone
)
寫入:
val result =
db.insert(
DBHelper.TABLE_NAME,
null,
cv
)
成功:
if (result != -1L) {
Toast.makeText(
this,
"Success: Row ID $result",
Toast.LENGTH_SHORT
).show()
etName.text.clear()
etEmail.text.clear()
etPhone.text.clear()
// ★ 自動重新查詢畫面
btnSelect.performClick()
}
如果 Name 有輸入:
db.query(
DBHelper.TABLE_NAME,
null,
"name=?",
arrayOf(name),
null,
null,
null
)
如果沒有:
db.query(
DBHelper.TABLE_NAME,
null,
null,
null,
null,
null,
null
)
也就是:
Name 有值
↓
WHERE name=?
Name 空白
↓
SELECT ALL
查到的 Cursor 再交給:
SimpleCursorAdapter
顯示到 ListView。
程式:
val cv =
ContentValues()
cv.put(
"email",
email
)
cv.put(
"phone",
phone
)
val rows =
db.update(
DBHelper.TABLE_NAME,
cv,
"name=?",
arrayOf(name)
)
成功:
if (rows > 0) {
Toast.makeText(
this,
"Updated $name",
Toast.LENGTH_SHORT
).show()
btnSelect.performClick()
}
val deletedRows =
db.delete(
DBHelper.TABLE_NAME,
"name=?",
arrayOf(name)
)
成功後:
if (deletedRows > 0) {
Toast.makeText(
this,
"Deleted $name",
Toast.LENGTH_SHORT
).show()
etName.text.clear()
btnSelect.performClick()
}
因此:
SQLite CRUD
│
┌──────────┼──────────┐
↓ ↓ ↓
INSERT SELECT UPDATE
│ │ │
└──────────┼──────────┘
↓
DELETE
↓
customer.db
今天還不只是 MainActivity CRUD。
Menu:
R.id.menu_select_all -> {
val intent =
Intent(
this,
DataActivity::class.java
)
startActivity(intent)
true
}
進入:
Customer data
DataActivity 查詢:
SELECT
_id,
name,
email,
phone,
created_time
FROM user_table
接著利用:
SimpleCursorAdapter
把 Cursor 顯示成 ListView。
這個操作已經很接近真正的 CRUD App。
點一筆:
┌──────────────────────┐
│ ID: 1 │
│ Name: Alex │
│ Email: ... │
│ Phone: ... │
└──────────────────────┘
↓
點擊
↓
┌─────────────────────────┐
│ Update data, id = 1 │
│ │
│ Name [ Alex ] │
│ Email [ ... ] │
│ Phone [ ... ] │
│ │
│ CANCEL OK │
└─────────────────────────┘
先載入:
val dialogView =
layoutInflater.inflate(
R.layout.dialog_layout,
null
)
再把舊資料預填:
etName.setText(name)
etEmail.setText(email)
etPhone.setText(phone)
按 OK:
val cv =
ContentValues()
cv.put(
"name",
newName
)
cv.put(
"email",
newEmail
)
cv.put(
"phone",
newPhone
)
val count =
db.update(
DBHelper.TABLE_NAME,
cv,
"_id=?",
arrayOf(
recordId.toString()
)
)
更新時間:
val cmd =
"UPDATE ${DBHelper.TABLE_NAME} " +
"SET created_time=" +
"datetime('now', 'localtime') " +
"WHERE _id=$recordId"
db.execSQL(cmd)
最後換 Cursor:
adapter.changeCursor(
newDataCursor
)
adapter.notifyDataSetChanged()
畫面立即刷新。
把今天所有內容放在一起:
Android Day 22
│
┌──────────────────┼──────────────────┐
│ │ │
↓ ↓ ↓
資料顯示 裝置感知 資料保存
│ │ │
ListView SensorManager SQLite
│ │ │
SimpleAdapter ┌─────┼─────┐ DBHelper
│ ↓ ↓ ↓ │
Intent Light Proxi ACC user_table
│ │ │
DisplayActivity │ ┌───┼───┐
│ ↓ ↓ ↓
X/Y/Z INSERT SELECT
│ ↓ ↓
六方向箭頭 UPDATE DELETE
│
OpenGL ES
│
3D 地球
│
↓
手機動 → 地球動
GridView
│
MyAdapter
│
BaseAdapter
│
ViewHolder
│
view.tag = holder
│
↓
效能提升
Spinner
│
SpinnerItem
│
SpinnerArrayAdapter
│
↓
圖片 + 文字
今天從:
SimpleAdapter
走到:
BaseAdapter
再到:
ArrayAdapter<SpinnerItem>
其實都是同一件事情:
Data
↓
Adapter
↓
View
今天的這兩張圖非常重要:


記住一句就可以:
第一次找 View,把它放進 Holder;之後直接從
view.tag把 Holder 拿回來。
程式核心:
if (convertView == null) {
// ★ 第一次建立
view =
LayoutInflater
.from(myContext)
.inflate(
R.layout.item_layout,
parent,
false
)
holder =
ViewHolder(
view.findViewById(
R.id.imageView_itemPic
),
view.findViewById(
R.id.textView_itemName
)
)
// ★ 把 Holder 保存到 View
view.tag = holder
} else {
// ★ 重複利用 View
view = convertView
// ★ 不必再 findViewById
holder =
view.tag as ViewHolder
}
以前:
使用者按 Button
↓
App 有反應
今天:
光線改變
↓
App 有反應
手靠近
↓
App 有反應
手機傾斜
↓
App 有反應
尤其 Accelerometer 再串:
手機
↓
Sensor
↓
X / Y / Z
↓
OpenGL ES
↓
3D Earth 🌍
這就是今天最有意思的地方。
今天的內容很多,但如果把它拆開,其實可以看到 Android App 正逐漸完整起來。
最開始學的是:
TextView
Button
EditText
接著:
Activity
Intent
Menu
今天則進一步做到:
ListView / GridView
Adapter
ViewHolder
Spinner
Sensor
OpenGL ES
SQLite
我覺得今天最值得記住的是 ViewHolder。
以前看到:
view.tag = holder
會覺得很奇怪:
為什麼要把 holder 放進 tag?
實際理解 convertView 的回收機制之後,就知道它其實是在做:
第一次:
建立工具箱
↓
把 ImageView、TextView 放進去
↓
工具箱跟 View 綁在一起
下一次:
View 被重複使用
↓
把工具箱拿出來
↓
直接使用
不用每次重新 findViewById()。
而今天另一個讓 Android 開始變得有趣的地方,就是 Sensor。
我們不再只等使用者按按鈕,而是開始讓 App 感受到:
光
距離
方向
移動
甚至把加速度感測器取得的 X / Y / Z 傳給 OpenGL ES,讓畫面上的 3D 地球跟著手機一起旋轉。
最後再把 SQLite 加進來:
UI
↓
資料輸入
↓
SQLite
↓
資料保存
↓
ListView
↓
修改 / 刪除
到這裡,一個 Android App 所需要的「畫面、互動、硬體感知、資料儲存」已經慢慢拼起來了。
| 今天技術 | 用途 |
|---|---|
ListView |
顯示直向資料列表 |
SimpleAdapter |
Map 資料綁定 View |
Intent.putExtra() |
傳送名稱與圖片 |
SensorManager |
管理 Android Sensor |
TYPE_LIGHT |
取得環境光線 Lux |
TYPE_PROXIMITY |
判斷 Near / Far |
TYPE_ACCELEROMETER |
取得 X / Y / Z |
GLSurfaceView |
顯示 OpenGL 畫面 |
| OpenGL ES 2.0 | 建立 3D 地球 |
GridView |
格狀資料顯示 |
BaseAdapter |
自訂 Adapter |
convertView |
重複利用 Item View |
ViewHolder |
快取 View 參照 |
view.tag |
將 Holder 與 View 綁定 |
SpinnerItem |
自訂 Spinner 資料模型 |
ArrayAdapter |
自訂圖片+文字 Spinner |
SQLiteOpenHelper |
建立/管理 SQLite |
ContentValues |
INSERT / UPDATE 資料 |
Cursor |
讀取查詢結果 |
SimpleCursorAdapter |
Cursor → ListView |
| CRUD | 新增、查詢、修改、刪除 |
今天從:
ListView
一路走到:
ViewHolder
Sensor
OpenGL ES
SQLite
如果要用一句話總結今天:
Day 22:讓 Android App 不只會「顯示」,還開始會「感知、重用、互動與記住資料」。
這就是今天從一般 Android UI,開始走向真正應用程式架構的一大步。