# Kotlin / Ch05 - Jetpack Compose
## 第一章:Jetpack Compose 簡介與核心概念
### 1.1 什麼是 Jetpack Compose
Jetpack Compose 是 Google 推出的現代化 Android UI 工具包,它採用 Declarative Programming 的方式來建構使用者介面。這個概念比較容易聯想到 React 或 Vue.js 這類前端框架,它們都強調透過描述「UI 應該是什麼樣子」而非「如何改變 UI」來開發介面。
傳統的 Android 開發使用 XML 來定義佈局,並透過 Java 或 Kotlin 程式碼操作 View 物件;Imperative Programming 的方式就會需要開發者手動管理 UI 狀態的同步,容易產生錯誤。Compose 則徹底改變了這個模式,它採用 Kotlin 函數來描述 UI,當資料改變時,框架會自動重新渲染相關的 UI 部分。
### 1.2 為什麼選擇 Compose
從傳統 View 系統遷移到 Compose 帶來了多個顯著優勢。首先是程式碼量的大幅減少。原本需要在 XML 中定義佈局,在 Activity 或 Fragment 中綁定 View,再設定各種監聽器的流程,現在可以在單一個 Composable 函數中就可以完成。這種整合性讓程式碼更容易閱讀和維護。
其次是型別安全,XML 佈局在編譯時期無法完全驗證,許多錯誤只能在執行時期發現。Compose 完全使用 Kotlin 編寫,享有完整的型別檢查、IDE 自動完成和重構支援。
第三是效能優化。Compose 的編譯器會進行智慧型差異比對(Smart Recomposition),只更新實際改變的 UI 元素。這個機制類似於 React 的 Virtual DOM,但更加高效。框架會追蹤資料依賴關係,確保不必要的 Re-render 被省略。
### 1.3 Declarative UI 的思維轉換
理解 Declarative Programming 是掌握 Compose 的關鍵。在 Imperative Programming 中,你會寫出「將按鈕的文字設為 X」、「隱藏這個 TextView」這樣的指令序列。不過在 Declarative Programming 中,你則是描述「當狀態為 A 時,按鈕顯示文字 X」、「當條件 B 成立時,這個元件不可見」。
> 這種思維方式在 Spring 開發中也有體現。例如 Spring Security 的設定,你不是寫「檢查使用者,如果是管理員則允許存取」,而是聲明「這個端點需要 ADMIN 角色」。Compose 將這個概念徹底應用到 UI 開發。
讓我們看一個簡單的對比。傳統方式可能需要這樣寫:
```kotlin=
val textView = findViewById<TextView>(R.id.message)
val button = findViewById<Button>(R.id.button)
button.setOnClickListener {
counter++
textView.text = "點擊次數: $counter"
}
```
而在 Compose 中,同樣的功能變成:
```kotlin=
@Composable
fun CounterScreen() {
var counter by remember { mutableStateOf(0) }
Column {
Text("點擊次數: $counter")
Button(onClick = { counter++ }) {
Text("點擊我")
}
}
}
```
**你不需要手動更新 UI,只需要改變狀態(`counter`),UI 會自動反映這個改變。**
### 1.4 Compose 的核心原則
#### 1.4.1 Composable 函數
Composable 函數是 Compose 的基本建構單元。它們是標註了 `@Composable` annotation 的普通 Kotlin 函數,用來描述 UI 的一部分。這個概念類似於 Spring 中的 `@Component` 或 `@Bean`,告訴框架這個函數有特殊的處理方式。
Composable 函數可以呼叫其他 Composable 函數,形成 UI 樹狀結構。每個函數可以接收參數來客製化其行為,就像普通函數一樣。重要的是,Composable 函數應該是純函數(Pure Function)的概念,相同的輸入應該產生相同的輸出。
#### 1.4.2 狀態與重組(Recomposition)
狀態(State)是驅動 UI 變化的核心。在 Compose 中,當狀態改變時,依賴該狀態的 Composable 函數會被重新執行,這個過程稱為重組(Recomposition)。框架會智慧地決定哪些部分需要重組,哪些可以跳過;**這個機制讓你可以專注於資料流,而非手動同步 UI。**
#### 1.4.3 單向資料流
Compose 遵循單向資料流(Unidirectional Data Flow)模式。資料從父組件流向子組件,事件從子組件向上傳遞到父組件。這個模式讓資料流動變得可預測和可追蹤,減少了狀態管理的複雜度。
在實作上,這通常意味著父組件持有狀態,並將狀態和回調函數傳遞給子組件。子組件是無狀態的(Stateless),只負責顯示接收到的資料和觸發事件。
---
## 第二章:Compose 的建構基礎
### 2.1 開發環境設定
開始 Compose 開發前需要確保開發環境符合要求。首先是 Android Studio,建議使用 Arctic Fox(2020.3.1)或更新版本,這些版本對 Compose 有完整的支援,包括即時預覽(Live Preview)和互動式預覽功能。
在專案的 `build.gradle.kts`(如果你使用 Kotlin DSL)或 `build.gradle` 中,需要設定 Compose 相關的依賴。以下是基本的設定範例:
```kotlin=
android {
compileSdk = 34
defaultConfig {
minSdk = 21
targetSdk = 34
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.3"
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.02.00")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.activity:activity-compose:1.8.2")
debugImplementation("androidx.compose.ui:ui-tooling")
}
```
這裡使用了 BOM(Bill of Materials)來管理 Compose 版本,就不需要為每個依賴指定版本號。
### 2.2 第一個 Composable 函數
讓我們建立第一個簡單的 Composable 函數。在 Compose 中,所有的 UI 元件都是函數,不是類別。這是一個重要的概念轉變,特別是對於習慣物件導向設計的 Java 開發者。
```kotlin=
@Composable
fun Greeting(name: String) {
Text(text = "你好, $name!")
}
```
這個函數接收一個字串參數,並顯示一段文字。`@Composable` annotation 告訴編譯器這個函數會發射 UI。`Text` 是 Compose 提供的基礎元件,類似於傳統 Android 的 TextView。
要在 Activity 中使用這個函數,你需要在 `setContent` 區塊中呼叫它:
```kotlin=
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
Greeting(name = "開發者")
}
}
}
}
```
`setContent` 是 Compose 的進入點,它替代了傳統的 `setContentView`。`MaterialTheme` 提供了 Material Design 的主題樣式,包含顏色、字型和形狀定義。
### 2.3 預覽功能
Compose 的一大優勢是即時預覽功能。你可以在不執行應用的情況下,直接在 Android Studio 中查看 UI 的樣子。這需要建立預覽函數:
```kotlin=
@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
MaterialTheme {
Greeting(name = "預覽模式")
}
}
```
`@Preview` annotation 告訴 IDE 這是一個預覽函數。你可以設定多個參數來客製化預覽效果,例如 `showBackground` 顯示背景,`uiMode` 切換深色模式,`device` 指定裝置類型等。
預覽函數不會出現在最終的 APK 中,它們純粹是開發時的輔助工具。你可以為同一個 Composable 建立多個預覽,展示不同的狀態或配置。
### 2.4 Modifier:樣式與行為的調整器
Modifier 是 Compose 中用來調整 Composable 外觀和行為的機制。它採用鏈式呼叫(Chaining)的方式,讓你可以依序套用多個修飾。這個設計模式類似於 Java Stream API 或 Kotlin 的集合操作。
```kotlin=
@Composable
fun StyledText() {
Text(
text = "樣式化的文字",
modifier = Modifier
.padding(16.dp)
.background(Color.LightGray)
.fillMaxWidth()
.height(100.dp)
)
}
```
這裡的 Modifier 鏈依序套用了內邊距、背景色、寬度填滿和固定高度。**順序很重要:例如先套用 padding 再套用 background,背景就不會包含 padding 區域;反之則會。**
Modifier 還可以處理互動行為。例如 `clickable` 讓元件可點擊,`draggable` 讓元件可拖曳。這些修飾器將行為與外觀統一在同一個 API 中處理。
### 2.5 組合與重用
**Compose 鼓勵建立小而專注的 Composable 函數,然後組合它們來建構複雜的 UI。** 這個理念與主流前端框架的「元件」設計邏輯一致,都是為了提高程式碼的可維護性和可測試性。
例如,你可以建立一個自訂的卡片元件:
```kotlin=
@Composable
fun UserCard(name: String, email: String) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = name,
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = email,
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray
)
}
}
}
```
這個 `UserCard` 可以在應用的多個地方重複使用,只需傳入不同的參數。這種組合式的設計讓 UI 變得模組化,每個元件都有清晰的責任界線。
---
## 第三章:狀態管理基礎
### 3.1 理解狀態
在 Compose 中,狀態(State)是指任何會隨時間變化並影響 UI 顯示的資料。這可能是使用者輸入、網路請求結果、定時器數值等等。理解狀態管理是掌握 Compose 的關鍵,因為 **Compose 的核心理念就是「UI = f(State)」**,也就是說,UI 是狀態的函數。
### 3.2 remember 與 mutableStateOf
Compose 提供了 `remember` 和 `mutableStateOf` 來建立和儲存狀態。讓我們深入理解這兩個 API 的運作方式。
`mutableStateOf` 建立一個可觀察的狀態物件。當這個物件的值改變時,所有讀取它的 Composable 函數都會被通知並重組。這類似於觀察者模式(Observer Pattern),是 Reactive Programming 的體現。
```kotlin=
@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
Column {
Text("目前計數: ${count.value}")
Button(onClick = { count.value++ }) {
Text("增加")
}
}
}
```
在這個範例中,`mutableStateOf(0)` 建立了一個初始值為 0 的狀態。`remember` 確保這個狀態在重組過程中被保留。沒有 `remember` 的話,每次重組都會重新建立狀態物件,導致值被重置。
`remember` 的作用類似於在 Spring 中使用 `@Scope("singleton")`,確保物件在特定範圍內只有一個實例。不同的是,Compose 的 remember 是按 Composable 的位置來記憶,而非全域單例。
### 3.3 委派屬性語法
直接使用 `count.value` 讀寫狀態是可行的,但 Kotlin 提供了更優雅的委派屬性(Delegated Property)語法:
```kotlin=
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Column {
Text("目前計數: $count")
Button(onClick = { count++ }) {
Text("增加")
}
}
}
```
使用 `by` 關鍵字後,你可以直接讀寫 `count` 而不需要透過 `.value`。這讓程式碼更簡潔,更接近普通變數的使用方式。在幕後,Kotlin 編譯器會自動生成必要的 getter 和 setter。
這個語法需要匯入 `import androidx.compose.runtime.getValue` 和 `import androidx.compose.runtime.setValue`,不過 Android Studio 通常會自動處理這些匯入。
### 3.4 狀態提升(State Hoisting)
Hoisting 是 Compose 中一個重要的模式,它指的是**將狀態從子元件移到父元件,讓子元件變成無狀態的(Stateless)**。這樣做有幾個好處:提高可重用性、簡化測試、讓資料流更清晰。
這邊可以直接看範例,首先是有狀態的版本:
```kotlin=
@Composable
fun StatefulCounter() {
var count by remember { mutableStateOf(0) }
CounterDisplay(
count = count,
onIncrement = { count++ }
)
}
```
然後是無狀態的子元件:
```kotlin=
@Composable
fun CounterDisplay(
count: Int,
onIncrement: () -> Unit
) {
Column {
Text("目前計數: $count")
Button(onClick = onIncrement) {
Text("增加")
}
}
}
```
Hoisting 讓 `CounterDisplay` 變得可預測且易於測試,你可以傳入任意的 count 值和 onIncrement 實作,不需要 Mock 任何東西。這個優勢比較能在單元測試中體現,類似在 Spring 中測試 Service 層比測試整個 Controller 更容易。
### 3.5 rememberSaveable
`remember` 只能在 Composable 的生命週期中保存狀態。當配置改變(例如螢幕旋轉)導致 Activity 重建時,remember 的狀態會遺失。為了處理這種情況,Compose 提供了 `rememberSaveable`。
```kotlin=
@Composable
fun PersistentCounter() {
var count by rememberSaveable { mutableStateOf(0) }
Column {
Text("計數(會保存): $count")
Button(onClick = { count++ }) {
Text("增加")
}
}
}
```
`rememberSaveable` 會將狀態儲存到 Android 的 saved instance state 機制中,這類似於傳統 Android 開發中的 `onSaveInstanceState` 和 `onRestoreInstanceState`。它只能儲存可序列化的基本類型或實作了 Parcelable 的物件。
那對於複雜的物件,你會需要提供自訂的 Saver:
```kotlin=
data class User(val name: String, val age: Int)
val UserSaver = Saver<User, List<Any>>(
save = { listOf(it.name, it.age) },
restore = { User(it[0] as String, it[1] as Int) }
)
@Composable
fun UserProfile() {
var user by rememberSaveable(stateSaver = UserSaver) {
mutableStateOf(User("訪客", 0))
}
// ... UI 實作
}
```
這個機制與 Spring Session 的序列化類似,都是為了在不同的生命週期階段保持狀態一致性。
## 第四章:Layout 與 UI 組件
### 4.1 基礎 Layout 元件
Compose 提供了幾個基礎的 Layout 元件來組織 UI 結構。這些元件類似於傳統 Android 的 LinearLayout、FrameLayout 等,但設計更加簡潔和靈活。
#### 4.1.1 Column 與 Row
`Column` 和 `Row` 是最常用的佈局元件,分別用於垂直和水平排列子元素。它們的使用非常直觀,對 CSS 熟悉的朋友們是直接無痛上手的:
```kotlin=
@Composable
fun ColumnExample() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("第一行文字")
Text("第二行文字")
Button(onClick = { }) {
Text("按鈕")
}
}
}
```
`verticalArrangement` 控制子元素在垂直方向的排列方式,可以是:
- `spacedBy`(等距分布)
- `SpaceBetween`(兩端對齊)
- `SpaceAround`(周圍留空)
`horizontalAlignment` 則控制子元素的水平對齊方式。這些參數讓你可以精確控制佈局行為,而不需要複雜的 XML 設定。
Row 的使用方式類似,只是方向相反:
```kotlin=
@Composable
fun RowExample() {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Favorite, contentDescription = "喜歡")
Text("項目名稱")
Text("$99", fontWeight = FontWeight.Bold)
}
}
```
#### 4.1.2 Box
`Box` 用於堆疊元素,類似於 FrameLayout。子元素會按照宣告順序疊加,後面的元素會覆蓋前面的元素。這在需要重疊效果時非常有用:
```kotlin=
@Composable
fun OverlayExample() {
Box(modifier = Modifier.size(200.dp)) {
Image(
painter = painterResource(id = R.drawable.background),
contentDescription = "背景圖片",
modifier = Modifier.fillMaxSize()
)
Text(
text = "覆蓋文字",
modifier = Modifier.align(Alignment.Center),
color = Color.White,
fontSize = 20.sp
)
}
}
```
Box 的 `contentAlignment` 參數可以控制預設的對齊方式,而每個子元素也可以透過 `Modifier.align()` 單獨指定對齊位置。
### 4.2 LazyColumn 與 LazyRow
當需要顯示大量項目時,使用 `LazyColumn` 或 `LazyRow` 可以獲得類似 RecyclerView 的效能優化。這些元件只會渲染螢幕上可見的項目,當使用者滾動時才載入新的項目。
```kotlin=
@Composable
fun UserList(users: List<User>) {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(users) { user ->
UserCard(user = user)
}
}
}
@Composable
fun UserCard(user: User) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// 使用者頭像
Box(
modifier = Modifier
.size(48.dp)
.background(Color.LightGray, CircleShape)
)
Column {
Text(
text = user.name,
style = MaterialTheme.typography.titleMedium
)
Text(
text = user.email,
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
}
}
}
}
```
`items` 函數是 LazyColumn 的 DSL 建構器,它接受一個集合並為每個項目產生一個 Composable。這比傳統的 RecyclerView Adapter 簡單許多,不需要建立 ViewHolder 或實作複雜的 Adapter 邏輯。
對於更複雜的列表需求,LazyColumn 還提供了其他有用的函數:
```kotlin=
LazyColumn {
// 單一項目
item {
HeaderComponent()
}
// 多個項目
items(userList) { user ->
UserCard(user)
}
// 帶索引的項目
itemsIndexed(userList) { index, user ->
UserCardWithIndex(index, user)
}
// 頁尾
item {
FooterComponent()
}
}
```
### 4.3 Scaffold:應用程式結構框架
`Scaffold` 是 Material Design 的核心佈局元件,它提供了一個標準的應用程式結構,包含 Top Bar、Bottom Bar、Floating Action Button 等常見元素。使用 Scaffold 可以快速建立符合 Material Design 規範的介面:
```kotlin=
@Composable
fun MainScreen() {
var selectedItem by remember { mutableStateOf(0) }
val items = listOf("首頁", "搜尋", "設定")
Scaffold(
topBar = {
TopAppBar(
title = { Text("我的應用") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
)
},
bottomBar = {
NavigationBar {
items.forEachIndexed { index, item ->
NavigationBarItem(
icon = { Icon(Icons.Filled.Home, contentDescription = item) },
label = { Text(item) },
selected = selectedItem == index,
onClick = { selectedItem = index }
)
}
}
},
floatingActionButton = {
FloatingActionButton(onClick = { /* 新增動作 */ }) {
Icon(Icons.Filled.Add, contentDescription = "新增")
}
}
) { paddingValues ->
// 主要內容區域
Box(modifier = Modifier.padding(paddingValues)) {
when (selectedItem) {
0 -> HomeContent()
1 -> SearchContent()
2 -> SettingsContent()
}
}
}
}
```
注意 Scaffold 的 content 參數會接收一個 `PaddingValues`,這包含了 top bar 和 bottom bar 佔用的空間。你需要將這個 padding 套用到主要內容上,以避免內容被系統 UI 遮擋。
### 4.4 自訂 Layout
雖然內建的 Layout 元件已經能滿足大部分需求,但有時你需要建立自訂的佈局邏輯。Compose 允許實現完全客製化的佈局演算法:
```kotlin=
@Composable
fun CustomLayout(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
// 測量所有子元素
val placeables = measurables.map { measurable ->
measurable.measure(constraints)
}
// 計算佈局大小
val width = placeables.maxOf { it.width }
val height = placeables.sumOf { it.height }
// 放置子元素
layout(width, height) {
var yPosition = 0
placeables.forEach { placeable ->
placeable.placeRelative(x = 0, y = yPosition)
yPosition += placeable.height
}
}
}
}
```
### 4.5 ConstraintLayout
對於複雜的相對佈局需求,Compose 也提供了 `ConstraintLayout`。雖然它不像在 XML 中那麼常用,但在某些情況下仍然方便:
```kotlin=
@Composable
fun ConstraintLayoutExample() {
ConstraintLayout(modifier = Modifier.fillMaxSize()) {
// 建立引用
val (title, description, button) = createRefs()
Text(
text = "標題",
modifier = Modifier.constrainAs(title) {
top.linkTo(parent.top, margin = 16.dp)
start.linkTo(parent.start, margin = 16.dp)
}
)
Text(
text = "這是一段描述文字",
modifier = Modifier.constrainAs(description) {
top.linkTo(title.bottom, margin = 8.dp)
start.linkTo(parent.start, margin = 16.dp)
}
)
Button(
onClick = { },
modifier = Modifier.constrainAs(button) {
bottom.linkTo(parent.bottom, margin = 16.dp)
end.linkTo(parent.end, margin = 16.dp)
}
) {
Text("確認")
}
}
}
```
使用 ConstraintLayout 需要額外的依賴:`implementation("androidx.constraintlayout:constraintlayout-compose:1.0.1")`。
---
## 第五章:進階狀態管理與架構
### 5.1 ViewModel 整合
對於複雜的應用程式,將所有狀態邏輯都放在 Composable 中會導致程式碼難以維護。這時就需要引入架構元件,特別是 ViewModel。ViewModel 的概念對於 Spring 開發者來說應該不陌生,它類似於 Service 層,負責處理業務邏輯和資料管理。
首先需要新增依賴:
```kotlin
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
```
然後建立 ViewModel:
```kotlin=
class UserViewModel : ViewModel() {
private val _users = MutableStateFlow<List<User>>(emptyList())
val users: StateFlow<List<User>> = _users.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
fun loadUsers() {
viewModelScope.launch {
_isLoading.value = true
try {
val result = userRepository.getUsers()
_users.value = result
} catch (e: Exception) {
// 處理錯誤
} finally {
_isLoading.value = false
}
}
}
fun addUser(user: User) {
_users.value = _users.value + user
}
}
```
這裡使用了 Kotlin Flow 的 `StateFlow`,它是一個狀態持有者,類似於 LiveData 但更適合 Kotlin Coroutines。`viewModelScope` 是 ViewModel 提供的協程作用域,當 ViewModel 被清除時會自動取消所有協程,避免記憶體洩漏。
在 Composable 中使用 ViewModel:
```kotlin=
@Composable
fun UserScreen(
viewModel: UserViewModel = viewModel()
) {
val users by viewModel.users.collectAsStateWithLifecycle()
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.loadUsers()
}
if (isLoading) {
LoadingIndicator()
} else {
UserList(users = users)
}
}
```
`collectAsStateWithLifecycle()` 是一個擴充函數,它將 Flow 轉換為 Compose State,並自動處理生命週期。當 Composable 離開畫面時,它會停止收集;回到畫面時會重新開始。避免了不必要的資源消耗。
### 5.2 Side Effects 與 Effect Handlers
Compose 是一個純函數式的系統,但應用程式總是需要執行 [Side Effects](https://developer.android.com/develop/ui/compose/side-effects)(非貶義),例如網路請求、資料庫操作或導航。Compose 提供了幾個 Effect API 來安全地處理這些情況。
#### 5.2.1 LaunchedEffect
`LaunchedEffect` 用於在 Composable 進入組成時啟動協程。它接受一個或多個 key,當這些 key 改變時,之前的協程會被取消並啟動新的協程:
```kotlin
@Composable
fun SearchScreen(query: String) {
var results by remember { mutableStateOf<List<SearchResult>>(emptyList()) }
LaunchedEffect(query) {
if (query.isNotEmpty()) {
delay(300) // 防抖動
results = searchRepository.search(query)
}
}
SearchResults(results)
}
```
這個範例實作了搜尋防抖動(Debouncing)。當 query 改變時,之前的搜尋會被取消,300 毫秒後才執行新的搜尋。這個模式在 Spring 中也相當常見,例如使用 `@Async` 執行非同步任務。
#### 5.2.2 DisposableEffect
`DisposableEffect` 用於需要清理資源的情況,類似於傳統 Android 的 `onStart`/`onStop`:
```kotlin=
@Composable
fun SensorMonitor() {
val context = LocalContext.current
val sensorManager = remember {
context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
}
DisposableEffect(Unit) {
val listener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
// 處理感應器資料
}
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
}
sensorManager.registerListener(
listener,
sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER),
SensorManager.SENSOR_DELAY_NORMAL
)
onDispose {
sensorManager.unregisterListener(listener)
}
}
}
```
`onDispose` 區塊會在 Composable 離開組成或 key 改變時執行,確保資源被正確釋放。
#### 5.2.3 rememberCoroutineScope
當你需要在事件處理器中啟動協程時,可以使用 `rememberCoroutineScope`:
```kotlin
@Composable
fun AnimatedButton() {
val scope = rememberCoroutineScope()
var isExpanded by remember { mutableStateOf(false) }
Button(
onClick = {
scope.launch {
isExpanded = true
delay(2000)
isExpanded = false
}
}
) {
Text(if (isExpanded) "已展開" else "點擊展開")
}
}
```
這個協程作用域綁定到 Composable 的生命週期,當 Composable 離開組成時會自動取消。
### 5.3 CompositionLocal
`CompositionLocal` 是 Compose 的依賴注入機制,類似於 React 的 Context API 或 Spring 的 DI。它允許你在組成樹的任何層級提供值,讓子元件可以存取這些值而不需要透過參數傳遞。
定義 CompositionLocal:
```kotlin=
data class AppConfiguration(
val apiEndpoint: String,
val enableDebugMode: Boolean
)
val LocalAppConfig = compositionLocalOf<AppConfiguration> {
error("未提供 AppConfiguration")
}
```
提供值:
```kotlin=
@Composable
fun App() {
val config = AppConfiguration(
apiEndpoint = "https://api.example.com",
enableDebugMode = BuildConfig.DEBUG
)
CompositionLocalProvider(LocalAppConfig provides config) {
MainScreen()
}
}
```
使用值:
```kotlin=
@Composable
fun ApiCallButton() {
val config = LocalAppConfig.current
Button(onClick = {
makeApiCall(config.apiEndpoint)
}) {
Text("呼叫 API")
}
}
```
Compose 內建了幾個 CompositionLocal,例如 `LocalContext`(提供 Android Context)和 `LocalConfiguration`(提供裝置配置資訊)。這個機制讓你可以避免在整個組成樹中傳遞相同的參數,類似於 Spring 的 `@Autowired`。
### 5.4 單向資料流架構
實現良好的架構需要遵循單向資料流(UDF)原則。在這個模式中,狀態向下流動,事件向上傳遞。可以直接看完整範例:
```kotlin=
// UI State
data class ProductListUiState(
val products: List<Product> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val selectedCategory: Category? = null
)
// UI Events
sealed class ProductListEvent {
data class SelectCategory(val category: Category) : ProductListEvent()
object RefreshProducts : ProductListEvent()
data class AddToCart(val productId: String) : ProductListEvent()
}
// ViewModel
class ProductListViewModel(
private val productRepository: ProductRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(ProductListUiState())
val uiState: StateFlow<ProductListUiState> = _uiState.asStateFlow()
fun onEvent(event: ProductListEvent) {
when (event) {
is ProductListEvent.SelectCategory -> {
selectCategory(event.category)
}
is ProductListEvent.RefreshProducts -> {
loadProducts()
}
is ProductListEvent.AddToCart -> {
addToCart(event.productId)
}
}
}
private fun selectCategory(category: Category) {
_uiState.update { it.copy(selectedCategory = category) }
loadProducts()
}
private fun loadProducts() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
try {
val products = productRepository.getProducts(
_uiState.value.selectedCategory
)
_uiState.update { it.copy(products = products, isLoading = false) }
} catch (e: Exception) {
_uiState.update {
it.copy(error = e.message, isLoading = false)
}
}
}
}
private fun addToCart(productId: String) {
viewModelScope.launch {
productRepository.addToCart(productId)
}
}
}
// UI Layer
@Composable
fun ProductListScreen(
viewModel: ProductListViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
ProductListContent(
uiState = uiState,
onEvent = viewModel::onEvent
)
}
@Composable
fun ProductListContent(
uiState: ProductListUiState,
onEvent: (ProductListEvent) -> Unit
) {
Column {
CategoryTabs(
selectedCategory = uiState.selectedCategory,
onCategorySelected = { category ->
onEvent(ProductListEvent.SelectCategory(category))
}
)
when {
uiState.isLoading -> LoadingIndicator()
uiState.error != null -> ErrorMessage(uiState.error)
else -> ProductGrid(
products = uiState.products,
onAddToCart = { productId ->
onEvent(ProductListEvent.AddToCart(productId))
}
)
}
}
}
```
這個架構模式清楚地展示了關注點分離:ViewModel 負責業務邏輯、UI 只負責渲染和事件傳遞。
## 第六章:導航與路由
### 6.1 Navigation Compose 入門
Navigation Compose 是 Jetpack Navigation 元件的 Compose 版本,它提供了一個型別安全的方式來處理應用程式內的頁面跳轉,與定義 router & path 非常類似。
首先需要新增依賴:
```kotlin
implementation("androidx.navigation:navigation-compose:2.7.7")
```
基本的導航設定包含三個核心概念:NavController(導航控制器)、NavHost(導航宿主)和導航路由。讓我們看一個完整的範例:
```kotlin=
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "home"
) {
composable("home") {
HomeScreen(
onNavigateToProfile = { userId ->
navController.navigate("profile/$userId")
},
onNavigateToSettings = {
navController.navigate("settings")
}
)
}
composable(
route = "profile/{userId}",
arguments = listOf(
navArgument("userId") { type = NavType.StringType }
)
) { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId")
ProfileScreen(
userId = userId,
onNavigateBack = {
navController.popBackStack()
}
)
}
composable("settings") {
SettingsScreen(
onNavigateBack = {
navController.popBackStack()
}
)
}
}
}
```
在這個範例中,我們定義了三個目的地:首頁、個人資料頁和設定頁。個人資料頁接受一個 `userId` 參數,這類似於 Spring MVC 中的 `@PathVariable`。
### 6.2 參數傳遞與深層連結
除了路徑參數,Navigation Compose 還支援查詢參數和複雜物件的傳遞。對於簡單的參數,可以使用路徑或查詢字串:
```kotlin=
// 路徑參數
composable("article/{articleId}") { backStackEntry ->
val articleId = backStackEntry.arguments?.getString("articleId")
ArticleScreen(articleId = articleId)
}
// 查詢參數
composable(
route = "search?query={query}&category={category}",
arguments = listOf(
navArgument("query") {
type = NavType.StringType
defaultValue = ""
},
navArgument("category") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { backStackEntry ->
val query = backStackEntry.arguments?.getString("query") ?: ""
val category = backStackEntry.arguments?.getString("category")
SearchScreen(query = query, category = category)
}
```
對於複雜物件,建議使用 ViewModel 或持久化儲存來共享資料,而不是透過導航參數傳遞。
另外,深層連結(Deep Links)允許從外部(例如通知或網頁連結)直接開啟應用的特定頁面:
```kotlin=
composable(
route = "article/{articleId}",
deepLinks = listOf(
navDeepLink {
uriPattern = "myapp://article/{articleId}"
action = Intent.ACTION_VIEW
},
navDeepLink {
uriPattern = "https://myapp.com/article/{articleId}"
action = Intent.ACTION_VIEW
}
)
) { backStackEntry ->
val articleId = backStackEntry.arguments?.getString("articleId")
ArticleScreen(articleId = articleId)
}
```
### 6.3 巢狀導航與底部導航列
複雜的應用通常需要巢狀導航結構。例如,底部導航列的每個分頁可能有自己的導航堆疊。Navigation Compose 透過巢狀圖表(Nested Graph)來處理這種情況:
```kotlin=
@Composable
fun MainScreen() {
val navController = rememberNavController()
Scaffold(
bottomBar = {
BottomNavigationBar(navController = navController)
}
) { paddingValues ->
NavHost(
navController = navController,
startDestination = "home_graph",
modifier = Modifier.padding(paddingValues)
) {
// 首頁導航圖
navigation(
startDestination = "home",
route = "home_graph"
) {
composable("home") {
HomeScreen(
onNavigateToDetail = { id ->
navController.navigate("home_detail/$id")
}
)
}
composable("home_detail/{id}") { backStackEntry ->
val id = backStackEntry.arguments?.getString("id")
DetailScreen(id = id)
}
}
// 搜尋導航圖
navigation(
startDestination = "search",
route = "search_graph"
) {
composable("search") {
SearchScreen()
}
}
// 個人資料導航圖
navigation(
startDestination = "profile",
route = "profile_graph"
) {
composable("profile") {
ProfileScreen()
}
}
}
}
}
@Composable
fun BottomNavigationBar(navController: NavController) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
NavigationBar {
NavigationBarItem(
icon = { Icon(Icons.Default.Home, contentDescription = "首頁") },
label = { Text("首頁") },
selected = currentRoute?.startsWith("home_graph") == true,
onClick = {
navController.navigate("home_graph") {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
NavigationBarItem(
icon = { Icon(Icons.Default.Search, contentDescription = "搜尋") },
label = { Text("搜尋") },
selected = currentRoute?.startsWith("search_graph") == true,
onClick = {
navController.navigate("search_graph") {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
NavigationBarItem(
icon = { Icon(Icons.Default.Person, contentDescription = "個人") },
label = { Text("個人") },
selected = currentRoute?.startsWith("profile_graph") == true,
onClick = {
navController.navigate("profile_graph") {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
```
這個設定確保了每個分頁都有自己獨立的導航堆疊,並且在切換分頁時會保存和恢復狀態。`launchSingleTop` 防止重複導航到相同的目的地。
### 6.4 型別安全的導航
傳統的字串路由容易出錯,Navigation Compose 2.8.0 引入了型別安全的導航 API:
```kotlin=
// 定義導航目的地
@Serializable
object Home
@Serializable
data class Profile(val userId: String)
@Serializable
data class Settings(val section: String? = null)
// 設定導航
@Composable
fun TypeSafeNavigation() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Home
) {
composable<Home> {
HomeScreen(
onNavigateToProfile = { userId ->
navController.navigate(Profile(userId))
}
)
}
composable<Profile> { backStackEntry ->
val profile = backStackEntry.toRoute<Profile>()
ProfileScreen(userId = profile.userId)
}
composable<Settings> { backStackEntry ->
val settings = backStackEntry.toRoute<Settings>()
SettingsScreen(section = settings.section)
}
}
}
```
---
## 第七章:最佳實踐
### 7.1 效能最佳化
#### 7.1.1 避免不必要的重組
Compose 的智慧重組機制已經很高效,但還是需要注意一些常見陷阱:
```kotlin=
// 不好的實踐:每次重組都建立新的 lambda
@Composable
fun BadExample(items: List<String>) {
LazyColumn {
items(items) { item ->
Button(onClick = { handleClick(item) }) {
Text(item)
}
}
}
}
// 好的實踐:穩定的回調函數
@Composable
fun GoodExample(
items: List<String>,
onItemClick: (String) -> Unit
) {
LazyColumn {
items(items) { item ->
Button(onClick = { onItemClick(item) }) {
Text(item)
}
}
}
}
```
#### 7.1.2 使用 derivedStateOf
當計算型狀態的依賴沒有改變時,可以使用 `derivedStateOf` 避免重新計算:
```kotlin=
@Composable
fun FilteredList(items: List<Item>, searchQuery: String) {
// 只有當 items 或 searchQuery 改變時才重新篩選
val filteredItems by remember(items, searchQuery) {
derivedStateOf {
items.filter { it.name.contains(searchQuery, ignoreCase = true) }
}
}
LazyColumn {
items(filteredItems) { item ->
ItemRow(item)
}
}
}
```
#### 7.1.3 延遲初始化
對於開銷比較大的物件建立,使用 `remember` 配合延遲初始化:
```kotlin
@Composable
fun ExpensiveComponent() {
val expensiveObject = remember {
// 這只會在第一次組成時執行
ExpensiveClass()
}
// 使用 expensiveObject
}
```
### 7.2 專案結構建議
對於大型專案,良好的專案結構至關重要。建議如下:
```
app/
├── ui/
│ ├── components/ # 可重用的 UI 元件
│ │ ├── buttons/
│ │ ├── cards/
│ │ └── inputs/
│ ├── screens/ # 畫面級別的 Composable
│ │ ├── home/
│ │ │ ├── HomeScreen.kt
│ │ │ ├── HomeViewModel.kt
│ │ │ └── HomeUiState.kt
│ │ ├── profile/
│ │ └── settings/
│ ├── navigation/ # 導航相關
│ └── theme/ # 主題和樣式
├── data/ # 資料層
│ ├── repository/
│ ├── local/
│ └── remote/
└── domain/ # 領域層
├── model/
└── usecase/
```
### 7.3 可訪問性(Accessibility)
確保應用程式對所有使用者友善是專業開發的重要部分,Compose 提供了內建的可訪問性支援:
```kotlin
@Composable
fun AccessibleButton() {
Button(
onClick = { /* 動作 */ },
modifier = Modifier.semantics {
contentDescription = "確認按鈕,點擊以提交表單"
role = Role.Button
}
) {
Icon(Icons.Default.Check, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("確認")
}
}
```
使用 `contentDescription` 為非文字元素提供描述,使用 `semantics` block 添加額外的語義資訊。這讓視障使用者可以透過螢幕閱讀器理解和操作你的應用程式。
---
## 附錄:Mermaid 架構圖
### Compose 組成樹結構
```mermaid
graph TD
A[MaterialTheme] --> B[Scaffold]
B --> C[TopAppBar]
B --> D[Content]
B --> E[BottomNavigationBar]
D --> F[LazyColumn]
F --> G[Item 1]
F --> H[Item 2]
F --> I[Item 3]
G --> J[Card]
J --> K[Row]
K --> L[Image]
K --> M[Column]
M --> N[Text: Title]
M --> O[Text: Description]
```
---
### 單向資料流模型
```mermaid
graph LR
A[ViewModel State] -->|向下| B[UI Layer]
B -->|事件向上| C[Event Handler]
C -->|更新狀態| A
style A fill:#e1f5ff
style B fill:#fff4e6
style C fill:#f3e5f5
```
---
### Compose 重組流程
```mermaid
sequenceDiagram
participant State
participant Compose
participant UI
State->>Compose: 狀態改變
Compose->>Compose: 標記需要重組的節點
Compose->>Compose: 執行重組
Compose->>UI: 更新受影響的 UI
Note over Compose: 智慧跳過未改變的部分
```
---
### 導航架構
```mermaid
graph TB
A[NavController] --> B[NavHost]
B --> C[Home Graph]
B --> D[Search Graph]
B --> E[Profile Graph]
C --> C1[Home Screen]
C --> C2[Detail Screen]
D --> D1[Search Screen]
D --> D2[Result Screen]
E --> E1[Profile Screen]
E --> E2[Settings Screen]
style A fill:#4caf50
style B fill:#2196f3
style C fill:#ff9800
style D fill:#ff9800
style E fill:#ff9800
```
---
## 結語
Jetpack Compose 代表了 Android UI 開發的新時代。透過 Declarative Programming、強大的狀態管理和豐富的工具支援,它讓開發者可以更快速、更可靠地建構現代化的應用程式介面。對於有 Java 和 Spring 背景的開發者,許多概念都是熟悉的:DI、生命週期管理、分層架構等。
從傳統的 View 系統過渡到 Compose 需要一些思維轉換,但一旦掌握了核心概念,你會發現開發效率和程式碼品質都有顯著提升。**關鍵原則不外乎是:保持 Composable 函數純淨、Hoisting 到適當的層級、善用 Effect API 處理 Side Effects、遵循 UDF。**