안드로이드 애플리케이션 개발 시 비동기 이미지 로딩 또는 대량 이미지 로딩을 다루는 것은 흔한 작업입니다. 이미지 로딩 과정에서 이미지 순서 혼란이나 OOM(메모리 부족) 문제와 같은 다양한 문제점에 직면할 수 있습니다. 이러한 문제점을 해결하기 위해 많은 오픈소스 이미지 로딩 프레임워크가 등장했으며, 그중 Universal-Image-Loader는 매우 강력한 기능을 제공하는 대표적인 라이브러리입니다. 이 글에서는 이 프레임워크의 기본 소개와 사용 방법을 다루어, 아직 이 프레임워크를 사용해보지 않은 개발자들에게 도움을 드리고자 합니다.
Github에서 해당 프로젝트를 확인할 수 있습니다: https://github.com/nostra13/Android-Universal-Image-Loader
이 라이브러리의 주요 특징은 다음과 같습니다:
- 다중 스레드를 이용한 이미지 다운로드, 이미지는 네트워크, 파일 시스템, 프로젝트의 assets 폴더 및 drawable 등 다양한 출처에서 로드 가능
- ImageLoader에 대한 자유로운 설정 지원 (스레드 풀, 이미지 다운로더, 메모리 캐시 전략, 디스크 캐시 전략, 이미지 표시 옵션 등)
- 메모리 캐싱, 파일 시스템 캐싱 또는 SD 카드 캐싱 지원
- 이미지 다운로드 과정 모니터링 기능
- 컨트롤(ImageView) 크기에 따라 Bitmap을 자르며, Bitmap이 너무 많은 메모리를 차지하는 것을 방지
- 이미지 로딩 과정을 제어하는 기능 제공 (일반적으로 ListView, GridView에서 스크롤 중에는 이미지 로딩 일시 중지, 스크롤 중단 시 다시 로드)
- 느린 네트워크 환경에서도 이미지 로딩 지원
위에 언급된 특징이 전부는 아니며, 다른 기능들은 실제 사용을 통해 발견할 수 있습니다. 이제 이 오픈소스 라이브러리의 기본 사용법을 살펴보겠습니다.
새로운 안드로이드 프로젝트를 생성하고, JAR 파일을 다운로드하여 프로젝트의 libs 폴더에 추가합니다.
Application 클래스를 상속받는 CustomApplication 클래스를 새로 만들고, onCreate() 메서드에서 ImageLoader의 설정 파라미터를 생성하여 초기화합니다.
package com.example.imageloaderdemo;
import com.nostra13.universalimageloader.core.ImageLoader;
import com.nostra13.universalimageloader.core.ImageLoaderConfiguration;
import android.app.Application;
public class CustomApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
// 기본 ImageLoader 설정 생성
ImageLoaderConfiguration config = ImageLoaderConfiguration
.createDefault(this);
// 설정으로 ImageLoader 초기화
ImageLoader.getInstance().init(config);
}
}
ImageLoaderConfiguration은 ImageLoader의 설정 파라미터로 빌더 패턴을 사용합니다. createDefault() 메서드를 사용하여 기본 설정을 생성할 수 있으며, 직접 설정을 커스터마이징할 수도 있습니다:
File cacheDir = StorageUtils.getCacheDirectory(context);
ImageLoaderConfiguration config = new ImageLoaderConfiguration.Builder(context)
.memoryCacheExtraOptions(480, 800) // 기본값: 기기 화면 크기
.diskCacheExtraOptions(480, 800, CompressFormat.JPEG, 75, null)
.taskExecutor(...)
.taskExecutorForCachedImages(...)
.threadPoolSize(3) // 기본값
.threadPriority(Thread.NORM_PRIORITY - 1) // 기본값
.tasksProcessingOrder(QueueProcessingType.FIFO) // 기본값
.denyCacheImageMultipleSizesInMemory()
.memoryCache(new LruMemoryCache(2 * 1024 * 1024))
.memoryCacheSize(2 * 1024 * 1024)
.memoryCacheSizePercentage(13) // 기본값
.diskCache(new UnlimitedDiscCache(cacheDir)) // 기본값
.diskCacheSize(50 * 1024 * 1024)
.diskCacheFileCount(100)
.diskCacheFileNameGenerator(new HashCodeFileNameGenerator()) // 기본값
.imageDownloader(new BaseImageDownloader(context)) // 기본값
.imageDecoder(new BaseImageDecoder()) // 기본값
.defaultDisplayImageOptions(DisplayImageOptions.createSimple()) // 기본값
.writeDebugLogs()
.build();
AndroidManifest 파일 설정:
<manifest>
<uses-permission android:name="android.permission.INTERNET" />
<!-- UIL이 SD 카드에 이미지를 캐시할 수 있도록 허용하려면 다음 권한 포함 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
...
<application android:name="CustomApplication">
...
</application>
</manifest>
이제 이미지를 로드해 보겠습니다. 먼저 Activity의 레이아웃 파일을 정의합니다:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:layout_gravity="center"
android:id="@+id/photo_view"
android:src="@drawable/empty_placeholder"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</FrameLayout>
ImageView만 포함된 간단한 레이아웃입니다. 이제 이미지를 로드해 보겠습니다. ImageLoader는 displayImage(), loadImage(), loadImageSync() 등 여러 가지 이미지 로딩 메서드를 제공합니다. loadImageSync() 메서드는 동기 방식이므로 안드로이드 4.0 이상에서는 메인 스레드에서 네트워크 작업을 수행할 수 없기 때문에 사용하지 않습니다.
loadImage()를 이용한 이미지 로드
먼저 ImageLoader의 loadImage() 메서드를 사용하여 네트워크 이미지를 로드해 보겠습니다:
final ImageView photoView = (ImageView) findViewById(R.id.photo_view);
String imageUrl = "https://example.com/sample_image.jpg";
ImageLoader.getInstance().loadImage(imageUrl, new ImageLoadingListener() {
@Override
public void onLoadingStarted(String imageUri, View view) {
// 이미지 로딩 시작 시 호출
}
@Override
public void onLoadingFailed(String imageUri, View view, FailReason failReason) {
// 이미지 로딩 실패 시 호출
}
@Override
public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) {
// 이미지 로딩 완료 시 호출
photoView.setImageBitmap(loadedImage);
}
@Override
public void onLoadingCancelled(String imageUri, View view) {
// 이미지 로딩 취소 시 호출
}
});
이미지 URL과 ImageLoaderListener를 전달하고, onLoadingComplete() 콜백 메서드에서 loadedImage를 ImageView에 설정합니다. ImageLoaderListener 인터페이스를 구현하는 것이 번거롭다고 느껴진다면, SimpleImageLoadingListener 클래스를 사용할 수 있습니다. 이 클래스는 기본적으로 ImageLoaderListener 인터페이스 메서드의 빈 구현을 제공합니다:
final ImageView photoView = (ImageView) findViewById(R.id.photo_view);
String imageUrl = "https://example.com/sample_image.jpg";
ImageLoader.getInstance().loadImage(imageUrl, new SimpleImageLoadingListener(){
@Override
public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) {
super.onLoadingComplete(imageUri, view, loadedImage);
photoView.setImageBitmap(loadedImage);
}
});
이미지 크기를 지정해야 한다면 어떻게 해야 할까요? ImageSize 객체를 초기화하여 이미지의 너비와 높이를 지정할 수 있습니다:
final ImageView photoView = (ImageView) findViewById(R.id.photo_view);
String imageUrl = "https://example.com/sample_image.jpg";
ImageSize imageSize = new ImageSize(100, 100);
ImageLoader.getInstance().loadImage(imageUrl, imageSize, new SimpleImageLoadingListener(){
@Override
public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) {
super.onLoadingComplete(imageUri, view, loadedImage);
photoView.setImageBitmap(loadedImage);
}
});
위 코드는 ImageLoader를 사용하여 네트워크 이미지를 로드하는 간단한 예제입니다. 실제 개발에서는 이렇게 사용하지 않으며, 대부분 DisplayImageOptions를 사용합니다. 이 옵션을 사용하면 이미지 로딩 중에 ImageView에 표시할 이미지, 메모리 캐시 사용 여부, 파일 캐시 사용 여부 등을 설정할 수 있습니다. 사용 가능한 옵션은 다음과 같습니다:
DisplayImageOptions options = new DisplayImageOptions.Builder()
.showImageOnLoading(R.drawable.loading_placeholder) // 리소스 또는 드로어블
.showImageForEmptyUri(R.drawable.empty_image) // 리소스 또는 드로어블
.showImageOnFail(R.drawable.error_image) // 리소스 또는 드로어블
.resetViewBeforeLoading(false) // 기본값
.delayBeforeLoading(1000)
.cacheInMemory(false) // 기본값
.cacheOnDisk(false) // 기본값
.preProcessor(...)
.postProcessor(...)
.extraForDownloader(...)
.considerExifParams(false) // 기본값
.imageScaleType(ImageScaleType.IN_SAMPLE_POWER_OF_2) // 기본값
.bitmapConfig(Bitmap.Config.ARGB_8888) // 기본값
.decodingOptions(...)
.displayer(new SimpleBitmapDisplayer()) // 기본값
.handler(new Handler()) // 기본값
.build();
위 코드를 약간 수정해 보겠습니다:
final ImageView photoView = (ImageView) findViewById(R.id.photo_view);
String imageUrl = "https://example.com/sample_image.jpg";
ImageSize imageSize = new ImageSize(100, 100);
// 이미지 표시 옵션 설정
DisplayImageOptions options = new DisplayImageOptions.Builder()
.cacheInMemory(true)
.cacheOnDisk(true)
.bitmapConfig(Bitmap.Config.RGB_565)
.build();
ImageLoader.getInstance().loadImage(imageUrl, imageSize, options, new SimpleImageLoadingListener(){
@Override
public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) {
super.onLoadingComplete(imageUri, view, loadedImage);
photoView.setImageBitmap(loadedImage);
}
});
DisplayImageOptions를 사용하여 이미지 표시 옵션을 설정했습니다. 여기서 메모리 캐싱과 파일 시스템 캐싱을 추가했으므로, 매번 네트워크에서 이미지를 로드할 필요가 없습니다. DisplayImageOptions 옵션 중 일부는 loadImage() 메서드에는 적용되지 않습니다. 예를 들어, showImageOnLoading, showImageForEmptyUri 등은 적용되지 않습니다.
displayImage()를 이용한 이미지 로드
이제 네트워크 이미지 로딩의 다른 방법인 displayImage() 메서드를 살펴보겠습니다:
final ImageView photoView = (ImageView) findViewById(R.id.photo_view);
String imageUrl = "https://example.com/sample_image.jpg";
// 이미지 표시 옵션 설정
DisplayImageOptions options = new DisplayImageOptions.Builder()
.showImageOnLoading(R.drawable.loading_placeholder)
.showImageOnFail(R.drawable.error_image)
.cacheInMemory(true)
.cacheOnDisk(true)
.bitmapConfig(Bitmap.Config.RGB_565)
.build();
ImageLoader.getInstance().displayImage(imageUrl, photoView, options);
위 코드에서 볼 수 있듯이, displayImage()를 사용하면 loadImage()보다 훨씬 간편합니다. ImageLoadingListener 인터페이스를 추가할 필요도 없으며, Bitmap 객체를 수동으로 ImageView에 설정할 필요도 없습니다. ImageView를 displayImage() 메서드의 파라미터로 전달하기만 하면 됩니다. 이미지 표시 옵션에서 이미지 로딩 중에 ImageView에 표시할 이미지와 이미지 로딩 실패 시 표시할 이미지를 추가했습니다. 초기에는 loading_placeholder 이미지가 표시되고, 이미지 로딩이 성공하면 실제 이미지가 표시되며, 오류 발생 시 error_image가 표시됩니다.
이 메서드는 사용하기 매우 편리하며, displayImage() 메서드는 컨트롤의 크기와 imageScaleType에 따라 이미지를 자동으로 잘라줍니다. CustomApplication 클래스를 수정하여 로그 출력을 활성화해 보겠습니다:
public class CustomApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
// 기본 ImageLoader 설정 생성
ImageLoaderConfiguration configuration = new ImageLoaderConfiguration.Builder(this)
.writeDebugLogs() // 로그 정보 출력
.build();
// 설정으로 ImageLoader 초기화
ImageLoader.getInstance().init(configuration);
}
}
이제 이미지 로딩 로그 정보를 살펴보겠습니다:
첫 번째 정보는 이미지 로딩을 시작했음을 알려주며, 이미지 URL과 최대 너비, 높이를 출력합니다. 이미지의 너비와 높이는 기본적으로 기기 화면 크기로 설정되지만, 이미지 크기를 정확히 알고 있다면 memoryCacheExtraOptions(int maxImageWidthForMemoryCache, int maxImageHeightForMemoryCache) 옵션을 설정하여 크기를 지정할 수 있습니다.
두 번째 정보는 이미지가 네트워크에서 로드되었음을 나타냅니다.
세 번째 정보는 원본 이미지 크기가 1024 x 682에서 잘려 512 x 341로 변환되었음을 보여줍니다.
네 번째 정보는 이미지가 메모리 캐시에 추가되었음을 나타냅니다. 여기서는 SD 카드 캐싱을 사용하지 않았으므로 파일 캐시 로그는 없습니다.
네트워크 이미지를 로드할 때 종종 이미지 다운로드 진행 상황을 표시해야 하는 경우가 있습니다. Universal-Image-Loader는 이러한 기능도 제공하며, displayImage() 메서드에 ImageLoadingProgressListener 인터페이스를 전달하기만 하면 됩니다:
imageLoader.displayImage(imageUrl, photoView, options, new SimpleImageLoadingListener(), new ImageLoadingProgressListener() {
@Override
public void onProgressUpdate(String imageUri, View view, int current, int total) {
// 이미지 로딩 진행 상황 업데이트
}
});
displayImage() 메서드 중 ImageLoadingProgressListener 파라미터를 가진 메서드들은 모두 ImageLoadingListener 파라미터를 가지므로, 여기서는 SimpleImageLoadingListener를 새로 생성하여 전달합니다. 그러면 onProgressUpdate() 콜백 메서드에서 이미지 로딩 진행 상황을 확인할 수 있습니다.
다양한 출처의 이미지 로드
Universal-Image-Loader 프레임워크는 네트워크 이미지뿐만 아니라 SD 카드의 이미지, Content provider 등을 로드할 수도 있습니다. 사용 방법은 이미지 URL을 약간 변경하기만 하면 됩니다. 다음은 파일 시스템의 이미지를 로드하는 예제입니다:
// 이미지 표시 옵션 설정
DisplayImageOptions options = new DisplayImageOptions.Builder()
.showImageOnLoading(R.drawable.loading_placeholder)
.showImageOnFail(R.drawable.error_image)
.cacheInMemory(true)
.cacheOnDisk(true)
.bitmapConfig(Bitmap.Config.RGB_565)
.build();
final ImageView photoView = (ImageView) findViewById(R.id.photo_view);
String imagePath = "/mnt/sdcard/sample_image.png";
String imageUrl = Scheme.FILE.wrap(imagePath);
imageLoader.displayImage(imageUrl, photoView, options);
물론 Content provider, drawable, assets 등에서 이미지를 로드할 수도 있습니다. 각 출처별로 Scheme으로 감싸서 이미지 URL로 전달하면(Content provider는 제외), Universal-Image-Loader 프레임워크는 다른 Scheme에 따라 입력 스트림을 가져옵니다:
// Content provider에서 이미지 로드
String contentProviderUrl = "content://media/external/audio/albumart/13";
// assets에서 이미지 로드
String assetsUrl = Scheme.ASSETS.wrap("image.png");
// drawable에서 이미지 로드
String drawableUrl = Scheme.DRAWABLE.wrap("R.drawable.image");
GridView, ListView에서 이미지 로드
대부분의 개발자들은 GridView나 ListView를 사용하여 대량의 이미지를 표시합니다. 빠르게 스크롤할 때 이미지 로딩을 중지하고, 스크롤이 멈췄을 때 현재 화면의 이미지를 로드하고 싶을 것입니다. 이 프레임워크는 이러한 기능을 제공하며, 사용 방법도 간단합니다. PauseOnScrollListener 클래스를 사용하여 ListView, GridView 스크롤 중 이미지 로딩을 중지할 수 있습니다. 이 클래스는 프록시 패턴을 사용합니다:
listView.setOnScrollListener(new PauseOnScrollListener(imageLoader, pauseOnScroll, pauseOnFling));
gridView.setOnScrollListener(new PauseOnScrollListener(imageLoader, pauseOnScroll, pauseOnFling));
첫 번째 파라미터는 이미지 로딩 객체 ImageLoader입니다. 두 번째 파라미터는 스크롤 중 이미지 로딩을 일시 중지할지 여부를 제어합니다. 중지하려면 true를 전달합니다. 세 번째 파라미터는 강하게 스크롤할 때 이미지를 로드할지 여부를 제어합니다.
OutOfMemoryError 처리
이 프레임워크는 효율적인 캐시 메커니즘을 통해 OOM 발생을 방지하지만, OOM이 절대 발생하지 않는다고 보장할 수는 없습니다. 이 프레임워크는 OOM에 대한 간단한 처리를 수행하여 프로그램이 OOM으로 인해 충돌하지 않도록 합니다. 하지만 이 프레임워크를 사용할 때 OOM이 자주 발생한다면 다음과 같은 방법으로 개선할 수 있습니다:
- 스레드 풀의 스레드 개수를 줄입니다. ImageLoaderConfiguration의 .threadPoolSize() 옵션에서 설정하며, 1-5를 권장합니다.
- DisplayImageOptions 옵션에서 bitmapConfig를 Bitmap.Config.RGB_565로 설정합니다. 기본값은 ARGB_8888이며, RGB_565를 사용하면 ARGB_8888보다 2배 적은 메모리를 소비합니다.
- ImageLoaderConfiguration에서 메모리 캐시를 memoryCache(new WeakMemoryCache())로 설정하거나 메모리 캐시를 사용하지 않도록 설정합니다.
- DisplayImageOptions 옵션에서 .imageScaleType(ImageScaleType.IN_SAMPLE_INT) 또는 imageScaleType(ImageScaleType.EXACTLY)를 설정합니다.
위 방법들을 통해 Universal-Image-Loader 프레임워크 사용에 대해 잘 이해하셨기를 바랍니다. 이 프레임워크를 사용할 때는 가능한 displayImage() 메서드를 사용하여 이미지를 로드하는 것이 좋습니다. loadImage() 메서드는 이미지 객체를 ImageLoadingListener 인터페이스의 onLoadingComplete() 메서드로 콜백하며, 수동으로 ImageView에 설정해야 합니다. displayImage() 메서드는 ImageView 객체에 약한 참조(Weak references)를 사용하여 가비지 컬렉터가 ImageView 객체를 쉽게 회수할 수 있도록 합니다. 고정 크기의 이미지를 로드해야 할 때는 loadImage() 메서드에 ImageSize 객체를 전달해야 하지만, displayImage() 메서드는 ImageView 객체의 측정값 또는 android:layout_width 및 android:layout_height로 설정된 값, 또는 android:maxWidth 및/또는 android:maxHeight로 설정된 값에 따라 이미지를 자릅니다.
오늘은 여기까지입니다. 궁금한 점이 있으면 댓글로 남겨주시면 최대한 답변해 드리겠습니다. 다음 글에서는 이 프레임워크에 대해 더 깊이 분석하겠습니다. 계속해서 관심 가져주시기 바랍니다!