본문 바로가기

안드로이드/이미지

[Coil] 이미지 Preload

이전에 진행했던 프로젝트에서 이미지를 다뤘는데, 지도에서 친구를 선택하면 친구가 찍은 사진이 지도에 표시되는 기능이었다.

 

다만 네트워크에서 이미지를 가져오려면 위와 같이 2~3초의 로딩이 소요되었다.

 

네트워크에서 이미지를 가져오는 작업은 많은 리소스가 필요하다. 매번 이미지를 네트워크에서 불러오게 된다면 많은 리소스 사용으로 인해 성능 또한 그만큼 떨어지게 될 것이다.

때문에 Coil과 Glide 같은 이미지 라이브러리에서는 네트워크에서 가져오는 작업을 최대한 줄이고자 캐싱을 이용한다.

캐싱

Coil에서 이미지를 불러올 때 처음으로 메모리 캐시에서 이미지를 찾고, 없으면 디스크 그 다음 네트워크 요청을 수행한다.

 

실제 Coil로 이미지 로드할 때 Log를 출력해보면

ImageRequest.Builder(context)
    .listener { request, result ->
        Log.e("Load Location", result.dataSource.name)
    }

 

처음 이미지를 로드할 땐 NETWORK에서 불러오고 이후 다시 이미지를 로드할 땐 메모리 캐시에서 이미지를 불러온다.

앱을 종료하여 메모리 캐시를 날리고 다시 이미지를 로드할 땐 디스크 캐시에서 이미지를 불러와 보다 빠르게 이미지를 사용자에게 보여준다.

 

문제 분석

현재 지도에 표시된 클러스터링을 클릭하면 해당 위치의 이미지들이 사용자에게 보여진다.

이미지를 불러오는 시점도 클러스터링을 클릭한 순간으로 만일 해당 클러스터링 이미지가 처음 불러오는 경우라면 사용자에겐

2~3초의 로딩 이후에 이미지가 보여지게 된다.

 

그렇다면 클러스터링을 클릭하기 전에 미리 이미지를 미리 가져와 메모리 또는 디스크에 캐싱해두면 클러스터링을 클릭했을 때 로딩 없이 이미지가 보여지지 않을까?

 

Image Preload

@Composable
fun EffectCollection(...){
    LaunchedEffect(friendData){
    	...
        val imageLoader = ImageLoader.Builder(context)
            .memoryCachePolicy(CachePolicy.DISABLED)
            .build()

        totalPhotos.forEach { photo ->
            launch {
                preload(photo.uri, context, imageLoader)
            }
        }
    }
}

private fun preload(context: Context, url: String, imageLoader: ImageLoader){
    val request = ImageRequest.Builder(context)
        .data(url)
        .crossfade(true)
        .build()

    imageLoader.enqueue(request)
}

친구 선택 시 친구 정보에 있는 사진 Uri를 비동기 처리로 ImageLoader를 통해 로드시켜보았다.

 

 

클러스터링 클릭 전 로드 된 이미지는 Disk에서 불러와 빠르게 보여지지만 아직 로드 중인 이미지는 다시 불러오는 것을 볼 수 있다.

Network Inspector로 확인해보면 로드 중간에 클러스터링을 클릭 시 다시 네트워크에서 이미지를 불러와 2번 로드 되었다.

 

즉, 클러스터링 클릭 전에 로드 된 이미지는 네트워크 호출을 한 번만 하며, Disk 또는 메모리에서 불러와 빠르게 사용자에게 보여지지만 아직 로딩 중인 이미지는 다시 네트워크에서 호출하여 불러오는 동안의 로딩과 불필요한 네트워크 호출이 한 번 더 수행되었다.

빠르게 이미지를 로드하고자 했던 일이 네트워크를 불필요하게 2번 호출하는 경우가 생겨버렸다.

 

2차 문제 - 네트워크 2번 호출

왜 2번 네트워크가 호출되었을까?

Coil의 AsyncImage 또는 SubcomposeAsyncImage는 호출되면 내부의 ImageLoader에서 캐싱을 확인하고 없는 경우 네트워크에서 이미지를 가져온다.

미리 로드 시켜놓은 Url 이미지의 로드가 완료되지 않은 경우, 메모리 또는 디스크 캐시에 이미지가 없으니 네트워크 호출이 일어나 기존에 미리 로드시킨 이미지 네트워크와 AsyncImage에서 다시 호출한 이미지 네트워크 통신이 발생하여 불필요한 네트워크 호출이 일어난 것이었다.

 

이를 해결하기 위해 처음 생각한 방법은 이미지 로드가 완료될 때까지 로딩 상태를 보여주는 것

사실 이 방법은 해결보단 회피에 가까운 방법으로 최후의 보루 느낌으로 Keep해놓고 다른 방법을 생각해보았다.

 

ImageRequest의 listener는 현재 로드 중인 이미지의 상태를 알려준다.

ImageRequest.Builder(context)
    .data(url)
    .listener(object : ImageRequest.Listener {
        override fun onStart(request: ImageRequest) {
            
        }

        override fun onSuccess(request: ImageRequest, result: SuccessResult) {
            
        }

        override fun onError(request: ImageRequest, result: ErrorResult) {
            
        }
    })
    .build()

 

ImageRequest.Listener는 메모리, 디스크와 네트워크 모두 동작한다.

  • onStart : 이미지 로드를 시작할 때
  • onSuccess: 이미지 로드를 완료했을 때
  • onError: 이미지 로드 중 에러 발생

해당 기능을 이용하여 Map에 Key는 Url, Value는 Url에 대한 상태를 저장한 뒤, 상태에 따라 이미지를 처리하기로 했다.

 

1. 로드 상태 정의

Preload 이미지의 상태를 정의 할 타입을 정의해준다.

sealed interface PreloadState {
    data object Loading : PreloadState

    data object Success : PreloadState

    data object Fail : PreloadState
}

 

2. 상태 저장

val preloadState = remember { hashMapOf<String, MutableState<PreloadState>>() }

 

Url의 로드 상태가 변함에 따라 UI 또한 변해야하기에 Value 타입은 MutableState로 Compose에서 관찰 가능하도록 구현하였고 내부 타입은 위에서 정의한 PreloadState로 선언

 

3. ImageRequest.Listener

val request = ImageRequest.Builder(context)
    .data(url)
    .listener(object : ImageRequest.Listener {
        override fun onStart(request: ImageRequest) {
            preloadState.getOrPut(url) { mutableStateOf(PreloadState.Loading) }
        }

        override fun onSuccess(request: ImageRequest, result: SuccessResult) {
            preloadState[url]?.value = PreloadState.Success
        }

        override fun onError(request: ImageRequest, result: ErrorResult) {
            preloadState[url]?.value = PreloadState.Fail
        }
    })
    .crossfade(true)
    .build()

imageLoader.enqueue(request)

각 Url 로드 상태에 따라 preloadState의 상태를 변경해준다.

 

4. UI 적용

Row(
    ...
) {
    row.forEach { photo ->
        when (preloadState[photo.uri]?.value ?: PreloadState.Fail) {
            is PreloadState.Loading -> {
                Box(
                    modifier = modifier,
                ) {
                    RotatingImageLoading(
                        drawableRes = LoadingIcons.entries.random().id,
                        stringRes = null,
                    )
                }
            }

            is PreloadState.Success, PreloadState.Fail -> {
                AsyncImage(
                    model = ImageRequest.Builder(LocalContext.current)
                        .data(photo.uri)
                        .placeholder(R.drawable.ic_image)
                        .build(),
                    contentDescription = photo.label.name,
                    modifier = modifier,
                    contentScale = ContentScale.Crop,
                    ...
                )
            }
        }

    }
    ...
}

 

각각의 이미지들은 preloadState의 Value인 MutableState<PreloadState>의 값이 Loading이라면 로딩 이미지가 보여지게되고, 이후 로드가 완료되면 이미지가 사용자에게 보여지게 된다.

 

결과

 

AsyncImage에 로그를 찍어 어디서 이미지를 불러오는지 확인해보면 아래와 같이 둘다 디스크에서 가져오고 있으며, 검은색 이미지의 경우 로딩상태에서 로드가 완료되자 디스크에서 이미지를 불러 화면에 보여지는 것을 확인할 수 있다.

 

또한 NetworkInspector로 네트워크 호출을 살펴보면 로딩 유무와 상관 없이 각 이미지가 단 한 번만 호출되는 것을 볼 수 있다.

 

'안드로이드 > 이미지' 카테고리의 다른 글

이미지 캐싱  (1) 2025.03.08
이미지 리사이징  (0) 2025.01.22
[안드로이드] 이미지 Glide  (0) 2023.11.03