葉聖羣 Aaron (呱呱)
    • Create new note
    • Create a note from template
      • Sharing URL Link copied
      • /edit
      • View mode
        • Edit mode
        • View mode
        • Book mode
        • Slide mode
        Edit mode View mode Book mode Slide mode
      • Customize slides
      • Note Permission
      • Read
        • Only me
        • Signed-in users
        • Everyone
        Only me Signed-in users Everyone
      • Write
        • Only me
        • Signed-in users
        • Everyone
        Only me Signed-in users Everyone
      • Engagement control Commenting, Suggest edit, Emoji Reply
    • Invite by email
      Invitee

      This note has no invitees

    • Publish Note

      Share your work with the world Congratulations! 🎉 Your note is out in the world Publish Note No publishing access yet

      Your note will be visible on your profile and discoverable by anyone.
      Your note is now live.
      This note is visible on your profile and discoverable online.
      Everyone on the web can find and read all notes of this public team.

      Your account was recently created. Publishing will be available soon, allowing you to share notes on your public page and in search results.

      Your team account was recently created. Publishing will be available soon, allowing you to share notes on your public page and in search results.

      Explore these features while you wait
      Complete general settings
      Bookmark and like published notes
      Write a few more notes
      Complete general settings
      Write a few more notes
      See published notes
      Unpublish note
      Please check the box to agree to the Community Guidelines.
      View profile
    • Commenting
      Permission
      Disabled Forbidden Owners Signed-in users Everyone
    • Enable
    • Permission
      • Forbidden
      • Owners
      • Signed-in users
      • Everyone
    • Suggest edit
      Permission
      Disabled Forbidden Owners Signed-in users Everyone
    • Enable
    • Permission
      • Forbidden
      • Owners
      • Signed-in users
    • Emoji Reply
    • Enable
    • Versions and GitHub Sync
    • Note settings
    • Note Insights New
    • Engagement control
    • Make a copy
    • Transfer ownership
    • Delete this note
    • Save as template
    • Insert from template
    • Import from
      • Dropbox
      • Google Drive
      • Gist
      • Clipboard
    • Export to
      • Dropbox
      • Google Drive
      • Gist
    • Download
      • Markdown
      • HTML
      • Raw HTML
Menu Note settings Note Insights Versions and GitHub Sync Sharing URL Create Help
Create Create new note Create a note from template
Menu
Options
Engagement control Make a copy Transfer ownership Delete this note
Import from
Dropbox Google Drive Gist Clipboard
Export to
Dropbox Google Drive Gist
Download
Markdown HTML Raw HTML
Back
Sharing URL Link copied
/edit
View mode
  • Edit mode
  • View mode
  • Book mode
  • Slide mode
Edit mode View mode Book mode Slide mode
Customize slides
Note Permission
Read
Only me
  • Only me
  • Signed-in users
  • Everyone
Only me Signed-in users Everyone
Write
Only me
  • Only me
  • Signed-in users
  • Everyone
Only me Signed-in users Everyone
Engagement control Commenting, Suggest edit, Emoji Reply
  • Invite by email
    Invitee

    This note has no invitees

  • Publish Note

    Share your work with the world Congratulations! 🎉 Your note is out in the world Publish Note No publishing access yet

    Your note will be visible on your profile and discoverable by anyone.
    Your note is now live.
    This note is visible on your profile and discoverable online.
    Everyone on the web can find and read all notes of this public team.

    Your account was recently created. Publishing will be available soon, allowing you to share notes on your public page and in search results.

    Your team account was recently created. Publishing will be available soon, allowing you to share notes on your public page and in search results.

    Explore these features while you wait
    Complete general settings
    Bookmark and like published notes
    Write a few more notes
    Complete general settings
    Write a few more notes
    See published notes
    Unpublish note
    Please check the box to agree to the Community Guidelines.
    View profile
    Engagement control
    Commenting
    Permission
    Disabled Forbidden Owners Signed-in users Everyone
    Enable
    Permission
    • Forbidden
    • Owners
    • Signed-in users
    • Everyone
    Suggest edit
    Permission
    Disabled Forbidden Owners Signed-in users Everyone
    Enable
    Permission
    • Forbidden
    • Owners
    • Signed-in users
    Emoji Reply
    Enable
    Import from Dropbox Google Drive Gist Clipboard
       Owned this note    Owned this note      
    Published Linked with GitHub
    1
    • Any changes
      Be notified of any changes
    • Mention me
      Be notified of mention me
    • Unsubscribe
    # 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。**

    Import from clipboard

    Paste your markdown or webpage here...

    Advanced permission required

    Your current role can only read. Ask the system administrator to acquire write and comment permission.

    This team is disabled

    Sorry, this team is disabled. You can't edit this note.

    This note is locked

    Sorry, only owner can edit this note.

    Reach the limit

    Sorry, you've reached the max length this note can be.
    Please reduce the content or divide it to more notes, thank you!

    Import from Gist

    Import from Snippet

    or

    Export to Snippet

    Are you sure?

    Do you really want to delete this note?
    All users will lose their connection.

    Create a note from template

    Create a note from template

    Oops...
    This template has been removed or transferred.
    Upgrade
    All
    • All
    • Team
    No template.

    Create a template

    Upgrade

    Delete template

    Do you really want to delete this template?
    Turn this template into a regular note and keep its content, versions, and comments.

    This page need refresh

    You have an incompatible client version.
    Refresh to update.
    New version available!
    See releases notes here
    Refresh to enjoy new features.
    Your user state has changed.
    Refresh to load new user state.

    Sign in

    Forgot password
    or
    Sign in via Facebook Sign in via X(Twitter) Sign in via GitHub Sign in via Dropbox Sign in with Wallet
    Wallet ( )
    Connect another wallet

    New to HackMD? Sign up

    By signing in, you agree to our terms of service.

    Help

    • English
    • 中文
    • Français
    • Deutsch
    • 日本語
    • Español
    • Català
    • Ελληνικά
    • Português
    • italiano
    • Türkçe
    • Русский
    • Nederlands
    • hrvatski jezik
    • język polski
    • Українська
    • हिन्दी
    • svenska
    • Esperanto
    • dansk

    Documents

    Help & Tutorial

    How to use Book mode

    Slide Example

    API Docs

    Edit in VSCode

    Install browser extension

    Contacts

    Feedback

    Discord

    Send us email

    Resources

    Releases

    Pricing

    Blog

    Policy

    Terms

    Privacy

    Cheatsheet

    Syntax Example Reference
    # Header Header 基本排版
    - Unordered List
    • Unordered List
    1. Ordered List
    1. Ordered List
    - [ ] Todo List
    • Todo List
    > Blockquote
    Blockquote
    **Bold font** Bold font
    *Italics font* Italics font
    ~~Strikethrough~~ Strikethrough
    19^th^ 19th
    H~2~O H2O
    ++Inserted text++ Inserted text
    ==Marked text== Marked text
    [link text](https:// "title") Link
    ![image alt](https:// "title") Image
    `Code` Code 在筆記中貼入程式碼
    ```javascript
    var i = 0;
    ```
    var i = 0;
    :smile: :smile: Emoji list
    {%youtube youtube_id %} Externals
    $L^aT_eX$ LaTeX
    :::info
    This is a alert area.
    :::

    This is a alert area.

    Versions and GitHub Sync
    Get Full History Access

    • Edit version name
    • Delete

    revision author avatar     named on  

    More Less

    Note content is identical to the latest version.
    Compare
      Choose a version
      No search result
      Version not found
    Sign in to link this note to GitHub
    Learn more
    This note is not linked with GitHub
     

    Feedback

    Submission failed, please try again

    Thanks for your support.

    On a scale of 0-10, how likely is it that you would recommend HackMD to your friends, family or business associates?

    Please give us some advice and help us improve HackMD.

     

    Thanks for your feedback

    Remove version name

    Do you want to remove this version name and description?

    Transfer ownership

    Transfer to
      Warning: is a public team. If you transfer note to this team, everyone on the web can find and read this note.

        Link with GitHub

        Please authorize HackMD on GitHub
        • Please sign in to GitHub and install the HackMD app on your GitHub repo.
        • HackMD links with GitHub through a GitHub App. You can choose which repo to install our App.
        Learn more  Sign in to GitHub

        Push the note to GitHub Push to GitHub Pull a file from GitHub

          Authorize again
         

        Choose which file to push to

        Select repo
        Refresh Authorize more repos
        Select branch
        Select file
        Select branch
        Choose version(s) to push
        • Save a new version and push
        • Choose from existing versions
        Include title and tags
        Available push count

        Pull from GitHub

         
        File from GitHub
        File from HackMD

        GitHub Link Settings

        File linked

        Linked by
        File path
        Last synced branch
        Available push count

        Danger Zone

        Unlink
        You will no longer receive notification when GitHub file changes after unlink.

        Syncing

        Push failed

        Push successfully