이번 영상에서는 WorkManager를 이용해 주기적으로 앱 캐시를 자동정리하는 시나리오를 구현해 보도록 하겠습니다.
우선은 WorkManager Dependency를 추가합니다. 참고로 WorkManager를 사용하기 위해서는 compileSdkVersion이 31 이상이어야 합니다.
// WorkManager
implementation 'androidx.work:work-runtime-ktx:2.7.1'
다음은 SettingsFragment 화면에 캐시정리 여부를 토글하는 버튼을 추가합니다. 이 설정이 활성화되어 있을 때 WorkManager가 작동하도록 할 것입니다. 그리고 WorkManager의 상태를 텍스트뷰에 표시하도록 했습니다.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
...
+ <TextView
+ android:id="@+id/tv_title_cache"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="16dp"
+ android:layout_marginTop="40dp"
+ android:layout_marginEnd="16dp"
+ android:text="Cache policy"
+ android:textAppearance="@style/TextAppearance.AppCompat.Large"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/rg_sort" />
+
+ <androidx.appcompat.widget.SwitchCompat
+ android:id="@+id/sw_cache_delete"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="16dp"
+ android:layout_marginTop="20dp"
+ android:text="Delete periodically"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@+id/tv_title_cache" />
+
+ <TextView
+ android:id="@+id/tv_work_status"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="34dp"
+ android:text="Status"
+ android:textColor="@color/teal_200"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toEndOf="@+id/sw_cache_delete"
+ app:layout_constraintTop_toBottomOf="@+id/tv_title_cache" />
</androidx.constraintlayout.widget.ConstraintLayout>
다음은 버튼 설정을 저장하는 DataStore를 구현합니다. 우선 리포지토리 인터페이스에 메소드를 추가합니다.
interface BookSearchRepository {
...
// DataStore
+ suspend fun saveCacheDeleteMode(mode: Boolean)
+ suspend fun getCacheDeleteMode(): Flow<Boolean>
}
그리고 메소드를 다음과 같이 구현합니다. 세이브 및 로드에 필요한 booleanPreferencesKey를 추가하면 나머지 구조는 sortMode를 다룰 때와 동일합니다.
class BookSearchRepositoryImpl(
private val db: BookSearchDatabase,
private val dataStore: DataStore<Preferences>
) : BookSearchRepository {
...
// DataStore
private object PreferencesKeys {
val SORT_MODE = stringPreferencesKey("sort_mode")
+ val CACHE_DELETE_MODE = booleanPreferencesKey("cache_delete_mode")
}
+ override suspend fun saveCacheDeleteMode(mode: Boolean) {
+ dataStore.edit { prefs ->
+ prefs[CACHE_DELETE_MODE] = mode
+ }
+ }
+ override suspend fun getCacheDeleteMode(): Flow<Boolean> {
+ return dataStore.data
+ .catch { exception ->
+ if (exception is IOException) {
+ exception.printStackTrace()
+ emit(emptyPreferences())
+ } else {
+ throw exception
+ }
+ }
+ .map { prefs ->
+ prefs[CACHE_DELETE_MODE] ?: false
+ }
+ }
}
그리고 ViewModel 에서 다음과 같이 설정값의 세이브 및 로드를 구현하는 메소드를 작성합니다. 이 부분도 sortMode를 다룰 때와 동일한 구조를 가집니다.
class BookSearchViewModel(
private val bookSearchRepository: BookSearchRepository,
private val workManager: WorkManager
) : ViewModel() {
...
// Datastore
+ fun saveCacheDeleteMode(value: Boolean) = viewModelScope.launch {
+ bookSearchRepository.saveCacheDeleteMode(value)
+ }
+ suspend fun getCacheDeleteMode() = withContext(Dispatchers.IO) {
+ bookSearchRepository.getCacheDeleteMode().first()
+ }
}
다음은 worker 패키지 밑에 백그라운드 작업 내용을 담은 CacheDeleteWorker 클래스를 작성합니다.
class CacheDeleteWorker(
context: Context,
workerParams: WorkerParameters,
) : Worker(context, workerParams) {
override fun doWork(): Result {
return try {
Log.d("WorkManager", "Cache has successfully deleted")
Result.success()
} catch (exception: Exception) {
exception.printStackTrace()
Result.failure()
}
}
}
Worker 클래스를 상속받아 doWork 내부에 백그라운드 작업을 정의하면 되는데, 여기서는 캐시를 제거하는 가상상황을 가정하여 단순 로그를 표시하도록 했습니다. 작업이 성공하면 Result.success(), 실패하면 Result.failure()를 반환하면 됩니다.
다음은 ViewModel에 WorkManager를 사용하기 위한 메소드를 작성합니다. 우선은 WorkManager 객체를 생성자로 전달합니다.
class BookSearchViewModel(
private val bookSearchRepository: BookSearchRepository,
+ private val workManager: WorkManager
) : ViewModel() {
그리고 작업에 필요한 메소드를 정의합니다.
companion object {
private const val WORKER_KEY = "cache_worker"
}
// WorkManager
fun setWork() {
val constraints = Constraints.Builder()
.setRequiresCharging(true)
.setRequiresBatteryNotLow(true)
.build()
val workRequest = PeriodicWorkRequestBuilder<CacheDeleteWorker>(15, TimeUnit.MINUTES)
.setConstraints(constraints)
.build()
workManager.enqueueUniquePeriodicWork(
WORKER_KEY, ExistingPeriodicWorkPolicy.REPLACE, workRequest
)
}
fun deleteWork() = workManager.cancelUniqueWork(WORKER_KEY)
fun getWorkStatus(): LiveData<MutableList<WorkInfo>> =
workManager.getWorkInfosForUniqueWorkLiveData(WORKER_KEY)
우선 WorkManager 작업의 name으로 사용할 WORKER_KEY를 정의합니다. setWork에서는 충전중이고 배터리 잔량이 낮지 않은 경우에만 작업을 수행하도록 constraints를 설정한 뒤 workRequest를 만듭니다. 이 때 15분마다 한 번씩 수행되도록 PeriodicWorkRequestBuilder를 사용하였습니다. 마지막으로 name을 붙여서 작업 큐에 전달하는데, 동일한 작업을 중복해서 등록하지 않도록 enqueueUniquePeriodicWork를 사용했습니다.
deleteWork에서는 WORKER_KEY의 name을 가진 작업을 찾아 삭제하도록 합니다. 그리고 getWorkStatus에서는 현재 작업 큐 내부의 작업 중에서 WORKER_KEY name을 가진 작업의 현재상태를 getWorkInfosForUniqueWorkLiveData 를 이용해 LiveData 타입으로 반환하도록 합니다.
ViewModel에 생성자를 추가했으니 ViewModelProviderFactory도 다음과 같이 변경해 줍니다.
@Suppress("UNCHECKED_CAST")
class BookSearchViewModelProviderFactory(
private val bookSearchRepository: BookSearchRepository,
+ private val workManager: WorkManager,
owner: SavedStateRegistryOwner,
defaultArgs: Bundle? = null,
) : AbstractSavedStateViewModelFactory(owner, defaultArgs) {
override fun <T : ViewModel?> create(
key: String,
modelClass: Class<T>,
handle: SavedStateHandle,
): T {
if (modelClass.isAssignableFrom(BookSearchViewModel::class.java)) {
+ return BookSearchViewModel(bookSearchRepository, workManager, handle) as T
}
throw IllegalArgumentException("ViewModel class not found")
}
}
그리고 MainActivity에서 WorkManager를 싱글톤으로 초기화하고 ViewModel에 전달하면 됩니다.
class MainActivity : AppCompatActivity() {
...
+ private val workManager = WorkManager.getInstance(application)
override fun onCreate(savedInstanceState: Bundle?) {
...
val database: BookSearchDatabase = BookSearchDatabase.getInstance(this)
val bookSearchRepository = BookSearchRepositoryImpl(database, dataStore)
+ val factory = BookSearchViewModelProviderFactory(bookSearchRepository, workManager,this)
bookSearchViewModel = ViewModelProvider(this, factory)[BookSearchViewModel::class.java]
}
}
마지막으로 버튼 동작을 구현합니다.
class SettingsFragment : Fragment() {
...
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
...
+ showWorkStatus()
}
private fun saveSettings() {
+ // WorkManager
+ binding.swCacheDelete.setOnCheckedChangeListener { _, isChecked ->
+ bookSearchViewModel.saveCacheDeleteMode(isChecked)
+ if (isChecked) {
+ bookSearchViewModel.setWork()
+ } else {
+ bookSearchViewModel.deleteWork()
+ }
+ }
}
private fun loadSettings() {
+ // WorkManager
+ lifecycleScope.launch {
+ val mode = bookSearchViewModel.getCacheDeleteMode()
+ binding.swCacheDelete.isChecked = mode
+ }
+ }
+ private fun showWorkStatus() {
+ bookSearchViewModel.getWorkStatus().observe(viewLifecycleOwner) { workInfo ->
+ Log.d("WorkManager", workInfo.toString())
+ if (workInfo.isEmpty()) {
+ binding.tvWorkStatus.text = "No works"
+ } else {
+ binding.tvWorkStatus.text = workInfo[0].state.toString()
+ }
+ }
+ }
}
showWorkStatus()에서는 LiveData로 반환받은 작업상태를 표시하도록 합니다. observe를 써서 구독하면 되는데 초기에는 값이 존재하지 않기 때문에 isEmpty로 분기를 만들어 줍니다. workInfo를 로그캣으로 확인하면 다음과 같은 형식을 가지고 있기 때문에 workInfo[0].state로 현재 상태를 가져와 화면에 표시하도록 했습니다.
[WorkInfo{mId='adc8d726-6a9e-4ad1-bec4-671121b128f5', mState=CANCELLED, mOutputData=Data {}, mTags=[com.example.booksearchapp.worker.CacheDeleteWorker], mProgress=Data {}}]
saveSettings에서는 버튼이 눌렸으면 setWork를 실행시키고 그렇지 않으면 deleteWork로 작업을 삭제합니다. 그리고 loadSettings에는 캐시버튼의 활성화 여부를 반영하는 내용을 추가하면 됩니다.
이제 앱을 백그라운드로 돌리고 로그캣을 확인하면 15분마다 캐시가 삭제되었다는 로그를 확인할 수 있습니다. 또 Android Studio의 App Inspection > Background Task Inspector를 사용하면 백그라운드 작업의 등록상태를 모니터링 할 수도 있습니다.