Jetpack Compose 清單、狀態與 Material

Table of contents
- 1. 資料模型:Task
- 2. DAO:資料庫操作介面
- 3. Room Database:建立 SQLite 實體
- 4. ViewModel:單一來源(StateFlow)
- 5. TaskInput:輸入框 + 新增
- 6. TaskList & TaskItem:
- 7. TaskScreen:Scaffold / FAB / Snackbar Undo / 空清單提示
- 8. 入口:MainActivity
- 9. Undo 的 ID 行為與實務選項
- 10. LazyColumn 的效能與重組最佳實務
- 11. 為什麼 ViewModel + StateFlow 比 LiveData 更符合 Compose
- 12. 為什麼 Room 的 suspend DAO 不需手動切換 IO
- 13. 總結

本文撰寫時間為 2025 年 7 月,請注意該文章的介紹是否與閱讀時有落差。
我們要完成的 To-Do App 具備:
任務清單(LazyColumn)
新增/刪除/勾選完成
Snackbar Undo
空清單提示(AnimatedVisibility)
Room(SQLite)永久儲存
ViewModel + StateFlow 管理狀態
Material 3 + Scaffold 架構
1. 資料模型:Task
檔案:app/data/Task.kt
package com.example.todo.data
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "tasks")
data class Task(
@PrimaryKey(autoGenerate = true) val id: Int = 0, // 自動 ID,避免你手動管理
val title: String,
val isDone: Boolean = false // 勾選完成的來源
)
做了什麼
- 宣告 Room 資料表結構(SQLite 會對應產生一張 tasks 表)。
為什麼/好處
@Entity 讓你不用手寫 CREATE TABLE。
autoGenerate 減少人為 ID 衝突。
模型先定義好 → 後續 UI、DAO 都有清楚型別依據。
2. DAO:資料庫操作介面
檔案:app/data/TaskDao.kt
package com.example.todo.data
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@Dao
interface TaskDao {
@Query("SELECT * FROM tasks")
fun getTasks(): Flow<List<Task>>
// 回傳 Flow 的好處:
// 只要資料變了(新增/刪除/更新),UI 透過 collectAsState() 會自動重組→自動更新畫面
@Insert
suspend fun insertTask(task: Task) // suspend → 非阻塞、Room 自有執行緒處理
@Update
suspend fun updateTask(task: Task) // 勾選完成只要 copy 後 update
@Delete
suspend fun deleteTask(task: Task) // 一行刪除,不用寫 SQL
}
做了什麼
- 定義查詢、插入、更新、刪除。
為什麼/好處
免 SQL:@Insert/@Update/@Delete 自動產生語法。
Flow:與 Compose 的宣告式 UI 完全契合,不用手動通知 UI 刷新。
3. Room Database:建立 SQLite 實體
檔案:app/data/TaskDatabase.kt
package com.example.todo.data
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [Task::class], version = 1, exportSchema = false)
abstract class TaskDatabase : RoomDatabase() {
abstract fun taskDao(): TaskDao
companion object {
@Volatile private var INSTANCE: TaskDatabase? = null
fun getDatabase(context: Context): TaskDatabase =
INSTANCE ?: synchronized(this) {
Room.databaseBuilder(
context.applicationContext,
TaskDatabase::class.java,
"task_database" // 實際 SQLite 檔案名稱
).build().also { INSTANCE = it }
}
}
做了什麼
- 建立單例資料庫,提供 TaskDao()。
為什麼/好處
單例避免多個連線爭用與記憶體浪費。
databaseBuilder 幫你完成 SQLite 開檔、版本管理基礎(之後可加 Migration)。
4. ViewModel:單一來源(StateFlow)
檔案:app/ui/TaskViewModel.kt
package com.example.todo.ui
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.example.todo.data.Task
import com.example.todo.data.TaskDatabase
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
class TaskViewModel(application: Application) : AndroidViewModel(application) {
private val dao = TaskDatabase.getDatabase(application).taskDao()
// Flow → StateFlow:讓 Composable 可以用 collectAsState() 讀取
val tasks: StateFlow<List<Task>> = dao.getTasks()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun addTask(title: String) {
viewModelScope.launch { dao.insertTask(Task(title = title)) }
// 好處:使用 suspend DAO + Room 內部執行緒 → 不阻塞主執行緒、不卡 UI
}
fun toggleTask(task: Task) {
viewModelScope.launch { dao.updateTask(task.copy(isDone = !task.isDone)) }
// 好處:只改變 isDone,UI 自動隨 Flow 更新
}
fun deleteTask(task: Task) {
viewModelScope.launch { dao.deleteTask(task) }
}
fun restoreTask(task: Task) {
viewModelScope.launch { dao.insertTask(task) }
}
}
做了什麼
- 以 StateFlow 暴露 UI 狀態,所有寫入統一由 ViewModel 完成。
為什麼/好處
旋轉不丟狀態:ViewModel 與 Activity 生命週期綁定。
單向資料流:UI 只觀察 tasks,互動呼叫 ViewModel → 清楚、好拆測。
Flow → StateFlow:穩定值、易於組合,UI 層只做顯示。
5. TaskInput:輸入框 + 新增
檔案:app/ui/components/TaskInput.kt
package com.example.todo.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun TaskInput(onAdd: (String) -> Unit) {
var text by remember { mutableStateOf("") } // Composable 本地輸入狀態
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextField(
value = text,
onValueChange = { text = it },
modifier = Modifier.weight(1f),
label = { Text("New Task") }
)
Button(onClick = {
if (text.isNotBlank()) {
onAdd(text) // 將意圖回拋給上層(ViewModel)
text = ""
}
}) { Text("Add") }
}
}
做了什麼
- 本地管理輸入框字串,按下「Add」回拋給上層。
為什麼/好處
UI/邏輯分離:onAdd 只傳遞意圖,上層決定「新增」怎麼做。
組合性:這個輸入框可在其他畫面重用,不依賴特定 ViewModel。
6. TaskList & TaskItem:
檔案:app/ui/components/TaskList.kt
package com.example.todo.ui.components
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.example.todo.data.Task
@Composable
fun TaskList(
tasks: List<Task>,
onToggle: (Task) -> Unit,
onDelete: (Task) -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// ✅ 重點:使用 key 維持項目 identity,減少不必要重組,動畫也更穩定
items(tasks, key = { it.id }) { task ->
TaskItem(task, onToggle, onDelete)
}
}
}
@Composable
fun TaskItem(
task: Task,
onToggle: (Task) -> Unit,
onDelete: (Task) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.animateContentSize(), // 刪除/切換時平滑變化
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = task.isDone,
onCheckedChange = { onToggle(task) } // 只拋「意圖」,不在 UI 改資料
)
Text(
text = task.title,
style = if (task.isDone)
TextStyle(textDecoration = TextDecoration.LineThrough)
else TextStyle.Default,
modifier = Modifier.padding(start = 8.dp)
)
}
IconButton(onClick = { onDelete(task) }) {
Icon(Icons.Default.Delete, contentDescription = "Delete Task")
}
}
}
為什麼要用 LazyColumn
它做了什麼
- 只「懶載入」可見範圍的項目(像 RecyclerView 的可見項目回收機制),不會一次把 1000 筆都繪製出來。
為什麼要用 LazyColumn(而不是 Column)
Column 會把所有 children 一次排版&繪製,項目很多時記憶體與排版成本暴增,滑動也容易卡。
LazyColumn 只生成「看得到的那幾個 item」,離開視窗的會被回收再利用,對 大型清單效能穩定。
進一步的好處
items(tasks, key = { it.id }):
提供 穩定 key,讓 Compose 在資料變動時能準確比對哪個項目被插入/刪除/移動,減少不必要的重組與版面抖動。
對動畫(如 animateContentSize)也更友善,狀態不會亂跳。
乾淨控制項目之間距,不必在每個 item 上手動加 Spacer,更好維護。
TaskItem 把互動「回拋」出去(onToggle/onDelete):
UI 不碰資料層,可測試性高、關注點分離。
任何內容高度改變(如打勾、刪除)都自帶平滑動畫,一行程式碼改善 UX。
7. TaskScreen:Scaffold / FAB / Snackbar Undo / 空清單提示
檔案:app/ui/TaskScreen.kt
package com.example.todo.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.todo.data.Task
import com.example.todo.ui.components.TaskInput
import com.example.todo.ui.components.TaskList
import kotlinx.coroutines.launch
@Composable
fun TaskScreen(viewModel: TaskViewModel = viewModel()) {
val tasks by viewModel.tasks.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
var lastDeleted by remember { mutableStateOf<Task?>(null) } // 暫存刪除,Undo 會用到
Scaffold(
topBar = { TopAppBar(title = { Text("Task List") }) }, // Material 3 標題列
floatingActionButton = {
FloatingActionButton(onClick = { viewModel.addTask("Quick Task") }) {
Icon(Icons.Default.Add, contentDescription = "Add Task")
}
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) } // 放 Snackbar 的主機
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.padding(16.dp)
) {
TaskInput(onAdd = { viewModel.addTask(it) })
Spacer(modifier = Modifier.height(16.dp))
// 空清單提示:沒有元素時,以動畫顯示人性化訊息
AnimatedVisibility(visible = tasks.isEmpty()) {
Text(
"No tasks available",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
}
TaskList(
tasks,
onToggle = { viewModel.toggleTask(it) },
onDelete = {
lastDeleted = it
viewModel.deleteTask(it)
// Snackbar Undo:操作可逆,降低誤刪風險,UX 友善
coroutineScope.launch {
val result = snackbarHostState.showSnackbar(
message = "Task deleted",
actionLabel = "Undo"
)
if (result == SnackbarResult.ActionPerformed) {
lastDeleted?.let { task -> viewModel.restoreTask(task) }
}
}
}
)
}
}
}
做了什麼
用 Scaffold 搭好 App 骨架:頂部列、FAB、Snackbar 容器。
新增任務(TaskInput)、顯示清單(TaskList)。
空清單提示(AnimatedVisibility)。
刪除時提供 Undo(Snackbar action)。
為什麼/好處
Scaffold:不用自己處理 FAB 與 Snackbar 的定位與內距,一致的 Material 設計行為。
空清單提示:避免「空白畫面」,給使用者下一步引導。
Snackbar Undo:讓敏感操作可逆,可信賴的 UX。
viewModel() + collectAsState():UI 對狀態單向相依,沒有手動刷新這種易錯點。
8. 入口:MainActivity
檔案:app/MainActivity.kt
package com.example.todo
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import com.example.todo.ui.TaskScreen
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme { // 同一路徑下的 Theme 也可客製(顏色/字型/shape)
Surface {
TaskScreen() // 單一入口,清晰、可測
}
}
}
}
}
做了什麼
- 用 setContent 啟動 Compose ,顯示 TaskScreen。
為什麼/好處
MaterialTheme/Surface:一致的顏色與排版基底,後續要換品牌色不必改每個元件。
入口簡潔,便於日後導入 Navigation(多畫面時改成 NavHost 即可)。
9. Undo 的 ID 行為與實務選項
我們的 Task(id=自動, ...) 在刪除後、Undo 時,直接再插入同一個 Task。
預設 @PrimaryKey(autoGenerate=true):如果你帶非 0 的 id,Room 會嘗試用該 id 插入(表中無同 id 時可行)。
兩個常見實務選擇:
不在意還原原始 id:restoreTask(task.copy(id = 0)),讓 Room 重新給新 id,最穩。
想保留原始 id:把 @Insert 改成 @Insert(onConflict = OnConflictStrategy.REPLACE),但意味著「同 id 重插會覆蓋」。
10. LazyColumn 的效能與重組最佳實務
穩定 key 很重要:items(tasks, key={it.id}) 讓 Compose 能追蹤項目 identity,避免換資料時整串重組。
避免在 item 區塊創建大量 remember 狀態:狀態應模組化、盡量集中在 ViewModel 或較高層。
長清單圖片或昂貴項目:可搭配 remember 圖片快取(如 Coil),或將昂貴計算移到 ViewModel。
11. 為什麼 ViewModel + StateFlow 比 LiveData 更符合 Compose
StateFlow:原生 Kotlin 協程型態,collectAsState() 融合度高。
LiveData:也可用,但需要額外轉換或 observeAsState;Flow 更統一(資料庫、網路都能以 Flow 提供)。
12. 為什麼 Room 的 suspend DAO 不需手動切換 IO
使用 room-ktx 的 suspend DAO,Room 會在自己的執行緒池執行資料庫操作,不會阻塞主執行緒。
你用 viewModelScope.launch { dao.insertTask(...) } 就好,不必再 withContext(Dispatchers.IO)(簡潔且安全)。
13. 總結
工具 | 你做了什麼 | 為什麼/好處 |
Compose + Material3 | 用宣告式 UI 寫畫面與互動,Scaffold 結構化 UI | 少樣板、狀態驅動、標準化 UI(TopAppBar/FAB/Snackbar) |
ViewModel + StateFlow | 所有清單狀態集中在 ViewModel,UI 只觀察 | 旋轉不丟、單向資料流、易測試、UI 無需手動刷新 |
Room(SQLite) | @Entity/@Dao/@Database 封裝資料存取 | 免 SQL、Flow 即時更新、資料永久化、離線可用 |
LazyColumn | 懶載入、items(key=...)、項目動畫 | 面對大量資料也流暢、重組準確、易維護 |
Snackbar Undo | 刪除提供復原機制 | 降低誤刪風險、提升信任感 |
AnimatedVisibility / animateContentSize | 空清單提示、項目高度變化動畫 | 一行達到專業級 UX、降低突兀感 |
輸入回拋(TaskInput.onAdd) | UI 只發出意圖,資料操作在 ViewModel | 關注點分離、可重用組件 |
Subscribe to my newsletter
Read articles from AgriLinq Admin directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
