안드로이드 앱 위젯 만들기 (with. 트러블슈팅)

GongBaek·2025년 8월 20일
post-thumbnail

앱 위젯은 “살짝 보고 슬쩍 눌러서” 쓰는 하나의 미니 앱이라고 볼 수 있습니다. 그런데 위젯은 프로세스 생명주기도 다르고, 백그라운드 제약도 빡빡해서 평소처럼 네트워크 호출을 진행하기는 어렵습니다. 😥

이번 글은 제가 물깜이라는 물 섭취량 위젯을 만들면서 겪은 시행착오를 정리한 기록입니다. 위젯은 RemoteViews + AppWidgetProvider로 구현했고, 브로드캐스트 리시버로 이벤트를 받고 WorkManager로 실제 작업을 실행하는 구조입니다. 중간에 UI가 WorkManager와의 직접적인 의존성을 분리하고 싶어, 추상화 계층(IntakeChecker)를 두어 정리해보았습니다.

지금부터 핵심 코드와 같이 보겠습니다.


1) 위젯 만드는 과정 (기본 골격)

1-1. 매니페스트 등록

<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>
  • exported="false": 외부 앱이 우리 브로드캐스트 리시버를 호출 못 하게 차단.
  • @xml/intake_widget_info: 위젯 메타데이터(레이아웃, 크기, 업데이트 주기) 선언.

1-2. 위젯 메타데이터

<?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" />
  • initialLayout: RemoteViews 레이아웃. XML 기반으로 구성했습니다. 사용 가능한 컴포넌트가 한정되어 있어 미리 확인하시면 좋을 듯합니다.
  • updatePeriodMillis=7200000(2시간): 시스템이 최소 주기로 갱신 트리거. 실시간 반영은 별도로 브로드캐스트로 갱신합니다.

1-3. AppWidgetProvider 뼈대

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
        }
    }
}
  • 홈 런처가 onUpdate를 불러주면 각 위젯 인스턴스 id 별로 UI 업데이트.
  • 사용자가 위젯을 눌렀을 때는 onReceive로 들어와 커스텀 액션 분기.

1-4. RemoteViews 채우기 + 클릭 액션 연결

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)
}
  • RemoteViews 제약 때문에 커스텀 뷰 직접 넣기 힘듭니다. 대신 GradientDonutChartView.createBitmap(...)로 비트맵을 생성해서 ImageView에 꽂는 전략을 사용했습니다. 생각보다 그럴듯하게 완성된 모습을 볼 수 있습니다.
  • MainActivity.newPendingIntent(...): 위젯 터치 시 앱 진입.
  • newDrinkPendingIntent(...): 브로드캐스트로 커스텀 액션(ACTION_DRINK)을 날립니다.
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,
    )
}
  • 요 포인트 중요: requestCode에 appWidgetId를 섞어 인스턴스별 PendingIntent 충돌을 회피합니다. (안 그러면 여러 개 깔았을 때 엉뚱한 인스턴스가 반응하거나 Extras가 덮어씌워질 수 있습니다.)
  • FLAG_IMMUTABLE은 최신 안드로이드에서 필수에 가깝습니다.

2) 동작 원리: 브로드캐스트 → WorkManager → 위젯 갱신

위젯은 브로드캐스트 리시버 기반이라, 클릭 이벤트를 받는 것까지는 쉬운데요, 네트워크/DB 같은 실질 작업을 직접 실행하면 안 됩니다. (백그라운드 서비스 제약 + 수 초 내 종료 위험 + ANR 리스크…)

그래서 모든 실작업은 WorkManager에게 위임합니다.

  • 위젯 클릭 → IntakeWidget.onReceive()
  • 액션 분기:
    • ACTION_DRINK → intakeChecker.drink(amount)로 작업 enqueuing
    • ACTION_REFRESH → 현재 인스턴스들 재갱신
  • WorkManager의 작업 완료를 관찰해서 결과가 오면 AppWidgetManager.updateAppWidget(...)으로 UI를 갱신합니다.

3) 백그라운드 작업: WorkManager + 추상화 계층

3-1. UI가 WorkManager를 직접 모르게:

IntakeChecker

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
    }
}
  • 위젯(UI)은 workManager.enqueue(...) 같은 구현 디테일을 몰라도 intakeChecker.drink()/checkWidgetInfo()만 호출하면 됩니다.
  • 반환값은 UUID (작업 id). 이 id로 완료 상태를 관찰합니다.
    • 아쉬운 점: 관찰도 추상화로 숨기면 더 깔끔하지만, 위젯에는 Lifecycle이 없어 observeForever를 써야 해서 일단 제출과 관찰을 분리하는 선에서 타협했습니다.

3-2. 작업 결과 관찰: getWorkInfoByIdLiveData + observeForever

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)
}
  • 생명주기 오너가 없는 브로드캐스트 리시버/위젯 문맥이라 observeForever를 씁니다.
  • 중요: isFinished 체크 후 반드시 removeObserver(this)로 정리. 안 그러면 우리가 무서워하는 메모리 릭(Memory Leak)이 발생할 수 있습니다.
  • applicationContext만 사용해 액티비티 컨텍스트 참조를 피합니다.

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)
}

3-3. Worker 구현

(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() },
        )
    }
}
  • 입력 데이터로 컵 용량을 받아 검증 → 저장소 호출 → 성공 여부를 outputData에 담아 반환.
  • 실패 시 Result.failure()로 끝내서 UI가 재시도나 메시지를 결정할 수 있게 했습니다.

(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() },
        )
}
  • 오늘 날짜(LocalDate.now()) 기준 진행률/목표/누적을 가져오고, 대표 컵 용량도 같이 반환.
  • 모든 키는 IntakeChecker.Companion에 상수로 모아 레이어 간 계약을 한 곳에서 관리했습니다.

4) 트러블슈팅 모음 🔧

4-1. “위젯에서 바로 API 호출하면 안 되나요?”

안 됩니다. 브로드캐스트 리시버는 수 초 내로 반환해야 하고, 안드 12+에서 백그라운드 서비스 제약이 강해졌습니다. 네트워크 호출/DB 처리/리트라이/제약 관리까지 챙겨주는 WorkManager를 활용해 이를 해결할 수 있었습니다.

4-2. 여러 위젯 인스턴스가 있을 때 클릭이 꼬인다면

PendingIntent 충돌이었습니다. requestCode에 appWidgetId를 섞고, FLAG_UPDATE_CURRENT | FLAG_IMMUTABLE로 고정해서 해결했습니다.

4-3. observeForever 누수 의심

위젯/브로드캐스트에는 LifecycleOwner가 없어서 어쩔 수 없이 observeForever를 씁니다.

대신 isFinished → removeObserver(this) 패턴을 습관처럼 넣었습니다. 그리고 항상 applicationContext만 쓰도록 고정했습니다.

4-4. 위젯 UI 상태가 최신이 아니라면

  • 수동 새로고침 액션(ACTION_REFRESH)을 두고, 음수 기록 성공 시 즉시 refreshWidget을 호출.
  • 시스템 주기(updatePeriodMillis)는 최소 보조 수단이라고 생각하세요. 실시간은 직접 트리거를 진행했습니다.

4-5. RemoteViews 제약 때문에 둥근 도넛 차트가 안 그려진다

커스텀 뷰는 위젯에서 사용하기에 제한이 많습니다. 비트맵으로 렌더링 후 setImageViewBitmap이 깔끔한 우회 방법이었습니다.

4-6. 빠르게 연타하면 작업이 중복 기록된다

지금은 enqueue()를 호출할 때마다 새로운 작업이 계속 쌓이는 방식이라, 빠르게 버튼을 연타하면 같은 작업이 여러 번 실행될 수 있습니다.
이런 경우에는 enqueueUniqueWork(...)를 쓰는 게 더 안전합니다.

  • ExistingWorkPolicy.KEEP: 이미 실행 중인 작업이 있으면 새 요청은 무시
  • REPLACE: 기존 작업을 취소하고 새 작업으로 교체
  • APPEND: 기존 작업이 끝난 뒤 이어서 실행

5) 아키텍처 고민: UI ↔ WorkManager 완전 분리까지

지금 구조는 제출은 IntakeChecker가 책임지고, 관찰은 위젯에서 getWorkInfoByIdLiveData로 합니다.

위젯 특성상 observeForever가 필요하니 절충했는데, 더 깔끔하게 가려면 선택지는 두 가지 정도가 있습니다:

  1. 추상화에 관찰까지 포함
    • IntakeChecker.drink(amount): LiveData<Result> 같은 형태로 감싸기.
    • 위젯은 결과만 보고 UI 갱신. (여전히 observeForever는 필요하지만 Work API 의존이 줄어듭니다.)
  2. 완전 비동기 브로드캐스트 콜백
    • Worker 완료 시 또 다른 브로드캐스트를 보내고, 위젯이 그걸 받아 갱신.
    • UI는 “아이디 관찰”조차 몰라도 됩니다. 대신 브로드캐스트 계약이 하나 더 늘고, 실패/리트라이 핸들링을 신경 써야 해요.

이번에는 시간 대비 안정성을 택해 1번 방향으로 점진 개선하는 메모만 남겼습니다. 야무지게 리팩토링하다 보면 UI는 “무엇을 한다”만 남기고 “어떻게 동작하나”는 다 추상화 뒤로 숨길 수 있을 겁니다. 😎


6) 전체 흐름 한 번에 보기

  1. 홈 런처가 onUpdate 호출 → checkWidgetInfo() 작업 enqueue
  2. Worker가 저장소에서 진행률/목표/누적/대표 컵 용량 조회 → outputData로 반환
  3. 위젯에서 getWorkInfoByIdLiveData(...).observeForever로 완료 감지 → showIntakeWidgetInfo(...)로 RemoteViews 갱신
  4. 사용자가 “한 컵” 버튼 클릭 → ACTION_DRINK 브로드캐스트
  5. drink(amount) 작업 enqueue → 성공 시 ACTION_REFRESH로 전체 갱신

마무리

위젯은 브로드캐스트로 얇게 이벤트만 받고, 실질 로직은 WorkManager로 밀어 넣는다면—위젯의 동작 정도는 마스터할 수 있습니다.

그리고 UI는 추상화 계층(IntakeChecker)만 알고, 키/계약을 한 곳에 모아두면 키값이나 동작이 엇갈릴 일이 줄어들게 됩니다.

아직 의존성 등이 모호해서 아쉬운 부분이 있지만요. 🥲

누군가 위젯을 제작하면서 삽질할 누군가에게 도움이 되길 바라며!
글을 마칩니다. 🎉

profile
Junior Android Developer

0개의 댓글