前幾天我們已經接觸過 Android 的 ListView、Adapter、Intent 與各種 UI 元件。
今天要再往真正 App 開發常見的架構前進一步:
RecyclerView 負責「把資料漂亮地顯示出來」,Room 負責「把資料真正保存下來」。
今天的練習其實可以分成兩條主線:
RecyclerView
資料 → Adapter → ViewHolder → Card Layout → RecyclerView
Room Database
UI → MainActivity → DAO → Room → SQLite
↑
Entity
最後再把兩個觀念串起來,就已經很接近一般 Android App 常見的「資料 → 儲存 → 顯示」流程了。
參考資料:Android Developers — RecyclerView
https://developer.android.com/develop/ui/views/layout/recyclerview
前面的練習我們使用過 ListView。
ListView 當然可以顯示清單,但當資料變多、畫面開始複雜,例如:
這時候 RecyclerView 就更適合。
它最大的特色其實就在名字:
Recycler = 回收再利用 View。
畫面上只需要建立目前看得到的那些 Item,當 Item 滑出螢幕後,其 View 可以重新拿來顯示後面的資料。
因此核心概念可以想成:
資料 1 ─┐
資料 2 ─┤
資料 3 ─┤
資料 4 ─┤──→ Adapter ─→ ViewHolder ─→ Card
資料 5 ─┤
... ─┘
而不是每一筆資料都重新建立一個完整 View。
參考資料:Android Developers — Create dynamic lists with RecyclerView
https://developer.android.com/develop/ui/views/layout/recyclerview
今天的 RecyclerView 範例準備了一組名稱資料,接著動態尋找:
flower1
flower2
flower3
...
這些 Drawable 圖片資源,再組成 MutableMap。
我的程式資料結構是:
val nameArray = resources.getStringArray(R.array.name)
val dataList = mutableListOf<MutableMap<String, Any>>()
接著逐筆建立:
for (i in 0 until nameArray.size) {
val item = mutableMapOf<String, Any>()
item["name"] = nameArray[i]
val resName = "flower${i + 1}"
val resId = resources.getIdentifier(
resName,
"drawable",
packageName
)
item["pic"] =
if (resId != 0)
resId
else
R.drawable.flower1
dataList.add(item)
}
因此每一筆資料概念上就是:
{
"name" : "名稱",
"pic" : R.drawable.flower1
}
這裡我覺得很值得記下來的是:
resources.getIdentifier()
它可以利用字串動態取得 Android Resource ID。
例如:
val resName = "flower${i + 1}"
當:
i = 0 → flower1
i = 1 → flower2
i = 2 → flower3
因此就不需要自己寫:
R.drawable.flower1
R.drawable.flower2
R.drawable.flower3
R.drawable.flower4
這對大量規則命名的圖片相當方便。我的 MainActivity 最後再把這些資料交給 CardAdapter 顯示。
參考資料:Android Developers — Resources
https://developer.android.com/guide/topics/resources/providing-resources
第一次學 RecyclerView 時,最容易被一堆 Class 搞混。
其實先記住三個角色即可:
RecyclerView
│
▼
LayoutManager
│
▼
Adapter
│
▼
ViewHolder
│
▼
card_layout.xml
負責顯示整個清單。
val recyclerView =
findViewById<RecyclerView>(R.id.recyclerView)
負責決定 Item 怎麼排列。
今天使用:
recyclerView.layoutManager =
LinearLayoutManager(this)
因此資料會以一般垂直清單排列。
最後指定 Adapter:
recyclerView.adapter =
CardAdapter(this, dataList)
完整概念:
dataList
↓
CardAdapter
↓
RecyclerView
↓
LinearLayoutManager
↓
畫面上的 Card
參考資料:Android Developers — RecyclerView
https://developer.android.com/develop/ui/views/layout/recyclerview
今天建立的 CardAdapter:
class CardAdapter(
private val myContext: Context,
private var dataList: MutableList<MutableMap<String, Any>>
) : RecyclerView.Adapter<CardAdapter.MyViewHolder>()
Adapter 本身不只是「放資料」。
它其實是在回答 RecyclerView 三個問題:
1. Item 長什麼樣子?
2. 這個位置應該顯示什麼資料?
3. 總共有多少筆資料?
所以 Adapter 最重要的三個函式就是:
onCreateViewHolder()
onBindViewHolder()
getItemCount()
參考資料:Android Developers — RecyclerView.Adapter
https://developer.android.com/reference/androidx/recyclerview/widget/RecyclerView.Adapter
這是 RecyclerView 最重要的觀念之一。
我的程式:
class MyViewHolder(itemView: View) :
RecyclerView.ViewHolder(itemView) {
val imageViewPic: ImageView =
itemView.findViewById(R.id.imageView)
val textViewName: TextView =
itemView.findViewById(R.id.textView)
}
以前如果每次顯示 Item 都重新:
findViewById()
資料量大時會造成不必要的 View 查找。
ViewHolder 就先把:
ImageView
TextView
的參照保存起來。
概念上:
Card View
┌─────────────────────┐
│ │
│ ImageView │ ← imageViewPic
│ │
│ 花朵名稱 │ ← textViewName
│ │
└─────────────────────┘
↑
ViewHolder
RecyclerView 滑動時就可以重複使用。
參考資料:Android Developers — RecyclerView.ViewHolder
https://developer.android.com/reference/androidx/recyclerview/widget/RecyclerView.ViewHolder
RecyclerView 需要新的 Item 時,就會呼叫:
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): MyViewHolder {
val itemView =
LayoutInflater
.from(myContext)
.inflate(
R.layout.card_layout,
parent,
false
)
return MyViewHolder(itemView)
}
這裡最重要的是:
R.layout.card_layout
也就是:
RecyclerView 裡面每一張 Card 的 UI 樣板。
流程:
card_layout.xml
↓
LayoutInflater
↓
View
↓
MyViewHolder
所以 RecyclerView 本身只是容器。
真正「每一列長什麼樣子」,是由 card_layout.xml 決定。
參考資料:Android Developers — LayoutInflater
https://developer.android.com/reference/android/view/LayoutInflater
有了 Card,下一步就是填資料。
override fun onBindViewHolder(
holder: MyViewHolder,
position: Int
) {
val data = dataList[position]
val name =
data["name"].toString()
val picId =
data["pic"] as Int
holder.textViewName.text = name
holder.imageViewPic
.setImageResource(picId)
}
這裡的:
position
就是目前要顯示第幾筆資料。
例如:
position = 0
↓
dataList[0]
↓
name + pic
↓
ViewHolder
↓
第一張 Card
下一筆:
position = 1
↓
dataList[1]
↓
第二張 Card
因此:
onCreateViewHolder()負責建立 View,onBindViewHolder()負責把資料綁到 View。
這兩個一定要分清楚。
參考資料:Android Developers — RecyclerView.Adapter
https://developer.android.com/reference/androidx/recyclerview/widget/RecyclerView.Adapter
今天的 CardAdapter 又增加了點擊事件。
點整張 Card:
holder.itemView.setOnClickListener {
Toast.makeText(
myContext,
"Select : $name",
Toast.LENGTH_SHORT
).show()
}
效果就是:
使用者點 Card
↓
取得這張 Card 的 name
↓
Toast
↓
Select : xxx
甚至還可以只讓 Card 裡面的 TextView 有自己的點擊行為:
holder.textViewName.setOnClickListener {
val mainTextView =
(myContext as Activity)
.findViewById<TextView>(
R.id.textView_data
)
mainTextView.text =
"Select : $name"
}
也就是:
Card
├── ImageView
└── TextView ──點擊──→ MainActivity TextView 更新
所以 RecyclerView 的每一個 Item 裡面,可以有自己獨立的互動。
參考資料:Android Developers — RecyclerView
https://developer.android.com/develop/ui/views/layout/recyclerview
到這裡有一個問題。
現在的:
dataList
只存在記憶體。
App 關閉之後,資料就沒了。
所以真正的 App 還需要:
資料持久化
Persistence
Android 本身可以使用 SQLite。
但是如果直接操作 SQLite,通常需要自己處理不少 SQL 與資料轉換工作。
因此 Android Jetpack 提供:
Room 是建立在 SQLite 上方的資料庫抽象層。
可以把架構想成:
Android App
↓
Room
↓
SQLite
參考資料:Android Developers — Room
https://developer.android.com/training/data-storage/room
今天第二個實作就是 Room。
Room 最重要的三個東西:
Entity
↓
DAO
↓
Database
更完整:
MainActivity
│
▼
DAO
│
▼
Room Database
│
▼
SQLite
▲
│
Entity
我的 MyDatabase.kt 就把這三個核心結構建立起來。
參考資料:Android Developers — Room
https://developer.android.com/training/data-storage/room
今天建立:
@Entity(tableName = "customer_table")
data class CustomerData(
@PrimaryKey(autoGenerate = true)
val uid: Int = 0,
@NonNull
@ColumnInfo(
name = "customer_name",
defaultValue = ""
)
val name: String,
val email: String,
val phone: String
)
這段非常重要。
它等於是在描述 SQLite:
customer_table
┌─────┬───────────────┬───────────────┬──────────────┐
│ uid │ customer_name │ email │ phone │
├─────┼───────────────┼───────────────┼──────────────┤
│ 1 │ Alex │ ... │ ... │
│ 2 │ Amy │ ... │ ... │
└─────┴───────────────┴───────────────┴──────────────┘
其中:
@PrimaryKey(autoGenerate = true)
表示:
uid 自動產生
所以新增資料:
CustomerData(
name = name,
email = email,
phone = phone
)
不需要自己設定:
uid
Room 會自動處理。
參考資料:Android Developers — Defining data using Room entities
https://developer.android.com/training/data-storage/room/defining-data
DAO 全名:
Data Access Object
簡單講:
專門負責操作資料庫。
今天的 DAO:
@Dao
interface DataDao {
@Query("Select * from customer_table")
suspend fun getAll(): List<CustomerData>
@Query(
"Select * from customer_table " +
"where customer_name = :name"
)
suspend fun getCustomer(
name: String
): List<CustomerData>
@Insert(
onConflict = OnConflictStrategy.IGNORE
)
suspend fun insertCustomer(
customer: CustomerData
): Long
@Update
suspend fun update(
customer: CustomerData
): Int
@Delete
suspend fun delete(
customer: CustomerData
): Int
}
這裡剛好就是資料庫最基本的:
C = Create → @Insert
R = Read → @Query
U = Update → @Update
D = Delete → @Delete
參考資料:Android Developers — Accessing data using Room DAOs
https://developer.android.com/training/data-storage/room/accessing-data
例如今天查詢姓名:
@Query(
"Select * from customer_table " +
"where customer_name = :name"
)
suspend fun getCustomer(
name: String
): List<CustomerData>
這裡:
:name
就是 Kotlin 傳進來的:
name
例如:
dao.getCustomer("Alex")
概念上就會執行:
SELECT *
FROM customer_table
WHERE customer_name = 'Alex'
因此 DAO 把 SQL 和 Kotlin 很自然地接起來了。
參考資料:Android Developers — Room DAO Query
https://developer.android.com/training/data-storage/room/accessing-data
有 Entity、DAO 之後,還需要真正的 Database。
@Database(
entities = [CustomerData::class],
version = 1,
exportSchema = false
)
abstract class MyDatabase :
RoomDatabase() {
abstract fun dataDao(): DataDao
}
這代表:
MyDatabase
│
├── CustomerData Entity
│
└── DataDao
然後利用:
Room.databaseBuilder(
context.applicationContext,
MyDatabase::class.java,
"customer_database"
).build()
真正建立:
customer_database
參考資料:Android Developers — Room Database
https://developer.android.com/reference/androidx/room/Database
我的程式還使用:
companion object {
@Volatile
private var INSTANCE:
MyDatabase? = null
fun getInstance(
context: Context
): MyDatabase {
return INSTANCE
?: synchronized(this) {
val instance =
Room.databaseBuilder(
context.applicationContext,
MyDatabase::class.java,
"customer_database"
).build()
INSTANCE = instance
instance
}
}
}
這個設計就是:
目的:
Activity A ─┐
Activity B ─┼──→ 同一個 MyDatabase
Activity C ─┘
而不是:
Activity A → Database 1
Activity B → Database 2
Activity C → Database 3
App 通常不需要一直建立新的 Room Database instance。
所以使用:
MyDatabase.getInstance(this)
取得共用 Database。
參考資料:Android Developers — Room
https://developer.android.com/training/data-storage/room
今天 DAO 很多函式前面都有:
suspend
例如:
suspend fun getAll():
List<CustomerData>
suspend fun insertCustomer(
customer: CustomerData
): Long
因此 Activity 裡面使用:
lifecycleScope.launch {
// Room 操作
}
這剛好也接回之前學過的 Kotlin Coroutine。
MainActivity
↓
lifecycleScope.launch
↓
Coroutine
↓
DAO
↓
Room
↓
SQLite
也就是之前學 Coroutine 時,可能還會覺得:
「Android 到底什麼時候會真的用到 suspend?」
今天 Room 就是一個非常實際的使用場景。
我的 CRUD 操作都是透過 lifecycleScope.launch 呼叫 DAO。
參考資料:Android Developers — Kotlin coroutines on Android
https://developer.android.com/kotlin/coroutines
首先取得使用者輸入:
val name =
etName.text.toString()
val email =
etEmail.text.toString()
val phone =
etPhone.text.toString()
如果姓名沒有輸入:
if (name.isEmpty()) {
Toast.makeText(
this,
"請輸入姓名",
Toast.LENGTH_SHORT
).show()
return@setOnClickListener
}
接著:
lifecycleScope.launch {
val data =
CustomerData(
name = name,
email = email,
phone = phone
)
dao.insertCustomer(data)
refreshList()
clearInputs()
Toast.makeText(
this@MainActivity,
"新增成功",
Toast.LENGTH_SHORT
).show()
}
流程:
EditText
↓
CustomerData
↓
dao.insertCustomer()
↓
Room
↓
SQLite
↓
refreshList()
↓
更新畫面
參考資料:Android Developers — Room Insert
https://developer.android.com/training/data-storage/room/accessing-data
查詢全部:
@Query(
"Select * from customer_table"
)
suspend fun getAll():
List<CustomerData>
Activity:
private fun refreshList() {
lifecycleScope.launch {
val allData =
dao.getAll()
val adapter =
ArrayAdapter(
this@MainActivity,
android.R.layout
.simple_list_item_1,
allData.map {
"${it.uid} | " +
"${it.name} | " +
"${it.phone}"
}
)
lvResult.adapter =
adapter
tvResult.text =
"資料筆數: ${allData.size}"
}
}
這裡 Room 查出的:
List<CustomerData>
再透過:
map
轉成 ListView 可以顯示的文字。
例如:
1 | Alex | 0912345678
2 | Amy | 0922333444
3 | Tom | 0933555666
參考資料:Android Developers — Room Query
https://developer.android.com/training/data-storage/room/accessing-data
按下單筆查詢:
val list =
dao.getCustomer(name)
如果找到:
if (list.isNotEmpty()) {
val data = list[0]
etEmail.setText(data.email)
etPhone.setText(data.phone)
}
因此查詢後,資料不只是顯示出來。
還可以重新放回:
EditText
方便後續修改。
完整流程:
輸入姓名
↓
getCustomer(name)
↓
Room Query
↓
找到 CustomerData
↓
email → EditText
phone → EditText
參考資料:Android Developers — Room DAO
https://developer.android.com/training/data-storage/room/accessing-data
更新時先找資料:
val list =
dao.getCustomer(name)
取得原本的:
val target = list[0]
接著今天使用 Kotlin Data Class 很方便的:
copy()
val updatedData =
target.copy(
email =
etEmail.text.toString(),
phone =
etPhone.text.toString()
)
這樣:
uid → 保留
name → 保留
email → 修改
phone → 修改
最後:
dao.update(updatedData)
這就是 Kotlin data class 與 Room 搭配非常方便的地方。
參考資料:Kotlin — Data classes
https://kotlinlang.org/docs/data-classes.html
刪除的流程也很直覺:
val list =
dao.getCustomer(name)
找到資料:
if (list.isNotEmpty()) {
dao.delete(list[0])
refreshList()
}
所以不是直接告訴 Room:
刪除 Alex
而是:
Alex
↓
Query
↓
CustomerData
↓
@Delete
↓
Room
↓
SQLite
參考資料:Android Developers — Room Delete
https://developer.android.com/training/data-storage/room/accessing-data
寫到 Room 之後,我開始發現:
前面那些 Kotlin 基礎並不是分開存在的。
今天幾乎全部又出現一次。
例如:
data class CustomerData(...)
用到了:
Class
Data Class
DAO:
interface DataDao
又用到:
Interface
資料:
List<CustomerData>
MutableList<MutableMap<String, Any>>
又回到:
Collection
List
MutableList
Map
資料轉換:
allData.map {
"${it.uid} | ${it.name} | ${it.phone}"
}
又回到:
Lambda
資料庫:
suspend fun getAll()
又回到:
Coroutine
suspend
Activity:
lifecycleScope.launch {
}
又把 Coroutine 真正放進 Android。
所以整條學習線開始串起來:
Kotlin 基本語法
↓
Collection
↓
Class / Interface
↓
Lambda
↓
Coroutine
↓
Android Activity
↓
Adapter
↓
RecyclerView
↓
Room
↓
真正具有資料功能的 Android App
參考資料:Kotlin Documentation
https://kotlinlang.org/docs/home.html
今天的兩個實驗目前可以分開看:
實驗 A
Array / MutableList
↓
RecyclerView
↓
CardAdapter
↓
畫面
以及:
實驗 B
EditText
↓
Room
↓
SQLite
↓
ListView
但真正 App 最終通常會變成:
┌──────────────┐
│ SQLite │
└──────▲───────┘
│
┌──────┴───────┐
│ Room │
└──────▲───────┘
│
┌──────┴───────┐
│ DAO │
└──────▲───────┘
│
List<CustomerData>
│
▼
┌──────────────┐
│ Adapter │
└──────┬───────┘
│
▼
┌──────────────┐
│ RecyclerView │
└──────────────┘
也就是:
Room 管資料,RecyclerView 管畫面。
這就是今天最值得記住的一句話。
參考資料:Android Developers — Room
https://developer.android.com/training/data-storage/room
把今天所有東西放在一起:
┌─────────────────────────────┐
│ MainActivity │
│ │
│ EditText / Button / UI │
└──────────────┬──────────────┘
│
│ lifecycleScope.launch
▼
┌─────────────────────────────┐
│ DataDao │
│ │
│ getAll() │
│ getCustomer() │
│ insertCustomer() │
│ update() │
│ delete() │
└──────────────┬──────────────┘
│
▼
┌─────────────────────────────┐
│ MyDatabase │
│ Room │
└──────────────┬──────────────┘
│
▼
┌─────────────────────────────┐
│ SQLite │
│ │
│ customer_table │
└─────────────────────────────┘
資料顯示:
Data
│
▼
CardAdapter
│
▼
ViewHolder
│
▼
card_layout.xml
│
▼
RecyclerView
今天已經不只是做一個 UI Demo。
而是開始碰到真正 Android App 很核心的:
資料結構
+
資料庫
+
非同步
+
清單 UI
+
Adapter
參考資料:Android Developers
https://developer.android.com/
今天最重要的觀念,我整理成:
| 技術 | 功能 |
|---|---|
| RecyclerView | 顯示大量清單資料 |
| Adapter | 資料與 UI 的橋樑 |
| ViewHolder | 保存並重複利用 Item View |
| LayoutManager | 決定 RecyclerView 排列方式 |
| Room | Android 本機資料庫抽象層 |
| Entity | 定義資料表 |
| DAO | 定義資料庫操作 |
@Query |
查詢資料 |
@Insert |
新增資料 |
@Update |
更新資料 |
@Delete |
刪除資料 |
| Coroutine | 非同步執行 Room 操作 |
lifecycleScope |
配合 Activity Lifecycle 執行 Coroutine |
| SQLite | Room 底層實際資料庫 |
今天的程式也完成了 Room 的新增、查詢、修改、刪除,以及 RecyclerView 的 Card 顯示與點擊互動。
參考資料:Android Developers
https://developer.android.com/
今天開始有一種「Android 基礎零件終於組起來」的感覺。
RecyclerView 解決:
資料要怎麼有效率地呈現在畫面?
Room 解決:
資料要怎麼真正存在手機裡?
Coroutine 解決:
資料庫操作要怎麼避免卡住 UI?
所以今天可以濃縮成一條非常重要的資料流:
SQLite
↕
Room
↕
DAO
↕
Coroutine
↕
Activity
↕
Adapter
↕
RecyclerView
↕
使用者
從 Kotlin 的 Class、Interface、Collection、Lambda、Coroutine,一路走到 Android 的 RecyclerView 與 Room,前面學過的東西開始真正連成一套 App 開發流程。
下一步,就可以繼續思考:
如果直接把 Room 查詢出來的
CustomerData放進 RecyclerView,而不是 ListView,會發生什麼事?
那就會開始從單純的元件練習,逐漸走向真正的 Android 資料型 App。
Day 25 完成!
參考資料:Android Developers — Save data in a local database using Room
https://developer.android.com/training/data-storage/room