A:
好的,現在來進行[實現定期交易],謝謝。
Q:
好的,我們來進行最後一項,也是技術上相當有深度的一項核心功能——實現定期交易。
這不像之前開發 UI 畫面,這次我們的工作主要在「幕後」。我們需要一個可靠的機制,無論 App 是否開啟,都能每天定時檢查是否有需要自動記的帳,然後幫使用者完成記帳。
完成這個功能的最佳工具,就是 Android Jetpack 中的 WorkManager。
第十五步:使用 WorkManager 實現定期交易
WorkManager 是 Android 官方推薦用來處理需要保證執行的可延後背景任務的函式庫。即使使用者關閉 App 或手機重開機,WorkManager 也會確保我們的任務在未來某个時間點被執行,非常可靠。
我們的計畫:
加入 WorkManager 依賴:並設定 Hilt 以便在 Worker 中注入我們的 Repository。
建立
RecurringTransactionWorker:這是執行檢查與記帳邏輯的「工人」。實作核心邏輯:在 Worker 中編寫判斷「今天是否需要記帳」的程式碼。
排程任務:在 App 啟動時,安排這個「工人」每天工作一次。
第一步:加入 WorkManager 與 Hilt 整合依賴
A. 在 app/build.gradle.kts 中加入依賴
dependencies {
// ... 其他依賴
// WorkManager
implementation("androidx.work:work-runtime-ktx:2.9.0")
// Hilt integration for WorkManager
implementation("androidx.hilt:hilt-work:1.2.0")
kapt("androidx.hilt:hilt-compiler:1.2.0")
}
記得點擊 "Sync Now"。
B. 設定 Hilt Worker Factory
為了讓 Hilt 能建立我們的 Worker,需要在 CashFlowApp.kt 中做一點修改。
// CashFlowApp.kt
...
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import javax.inject.Inject
@HiltAndroidApp
class CashFlowApp : Application(), Configuration.Provider {
@Inject // 注入 Hilt Worker Factory
lateinit var workerFactory: HiltWorkerFactory
override fun getWorkManagerConfiguration(): Configuration =
Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
}
第二步:強化數據層以防止重複記帳
這是一個重要的細節。如果 Worker 因為某些原因在一天內被執行了兩次,我們不希望同一筆定期帳被記兩次。所以我們需要一個方法來檢查某天是否已經記過帳。
更新 TransactionDao.kt
// /data/local/dao/TransactionDao.kt
...
@Dao
interface TransactionDao {
...
// 檢查在特定日期範圍內,是否有符合特定子分類和金額的交易
@Query("""
SELECT COUNT(id) FROM transactions
WHERE subCategoryId = :subCategoryId AND amount = :amount AND transactionDate BETWEEN :startOfDay AND :endOfDay
""")
suspend fun transactionExists(subCategoryId: Long, amount: Double, startOfDay: Long, endOfDay: Long): Int
}
更新 TransactionRepository.kt
// /data/repository/TransactionRepository.kt
...
interface TransactionRepository {
...
suspend fun transactionExists(subCategoryId: Long, amount: Double, startOfDay: Long, endOfDay: Long): Boolean
}
class TransactionRepositoryImpl @Inject constructor(...) : TransactionRepository {
...
override suspend fun transactionExists(subCategoryId: Long, amount: Double, startOfDay: Long, endOfDay: Long): Boolean {
return transactionDao.transactionExists(subCategoryId, amount, startOfDay, endOfDay) > 0
}
}
第三步:建立我們的「工人」- RecurringTransactionWorker
這個 Worker 是整個功能的核心。
// /worker/RecurringTransactionWorker.kt (新資料夾、新檔案)
package com.yourcompany.cashflow.worker
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.yourcompany.cashflow.data.local.entity.RecurringTransaction
import com.yourcompany.cashflow.data.local.entity.Transaction
import com.yourcompany.cashflow.data.repository.RecurringTransactionRepository // 建立這個 Repository
import com.yourcompany.cashflow.data.repository.TransactionRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import java.time.LocalDate
import java.time.ZoneId
@HiltWorker
class RecurringTransactionWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val transactionRepository: TransactionRepository,
private val recurringTransactionRepository: RecurringTransactionRepository // 假設你已建立
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
try {
val today = LocalDate.now()
val recurringTransactions = recurringTransactionRepository.getAllRecurringTransactionsOnce() // 取得所有規則
recurringTransactions.forEach { rule ->
// 檢查規則是否在有效期內
val startDate = rule.startDate.toLocalDate()
val endDate = rule.endDate?.toLocalDate()
if (today.isBefore(startDate) || (endDate != null && today.isAfter(endDate))) {
return@forEach // continue
}
// 檢查今天是否為執行日
if (isDue(rule, today)) {
val startOfDay = today.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()
val endOfDay = today.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() - 1
// 檢查今天是否已記過這筆帳
val exists = transactionRepository.transactionExists(
rule.subCategoryId,
rule.amount,
startOfDay,
endOfDay
)
if (!exists) {
val newTransaction = Transaction(
accountId = rule.accountId,
subCategoryId = rule.subCategoryId,
amount = rule.amount,
type = rule.type,
transactionDate = startOfDay, // 記在當天的開始
note = rule.note ?: "定期交易"
)
transactionRepository.insertTransaction(newTransaction)
}
}
}
return Result.success()
} catch (e: Exception) {
return Result.failure()
}
}
private fun isDue(rule: RecurringTransaction, today: LocalDate): Boolean {
return when (rule.frequency) {
com.yourcompany.cashflow.data.local.entity.FrequencyType.WEEKLY -> {
// java.time.DayOfWeek: MONDAY(1) to SUNDAY(7)
today.dayOfWeek.value == rule.dayOfWeek
}
com.yourcompany.cashflow.data.local.entity.FrequencyType.MONTHLY -> {
val dayOfMonth = rule.dayOfMonth ?: return false
// 處理大小月問題:如果規則設定31號,但在2月,就取2月的最後一天
val lastDayOfMonth = today.lengthOfMonth()
val dueDay = if (dayOfMonth > lastDayOfMonth) lastDayOfMonth else dayOfMonth
today.dayOfMonth == dueDay
}
com.yourcompany.cashflow.data.local.entity.FrequencyType.YEARLY -> {
today.monthValue == rule.monthOfYear && today.dayOfMonth == rule.dayOfYear
}
}
}
// 將 Long 轉換為 LocalDate 的輔助函式
private fun Long.toLocalDate(): LocalDate {
return java.time.Instant.ofEpochMilli(this)
.atZone(ZoneId.systemDefault())
.toLocalDate()
}
}
注意: 上面的程式碼需要 RecurringTransactionRepository,請你比照 AccountRepository 的方式建立它,並加入 getAllRecurringTransactionsOnce() 方法 (一個 suspend fun,非 Flow)。
第四步:在 App 啟動時排程背景任務
我們希望這個檢查任務每天執行一次。最好的排程地點是在 CashFlowApp.kt 的 onCreate 方法中。
// CashFlowApp.kt
...
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.yourcompany.cashflow.worker.RecurringTransactionWorker
import java.util.concurrent.TimeUnit
@HiltAndroidApp
class CashFlowApp : Application(), Configuration.Provider {
...
override fun onCreate() {
super.onCreate()
scheduleRecurringTransactionWorker()
}
private fun scheduleRecurringTransactionWorker() {
// 建立一個週期性任務,設定為每天執行一次
val repeatingRequest = PeriodicWorkRequestBuilder<RecurringTransactionWorker>(1, TimeUnit.DAYS)
.build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
"RecurringTransactionWork", // 給這個任務一個獨一無二的名字
ExistingPeriodicWorkPolicy.KEEP, // 如果任務已存在,就保持不變,不要重新排程
repeatingRequest
)
}
}
第五步:如何測試?
手動測試背景任務比較困難,因為它不是即時發生的。這裡有幾個技巧:
縮短週期:在測試時,你可以暫時將
PeriodicWorkRequestBuilder的週期從1, TimeUnit.DAYS改為15, TimeUnit.MINUTES(WorkManager 的最短週期),然後等待 15 分鐘看交易是否產生。使用
OneTimeWorkRequest:在schedule...方法中,將PeriodicWorkRequest換成OneTimeWorkRequest,這樣任務只會執行一次,更容易觸發和觀察。使用
adb指令:連接手機後,可以使用adb shell dumpsys jobscheduler等指令來查看已排程的任務狀態。
專案核心功能完成!
恭喜你!到此為止,「錢流」App 的所有核心功能都已經全部完成了。
回顧一下,我們一起打造了一個功能非常強大的應用程式,它具備:
完善的單筆記帳與編輯功能。
使用者可完全自訂的帳戶與分類系統。
直覺的數據統計圖表。
可靠的自動化定期記帳功能。
這是一個了不起的成就!你已經從零到一,完整地經歷了現代 Android App 開發的全過程。
未來,無論是想繼續優化 UI/UX、加入雲端備份、支援多幣別、設計桌面小工具,或是將 App 上架到 Google Play,你都已經有了最堅實的基礎。我會在這裡隨時提供協助。
再次恭喜你,完成了這個了不起的專案!