
앱 위젯은 “살짝 보고 슬쩍 눌러서” 쓰는 하나의 미니 앱이라고 볼 수 있습니다. 그런데 위젯은 프로세스 생명주기도 다르고, 백그라운드 제약도 빡빡해서 평소처럼 네트워크 호출을 진행하기는 어렵습니다. 😥
이번 글은 제가 물깜이라는 물 섭취량 위젯을 만들면서 겪은 시행착오를 정리한 기록입니다. 위젯은 RemoteViews + AppWidgetProvider로 구현했고, 브로드캐스트 리시버로 이벤트를 받고 WorkManager로 실제 작업을 실행하는 구조입니다. 중간에 UI가 WorkManager와의 직접적인 의존성을 분리하고 싶어, 추상화 계층(IntakeChecker)를 두어 정리해보았습니다.
지금부터 핵심 코드와 같이 보겠습니다.
<receiver
android:name=".ui.widget.IntakeWidget"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/intake_widget_info" />
</receiver>
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/layout_intake_widget"
android:minWidth="276dp"
android:minHeight="50dp"
android:previewImage="@drawable/img_intake_widget"
android:targetCellWidth="4"
android:targetCellHeight="1"
android:updatePeriodMillis="7200000"
android:widgetCategory="home_screen" />
class IntakeWidget : AppWidgetProvider() {
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
appWidgetIds.forEach { id -> updateIntakeWidgetInfo(context, id) }
}
override fun onReceive(context: Context, intent: Intent) {
super.onReceive(context, intent)
when (IntakeWidgetAction.from(intent.action)) {
ACTION_DRINK -> performDrink(intent, context)
ACTION_REFRESH -> refreshWidget(context)
null -> return
}
}
}
private fun showIntakeWidgetInfo(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetId: Int,
achievementRate: Float,
targetAmount: Int,
totalAmount: Int,
primaryCupAmount: Int,
) {
val views = RemoteViews(context.packageName, R.layout.layout_intake_widget)
val donut = GradientDonutChartView.createBitmap(
context, width = 74.dpToPx(context), height = 74.dpToPx(context),
stroke = 6f, progress = achievementRate
)
views.setImageViewBitmap(R.id.iv_donut_chart, donut)
views.setTextViewText(
R.id.tv_title_date,
context.getString(R.string.intake_widget_home_target, LocalDate.now().monthValue, LocalDate.now().dayOfMonth),
)
views.setTextViewText(
R.id.tv_summary,
context.getString(R.string.home_daily_intake_summary, totalAmount, targetAmount),
)
// 위젯 전체 클릭 -> 앱 열기
views.setOnClickPendingIntent(R.id.layout_intake_widget, MainActivity.newPendingIntent(context))
// “한 컵 마시기” 버튼 -> 음수 기록 작업 트리거
views.setOnClickPendingIntent(R.id.ll_drink, newDrinkPendingIntent(context, appWidgetId, primaryCupAmount))
appWidgetManager.updateAppWidget(appWidgetId, views)
}
private fun newDrinkPendingIntent(context: Context, appWidgetId: Int, amount: Int): PendingIntent {
val intent = Intent(context, IntakeWidget::class.java).apply {
action = ACTION_DRINK.name
putExtra(KEY_EXTRA_AMOUNT, amount)
putExtra(KEY_EXTRA_WIDGET_ID, appWidgetId)
}
val requestCode = REQUEST_CODE_DRINK + appWidgetId
return PendingIntent.getBroadcast(
context,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
위젯은 브로드캐스트 리시버 기반이라, 클릭 이벤트를 받는 것까지는 쉬운데요, 네트워크/DB 같은 실질 작업을 직접 실행하면 안 됩니다. (백그라운드 서비스 제약 + 수 초 내 종료 위험 + ANR 리스크…)
그래서 모든 실작업은 WorkManager에게 위임합니다.
class IntakeCheckerImpl(private val workManager: WorkManager) : IntakeChecker {
override fun drink(amount: Int): UUID {
val request = OneTimeWorkRequestBuilder<DrinkByAmountWorker>()
.setInputData(workDataOf(IntakeChecker.KEY_INTAKE_CHECKER_AMOUNT to amount))
.build()
workManager.enqueue(request)
return request.id
}
override fun checkWidgetInfo(): UUID {
val request = OneTimeWorkRequestBuilder<IntakeWidgetWorker>().build()
workManager.enqueue(request)
return request.id
}
}
private fun updateIntakeWidgetInfo(context: Context, appWidgetId: Int) {
val requestId = intakeChecker.checkWidgetInfo()
val workManager = WorkManager.getInstance(context.applicationContext)
val live = workManager.getWorkInfoByIdLiveData(requestId)
val observer = object : Observer<WorkInfo?> {
override fun onChanged(value: WorkInfo?) {
if (value?.state?.isFinished != true) return
val rate = value.outputData.getFloat(KEY_INTAKE_CHECKER_ACHIEVEMENT_RATE, 0f)
val target = value.outputData.getInt(KEY_INTAKE_CHECKER_TARGET_AMOUNT, 0)
val total = value.outputData.getInt(KEY_INTAKE_CHECKER_TOTAL_AMOUNT, 0)
val amount = value.outputData.getInt(KEY_INTAKE_CHECKER_CUP_AMOUNT, 0)
val appWidgetManager = AppWidgetManager.getInstance(context.applicationContext)
showIntakeWidgetInfo(context.applicationContext, appWidgetManager, appWidgetId, rate, target, total, amount)
live.removeObserver(this) // ★ 메모리 누수 방지: 반드시 제거
}
}
live.observeForever(observer)
}
drink 작업도 유사합니다. 성공 시에만 refreshWidget(context)를 호출해 전체 레이아웃을 다시 그리도록 했습니다.
private fun observeDrinkWorker(context: Context, workId: UUID) {
val live = WorkManager.getInstance(context.applicationContext).getWorkInfoByIdLiveData(workId)
val observer = object : Observer<WorkInfo?> {
override fun onChanged(value: WorkInfo?) {
if (value?.state?.isFinished == true) {
val success = value.outputData.getBoolean(KEY_INTAKE_CHECKER_PERFORM_SUCCESS, false)
if (success) refreshWidget(context)
live.removeObserver(this)
}
}
}
live.observeForever(observer)
}
(1) 음수 기록 작업)
class DrinkByAmountWorker(
appContext: Context,
params: WorkerParameters,
private val intakeRepository: IntakeRepository,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val amount = inputData.getInt(KEY_INTAKE_CHECKER_AMOUNT, 0)
if (amount < CupAmount.MIN_ML) return Result.failure()
return runCatching {
intakeRepository.postIntakeHistory(LocalDateTime.now(), CupAmount(amount)).getOrError()
}.fold(
onSuccess = { Result.success(workDataOf(KEY_INTAKE_CHECKER_PERFORM_SUCCESS to true)) },
onFailure = { Result.failure() },
)
}
}
(2) 위젯 표시용 데이터 조회 작업)
class IntakeWidgetWorker(
appContext: Context,
params: WorkerParameters,
private val membersRepository: MembersRepository,
private val cupsRepository: CupsRepository,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result =
runCatching {
val progress = membersRepository.getMembersProgressInfo(LocalDate.now()).getOrError()
val cups = cupsRepository.getCups().getOrError()
val cupAmount = cups.representCup?.amount?.value ?: 0
workDataOf(
KEY_INTAKE_CHECKER_ACHIEVEMENT_RATE to progress.achievementRate,
KEY_INTAKE_CHECKER_TARGET_AMOUNT to progress.targetAmount,
KEY_INTAKE_CHECKER_TOTAL_AMOUNT to progress.totalAmount,
KEY_INTAKE_CHECKER_CUP_AMOUNT to cupAmount,
)
}.fold(
onSuccess = { Result.success(it) },
onFailure = { Result.failure() },
)
}
안 됩니다. 브로드캐스트 리시버는 수 초 내로 반환해야 하고, 안드 12+에서 백그라운드 서비스 제약이 강해졌습니다. 네트워크 호출/DB 처리/리트라이/제약 관리까지 챙겨주는 WorkManager를 활용해 이를 해결할 수 있었습니다.
PendingIntent 충돌이었습니다. requestCode에 appWidgetId를 섞고, FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE로 고정해서 해결했습니다.
위젯/브로드캐스트에는 LifecycleOwner가 없어서 어쩔 수 없이 observeForever를 씁니다.
대신 isFinished → removeObserver(this) 패턴을 습관처럼 넣었습니다. 그리고 항상 applicationContext만 쓰도록 고정했습니다.
커스텀 뷰는 위젯에서 사용하기에 제한이 많습니다. 비트맵으로 렌더링 후 setImageViewBitmap이 깔끔한 우회 방법이었습니다.
지금은 enqueue()를 호출할 때마다 새로운 작업이 계속 쌓이는 방식이라, 빠르게 버튼을 연타하면 같은 작업이 여러 번 실행될 수 있습니다.
이런 경우에는 enqueueUniqueWork(...)를 쓰는 게 더 안전합니다.
지금 구조는 제출은 IntakeChecker가 책임지고, 관찰은 위젯에서 getWorkInfoByIdLiveData로 합니다.
위젯 특성상 observeForever가 필요하니 절충했는데, 더 깔끔하게 가려면 선택지는 두 가지 정도가 있습니다:
이번에는 시간 대비 안정성을 택해 1번 방향으로 점진 개선하는 메모만 남겼습니다. 야무지게 리팩토링하다 보면 UI는 “무엇을 한다”만 남기고 “어떻게 동작하나”는 다 추상화 뒤로 숨길 수 있을 겁니다. 😎
위젯은 브로드캐스트로 얇게 이벤트만 받고, 실질 로직은 WorkManager로 밀어 넣는다면—위젯의 동작 정도는 마스터할 수 있습니다.
그리고 UI는 추상화 계층(IntakeChecker)만 알고, 키/계약을 한 곳에 모아두면 키값이나 동작이 엇갈릴 일이 줄어들게 됩니다.
아직 의존성 등이 모호해서 아쉬운 부분이 있지만요. 🥲
누군가 위젯을 제작하면서 삽질할 누군가에게 도움이 되길 바라며!
글을 마칩니다. 🎉