iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
Software Development

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

Android 從 ListView 到感測器、ViewHolder、Spinner 與 SQLite:讓 App 從「顯示資料」進化到「感知與儲存」

  • 分享至 

  • xImage
  •  

Android 從 ListView 到感測器、ViewHolder、Spinner 與 SQLite:讓 App 從「顯示資料」進化到「感知與儲存」

Day 21 我們練習了 Android 的 Menu 與 Navigation;Day 22 的內容明顯再往前跨了一大步。

今天的教材其實可以串成一條很完整的 Android 學習路線:

ListView 顯示資料 → Intent 傳遞 → Sensor 感知環境 → GridView + 自訂 Adapter → ViewHolder 效能最佳化 → 自訂 Spinner → SQLite CRUD。

https://ithelp.ithome.com.tw/upload/images/20260822/20112100dDif0SecOT.png

一、今天要完成什麼?

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 很重要的三個核心:

資料怎麼顯示?裝置資料怎麼取得?資料怎麼永久保存?


Part 1:ListView — 花朵與貓咪資料列表

今天第一個範例先從熟悉的 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 的資料來源即可。


Part 2:從 resources 取得文字與圖片

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

Part 3:把名稱與圖片組成 Map

接著建立:

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
  }
]

Part 4:SimpleAdapter 把資料交給 ListView

今天第一種 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 之間的橋樑。


Part 5:點 ListView → Intent → DisplayActivity

今天並沒有停在「顯示列表」。

點擊某一筆資料後:

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 串起來。


Part 6:Android Sensor — 手機其實有很多「感官」

第二部分開始進入今天非常有趣的主題:

Sensor 感測器

程式先取得:

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}"

所以不同手機執行,結果可能不同。


Part 7:點 Sensor 進不同 Activity

點擊 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

Part 8:Light Sensor — 手機知道環境亮不亮

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 資料。


Part 9:Lux 決定亮/暗圖片

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 值與亮/暗圖片切換】


Part 10:Proximity Sensor — 手機知道物體靠近了

另一個 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 裝置都一定具有相同感測器。


Part 11:Near / Far 判斷

取得:

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 實機效果】


Part 12:Accelerometer — 今天最有趣的 Sensor

接下來是:

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:使用你提供的「三維空間的方向與移動」圖】


Part 13:理解 X、Y、Z 三個方向

你提供的三維方向圖非常適合放在這一段。

三維空間方向移動筆記

可以先用最基本的概念理解:

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 動作。


Part 14:六個方向箭頭

今天還加入:

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

Part 15:加速度感測器 + OpenGL ES 2.0 3D 地球 🌍

今天這份 AccActivity 最特別的地方,是又加入:

GLSurfaceView

並設定:

glView.setEGLContextClientVersion(2)

renderer =
    EarthRenderer(this)

glView.setRenderer(renderer)

glView.renderMode =
    GLSurfaceView.RENDERMODE_CONTINUOUSLY

也就是:

OpenGL ES 2.0

程式自己建立一顆球:

createSphere(
    1.2f,
    30,
    30
)

再載入:

textureId =
    loadTexture(
        context,
        R.drawable.earth_map
    )

把世界地圖貼到球體表面。

於是:

Sphere
  +
earth_map
  ↓
3D Earth 🌍

Part 16:手機一傾斜,地球跟著轉

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 的整合。


Part 17:GridView — 從直向列表變成格狀資料

下一個範例使用:

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

Part 18:為什麼需要自己寫 MyAdapter?

因為我們開始想更精細地控制:

資料
 ↓
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 都是在這裡建立與綁定資料。


Part 19:今天的重點之一 — ViewHolder

如果每次 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
}

Part 20:view.tag = holder 到底是什麼?

這張就是今天最重要的筆記之一:

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。


Part 21:Holder 為什麼比較快?

另一張圖把效能差異畫得更清楚:

Holder 用法與意義

沒有 Holder:

Item 1 → findViewById()
Item 2 → findViewById()
Item 3 → findViewById()
Item 4 → findViewById()
...

使用 Holder:

第一次
 ↓
findViewById()
 ↓
Holder
 ↓
存起來

之後
 ↓
直接拿 Holder
 ↓
綁資料

今天教材中的生活化概念也很好理解:

沒有 Holder
= 每次都重新找工具

使用 Holder
= 先準備好工具箱
   下次直接拿來使用

所以主要好處是:

  • 減少重複 findViewById()
  • 配合 convertView 重複使用 View
  • 降低不必要的處理
  • ListView / GridView 滑動更順暢
  • 程式結構更清楚

這也是後來 RecyclerView 的 ViewHolder 思想中非常重要的基礎概念。今天的 MyAdapter 正是透過 view.tag 保存及取回 Holder。


Part 22:GridView 點一下 → 下方顯示大圖

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
 ┌────┬────┬────┐
 │ 🌷 │ 🌹 │ 🌻 │
 ├────┼────┼────┤
 │ 🌼 │ 🌺 │ 🌸 │
 └────┴────┴────┘
          ↓ 點擊
    ┌─────────────┐
    │   大圖片     │
    └─────────────┘

Part 23:Spinner 也可以「圖片 + 文字」

下一個範例把 Spinner 升級。

先定義資料模型:

data class SpinnerItem(
    val name: String,
    val image: Int
)

一筆 Spinner 資料不再只是:

"Ice Cream"

而是:

SpinnerItem
├── name
└── image

Part 24:建立 Spinner 資料

主畫面:

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()

Part 25:自訂 SpinnerArrayAdapter

今天又自己寫了一個 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      │
└────────────────┘

Part 26:Spinner 選完取得完整物件

設定:

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

Part 27:最後的大魔王 — SQLite

Day 22 最後進入非常重要的:

SQLite Database

前面資料都是:

App 開啟
   ↓
記憶體資料
   ↓
App 關掉
   ↓
資料可能消失

SQLite 則是:

App
 ↓
SQLite
 ↓
customer.db
 ↓
資料持久保存

Part 28:DBHelper 建立 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   │ ...         │ ...      │ ...          │
└─────┴───────┴─────────────┴──────────┴──────────────┘

Part 29:SQLite CRUD

CRUD 是資料庫最基本的四個動作:

C → Create → INSERT
R → Read   → SELECT
U → Update → UPDATE
D → Delete → DELETE

今天全部都有做到。


Part 30:INSERT 新增資料

先取得:

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()
}

Part 31:SELECT 查詢

如果 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。


Part 32:UPDATE 修改資料

程式:

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()
}

Part 33:DELETE 刪除資料

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

Part 34:DataActivity — ListView 點下去直接修改資料

今天還不只是 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。


Part 35:點資料 → AlertDialog → UPDATE

這個操作已經很接近真正的 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()

畫面立即刷新。


🔥 Day 22 整體架構圖

把今天所有內容放在一起:

                     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
          │
          ↓
     圖片 + 文字

📚 今天真正重要的三個觀念

① Adapter 是「資料」與「View」之間的橋樑

今天從:

SimpleAdapter

走到:

BaseAdapter

再到:

ArrayAdapter<SpinnerItem>

其實都是同一件事情:

Data
  ↓
Adapter
  ↓
View

② ViewHolder 是「重複利用」

今天的這兩張圖非常重要:

https://ithelp.ithome.com.tw/upload/images/20260822/201121006z3JyAIMyu.png

https://ithelp.ithome.com.tw/upload/images/20260822/20112100AABgMCj8Ue.png

記住一句就可以:

第一次找 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
}

③ Sensor 把「真實世界」帶進 App

以前:

使用者按 Button
       ↓
App 有反應

今天:

光線改變
   ↓
App 有反應

手靠近
   ↓
App 有反應

手機傾斜
   ↓
App 有反應

尤其 Accelerometer 再串:

手機
 ↓
Sensor
 ↓
X / Y / Z
 ↓
OpenGL ES
 ↓
3D Earth 🌍

這就是今天最有意思的地方。


💡 Day 22 心得

今天的內容很多,但如果把它拆開,其實可以看到 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 所需要的「畫面、互動、硬體感知、資料儲存」已經慢慢拼起來了。


✅ Day 22 重點整理

今天技術 用途
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 新增、查詢、修改、刪除

Day 22 完成 🎉

今天從:

ListView

一路走到:

ViewHolder
Sensor
OpenGL ES
SQLite

如果要用一句話總結今天:

Day 22:讓 Android App 不只會「顯示」,還開始會「感知、重用、互動與記住資料」。

這就是今天從一般 Android UI,開始走向真正應用程式架構的一大步。


上一篇
Android 選單大集合:Menu、Dialog、WebView 到 Navigation,一次搞懂 App 的操作入口
系列文
Android 鐵人賽: 天命最高 - 陪伴大家一步步打造屬於自己的app22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言