[Compose Multiplatform] Coil 을 이용한 Network Image Load

이지훈·2024년 1월 1일
1

서두

Compose Multiplatform(이하 CMP) 을 통해 토이 프로젝트를 진행하는 도중, 반가운 소식이 하나 들려왔다.

https://github.com/coil-kt/coil/blob/main/CHANGELOG.md#300-alpha01---december-30-2023

요약하면 Coil 이 드디어 CMP 를 지원한다는 내용이다.

기존에 Kotlin Multiplatform(이하 KMP) 혹은, CMP 에서 Image Load 를 위해선 Kamel 이나 Compose-Image-Loader 라이브러리를 사용 했어야 했는데, Compose 로 개발하는 개발자들 입장에선 익숙하고, 메모리 이슈를 핸들링 하기 용이한 Coil 을 사용하는 것을 더 선호할 것이다. 근--본


Coil은 위에 설명에서 처럼, 기본적으로 singleton 으로 설정된 ImageLoader 를 사용하는데, 한번 로드 되었던 이미지의 경우 캐싱되어, 다음번에 같은 이미지를 로드할 경우, 인터넷 연결이 되지 않은 상태에서도 로드할 수 있는 장점이 있다.

다만, 아직 alpha 버전이고, 예측할 수 없는 문제가 발생할 수 있기에, 상용 프로젝트엔 도입하기엔 무리가 있을 것 같고, 토이 프로젝트에는 적용 해볼만 할 것 같다.
기존에 Compose-Image-Loader 를 통해 network 에서 불러온 image를 load 하였는데, 이를 Coil3로 migration 해보도록 하겠다.

참고로 Compose Multiplatform 프로젝트 생성은 Jetbrains 공식 홈페이지에서 제공하는 Wizard 를 통해 가능하다.
https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-multiplatform-create-first-app.html#create-the-project-with-a-wizard

TL;DR

  • Coil 에서는 LocalPlatformContext.current 를 통해 context 를 받아올 수 있다.

  • 네트워크 이미지를 로드하기 위해선 별도의 코드를 작성해줘야 한다.
    https://coil-kt.github.io/coil/upgrading_to_coil3/#network-images

  • 역시 진리의 공식 문서! 공식 문서를 꼼꼼히 읽자... 답이 있으리...

문제 상황 발생 1

일반적으로 coil 을 사용할 때, 다음과 같이 코드를 작성하곤 했다.

AsyncImage(
	model = ImageRequest.Builder(context)
    			.data(photo.downloadUrl) // 로드할 이미지 url
                .crossfade(true) // 이미지가 로드되면서 서서히 보여지는 효과 적용
                .build(),
    contentDescription = stringResource(id = R.string.photo_image),
    contentScale = ContentScale.Crop, // 이미지를 Crop 해서 보여줌
)

CMP 프로젝트에선 UI 에 해당하는 Component, Screen 코드를 AndroidMain 모듈이 아닌 CommonMain 모듈에서 작성 해주어야 한다.(각각의 플랫폼에서 별도로 UI를 구성해줄 필요 없이 공유하도록)
그렇다.
context 에 접근할 수 없다!!!...

val context = LocalContext.current

위와 같은 방식으로 기존의 composable 함수에선 context 를 손쉽게 가져올 수 있었는데, CMP 에서는 해당 방식이 통하지 않으므로, context 가 있어야 이미지를 로드할 수 있는 상황에서 context 를 가져올 수 없는 문제에 직면했다.

문제 해결 1

다행히 coil githubsample 의 코드를 타고 타고 들어가보면서 coil3 측에서 제공하는 LocalPlatformContext 가 존재한다는 것을 확인할 수 있었고, 문제를 해결할 수 있었다.

package coil3.compose

import androidx.compose.runtime.staticCompositionLocalOf
import coil3.PlatformContext

actual val LocalPlatformContext = staticCompositionLocalOf { PlatformContext.INSTANCE }
package coil3

import kotlin.jvm.JvmField

actual abstract class PlatformContext private constructor() {
    companion object {
        @JvmField val INSTANCE = object : PlatformContext() {}
    }
}
AsyncImage(
	model = ImageRequest.Builder(LocalPlatformContext.current)
    			.data(photo.downloadUrl) // 로드할 이미지 url
                .crossfade(true) // 이미지가 로드되면서 서서히 보여지는 효과 적용 
                .build(),
   	contentDescription = stringResource(id = R.string.photo_image),
    contentScale = ContentScale.Crop, // 이미지를 Crop 해서 보여줌
)

또한 AsyncImage 에 placeholder 옵션을 추가해주고 싶을 수 있는데, KMP(CMP) 프로젝트의 shared, commonMain 모듈의 경우 R(Android Resource)에 접근할 수 없기에, moko-resource 라이브러리를 프로젝트에 주입하는 것으로, resource 파일(svg, png, otf, ttf 등등..)들을 shared, commonMain 모듈에 추가할 수 있다.

이는 이번 글의 주제와 맞지 않기에 다음 글에서 다뤄보도록 하겠다.
moko-resources 를 이용하여 font 적용하기

문제 상황 발생 2

이젠 눈으로 보이는 에러(컴파일 에러)는 존재하지 않아, 정상적으로 이미지가 로드되는지 확인 해보기 위해 빌드를 수행해보았다.

Shit... 이미지가 정상적으로 로드되지 않는다. 아직 문제가 남아있는 듯 하다.
원인을 파악해보기 위해서 디버깅을 해보기로 하였다.

참고로 Timber나 Android Log4j 를 통한 로그 확인은 불가능하다, 이전에 작성했던 아래 글을 참고
https://velog.io/@mraz3068/logging-method-in-domain-kotlin-module

Coil 에서는 AsyncImage 내에 onState 파라미터를 통해 현재 이미지 로드 상태를 확인할 수 있기에, 아래와 같이 코드를 작성하고, 중단점을 찍은 뒤 디버깅을 해보았다.

다음과 같은 결과를 확인할 수 있었는데

java.lang.IllegalStateException: Unable to create a fetcher that supports: "${imageUrl}"

imageUrl 을 통한 이미지 로드를 지원하기 위한 fetcher 를 생성할 수 없다는 에러 였다.

문제 해결 2

공식 문서에 다음과 같은 챕터가 있는 것을 확인할 수 있었다.
https://coil-kt.github.io/coil/upgrading_to_coil3/#network-images

요약하자면, imageUrl 통해 image load 를 수행하는 ImageLoader 가 더이상 default로 제공되는게 아니기 때문에, 별도의 설정을 직접 해줘야 한다는 의미이다.

추가적으로 이젠 OkHttp가 아닌 Ktor 에 의존하기에 Ktor 관련한 의존성을 주입하라고 적혀있다. OkHttp도 multiplatform 을 지원하기 위해 업데이트를 하고 있던데, 아직은 진행중이고, 언제 될지 모르는 일이기에, coil3 측에서 multiplatform을 지원하는 Ktor 로 migration을 한 듯 하다.(추측)

다시 본론으로 돌아가 공식 문서에서 제시하는 내용을 바탕으로 의존성을 추가해주고,

gradle/libs.versions.toml

ktor = "2.3.7"

coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil3" }
// 추가
coil-network = { module = "io.coil-kt.coil3:coil-network", version.ref = "coil3" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-engine-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
ktor-engine-ios = { module = "io.ktor:ktor-client-ios", version.ref = "ktor" }
ktor-engine-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }

composeApp/build.gradle.kts

      androidMain.dependencies {
          implementation(libs.compose.ui.tooling.preview)
          implementation(libs.androidx.activity.compose)
          implementation(libs.ktor.engine.android) // 추가
      }
      commonMain.dependencies {
          implementation(compose.runtime)
          implementation(compose.foundation)
          implementation(compose.material)
          implementation(compose.ui)
          @OptIn(ExperimentalComposeLibrary::class)
          implementation(compose.components.resources)
          implementation(libs.coil.compose)
          implementation(libs.coil.network) // 추가
      }

      iosMain.dependencies{
          implementation(libs.ktor.engine.ios) // 추가
      }

      desktopMain.dependencies {
          implementation(compose.desktop.currentOs)
          implementation(libs.ktor.engine.js) // 추가
      }
  }
}

이제 의존성은 모두 추가 완료했고, androidMain 에서 imageLoader 를 위한 클래스들을 추가줘야 한다.

AndroidMain/Application.kt

import android.app.Application
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
import org.jetbrains.compose.components.resources.BuildConfig
import util.newImageLoader

class Application : Application(), SingletonImageLoader.Factory {
      override fun newImageLoader(context: PlatformContext): ImageLoader {
          return newImageLoader(this, BuildConfig.DEBUG)
  }
}

commonMain/ImageLoader.kt

import coil3.ImageLoader
import coil3.PlatformContext
import coil3.annotation.ExperimentalCoilApi
import coil3.fetch.NetworkFetcher
import coil3.memory.MemoryCache
import coil3.request.crossfade
import coil3.util.DebugLogger

@OptIn(ExperimentalCoilApi::class)
fun newImageLoader(
    context: PlatformContext,
    debug: Boolean = false,
): ImageLoader {
    return ImageLoader.Builder(context)
        .components {
            add(NetworkFetcher.Factory())
        }
        .memoryCache {
            MemoryCache.Builder()
                // Set the max size to 25% of the app's available memory.
                .maxSizePercent(context, percent = 0.25)
                .build()
        }
        // Show a short crossfade when loading images asynchronously.
        .crossfade(true)
        // Enable logging if this is a debug build.
        .apply {
            if (debug) {
                logger(DebugLogger())
            }
        }
        .build()
}

이 부분에 대해선 coil 관련한 변경 사항이 생겨, 글 제일 하단에 Update 부분을 참고 부탁드립니다.

commonMain/App.kt

@OptIn(ExperimentalResourceApi::class, ExperimentalCoilApi::class)
@Composable
fun App(
  debug: Boolean = false
) {
  setSingletonImageLoaderFactory { context ->
      newImageLoader(context, debug)
  }

추가적으로 androidMain 내에 AndroidManifest.xml 에서 인터넷 권한을 부여하는 것을 잊지 말자.

<uses-permission android:name="android.permission.INTERNET" />

실행 결과)

AndroidiOS

정상적으로 Android/iOS 플랫폼에서 모두 Network 이미지를 로드할 수 있었다.

아직은 compose-image-loader를 사용하는 것이 비교적 별다른 공수 없이 더 간편하게 이미지를 로드할 수 있지만, coil 을 이용하여 compose-multiplatform 에서도 이미지를 성공적으로 로드 해볼 수 있었다. (뭐든 성공 해보는 경험이 중요하다고 생각한다.)

https://github.com/coil-kt/coil/discussions/1279

url 형태 의외에도 bitmap, byteArray 등을 로드하는 방법을 제공해준다고 하니, 갤러리에서 선택한 이미지의 uri 을 반환하는 Android의 PhotoPicker 뿐만 아니라 byteArray 타입으로 이미지를 반환 받는 iOS 의 PHPickerViewController 를 사용하는 경우에도 coil 을 사용할 수 있을 듯 하다!

전체 코드는 하단의 레포지토리 링크를 통해 확인 가능합니다.
https://github.com/KwonDae/ImagePicker

Update

Coil 최신 버전에서는 위에 작성된 코드 중, 하단에 주석으로 표기한 부분에서 에러가 발생한다고 한다.

commonMain/ImageLoader.kt

import coil3.ImageLoader
import coil3.PlatformContext
import coil3.annotation.ExperimentalCoilApi
import coil3.fetch.NetworkFetcher
import coil3.memory.MemoryCache
import coil3.request.crossfade
import coil3.util.DebugLogger

@OptIn(ExperimentalCoilApi::class)
fun newImageLoader(
    context: PlatformContext,
    debug: Boolean = false,
): ImageLoader {
    return ImageLoader.Builder(context)
        // 여기 아래 부분 !! 
        .components {
            add(NetworkFetcher.Factory()) // <-- 이 부분 !!
        }
        // 여기 윗 부분 !!
        .memoryCache {
            MemoryCache.Builder()
                // Set the max size to 25% of the app's available memory.
                .maxSizePercent(context, percent = 0.25)
                .build()
        }
        // Show a short crossfade when loading images asynchronously.
        .crossfade(true)
        // Enable logging if this is a debug build.
        .apply {
            if (debug) {
                logger(DebugLogger())
            }
        }
        .build()
}

위에 주석으로 표기된 부분을

 .components {
     add(KtorNetworkFetcherFactory())
 }

이렇게 바꿔주면 정상적으로 동작한다고 한다.

해당 함수는 coil3.network.ktor 패키지 안에 있는 KtorNetworkFetcher.kt 파일에서 확인할 수 있다.

@JvmName("factory")
fun KtorNetworkFetcherFactory() = NetworkFetcher.Factory(
     networkClient = { HttpClient().asNetworkClient() },
     cacheStrategy = { CacheStrategy() },
)

제보주신 박준수님 감사드립니다 ㅎㅎ

참고)
https://github.com/coil-kt/coil
https://coil-kt.github.io/coil/upgrading_to_coil3/#network-images
https://code.cash.app/multiplatform-image-loading
https://medium.com/preat/%ED%85%8C%EC%8A%A4%ED%8A%B8-eafc76e723fa
https://github.com/coil-kt/coil/discussions/1279
https://medium.com/preat/compose-multiplatform-%EC%9D%B4%EB%AF%B8%EC%A7%80-%ED%94%BC%EC%BB%A4-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%EB%B0%B0%ED%8F%AC%ED%95%98%EA%B8%B0-916f709c4e64
https://github.com/TEAM-PREAT/peekaboo
https://github.com/coil-kt/coil/blob/main/coil-network-ktor/src/commonMain/kotlin/coil3/network/ktor/KtorNetworkFetcher.kt

profile
실력은 고통의 총합이다. Android Developer

0개의 댓글