저작권 안내: 저작권자표시 Yes 상업적이용 No 컨텐츠변경 No

스프링5 입문

JSP 2.3

JPA 입문

DDD Start

인프런 객체 지향 입문 강의

안드로이드에서 이미지를 ImageView에 출력할 때 발생할 수 있는 문제는 크게 다음의 두 가지가 있다.

이 두 가지 문제를 해소하려면 이미지 크기가 특정 크기를 넘어선 경우 이미지 크기를 줄여서 읽어오면 된다.

이미지 크기 구하기

이미지 크기를 구할 때에는 BitmapFactory.Options의 inJustDecodeBounds 값을 true로 지정해 주고, 이 옵션을 이용해서 BitmapFactory의 decode 메서드를 사용한다. 아래는 파일로부터 이미지 크기를 구할 때 사용되는 코드 예를 보여주고 있다.

private BitmapFactory.Options getBitmapSize(File imageFile) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeFile(imageFile.getAbsolutePath(), options);
return options;
}


BitmapFactory의 decode 메서드는 옵션의 inJustDecodeBounds 값이 true일 경우, 이미지의 크기만 구해서 옵션에 설정한다. 이 메서드 실행 후, 옵션의 

BitmapFactory.options option = getBitmapSize(imgFile);
// option.outWidth : 이미지 폭
// options.outHeight : 이미지 높이

if (option.outWidth > maxWidthSize || option.outHeight > maxHeightSize) {
    // 최대 크기를 벗어난 경우의 처리, 이미지 크기 변환 등
}

이 값을 이용해서 이미지가 제한된 크기를 벗어났는지의 여부를 알 수 있다.

이미지 크기 변경하기

이미지 크기를 변경할 때에는 옵션의 inSampleSize 값을 이용한다. 예를 들어, 아래 코드는 크기를 2배 축소시켜서 Bitmap을 생성해주는 코드이다.

BitmapFactory.Options options = new BitmapFactory.Options();
options.inSampleSize = 2;
Bitmap bitmap = BitmapFactory.decodeFile(imageFile.getAbsolutePath(), options);

3000*2000 크기의 이미지가 있을 때, inSampleSize 값으로 2를 사용하면 생성되는 Bitmap의 크기는 1500*1000이 된다. inSampleSize의 값을 4로 주면 실제 폭/높이는 1/4로 줄고, 8을 주면 실페 폭/높이는 1/8로 준다. 2의 거듭제곱을 inSampleSize의 값으로 주면 연산이 좀 더 빠르게 처리된다.

관련 자료


Posted by 최범균 madvirus

댓글을 달아 주세요

  1. ㄹㄹㄹㄹㄹㄹㄹㄹㄹㄹㄹ 2016.04.04 18:15 신고  댓글주소  수정/삭제  댓글쓰기

    ㅎㅎㅎㅎㅎㅎㅎㅎ호ㅗㅗㅗㅗㅗㅗㅗㅗㅗㅗ

안드로이드의 ImageView에 출력할 수 있는 이미지 크기에 제한이 있는데, 이 제한은 장치마다 다르다. 예를 들어, 넥서스7의 경우는 2048*2048 이내의 이미지를 출력할 수 있고, 갤럭시노트10.1의 경우 4096*4096 이내의 이미지를 출력할 수 있다. 만약 제한 크기를 넘는 이미지를 ImageView를 통해 보여주려고 하면 다음과 비슷한 메시지가 로그에 출력되면서 ImageView에 이미지가 출력되지 않게 된다.


W/OpenGLRenderer(6156): Bitmap too large to be uploaded into a texture (560x6076, max=2048x2048)


ImageView에 표시할 수 있는 이미지의 제한 크기를  알아내려면 OpenGL의 glGetIntegerv() 메서드를 사용하면 된다. 이 메서드를 사용하려면 OpenGL Context를 생성해주어야 하는데, SurfaceView를 이용하면 간단하게 알아낼 수 있다.


다음은 SurfaceView와 OpenGL을 이용해서 제한 크기를 알아내는 코드 예이다.


import java.nio.IntBuffer;

import javax.microedition.khronos.egl.EGL10;

import javax.microedition.khronos.egl.EGLContext;

import javax.microedition.khronos.opengles.GL10;

import android.view.SurfaceHolder;

import android.view.SurfaceView;

...


public class SplashActivity extends Activity {


@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

setContentView(R.layout.splash_layout);

LinearLayout layout = (LinearLayout) findViewById(R.id.body);

layout.addView(new GetMaxTextureSizeSurfaceView(this));

}


private void goHome() {

Intent intent = new Intent(this, HomeActivity.class);

startActivity(intent);

finish();

}


class GetMaxtextureSizeSurfaceView extends SurfaceView implements

SurfaceHolder.Callback {


public GetSizeSurfaceView(Context context) {

super(context);

SurfaceHolder holder = getHolder();

holder.addCallback(this);

}


@Override

public void surfaceCreated(SurfaceHolder holder) {

setMaxTextureSize();

goHome();

}


private void setMaxTextureSize() {

EGL10 egl = (EGL10) EGLContext.getEGL();

EGLContext ctx = egl.eglGetCurrentContext();

GL10 gl = (GL10) ctx.getGL();

IntBuffer val = IntBuffer.allocate(1);

gl.glGetIntegerv(GL10.GL_MAX_TEXTURE_SIZE, val);

int size = val.get(); // 최대 크기 구함

Constants.setMaxTextureSize(size); // Constants는 글로벌 변수 저장용

}


@Override

public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {

}


@Override

public void surfaceDestroyed(SurfaceHolder holder) {

}


}

}


최초로 실행되는 Activity에서 위와 같이 glGetIntegerv() 함수를 이용해서 최대 크기 값을 글로별 변수에 저장하면, 이후 Activity에서는 글로벌 변수를 이용해서 Bitmap 크기가 최대 크기를 넘어섰는지 확인할 수 있다. 만약 최대 크기를 넘어섰다면 이미지를 허용까지 줄여서 출력하면 된다.


주의할 점은 SurfaceView가 화면에 반드시 보여야 한다는 점이다. 화면에 보이지 않게 되면 관련 콜백 메서드(surfaceCreated() 등)가 호출되지 않는다. GetSizeSurfaceView는 단순히 최대 크기를 알아내기 위해 만든 것이므로 1px 정도의 크기를 갖는 Layout에 SurfaceView를 추가하는 방법으로 SurfaceView를 생성하면 화면에 영향을 거의 안 주면서 SurfaceView를 이용해서 MAX_TEXTURE_SIZE 값을 구할 수 있을 것이다.


이미지 크기를 알아내고 크기를 변경하는 방법은 다음에 정리해보겠다.



Posted by 최범균 madvirus

댓글을 달아 주세요

  1. baeflower 2015.04.02 16:43 신고  댓글주소  수정/삭제  댓글쓰기

    감사합니다 큰 도움이 됐습니다!

특정 URL 이미지를 가져와 ImageView에 보여주는 기능을 구현해보자. 먼저 이 기능을 구현하는데에는 아래 글에서 구현했던 기능들이 재사용된다. 그러니, 코드 중간에 설명되지 않는 내용들은 아래 글들을 읽어보면 이해가 될 것이다.

클래스 구성


URL로부터 이미지를 다운받아 ImaveView에 출력하는 기능을 구현하기 위한 클래스는 다음과 같다.

  • ImageDownloadAsyncCallback
  • ImageDownloader
    • 기본 이미지를 ImageView에 반영 후, AsyncExecutor를 이용해서 이미지 다운로드를 실행

ImageDownloadAsyncCallback 클래스


ImageDownloadAsyncCallback 클래스는 AsyncExecutor에 전달되는 콜백 객체를 구현하고 있다.


public class ImageDownloadAsyncCallback implements AsyncCallback<File>,

AsyncExecutorAware<File> {


private String url;

private WeakReference<ImageView> imageViewReference;

private AsyncExecutor<File> asyncExecutor;

private ImageCache imageCache;


public ImageDownloadAsyncCallback(String url, ImageView imageView,

ImageCache imageCache) {

this.url = url;

this.imageViewReference = new WeakReference<ImageView>(imageView);

this.imageCache = imageCache;

}


@Override

public void setAsyncExecutor(AsyncExecutor<File> asyncExecutor) {

this.asyncExecutor = asyncExecutor;

}


public boolean isSameUrl(String url2) {

return url.equals(url2);

}


@Override

public void onResult(File bitmapFile) {

Bitmap bitmap = addBitmapToCache(bitmapFile);

applyBitmapToImageView(bitmap);

}


private Bitmap addBitmapToCache(File bitmap) {

imageCache.addBitmap(url, bitmap);

return imageCache.getBitmap(url);

}


private void applyBitmapToImageView(Bitmap bitmap) {

ImageView imageView = imageViewReference.get();

if (imageView != null) {

if (isSameCallback(imageView)) {

imageView.setImageBitmap(bitmap);

imageView.setTag(null);

}

}

}


private boolean isSameCallback(ImageView imageView) {

return this == imageView.getTag();

}


public void cancel(boolean b) {

asyncExecutor.cancel(true);

}


@Override

public void exceptionOccured(Exception e) {

}


@Override

public void cancelled() {

}


}


AsyncExecutor가 파일 다운로드를 완료하면 onResult() 메서드가 호출되는데, onResult() 메서드는 전달받은 이미지 파일을 캐시에 추가하고 ImageView에 반영한다.


applyBitmapToImageView() 메서드는

  • imageViewReference로부터 ImageView를 구한다.
  • imageView가 존재한다면,
    • imageView의 콜백 객체가 this와 동일한지 확인한다.
      • 참고로, 뒤에서 살펴볼 ImageDownloader는 이미지 다운로드가 시작되면, ImageView의 tag에 관련된 콜백 객체를 설정한다.
    • 동일하다면, imageView에 이미지를 반영한다.
    • 동일하지 않다면, 이 콜백이 생성된 뒤에 동일한 ImageView에 대해 다른 URL 요청이 있었다는 것이므로 이미지를 imageView에 반영하지 않는다.

ImageDownloader 클래스


ImageDownloader 클래스의 코드 중 앞 부분은 다음과 같다.


// ImageDownloader의 앞 부분


public class ImageDownloader {

static final String TAG = "ImageDownloader";


private Context context;

private ImageCache imageCache;


public ImageDownloader(Context context, String imageCacheName) {

this.context = context;

imageCache = ImageCacheFactory.getInstance().get(imageCacheName);

}


public void download(String url, ImageView imageView, Drawable noImageDrawable) {

Bitmap bitmap = imageCache.getBitmap(url);

if (bitmap == null) {

forceDownload(url, imageView, noImageDrawable);

} else {

cancelPotentialDownload(url, imageView);

imageView.setImageBitmap(bitmap);

}

}


private void forceDownload(String url, ImageView imageView, Drawable noImageDrawable) {

if (!cancelPotentialDownload(url, imageView))

return;


imageView.setImageDrawable(noImageDrawable);

runAsyncImageDownloading(url, imageView);

}


private boolean cancelPotentialDownload(String url, ImageView imageView) {

ImageDownloadAsyncCallback asyncCallback =

(ImageDownloadAsyncCallback) imageView.getTag();

if (asyncCallback == null)

return true;

if (asyncCallback.isSameUrl(url))

return false;

asyncCallback.cancel(true);

return true;

}


ImageDownloader는 '안드로이드 이미지 캐시 구현' 글에서 만들었던 ImageCache를 이용해서 다운로드 받은 이미지 파일을 캐시하는데, 생성자를 통해서 사용할 캐시 이름을 전달받는다.


download() 메서드는 다음과 같이 세 개의 파라미터를 받는다.

  • download(String url, ImageView imageView, Drawable noImageDrawable)

첫 번째 파라미터는 이미지 URL이고, 두 번째 파라미터는 이미지를 보여줄 ImageView이다. 그리고 세 번째 noImageDrawable은 이미지를 다운로드 받는 동안에 imageView에 보여줄 이미지이다.


download 메서드는

  • 캐시에 이미지가 존재하는 지 확인한다.
  • 캐시에 존재하지 않으면 이미지 다운로드를 실행한다. (forceDownload)
  • 캐시에 존재하면 
    • cancelPotentialDownload()를 호출해서 ImageView에 대해 다른 URL을 다운로드 중이라면 작업을 취소하고,
    • 캐시에서 읽어온 이미지를 imageView에 반영한다.

forceDownload() 메서드는

  • cancelPotentialDownload() 메서드가 false를 리턴한 경우 바로 리턴한다.
    • 이 메서드가 false를 리턴하면, 같은 URL에 대해 이미 다운로드가 진행중이라는 것이다.
  • cancelPotentialDownload()가 true를 리턴한 경우
    • imageView의 이미지로 noImageDrawble를 설정한 뒤에
    • runAsyncImageDownloading()을 실행해서 이미지 다운로드를 시작한다.

cancelPotentialDownload() 메서드는 한 개의 ImageView에 대해 서로 다른 이미지를 로딩하는 것을 방지하기 위한 코드이다. 예를 들어, ListView에서 사용되는 ImageView는 재사용될 가능성이 높은데 이 경우 시간 간격을 두고 ImageView에 서로 다른 URL 이미지 다운로드가 실행될 수 있다. 이 경우 앞서 요청한 이미지는 다운로드 할 필요가 없기 때문에 다운로드를 취소함으로써 불필요한 네트워크 사용을 방지할 수 있을 것이다. cancelPotentialDownload() 메서드가 바로 이 취소작업을 처리한다.


뒤에서 살펴볼 runAsyncImageDownloading() 메서드는 이미지 다운로드를 시작하기 전에 ImageView의 tag 값으로  ImageDownloadAsyncCallback 객체를 보관하는데, cancelPottentialDownlad() 메서드는 이 tag 객체를 이용해서 ImageView와 관련된 이미지 다운로드가 진행중인 것이 있는지 확인한다. 


cancelPotentialDownload() 메서드는

  • imageView.getTag()를 이용해서 ImageDownloadAsyncCallback 객체를 구한다.
  • imageView의 tag가 null이면 ImageView와 관련된 다운로드가 진행중이지 않은 것이므로 true를 리턴한다.
  • tag에 보관된 콜백 객체의 url이 새롭게 요청한 url과 같으면 이미 동일 URL에 대한 다운로드가 진행중인 것이므로 false를 리턴한다.
  • 콜백 객체의 url이 요청 url과 다르면, 다른 이미지를 다운로드하고 있는 것이므로 작업 취소를 요청하고 false를 리턴한다.

실제로 이미지 다운로드를 시작하는 runAsyncImageDownloading() 메서드 코드 및 관련 메서드를 아래 코드에 표시하였다.


// ImageDownloader의 뒷 부분


private void runAsyncImageDownloading(String url, ImageView imageView) {

File tempFile = createTemporaryFile();

if (tempFile == null)

return;


Callable<File> callable = new FileDownloadCallable(url, tempFile);

ImageDownloadAsyncCallback callback = new ImageDownloadAsyncCallback(

url, imageView, imageCache);

imageView.setTag(callback);


new AsyncExecutor<File>().setCallable(callable).setCallback(callback).execute();

}


private File createTemporaryFile() {

try {

return File.createTempFile("image", ".tmp", context.getCacheDir());

} catch (IOException e) {

Log.e(TAG, "fail to create temp file", e);

return null;

}

}


}


runAsyncImageDownloading() 메서드는

  • 다운로드 받은 내용을 저장할 임시 파일을 생성한다. (createTemporaryFile())
  • 파일 다운로드 작업을 실행하는 FileDownloadCallable 객체를 생성한다. (이 클래스는 '안드로이드에서 URL로 파일 다운 받아 로컬에 저장하기' 글에서 작성한 것이다.)
  • 비동기 파일 다운로드가 완료될 때 사용될 ImageDownloadAsyncCallback 객체를 생성하고, ImageView의 tag 값으로 콜백 객체를 설정한다.
  • AsyncExecutor를 이용해서 비동기 파일 다운로드를 시작한다.


ImageDownloader 클래스 사용


ImageDownloader 클래스의 사용 방법은 다음과 같이 간단하다.


-- 이미지 캐시를 사용하기 때문에, onCreate() 같은 곳에서 파일캐시/이미지캐시 초기화 필요

// 2레벨 캐시(이미지 파일 캐시)를 사용하려면 동일 이름의 파일 캐시를 생성해 주어야 한다.

FileCacheFactory.getInstance().create("imagecache", cacheSize);

// 이미지 캐시 초기화

ImageCacheFactory.getInstance().createTwoLevelCache("imagecache", 40);



-- ImageDownloader가 필요한 코드에서 사용, 예를 들면 LiveView의 Adapter


public class FeaturedToonAdapter extends BaseAdapter {


private ImageDownloader imageDownloader;


public FeaturedToonAdapter(Context context) {

...

this.imageDownloader = new ImageDownloader(context, "imagecache");

}


@Override

public View getView(int position, View convertView, ViewGroup parent) {

View row = convertView;

...

BitmapDrawable noImage = new BitmapDrawable(context.getResources(), 

BitmapFactory.decodeResource(context.getResources(), R.drawable.noimage))

imageDownloader.download(imageUrl, imageView, noImage);

return row;

}



관련자료




Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 안구환 2013.09.03 23:14 신고  댓글주소  수정/삭제  댓글쓰기

    ImageDownloader클래스의 download()메소드에서는 캐시 등록은 안하는 건가요?
    아니면 캐시로 등록된 이미지가 있는지 여부를 검색하고 아니면 캐시에 다운 받은 이미지를 등록하는 건가요? 혹 등록된다면 알 수 있는 방법은 무엇인지. 그리고 저장이 안된다면 어떻게 등록하는지 궁금합니다.

    ImageCache에서 putBtimap()메소드를 써야만 하나요? 이 경우 ImageCache 객체 생성시 이미지 캐시 이름 부분에서 오류가나는데.. 예로 들은 경우 처럼 "imagecache"를 사용하니 ..에러가 ㅠㅜ

    • 최범균 madvirus 2013.09.04 23:43 신고  댓글주소  수정/삭제

      ImageDownloader 클래스에서 캐시 처리를 하는데요,
      실제 캐시 기능을 제공하는 건 ImageCache 입니다.
      ImageCache를 사용하는 방법은 http://javacan.tistory.com/entry/android-image-cache-implementation 글에 나와 있으니, 이 글을 참고해 주시기 바랍니다.

특정 URL 이미지를 목록에서 보여주어야 할 때, 동일 URL 이미지를 매번 다운로드 받아 출력하면 비효율적일 뿐만 아니라 사용자에게 보여지는 응답도 느려지게 된다. 사용자에게 응답을 빠르게 보여주기 위해서는 URL 이미지를 메모리나 로컬 파일에 캐싱하도록 구현해야 한다. 이미지를 캐싱함으로써, 동일 URL 이미지를 보여주어야 할 때 다운로드 없이 빠르게 이미지를 사용자에게 보여줄 수 있게 된다.


본 글에서는 이미지를 위한 캐시를 만들어보도록 하겠다. 실제 URL로부터 이미지를 읽어와 캐시에 담고 ImageView에 다운로드 받은 이미지를 보여주는 코드는 '안드로이드에서 URL 이미지를 ImageView에 보여주기' 글을 참고하기 바란다.


이미지 캐시 기능


제공할 기능은 다음과 같다.

  • 이미지 메모리 캐시를 제공한다.
    • 메모리에 지정 개수 만큼의 이미지를 보관한다.
  • 이미지 메모리/파일의 2레벨 캐시를 제공한다. 
    • 캐시에 이미지를 보관하면 메모리와 파일에 동시에 보관된다.
    • 메모리 캐시는 보관할 수 있는 개수에 제한이 있다.
    • 파일 캐시는 보관할 수 있는 전체 크기에 제한이 있다. ('안드로이드에서 파일 캐시 구현하기' 글에서 만든 파일 캐시를 사용해서 구현한다.)
    • 메모리 캐시에 없으면, 파일 캐시로부터 이미지를 읽어온다.

2레벨 캐시를 사용할 경우 자주 사용되는 이미지는 메모리에 담고 일정 크기만큼의 이미지는 파일로도 보관한다. 이를 통해 메모리 용량의 사용을 일정 수준으로 유지하면서 동시에 네트워크 사용을 최소화해서 사용자에게 이미지를 빠르게 보여줄 수 있다.


이미지 캐시  클래스 구성


구현할 이미지 캐시의 클래스 구성은 아래와 같다.



구성요소

설명 

ImageCacheFactory

ImageCache의 생성 및 검색 기능을 제공한다. 

ImageCache

이미지 캐시를 위한 인터페이스를 제공한다. 

MemoryImageCache

메모리 기반의 이미지 캐시를 구현한다. 

FileImageCache 

파일 기반의 이미지 캐시를 구현한다.

ChainedImageCache 

캐시 체인 기능을 제공한다.


ImageCache 인터페이스


public interface ImageCache {


public void addBitmap(String key, Bitmap bitmap);


public void addBitmap(String key, File bitmapFile);


public Bitmap getBitmap(String key);


public void clear();


}


MemoryImageCache 클래스


MemoryImageCache는 내부적으로 LruCache를 사용해서 구현하였다.


package com.toonburi.app.infra.imagecache;


import java.io.File;


import android.graphics.Bitmap;

import android.graphics.BitmapFactory;

import android.support.v4.util.LruCache;


public class MemoryImageCache implements ImageCache {


private LruCache<String, Bitmap> lruCache;


public MemoryImageCache(int maxCount) {

lruCache = new LruCache<String, Bitmap>(maxCount);

}


@Override

public void addBitmap(String key, Bitmap bitmap) {

if (bitmap == null)

return;

lruCache.put(key, bitmap);

}


@Override

public void addBitmap(String key, File bitmapFile) {

if (bitmapFile == null)

return;

if (!bitmapFile.exists())

return;


Bitmap bitmap = BitmapFactory.decodeFile(bitmapFile.getAbsolutePath());

lruCache.put(key, bitmap);

}


@Override

public Bitmap getBitmap(String key) {

return lruCache.get(key);

}


@Override

public void clear() {

lruCache.evictAll();

}


}


FileImageCache 클래스


FileImageCache는 앞서 '안드로이드에서 파일 캐시 구현하기'에서 만든 파일 캐시를 이용해서 구현하였다. 코드는 다음과 같다.


public class FileImageCache implements ImageCache {

private static final String TAG = "FileImageCache";


private FileCache fileCache;


public FileImageCache(String cacheName) {

fileCache = FileCacheFactory.getInstance().get(cacheName);

}


@Override

public void addBitmap(String key, final Bitmap bitmap) {

try {

fileCache.put(key, new ByteProvider() {

@Override

public void writeTo(OutputStream os) {

bitmap.compress(CompressFormat.PNG, 100, os);

}

});

} catch (IOException e) {

Log.e(TAG, "fail to bitmap to fileCache", e);

}

}


@Override

public void addBitmap(String key, File bitmapFile) {

try {

fileCache.put(key, bitmapFile, true);

} catch (IOException e) {

Log.e(TAG, String.format("fail to bitmap file[%s] to fileCache",

bitmapFile.getAbsolutePath()), e);

}

}


@Override

public Bitmap getBitmap(String key) {

FileEntry cachedFile = fileCache.get(key);

if (cachedFile == null) {

return null;

}

return BitmapFactory.decodeFile(cachedFile.getFile().getAbsolutePath());

}


@Override

public void clear() {

fileCache.clear();

}


}


위 코드에서 유의할 점은 FileImageCache 객체를 생성할 때, 파라미터로 전달받은 cacheName을 이용해서 FileCache를 구한다는 점이다. 즉, FileImageCache의 이름과 동일한 이름을 갖는 FileCache가 존재해야 정상적으로 동작한다. 따라서, FileImageCache를 사용하기 전에 다음과 같이 이미지 캐시와 동일한 이름을 갖는 FileCache를 생성해 주어야 한다.


// onCreate 등에서 파일을 이용하는 이미지 캐시 생성 전에 초기화

FileCacheFactory.getInstance().create(cacheName, cacheSize);


ChainedImageCache 클래스


이미지 캐시와 파일 캐시를 1차/2차 캐시로 사용하기 위해 ChainedImageCache 클래스를 만들었다.


public class ChainedImageCache implements ImageCache {


private List<ImageCache> chain;


public ChainedImageCache(List<ImageCache> chain) {

this.chain = chain;

}


@Override

public void addBitmap(String key, Bitmap bitmap) {

for (ImageCache cache : chain) {

cache.addBitmap(key, bitmap);

}

}


@Override

public void addBitmap(String key, File bitmapFile) {

for (ImageCache cache : chain) {

cache.addBitmap(key, bitmapFile);

}

}


@Override

public final Bitmap getBitmap(String key) {

Bitmap bitmap = null;

List<ImageCache> previousCaches = new ArrayList<ImageCache>();

for (ImageCache cache : chain) {

bitmap = cache.getBitmap(key);

if (bitmap != null) {

break;

}

previousCaches.add(cache);

}

if (bitmap == null)

return null;


if (!previousCaches.isEmpty()) {

for (ImageCache cache : previousCaches) {

cache.addBitmap(key, bitmap);

}

}

return bitmap;

}


@Override

public final void clear() {

for (ImageCache cache : chain) {

cache.clear();

}

}


}


ChainedImageCache는 chain에 등록되어 있는 모든 ImageCache를 차례대로 실행한다. getBitmap()은 약간 복잡하다. getBitmap()은 체인을 따라 Bitmap이 존재할 때까지 탐색한다. Bitmap이 발견되면 해당 캐시 이전에 위치한 캐시들(previousCaches에 보관됨)에 Bitmap 정보를 추가해서, 이후 동일 키로 요청이 오면 체인의 앞에서 발견되도록 한다.


ImageCacheFactory 클래스


ImageCacheFactory는 캐시 생성 기능을 제공한다.


public class ImageCacheFactory {


private static ImageCacheFactory instance = new ImageCacheFactory();


public static ImageCacheFactory getInstance() {

return instance;

}


private HashMap<String, ImageCache> cacheMap = new HashMap<String, ImageCache>();


private ImageCacheFactory() {

}


public ImageCache createMemoryCache(String cacheName, int imageMaxCounts) {

synchronized (cacheMap) {

checkAleadyExists(cacheName);

ImageCache cache = new MemoryImageCache(imageMaxCounts);

cacheMap.put(cacheName, cache);

return cache;

}

}


private void checkAleadyExists(String cacheName) {

ImageCache cache = cacheMap.get(cacheName);

if (cache != null) {

throw new ImageCacheAleadyExistException(String.format(

"ImageCache[%s] aleady exists", cacheName));

}

}


public ImageCache createTwoLevelCache(String cacheName, int imageMaxCounts) {

synchronized (cacheMap) {

checkAleadyExists(cacheName);

List<ImageCache> chain = new ArrayList<ImageCache>();

chain.add(new MemoryImageCache(imageMaxCounts));

chain.add(new FileImageCache(cacheName));

ChainedImageCache cache = new ChainedImageCache(chain);

cacheMap.put(cacheName, cache);

return cache;

}

}


public ImageCache get(String cacheName) {

ImageCache cache = cacheMap.get(cacheName);

if (cache == null) {

throw new ImageCacheNotFoundException(

String.format("ImageCache[%s] not founds"));

}

return cache;

}

}


createMemoryCache() 메서드는 메모리만 사용하는 ImageCache를 생성한다. createTwoLevelCache() 메서드는 1차 메모리/2차 파일 기반의 2레벨 캐시를 생성한다.


이미지 캐시 사용하기


다음은 이미지 캐시의 사용 예시이다.


-- onCreate 등 초기화 부분


// 2레벨 캐시(이미지 파일 캐시)를 사용하려면 동일 이름의 파일 캐시를 생성해 주어야 한다.

FileCacheFactory.getInstance().create(cacheName, cacheSize);


// 이미지 캐시 초기화

ImageCacheFactory.getInstance().createTwoLevelCache(cacheName, memoryImageMaxCounts);



-- 이미지 캐시 사용 부분

ImageCache imageCache = ImageCacheFactory.getInstance().getCache(cacheName);

Bitmap bitmap = imageCache.getBitmap(key);

if (bitmap != null) {

imageView.set.....

}


-- 이미지 캐시 추가 부분

imageCache.putBitmap(key, someBitmap);


실제 ImageCache를 사용하는 예제 코드는 '안드로이드에서 URL 이미지를 ImageView에 보여주기'에 있으니 이 글을 참고하면 된다.


관련자료



Posted by 최범균 madvirus

댓글을 달아 주세요

  1. bluepoet 2013.01.31 17:35 신고  댓글주소  수정/삭제  댓글쓰기

    저도 이번 스타앱 리뉴얼때, 프로게이머 프로필 이미지쪽을 구현할 예정이었는데

    이번 포스팅이 많이 참고가 되겠네요.

    특히나, 체인을 이용한 캐시 구현방법은 참 신선하네요.

    근데, previousCaches에 정보를 넣고 활용하는 쪽은 좀 어렵네요.

    동일 키로 요청이 오면 어떻게 체인의 앞에서 발견되도록 하는 건지 궁금합니다.

  2. andu 2013.05.16 13:28 신고  댓글주소  수정/삭제  댓글쓰기

    FileImageCache 클래스에서 fileCache.clear()가 사용되는데,

    FileCache 클래스에는 clear()가 없네요. 새로 추가된 메소드인가요?

  3. truelifer 2013.10.10 17:13 신고  댓글주소  수정/삭제  댓글쓰기

    좋은 포스팅 감사합니다.
    저도 한가지 의문이 있는건, bluepoet 님이 문의하신 것 처럼 동일 키로 요청이 오면 어떻게 체인의 앞에서 발견되도록 하는건지 궁금합니다.
    코드를 보니 previousCaches 를 따로 저장하지 않고 getBitmap() 함수가 끝나면 previousCaches 가 소멸될 것 같은데..

    • 최범균 madvirus 2013.10.11 09:09 신고  댓글주소  수정/삭제

      previousCaches 라는 로컬 변수는 사라지지만, previousCache에 담은 객체는 chain 필드에 보관되어 있는 ImageCache 입니다.
      getBitmap() 메서드를 보시면, chain에 있는 ImageCache를 차례대로 탐색하면서 해당 ImageCache가 key에 해당하는 비트맵을 갖고 있는 지 확인을 합니다. 갖고 있으면, for 루프를 나오고, 아니면 (로컬 변수인) previousCaches에 ImageCache를 추가합니다.

      발견된 Bitmap이 없으면 그냥 null을 리턴하고, 발결된 Bitmap이 있으면, previousCaches에 담아 두었던 ImageCache들의 addBitmap을 호출해서 비트맵을 추가해줍니다. 여기서 previousCaches에는 chain에서 비트맵이 발견되기 전까지의 ImageCache 목록을 갖고 있게 되죠. 따라서, chain의 특정 ImageCache에서 비트맵이 발견되면, 그 ImageCache 이전에 있던 ImageCache들에 비트맵을 추가하게 됩니다.

  4. winterCha 2013.10.17 16:38 신고  댓글주소  수정/삭제  댓글쓰기

    정말 감사합니다 아주 투통이 사라지는 캐쉬 로직이네요
    태클아닌 태클 하나.....

    checkAlreadyExists 를 표현 하고 싶으신것 같은데 r이 빠졌네요
    쏘리욤

    madvirus fan 입니다 최범균님 책으로 참 많은 것을 공부 했었던 기억이

    존경합니다.

  5. cs만두 2014.05.19 22:22 신고  댓글주소  수정/삭제  댓글쓰기

    안녕하세요 안드로이드에 관심이 많은 대학생 개발자입니다!
    몇달전 프로젝트를 진행하면서 bitmap OOM관련 이슈때문에 고민을 하다가 AUIL 라이브러리를 사용해서 해결했습니다. 그러면서 꼭 시간나면 이미지 로더 관련해서 공부해봐야겠다 생각했는데 이렇게 좋은 글을 만나서 너무 좋습니다.
    좋은 글 감사합니다! 열심히 공부하겠습니다!!!!!!

  6. 이승화 2014.05.23 16:59 신고  댓글주소  수정/삭제  댓글쓰기

    감사합니다. 덕분에 좀더 쉽게 코딩할수 있게 되었습니다.
    책도 많이 쓰셨던데 짱~ 이십니다ㅋ

  7. 최형식 2015.03.25 14:07 신고  댓글주소  수정/삭제  댓글쓰기

    지도 앱을 만들고 있습니다. svg파일로 하든 png로 하든 비트맵으로 변경해서 보여주려고 합니다. 사이즈가 크다 보니 타일처럼 짤라서 화면밖의 타일들은 캐시로, 이미지가 움직이면 캐시에서 불러오고 이런식으로 하고싶은데 어떤 방법이 있나요?

  8. 최수혁 2015.05.22 11:03 신고  댓글주소  수정/삭제  댓글쓰기

    안녕하세요?
    이 글 보고 안드로이드 이미지 캐시를 완벽하게 구현하였습니다.
    그런데 문제가 하나 있습니다.

    A라는 이름으로 캐시를 하나 창조해서 다운로드 해서 이미지를 모두 넣었습니다.
    그 다음 액티비티에서 B라는 이름으로 캐시를 하나 또 창조했는데 다운로드되지 않고 있습니다.
    캐시창조할때에는 모두 오류가 없는데 이상하게 onResult에는 안들어오네요..

    B와 A의 순서를 바꾸어도 역시 2번째는 작동을 안합니다.

    왜그럴가요?

    도저히 원인을 못찾겠네요.

    • 최범균 madvirus 2015.05.23 00:47 신고  댓글주소  수정/삭제

      코드를 바꾸지 않으셨다면, 코드에 버그가 있는 거겠죠. 지금은 졸려서 코드가 눈에 잘 안 들어오네요. 좀 맨 정신에 코드 보고 답글 달아보렵니다.

안드로이드로 앱을 개발할 때 흔히 필요한 기능이 특정 URL 이미지를 로컬 파일로 다운로드 받은 뒤에, 그 이미지를 ImageView로 출력하는 기능이다. 이미지를 다운받아 ImageView로 출력하려면 일단 이미지를 다운받아 로컬에 파일로 저장하는 기능이 필요하다.


본 글에서는 앞서 "유지보수를 고려한 안드로이드 비동기 처리 기반 코드 만들기" 글에서 비동기 처리를 위한 만들었던 AsyncExecutor를 이용해서 파일을 다운로드 하는 코드를 만들어보겠다. (앞서 글에서 AsyncExecutor의 구현을 먼저 확인해 본 뒤 본 글을 읽으면 쉽게 이해가 될 것이다.)


이 기능을 구현하기 위해 세 개의 클래스를 만들었다.

  • HttpRequestHelper: HttpClient를 이용해서 다운로드 받은 데이터를 File로 쓰는 기능 제공
  • FileDownloadCallable: AsyncExecutor에서 실행할 Callable 구현 클래스로서 HttpRequestHeloper가 제공하는 다운로드 기능을 실행
  • AsyncFileDownloader: 비동기로 파일 다운로드를 처리해주는 기능 제공

HttpRequestHelper 클래스의 파일 다운로드 기능 구현


HttpRequestHelper 클래스는 지정한 URL로부터 데이터를 읽어와 지정한 파일로 기록하는 기능을 제공한다. 다음은 구현 코드이다.


public class HttpRequestHelper {


public File download(String url, File toFile) throws IOException {

AndroidHttpClient client = AndroidHttpClient.newInstance("ANDROID");

HttpGet getRequest = new HttpGet(url);

try {

HttpResponse response = client.execute(getRequest);

checkStatusAndThrowExceptionWhenStatusIsNotOK(response);

return writeResponseToFileAndGet(response.getEntity(), toFile);

} catch (IOException e) {

getRequest.abort();

throw e;

} finally {

client.close();

}

}


private void checkStatusAndThrowExceptionWhenStatusIsNotOK(

HttpResponse response) throws IOException {

int statusCode = response.getStatusLine().getStatusCode();

if (statusCode != HttpStatus.SC_OK) {

throw new IOException("invalid response code:" + statusCode);

}

}


private File writeResponseToFileAndGet(HttpEntity entity, File toFile)

throws IOException {

InputStream in = null;

try {

IOUtils.copy(entity.getContent(), toFile);

return toFile;

} finally {

IOUtils.close(in);

entity.consumeContent();

}

}


public static HttpRequestHelper getInstance() {

return new HttpRequestHelper();

}


}


download() 메서드는 지정한 URL을 이용해서 HttpClient를 실행한다. 응답 코드가 OK가 아니면 익셉션을 발생시키고, 정상응답인 경우 응답 데이터를 지정한 파일에 저장한다.


FileDownloadCallable 클래스 구현


FileDownloadCallable 클래스는 AsyncExecutor가 비동기로 실행할 기능을 제공한다. FileDownloadCallable의 call() 메서드는 앞서 구현한 HttpRequestHelper의 download() 기능을 실행하고 그 결과를 리턴한다.


public class FileDownloadCallable implements Callable<File> {


private String url;

private File file;


public FileDownloadCallable(String url, File file) {

this.url = url;

this.file = file;

}


@Override

public File call() throws Exception {

return HttpRequestHelper.getInstance().download(url, file);

}


}


AsyncFileDownloader 클래스 구현


AsyncFileDownloader 클래스는 AsyncExecutor와 FileDownloadCallable을 이용해서 비동기로 파일을 다운받아 저장한다. 아래 코드는 구현 코드이다.


public class AsyncFileDownloader {


private Context context;


public AsyncFileDownloader(Context context) {

this.context = context;

}


public void download(String url, AsyncCallback<File> callback) {

download(url, null, callback);

}


public void download(String url, File destination, AsyncCallback<File> callback) {

try {

destination = getDestinationIfNotNullOrCreateTemp(destination, callback);

} catch (IOException e) {

callback.exceptionOccured(e);

return;

}

runAsyncDownload(url, destination, callback);

}


private File getDestinationIfNotNullOrCreateTemp(File destination,

AsyncCallback<File> callback) throws IOException {

if (destination != null) {

return destination;

}

return createTemporaryFile();

}


private File createTemporaryFile() throws IOException {

return File.createTempFile("afd", ".tmp", context.getCacheDir());

}


private void runAsyncDownload(String url, File destination, AsyncCallback<File> callback) {

Callable<File> callable = new FileDownloadCallable(url, destination);

new AsyncExecutor<File>().setCallable(callable).setCallback(callback).execute();

}


}


AsyncFileDownloader의 download() 메서드는 다운로드 받을 URL(url 파라미터), 다운받은 파일을 보관할 경로(destination 파라미터), 그리고 파일 다운이 완료될 때 호출할 콜백(callback 파라미터)를 전달받는다. download() 메서드의 실행 순서는 다음과 같다.

  • getDestinationIfNotNullOrCreateTemp() 메서드를 이용해서 destination이 null 이면 임시 파일을 생성한다.
    • 임시 파일을 생성하는 도중에 익셉션이 발생하면 callback 객체에 에러 사실을 알리고 종료한다.
    • 임시 파일은 캐시 디렉토리에 생성한다.
  • runAsyncDownload() 메서드를 실행해서 비동기 다운로드를 실행한다.
runAsyncDownload() 메서드는 AsyncExecutor 객체를 이용해서 비동기로 파일 다운로드를 처리한다.


AsyncFileDownloader 사용해서 다운로드 파일 사용하기


다음 코드는 AsyncFileDownloader의 사용 예이다.


public class DetailActivity extends Activity {

...

private void downloadFile() {

new AsyncFileDownloader(this).download(someImageUrl, fileDownCallback);

}


private AsyncCallback<File> fileDownCallback = new AsyncCallback.Base<File>() {

@Override

public void onResult(File result) {

// result 파일을 사용해서 처리

}


@Override

public void exceptionOccured(Exception e) {

// 익셉션 처리

}

};



관련 자료



Posted by 최범균 madvirus

댓글을 달아 주세요

  1. andu 2013.05.16 17:40 신고  댓글주소  수정/삭제  댓글쓰기

    AsyncFileDownloader의 사용 예시에 new AsyncCallback.Base<File>()가 사용되는데,
    AsyncCallBack 클래스에는 Base() 없네요.
    이것도 추가됐나요?

안드로이드 프로그래밍을 하다 보니까, 파일로 캐시를 구현해야 하는 경우가 발생했다. 다음은 파일 캐시가 필요한 경우의 예이다.

  • REST API를 호출한 결과를 파일로 기록해 두고, 다음에는 REST API 호출 없이 파일에서 읽어와 빠른 응답 제공
  • 목록에서 사용되는 웹 이미지를 다운받아 파일로 기록해 두고, 이후 동일 이미지를 빠르게 출력

두 가지 경우 모두 로컬에 데이터를 파일 형태로 캐싱하는 기능을 필요로 한다.


구현할 파일 캐시 기능


여기서 구현해 볼 파일 캐시는 다음의 특징을 갖는다.

  • 두 개 이상의 파일 캐시를 제공한다.
  • 캐시에 보관될 최대 파일 크기를 제한할 수 있다.
  • 시스템의 캐시 폴더에 파일을 기록하기 때문에, 공간이 필요할 경우 안드로이드가 자동으로 오래된 캐시 파일을 삭제한다.


클래스 구성


다음 클래스 다이어그램은 파일 캐시와 관련된 구성 요소의 관계를 표현한 것이다.



[파일 캐시 클래스 다이어그램]


주요 구성 요소는 다음과 같다.


구성 요소 

설명 

FileCacheFactory

FileCache를 생성하고 구할 때 사용되는 Factory 클래스.

FileCache

파일 캐시 기능을 정의한 인터페이스.

FileCacheImpl

CacheStorage를 이용한 FileCache 구현체. 클라이언트의 요청을 

CacheStorage

캐시 파일 저장을 처리하는 클래스. 지정한 최대 크기를 넘길 경우 캐시된 파일 중 오래된 파일을 삭제한다.

CacheStorage.Initializer

CacheStorage를 초기화. 기존에 캐싱된 파일 목록을 등록한다. 

ByteProvider 

FileCache에 데이터를 제공할 때 사용된다. 

ByteProvider 인터페이스 및 보조 클래스


FileCache는 데이터를 파일로 저장하기 때문에 바이트로 데이터를 제공해 주어야 한다. 이를 명시적으로 하기 위해 ByteProvider를 정의하였다. ByteProvider 인터페이스는  다음과 같이 정의되어 있다.


public interface ByteProvider {


void writeTo(OutputStream os) throws IOException;


}


ByteProvider의 몇 가지 구현체를 제공하는 보조 클래스를 만들면 좀 더 편리하게 사용할 수 있다. 다음은 구현 예이다.


public abstract class ByteProviderUtil {


public static ByteProvider create(final InputStream is) {

return new ByteProvider() {

@Override

public void writeTo(OutputStream os) throws IOException {

IOUtils.copy(is, os);

}

};

}


public static ByteProvider create(final File file) {

return new ByteProvider() {

@Override

public void writeTo(OutputStream os) throws IOException {

IOUtils.copy(file, os);

}

};

}


public static ByteProvider create(final String str) {

return new ByteProvider() {

@Override

public void writeTo(OutputStream os) throws IOException {

IOUtils.copy(str, os);

}

};

}

}




CacheStorage 클래스의 구현


핵심 클래스는 CacheStorage 이다. 이 클래스가 가장 길고 복잡하므로 부분 부분 나눠서 구현 코드를 살펴보도록 하겠다.


CacheStorage의 초기화 부분

다음은 CacheStorage의 초기화 부분의 코드이다.

// CacheStorage.java

public class CacheStorage {
private static final String TAG = "CacheStorage";

private File cacheDir;
private Map<String, CacheFile> cacheFileMap;

private long maxBytesSize;
private AtomicLong currentBytesSize = new AtomicLong();;

private ReadWriteLock rwl = new ReentrantReadWriteLock();
private Lock readLock = rwl.readLock();
private Lock writeLock = rwl.writeLock();

public CacheStorage(File cacheDir, long maxBytesSize) {
this.cacheDir = cacheDir;
this.maxBytesSize = maxBytesSize;
this.cacheFileMap = Collections
.synchronizedMap(new LinkedHashMap<String, CacheFile>(1024));

createCacheDirIfNotExists();
initializing();
}

private void createCacheDirIfNotExists() {
if (cacheDir.exists())
return;
cacheDir.mkdirs();
}

private void initializing() {
new Thread(new Initializer()).start();
}

생성 과정에서는 다음의 작업을 수행한다.

  • 필드 초기화 (생성자)
    • cacheDir: 캐시 경로를 보관하는 File
    • maxBytesSize: 캐시 보관소의 최대 크기
    • cacheFileMap: 캐시 파일 정보를 담을 Map
    • 읽기/쓰기에 사용될 Lock 생성. 캐시 읽기와 쓰기가 동시에 진행되는 것을 방지하기 위한 Lock.
    • currentBytesSize: 캐시 디렉토리에 보관된 전체 파일의 크기
  • createCacheDirIfNotExists(): 캐시 디렉토리가 존재하지 않으면 생성한다.
  • initializing(): 별도 쓰레도로 Initializer를 실행한다.
Initializer는 캐시 디렉토리에 보관된 파일 정보를 cacheFileMap에 로딩하는 기능을 제공한다. Initializer는 CacheStorage의 내부 클래스로서 다음과 같다.

// CacheStorage의 내부 클래스

private class Initializer implements Runnable {

@Override
public void run() {
writeLock.lock();
try {
File[] cachedFiles = cacheDir.listFiles();
for (File file : cachedFiles) {
putFileToCacheMap(file);
}
} catch (Exception ex) {
Log.e(TAG, "CacheStorage.Initializer: fail to initialize - "
+ ex.getMessage(), ex);
} finally {
writeLock.unlock();
}
}
}


Initializer는 캐시 디렉토리에 존재하는 파일 목록을 읽어와 각 파일에 대해 putFileToCacheMap()에 전달한다. putFileToCacheMap() 메서드는 파일 정보를 추가한다. 이 메서드에 대한 내용은 뒤에서 다시 설명한다.


캐시 파일 정보를 구하는 get() 메서드


캐시 스토리지로부터 캐시 파일을 구해주는 get() 메서드는 다음과 같이 구현하였다.


// CacheStorage.java


public File get(String filename) {

readLock.lock();

try {

CacheFile cachdFile = cacheFileMap.get(filename);

if (cachdFile == null) {

return null;

}

if (cachdFile.file.exists()) {

moveHitEntryToFirst(filename, cachdFile);

return cachdFile.file;

}

removeCacheFileFromMap(filename, cachdFile);

return null;

} finally {

readLock.unlock();

}

}


private void moveHitEntryToFirst(String filename, CacheFile cachedFile) {

cacheFileMap.remove(filename);

cacheFileMap.put(filename, cachedFile);

}


private void removeCacheFileFromMap(String filename, CacheFile cachedFile) {

currentBytesSize.addAndGet(-cachedFile.size);

cacheFileMap.remove(filename);

}


다음은 get() 메서드의 실행순서를 정리한 것이다.

  • cacheFileMap 으로부터 해당 이름의 CacheFile이 존재하는지 확인한다.
    • 존재하지 않으면 null을 리턴한다.
  • CacheFile의 file이 실제로 존재하는지 확인한다.
    • 존재하면, moveHitEntryToFirst()를 실행한 뒤에 해당 File을 리턴한다.
    • 존재하지 않으면, removeCacheFileFromMap()을 실행해서 메모리에서 정보를 제거하고 null을 리턴한다.
cacheFileMap은 LinkedHashMap을 사용해서 생성했는데, LinkedHashMap은 등록된 순서 정보를 보관하는 Map이다. 이 Map 구현을 사용한 이유는 전체 파일 크기가 제한된 크기를 넘어선 경우, 최근 등록된 파일이 아닌 오래된 파일을 선택해서 삭제하기 위함이다.

get() 메서드는 cacheFileMap에 데이터가 존재할 경우, File을 리턴하기 전에 moveHitEntryToFirst() 메서드를 호출하는데 이 메서드는 최근에 사용된 파일을 제일 앞으로 보내서 용량 초과시 대상이 되지 않도록 만들어준다.

cacheFileMap에는 존재하지만 실제 대상 파일이 존재하지 않을 수도 있다. (그 이유는 생성한 파일이 캐시 디렉토리에 있기 때문이다.) 이 경우 removeCacheFileFromMap()을 호출해서 전체 파일 크기를 줄이고 cacheFileMap에서 제거한다.

스토리지에 파일 추가하는 write() 메서드

다음은 write() 메서드의 구현이다.

// CacheStorage.java

public void write(String filename, ByteProvider provider) throws IOException {
writeLock.lock();
try {
createCacheDirIfNotExists();
File file = createFile(filename);
copyProviderToFile(provider, file);
putToCachMapAndCheckMaxThresold(file);
} finally {
writeLock.unlock();
}
}

private File createFile(String filename) {
return new File(cacheDir, filename);
}

private void copyProviderToFile(ByteProvider provider, File file)
throws FileNotFoundException, IOException {
BufferedOutputStream os = null;
try {
os = new BufferedOutputStream(new FileOutputStream(file));
provider.writeTo(os);
} finally {
IOUtils.close(os);
}
}

private void putToCachMapAndCheckMaxThresold(File file) {
putFileToCacheMap(file);
checkMaxThresoldAndDeleteOldestWhenOverflow();
}

private void putFileToCacheMap(File file) {
cacheFileMap.put(file.getName(), new CacheFile(file));
currentBytesSize.addAndGet(file.length());
}

write() 메서드는 다음의 순서에 따라 파일 쓰기를 처리한다.
  • createCacheDirIfNotExists()를 호출해서 캐시 디렉토리가 없으면 생성한다. 앱의 캐시 디렉토리를 삭제할 수 있기 때문에, 디렉토리 존재 여부를 확인해야 한다.
  • createFile()로 캐시 파일 정보를 생성한다.
  • copyProviderToFile()로 ByteProvider가 제공하는 내용을 파일에 쓴다. ByteProvider.writeTo() 메서드는 파라미터로 제공받은 OutputStream에 데이터를 쓴다.
  • putToCacheMapAndCheckMaxThresold()를 실행해서 cacheFileMap에 캐시 파일 정보를 추가하고, 최대 크기를 초과했는지 확인한다.
putToCachMapAndCheckMaxThresold() 메서드는 다음의 두 작업을 실행한다.
  • putFileToCacheMap() 메서드로 cacheFileMap에 캐시 파일 정보를 추가하고 현재 스토리지 크기(currentBytesSize) 값을 증가한다.
  • checkMaxThresoldAndDeleteOldestWhenOverflow() 메서드를 호출해서, 캐시 디렉토리에 보관된 전체 파일의 크기가 지정한 최대 크기를 초과하면 오래된 파일을 삭제해서 전체 파일 크기를 유지한다.

최대 저장 크기 확인 처리 부분

checkMaxThresoldAndDeleteOldestWhenOverflow() 메서드의 구현 코드는 다음과 같다.

// CacheStorage.java

private void checkMaxThresoldAndDeleteOldestWhenOverflow() {
if (isOverflow()) {
List<Entry<String, CacheFile>> deletingCandidates = getDeletingCandidates();
for (Entry<String, CacheFile> entry : deletingCandidates) {
delete(entry.getKey());
}
}
}

private boolean isOverflow() {
if (maxBytesSize <= 0) {
return false;
}
return currentBytesSize.get() > maxBytesSize;
}

private List<Entry<String, CacheFile>> getDeletingCandidates() {
List<Entry<String, CacheFile>> deletingCandidates = 
new ArrayList<Entry<String, CacheFile>>();
long cadidateFileSizes = 0;
for (Entry<String, CacheFile> entry : cacheFileMap.entrySet()) {
deletingCandidates.add(entry);
cadidateFileSizes += entry.getValue().file.length();
if (currentBytesSize.get() - cadidateFileSizes < maxBytesSize) {
break;
}
}
return deletingCandidates;
}

checkMaxThresoldAndDeleteOldestWhenOverflow() 메서드는 isOverflow()를 이용해서 캐시 디렉토리에 보관된 파일들의 전체 크기가 지정한 최대 크기를 초과했는지 검사한다. 초과한 경우 getDeletingCandidates()를 이용해서 삭제 대상을 구한 뒤에, delete()로 삭제 처리를 한다.

getDeletingCandidates() 메서드는 캐시 파일 중 오래된 파일을 삭제 대상에 추가한다. 최대 크기를 초과하지 않을 때 까지 캐시 파일들을 차례대로 삭제 대상에 추가한다.

스토리지에 파일을 이동시키는 move() 메서드

move() 메서드는 특정 파일을 스토리지로 이동시켜 보관한다.

public void move(String filename, File sourceFile) {
writeLock.lock();
try {
createCacheDirIfNotExists();
File file = createFile(filename);
sourceFile.renameTo(file);
putToCachMapAndCheckMaxThresold(file);
} finally {
writeLock.unlock();
}
}

캐시 파일 삭제 위한 delete() 메서드 및 deleteAll() 메서드

delete() 파일은 간단하다. cacheFileMap에서 캐시파일 정보를 읽어온 뒤, removeCacheFileFromMap() 메서드를 이용해서 메모리에서 정보를 삭제하고 그 다음 파일을 삭제한다.

public void delete(String filename) {
writeLock.lock();
try {
CacheFile cacheFile = cacheFileMap.get(filename);
if (cacheFile == null)
return;

removeCacheFileFromMap(filename, cacheFile);
cacheFile.file.delete();
} finally {
writeLock.unlock();
}
}

public void deleteAll() {
writeLock.lock();
try {
List<String> keys = new ArrayList<String>(cacheFileMap.keySet());
for (String key : keys) {
delete(key);
}
} finally {
writeLock.unlock();
}
}

CacheFile 코드

CacheFile 클래스는 CacheStorage 클래스에 정의된 중첩 클래스로서 다음과 같다.

public class CacheStorage {

...

private static class CacheFile {
public File file;
public long size;

public CacheFile(File file) {
super();
this.file = file;
this.size = file.length();
}
}
}

FileCache 및 FileCacheImpl 구현

FileCache 인터페이스

FileCache 인터페이스는 캐시 목적의 기능을 정의한다.

public interface FileCache {

public FileEntry get(String key);

public void put(String key, ByteProvider provider) throws IOException;

public void put(String key, InputStream is) throws IOException;

public void put(String key, File sourceFile, boolean move) throws IOException;

public void remove(String key);

public void clear();
}

FileCacheImpl 클래스의 구현


파일 캐시를 필요로 하는 코드는 FileCache 타입을 사용하는데, 이 타입의 구현 클래스가 FileCacheImpl이다. FileCacheImpl 클래스는 CacheStorage를 생성하고, get/put/remove 메서드는 cacheStorage에 요청을 전달한다.


public class FileCacheImpl implements FileCache {


private CacheStorage cacheStorage;


public FileCacheImpl(File cacheDir, int maxKBSizes) {

long maxBytesSize = maxKBSizes <= 0 ? 0 : maxKBSizes * 1024;

cacheStorage = new CacheStorage(cacheDir, maxBytesSize);

}


@Override

public FileEntry get(String key) {

File file = cacheStorage.get(keyToFilename(key));

if (file == null) {

return null;

}

if (file.exists()) {

return new FileEntry(key, file);

}

return null;

}


@Override

public void put(String key, ByteProvider provider) throws IOException {

cacheStorage.write(keyToFilename(key), provider);

}


@Override

public void put(String key, InputStream is) throws IOException {

put(key, ByteProviderUtil.create(is));

}


@Override

public void put(String key, File sourceFile, boolean move)

throws IOException {

if (move) {

cacheStorage.move(keyToFilename(key), sourceFile);

} else {

put(key, ByteProviderUtil.create(sourceFile));

}

}


@Override

public void remove(String key) {

cacheStorage.delete(keyToFilename(key));

}


private String keyToFilename(String key) {

String filename = key.replace(":", "_");

filename = filename.replace("/", "_s_");

filename = filename.replace("\\", "_bs_");

filename = filename.replace("&", "_bs_");

filename = filename.replace("*", "_start_");

filename = filename.replace("?", "_q_");

filename = filename.replace("|", "_or_");

filename = filename.replace(">", "_gt_");

filename = filename.replace("<", "_lt_");

return filename;

}


@Override

public void clear() {

cacheStorage.deleteAll();

}


}


캐시 키를 그대로 파일 이름으로 사용할 수 없기 때문에, get/put/remove/move 메서드는 keyToFilename()를 이용해서 파일명으로 사용될 수 없는 문자를 알맞게 치환한다.


FileCacheFactory 클래스의 구현


FileCacheFactory 클래스는 FileCache를 생성하고 제공하는 기능을 제공한다. 안드로이드의 캐시 디렉토리를 먼저 구해야 하기 때문에, 초기화(initialize)를 수행해야 getInstance()를 구하도록 제약을 두었다.


public class FileCacheFactory {


private static boolean initialized = false;

private static FileCacheFactory instance = new FileCacheFactory();


public static void initialize(Context context) {

if (!initialized) {

synchronized (instance) {

if (!initialized) {

instance.init(context);

initialized = true;

}

}

}

}


public static FileCacheFactory getInstance() {

if (!initialized) {

throw new IllegalStateException(

"Not initialized. You must call FileCacheFactory.initialize() before getInstance()");

}

return instance;

}


private HashMap<String, FileCache> cacheMap = new HashMap<String, FileCache>();

private File cacheBaseDir;


private FileCacheFactory() {

}


private void init(Context context) {

cacheBaseDir = context.getCacheDir();

}


public FileCache create(String cacheName, int maxKbSizes) {

synchronized (cacheMap) {

FileCache cache = cacheMap.get(cacheName);

if (cache != null) {

throw new FileCacheAleadyExistException(String.format(

"FileCache[%s] Aleady exists", cacheName));

}

File cacheDir = new File(cacheBaseDir, cacheName);

cache = new FileCacheImpl(cacheDir, maxKbSizes);

cacheMap.put(cacheName, cache);

return cache;

}

}


public FileCache get(String cacheName) {

synchronized (cacheMap) {

FileCache cache = cacheMap.get(cacheName);

if (cache == null) {

throw new FileCacheNotFoundException(String.format(

"FileCache[%s] not founds.", cacheName));

}

return cache;

}

}


public boolean has(String cacheName) {

return cacheMap.containsKey(cacheName);

}

}


파일 캐시 사용법


파일 캐시를 사용하는 방법은 간단하다. 먼저 FileCacheFactory.initialize()로 초기화를 하고, create() 메서드로 캐시를 생성한다. 그 다음 캐시를 필요로 하는 곳에서 get() 메서드를 이용해서 FileCache를 구해서 사용하면 된다.


다음은 FileCache의 사용예를 간단하게 정리한 것이다.


public class SomeLoadActivity extends Activity {


private FileCache fileCache;

@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

FileCacheFactory.initialize(this);

if (! FileCacheFactory.getInstance().has(cacheName)) {

FileCacheFactory.getInstance().create(cacheName, cacheSize);

}

fileCache = FileCacheFactory.getInstance().get(cacheName);

...

}


public void load() {

...

FileEntry fileEntry = fileCache.get(key);

if (fileEntry != null) {

// 캐시에서 읽어온 데이터로 처리

String data = loadDataFromFile(fileEntry.getFile());

processing(data);

return;

}

// 실제 데이터 로딩 (실제로는 웹에서 비동기로 읽어오는 등의 코드)

String data = loadingDataRealSource();

// 캐시에 보관

fileCache.put(key, ByteProviderUtil.create(dataFile));

// 처리

processing(data);

}


기타 클래스: IOUtils, FileEntry


IOUtils 클래스는 입출력 관련 처리를 위한 보조 클래스로서 본 글에서 사용하는 코드는 다음과 같다.


public abstract class IOUtils {


public static String read(InputStream is) throws IOException {

InputStreamReader reader = null;

try {

reader = new InputStreamReader(is);

StringBuilder builder = new StringBuilder();

char[] readDate = new char[1024];

int len = -1;

while ((len = reader.read(readDate)) != -1) {

builder.append(readDate, 0, len);

}

return builder.toString();

} finally {

close(reader);

}

}


public static void copy(InputStream is, OutputStream out)

throws IOException {

byte[] buff = new byte[4096];

int len = -1;

while ((len = is.read(buff)) != -1) {

out.write(buff, 0, len);

}

}


public static void copy(File source, OutputStream os) throws IOException {

BufferedInputStream is = null;

try {

is = new BufferedInputStream(new FileInputStream(source));

IOUtils.copy(is, os);

} finally {

IOUtils.close(is);

}

}


public static void copy(InputStream is, File target) throws IOException {

OutputStream os = null;

try {

os = new BufferedOutputStream(new FileOutputStream(target));

IOUtils.copy(is, os);

} finally {

IOUtils.close(os);

}

}


public static void copy(String str, OutputStream os) throws IOException {

os.write(str.getBytes());

}


public static void close(Closeable stream) {

if (stream != null) {

try {

stream.close();

} catch (IOException e) {

}

}

}


}


다음은 FileEntry 클래스의 소스 코드이다.


public class FileEntry {


    private String key;

    private File file;


    public FileEntry(String key, File file) {

        this.key = key;

        this.file = file;

    }


    public InputStream getInputStream() throws IOException {

        return new BufferedInputStream(new FileInputStream(file));

    }


    public String getKey() {

        return key;

    }


    public File getFile() {

        return file;

    }


}


Posted by 최범균 madvirus

댓글을 달아 주세요

  1. andu 2013.05.15 17:52 신고  댓글주소  수정/삭제  댓글쓰기

    FileEntry 클래스가 없네요...

  2. andu 2013.05.16 14:05 신고  댓글주소  수정/삭제  댓글쓰기

    FileCacheFactory의 initialize() 정의는 initialize(Context context)인 반면,
    사용법에서는 FileCacheFactory.initialize()로 호출을 하고 있는데,
    이것은 어떤 차이가..

    • 최범균 madvirus 2013.05.19 18:13 신고  댓글주소  수정/삭제

      오류네요... 원본 사용 코드를 그대로 넣지 못해서 복사하고 정리했는데, 그 과정에서 잘못 복사되었어요. FileCacheFactory.initialize(this); 로 수정했습니다.

  3. hong 2013.05.20 22:12 신고  댓글주소  수정/삭제  댓글쓰기

    FileCacheAleadyExistException
    해당 예외처리는 어찌해주셧어요 ??

    다이어그램을 찾아보고 소스부를 봐도 예외처리해준부분은 따로 없어서요 ;;

    익셉션 상속받아 해당 이름으로 예외 클래스 만들어줘서 일단 처리는 해놨지만

    예외처리 클래스 코드가 어떤지 알고싶어서요 부탁드리겠습니다.

    • 최범균 madvirus 2013.05.21 21:51 신고  댓글주소  수정/삭제

      익셉션 직접 만들고 try-catch로 처리해 주심 됩니다.
      FileCacheAleadyExistException 익셉션이 발생한다는 건
      이미 해당 캐시가 존재한다는 것이므로,
      catch 부분에 로그 정도를 남겨주시면 어떨까요?
      전 warn 레벨로 남겼어요.

안드로이드에서 비동기로 작업을 할 때 사용되는 AsyncTask는 잘 사용하지 않으면, 코드 유지보수를 어렵게 만드는 악의 원천이 될 수 있다. 특히, 많은 안드로이드 입문서들이  AsyncTask를 이용해서 비동기를 처리하는 코드를 보여줄 때 다음의 두 가지 문제점을 포함하고 있는 경우가 많다.

  • AsyncTask를 상속 받은 클래스가 비동기 처리 결과를 사용하는 코드에 대한 의존을 갖는다.
  • 비동기 처리가 필요한 기능마다 AsyncTask 클래스를 상속받아 구현한다.
우선, 첫 번째 문제는 코드의 응집도를 낮추고 결합도를 높여주기 때문에, 코드가 만들어지면 만들어질수록 유지보수를 하기 어렵게 만들어준다. 다음은 전형적인 코드이다.

public class AsyncHttpTask extends AsyncTask<String, Void, String> {
private Handler handler;
private Exception exception;

public AsyncHttpTask(Handler handler) {
this.handler = handler;
}

@Override
protected T doInBackground(String... urls) {
try {
// urls[0]의 URL부터 데이터를 읽어와 String으로 리턴
...
return responseData;
} catch(Exception ex) {
this.exception = ex;
return null;
}
}

@Override
protected void onPostExecute(String responseData) {
if (exception != null) {
Message msg = handler.obtainMessage();
msg.what = -1;
msg.obj = exception;
handler.sendMessage(msg);
return;
} else {
Message msg = handler.obtainMessage();
msg.what = 0;
msg.obj = responseData;
handler.sendMessage(msg);
}
}
}


AsyncHttpTask를 필요로 하는 코드는 Handler 객체를 이용해서 결과를 받아온다.


public class SomeActivity extends Activity {


public void some() {

new AsyncHttpTask(handler).execute(url);

}

private Handler handler = new Handler() {

public void handleMessage(Message msg) {

switch(msg.what) {

case -1:

// 에러 처리

break;

case 0:

// 정상 응답 처리

break;

}

}

};


}


AsyncHttpTask를 사용하는 모든 코드는 위와 같이 결과를 받아오기 위해 Handler를 사용해야 하고, -1과 0 이라는 숫자를 사용해야 한다. (-1과 0 대신에 ASYNC_HTTP_ERROR, ASYNC_HTTP_OK 라는 상수를 사용하면 조금은 나을 것 같다.) 문제는 Handler를 이곳 저곳에서 사용하기 시작하면, case 문이 점점 복잡해질 수 있다는 것이다. 예를 들어, 또 다른 Async 처리 기능이 있다고 해 보자. 이 경우 위 코드는 다음과 같이 될 것이다.


public class SomeActivity extends Activity {


public void some() {

new AsyncHttpTask(handler).execute(url);

}

public void process() {

new AsyncImageFileProcessingTask(handler).execute(file);

}

private Handler handler = new Handler() {

public void handleMessage(Message msg) {

switch(msg.what) {

case -1:

// 에러 처리

break;

case 0:

// 정상 응답 처리

break;

case 101:

// 파일 프로세싱 시작

break;

case 102:

// 파일 프로세싱 종료

break;

}

}

};


}


여기서 짜증나는 건 AsyncHttpTask와 AsyncImageFileProcessingTask가 동일한 Handler를 공유할 수 있기 때문에, 서로 Message의 what 값이 충돌나지 않게 구현해야 한다는 점이다. AsyncImageFileProcessingTask가 실패할 경우 what 값을 -1로 준다면, 위 코드는 문제가 발생한다. 즉, 서로 다른 비동기 처리 코드임에도 불구하고 Handler를 사용함으로써 암묵적인 커플링이 발생하는 것이다.


그래서, Handler를 사용해서 결과를 주고 받는 방식보다는 콜백 인터페이스를 이용해서 결과를 받는 것이 코드 유지보수에 유리하다. (이에 대한 내용은 예전에 쓴  '안드로이드 Handler 사용으로 인해 흩어진 코드 커맨드와 콜백으로 정리하기' 글을 참고한다.)


유지보수를 고려한 비동기 처리 기반 코드


최근에 안드로이드 코딩 중, 다른 종류의 비동기 작업을 해야할 일이 생겨서, 위에서 언급한 문제를 최대한 제거한 비동기 처리 기반 코드를 만들어보았다. 이 코드에서 출현하는 타입은 세 개이다.

  • AsyncExecutor<T> - AsyncTask를 상속받은 클래스로서, 비동기로 작업을 실행하고 결과를 전달해주는 기능이다.
  • Callable<T> - java.util.concurrent.Callable 인터페이스로 비동기로 실행할 작업을 제공한다.
  • AsyncCallback<T> - 비동기 처리 결과를 받을 때 사용되는 콜백 인터페이스이다.
  • AsyncExecutorAware<T> - 콜백 구현체가 AsyncExecutor 객체를 참조해야 할 경우에 사용되는 인터페이스이다.
AsyncCallback 인터페이스

AsyncCallback 인터페이스는 다음과 같이 정의하였다.

public interface AsyncCallback<T> {
    public void onResult(T result);

    public void exceptionOccured(Exception e);

    public void cancelled();
}

각 메서드는 결과를 받고(onResult), 처리 도중 발생한 익셉션을 받고(exceptionOccured), 작업을 취소했음을 받을(cancelled) 때 사용된다. 필요에 따라 진행율을 받을 수 있는 콜백 메서드를 추가할 수도 있을 것이다.

AsyncExecutorAware 인터페이스

콜백 구현 객체에서 AsyncExecutor에 대한 접근이 필요할 때가 있는데, 이런 경우 콜백 구현 클래스는 AsyncExecutorAware 인터페이스를 구현하면 된다.

public interface AsyncExecutorAware<T> {

public void setAsyncExecutor(AsyncExecutor<T> asyncExecutor);

}

뒤에서 구현할 AsyncExecutor는 콜백 객체가 AsyncExecutorAware 인터페이스를 구현한 경우, 콜백 객체의 setAsyncExecutor()를 호출하여 자기 자신을 콜백 객체에 전달한다.

AsyncExecutor 클래스

AsyncExecutor는 다음과 같이 구현하였다.

import java.util.concurrent.Callable;

import android.os.AsyncTask;
import android.util.Log;

public class AsyncExecutor<T> extends AsyncTask<Void, Void, T> {
private static final String TAG = "AsyncExecutor";

private AsyncCallback<T> callback;
private Callable<T> callable;
private Exception occuredException;

public AsyncExecutor<T> setCallable(Callable<T> callable) {
this.callable = callable;
return this;
}

public AsyncExecutor<T> setCallback(AsyncCallback<T> callback) {
this.callback = callback;
processAsyncExecutorAware(callback);
return this;
}

@SuppressWarnings("unchecked")
private void processAsyncExecutorAware(AsyncCallback<T> callback) {
if (callback instanceof AsyncExecutorAware) {
((AsyncExecutorAware<T>) callback).setAsyncExecutor(this);
}
}

@Override
protected T doInBackground(Void... params) {
try {
return callable.call();
} catch (Exception ex) {
Log.e(TAG,
"exception occured while doing in background: "
+ ex.getMessage(), ex);
this.occuredException = ex;
return null;
}
}

@Override
protected void onPostExecute(T result) {
if (isCancelled()) {
notifyCanceled();
}
if (isExceptionOccured()) {
notifyException();
return;
}
notifyResult(result);
}

private void notifyCanceled() {
if (callback != null)
callback.cancelled();
}

private boolean isExceptionOccured() {
return occuredException != null;
}

private void notifyException() {
if (callback != null)
callback.exceptionOccured(occuredException);
}

private void notifyResult(T result) {
if (callback != null)
callback.onResult(result);
}

}

AsyncExecutor의 주요 기능은 다음과 같다.
  • doInBackground() 메서드: callable 객체에 작업 실행을 위임한다.
  • onPostExecute() 및 관련 메서드: callback 객체에 결과를 전달한다.
사용 예시

AsyncExecutor의 사용 방법은 다음과 같다.

public void load() {
// 비동기로 실행될 코드
Callable<ToonDataList> callable = new Callable<ToonDataList>() {
@Override
public ToonDataList call() throws Exception {
return client.getFeaturedTopData(startRow);
}
};

new AsyncExecutor<ToonDataList>()
.setCallable(callable)
.setCallback(callback)
.execute();
}

// 비동기로 실행된 결과를 받아 처리하는 코드
private AsyncCallback<ToonDataList> callback = new AsyncCallback<ToonDataList>() {
@Override
public void onResult(ToonDataList result) {
appendResult(result);
}

@Override
public void exceptionOccured(Exception e) {
AlertUtil.alert(context, context.getString(R.string.dataloading_error));
}

@Override
public void cancelled() {
}
};


기타

AsyncCallback 인터페이스의 두 메서드 중 한 개만 구현하면 되는 클래스를 위해 아무것도 하지 않는 구현을 제공한 Base 클래스를 추가했다.

public interface AsyncCallback<T> {
...
public static abstract class Base<T> implements AsyncCallback<T> {

@Override
public void exceptionOccured(Exception e) {
}

@Override
public void cancelled() {
}

}
}

관련자료


Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 뉴비뉴비 2013.05.14 11:29 신고  댓글주소  수정/삭제  댓글쓰기

    아 너무 좋네요 코드가 완전 깔끔해졌어요~ 잘보고갑니다 감사합니다 ^ 0^

  2. 식빵 2013.07.23 16:39 신고  댓글주소  수정/삭제  댓글쓰기

    정말 신세계를 보고 가네요..=ㅂ=!!!

  3. 대관령 2014.04.08 22:19 신고  댓글주소  수정/삭제  댓글쓰기

    감동의 연속입니다.
    제가 몇일동안 고민하던 사항들을 한방에 날려주시는군요.
    한편으로는 새로운 자극을 받게되어 감사드립니다.
    화이팅입니다~~

  4. 비동기 2014.07.03 13:43 신고  댓글주소  수정/삭제  댓글쓰기

    좋은 글 감사합니다^^

    궁금한게 있는데요.

    한 화면에서 여러가지 비동기 처리를 할 때는 작업의 구분을 어떻게 해야 할까요?

    AsyncCallback 함수의 onResult 에서 처리를 하려면 Handler에서 처리하는 것 처럼 상수 값이나 다른 구분값이 필요할 것 같은데요...

    아니면 AsyncCallback 를 비동기 처리 갯수만큼 만들어야 하나요?

    제가 제대로 이해를 못한건지.. 답변 부탁드릴게요 ㅠ

  5. 오리왕 2014.07.06 09:14 신고  댓글주소  수정/삭제  댓글쓰기

    callable 내에서 진행률을 처리해야 할 경우, AsyncTask 의 progress 즉 진행률을 비동기로 받으려면 어떻게 해야 하나요?
    제가 생각한 구조는 아래의 코드와 같습니다. 이렇게 된다면 callable 의 call() 내부에서 callback 을 호출하는 부분(callback.onProgress) 이 필요한데요...
    이 부분의 구현에 대한 힌트 또는 예제 코드를 얻을수 있을까요?

    public interface AsyncCallback<T> {
    public void onResult(T result);
    public void onProgress(Integer progress); // 진행률 표시와 관련된 부분
    public void exceptionOccured(Exception e);
    public void cancelled();
    }

    doInBackground(Void... params) {
    try {
    // 진행률 표시부 시작
    int progress = 10;
    if (callback != null)
    callback.onProgress(progress);
    ...
    // 진행률 표시부 종료
    return callable.call();

    } catch (Exception e) {
    }
    }

    • 최범균 madvirus 2014.07.08 10:12 신고  댓글주소  수정/삭제

      말씀하신 것처럼 AsyncCallback 에는 onProgress 메서드를 추가해야 할 것 같고, 진행률을 누가 전달할지가 관건일 듯 합니다.
      실제 진행률을 알고 있는 건, callable 이지 AsyncExecutor는 아니니까요. AsyncExecutor가 AsyncTask를 상속받았으니까, 결과적으로
      - callable에서 AsyncExecutor 객체의 publishProgress()를 실행하고,
      - AsyncExecutor 객체의 onProgressUpdate()에서 callback의 onProgress()를 호출하도록 하면 될 것 같습니다.

  6. beetcom 2014.08.01 02:14 신고  댓글주소  수정/삭제  댓글쓰기

    callable에서 AsyncExecutor aware 하더라도 publishProgress는 protected 니까
    callable이 publishProgress를 직접 호출할 수는 없을 것 같은데요.
    뭔가 깔끔한 방법은 안떠오르네요.

    • 최범균 madvirus 2014.08.04 19:06 신고  댓글주소  수정/삭제

      글을 쓴지 좀 되서 기억을 복구하는데 꽤 시간이 걸리네요 ^^; (한해 한해 기억력이 뚝뚝 떨어짐을 느낍니다.)
      조금 더 나가보자면 다음과 같이 해 볼 수 있을 것 같습니다.
      * AsyncExecutor를 인터페이스로 만들고,
      * AsyncExecutor 인터페이스에 notifyProgress()와 같은 메서드를 추가하고,
      * AsyncExecutor를 구현한 AsyncTaskExecutor 클래스가 AsyncTask 클래스를 상속받도록 한 다음에
      * AsyncTaskExecutor 클래스의 notifyProgress() 메서드가 상위 클래스의 publishProgress()를 호출하게 한다거나 하는 식으로
      해 보면 조금 쓸만한 모양이 나오지 않을까 싶습니다. 해보지 않고 상상으로만 적어본거라 어떨지 모르겠네요.

  7. 김형주 2014.09.04 12:01 신고  댓글주소  수정/삭제  댓글쓰기

    자료 감사합니다.

    설명해주신 자료를 보면 AsynCallback으로 try/catch 의 Exception 관련 에러메시지는 받을 수 있지만,
    HttpResponse Status Code (ex: 404, 500 .. ) 에 관련된 에러 코드는 받을 수 없는것 같습니다.

    원하는 코드까지 받으려면 AsyncHttpTask 에서 리턴되는 결과값을 HashMap으로 대체해서
    응답값과 코드까지 받으려고 하는데.. 문제가 될까요?


  8. sinki 2015.01.08 17:12 신고  댓글주소  수정/삭제  댓글쓰기

    우선 정말 좋은 예제 감사드립니다.

    상기 예제에서 정말 이해가 안가는 부분이 있는데
    AsyncExecutorAware 인터페이스 에서

    "콜백 구현 객체에서 AsyncExecutor에 대한 접근이 필요할 때가 있는데"
    실제 어떻게 구현 해야 하는지 감 조차 안 옵니다. ^^

    많이 바쁘시겠지만, 힌트라도 주시겠어요?

    미리 감사합니다.


    • 최범균 madvirus 2015.01.12 12:25 신고  댓글주소  수정/삭제

      클래스가 두 개 인터페이스를 상속받도록 구현하시면 됩니다.

      public class MyCallback implements AsyncExecutorAware, AsyncCallback<Some>() {
      private AsyncExecutor<Some> executor;
      public void setAsyncExecutor(AsyncExecutor<Some> executor) {
      this.executor = executor;
      }
      // 다른 메서드들 구현

      }

안드로이드에서 2회 연속 백버튼을 눌러서 앱을 종료시키는 경우가 흔한데, 이 기능은 많이 사용되므로 다음과 같이 별도 클래스로 기능을 분리하면 향후 기능 재사용이 편리하다.


public class BackPressCloseHandler {


private long backKeyPressedTime = 0;

private Toast toast;


private Activity activity;


public BackPressCloseHandler(Activity context) {

this.activity = context;

}


public void onBackPressed() {

if (System.currentTimeMillis() > backKeyPressedTime + 2000) {

backKeyPressedTime = System.currentTimeMillis();

showGuide();

return;

}

if (System.currentTimeMillis() <= backKeyPressedTime + 2000) {

activity.finish();

toast.cancel();

}

}


private void showGuide() {

toast = Toast.makeText(activity, "\'뒤로\'버튼을 한번 더 누르시면 종료됩니다.",

Toast.LENGTH_SHORT);

toast.show();

}


}


BackPressCloseHandler의 구현은 간단하다.

  • backKeyPressedTime은 백버튼이 눌린 마지막 시간을 기록한다.
  • onBackPressed() 메서드는 현재 시간이 마지막 백버튼 누른 시간으로부터 
    • 2초 이상 지났으면, 마지막 백버튼 눌린 시간을 현재 시간으로 갱신하고 showGuide()를 실행한다.
    • 2초 이상 지나지 않았으면, Activity를 종료한다.
    • 참고로, 2초는 Toast.LENGTH_SHORT의 기본 값이다.
  • showGuide() 메서드는 Toast를 이용해서 메시지를 출력한다.

2회 연속 백버튼 누를 때 종료시키고 싶은 Activity가 있다면, 다음과 같이 사용한다.

  • BackPressCloseHandler 타입의  backPressCloseHandler 필드를 추가한다.
  • onCreate() 메서드에서 BackPressCloseHandler 객체를 생성해서 필드에 할당한다.
  • onBackPressed() 메서드에서 backPressCloseHandler.onBackPressed()를 호출한다.
아래 코드는 실제 적용한 코드의 일부를 발췌한 것이다.


public class HomeActivity extends Activity ... {


private BackPressCloseHandler backPressCloseHandler;


@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

setContentView(R.layout.home_layout);

...

backPressCloseHandler = new BackPressCloseHandler(this);

}


@Override

public void onBackPressed() {

backPressCloseHandler.onBackPressed();

}

}


Posted by 최범균 madvirus

댓글을 달아 주세요

  1. morcavon 2014.04.14 22:58 신고  댓글주소  수정/삭제  댓글쓰기

    유용한 정보 감사합니다 :-)

  2. Thankx 2015.05.28 21:12 신고  댓글주소  수정/삭제  댓글쓰기

    감사합니다. ㅜ.ㅜ~

  3. 므시칸곰틔군 2015.08.04 11:27 신고  댓글주소  수정/삭제  댓글쓰기

    정보 감사합니다.

  4. 전동화 2016.12.19 14:25 신고  댓글주소  수정/삭제  댓글쓰기

    좋은 정보 감사합니다.

안드로이드 기초 쌓기 - 해상도와 DP(http://javacan.tistory.com/233) 글에서 DPI, DP 등 크기와 관련된 내용을 정리했는데, 이번에 정리해볼 내용은 구성 요소의 크기에 대한 부분이다.


UI 구성 요소의 크기를 어떻게 할지 결정해야 하는데, 다음의 3가지 기준을 고려한다.

  • 물리적으로 동일한 크기로 배치할 것들
  • 비율을 사용해서 배치할 것들
  • 픽셀 단위 크기 사용해서 배치할 것들

예를 들어, 트위터 앱을 옵티머스LTE2와 넥서스7에서 실행해보면 다음과 같이 물리적인 높이/아이콘 크기는 동일한데, 각 탭의 폭은 동일한 비율로 배치된 것을 알 수 있다.



이 경우, 이미지 크기는 물리적으로 동일한 크기를 가져야 하므로 DP 단위를 사용해야 하고, 폭은 weight를 이용해서 비율로 크기를 지정해 주어야 한다. 실제 레이아웃 설정은 다음과 같은 방식으로 크기를 지정하고 있을 것이다.


<LinearLayout

    android:orientation="horizontal"

    android:layout_width="fill_parent"

    android:layout_height="wrap_content">


<ImageButton

android:id="@+id/btn1"

android:layout_width="0px" 

android:layout_height="48dp"

android:layout_weight="1"

... /> 


<ImageButton

android:id="@+id/btn2"

android:layout_width="0px" 

android:layout_height="48dp"

android:layout_weight="1"

... /> 


<ImageButton

android:id="@+id/btn3"

android:layout_width="0px" 

android:layout_height="48dp"

android:layout_weight="1"

... /> 

<ImageButton

android:id="@+id/btn4"

android:layout_width="0px" 

android:layout_height="48dp"

android:layout_weight="1"

... /> 


</LinearLayout>


위 설정에서 android:layout_weight 속성의 값은 모두 "1"인데, 이 경우 네 개의 구성 요소의 크기 비율은 1:1:1:1 이 된다. 즉, 전체 크기가 800 px이면, 각 구성 요소의 크기는 200 px이 된다.


Posted by 최범균 madvirus

댓글을 달아 주세요

안드로이드를 좀 더 잘 해 보기 위해 기초를 다지고 있는데, 그 중 첫 번째로 공부하고 있는 부분이 해상도 및 레이아웃과 관련된 내용이다. 


안드로이드의 주요 단위


안드로이드 기기들이 해상도와 물리적인 크기가 저마다 다르기 때문에, UI 레이아웃을 기기별로 깨지지 않게 만들어주려면 주요 단위에 대한 이해가 필요하다. 다음은 안드로이드 개발시 알아야 하는 용어/단위를 정리한 것이다.


용어 및 단위

설명 

Pixel

화면상의 픽셀 

해상도(Resolution)

픽셀 단위의 화면 크기. 예를 들어, 갤럭시노트 10.1의 해상도는 1280*800인데, 이는 픽셀이 1280개 및 800개임을 의미한다.

DPI (Dots Per Inch) / 밀도

물리적인 1 인치 당 포함되는 픽셀 개수. 예를 들어, 160 DPI는 1인당 픽셀이 160개 포함된다는 것을 의미한다. 주요 DPI는 다음과 같다.

- LDPI (low) : 120 DPI

- MDPI (medium) : 160 DPI

- TVDPI : 213 DPI

- HDPI (high) : 240 DPI

- XHDPI (extra high) : 320 DPI

스크린 크기

물리적인 크기의 종류를 나타낸다. 다음의 4종류가 존재한다.

- X-Large: 주로 10.1 인치 이상의 디바이스

- Large: 주로 5인치 이상의 디바이스

- Normal: 3인치에서 5인치 미만의 사이의 디바이스

- Small: 3인치 미만의 디바이스

 px

픽셀 기반의 단위 

 dip (density-independent pixels) 또는 dp

밀도 독립 단위로, 장치의 밀도에 상관없이 물리적으로 (거의) 동일한 크기를 갖는다. 

 sp (scale-independent pixels)

스케일 독립 픽셀 단위로 , dip와 유사하며, 글꼴 크기를 지정할 때 주로 사용된다.


실제 테스트 해 볼 수 있는 기기별로 확인해보니 주요 값은 다음과 같았다.


 

 갤럭시노트 10.1

옵LTE 2 

넥서스7 

옵Q 

해상도 (픽셀단위)

800 x 1280 

720 x 1280 

800 x 1280 

480 x 800 

해상도 (DP 단위)

800 x 1280 

360 x 640

600 x 961 

320 x 533 

DPI

160 DPI (mdpi)

320 DPI (xhdpi)

213 DPI
(tvdip, hdip) 
240 DPI 

스크린 크기

xlarge 

normal 

large 

normal 

밀도 비율

(DPI / 160)

1.331250

1.5

안드로이드의 기준 DPI는 중간 수준인 160 DPI이다. 160 DPI를 기준으로 DPI가 크면 밀도가 높아지고, DPI가 작으면 밀도가 낮아진다. 또한, 160 DPI인 경우 밀도독립 단위인 DP(DIP)와 픽셀이 같은 크기를 갖는다. 즉, 160 DPI에서 1 DP는 1 PX이 된다.


PX와 DP


옵LTE2와 넥서스7 그리고 갤럭시노트 10.1에서 트위터를 실행해보면, 기기의 크기는 다르지만, 상단 바 부분의  물리적 높이가 동일한 것을 확인할 수 있다. 또한, 글자 크기도 동일한 것을 확인할 수 있다.


[옵티머스LTE2(좌)와 넥서스7(우)에서 트위터를 실행한 화면. 상단 바와 메뉴의 높이가 (거의) 같다]


위 그림에서 두 기기의 높이 해상도는 1280이지만, 물리적인 크기는 넥서스7이 더 크다. 따라서, 위 그림에서 실제 px 단위의 높이 값은 좌측의 옵티머스LTE2가 넥서스7보다 커야 위와 같이 물리적으로 동일한 크기로 표시된다. 모든 기기마다 물리적으로 동일한 높이를 갖는 px 값을 구해서 계산한다는 것은 매우 힘든데, dp 단위를 사용하면 위 그림처럼 기기의 크기에 상관없이 물리적으로 동일한 크기로 레이아웃을 구성할 수 있다.


XML 레이아웃 설정 파일에서 dp 단위로 크기를 지정하면, 안드로이드는 내부적으로 알맞은 px 단위로 값을 변환해서 크기를 구성한다. 따라서, 개발자는 dp 단위를 사용해서 물리적으로 동일한 크기를 갖는 레이아웃을 구성할 수 있다.


코드에서 직접 크기를 설정하는 경우에는 픽셀 단위로 지정하게 되는데, 이 경우 다음의 공식을 이용해서 dp 단위의 값을 px 단위의 값으로 변환할 수 있다.


px = dp * (DPI / 160)


기기의 DPI 구하기


dp 단위의 값으로부터 px 단위의 값을 구하려면 기기의 DPI를 구해야 하는데, 다음의 코드를 이용하면 DPI를 구할 수 있다.


Display dis = ((WindowManager) getSystemService(WINDOW_SERVICE)).getDefaultDisplay();

DisplayMetrics metrics = new DisplayMetrics();

dis.getMetrics(metrics);

// 해상도: dis.getWidth() * dis.getHeight() / metrics.widthPixels * metrics.heightPixels

// DPI: metrics.densityDpi

// 밀도비율 (DPI / 160) : metrics.density


참고자료

  • 기기별 DPI/해상도/크기 등: http://developer.android.com/tools/revisions/platforms.html
  • Supporting Multiple Screens: http://developer.android.com/guide/practices/screens_support.html


Posted by 최범균 madvirus

댓글을 달아 주세요

안드로이드의 MediaPlayer가 스트리밍 프로토콜로 RTSP를 지원하지만 RTSP를 사용해서 미디어를 플레이해보면, 스트리밍이 얼마나 불안정한지 알 수 있다. 안드로이드 3 부터는 HLS를 지원하다고는 하지만 실제로 해보면 기기에 따라 지원이 안 되는 경우도 있다. 그리고, 여전히 2.2~2.3 버전의 기기를 사용하는 사람들도 아직은 상당수 존재할 것 같다. 이런 이류로 예전에 구현 작업을 진행할 때 VOD를 플레이하기 위해 RTSP 대신 HTTP PDL(Progressive download) 방식을 사용했지만, 여전히 LIVE 방송 같은 것을 구현하려면 RTSP를 사용해야했다.


필자가 FFmpeg을 사용해가면서 할 만큼 안드로이드만 집중적으로 팔 수 있는 상황이 아니였기에 포기하고 있었는데, 검색을 하던 중 우연히 Vitamio라는 안드로이드 라이브러리를 알게 되었다. Vitamio는 ARM 기반 프로세스를 위한 ffmpeg 모듈과 이를 이용한 미디어 플레이어 기반 코드를 제공하는 안드로이드 라이브러리로서, 이를 사용하면 비교적 쉽게 안드로이드 기기에서 RTMP, HLS 등의 스트리밍을 플레이할 수 있다. 물론, HTTP PDL도 지원한다.


Vitamio는 안드로이드 2.1 이상을 지원하고 ARMv6, VFP, ARMv7, NEON 등을 지원하기 때문에 현재 시중에 나온 대다수의 안드로이드 기기에서 동작한다.


Vitamio 사용을 위한 개발 환경 설정


아주 간단하다. 다음의 순서대로 진행하면 된다.

  1. http://vitamio.org/vitamios/android-3-dot-0?locale=en 에서 3.0 버전을 다운로드 받는다. (3.0 버전 기준)
  2. 압축받은 파일을 풀면 VitamioBundle 폴더와 VitamioDemo 폴더가 생긴다.
  3. 이클립스에서 VitamioBundle을 안드로이드 프로젝트로 임포트한다.
    1. 프로젝트 이름이 InitActivity로 임포트 되는데, VitamioBundle로 바꿔준다. (안 바꿔줘도 상관은 없다.)
  4. 안드로이드 프로젝트를 생성한다. A프로젝트라고 하자.
  5. A 프로젝트 선택 후, [Project 메뉴] -> [Properties] 메뉴 실행
    1. [Android] 항목 -> 레퍼런스 프로젝트에 VitamioBundle를 추가
    2. [Project References] 항목 -> VitamioBundle 추가
  6. A프로젝트의 AndroidManifest.xml 파일에 다음 Activity 설정을 추가한다.
  7. <activity

    android:name="io.vov.vitamio.activity.InitActivity"

    android:launchMode="singleTop"

    android:theme="@android:style/Theme.NoTitleBar"

    android:windowSoftInputMode="stateAlwaysHidden" />

  8. 이제 A프로젝트에서 Vitamio가 제공하는 VideoView, MediaPlayer 등을 이용해서 구현하면 된다.

Vitamio는 안드로이드가 기본으로 제공하는 VideoView, MediaPlayer 등과 (패키지만 다른) 동일한 이름의 클래스를 제공하고 있다. 따라서, 기존에 안드로이드의 미디어 관련 기능을 사용하고 있다면, 아주 작은 코드 수정만으로도 Vitamio의 기능을 사용할 수 있다. (다운로드 배포판에 함께 포함된 VitamioDemo에 사용코드 예제가 포함되어 있으니 참고하기 바란다.)


Vitamio가 제공하는 기능을 사용하려면 최초에 네이티브 라이브러리를 로딩하는 과정을 거쳐야 하는데, 이 과정은 다음의 코드를 사용하여 처리한다.


import io.vov.vitamio.LibsChecker;


public class MainActivity extends Activity {


@Override

public void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

if (! LibsChecker.checkVitamioLibs(this)) {

return;

}


따라서, 앱이 실행될 때 최초에 한 번 위 코드를 실행해 주어야 Vitamio가 제공하는 기능을 올바르게 사용할 수 있다.


VitamioBundle 프로젝트에 대한 레퍼런스 없애기


VitamioBundle에 대한 의존을 하려면 함께 하는 개발자들의 이클립스 프로젝트 VitamioBundler을 임포트 해 주어야 하는 불편함이 있다. 여럿이 공동으로 작업을 한다면 VitamioBundler을 사용하지 않고 작업할 수 있으면 더 좋을 것이다.


이를 위한 방법은 역시 간단하다. 다음의 순서대로 진행하면 된다. (참고로, 아래의 코드들은 VitamioBundle을 이용해서 개발하고 있는 OPlayer의 소스 코드 http://code.taobao.org/p/oplayer/src/trunk/OPlayer/ 에서 참고한 것이다.)

  1. 안드로이드 프로젝트의 libs 폴더에 다음의 파일을 복사한다.
    1. VitamioBundle/lib/vitamio.jar
    2. VitamioBundle/lib/armeabi 폴더 및 armeabi-v7a 폴더
  2. 안드로이드 프로젝트의 res/raw 폴더에 다음의 파일을 복사한다.
    1. VitamioBundle/res/raw/libarm.so
  3. io.vov.vitamio 패키지를 생성하고 그 곳에 R.java 클래스를 생성한다. [소스 코드는 아래 참고]
  4. [프로젝트패키지].vitamio 패키지에 다음의 두 클래스를 생성한다.
    1. LibsChecker.java [소스 코드는 아래 참고]
    2. InitActivity.java [소스 코드는 아래 참고]
  5. AndroidManifest.xml 파일에 Vitamio의 InitActivity가 아닌 4-2 과정에서 생성한 InitActivity를 이용해서 액티비티 설정을 추가한다.
그 다음에 Vitamio에 포함된 LibsChecker가 아닌 위 과정에서 생성한 LibsChecker를 이용해서 초기화작업을 진행하면 된다.

import [마이패키지].vitamio.LibsChecker;

public class MainActivity extends Activity {

@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (!LibsChecker.checkVitamioLibs(this)) {
return;
}

앞서 과정에서 소개한 각 코드는 다음과 같다.

- R.java
package io.vov.vitamio;

public class R {
public static final class raw {
public static final int libarm = [마이패키지].R.raw.libarm;
}
}

- InitActivity.java
/*
 * Copyright (C) 2012 YIXIA.COM
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package [마이패키지].vitamio;

import io.vov.vitamio.Vitamio;
import android.app.Activity;
import android.app.ProgressDialog;
import android.content.Intent;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.view.WindowManager;

public class InitActivity extends Activity {
public static final String FROM_ME = "fromVitamioInitActivity";
public static final String EXTRA_MSG = "EXTRA_MSG";
public static final String EXTRA_FILE = "EXTRA_FILE";
private ProgressDialog mPD;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);

new AsyncTask<Object, Object, Object>() {
@Override
protected void onPreExecute() {
mPD = new ProgressDialog(InitActivity.this);
mPD.setCancelable(false);
mPD.setMessage("Initializing decoders...");
mPD.show();
}

@Override
protected Object doInBackground(Object... params) {

Vitamio.initialize(getApplicationContext());
uiHandler.sendEmptyMessage(0);
return null;
}
}.execute();
}

private Handler uiHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
mPD.dismiss();
Intent src = getIntent();
Intent i = new Intent();
i.setClassName(src.getStringExtra("package"),
src.getStringExtra("className"));
i.setData(src.getData());
i.putExtras(src);
i.putExtra(FROM_ME, true);
startActivity(i);

finish();
}
};
}

- LibsChecker.java
package [마이패키지].vitamio;

import android.app.Activity;
import android.content.Intent;
import io.vov.vitamio.Vitamio;

public final class LibsChecker {
public static final String FROM_ME = "fromVitamioInitActivity";

public static final boolean checkVitamioLibs(Activity ctx) {
if ((!Vitamio.isInitialized(ctx))
&& (!ctx.getIntent().getBooleanExtra("fromVitamioInitActivity",
false))) {
Intent i = new Intent();
i.setClassName(ctx.getPackageName(),
"com.scgs.vitamio.InitActivity");
i.putExtras(ctx.getIntent());
i.setData(ctx.getIntent().getData());
i.putExtra("package", ctx.getPackageName());
i.putExtra("className", ctx.getClass().getName());
ctx.startActivity(i);
ctx.finish();
return false;
}
return true;
}
}

미디어 플레이하기


가장 쉬운 방법은 VitamioBundle에 포함된 io.vov.vitamio.widget.VideoView 클래스를 사용하는 것이다. VitamioDemo에 포함된 VideoViewDemo 클래스에 VideoView의 사용 예제가 포함되어 있다.


또한, VideoView 소스 코드를 보면 io.vov.vitamio.MediaPlayer를 어떻게 사용하는지 알 수 있으므로, MediaPlayer를 직접 이용해서 자신에 맞는 플레이어 화면을 구현할 수도 있다.




Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 팔팔청춘 2013.03.21 10:05 신고  댓글주소  수정/삭제  댓글쓰기

    좋은 정보 감사드립니다!!

    • 최범균 madvirus 2013.03.25 09:55 신고  댓글주소  수정/삭제

      플레이어 기능 구현에 도움이 되셨으면 좋겠네요.

    • 정한욱 2015.10.11 21:54 신고  댓글주소  수정/삭제

      안녕하세요 Wowza 스트리밍 서버를 통해서 안드로이드 Videoview 로 RTSP URL 받아오려고 하는 초보 개발자 입니다. ㅠㅠ 영상을 보내는데 android 에서 Videoview로 받을때 아예 회색으로 다 깨져버리는 현상이 있는데 어떻게 처리해야하는지 정말로 도움 부탁드립니다. ㅠㅠ 꼭 부탁드리고 귀찮더라도 01094960568연락주시면 감사하겠습니다.

  2. 흐아아앜 2013.06.11 19:30 신고  댓글주소  수정/삭제  댓글쓰기

    demo 코드로 미디어플레이어에서 RTMP 재생을 해봤는데 안되네요

    다른방법을 써야하나요?

  3. 고맙습니다 2013.07.14 16:47 신고  댓글주소  수정/삭제  댓글쓰기

    어떻게 구현할지 막막했는데 좋은 정보 감사합니다!!!
    fms에 있는 동영상을 앱으로 스트리밍 재생하는 연습을 해볼건데 이 라이브러리로 가능한거겠죠??

  4. 지젝 2013.12.09 19:22 신고  댓글주소  수정/삭제  댓글쓰기

    잘 돌아가네요 ^^ 감사합니다.

  5. wind~~~ 2013.12.11 13:37 신고  댓글주소  수정/삭제  댓글쓰기

    감사합니다~
    덕분에 보틀넥 하나를 풀었네요...

    더불어...

    RTSP 서버에 붙을 때 vitamio Fatal signal 11 (SIGSEGV) 라는 에러가 발생할 수 있습니다. 이 때 재생이 안되죠.

    source file 중 MediaPlayerDemo_Video.java에서 mMediaPlayer.getMetadata();를 제거하면 데모를 돌릴 수 있습니다.
    참고 하세요....

    • 정한욱 2015.10.11 21:55 신고  댓글주소  수정/삭제

      안녕하세요 Wowza 스트리밍 서버를 통해서 안드로이드 Videoview 로 RTSP URL 받아오려고 하는 초보 개발자 입니다. ㅠㅠ 영상을 보내는데 android 에서 Videoview로 받을때 아예 회색으로 다 깨져버리는 현상이 있는데 어떻게 처리해야하는지 정말로 도움 부탁드립니다. ㅠㅠ 꼭 부탁드리고 귀찮더라도 01094960568연락주시면 감사하겠습니다.

  6. allday 2014.03.04 11:01 신고  댓글주소  수정/삭제  댓글쓰기

    좋은글 잘 보고 갑니다~

  7. 정한욱 2015.10.11 21:54 신고  댓글주소  수정/삭제  댓글쓰기

    안녕하세요 Wowza 스트리밍 서버를 통해서 안드로이드 Videoview 로 RTSP URL 받아오려고 하는 초보 개발자 입니다. ㅠㅠ 영상을 보내는데 android 에서 Videoview로 받을때 아예 회색으로 다 깨져버리는 현상이 있는데 어떻게 처리해야하는지 정말로 도움 부탁드립니다. ㅠㅠ 꼭 부탁드리고 귀찮더라도 01094960568연락주시면 감사하겠습니다.

  8. minsus 2016.01.07 14:26 신고  댓글주소  수정/삭제  댓글쓰기

    이거 유료 라이브러리죠?

  9. kc 2017.02.24 07:29 신고  댓글주소  수정/삭제  댓글쓰기

    글 잘봤습니다. 감사합니다.ㅋ

    저 하나 질문이 있는데요. vitamio Videoview 및 vitamio Mediaplayer를 다중으로 사용해서 여러 영상을 못 띄우나요?

필자는 요즘 안드로이드 코드를 만져보고 있다. 안드로이드를 공부하면서, 그리고 기존에 만들어 놓은 코드들을 보면서 그리고 책에 예제로 나와 있는 코드를 보면서 뭔가 정리하고 싶은 게 생겼는데, 그 부분은 바로 Handler의 사용과 관련된 부분이다. 특히 비동기로 서버에서 데이터를 읽어와 UI에 결과를 반영해주는 코드는 정말 최악인데, 이 때의 Handler 처리 과정을 정리해보면 다음과 같은 방식으로 이루어진다.




뭔가 복잡하다. 위 그림은 관련된 코드를 그림 하나로 표현한 것이어서 그나마 덜 복잡해 보이고는거고, 실제 코드에서는 handleMessage() 안에 상단히 많은 case 문이 위치하고, handler 코드와 someMethod() 사이에 많은 다른 코드들이 위치하게 된다. 그래서 버튼을 눌렀을 때 기능이 어떻게 동작하는지 확인하려면 코드를 이리 저리 탐색하면서 돌아다녀야 하고, 이는 코드 분석을 힘들게 만들어준다.


위 코드의 문제점들을 나열해보면 다음과 같은 것들이 있다.

  • 처리 결과를 Message를 이용해서 Handler에 전달할 때, 각 메시지를 구분하기 위해 정수값(message.what)을 사용한다. 
  • 비동기 방식 동작 코드에 Handler가 전달되어 불필요한 의존이 발생한다.
  • 무엇보다도 응집도가 떨어져 분석을 어렵게 만든다.

Handler는 결과에 따라 다른 행동을 하기 위해서 int 값을 사용한다. 아래 코드는 전형적인 Handler의 handleMessage() 메서드의 구현 모습이다.


Handler handler = new Handler() {

    public void handlerMessage(Message msg) {

        switch(msg.what) {

            case 0: // <--- 0 의 의미는?

                // msg.obj 사용

                // UI 처리 코드

                break;

            case 1: // <-- 새로운 조건 추가될 때 마다 case 비교 구문 추가

                // UI 처리 코드

            ...

            case SOME_CONSTANT: // <-- 새로운 조건 추가될 때 마다 상수 추가하는 번거로움

            ....

        }

    }

}


위 case에서 0과 1은 무엇을 의미할까? 의미를 부여하기 위해 상수를 사용할 수도 있다. 하지만, 그런다고 문제가 끝나는 것은 아니다. 새로운 조건이 추가되면 그 때 마다 case가 하나 추가되고 상수이름을 생각해서 추가해 주어야 한다. 실수도 서로 다른 상수가 같은 값을 갖게 되면 디버깅 하느라 (헛)수고 좀 해야 한다.


또 다른 문제는 비동기 처리를 위해 Handler 객체가 이곳 저곳으로 전달된다는 것이다. 예를 들어, HTTP 요청을 비동기로 보내고 그 결과를 받기 위해 HTTP 요청을 처리하는 객체에 Handler를 전달하고 (앞서 그림에서 과정1), 응답이 도착하면 Handler에 메시지를 보내는 방법으로 응답 결과를 UI에 반영한다. (앞서 그림에서 과정4와 과정5) HTTP 요청을 처리하는 객체는 사실 Handler와는 전혀 상관이 없는 객체이다. 그런데, 비동기로 들어오는 응답을 UI에 반영하기 위해 부득이 Handler 객체에 대한 의존이 발생하는 것이다.


Handler 객체가 날아다니기 시작할 때의 또 다른 문제점은 실제 handler.sendMessage()를 수행하는 Handler가 어떤 Handler인지 찾아다녀야 한다는 점이다. 예를 들어, HTTP 요청 처리 객체를 여러 액티비티에서 호출한다고 해 보자. 이 때, HTTP 요청 처리 객체 내부적으로 사용되는 handler가 어느 액티비티 객체의 멤버인지 확인하려면 코드를 탐색해야 하는 (헛)수고를 하게 된다.


낮아지는 응집도(cohesion)!


결국 앞에서 Handler의 사용으로 인해 발생하는 문제점은 코드의 응집도를 확 낮춰준다는 것이다. 이 말은 관련된 코드가 이곳 저곳으로 퍼진다는 것을 뜻하는데, 예를 들어, 뭔가 버튼 클릭시 서버에서 데이터를 읽어와 UI에 반영하는 코드는 다음과 같이 퍼지게 된다.

  • 버튼 클릭시 호출되는 메서드: HTTP 요청 처리 객체에 handler를 주고 비동기로 처리를 요청한다.
  • HTTP 요청 객체: 응답 처리 결과를 handler를 통해 전달한다.
  • handler의 handlerMessage() 메서드: case 문으로 메시지를 확인하고, 응답 처리 결과를 이용해서 UI를 조작한다.

뭔가 하나의 처리 흐름을 이해하기 위해 관련된 코드들이 위와 같이 세 곳에 퍼지게 된다. 이는 코드를 산만하게 이곳 저곳에 배치시켜서 가독성을 바닥으로 확 떨어뜨리고 유지보수 하기 어렵게 만들어준다. 게다가 Handler가 각각의 메시지를 구분할 수 있도록 나름대로 상수나 코드 값을 불필요하게 정의해 주어야 한다. (이런 거 은근 머리 아프다.)


커맨드와 콜백을 활용한 해결


앞에서 비동기 처리 상황에서 Handler를 사용하기 위해 발생했던 응집도 저하 문제를 어떻게 해결하면 좋을까 곰곰히 생각하다가, 다음의 두 가지를 생각해봤다.

  • 콜백
  • 커맨드

두 가지가 앞의 문제들을 어떻게 없애주는지 살펴보자.


1단계: 콜백 전달해서 호출해 달라고 요청하기


Handler가 이곳 저곳으로 전달되는 이유는 비동기로 처리한 결과를 UI로 받아야 하기 때문이다. 하지만, Handler가 다른 객체에 파라미터로 전달되는 순간부터 한 기능과 관련된 코드가 이곳 저곳으로 퍼지게 되고 이는 심각한 응집도 저하를 불러 일으킨다. 이를 방지하기 위한 한 가지 방법이 콜백을 사용하는 것이다. 우선, 다음과 같이 간단한 콜백 인터페이스를 만든다.


public interface AsyncCallBack<T> {

    public void call(T result);

}


이제 뭔가 비동기로 데이터를 읽어오는 코드가 위 콜백을 통해서 결과를 전달하도록 변경해 보자. 예를 들면, 아래와 같이 코드가 만들어질 것이다.


public class AsyncJob {

    public void loadSomeData(final AsyncCallBack<SomeData> callback) {

        new Thread() {

            // 데이터를 외부에서 로딩

           SomeData data = ....;

           callback.call(data);

        }.start();

    }

}


버튼이 클릭되었을 때 위 기능을 사용해야 한다면, 이제 Handler를 전달하는 대신 아래와 같이 콜백 객체를 전달하도록 코드가 바뀐다.


public void onClick(View v) {

    asyncJob.loadSomeData(

        new AsyncCallBack<SomeData> () {

            public void call(SomeData result) {

                // 여기서 뭔가 handler에 결과 전달함

                Message msg = handler.obtainMessage();

                msg.what = 0;

                msg.obj = result;

                handler.sendMessage(msg); // TODO: 아직도 데이터 로딩 후 UI 조작 코드가 분리됨 

            }

        }

    );

}


handler = new Handler() {

    public void handleMessage(Message msg) {

        switch(msg.what) {

            case 0:

                SomeData someData = (SomeData) msg.obj;

                // UI 조작

        }

    }

}


위와 같이 바꾸면, 이제 비동기 작업을 수행하는 코드에 Handler 객체를 전달할 필요가 없어진다. 위 코드의 경우 AsyncJob.loadSomeData() 메서드는 비동기로 데이터를 읽어오면 파라미터로 전달된 콜백 메서드를 호출한다. 즉, 콜백 메서드에서 Handler에 보낼 메시지를 생성하고 전달하게 되는 것이다.


하지만, 아직도 데이터를 읽어와서 UI를 변경한다는 하나의 작업과 관련된 코드가 onClick() 메서드와 Handler의 handleMessage() 메서드에 분리되어 있다. 이는 커맨드를 이용해서 해결할 수 있다.


2단계: 커맨드 패턴을 활용해서 Handler 처리 코드에서 what 제거하기


두 번째 과정은 커맨드를 사용하는 것이다. 우선 다음과 같은 간단한 커맨드 인터페이스를 정의한다.


public interface Command {

    void execute();

}


그리고, 다음과 같이 위 Command 만을 전문적으로 처리하는 CommandHandler 클래스를 정의한다.


public class CommandHandler {

    private Handler handler = new Handler() {

        public void handleMessage(Message msg) {

            if (msg.obj instanceof Command) {

                ((Command) msg.obj).execute();

            }

        }

    }

    public void send(Command command) {

        Message message = handler.obtainMessage();

        message.obj = command;

        handler.sendMessage(message);

    }

}


CommandHandler의 handler는 switch 문이 없다. 단지 Message를 통해서 전달받은 Command 객체의 execute() 메서드를 호출하는 것만 한다. CommandHandler의 send() 메서드는 Message에 Command 객체를 담아 handler에 전송한다.


이제 Handler 대신 CommandHandler를 사용하도록 코드를 변경해 보자. 


// 어떤 작업

final SomeData data = getSomeData();


// Command 전송

commandHandler.send(new Command() {

    public void execute() {

        // UI 변경

        changeUI(data);

    }

});


Handler를 사용하는 코드와 비교해 보자. 위와 같이 함으로써 다음과 같은 변화가 발생했다.

  • Handler.handleMessage() 메서드의 switch 문에서 사용할 상수 값 불필요
  • 작업 결과물을 UI에 반영하는 코드가 한 곳으로 모임
    • Handler를 사용하는 경우, Handler에 메시지를 보내는 코드와 Handler의 handleMessage() 메서드에서 분리되서 들어감
즉, 개발자는 Handler가 메시지를 구분하기 위해서 사용할 상수값(앞서 봤던 0, 1, SOME_CONSTANT 등)을 고민해서 만들 필요가 없고, 그 상수값을 맞출 필요가 없다. 그냥 알맞은 Command 구현 객체만 만들어서 전달해주면 된다.

그리고 응집도가 높아졌다. Handler를 사용하게 되면 데이터를 읽어오는 코드와 읽어온 데이터를 사용하는 코드가 분리되어 코드의 응집도가 떨어지고 이로 인해 코드 분석이나 변경이 어렵게 되는데, 위의 경우는 한 곳에 몰려 있어서 좀 더 빠르게 코드를 분석하고 이해할 수 있게 된다.

두 가지를 합친 결과 코드

두 가지를 합치면 앞서의 onClick() 메서드가 다음과 같이 바뀐다.

public void onClick(View v) {

    asyncJob.loadSomeData( // 1. 데이터 로딩 실행

        new AsyncCallBack<SomeData> () { 

            public void call(final SomeData result) { // 2. 로딩된 데이터 수신

                commandHandler.send(

                    new Command() {

                        public void execute() {

                            changeUI(result); // 3. 수신한 데이터를 이용 UI 변경

                        }

                    }

                );

            }

        }

    );

}


최초에 봤던 코드와 비교해 보자. 앞서 Handler를 전달하는 방식의 코드에서는 데이터가 처리되는 과정을 살펴보려면 이벤트 처리 코드(onClick과 같은), 비동기로 데이터 읽어오는 코드, 그리고 Handler 코드를 살펴봐야 했다. 또한, 그 과정에서 Handler가 각각의 메시지를 식별할 수 있도록 상수도 정의해 주어야 했다.

이랬던 것들이, 콜백과 커맨드를 사용함으로써 위 코드와 같이 응집도를 확 높일 수 있게 되었다. 관련 코드가 한 곳에 모여 있기 때문에 이제 데이터의 처리 과정을 확인하기 위해 이곳 저곳 흩어져 있는 코드를 살펴볼 필요가 없어 졌고, 메시지 구분 식별값 등 불필요한 고민을 하지 않아도 된다. 물론, 임의 객체 사용으로 인해 코드가 약간 복잡해 보이지만, 코드의 응집도가 낮아져서 코드 분석을 어렵게 하는 비용과 비교해보면 약간의 복잡한 코드가 비용이 (훨씬~~~) 작다.

자바에 클로저만 있었어도 더 간결한 코드를 얻을 수 있겠지만 지금은 이 정도만으로도 만족할 만한 코드 결과물을 얻을 수 있게 되었다.


관련자료



Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 하늘섬 2012.09.14 09:44 신고  댓글주소  수정/삭제  댓글쓰기

    이런한 경우에능 안드로이드 프래임웍에서 제공하는 AsyncTask를 사용하시면 매우 편리합니다.

  2. bluepoet 2013.03.19 19:54 신고  댓글주소  수정/삭제  댓글쓰기

    이번 스타리그 앱의 쓰레드작업 부분을 이걸로 적용해봐야겠네요.

    무엇보다 코드 가독성과 응집도가 높아진게 맘에 듭니다.

    좋은글 잘 읽었습니다^^

  3. 응집도 2013.04.23 12:28 신고  댓글주소  수정/삭제  댓글쓰기

    결합도를 낮추고 응집도를 높히는 작업을 하신듯 하네요~^^

동영상 스트리밍을 위해 최초에 선택한 방법은 다음과 같았다.

  • PC 웹 브라우저: RTMP 프로토콜 이용 스트리밍/플래시 플레이어 이용.
  • 안드로이드: RTSP 프로토콜 이용 스트리밍, 안드로이드 MediaPlayer 이용.

미디어 서버로는 Wowza를 선택했는데, 그 이유는 위 두 가지 프로토콜을 모두 지원하기 때문이었다. 


안드로이드와 RTSP의 나쁜 궁합


그런데, 기능 구현을 진행하다보니 안드로이드에서 다음의 문제점들이 드러났다.

  • 스트리밍 품질
  • seeking 기능의 문제
우선, 스트리밍 자체의 품질이 좋지 않았다. RTSP 자체가 데이터 송수신에 UDP를 사용하는 것에서 비롯되는 것도 있겠지만, 안드로이드 2.2 기반 폰, 3.2 기반 태블릿, 4.0 기반 폰, 4.1 기반 넥서스 7에서 화면이 일부 깨지거나 하는 등의 현상이 발생했다.

특히 문제되는 부분은 시간바의 이동, 즉 seeking 기능에 있었다. seek bar를 이동하는 동안 플레이어가 해당 시점으로 이동하고 버퍼링을 하는 등의 작업을 하는데, RTSP의 경우 시간 이동과 버퍼링 등이 신속하게/원활하게 동작하지 않는 경우가 많았다. 특히, 허니콤 기반의 갤럭시 탭은 시간 이동이 안 될 정도로 문제가 심각했다. (갤럭시 탭을 아이스크림으로 업그레이드 해야 그나마 seek bar 이동이 동작했다.)

안드로이드의 MediaPlayer와 RTSP와의 궁합이 그닥 좋지 않다는 점, 특히 RTSP를 사용할 때 seeking이 부드럽게 되지 않는다는 점 때문에 스트리밍 방식을 교체하기로 결정했다.

HTTP 기반 스트리밍으로의 전환

여러 방법을 고민하다가 HTTP 기반의 스트리밍으로 처리하기로 결정했다. 필요한 건 다음과 같은 것들이다.
  • HTTP range 헤더를 지원하는 웹 서버. 아파치 httpd는 당연히 지원하므로, 아파치 웹 서버를 사용 (seeking과 관련)
  • 힌팅(hinting)된 MP4 파일 (비디오는 H.264, 오디오는 AAC로 인코딩 된 버전)
안드로이드의 MediaPlayer는 힌팅된 MP4 파일을 HTTP 기반으로 스트리밍으로 플레이 할 수 있는 기능을 제공하고 있다. 따라서, 아파치 웹 서버의 문서 디렉토리에 MP4 파일을 업로드 하고, MediaPlayer의 데이터 소스로 다음과 같이 MP4 파일에 대한 URL을 지정하면 해당 MP4를 플레이할 수 있게 된다.

path = "http://mediaserver/starwords_1.mp4";
mediaPlayer = new MediaPlayer();
mediaPlayer.setDataSource(path);
...

MediaPlayer의 Seeking 기능을 사용할 경우 HTTP의 range 헤더를 이용해서 원하는 위치로 빠르게 이동할 수 있게 된다. 즉, 프로그레시브 다운로드Progressive Download 방식의 비디오 플레이와 달리 원하는 위치로 빠르게 이동할 수 있게 된다.

안드로이드 앱에서 동영상 미디어를 스트리밍으로 플레이하는 기능이 필요하다면, 현재 수준에서는 폼질/안정성 측면을 고려한다면 여러모로 불안한 RTSP 보다는 HTTP 기반 스트리밍을 사용하는 것이 현명할 것이다.

참고:


Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 2012.10.31 21:04  댓글주소  수정/삭제  댓글쓰기

    비밀댓글입니다

필자는 Maven을 너무 좋아하기에 안드로이드 개발도 Maven 프로젝트로 관리하고 싶었다. 그래서 구글링을 좀 했고, 영어로 된 걸 매번 보고 싶지 않아 나중을 위해 한글로 정리해둔다.


M2E를 위한 Android Connector 설치


먼저 할 일은 이클립스에 설치한 Maven 플러그인과 ADT를 연결하는 위해 Android Connector를 설치하는 것이다. 설치를 하려면 Preferences > Android/Discovery > Open Catalog 메뉴를 실행한 뒤, 아래 그림이 나올 때 android로 검색하면 된다.



검색 결과로 나온 Android Connector를 선택한 뒤 설치하자.


Android용 Archetype 추가 (옵션)


Android Connector를 설치 후, pom.xml 파일에 maven-android-plugin을 설정하면 해당 프로젝트를 ADT와 연동해 준다. 하지만, pom.xml 파일을 처음부터 만들면 (다소) 귀찮을 수 있는데, 그걸 대신 해주는 archetype을 추가해주면 좀 더 편하게 pom.xml 파일을 생성할 수 있다.


Archetype 타입을 추가해주는 방법은 간단하다. New > Maven Project > Next > Select an Archetype 화면에서 [Add Archetype...] 버튼을 클릭한다. 그런 다음 아래와 같이 정보를 입력하고 [OK] 버튼을 클릭하면 해당 Archetype이 추가된다.


* Group Id: de.akquinet.android.archetypes

* Artifact Id: android-quickstart

* Version: 1.0.8


안드로이드 프로젝트 생성하기


안드로이드 프로젝트를 생성하는 방법은 간단하다. 앞서 생성한 android-quickstart Archetype을 이용해서 안드로이드 프로젝트를 생성하면 된다. android-quickstart Archetype을 선택하면 아래 그림과 같은 화면이 나온다. platform의 값에 사용할 안드로이드 플랫폼 버전을 입력해주면 된다.



프로젝트를 생성하면 잠시 후 아래 그림과 같이 Maven 프로젝트가 ADT와 연동된 것을 확인할 수 있다. 아래 그림을 보면 자원 관리를 위한 res 폴더, 자동 생성되는 파일을 위한 gen 폴더 등이 생성된 것을 확인할 수 있다.



생성된 pom.xml 파일을 maven-android-plugin 설정 및 플랫폼 버전 정보 등이 포함된 것을 확인할 수 있다.


<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">

    <modelVersion>4.0.0</modelVersion>

    <groupId>com.madvirus</groupId>

    <artifactId>NetworkExplorer</artifactId>

    <version>0.0.1-SNAPSHOT</version>

    <packaging>apk</packaging>

    <name>NetworkExplorer</name>


    <properties>

        <platform.version>2.3.2</platform.version>

    </properties>


    <dependencies>

        <dependency>

            <groupId>com.google.android</groupId>

            <artifactId>android</artifactId>

            <version>${platform.version}</version>

            <scope>provided</scope>

        </dependency>

    </dependencies>


    <build>

        <plugins>

            <plugin>

                <groupId>com.jayway.maven.plugins.android.generation2</groupId>

                <artifactId>android-maven-plugin</artifactId>

                <version>3.1.1</version>

                <configuration>

                    <androidManifestFile>${project.basedir}/AndroidManifest.xml</androidManifestFile>

                    <assetsDirectory>${project.basedir}/assets</assetsDirectory>

                    <resourceDirectory>${project.basedir}/res</resourceDirectory>

                    <nativeLibrariesDirectory>${project.basedir}/src/main/native</nativeLibrariesDirectory>

                    <sdk>

                        <platform>10</platform>

                    </sdk>

                    <undeployBeforeDeploy>true</undeployBeforeDeploy>

                </configuration>

                <extensions>true</extensions>

            </plugin>


            <plugin>

                <artifactId>maven-compiler-plugin</artifactId>

                <version>2.3.2</version>

                <configuration>

                    <source>1.6</source>

                    <target>1.6</target>

                </configuration>

            </plugin>

        </plugins>

    </build>

</project>




참고로, Android Connector와 android-maven-plugin인에 대한 보다 자세한 내용이 궁금하면 아래 사이트를 방문해서 확인하면 된다.


Posted by 최범균 madvirus

댓글을 달아 주세요

  1. 예감 2013.07.29 12:09 신고  댓글주소  수정/삭제  댓글쓰기

    Maven관련 자료 잘보고 갑니다^^