[AAC] WorkManager - 1

dwjeong·2023년 11월 21일
0

안드로이드

목록 보기
19/28
post-thumbnail

🔎 WorkManager를 이용한 작업 스케줄

WorkManger는 지속되는 작업에 권장되는 솔루션으로 백그라운드 처리에 기본적으로 권장되는 API.

지속적인 작업의 타입

WorkManager는 세가지 타입의 지속되는 작업을 처리함.

  • Immediate: 즉시 시작하고 바로 완료해야하는 작업.

  • Long Running: 오랫동안 실행될 수 있는 작업 (10분 이상)

  • Deferrable: 나중에 시작되고 정기적으로 실행될 수 있는 예약된 작업.




타입주기접근 방법
Immediate일회성OneTimeWorkRequest와 Worker. 신속한 작업을 위해 OneTimeWorkRequest에서 setExpedited()를 호출.
Long Running일회성 또는 주기적WorkRequest 혹은 Worker. 알림을 처리하려면 Worker에서 setForeground()를 호출.
Defferrable일회성 또는 주기적PeriodicWorkRequest와 Worker.

장점

  1. 작업 제약 (Work constraints)
    작업 제약 조건을 사용하여 작업을 실행하기 위한 최적의 조건을 선언적으로 정의.
    예를 들어 장치가 무제한 네트워크에 있을 때, 장치가 유휴 상태일 때 혹은 배터리가 충분한 경우에만 실행.

  2. 강력한 스케줄링
    WorkManager를 사용하면 작업을 한번 또는 반복적으로 실행할 수 있도록 예약할 수 있음.
    작업에 태그를 지정하고 이름을 지정할 수도 있으므로 작업을 예약하고 작업 그룹을 모니터링하고 취소할 수 있음. 예약된 작업은 내부적으로 관리되는 SQLite 데이터베이스에 저장되며 WorkManager는 장치 재부팅 시에도 일정이 변경되도록 관리함. 또한 잠자기 모드와 같은 절전기능을 지원하므로 걱정할 필요가 없음.

  1. 신속한 작업
    WorkManger를 사용하면 백그라운드에서 실행할 즉각적인 작업을 예약할 수 있음.

  2. 유연한 재시도 정책
    가끔 작업이 실패할 때, WorkManger는 유연한 재시도 정책을 제공.

  3. 작업 체인
    복잡한 관련 작업의 경우 순차적으로 실행되는 부분과 병렬로 실행되는 부분을 제어할 수 있는 직관적 인터페이스를 사용하여 개별 작업을 함께 연결.


val continuation = WorkManager.getInstance(context)
    .beginUniqueWork(
        Constants.IMAGE_MANIPULATION_WORK_NAME,
        ExistingWorkPolicy.REPLACE,
        OneTimeWorkRequest.from(CleanupWorker::class.java)
    ).then(OneTimeWorkRequest.from(WaterColorFilterWorker::class.java))
    .then(OneTimeWorkRequest.from(GrayScaleFilterWorker::class.java))
    .then(OneTimeWorkRequest.from(BlurEffectFilterWorker::class.java))
    .then(
        if (save) {
            workRequest<SaveImageToGalleryWorker>(tag = Constants.TAG_OUTPUT)
        } else /* upload */ {
            workRequest<UploadWorker>(tag = Constants.TAG_OUTPUT)
        }
    )

각 작업 태스크에 대해 해당 작업에 대한 입력 및 출력 데이터를 정의할 수 있음. 여러 작업을 연결하면 WorkManager는 자동으로 한 작업에서 다음 작업으로 출력 데이터를 전달.

  1. 내장 스레딩 상호 운용성
    WorkManager는 코루틴 및 RxJava와 원활하게 호환되며 자체 비동기 API를 연결할 수 있는 유연성 제공.

⭐️ 참고: 코루틴과 WorkManager는 다양한 사례에 권장되지만 상호 배타적이지 않음. WorkManager를 통해 예약된 작업 내에서 코루틴을 사용할 수 있음.


안정적인 작업을 위한 WorkManager 사용

WorkManager는 사용자가 화면 밖으로 나가거나 앱이 종료되거나 기기가 재부팅되는 경우에도 안정적으로 실행되어야하는 작업을 위한 것.

  • 예시
  1. 백엔드 서비스에 로그 혹은 분석을 보냄.
  2. 애플리케이션 데이터를 서버와 주기적으로 동기화함.

다른 API와의 관계

코루틴은 특정 사용 사례에 권장되는 솔루션이지만 지속적인 작업에 사용해서는 안됨.

코루틴은 동시성 프레임워크인 반면 WorkManager는 지속적인 작업을 위한 라이브러리라는 점에서 유의해야함.

또한 시계나 달력에는 AlarmManager를 사용해야함.

API권장 대상(Recommended for)WorkManager와의 관계
Coroutines지속적일 필요가 없는 모든 비동기 작업.코루틴은 코틀린에서 메인 스레드를 벗어나는 표준 방식이지만 앱이 종료된 후에 메모리를 남겨두므로 지속적인 작업의 경우 WorkManager를 사용.
AlarmManager알람에만 사용WorkManager와 달리 AlarmManager는 기기의 절전모드를 해제함. 따라서 전원 및 리소스 관리 측면에서 비효율적. 백그라운드 작업이 아닌 정확해야 하는 알람이나 알림(ex: 캘린더 일정)에만 사용할 것.




🔎 WorkManager 시작하기

📖 Work 정의

Work는 Worker 클래스를 사용하여 정의됨. doWork() 메서드는 WorkManager에서 제공하는 백그라운드 스레드에서 비동기적으로 처리됨.

WorkManager가 실행할 작업을 만들려면 Worker 클래스를 확장하고 doWork() 메서드를 재정의.


class UploadWorker(appContext: Context, workerParams: WorkerParameters):
       Worker(appContext, workerParams) {
   override fun doWork(): Result {

       // Do the work here--in this case, upload the images.
       uploadImages()

       // Indicate whether the work finished successfully with the Result
       return Result.success()
   }
}

doWork()에서 반환된 결과는 작업 성공 여부를 WorkManager에게 알리고 실패한 경우 작업을 재시도할 것인지 여부를 알려줌.

  • Result.success() : 작업이 성공적으로 완료됨.
  • Result.failure() : 작업 실패.
  • Result.retry() : 작업이 실패했으며 재시도 정책에 따라 나중에 재시도해야함.

📖 WorkRequest 생성

Work가 정의되면 WorkManager 서비스를 사용하여 예약해야 실행됨.

WorkManager는 작업 일정을 계획하는데 있어 많은 유연성 제공.
일정 기간동안 주기적으로 실행되도록 예약하거나 한 번만 실행되도록 예약할 수 있음.

작업 예약을 선택하면 항상 WorkRequest를 사용하게 됨. Worker가 작업 단위를 정의하는 반면, WorkRequest(및 하위 클래스)는 실행방법과 시기를 정의.


val uploadWorkRequest: WorkRequest =
   OneTimeWorkRequestBuilder<UploadWorker>()
       .build()

📖 시스템에 WorkRequest 제출

마지막으로 enqueue() 메서드를 사용하여 WorkRequest를 WorkManager에게 제출해야 함. Worker가 실행될 정확한 시간은 WorkRequest에 사용되는 제약조건과 시스템 최적화에 따라 달라짐.

WorkManager
    .getInstance(myContext)
    .enqueue(uploadWorkRequest)




🔎 WorkRequest 정의

  • 일회성 및 반복 작업 예약
  • Wi-Fi 필요, 충전 등 작업 제약 조건 설정
  • 작업 실행 지연 최소화 보장
  • 재시도 및 백오프 전략 설정
  • 입력 데이터를 작업에 전달
  • 태그를 사용하여 관련 작업을 그룹화

위와 같은 사례에서 WorkRequest 객체를 정의해야 함.

Work는 WorkRequest를 통해 WorkManager에서 정의됨.
WorkManager를 사용하여 작업을 예약하려면 먼저 WorkRequest 객체를 만든 다음 대기열에 추가해야함.


val myWorkRequest = ...
WorkManager.getInstance(myContext).enqueue(myWorkRequest)

WorkRequest 객체에는 WorkManager가 작업을 예약하고 실행하는 데 필요한 모든 정보가 포함되어 있음. 여기에는 작업을 실행하기 위해 충족해야하는 제약 조건, 지연 또는 반복 간격과 같은 예약 정보, 재시도 구성이 포함되며 작업에 의존하는 경우 입력 데이터가 포함될 수 있음.


WorkRequest 자체는 추상 기본 클래스. 이 클래스엔 두 가지 파생 구현이 있음.
1. OneTimeWorkRequest
반복되지 않는 작업을 예약하는 데 유용.

2. PeriodicWorkRequest
일정 간격으로 반복되는 작업을 예약하는데 적합.

📖 일회성 작업 예약

추가 설정이 필요없는 간단한 작업일 경우 정적 메소드인 from 사용.

val myWorkRequest = OneTimeWorkRequest.from(MyWork::class.java)

좀 더 복잡한 작업일 경우 다음과 같은 빌더를 사용할 수 있음.

val uploadWorkRequest: WorkRequest =
   OneTimeWorkRequestBuilder<MyWork>()
       // Additional configuration
       .build()

📖 신속 처리 작업 예약

  • 신속한 작업의 특징
  1. 중요성(Importance): 신속한 작업은 사용자에게 중요하거나 사용자가 시작하는 작업에 적합.

  2. 속도(Speed): 신속 작업은 즉시 시작하여 몇분 내에 완료되는 짧은 작업에 적합.

  3. 할당량(Quotas): 포그라운드 실행 시간을 제한하는 시스템 수준 할당량은 신속한 작업을 시작할 수 있는지 여부를 결정

  4. 전원 관리(Power Management): 배터리 절약모드, 잠자기 등의 전원 관리 제한 사항은 작업에 영향을 미칠 가능성이 적음

  5. 지연 시간(Latency): 시스템의 현재 워크로드로 처리가 가능한 경우 시스템은 신속 처리 작업을 즉시 실행함. 즉 신속 처리 작업은 지연 시간에 민감하여 나중에 실행되도록 할 수 없음.


신속 처리 작업의 예시

채팅 앱에서 사용자가 메시지 또는 첨부된 이미지를 전송하려는 경우.
결제/구독 흐름을 처리하는 앱도 신속 처리 작업을 사용하는것이 좋음.

👉 이러한 작업은 사용자에게 중요하고 백그라운드에서 빠르게 실행되며 즉시 시작해야 하고 사용자가 앱을 닫아도 계속 실행되어야 하기 때문.


  • 할당량

시스템은 신속 처리 작업을 실행하기 전에 신속 처리 작업에 실행 시간을 할당해야 함. 실행 시간은 무제한이 아님. 각 앱은 실행 시간 할당량을 받음.

앱에서 실행 시간을 사용하고 할당된 할당량에 도달하면 할당량이 새로고침될 때까지 신속 처리 작업을 더 이상 실행할 수 없음. 이를 통해 안드로이드는 애플리케이션 간의 리소스 균형을 더 효과적으로 유지함.

앱에서 사용 가능한 실행 시간은 대기 버킷(standby bucket) 및 프로세스 중요도에 따라 결정됨.


📚 참고
앱이 포그라운드에 있는 동안 할당량은 신속 처리 작업 실행을 제한하지 않음.
실행 시간 할당량은 앱이 백그라운드에 있거나 백그라운드로 이동하는 경우에만 적용됨.
따라서 백그라운드에서 계속하려는 작업을 신속하게 처리해야 함.
앱이 포그라운드에 있는 동안 계속해서 setForeground()를 사용할 수 있음.



📖 신속한 작업 실행

WorkManager 2.7부터 앱은 setExpedited()를 호출하여 WorkRequest가 신속 작업을 사용하여 최대한 빨리 실행되어야 함을 선언할 수 있음.

val request = OneTimeWorkRequestBuilder()
    .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
    .build()

WorkManager.getInstance(context)
    .enqueue(request)

위의 코드에서는 OneTiemWorkRequest 인스턴스를 초기화하고 이에 대해 setExpedited() 을 호출. 할당량이 허용되면 백그라운드에서 즉시 실행되기 시작함.

할당량이 사용된 경우 OutOfQuotaPolicy 매개변수는 요청이 긴급 처리되지 않은 일반적인 작업으로 실행되어야 함을 나타냄.



이전 버전과의 호환성 및 포그라운드 서비스

신속 작업의 이전 버전과의 호환성을 유지하기 위해 WorkManager는 Android 12 이전 플랫폼 버전에서 포그라운드 서비스를 실행할 수 있음.

포그라운드 서비스는 사용자에게 알림을 표시할 수 있음.

Worker의 getForegroundInfoAsync()getForegroundInfo() 메서드를 사용하면 Android 12 이전 플랫폼에서 setExpedited()를 호출할 때 WorkManager가 알림을 표시할 수 있음.

작업이 긴급 작업으로 실행되도록 요청하려면 모든 ListenableWorker가 getForegroundInfo 메서드를 구현해야함.

주의: 해당 getForegroundInfo 메소드를 구현하지 못하면 이전 플랫폼 버전에서 setExpedited를 호출할 때 런타임 충돌이 발생할 수 있음.

Android 12 이상을 타겟팅하는 경우 상응하는 setForeground 메서드를 통해 포그라운드 서비스를 계속 사용할 수 있음.

주의: setForeground()는 Android 12에서 런타임 예외를 발생시킬 수 있으며 실행이 제한되면 예외가 발생할 수도 있음.



📖 Worker

Worker는 실행중인 작업이 신속 처리 작업인지 알 수 없음. 그러나 WorkRequest가 신속 처리되면 Worker는 안드로이드 일부 버전에서 알림을 표시할 수 있음.

이를 위해 WorkManager는 getForegroundInfoAsync() 메서드를 제공하며, 필요 시 WorkManager가 ForegroundService를 시작할 수 있는 알림을 표시하도록 구현해야 함.


📖 Coroutine Worker

CoroutineWorker를 사용하는 경우 getForegroundInfo()를 구현해야함. 그런 다음 doWork() 내의 setForeground()에 전달함. 그렇게 하면 Android 12 이전 버전에서 알림이 생성됨.

  class ExpeditedWorker(appContext: Context, workerParams: WorkerParameters):
   CoroutineWorker(appContext, workerParams) {

   override suspend fun getForegroundInfo(): ForegroundInfo {
       return ForegroundInfo(
           NOTIFICATION_ID, createNotification()
       )
   }

   override suspend fun doWork(): Result {
       TODO()
   }

    private fun createNotification() : Notification {
       TODO()
    }

}

할당량 정책

앱이 실행 할당량에 도달하면 신속 작업에 발생하는 일을 제어할 수 있음. 계속하려면 setExpedited()를 전달.

  • OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST: 작업이 일반 작업 요청으로 실행됨.
  • OutOfQuotaPolicy.DROP_WORK_REQUEST: 할당량이 충분하지 않으면 취소됨.

WorkManager Sample 주소



신속 처리 작업 지연

시스템에서는 신속 처리 작업이 호출된 후 최대한 빨리 주어진 작업을 실행하려고함. 그러나 다른 유형의 작업과 마찬가지로 다음과 같은 경우 시스템에서 새로운 신속 처리 작업의 시작을 지연시킬 수 있음.

  • 부하(Load): 시스템 부하가 너무 높을 때. 이는 이미 너무 많은 작업이 실행되고 있거나 시스템에 메모리가 충분하지 않을 때 발생할 수 있음.

  • 할당량(Quota): 신속 처리 작업 할당량 한도가 초과되었을 때. 신속 처리 작업은 앱 대기 버킷에 기반하는 할당량 시스템을 사용하고 롤링 시간 내에서 최대 실행 시간을 제한함. 신속 처리 작업에 사용되는 할당량은 다른 유형의 백그라운드 작업에 사용되는 할당량보다 더 제한적임.



📖 주기적인 작업 스케줄

앱에서 특정 작업을 주기적으로 실행해야할 경우

예) 정기적으로 데이터 백업, 앱에 새로운 컨텐츠 다운로드, 서버에 로그 업로드


val saveRequest =
       PeriodicWorkRequestBuilder<SaveImageToFileWorker>(1, TimeUnit.HOURS) //작업이 1시간 간격으로 반복됨.
    // Additional configuration
           .build()

Worker가 실행될 정확한 시간은 WorkRequest 객체에서 사용하는 제약조건과 시스템에서 수행되는 최적화에 따라 달라짐.

⭐️ 참고: 정의할 수 있는 최소 반복 간격은 15분. (JobScheduler API와 동일.)


📕 유연한 실행 간격

작업 특성상 실행 타이밍에 민감한 경우 아래 그림과 같이 각 간격 기간 내의 가변 기간 내에 실행되도록 PeriodicWorkRequest를 구성할 수 있음.


가변 기간을 사용하여 주기적 작업을 정의하려면 PeriodicWorkRequest를 생성할 때 RepeatInterval과 함께 flexInterval을 전달. (가변 기간은 RepeatInterval - flexInterval에서 시작하여 간격의 끝)


//1시간마다 마지막 15분동안 실행할 수 있는 주기적 작업 코드
val myUploadWork = PeriodicWorkRequestBuilder<SaveImageToFileWorker>(
       1, TimeUnit.HOURS, // repeatInterval (the period cycle)
       15, TimeUnit.MINUTES) // flexInterval
    .build()

반복 간격은 PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS보다 크거나 같아야 하며 가변 간격은 PeriodicWorkRequest.MIN_PERIODIC_FLEX_MILLIS보다 크거나 같아야 함.


📕 주기적 작업에 대한 제약의 영향

주기적인 작업에 제약 적용 가능. (예를 들어 장치가 충전 중일때만 작업이 실행되도록 제약 조건 추가.)

정의된 반복 간격이 경과하더라도 조건이 충족될 때까지 PeriodicWorkRequest가 실행되지 않음.

이로 인해 특정 작업 실행이 지연되거나 실행 간격 내에 조건이 충족되지 않으면 건너뛸 수 있음.


📖 Work constraints

제약 조건은 최적의 조건이 충족될 때까지 작업이 연기되도록 보장함.

  • NetworkType: 작업을 실행하는 데 필요한 네트워크 유형을 제한함. (예를 들어 Wi-Fi(UNMETERED))

  • BatteryNotLow: true로 설정하면 장치가 배터리 부족 상태인 경우 작업이 실행되지 않음.

  • RequiresCharging: true로 설정하면 장치가 충전중일 때만 작업이 실행됨.

  • DeviceIdle: true로 설정하면 작업이 실행되기전에 장치가 유휴 상태여야 함. 기기에서 활발하게 실행되는 다른 앱의 성능에 부정적인 영향을 미칠 수 있는 일괄 작업을 실행하는데 유용.

  • StorageNotLow: true로 설정하면 기기의 저장 공간이 너무 부족한 경우 작업이 실행되지 않음.


제약을 생성하고 이를 일부 작업과 연결하려면 Constraints.Builder()를 사용하여 Constraints 인스턴스를 생성하고 이를 WorkRequest.Builder()에 할당함.


여러 제약 조건이 지정되면 모든 제약 조건이 충족되는 경우에만 작업이 실행됨.
작업이 실행되는 동안 제약 조건이 충족되지 않는 경우 WorkManager는 Worker를 중지함. 모든 제약 조건이 충족되면 작업이 다시 시도됨.


//사용자 장치가 충전 중이고 wifi에 연결되어 있을때만 실행
val constraints = Constraints.Builder()
   .setRequiredNetworkType(NetworkType.UNMETERED)
   .setRequiresCharging(true)
   .build()

val myWorkRequest: WorkRequest =
   OneTimeWorkRequestBuilder<MyWork>()
       .setConstraints(constraints)
       .build()

📖 Delayed Work

작업에 제약 조건이 없거나 작업이 대기열에 추가될 때 모든 제약 조건이 충족되는 경우 시스템은 작업을 즉시 실행하도록 선택할 수 있음. 작업을 즉시 실행하지 않으려면 최소 초기 지연 후 작업이 시작되도록 지정할 수 있음.


// PeriodicWorkRequest에 대한 초기 지연을 설정할 수도 있음. 이 경우 첫번째 작업 주기 실행때만 지연됨.
val myWorkRequest = OneTimeWorkRequestBuilder<MyWork>()
   .setInitialDelay(10, TimeUnit.MINUTES)
   .build()

⭐ 참고: 작업자가 실행될 정확한 시간은 작업 요청에 사용되는 제약 조건과 시스템 최적화에 따라 달라짐.


📖 Retry and backoff policy

WorkManager가 작업을 다시 시도하도록 요구하는 경우 Worker로부터 Result.retry()를 반환받을 수 있음. 그런 다음 백오프 지연 및 백오프 정책에 따라 작업 일정이 다시 조정됨.

  • 백오프 지연은 첫 번째 시도 후 작업을 재시도하기 전까지 기다려야 하는 최소시간을 지정함.
    이 값은 10초(혹은 MIN_BACKOFF_MILLIS) 이상일 수 있음.

WorkManager가 작업을 다시 시도하도록 요구하는 경우 작업자로부터 Result.retry()를 반환할 수 있습니다. 그런 다음 백오프 지연 및 백오프 정책에 따라 작업 일정이 다시 조정됩니다.

백오프 지연은 첫 번째 시도 후 작업을 다시 시도하기 전까지 기다려야 하는 최소 시간을 지정합니다. 이 값은 10초(또는 MIN_BACKOFF_MILLIS) 이상일 수 있습니다.

  • 백오프 정책은 후속 재시도에 대해 시간이 지남에 따라 백오프 지연이 어떻게 증가해야 하는지를 정의. WorkManager는 LINEAR 및 EXPONENTIAL이라는 2가지 백오프 정책을 지원.

모든 작업 요청엔 백오프 정책과 백오프 지연이 있음. 기본 정책은 30초 지연의 EXPONENTIAL이지만 작업 요청 구성에서 이를 재정의할 수 있음.


val myWorkRequest = OneTimeWorkRequestBuilder<MyWork>()
   .setBackoffCriteria(
       BackoffPolicy.LINEAR,
       OneTimeWorkRequest.MIN_BACKOFF_MILLIS,
       TimeUnit.MILLISECONDS)
   .build()

위의 코드에서는 최소 백오프지연이 허용되는 최소 값인 10초로 설정됨.
정책이 LINEAR 이므로 새로 시도할 때마다 재시도 간격이 10초씩 늘어남.
예를 들어, Result.retry()로 끝나는 첫 번째 실행은 10초 후에 다시 시도되고, 후속 시도 후에도 작업이 계속해서 Result.retry()를 반환하는 경우 20, 30, 40 등의 순서로 시도됨.

PONENTIAL로 설정된 경우 재시도 기간 시퀀스는 20, 40, 80 등에 가까워짐.


참고: 백오프 지연은 정확하지 않으며 재시도마다 몇 초씩 달라질 수 있지만 구성에 지정된 초기 백오프 지연보다 작지는 않음.



📖 Tag work

모든 작업 요청에는 나중에 작업을 취소하거나 진행 상황을 관찰하기 위해 해당 작업을 식별하는 데 사용할 수 있는 고유 식별자가 있음.

논리적으로 관련된 작업 그룹이 있을 경우 해당 작업 항목에 태그를 지정하는 것이 도움이 될 수 있음.


val myWorkRequest = OneTimeWorkRequestBuilder<MyWork>()
   .addTag("cleanup")
   .build()

단일 작업 요청에 여러 태그를 추가할 수도 있음.
WorkRequest와 연결된 태그 세트를 가져오려면 WorkInfo.getTags()를 사용.
Worker 클래스에서 ListenableWorker.getTags()를 통해 태그 세트를 검색할 수 있음.


📖 Assign input data

작업을 수행하기 위해 입력 데이터가 필요한 경우 (예를 들어 이미지 업로드를 처리하는 작업에서 이미지 URI를 입력으로 업로드해야 할 경우) 입력값은 데이터 개체에 키-값 쌍으로 저장되며 작업 요청 시 설정할 수 있음.

WorkManager는 작업을 실행할 때 입력 데이터를 작업에 전달. Worker클래스는 Worker.getInputData()를 호출하여 입력 인수에 액세스할 수 있음.


// Define the Worker requiring input
class UploadWork(appContext: Context, workerParams: WorkerParameters)
   : Worker(appContext, workerParams) {

   override fun doWork(): Result {
       val imageUriInput =
           inputData.getString("IMAGE_URI") ?: return Result.failure()

       uploadFile(imageUriInput)
       return Result.success()
   }
   ...
}

// Create a WorkRequest for your Worker and sending it input
val myUploadWork = OneTimeWorkRequestBuilder<UploadWork>()
   .setInputData(workDataOf(
       "IMAGE_URI" to "http://..."
   ))
   .build()

0개의 댓글