A:
好的,請開始下一步,另外請問我該怎麼進行app的測試,謝謝。
Q:
好的,我們立刻開始讓 App「動起來」的這一步。同時,我也會為你詳細解析如何為你的 App 建立測試,確保程式碼的品質與穩定性。
這是一個大主題,我會先專注於實現新增交易的功能,然後在第二部分詳細講解測試策略。
第八步:實現互動功能 - 新增一筆交易
這個流程包含幾個部分:
建立導航 (Navigation): 讓 App 可以在「主畫面」和「新增交易畫面」之間切換。
修改
HomeScreen: 加上一個懸浮按鈕 (Floating Action Button, FAB)。建立
AddTransactionScreen: 也就是我們的輸入表單畫面。擴充
ViewModel: 加入儲存新交易的邏輯。
1. 加入 Navigation Compose 依賴
為了管理畫面切換,我們使用 Jetpack Navigation for Compose。
在 app/build.gradle.kts 的 dependencies 中加入:
implementation("androidx.navigation:navigation-compose:2.7.7")
記得點擊 "Sync Now"。
2. 設定導航
我們會修改 MainActivity 和 HomeScreen,讓 MainActivity 作為導航的「主舞台」。
A. 建立 AppNavigation.kt
在 ui 套件下建立一個新檔案,專門管理所有導航路線和主機。
// /ui/AppNavigation.kt
package com.yourcompany.cashflow.ui
import androidx.compose.runtime.Composable
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.yourcompany.cashflow.ui.screens.AddTransactionScreen
import com.yourcompany.cashflow.ui.screens.HomeScreen
// 定義我們的導航路線名稱,避免使用魔法字串
object Routes {
const val HOME = "home"
const val ADD_TRANSACTION = "add_transaction"
}
@Composable
fun AppNavigation() {
val navController = rememberNavController() // 建立導航控制器
NavHost(
navController = navController,
startDestination = Routes.HOME // 設定起始畫面
) {
// 定義主畫面
composable(Routes.HOME) {
HomeScreen(
onNavigateToAddTransaction = {
navController.navigate(Routes.ADD_TRANSACTION)
}
)
}
// 定義新增交易畫面
composable(Routes.ADD_TRANSACTION) {
AddTransactionScreen(
onNavigateBack = {
navController.popBackStack() // 返回上一個畫面
}
)
}
}
}
B. 更新 MainActivity.kt
讓它載入我們的 AppNavigation。
// MainActivity.kt
...
import com.yourcompany.cashflow.ui.AppNavigation // 匯入
...
setContent {
CashFlowTheme {
Surface(...) {
AppNavigation() // 將 HomeScreen() 替換成 AppNavigation()
}
}
}
...
3. 修改 HomeScreen.kt
我們需要加上 FAB 按鈕,並在點擊時觸發導航。
// /ui/screens/HomeScreen.kt
...
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.* // 確保匯入
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
viewModel: TransactionViewModel = hiltViewModel(),
onNavigateToAddTransaction: () -> Unit // 加入導航回呼函式
) {
val transactions by viewModel.allTransactions.collectAsStateWithLifecycle(initialValue = emptyList())
Scaffold(
topBar = { TopAppBar(title = { Text("錢流 - 交易紀錄") }) },
floatingActionButton = {
FloatingActionButton(onClick = onNavigateToAddTransaction) { // 點擊時呼叫導航
Icon(Icons.Filled.Add, contentDescription = "新增交易")
}
}
) { innerPadding ->
TransactionList(
transactions = transactions,
modifier = Modifier.padding(innerPadding)
)
}
}
4. 擴充 ViewModel
AddTransactionScreen 需要知道有哪些分類和帳戶可以選,並且需要一個方法來儲存新交易。
// /ui/viewmodel/TransactionViewModel.kt
...
import androidx.lifecycle.viewModelScope
import com.yourcompany.cashflow.data.local.entity.Transaction // 匯入
import kotlinx.coroutines.launch // 匯入
@HiltViewModel
class TransactionViewModel @Inject constructor(
private val repository: TransactionRepository
// 之後我們會在這裡注入 CategoryRepository 和 AccountRepository
) : ViewModel() {
val allTransactions = repository.getAllTransactionDetails()
// TODO: 未來從 CategoryRepository 取得可選的分類列表
// val categories = ...
// TODO: 未來從 AccountRepository 取得可選的帳戶列表
// val accounts = ...
/**
* 提供給 UI 呼叫,用來插入一筆新的交易
*/
fun insertTransaction(transaction: Transaction) {
// 使用 viewModelScope.launch 來在背景執行緒中執行資料庫操作
viewModelScope.launch {
repository.insertTransaction(transaction)
}
}
}
5. 建立 AddTransactionScreen.kt
這是一個比較複雜的畫面,我們先建立一個基本的骨架。
// /ui/screens/AddTransactionScreen.kt
package com.yourcompany.cashflow.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.yourcompany.cashflow.data.local.entity.Transaction
import com.yourcompany.cashflow.data.local.entity.TransactionType
import com.yourcompany.cashflow.ui.viewmodel.TransactionViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddTransactionScreen(
viewModel: TransactionViewModel = hiltViewModel(),
onNavigateBack: () -> Unit
) {
// 使用 remember 來保存輸入欄位的狀態
var amount by remember { mutableStateOf("") }
var note by remember { mutableStateOf("") }
// TODO: 加入更多狀態,如選擇的分類、帳戶、日期等
Scaffold(
topBar = {
TopAppBar(
title = { Text("新增交易") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = "返回")
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
.padding(16.dp)
.fillMaxWidth()
) {
// 金額輸入欄位
OutlinedTextField(
value = amount,
onValueChange = { amount = it },
label = { Text("金額") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
// 備註輸入欄位
OutlinedTextField(
value = note,
onValueChange = { note = it },
label = { Text("備註 (可選)") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(32.dp))
// 儲存按鈕
Button(
onClick = {
val amountDouble = amount.toDoubleOrNull()
if (amountDouble != null) {
// 建立 Transaction 物件
// 注意:這裡的 accountId 和 subCategoryId 暫時寫死,之後要換成使用者的選擇
val newTransaction = Transaction(
amount = amountDouble,
type = TransactionType.EXPENSE, // 暫時寫死為支出
transactionDate = System.currentTimeMillis(),
accountId = 1, // 暫時寫死
subCategoryId = 1, // 暫時寫死
note = note
)
// 呼叫 ViewModel 的方法來儲存
viewModel.insertTransaction(newTransaction)
// 儲存後返回主畫面
onNavigateBack()
}
},
modifier = Modifier.fillMaxWidth()
) {
Text("儲存")
}
}
}
}
現在執行 App,你應該可以點擊主畫面的 + 按鈕,跳轉到新增頁面,輸入金額和備註,點擊儲存後返回主畫面。如果你有預先在資料庫建立 ID 為 1 的帳戶和分類,你應該會看到主畫面的列表自動更新了!
如何進行 App 的測試
你的第二個問題非常專業!測試是確保 App 品質、減少 Bug、讓你未來敢於修改舊程式碼的基石。Android 的測試主要分為三類:
1. 單元測試 (Unit Tests)
測什麼? 測試單一的類別或函式,完全不依賴 Android 框架。主要用來測試業務邏輯,例如
ViewModel。放哪裡?
app/src/test/java/...工具: JUnit, MockK (一個強大的 Kotlin Mocking 函式庫)。
範例 (
TransactionViewModel測試):
// 在 app/src/test/java/.../viewmodel/TransactionViewModelTest.kt
import com.yourcompany.cashflow.data.local.entity.Transaction
import com.yourcompany.cashflow.data.repository.TransactionRepository
import com.yourcompany.cashflow.ui.viewmodel.TransactionViewModel
import io.mockk.coVerify
import io.mockk.impl.annotations.RelaxedMockK
import io.mockk.junit4.MockKRule
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.setMain
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@ExperimentalCoroutinesApi
class TransactionViewModelTest {
@get:Rule
val mockkRule = MockKRule(this) // 啟用 MockK
@RelaxedMockK // 建立一個假的 Repository
private lateinit var repository: TransactionRepository
private lateinit var viewModel: TransactionViewModel
@Before
fun setUp() {
Dispatchers.setMain(StandardTestDispatcher()) // 設定一個測試用的主執行緒
viewModel = TransactionViewModel(repository)
}
@Test
fun `insertTransaction should call repository`() {
// 1. 準備 (Arrange)
val testTransaction = Transaction(/*... 假資料 ...*/)
// 2. 執行 (Act)
viewModel.insertTransaction(testTransaction)
// 3. 驗證 (Assert)
// 驗證 repository 的 insertTransaction 方法是否被呼叫了,且傳入的參數是 testTransaction
coVerify { repository.insertTransaction(testTransaction) }
}
}
2. UI 測試 (Compose Tests)
測什麼? 測試 Composable 是否正確顯示,以及與其互動是否如預期。
放哪裡?
app/src/androidTest/java/...(因為需要 Android 環境)工具: Compose Test Rule (
createComposeRule)。範例 (
TransactionItem測試):
// 在 app/src/androidTest/java/.../components/TransactionItemTest.kt
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import com.yourcompany.cashflow.data.model.TransactionWithDetails // ... 匯入假資料
import com.yourcompany.cashflow.ui.components.TransactionItem
import com.yourcompany.cashflow.ui.theme.CashFlowTheme
import org.junit.Rule
import org.junit.Test
class TransactionItemTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun transactionItem_displaysDataCorrectly() {
// 1. 準備 (Arrange)
val fakeDetails = TransactionWithDetails(/*... 你的假資料 ...*/)
// 2. 執行 (Act)
composeTestRule.setContent {
CashFlowTheme {
TransactionItem(transactionDetails = fakeDetails)
}
}
// 3. 驗證 (Assert)
// 驗證子類別名稱和金額是否顯示在畫面上
composeTestRule.onNodeWithText(fakeDetails.subCategory.name).assertIsDisplayed()
composeTestRule.onNodeWithText("%,.0f".format(fakeDetails.transaction.amount)).assertIsDisplayed()
}
}
3. 整合測試 (Integration Tests)
測什麼? 測試多個元件協同工作的場景,例如「點擊按鈕 -> 開啟新畫面 -> 儲存資料 -> 驗證資料庫」。
放哪裡?
app/src/androidTest/java/...工具: Compose Test Rule, Hilt Test Support (
@HiltAndroidTest)。概念: 這種測試最複雜也最耗時。你會使用 Hilt 的測試功能來替換掉真實的資料庫,改用一個「記憶體內的假資料庫」,然後模擬使用者的完整操作流程,最後驗證 UI 狀態或假資料庫的內容是否正確。
| 測試類型 | 目的 | 位置 | 主要工具 | 速度 |
| 單元測試 | 驗證獨立的業務邏輯 | test | JUnit, MockK | 最快 |
| UI 測試 | 驗證 Composable 的顯示與互動 | androidTest | Compose Test Rule | 中等 |
| 整合測試 | 驗證完整的用戶流程 | androidTest | Hilt Test, Espresso | 最慢 |
建議: 從單元測試和 UI 測試開始。它們涵蓋了大部分場景,能給你帶來最大的信心。當你熟悉後,再挑戰更複雜的整合測試。