쾌락코딩

Android File Storage & File Provider(번역)

|

원본 - Android File Storage & File Provider

도입부

이전 글에서 안드로이드 파일 저장소에대한 기본적인 사항들과 앱이 각 디렉토리에 파일을 저장할 때 쓰는 몇 가지 API들을 살펴보았습니다. 아직 읽지 않으셨다면 Part 1을 봐주세요. 오늘은 이전 글에서 배운 내용들을 실습해보려 합니다.

실습 목록

  1. 파일 디렉토리에 파일 저장하기
  2. 외부 저장소(external files directroy)에 파일 저장하기
  3. File Provider API - content URI를 사용하여 당신의 앱 파일을 외부 시스템에 안전하게 공유하기

실습 1

첫 실습과 두 번째 실습에서는 오직 해당 앱의 저장소에만 접근하기 때문에 어떠한 런타임 permission을 선언하진 않습니다. 아래와 같이 디렉토리에 파일을 저장하는 함수를 만들어 봅시다.

fun saveFileInAppDirectory() {
    val file = FileUtils.createFileInStorage(appContext,
        "test.jpeg"
    )
    Timber.d("file path %s", file!!.absolutePath)
    if (!file.exists()) {
        file.createNewFile()
    }
    val assetManager = appContext.assets
    val inputStream: InputStream
    val bitmap: Bitmap
    try {
        inputStream = assetManager.open(SAMPLE_FILE_NAME)
        bitmap = BitmapFactory.decodeStream(inputStream)
        saveBitmap(bitmap, file)
    } catch (e: Exception) {
        Timber.e(e)
    }
}

asset 폴더에서 파일을 읽어온 후 디렉토리에 그 파일을 저장합니다. 파일과 관련된 유틸성 함수들을 모아둔 FileUtils 클래스도 있습니다. FileUtils.kt를 만들어 아래 함수를 복사해두세요.

fun createFileInStorage(context: Context, fileName: String): File? {
    val timeStamp: String = System.currentTimeMillis().toString() + Constants.JPEG
    val name = if (fileName.isBlank()) timeStamp else fileName
    return File(getAppFilesDir(context), name)
}
fun createFileInExternalStorage(context: Context, fileName: String): File? {
    val timeStamp: String = System.currentTimeMillis().toString() + Constants.JPEG
    val name = if (fileName.isBlank()) timeStamp else fileName
    return File(getAppExternalFilesDir(context), name)
}
private fun getAppFilesDir(context: Context): File? {
    val file = context.filesDir
    if (file != null && !file.exists()) {
        file.mkdirs()
    }
    return file
}
private fun getAppExternalFilesDir(context: Context): File? {
    val file = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
    if (file != null && !file.exists()) {
        file.mkdirs()
    }
    return file
}

saveFileInAppDirectory() 함수를 호출하면 앱의 특정 디렉토리에 파일이 저장되며 이 디렉토리는 우리 앱의 내부 저장소이기 때문에 다른 앱이나 파일 매니저가 접근할 수 없습니다. 필자의 경우 Pixel 3 API 28 에뮬레이터에서 이 함수를 실행했을 때, 파일이 저장된 경로는 아래와 같았습니다.

/data/user/0/com.example.android.boilerplate/files/test.jpeg

실습 2

위와 마찬가지로 앱의 외부 저장소에 파일을 저장할 수도 있습니다. 아래와 같이 함수를 호출해봅시다.

fun saveFileInAppExternalDirectory() {
    val file = FileUtils.createFileInExternalStorage(appContext,
        "test.jpeg"
    )
    Timber.d("file path %s", file!!.absolutePath)
    if (!file.exists()) {
        file.createNewFile()
    }
    val assetManager = appContext.assets
    val inputStream: InputStream
    val bitmap: Bitmap
    try {
        inputStream = assetManager.open(SAMPLE_FILE_NAME)
        bitmap = BitmapFactory.decodeStream(inputStream)
        saveBitmap(bitmap, file)
    } catch (e: Exception) {
        Timber.e(e)
    }
}

주의 : SAMPLE_FILE_NAME은 assets folder 이름입니다.

마찬가지로 같은 환경에서 저장된 파일의 위치를 찍어 보았을 때 결과는 아래와 같았습니다.

/storage/emulated/0/Android/data/com.example.android.boilerplate/files/Pictures/test.jpeg

이 경우에는 파일 매니저를 사용하여 에뮬레이터에서 저장된 파일을 확인할 수 있습니다. 파일 매니저 앱을 열고, 오른쪽 상단 모서리에 위치한 setting 버튼을 클릭하여 “내부 저장소 보기” 버튼을 클릭해보세요. 그리고 나서 내부 저장소 폴더에 들어간 뒤 Android/data//files 에 들어가시면 됩니다.

앱 특정 캐시 디렉토리에 파일을 저장하는 경우에도 위와 동일한 접근 방식을 따르면 됩니다.

File Provider

가끔은 당신의 앱 특정 디렉토리에 저장된 파일을 다른 앱과 공유하고 싶을 경우가 있을겁니다. 그렇다면 content URI를 사용하여 공유할 수 있습니다. 안드로이드 File Provider는 이를 위한 간단한 API들을 제공합니다. context URI를 공유함으로써 다른 앱들이 특정 파일을 쓰고 읽을 수 있도록 임시 권한을 부여합니다.

파일의 content URI을 만들기 위해서 아래와 같은 절차를 따르세요.

  1. Android Manifest에 File Proficer를 선언합니다.
  2. res 디렉토리의 xml 폴더 경로를 선언합니다.
  3. fire provier API를 사용하여 context URI를 생성합니다.

Manifest에 Fire Provider 선언

아래 코드의 mydomain 은 여러분의 도메인을 넣으세요.

<manifest>
    ...
    <application>
        ...
        <provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="com.mydomain.fileprovider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/file_paths" />
        </provider>
        ...
    </application>
</manifest>

resource 디렉토리의 xml 폴더 경로 선언

File Provider는 당신이 미리 지정해둔 디렉토리안의 파일에 대해서만 content URI를 생성할 수 있습니다. 따라서 res 디렉토리안에 xml폴더를 만들고, file_paths.xml 파일을 만들어야 합니다. 그 파일은 아래와 같이 만듭니다.

<paths xmlns:android="http://schemas.android.com/apk/res/android">
   <external-files-path name="pictures" path="Pictures/" />
    ...
</paths>

여기서는 우리 앱의 외부 저장소의 파일에 접근할 것이고, 외부 저장소 안의 특정 파일에 대해서만 content URI를 생성할 것이기 때문에 external-files-path으로 경로를 선언해주었습니다. 이것은 앱의 외부 저장소에 파일을 저장하는 실습 2에 해당하는 내용이고 path 는 특정 파일이 위치한 폴더를 나타냅니다. 실습 2의 경우 path는 앱의 외부 디렉토리 아래의 Pictures 폴더가 되죠.

선택사항

만약 실습 1 에 해당하는 File Provider를 선언하려 한다면, path 선언은 아래와 같습니다.

<files-path path="/" name="pictures" />

주의 : 실습 1 에서는 실습 2 와는 달리 파일 디렉토리 안에 어떤 폴더도 만들지 않았습니다.

file의 content URI 생성

파일의 content URI를 생성하기 위해서 실습 2 의 상황을 예로 들겠습니다. 우리는 assets 에서 파일을 읽어온 후 앱의 외부 디렉토리에 파일을 저장하고, 저장된 파일의 content URI를 생성할 것 입니다.

fun getContentUri(): String? {
    val file = FileUtils.createFileInExternalStorage(appContext,
        Constants.FILE_NAME
    )
    Timber.d("file path %s", file!!.absolutePath)
    if (!file.exists()) {
        file.createNewFile()
    }
    val assetManager = appContext.assets
    val inputStream: InputStream
    val bitmap: Bitmap
    try {
        inputStream = assetManager.open(SAMPLE_FILE_NAME)
        bitmap = BitmapFactory.decodeStream(inputStream)
        saveBitmap(bitmap, file)
        return provideContentUri(file)
    } catch (e: Exception) {
        Timber.e(e)
    }
    return null
}
private fun provideContentUri(file: File): String {
    val contentUri: Uri = FileProvider.getUriForFile(appContext,
        Constants.FILE_PROVIDER_AUTHORITY, file)
    return contentUri.toString()
}

위 함수는 아래와 같은 content URI를 리턴합니다.

content://com.mydomain.fileprovider/pictures/test.jpeg

끝났습니다!

이 시리즈의 마지막 포스팅에서는 몇가지 실습을 통하여 Scoped Storage를 배워보도록 하겠습니다.

Android Storage & Scoped Storage(번역)

|

원문 - Android Storage & Scoped Storage

Overview

Android는 디바이스 저장소의 읽고 쓰는 작업에 대한 많은 개선을 거쳐 오고 있습니다. 그 시작은 rumtime Permissions이었는데요, 디바이스 공유 저장소에 접근하려거든 필히 사용자에게 접근 권한을 얻어야만 하는 정책이죠. 그때부터 시작하여 Android는 앱이 정말로 그 데이터가 필요할 때만 디바이스로 부터 데이터를 읽거나 쓰도록 점점 강하게 제한을 걸어왔습니다.

Android의 저장소는 아래와 같이 분류할 수 있습니다.

  1. 앱 특화 저장소
  2. 공유 저장소
  3. Preference
  4. 데이터베이스

Android에 scoped storage가 도입된 이유를 이해하기 위해서는, 앱이 어떻게 내부/외부 저장소안에 있는 미디어 파일과 documents들에 접근하고, 수정하고, 저장하는지에 대한 기본적인 이해가 필요하고, 디바이스의 공유 파일에 접근하기 위해 제공되는 permission들에 대한 이해도 필요합니다.

Application Storage

앱 저장소는 두 가지로 나눌 수 있어요.

  1. 내부 저장소(Internal Storage) - 여기에 저장되는 파일은 외부로 부터 보호되며 오직 여기에 파일을 생성한 자기 자신(앱)만이 접근할 수 있음.
  2. 외부 저장소(External Storage) - android 기기 저장소, SD 카드, 등등이 될 수 있음

내부 저장소(Internal Storage)에 저장되는 파일은 다른 앱에서는 접근이 불가능 합니다. 오직 그 파일을 생성한 앱 만이 접근할 수 있어요. 이는 앱의 중요하고 민감한 파일들을 외부로 부터 보호하도록 보안되어있습니다.

외부 저장소(External Storage)에 저장된 파일은 적절한 권한을 획득한 다른 앱들이 접근할 수도 있습니다. 비록 다른 앱이 이 파일들에 접근하는게 가능하긴 하지만, 이 디렉토리에 저장된 파일들은 오직 당신의 앱에서만 사용되도록 고안되었습니다.

만약 다른 앱에서 당신이 만든 앱이 만든 파일에 접근해야만 해서 외부 저장소에 파일을 저장하고 싶다면, 그 파일은 외부 저장소의 공유 저장소(shared storage)에 저장해야합니다. 예를 들자면, 카메라 앱이 있겠네요. 카메라 앱에서 생성/수정된 파일은 Gallery app, File manager app 등등에서 접근이 가능해야만 합니다.

내부 저장소의 앱 특화 디렉토리

Android OS는 앱이 디렉토리에 접근할 수 있도록 내부 저장소에 상당한 양의 공간을 제공합니다. 보통 내부 저장소는 context 객체로 부터 호출할 수 있습니다.

  1. 앱 영구 디렉토리
val file = File(context.filesDir, filename)
  1. 앱 캐시 디렉토리
val cacheFile = File(context.cacheDir, filename)

외부 저장소의 앱 특화 디렉토리

만약 내부 저장소가 앱 특화 파일을 모두 저장하기에 부족하다면, 외부 저장소를 사용하세요. 내부 저장소의 앱 특화 디렉토리와 마찬가지로 context 객체에서 접근이 가능합니다.

  1. 앱 영구 디렉토리
val file = File(context.filesDir, filename)
  1. 앱 캐시 디렉토리
val cacheFile = File(context.cacheDir, filename)

저장소 위치 선택하기

때때로 디바이스는 내부 메모리의 파티션을 외부 저장소로 할당하기 위해 SD 카드 슬롯을 제공하기도 합니다. 그렇게 하여 디바이스가 여러개의 저장소 볼륨을 사용할 수 있게 합니다. 따라서 여러 다른 저장소 볼륨에 접근하려면 특별한 함수를 호출해야합니다.

val externalStorageVolumes: Array<out File> =
        ContextCompat.getExternalFilesDirs(applicationContext, null)
val primaryExternalStorage = externalStorageVolumes[0]

배열의 첫 번째 요소는 디바이스에 의해 외부 저장소 볼륨에 할당된 “primary 외부 저장소”로 간주됩니다.

저장소 가용성

대부분의 경우 외부 저장소는 외부에 장착된 디스크(SD card)를 의미하기도 합니다. 따라서 특정 볼륨이 접근 가능한지를 먼저 파악하고나서 외부 저장소로부터 앱 특화 데이터를 읽고/쓰는 것이 중요합니다.

// 외부 저장소를 포함한 볼륨이 읽고 쓸수 있는 상태인지 확인
fun isExternalStorageWritable(): Boolean {
    return Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED
}

// 외부 저장소를 포함한 볼륨이 적어도 읽을 수 있는 상태인지 확인(쓰지는 못하더라도)
fun isExternalStorageReadable(): Boolean {
     return Environment.getExternalStorageState() in
        setOf(Environment.MEDIA_MOUNTED, Environment.MEDIA_MOUNTED_READ_ONLY)
}

여유 공간 쿼리

디바이스에 사용 가능한 저장 공간이 많지 않은 경우가 흔하기 때문에 앱은 신중하게 공간을 사용해야합니다. 아래 코드를 사용하여 사용 가능한 여유 공간을 확인할 수 있고, 저장소 관련 작업을 수행할 수 있습니다.

// 앱은 내부 저장소에 10 MB를 필요로 합니다.
const val NUM_BYTES_NEEDED_FOR_MY_APP = 1024 * 1024 * 10L;

val storageManager = applicationContext.getSystemService<StorageManager>()!!
val appSpecificInternalDirUuid: UUID = storageManager.getUuidForPath(filesDir)
val availableBytes: Long =
        storageManager.getAllocatableBytes(appSpecificInternalDirUuid)
if (availableBytes >= NUM_BYTES_NEEDED_FOR_MY_APP) {
    storageManager.allocateBytes(
        appSpecificInternalDirUuid, NUM_BYTES_NEEDED_FOR_MY_APP)
} else {
    val storageIntent = Intent().apply {
        action = ACTION_MANAGE_STORAGE
    }
    // 사용자가 파일을 삭제할 수 있도록 prompt를 보여줍니다.
}

Part 2 에서는 앱을 하나 만들어 보면서 우리가 다루었던 주제들을 실험해 보겠습니다. 또한 File Provider API도 알아봅니다.

7 Gotchas When Explore Kotlin Coroutine(번역)

|

원본 - 7 Gotchas When Explore Kotlin Coroutine

저는 Kotlin Coroutine Ecosystem을 더 파고들기 위해 연말 휴가를 보냈습니다. 그러면서 굉장히 많은 행동들이 예상 외로 동작한다는 것에 놀랐습니다. 그 중 몇 가지는 문서화 되어 있지만, 일부는 그렇지 않았습니다(적어도 아직 제가 찾지 못했습니다).

올바른 행동이 무엇인지, 왜 그렇게 되는지 알기 위해 StackOverflow에 질문을 해보기도 했고, 머리를 쥐어 뜯기도 했고, 정답을 얻기위한 디버깅을 어떻게 해야하는지 아이디어를 얻기 위해 낮잠을 자보기도 했습니다.

모든 분들에게 도움이 되고자 여기에 공유하려고 합니다.

Note: 이 글은 Coroutine의 기본적인 지식을 바탕으로 합니다. 그렇지 않다면 아래의 문서들을 참고해보세요.

  1. Differentiating Thread and Coroutine (launch & runBlocking) in Kotlin
  2. Understanding suspend function of Kotlin Coroutines
  3. Kotlin Coroutine Scope, Context and Job

1. runBlocking 은 App을 중단시킬 수 있습니다.

별다른 기능 없는 앱에서 아래의 코드를 실행시켜보세요.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    runBlocking(Dispatchers.Main) {
        Log.d("Track", "${Thread.currentThread()}")
        Log.d("Track", "$coroutineContext")
    }
}

이 코드는 App을 멈추게 할겁니다. onCreate 함수가 완료될 수 없어요. 대신, 아래의 코드를 돌린다면 별다른 이상 없이 앱이 동작할 겁니다. (Dispatchar.Main 을 지우세요)

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    runBlocking {
        Log.d("Track", "${Thread.currentThread()}")
        Log.d("Track", "$coroutineContext")
    }
}

이것이 의미하는 바는, main thread 안에서의 runBlocking 은 main thread 안에서의 runBlockin(Dispatchers.Main)과 같지 않다는 것입니다.

2. main thread안의 runBlocking은 main thread 안의 runBlocking(Dispatchers.Main) 과 다릅니다

처음에는 이 사실에 혼란스러웠습니다.

저는 main thread에서 runBlocking 을 사용하면 당연히 Dispatchers.Main 을 사용할 거라 생각했었거든요. 그러나 이 생각은 명백히 틀렸더라구요.

이 사실을 증명해보려고 test코드를 작성했습니다.

@Test
fun running() {
    runBlocking {
        println(Thread.currentThread())
        println(coroutineContext)
    }
}

결과는 아래와 같습니다.

Thread[main @coroutine#1,5,main]
[CoroutineId(1), "coroutine#1":BlockingCoroutine{Active}@436a4e4b, BlockingEventLoop@f2f2cc1]

그런데 아래와 같이 코드를 돌리면,

@Test
fun running() {
    runBlocking(Dispatchers.Main) {
        println(Thread.currentThread())
        println(coroutineContext)
    }
}

앱 Crash가 발생합니다.

Java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize

이를 해결하기 위해 [Dispatchers.Main Delegate](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-test/) 설정이 필요합니다. 설정을 해준 다음 코드를 다시 돌리면 아래와 같은 결과가 나옵니다.

Thread[Test Main @coroutine#2,5,main]
[CoroutineId(2), "coroutine#2":BlockingCoroutine{Active}@20f637a1, Dispatchers.Main]

모두 runBlocking을 사용했지만 쓰레드 이름이 다른게 보이시나요? 하나는 Dispatcher.Main 이고, 하나는 BlockingEventLoop@f2f2cc1 입니다.

더 자세한 정보를 알고 싶으시면 이 StackOverflow를 참고하세요.

3. 안드로이드는 Thread.currentThread()로 coroutine id name을 print할 수 없습니다

디버깅을 위해 우리는 CoroutineName Context를 사용하여 코루틴에 이름을 할당할 수 있습니다. 그리고 Thread.currentThread() 를 로깅함으로써 이름을 볼 수 있습니다.

하지만 안드로이드에서 Log를 사용하여 로깅을 하면 이름을 출력하지 않습니다.

아래의 코드를 실행해보세요.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    runBlocking(CoroutineName("My Coroutine")) {
        Log.d("Track", "${Thread.currentThread()}")
    }
}

이 코드는 단지 아래와 같은 결과를 나타낼 뿐입니다.

Thread[main,5,main]

thread 이름, 우선순위, thread group 정보입니다.

만약 테스트 환경에서 코드를 돌려보면 어떨까요?

@Test
fun running() {
    runBlocking(CoroutineName("My Coroutine")) {
        println("${Thread.currentThread()}")
    }
}

결과는 아래와 같습니다.

Thread[main @My Coroutine#1,5,main]

@My Coroutine#1 는 코루틴 이름과 ID입니다.

결론

안드로이드에서 코루틴 이름을 출력하고 싶다면, coroutineContext 를 출력하는 게 하나의 방법이 될 수 있습니다.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    runBlocking(CoroutineName("My Coroutine")) {
        Log.d("Track", "${Thread.currentThread()}")
        Log.d("Track", "$coroutineContext")
    }
}

위 코드의 결과는 아래와 같습니다.

Thread[main,5,main]
[CoroutineName(**My Coroutine**), BlockingCoroutine{Active}@c3e0260, BlockingEventLoop@1607319]

다른 방법은 kotlinx.coroutines.debug 를 설정하는 것입니다. Appication에서 설정하시면 됩니다.

System.setProperty("kotlinx.coroutines.debug", "on" )

설정하시면 아래와 같이 출력할 수 있습니다.

Thread[main @My Coroutine#1,5,main]
[CoroutineName(My Coroutine), CoroutineId(1), "My Coroutine#1":BlockingCoroutine{Active}@c3e0260, BlockingEventLoop@1607319]

4. 코루틴 연산이 언제든지 즉각 취소될 수는 없습니다

아래의 코드를 실행해보세요.

runBlocking {
    println("Launching...")
    val job = launch(Dispatchers.IO) {
        repeat(2000) {
            repeat(2000) {
                println("Suspending...")
            }
        }
        println("Done...")
    }
    println("Launched...")
    delay(100)
    println("Canceling...")
    job.cancel()
    println("Canceled...")
}

아래와 같은 결과를 볼 수 있으실겁니다.

Track: Launching...
Track: Launched...
Track: Suspending...
Track: Suspending...
Track: Canceling...
Track: Suspending...
Track: Suspending...
Track: Canceled...
Track: Suspending...
Track: Suspending...
Track: Suspending...
Track: Suspending...
:
: (a lot more suspending)
:
Track: Done...

살펴보면,

  1. blocking도 아니고
  2. 다른 쓰레드도 아니고
  3. 실행중에 cancel이 실행되었는데

위 코드는 0.1초 뒤에 취소되지 않습니다. 대신, Track: Done... 을 만날 때까지 긴 loop를 모두 실행하죠.

설명

코루틴에서 suspension과 cancellation는 오직 중단 함수(suspend-function)에서만 발생합니다(e.g. yield(), delay() ).

Kotlin 공식 문서에 나와있듯이 cancellation은 협동적, 협렵적입니다. 따라서 코루틴을 취소하기 위해선 협력이 필요합니다. 우리는 yield()를 사용하거나 isActive를 체크해야만 합니다.

이를 더 잘 이해하기 위해서는 아래의 자료를 보고 언제 코루틴이 일시중지되고, 종료되며, 시작되는지를 알아보시는걸 추천드립니다.

Coroutine suspend function: when does it start, suspend or terminate?

코루틴 취소 메서드가 process를 즉각 취소할 수 없다는 점을 고려해볼 때, Newtork Fetching은 어떻게 다룰 수 있을까요? 아래의 자료에서 그 방법을 확인하실 수 있습니다.

Network Fetch with Kotlin Coroutine

5. delay가 없이는 Android coroutine이 작업을 완료할 수 없는 이유

아래의 코드를 살펴봅시다.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    runBlocking {
        launch {
            repeat(5) {
                Log.d("Track", "First, current thread")
                delay(1)
            }
        }
        launch {
            repeat(5) {
                Log.d("Track", "Second, current thread")
                delay(1)
            }
        }
    }
}

결과는 아래와 같은데, 서로 작업을 번갈아가며 5번씩 실행합니다.

Track: First, current thread
Track: Second, current thread
Track: First, current threa
Track: Second, current thread
Track: First, current thread
Track: Second, current thread
Track: First, current thread
Track: Second, current thread
Track: First, current thread
Track: Second, current thread

그러나 안드로이드에서 dealy(1) 을 제거하고 코드를 돌려보면

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    runBlocking {
        launch {
            repeat(5) {
                Log.d("Track", "First, current thread")
                // delay(1)
            }
        }
        launch {
            repeat(5) {
                Log.d("Track", "Second, current thread")
                // delay(1)
            }
        }
    }
}

첫 번째, 더이상 서로 번갈아가며 실행하지 않습니다. 이 현상은 함수가 중단되게끔 하는 yielddelay 가 없기 때문이라고 생각할 수 있겠네요.

두 번째, 겨우 두번씩 밖에 실행하지 않고 다른 코루틴으로 실행이 넘어갑니다. 이건 좀 이상하지 않은가요? 왜 이런 일이 일어날까요?

Track: First, current thread
Track: First, current thread
Track: Second, current thread
Track: Second, current thread

설명

결론 부터 말씀드리면 코루틴 이슈가 아니라 안드로이드 이슈입니다.

안드로이드는 같은 형태의 로그가 두 번 넘게 찍히면, 세번째 로그 부터는 자동으로 제거합니다.

6. 취소된 코루틴 Scope를 재사용할 수 있을까요?

아래 코드를 실행해봅시다.

@Test
fun testingLaunch() {
    val scope = MainScope()
    runBlocking {
        scope.cancel()
        scope.launch {
            try {
                println("Start Launch 2")
                delay(200)
                println("End Launch 2")
            } catch (e: CancellationException) {
                println("Cancellation Exception")
            }
        }.join()
    println("Finished")
    }
}

scope.launch 가 더이상 작동하지 않는 것을 확인할 수 있을 겁니다.

비슷하게 아래 코드를 볼게요.

@Test
fun testingAsync() {
    val scope = MainScope()
    runBlocking {
        scope.cancel()
        val defer = scope.async {
            try {
                println("Start Launch 2")
                delay(200)
                println("End Launch 2")
            } catch (e: CancellationException) {
                println("Cancellation Exception")
            }
        }
        defer.await()
        println("Finished")
    }
}

제대로 동작하지 않을 뿐만 아니라, defer.await() 에서 crash가 나는 것을 볼 수 있습니다.

kotlinx.coroutines.JobCancellationException: Job was cancelled
; job=SupervisorJobImpl{Cancelled}@39529185

scope.cancel() 를 제거하면 제대로 동작합니다.

scope가 취소된 이후에는 scope를 재사용할 수 없습니다.

제 경험상으로, scope가 취소되면 더이상 launch 할 수 없습니다. 그것을 재설정 하는 방법도 없습니다.

StackOverflow에 이에 대한 내용을 작성했으니, 여러 의견을 댓글에 달아주세요.

이를 해결하기 위한 단 하나의 방법은, scope가 취소되고 나면 그저 새로운 scope를 만드는 것 뿐입니다.

7. 기본 코루틴 scope는 예외 처리 중에 다시 launch 할 수 없습니다.

코루틴에서는 [CoroutineExceptionHandler](https://kotlinlang.org/docs/reference/coroutines/exception-handling.html#coroutineexceptionhandler) 를 사용하여 간단하게 예외를 캡쳐할 수 있습니다.

private var coroutineScope: CoroutineScope? = null
private val errorHandler = CoroutineExceptionHandler {
    context, error ->
    println("Launch Exception ${Thread.currentThread()}")
    coroutineScope?.launch(Dispatchers.Main) {
        println("Launch Exception Result ${Thread.currentThread()}")
    }
}

@Test
fun testData() {
    runBlocking {
        coroutineScope = CoroutineScope(Dispatcher.IO)
        coroutineScope?.launch(errorHandler) {
           println("Launch Fetch Started ${Thread.currentThread()}")
           throw IllegalStateException("error")
        }?.join()
    }
}

위 코드는 CoroutineExceptionHandler 에서 예외를 포착할 수 있는지를 확인하기 위해 일부러 예외를 발생시킵니다. 결과는 아래와 같습니다.

Launch Fetch Started Thread[DefaultDispatcher-worker-1 @coroutine#2,5,main]
Launch Exception Thread[DefaultDispatcher-worker-1 @coroutine#2,5,main]

예외는 CoroutineExceptionHandler 에 의해 포착되었습니다만, 아래 코드는 실행되지 않았습니다.

coroutineScope?.launch(Dispatchers.Main) {
    println("Launch Exception Result ${Thread.currentThread()}")
}

이 코드가 실행되지 않은 이유는, 자식 코루틴의 exception이 부모에게 전파되어 부모 scope역시 새로운 launch를 할 수 없는 상황이 되었기 때문입니다. 자식은 자식인데 부모까지 망가뜨리니 뭔가 별로네요.

이를 다루려면 SupervisorJob() CoroutineScope가 필요합니다.

명백하게 기본 CoroutineScope(Dispatcher.IO) 은 자식 코루틴이 에러를 내뿜으면 제대로 동작하지 않습니다.

자식 코루틴의 예외를 부모와 떼어내기 위해서는 CoroutineScopeSupervisorJob() 이 필요합니다. 이는 SupervisorJob 문서에 언급되어있습니다.

따라서 아래와 같이 코드를 변경하면 올바르게 작동합니다.

private var coroutineScope: CoroutineScope? = null
private val errorHandler = CoroutineExceptionHandler {
    context, error ->
    println("Launch Exception ${Thread.currentThread()}")
    coroutineScope?.launch(Dispatchers.Main) {
        println("Launch Exception Result ${Thread.currentThread()}")
    }
}

@Test
fun testData() {
    runBlocking {
        coroutineScope = CoroutineScope(
            SupervisorJob() + Dispatcher.IO)
        coroutineScope?.launch(errorHandler) {
           println("Launch Fetch Started ${Thread.currentThread()}")
           throw IllegalStateException("error")
        }?.join()
    }
}

결과는 아래와 같습니다.

Launch Fetch Started Thread[DefaultDispatcher-worker-1 @coroutine#2,5,main]
Launch Exception Thread[DefaultDispatcher-worker-1 @coroutine#2,5,main]
Launch Exception Result Thread[Test Main @coroutine#3,5,main]

이 글이 코루틴을 학습하는데 많은 도움이 되었으면 좋겠습니다. 감사합니다.

Animations in Jetpack Compose(번역)

|

원본 - Animations in Jetpack Compose

애니메이션은 좋은 사용자 경험을 제공해줄 뿐만 아니라 애플리케이션을 보다 매력적으로 만들어 주는 중요한 역할을 합니다.

Jetpack Compose는 애니메이션을 만들기 위한 좋은 API를 제공합니다. 이를 사용하여 alpha, scale 애니메이션 등 다양한 타입의 애니메이션을 사용할 수 있습니다. 이번 튜토리얼에서는 어떻게 Jetpack Compose에서 애니메이션을 만들고 사용하는지 배워보겠습니다.

뉴스 앱을 만든다고 가정해볼게요. 이 앱은 최신 뉴스들을 리스트로 보여줄겁니다. 우리는 리스트의 모든 아이템이 오른쪽에서 들어오도록 만들어 볼겁니다.

먼저, 애니메이션 로직이 들어있는 ListItemAnimationDefinition 클래스를 만들어 봅시다.

class ListItemAnimationDefinition(delay: Int = 0) {
    val slideValue = DpPropKey(label = "offset")
}
  • delay - 애니메이션에 지연시간을 적용합니다.
  • slideValue - 변할 값을 정의하는 property key입니다. 이미 ValueAnimation을 알고 계시다면 이와 비슷한 것이라고 생각해도 좋습니다. 이 값은 UI에 대한 어떤 정보도 가지고 있지 않고, 단지 전달될 값을 업데이트할 뿐입니다.

또한 애니메이션 상태를 정의해보겠습니다. enum class를 사용해서 정의할 텐데, 원하신다면 어떤 형태로든 다르게 구현하실 수도 있습니다.

enum class ListItemAnimationState {
    INITIAL,
    FINAL
}

이제 ListItemAnimationDefinition 클래스에다가 transitionDefinition를 정의합니다. 여기서 INITIAL과 FINAL에 해당하는 애니메이션 로직을 작성합니다.

val definition = transitionDefinition<ListItemAnimationState> {
    state(ListItemAnimationState.INITIAL) {
        this[slideValue] = 90.dp
    }
    state(ListItemAnimationState.FINAL) {
        this[slideValue] = 0.dp
    }
    transition(
        fromState = ListItemAnimationState.INITIAL,
        toState = ListItemAnimationState.FINAL
    ) {
        slideValue using tween(
            delayMillis = delay,
            durationMillis = 300
        )
    }
}

위 코드를 한 번 자세히 들여다 보겠습니다.

애니메이션 상태가 INITIAL이라면 slideValue90.dp가 되고, FINAL상태라면 0.dp가 됩니다.

그 다음 부분이 굉장히 중요한데요, 애니메이션이 어떻게 동작할지를 결정하게 됩니다. 우리는 tween애니메이션을 사용할텐데, 이는 아래와 같은 파라미터를 받습니다.

  • durationMillis - 애니메이션이 동작하는 시간
  • delayMillis - 애니메이션이 시작되기 전까지 지연 시간
  • easing - 시작과 끝 사이에 적용될 움직임의 곡선(번역이 매끄럽지 못해 원본을 표기합니다 - The easing curve that will be used to interpolate between start and end)

Jetpack Compose는 다른 애니메이션 타입들도 제공하기 때문에 관심이 있으시면 아래에서 참고해보시길 추천합니다. https://developer.android.com/reference/kotlin/androidx/compose/animation/core/TransitionSpec

이제 애니메이션을 UI에 적용해봅시다.

@Composable
fun ItemCardView(index : Int) {
    val listItemAnimationDefinition = remember(index) {
            ListItemAnimationDefinition(300)
        }
    val listItemTransition = transition(
        definition = listItemAnimationDefinition.definition,
        initState = ListItemAnimationState.INITIAL,
        toState = ListItemAnimationState.FINAL,
    )
    Card(
        modifier = Modifier
            .height(200.dp)
            .fillMaxWidth()
            .absoluteOffset(x = listItemTransition[listItemAnimationDefinition.slideValue])
    ) {
        Text(
            text = "I'm a card from Jetpack Compose",
            textAlign = TextAlign.Center
        )
    }
}

위 코드에는 INITIAL 상태에서 FINAL 상태로 변화할 때 동작하는 애니메이션을 정의했고, 애니메이션이 어떻게 동작할 지 정의한 definition을 인자로 넘겼습니다.

또한 우리는 Jetpack Compose가 여러번 recomposition되는 것을 알고 있기 때문에 remember를 사용하여 애니메이션을 저장하고 recomposition이 일어날 때 이를 재사용 합니다.

Card를 animate하기 위해 absoluteOffset 함수를 사용하며 x 매개변수에 animated value를 넘겨줍니다.

.absoluteOffset(x =listItemTransition[listItemAnimationDefinition.slideValue])

이게 끝입니다. 앱을 실행시켜 결과를 볼까요?

1_JCANITl25AC8uKXK8v30jw

Additional

FloatPropKey를 사용하여 투명도 애니메이션을 추가할 수도 있습니다.

아래처럼 새로운 변수를 추가합니다.

val alphaValue = FloatPropKey(label = "alpha")

INITIAL상태와 FINAL상태에 해당하는 값을 정의해둡니다.

state(ListItemAnimationState.INITIAL) {
    this[slideValue] = 90.dp
    this[alphaValue] = 0.0f
}
state(ListItemAnimationState.FINAL) {
    this[slideValue] = 90.dp
    this[alphaValue] = 0.0f
}

이제 transitionDefinition 를 아래 코드처럼 넣어줍니다.

alphaValue using tween(
    durationMillis = 400,
    delayMillis = delay
)

이 alpha값으로 UI를 바꾸는 것을 잊지 마세요.

.alpha(listItemTransition[listItemAnimationDefinition.slideValue])

Conclusion

이렇게 해서 Jetpack Compose로 애니메이션을 만들고 사용하는 방법을 알아보았습니다. Jetpack Compose에서는 멋진 애니메이션을 아주 쉽게 만들 수 있기 때문에 인상적입니다. 이게 당신에게 도움이 되셨다면 좋겠네요.

언제든 제 트위터를 팔로우해주세요. 이 글과 관련된 어떤 질문을 하셔도 좋습니다.

읽어주셔서 감사합니다.

Using CoroutineContext to repeat failed HTTP request(번역)

|

원본 - Using CoroutineContext to repeat failed HTTP request

안드로이드 앱에서는 인터넷 연결에 문제가 있을 때처럼 무언가 문제가 있을 때면 요청을 다시 보내야 하는 경우가 흔합니다.

여러개의 HTTP 요청을 보내고나서 그 중 실패한 요청들에 한하여 “재요청” 버튼을 누를 수 있도록 하는 화면이 있다고 가정해봅시다(동시에 여러개의 요청을 보내는 상황으로만 생각하실 필요는 없습니다).

만약 화면에 요청이 단 하나만 있다면 매우 간단한 일이겠죠. 단지 그 요청을 다시 요청하면 되니까요. 그러나 한 화면에서 제각각 다른 시각에 보내진 여러 다른 요청들이 있다면, 우리는 그 요청들을 구분해낼 수 있는 방법이 필요합니다. 마지막에 호출된 함수 자체를 계속 저장해놓는 방법이 있긴 하지만 이건 유연하지 못할 겁니다. 만약 몇몇 요청은 “다시 시도하기” 다이얼로그를 띄워주지 않아도 될 경우 그 변수들을 다 지워줘야 하고, 만약 지워주는 걸 까먹는다면 “다시 시도하기” 버튼을 눌렀을 경우 성공한 함수가 재요청되는 버그가 되겠죠.

간단한 해결법은 CoroutineContext를 사용하여 나중에 다시 시도해야하는 함수를 저장하는 것입니다.

기본적으로 CoroutineContext는 Job, CoroutineDispatcher, CoroutineName, CoroutineExceptionHandler 들로 구성됩니다(자세한 내용는 article about Coroutines에서 확인할 수 있습니다). 그러나 공식 문서의 정의에 따르면 CoroutineContext는 그저 Element 객체들의 indexed set입니다. 이말은 즉 AbstractCoroutineContextElement 클래스를 확장하여 자신만의 Element interface를 구현할 수도 있다는 이야기입니다.

class RetryCallback(val callback: () -> Unit) :
   AbstractCoroutineContextElement(RetryCallback) {
   companion object Key : CoroutineContext.Key<RetryCallback>
}

위와 같이 코드를 작성하고나면 우리가 시작한 코루틴의 CoroutineContext에 RetryCallback를 추가할 수 있습니다(launch의 첫 번째 인자로 CoroutineContext를 넘깁니다).

fun someFunction(someParam: List<String>) {
   viewModelScope.launch(exceptionHandler
      + RetryCallback { someFunction(someParam) }) {
      // perform the request, update the view with the result
   }
}

그리고 만약 request가 실패한다면 CoroutineExceptionHandler에서 callback을 다룰수 있습니다.

private val exceptionHandler = CoroutineExceptionHandler {
   coroutineContext, throwable ->
   val callback: (() -> Unit)? = coroutineContext[RetryCallback.Key]
                                    ?.callback
   // ...
}

CoroutineExceptionHandler에서 단순히 함수를 Activity나 Fragment로 넘기고, 사용자가 “다시 시도하기”버튼을 클릭할 때 그 함수를 실행히시키만 하면 됩니다.