프로그래밍/안드로이드

안드로이드 - ImageDownloader 분석

가카리 2013. 12. 7. 22:43
반응형
[이 포스트는 Gilles Debunne 에 의해 작성되었습니다. 그는 멀티 태스킹을 사랑하는 안드로이드 엔지니어 입니다. — Tim Bray]


 빠르게 반응하는 어플리케이션을 만들기 위해서는 메인 UI 스레드가 가능한 최소한의 일만을 수행하도록 해야합니다. 수행하는데 오랜 시간이 걸릴 가능성이 있는 작업들은 반드시 메인 스레드가 아닌 다른 스레드에서 수행되어야합니다. 이러한 좋은 예가 바로 네트워크 작업입니다. 네트워크 작업은 어느정도의 시간이 걸릴지 예측하기 힘들기 때문에, 조심스럽게 처리하지 않으면 사용자들은 잠깐 잠깐씩 어플리케이션이 버벅거린다고 느끼거나, 어떤 경우에는 아예 멈추어버린 것 처럼 느낄 수도 있습니다.

 이번 포스트에서는 이러한 문제를 해결할 수 있는 방법을 살펴보기 위해, 이미지를 다운로드 하는 간단한 코드를 작성해볼 것 입니다. 이를 바탕으로, 인터넷에서 다운로드한 이미지 썸네일을 표시해주는 ListView 를 생성한 후, 어플리케이션은 정상적으로 빠르게 동작하는 가운데, 백그라운드에서 비동기적으로 이미지 다운로드 작업을 수행하도록 구현할 것입니다.

An Image Downloader

 안드로이드 프레임워크에서 제공하는 HTTP 관련 클래스를 사용하면, 웹에서 이미지를 다운로드하는 것은 꽤나 단순합니다. 여기 한 가지 예가 있습니다.

static Bitmap downloadBitmap(String url) {
   
final AndroidHttpClient client = AndroidHttpClient.newInstance("Android");
   
final HttpGet getRequest = new HttpGet(url);

   
try {
       
HttpResponse response = client.execute(getRequest);
       
final int statusCode = response.getStatusLine().getStatusCode();
       
if (statusCode != HttpStatus.SC_OK) {
           
Log.w("ImageDownloader", "Error " + statusCode + " while retrieving bitmap from " + url);
           
return null;
       
}
       
       
final HttpEntity entity = response.getEntity();
       
if (entity != null) {
           
InputStream inputStream = null;
           
try {
                inputStream
= entity.getContent();
               
final Bitmap bitmap = BitmapFactory.decodeStream(inputStream);
               
return bitmap;
           
} finally {
               
if (inputStream != null) {
                    inputStream
.close();  
               
}
                entity
.consumeContent();
           
}
       
}
   
} catch (Exception e) {
       
// Could provide a more explicit error message for IOException or IllegalStateException
        getRequest
.abort();
       
Log.w("ImageDownloader", "Error while retrieving bitmap from " + url, e.toString());
   
} finally {
       
if (client != null) {
            client
.close();
       
}
   
}
   
return null;
}

 HTTP 클라이언트와 Request 를 생성합니다. 만일 HTTP Request 가 성공한다면, HttpResoponse InputStream 에는 특정 이미지 정보가 담겨 있을 것이고, 해당 Stream 을 바로 디코딩하여 Bitmap 을 생성할 수 있습니다. 물론, 이러한 일이 가능하기 위해서는 우선적으로 어플리케이션이 INTERNET 퍼미션을 갖고 있어야 합니다.

Note: 이전 버전의 BitmapFactory.decodeStream 에는 네트워크 커넥션이 느린경우에는 정상적으로 작동하지 않는 버그가 있습니다. 따라서, 결과로 전달 받은 InputStream 을 new FlushedInputStream(inputStream) 으로 생성한 후에 이미지 디코딩을 수행하시는 편이 좋습니다. 이 클래스는 스트림이 끝나지 않은 한, skip() 메서드가 실재로 전달받은 바이트 수 만큼을 건너띄도록 구현되어있습니다. 
static class FlushedInputStream extends FilterInputStream {
   
public FlushedInputStream(InputStream inputStream) {
       
super(inputStream);
   
}

   
@Override
   
public long skip(long n) throws IOException {
       
long totalBytesSkipped = 0L;
       
while (totalBytesSkipped < n) {
           
long bytesSkipped = in.skip(n - totalBytesSkipped);
           
if (bytesSkipped == 0L) {
                 
int byte = read();
                 
if (byte < 0) {
                     
break;  // we reached EOF
                 
} else {
                      bytesSkipped
= 1; // we read one byte
                 
}
           
}
            totalBytesSkipped
+= bytesSkipped;
       
}
       
return totalBytesSkipped;
   
}
}

 만일 여러분이 위에 구현된 이미지 다운로드 메서드를 ListAdapter 의 getView() 메서드에서 직접 호출한다면, 아마도 어플레케이션은 우울할정도로 버벅될 것 입니다. 새로운 View 를 그릴 때 마다 이미지가 다운로드 하기 위해 어플리케이션이 멈추고, 스크롤이 부드럽게 이루어지지 않습니다.

 더군다나, AndroidHttpClient 는 메인 스레드에서 사용될 수도 없습니다. 위의 코드를 실행하면, "This thread forbids HTTP request(이 스레드는 HTTP Request 를 허용하지 않습니다.)" 라는 에러가 발생합니다. 여러분이 반드시 메인 스레드 내에서 HTTP Request 를 요청하고자 한다면, DefaultHttpClient 클래스를 사용하셔야 합니다.

Introducing asynchronous tasks

 AsyncTask 클래스는 UI 스레드가 아닌 스레드에서 여러가지 작업을 수행할 때 사용할 수 있는 가장 간단한 해결책 중에 하나입니다. AsyncTask 클래스를 사용하는 ImageDownloader 클래스를 한번 만들어 봅시다. 이 클래스는 특정한 URL 경로에 위치한 이미지를 다운로드 한 후, 해당 이미지를 주어진 ImageView 에 바인드하는 download 메서드를 제공합니다. 

public class ImageDownloader {

   
public void download(String url, ImageView imageView) {
           
BitmapDownloaderTask task = new BitmapDownloaderTask(imageView);
            task
.execute(url);
       
}
   
}

   
/* class BitmapDownloaderTask, see below */
}

 BitmapDownloaderTask 는 ImageDownloader 클래스에서 생성하는, 이미지를 다운로드 작업을 실재로 수행하는 AsyncTask 클래스입니다. execute 메서드를 통해 다운로드가 시작되며, execute 메서드는 즉시 리턴됩니다. execute 메서드는 UI 스레드에서 호출되도록 구현되었기 때문에 그렇습니다. BitmapDownloaderTask 은 아래와 같이 구현됩니다.

class BitmapDownloaderTask extends AsyncTask<String, Void, Bitmap> {
   
private String url;
   
private final WeakReference<ImageView> imageViewReference;

   
public BitmapDownloaderTask(ImageView imageView) {
        imageViewReference
= new WeakReference<ImageView>(imageView);
   
}

   
@Override
   
// Actual download method, run in the task thread
   
protected Bitmap doInBackground(String... params) {
         
// params comes from the execute() call: params[0] is the url.
         
return downloadBitmap(params[0]);
   
}

   
@Override
   
// Once the image is downloaded, associates it to the imageView
   
protected void onPostExecute(Bitmap bitmap) {
       
if (isCancelled()) {
            bitmap
= null;
       
}

       
if (imageViewReference != null) {
           
ImageView imageView = imageViewReference.get();
           
if (imageView != null) {
                imageView
.setImageBitmap(bitmap);
           
}
       
}
   
}
}
 doInBackground 메서드는 메인 스레드와는 별개의 프로세스(주>아마 오타인 듯. 스레드가 맞을거 같네요)에서 수행됩니다. 위 코드에서 donInBackground 메서드는 단순히 앞서 구현한 downloadBitmap 메서드를 호출하도록 작성되었습니다.

 onPostExecute 메서드는 태스크가 종료된 시점에 UI 스레드에서 실행됩니다. 이 메서드는 인자로 넘겨받은 Bitmap 을 미리 지정된 ImageView 에 설정합니다. ImageView 가 WeakReference 로 저장되어 있는 것을 주의깊게 살펴보세요. 만일 BitmapDownloaderTask 가 ImageView 의 실재 참조를 갖고 있을경우, ImageView 와 연관된 Activity 가 종료된 후에도, GC 가 해당 ImageView 를 제거할 수 없습니다. WeakReference 를 사용했기 때문에 onPostExecute 메서드 내에서 ImageView 를 사용하기 전에 WeakReference 값이 null 인지, 그리고 실재 ImageView 가 null 인지 이중으로 확인해 보아야 합니다. 

 이 간단한 예제는 어떻게 AsyncTask 를 사용할 수 있는지에 관해 설명해주며, 만일 여러분이 이러한 기법을 실재로 시도해본다면, 단지 몇 줄의 코드만으로 ListView 가 부드럽게 스크롤 될 만큼 성능이 향상 됨을 확인 할 수 있을 것 입니다. AstncTask 에 관해 보다 자세히 알고 싶으시다면, Painless Threading 문서를 살펴보세요.

 그러나 ListView 와 관련되어, 현재의 구현 방식에는 한 가지 문제점이 있습니다. ListView 는 메모리를 효과적으로 활용하기 위해 한 번 사용된 View 를 재활용합니다. 사용자가 ListView 를 스크롤 하면, 한번 생성된 ImageView 가 여러번 사용되게 되지요. 또, 매번 ImageView 가 화면에 그려질 때면, 해당 ImageView 에 적합한 이미지를 다운로드 하기 위한 BitmapDownloaderTask 가 정확히 실행 되됩니다. 이런 경우 어떤 문제가 발생할까요? 대부분의 패러럴 어플리케이션 처럼, 가장 중요한 이슈는 바로 '순서' 에 있습니다. 우리의 경우, 이미지 다운로드 작업이 시작된 순서대로 종료된다는 보장이 없습니다. 결과적으로 특정 이미지를 다운로드 하는데 오랜 시간이 걸릴 경우, 훨씬 앞에 표시되어야할 이미지가 가장 마지막에 표시될 수도 있습니다. 만일 각각의 ImageView 에 이미지가 한 번씩만 설정된다면 별 문제 아닐 수가 있지만, 이는 일반적인 해결책이라고 할 수 없습니다. 이 문제를 한 번 해결해 봅시다.

Handling Concurrency

 이 문제를 해결하기 위해, 우리는 다운로드의 순서를 기억하고, 가장 마지막에 시작된 이미지만이 화면 상에 표시되도록 해야 합니다. 즉, 각각의 ImageView 가 마지막에 다운로드 된 이미지만을 기억하도록 구현하면 됩니다. 다운로드가 진행 중인 동안 임시로 ImageView 에 바인드 되는 커스텀한 Drawable 클래스를 이용하여 ImageView 에 부가 정보를 추가하겠습니다. 추가된 DownloadedDrawable 클래스의 코드는 다음과 같습니다.

static class DownloadedDrawable extends ColorDrawable {
   
private final WeakReference<BitmapDownloaderTask> bitmapDownloaderTaskReference;

   
public DownloadedDrawable(BitmapDownloaderTask bitmapDownloaderTask) {
       
super(Color.BLACK);
        bitmapDownloaderTaskReference
=
           
new WeakReference<BitmapDownloaderTask>(bitmapDownloaderTask);
   
}

   
public BitmapDownloaderTask getBitmapDownloaderTask() {
       
return bitmapDownloaderTaskReference.get();
   
}
}

 DownloadedDrawable 은 다운로드 중인 이미지가 있는 경우, ImageView 에 검은 화면을 표시하기 위하여 ColorDrawable 을 상속받아 구현되었습니다. 물론 검은 화면 대신 "다운로드가 진행 중 입니다." 같은 이미지를 사용할 수도 있을 것 입니다. 또한, 오브젝트의 의존성을 제한하기 위하여 WeakReference 를 사용한 것을 다시 한번 유의깊게 살펴보시기 바랍니다.

 새로운 클래스를 사용하도록 기존의 코드를 수정해 봅시다. 먼저, download 메서드는 이 새로운 클래스의 인스턴스를 생성한 후 해당 인스턴스를 ImageView 에 설정합니다.

public void download(String url, ImageView imageView) {
     
if (cancelPotentialDownload(url, imageView)) {
         
BitmapDownloaderTask task = new BitmapDownloaderTask(imageView);
         
DownloadedDrawable downloadedDrawable = new DownloadedDrawable(task);
         imageView
.setImageDrawable(downloadedDrawable);
         task
.execute(url, cookie);
     
}
}
 cancelPotentialDownload 메서드는 새로운 다운로드 작업이 시작됨으로, 현재 ImageView 와 연관된 이미지 다운로드 작업을 정지 시키는 역할을 수행합니다. 하지만, 이것만으로 가장 마지막에 다운로드를 시작한 이미지가 화면에 표시된다고 보장할 수는 없습니다. 왜냐하면 이전에 진행 중이던 다운로드 작업이 이미 완료되어 onPostExecute 메서드가 실행 중인 상황인 경우, 때에 따라서 onPostExecute  메서드가 새로운 다운로드 작업이 모두 완료되고 해당 다운로드의 onPostExecute 메서드가 호출된 이 후에 실행 될 수도 있기 때문입니다. 

private static boolean cancelPotentialDownload(String url, ImageView imageView) {
   
BitmapDownloaderTask bitmapDownloaderTask = getBitmapDownloaderTask(imageView);

   
if (bitmapDownloaderTask != null) {
       
String bitmapUrl = bitmapDownloaderTask.url;
       
if ((bitmapUrl == null) || (!bitmapUrl.equals(url))) {
            bitmapDownloaderTask
.cancel(true);
       
} else {
           
// The same URL is already being downloaded.
           
return false;
       
}
   
}
   
return true;
}
 cancelPotentialDownload 메서드는 진행 중인 다운로드 작업을 정지하기 위해 AsyncTask 의 cancel 메서드를 사용합니다. 일반적으로 'true' 값을 리턴하며, 새로운 다운로드 작업이 시작됩니다. 다만, 이미 동일한 URL 에 대하여 다운로드가 진행 중인 경우에는 기존 다운로드 작업을 취소하지 않고 계속 진행되도록 구현되었습니다. 한가지 주의할 점이 있습니다. 현재의 구현대로라면 만일 ImageView 가 가비지 콜랙팅된 경우에도 이와 연관된 다운로드 작업이 중단되지 않습니다. 이를 처리하기 위해서는 RecyclerListener 를 사용할 수 있을거 같네요.

 위 메서드는 getBitmapDownloderTask 라는 함수를 사용하고 있습니다. 이 함수는 다음과 같이 간단하게 구현되어있습니다.

private static BitmapDownloaderTask getBitmapDownloaderTask(ImageView imageView) {
   
if (imageView != null) {
       
Drawable drawable = imageView.getDrawable();
       
if (drawable instanceof DownloadedDrawable) {
           
DownloadedDrawable downloadedDrawable = (DownloadedDrawable)drawable;
           
return downloadedDrawable.getBitmapDownloaderTask();
       
}
   
}
   
return null;
}
 마지막으로, onPostExecute 메서드는 오직 해당 ImageView 가 자기 자신과 연결되어 있는 경우에만 이미지를 설정하도록 수정되어야합니다.

if (imageViewReference != null) {
   
ImageView imageView = imageViewReference.get();
   
BitmapDownloaderTask bitmapDownloaderTask = getBitmapDownloaderTask(imageView);
   
// Change bitmap only if this process is still associated with it
   
if (this == bitmapDownloaderTask) {
        imageView
.setImageBitmap(bitmap);
   
}
}
 이제 ImageDownloader 클래스는 우리가 기대하는 기본적인 서비스들을 제공하도록 수정되었습니다. 이 클래스 혹은 여기에 사용된 비동기 패턴을 여러분의 어플리케이션의 반응성을 향상시키는데 자유롭게 사용하시기 바랍니다.

Demo

 이 포스트에 나온 소스 코드는 online on Google Code 에서 확인하실 수 있습니다. 여러분은 이 포스트에서 서술한 세 가지 방법(AsyncTask 를 사용하지 않은 버전, Bitmap 설정과 관련된 추가 작업을 수행하지 않는 버전, 최종 버전)을 선택하여 각각의 구현을 비교해 볼 수 있습니다. 관련된 문제를 보다 잘 보여주기 위해 10개의 이미지 만을 캐쉬 하도록 제한되어 있습니다.

 


Future work

 이 코드는 패러럴한 작업에 관련된 부분에만 초점을 맞추도록 간략하게 작성되어 있으며, 여러가지 유용한 기능이 제외되어 있습니다. ImageDownloader 클래스는 동일한 이미지를 여러번 다운로드 받지 않도록 캐쉬를 이용하여 성능이 크게 개선될 수 있습니다. (ListView 와 함께 사용되는 경우라면 더욱 그렇습니다.) 이미지의 URL 을 키 값으로, Bitamp 의 SoftReference 를 밸류 값을 갖는 LinkedHashMap 을 이용하여 LRU(Least Recently Used) 방식의 캐쉬를 손쉽게 구현할 수 있습니다. 물론, 로컬 저장 장치에 이미지를 저장하는 방식으로 캐쉬 메카니즘을 구현할 수도 있으며, 이미지 리사이징이나 썸네일을 만드는 기능도 필요한 경우 추가될 수 있습니다.

 다운로드 오류와 네트워크 타임아웃 관련된 오류들은 현재 버전에서도 올바르게 처리되고 있습니다. Bitmap 대시 null 이 리턴됩니다. 이 경우 에러 이미지를 표시하도록 구현할 수도 있을 것 입니다.

 현재의 HTTP Request 는 꽤나 단순합니다. 특정한 웹사이트에서 요구되는 파라매터나 쿠키를 추가하도록 수정할 수도 있습니다.

 이 포스트에서 사용된 AsyncTask 는 UI 스레드가 아닌 다른 곳에서 특정한 작업을 수행할 수 있는 편리하고 쉬운 방법입니다. 여러분이 여러분이 수행하는 일을 보다 정교하게 제어하고자 하는 경우에는  (동시에 수행되는 다운로드 스레드의 수를 제한하는 등) Handler 클래스를 사용할 수 있습니다.