안드로이드에서의 비트맵과 메모리의 상관관계

marojun
marojun’s Android
34 min readMar 16, 2014

사실 이 글을 처음 접했을땐 서문에 “API 버전에 따른 비트맵 사용시 메모리 관리의 차이”라는 부분을 읽고 안드로이드 2.3.3(API level 10)기준 전후 버전의 메모리 모델 변경에 따른 recycle() 필요 유무에 대한 이야기이겠거니 생각했습니다.

이러한 내용은 구글 개발자 사이트의 Managing Bitmap Memory 에 설명되어 있는 부분이라 큰 기대를 하지 않았던게 사실이었고 좀 더 자세한 내용은 NHN 개발자 블로그의 Android 앱 메모리 최적화 글을 통해서도 충분히 알 수 있는 내용이었습니다. 이에 이전에 알고있던 비트맵 사용시 이를 최적화하는 여러가지 기법들을 통해 메모리 관리는 충분하다 생각했습니다만 글을 끝까지 읽은 후, 이러한 작업만으로는 사용하지 않는 비트맵 메모리를 완벽히 반환하지 못한다는 사실을 깨닫게되어 이번 포스트를 작성하게 되었습니다.

이 글을 통해 OS별(특히 4.x ~ ) 메모리 관리시 조금이나마 도움이 되셨으면 합니다. 이외에도 메모리 분석 툴 사용법에 대해서도 상세히 설명되어 있으니 이래저래 도움이 되실꺼라 생각합니다.

참고로 원문의 경우 작성자가 에뮬레이터로 테스트를 진행 하였기에 신뢰성을 위해 실제 디바이스 (4.3 갤럭시 S4, 4.4 넥서스5)를 통해 직접 테스트를 진행 하였습니다. 덕분에 고생좀 했지만 테스트시 혹시라도 저와 같은 상황을 겪으실 분들이 더이상 없을꺼라 생각하니 마음이 한결 가볍습니다. 이에 대한 내용은 글의 말미에서 확인하실 수 있습니다. 또한 좀 더 쉽게 이해하실 수 있도록 설명 및 이미지를 보충하였습니다. 흥미로운 내용이니 끝까지 재미있게 읽어주셨으면 합니다. ☺

몇달 전 비트맵 메모리 이슈를 해결하는 방법에 대해 작성한 적이 있는데 최근 동료가 그 글을 읽고 그 중 어떤 코드에 대해 질문을 했다. 그 당시에는 심사숙고해서 작성 했었지만, 정확히 왜 그렇게 썻는지에 대한 기억이 나지 않아 구글링을 결정, 이것저것 보면서 대충 기억을 되찾았으나 명쾌하지않아 뭔가 찝찝했다.

사실 이런 구글링은 내 스타일이 아니라 직접 테스트 앱을 만들어 메모리를 분석하는 작업을 진행하였다. 테스트 결과는 예상밖이었다. API 버전에 따라 결과가 달라지는 매우 흥미로운 현상을 발견한 것이다.

이것이야말로 블로그에 포스트할 좋은 기회가 아닌가 생각했다. 이에 여기에서 간단한 리뷰와 비트맵 사용에 따른 메모리 관리 그리고 메모리 분석에 대한 예제들을 통해 해당 내용을 살펴보도록 하겠다.

Memory Analysis

안드로이드에 대해 충분한 시간을 들였다면 아래 에러를 본 경험이 있을 것이다.

java.lang.OutofMemoryError: bitmap size exceeds VM budget.

비트맵 메모리 이슈에 대한 분석을 위한 최고의 방법은 힙 덤프와 메모리 분석툴을 사용하는 것이다. 자세한 내용은 다음링크를 참조하자. — http://android-developers.blogspot.com/2011/03/memory-analysis-for-android.html

이제 어떻게 개발자 블로그에 언급된 HPROF(Heap Profiling Tool) 덤프와 Eclipse Memory Analyzer(MAT) 툴을 사용하는지 알아볼 것이다.

Loading Large Bitmaps Efficiently?

개발자가 실수하는 것중에 한가지는 이미지 크기만큼 메모리 소모량을 가정할 때이다. JPEG나 PNG는 압축포맷이기 때문에 비트맵으로 디코딩될때 파일의 사이즈보다 더 많은 메모리를 사용하게 된다.

압축되지 않은 이미지의 용량은 픽셀의 갯수 * bytes 이다.

그렇다면 1920 x 1080 픽셀이면서 32 bit인 이미지가 있다고 생각해보자.

이 경우 1920 px * 1080 px * 4 bytes 으로 수렴되므로 약 8메가 정도된다고 생각하면 된다. (정확히는 8,294,400 bytes 이다.)

8 bit 는 1 byte 다. 즉, 32 bit 는 4 bytes 가 된다.

이것이 JPEG 파일일 때, 일반적으로 200KB ~ 1MB 가 된다.

http://developer.android.com/training/building-graphics.html

안드로이드 디벨로퍼 사이트에 비트맵과 메모리를 관리에 대한 내용이 있다. 이중 하나의 섹션은 큰 크기의 비트맵을 효과적으로 로딩하는 방법에 대해 명시되어있다. 기본적으로 이미지가 이미지뷰보다 많이 크다면 (두 배 이상) 이미지의 퀄리티를 낮추지않고 다운샘플링하여 메모리를 절약할 수 있다.

해당글의 내용은 훌륭하며 copy and paste를 통해 사용할 수 있는 코드도 제공되지만 미심쩍은 부분도 존재한다. 사실 나는 얼마나 많은 메모리를 실제로 절약할 수 있는지를 보고싶었다. 또한 이것이 올바른 방법인지도 알고 싶었다. 그래서 메모리 분석툴을 사용하여 테스트를 진행하게 되었다.

Simple Image Load vs Sampled Image Load

비교를 위해 같은 이미지를 540 x 960 픽셀의 이미지뷰에 넣었다. 여기서 사용한 이미지는 요즘 스마트폰의 평균 스펙인 800만 화소 렌즈를 통해 촬영한 4.4 MB 2160x3840 JPG 파일이다.

원본 이미지를 로딩하는 경우는 일반적인 방법을 사용하였고 샘플링하는 이미지는 구글 개발자 페이지에 있는 코드를 조금 변경해서 사용하였다.

테스트앱의 소드는 아래 링크를 통해 확인할 수 있다.

https://github.com/tigerpenguin/BitmapMemory-part1

이에 대한 결과는 다음과 같다:

Simple Image Load

비트맵을 로드하는데 31.6 MB의 메모리가 필요했다. 여기서 위의 공식을 통해 32 bit 2160x3840 크기의 이미지를 사용될것이라고 추측한 2160px * 3840px * 4 bytes = 33,177,600 bytes의 용량과 꽤 비슷한 것을 볼 수 있다.

Sampled Image Load

샘플링을 진행한 경우 비트맵이 로드되는데 2 MB의 메모리만 사용되었다. 이때 샘플 사이즈는 4로 정의하였으며 이에 따른 결과는 16배의 메모리 절감 효과를 가져온다.

(2160px / 4) * (3840px / 4 ) * 4 bytes = 2,073,600 bytes

Memory Analysis Walkthrough

여기서 보여지는 수치와 그래프를 어떻게 정리할 수 있었을까? 여기서부터는 HPROF 데이터을 어떻게 얻어냈고 메모리 분석툴을 어떻게 사용하였는지에 대해 알아보도록 하겠다.

만약 HPROF 의 덤프를 뜨는법과 MAT를 사용하는 법을 알고 있다면 이번 파트는 생략해도 좋다.

Step 1

먼저 메모리 스냅샷을 찍고싶은 상태를 만든다. 이에 메모리 분석을 위해 위 이미지가 보이는 액티비티로 이동한다.

Step 2
안드로이드 SDK 경로중 /tools 디렉토리에 있는 Monitor를 실행시키거나 IntelliJ의 경우 아래 메뉴를 선택한다.

개발 중 SDK 폴더에 있는 파일들을 사용하는 경우가 많습니다. 앞으로를 위해 환경 변수로 해당 폴더들을 지정하지 않았다면 이참에 설정하는것을 추천합니다.

맥에서 안드로이드 SDK 폴더 환경 변수로 지정하기 : http://hambughunter.blogspot.kr/2013/07/mac.html

Step 3

현재 디바이스는 잘 물려있는지 프로세스는 잘 돌아가고 있는지 확인하자. 이후 쓰레기통 모양의 가비지 콜렉션을 클릭한다.이것을 수행하는 이유는 우리가 현재 알아보고 있는 메모리 분석과 관련없이 힙 메모리에 나타나는 WeakReference 를 제거하기 위해서이다.

Step 4

HPROF 파일 아이콘을 클릭하자. 이후 파일을 저장한다. 만약 메모리 덤프의 크기가 크다면 좀 기다려야 할 것이다. 하단의 로그캣을 통해 메모리 덤프의 시작과 종료를 알려주는 메시지를 볼 수 있을것이다.

덤프 완료 메시지
덤프 파일 저장 다이얼로그 팝업

혹시 힙 덤프 작업이 완료 되었다는 메시지만 노출되고 해당 파일을 저장하는 다이얼로그 팝업이 뜨지 않을 경우 Monitor를 재구동하도록 한다. OS X 메버릭스에서 테스트하면서 해당 이슈가 발생하였는데 Monitor를 구동하면 단 한번만 다이얼로그가 뜨는 현상이 발생되었다.

현재 글을 정리하는 저의 환경도 매버릭스인데 동일한 버그가 발생했습니다.

Step 5
이제 안드로이드 HPROF 파일을 자바 HPROF 분석기가 이해할 수 있도록 변환하는 과정이 필요하다. 이 작업은 hprof-conv 툴을 이용하면되는데 해당 파일은 monitor 툴과 같은 폴더에 있다.

hprof-conv my_heap_dump.hprof(원본) converted_file.hprof(변환)

실제 변환 커맨드

Step 6
변환된 힙 덤프을 메모리 분석기에서 열어보자. 나는 Eclipse Memory Analyzer(MAT) 을 사용하였는데 이클립스를 설치하기 싫다면 http://www.eclipse.org/mat 에서 해당 분석 툴 파일만 다운받도록한다. MAT에서 힙 덤프 파일을 열면 MAT는 다른 파일들을 많이 생성해버릴 것이다. 이를 방지하기 위해 각각의 덤프 파일을 개별적으로 디렉토리에 넣어 관리하는것이 편리할 것이다.

MAT로 hprof파일을 열었을때 생성되는 파일들

Step 7

MAT 에서 변환된 힙 덤프를 연 후, Leak Suspect 레포트를 연다. 여기서 우리는 31.6 MB 의 사용된 메모리를 확인 할 수 있다.

Step 8

클릭하여 상세내역을 확인할 경우 SimpleImageLoadActivity에 있는 이미지뷰가 참조하는 비트맵 객체를 볼 수 있을 것이다.

Now what?

바로 위에서 Loading Large Bitmaps Efficiently 을 사용하여 원본 이미지와 샘플링된 이미지 로딩 사이의 사용되는 메모리 차이점을 알아보았다.

그렇다면 비트맵이 노출되고 있는 상태에서 다른 액티비티를 띄우면 어떻게 될까? 메모리는 반환이 될까? 만약 안된다면 어떻게 반환해야 할까? 여기서는 흥미로운 자료들을 통해 이 주제에 대해 이야기 하려한다.

여기서 이야기 하는 코드는 https://github.com/tigerpenguin/BitmapMemory-part2 에서 다운받을 수 있으니 여러가지 시나리오에 대해서 테스트 해보도록 하자. 나는 명확한 결과를 볼 수 있도록 샘플링 되지 않은 32 MB 이미지를 사용했다.

Quick Disclaimer

여기서 사용하는 테스트 앱은 순전히 분석용으로 단순한 기능만을 적용화여 제작한 것임을 밝히고 싶다. 다시 말하자면 현실 시나리오에는 100% 적용되지 않아도, 이 컨셉이 다양한 사용자 케이스에 적용될 수 있다는 것이다.

테스트 환경은 4.3 에뮬레이터에서 이루어지고 있으며 여기서 이런 이야기를 왜하는지는 추후 설명하도록 하겠다.

Simple Image Load

이 시나리오에는 특별할 것은 없다. 이미지가 SimpleImageLoadActivity에 로드 되고 이후 OtherActivity 가 그 위에 보여지게 된다. OtherActivity 가 보여질 때 힙 덤프가 생성된다. 아래 그림을 보면 알겠지만, SimpleImageLoadActivity에 32 MB 비트맵은 여전히 메모리에 상주하고 있다.

Simple Image Load Overview
Simple Image Load Detail

만약 좀 더 자세히 알고 싶다면 SimpleImageLoadActivity ->ImageView -> BitmapDrawable -> Bitmap을 확인해라.

Simple Image Release

디스플레이도 되지 않는 상태에서 32MB 비트맵을 갖고있는 것은 무의미하기 때문에, 보여지지 않을 때는 메모리를 반환시켜야 한다. 이것은 매우 간단하다. ImageView의 BitmapDrawable이 비트맵을 참조하고 있기 때문에, onStart() 에서 이미지를 셋팅하고, onStop()에서 이미지를 클리어한다. 그럼 아래와 같이 SimpleImageReleaseActivity를 만들어보자!

@Override
protected void onStart() {
super.onStart();
pictureView.setImageResource(R.drawable.dessert);
}
@Override
protected void onStop() {
super.onStop();
pictureView.setImageDrawable(null);
}

그리고 아래는 OtherActivity에서 가져온 메모리 스냅샷이다.

Simple Image Release Overview

안타깝게도, 비트맵은 여전히 SimpleImageReleaseActivity에서 참조되고 있다. 하지만, 메시지가 조금 변경되었다. GLES20DisplayList이 로딩 되었다고 나오고, 디테일 뷰에서 우리는 비트맵을 참조했던 BitmapDrawable가 더이상 없다는 것을 알 수 있다. 대신에, 비트맵은 이제 ImageView의 GLES20DisplayList 에서 참조되고 있다.

Bitmap Recycle

메모리가 반환되지 않았기 때문에 다른 방법을 찾아보도록 하였다. Bitmap.recycle()를 많이들 들어봤을 것이다. 메모리를 반환할 수 있는지만 확인하고 싶기 때문에, BitmapRecycleActivity에서 간단한 작업을 할 것이다.

@Override
protected void onStart() {
super.onStart();
pictureView.setImageResource(R.drawable.dessert);
}
@Override
protected void onStop() {
super.onStop();
Bitmap bitmap = ((BitmapDrawable) pictureView.getDrawable()).getBitmap();
pictureView.setImageDrawable(null);
bitmap.recycle();
}

결과는 다음과 같다:

Bitmap Recycle Overview
Bitmap Recycle Detail

마치 내가 잘못된 스크린샷을 넣은 것 처럼 보인다. 하지만 자세히 보면, 이 케이스의 액티비티명이 BitmapRecycleActivity라는 것을 알 수 있다. 내가 아직도 API 18 JB 4.3 에뮬레이터 이미지를 사용하고 있다는 것을 기억해라.

이것도 역시 잘 적용되지 않았다. 게다가, OtherActivity에서 백버튼을 누르면 크래쉬가 발생한다.

FATAL EXCEPTION: main
java.lang.IllegalArgumentException: Cannot draw recycled bitmaps
at android.view.GLES20Canvas.drawBitmap(GLES20Canvas.java:772)
at android.view.GLES20RecordingCanvas.drawBitmap(GLES20RecordingCanvas.java:105)
at android.graphics.drawable.BitmapDrawable.draw(BitmapDrawable.java:440)
at android.widget.ImageView.onDraw(ImageView.java:1025)

어쩌면, 접근법이 틀렸던 것 같다. 결국, Android Developer page에 따르면 API 10 GB 2.3.3나 그 이하에서는 Bitmap.recycle()를 사용하는 것을 추천한다. 아니면…?

Image Detach

뷰 구조에서 ImageView를 분리하고 제거하는 것은 어떨까? 한 번 ImageDetachActivity에서 시도해보자.

@Override
protected void onStart() {
super.onStart();
pictureView = new ImageView(this);
// set layout params
pictureView.setImageResource(R.drawable.dessert);
mainLayout.addView(pictureView);
}
@Override
protected void onStop() {
super.onStop();
mainLayout.removeView(pictureView);
pictureView = null;
}

이건 잘 되야겠지? 한 번 보자!

Image Detach Overview
Image Detach Detail

이거 이상해…GLES20DisplayList는 아직 있는데, 상세내용을 보면, 그것을 참조로 하는 Activity or ImageView가 없네…?

Hardware Off Release

GLES20DisplayList 가 여전히 비트맵을 잡고 있다는 사실이 나를 불편하게 했다. 간단한 구글링을 통해 이 일이 발생하는 원인을 알아보았다. — http://stackoverflow.com/questions/13754876/release-bitmaps-from-android-view-gles20displaylist

오~ 그렇단 말이지 하드웨어 가속을 끔으로써 버그를 해결해보자.

<activity android:hardwareAccelerated=”false”
android:name=”.HardwareOffReleaseActivity”
android:label=”Hardware Off Release Activity”/>
@Override
protected void onStart() {
super.onStart();
pictureView.setImageResource(R.drawable.dessert);
}
@Override
protected void onStop() {
super.onStop();
pictureView.setImageDrawable(null);
}

비트맵이 반환되었다!

Hardware Off Overview

하지만 만약 액티비에서 하드웨어 가속을 사용해야 한다면? 더 좋은 방법이 있을까? the Android Developer page를 검색해본 결과, 뷰 레벨마다 하드웨어 가속을 끌 수 있는것을 알게되었다. 하지만 미심적은 부분이 있어 직접 확인해보기했다.

특정 화면이나 뷰에 대한 하드웨어 가속 컨트롤 방법

http://developer.android.com/intl/ko/guide/topics/graphics/hardware-accel.html#controlling

View-level Hardware Off Release

<activity android:name=”.ViewHardwareOffReleaseActivity”
android:label=”View Hardware Off Release Activity”/>
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
pictureView.setLayerType(View.LAYER_TYPE_SOFTWARE, null);
}
@Override
protected void onStart() {
super.onStart();
pictureView.setImageResource(R.drawable.dessert);
}
@Override
protected void onStop() {
super.onStop();
pictureView.setImageDrawable(null);
}

다행히도, 이 작업도 잘 작동되는 것으로 보인다.

View-level Hardware Off Overview

하지만 정말 잘 된걸까? 하드웨어 가속을 끄는 것 외에 다른 방법은 없을까?

내가 API 18 JB 4.3 에뮬레이터 이미지를 사용한다는 것을 기억하는가? Romaine Guy 에 의해 언급된 버그로 인해 각각의 API 버전에서 다른 결과를 보인다는 것을 알 수 있다.

https://android.googlesource.com/platform/frameworks/base/+/034de6b1ec561797a2422314e6ef03e3cd3e08e0

하지만 나를 가장 걸리게 했던 것은, 각 JellyBean 버전마저도 다른 결과를 보였다는 것이다! B 4.1, JB 4.2, 그리고 JB 4.3 모두 다르게 행동한다. 어떻게 다를까?

위글에서 ImageView의 비트맵 메모리를 반환하는 몇 가지 방법에 대해 알아봤다. 하지만, API 18 JB 4.3 에뮬레이터에서는 하드웨어 가속기를 끄지 않는 이상 작업이 불가했다. 다른 API 버전들을 시도해본 결과, 행동이 다르다는 것을 알 수 있었다. 심지어 각 JellyBean 버전도 달랐다.

이제부터는 API 15 ICS 4.0.3 에서 API 19 KK 4.4 까지 각각의 API 버전에서의 케이스를 알아볼 것이다.

Simple Image Load

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_picture);
pictureView = (ImageView) findViewById(R.id.pictureView);
pictureView.setImageResource(R.drawable.dessert);
pictureView.setOnClickListener(new PictureClickListener());
}

SimpleImageLoadActivity에서 OtherActivity로 가는 케이스의 결과는 모두 같았다. SimpleImageLoadActivity는 OtherActivity가 보여질 때 32MB 비트맵 메모리를 유지하고 있었다.

Simple Image Release

@Override
protected void onStop() {
super.onStop();
pictureView.setImageDrawable(null);
}

API 15 ICS 4.0.3 ~ API 18 JB 4.3 에서는 비트맵 메모리가 반환되지 않았지만, API 19 KK 4.4에서는 비트맵이 반환 되었다.

Bitmap Recycle

@Override
protected void onStop() {
super.onStop();
Bitmap bitmap = ((BitmapDrawable) pictureView.getDrawable()).getBitmap();
bitmap.recycle();
pictureView.setImageDrawable(null);
}

API 15 ICS 4.0.3 와 API 16 JB 4.1에서는 비트맵이 반환되었다. API 17 JB 4.2 and API 18 JB 4.3에서는 비트맵이 반환되지 않았다. API 18 KK 4.4에서는 반환 되었다.

Image Detach

@Override
protected void onStop() {
super.onStop();
mainLayout.removeView(pictureView);
pictureView = null;
}

API 15 ICS 4.0.3, API 16 JB 4.1, API 17 JB 4.2, and API 19 KK 4.4에서 비트맵이 반환되었다. 하지만 API 18 JB 4.3에서 ImageView을 분리해도 비트맵이 반환되지 않았다.

Hardware Off Release / View Hardware Off Release

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
pictureView.setLayerType(View.LAYER_TYPE_SOFTWARE, null);
}
@Override
protected void onStart() {
super.onStart();
pictureView.setImageResource(R.drawable.dessert);
}
@Override
protected void onStop() {
super.onStop();
pictureView.setImageDrawable(null);
}

액티비티 또는 뷰에 대해서 하드웨어 가속을 끄면, 간단한 pictureView.setImageDrawable(null) 호출을 통해 모든 케이스에 대해서 비트맵 메모리가 반환되었다.

Summary

이것이 비트맵 메모리를 반환하는 것에 대해 인터넷에 모순되는 답들이 있는 이유를 설명한다. 어떤 때는 Drawable을 null로 설정하는 것이 비트맵을 반환하지만, 또 어떤 때는 비트맵이 리사이클 되어야 한다. 하지만 또 어떤 때는 ImageView를 분리해야 하지만, 이것이 항상 적용되는 것도 아니다. 이것은 모두 API 레벨에 의존한다.

Solution?

그럼 가장 좋은 방법은 무엇일까? 이부분은 앱에서 하드웨어 가속이 얼마나 필요한지에 달려있다. 2D 렌더링에서 하드웨어 가속을 쓰는 것은 퍼포먼스를 향상시킬 수 있다. 특히, View 구조가 복잡하고 애니메이션이나 투명도를 적용할 때 말이다. 한편, 만약 정적인 이미지를 보여주려 한다면, 이것은 필요 없을 것이다. 하드웨어 가속에 대해 더 많은 정보는 official Android developer documentation 을 참조하자.

Option 1. No Hardware Acceleration

하드웨어 가속이 필요 없다면, 매우 직관적인 방법이 있다. 먼저, 하드웨어 가속을 Activity or the ImageView 별로 끈다. 그리고는 Drawable을 null로 메모리를 반환시킨다.

하드웨어 가속은 AndroidManifest.xml의 액티비티에서 끌 수 있다:

<activity android:hardwareAccelerated=”false”
android:name=”.HardwareOffReleaseActivity”
android:label=”Hardware Off Release Activity”/>

아니면, 코드의 ImageView 에서 끌 수 있다. 명심해야 할 것은, 이미지가 세팅되기 전에 하드웨어 가속을 꺼야한다는 것이다.

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
pictureView.setLayerType(View.LAYER_TYPE_SOFTWARE, null);
}

그리고는 onStart() 에서 이미지를 로딩하고 onStop()에서 이미지를 클리어하면 된다.

@Override
protected void onStart() {
super.onStart();
pictureView.setImageResource(R.drawable.dessert);
}
@Override
protected void onStop() {
super.onStop();
pictureView.setImageDrawable(null);
}

Option 2. Maximize Hardware Acceleration

하드웨어 가속을 최대화 해야 한다면, ImageView를 동적으로 붙이거나 분리시키면 된다. 하지만, API 18 JB 4.3에서는 잘 드는 방법이 없었기 때문에, 여전히 하드웨어 가속을 꺼야한다.

@Override
protected void onStart() {
super.onStart();
pictureView = new ImageView(this);
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.JELLY_BEAN_MR2) {
// must do this before setting the image
pictureView.setLayerType(View.LAYER_TYPE_SOFTWARE, null);
}
// set layout params
pictureView.setImageResource(R.drawable.dessert);
mainLayout.addView(pictureView);
pictureView.setOnClickListener(new PictureClickListener());
}
@Override
protected void onStop() {
super.onStop();
mainLayout.removeView(pictureView);
pictureView = null;
}

Option 3. Selectively Enable Hardware Acceleration

대신, 하드웨어 가속을 기본적으로 켜놓는것이 좋겠지만 해당 퍼포먼스게 서비스에 큰 비중을 차지하고 있는게 아니라면, API 19+ 이상일 경우에는 enable 시키고 이전 버전에서는 disable 시키면 된다. 이 방법을 쓰면, ImageView를 동적으로 붙이거나 분리시키는 작업에 대해 걱정할 필요가 없다.

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_selective_hardware);
pictureView = (ImageView) findViewById(R.id.pictureView);
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) {
pictureView.setLayerType(View.LAYER_TYPE_SOFTWARE, null);
}
pictureView.setOnClickListener(new PictureClickListener());
}
@Override
protected void onStart() {
super.onStart();
pictureView.setImageResource(R.drawable.dessert);
}
@Override
protected void onStop() {
super.onStop();
pictureView.setImageDrawable(null);
}

Sample App

이에 대한 샘플앱은 다음링크를 참조하자.

https://github.com/tigerpenguin/BitmapMemory-part3

Closing Remarks

ImageView가 비트맵 메모리를 갖고있는 것과 이것을 반환시키는 것에 대해 보여줬지만, 이것은 모든 ImageView에 대해 이러한 작업을 해야한다는 것은 뜻하지 않는다. 어쨌든, 만약 32 MB bitmap을 로딩한다면, 먼저 샘플링 하고 메모리 사용량을 최적화하는 것이 좋다. 추가적으로, 일반적인 경우에는, 메모리가 쌓일경우 액티비티가 종료될 수 있다.

즉, 앱이 비트맵 메모리를 계속 보유함에 따라 OutOfMemoryError를 일으키는 비슷한 경우가 일어날 수 있다. 이런 경우에는, 위에 공유한 테이블과 각종 옵션들, 그리고 분석 결과들을 통해 가장 적절한 해결방안을 적용하도록 한다.

결과적으로, 이글은 가이드일 뿐이고, 본인 스스로가 OutOfMemoryError의 근본 원인에 대한 가장 좋은 해결 방안을 결정해야 할 것이다. 프로요나 진저브레드를 아직도 지원해야하는 불쌍한 영혼들이 있다면, 이것이 적어도 조금의 도움이 되었길 바란다.

재미있게 읽으셨나요? 제가 실 단말을 통해 별도로 진행한 테스트 또한 위 결과와 동일하여 따로 결과는 정리하지 않았습니다. 그러나 테스트 중 발생했던 이슈 및 궁금했던 사항들을 공유합니다.

1. 에뮬레이터가 아닌 실제 단말에서도 동일현상이 발생하는가?

2. GLES20DisplayList 이 뭐길래 계속 비트맵을 참조하는가?

3. GLES20DisplayList이 비트맵을 참조하고 있다고 하지만 금방 메모리를 반환하는거 아닌가?

4. 4.4에서는 어떤 방법으로 해당 이슈들을 해결하였나?

첫번째 질문에 대한 결론부터 말씀드리면 해당 테스트 코드를 수정없이 18 (4.3) 갤럭시 S4에서 테스트한 경우 이상하게도 메모리를 잘 반환하는 현상이 발생했습니다. (테스트 결과는 18 (4.3) 에서 image release, bitmap recycle, Image Detach 상황에 대해 모두 반환하지 못하는 결과를 보여줌)

메모리가 반환되지 않은 경우는 모두 하드웨어 가속을 통해 GLES20DisplayList 가 비트맵을 참조하여 발생하는 현상이므로 혹시나 가속이 꺼져있어 그런것은 아닐까? 라는 생각이 들어 14 (4.0) 버전이후 hardwareAccelerated 값은 기본으로 true임에도 불구, 메니페스트에 android:hardwareAccelerated=”true” 값을 명시적으로 넣어보기도 하고 개발자 옵션에서 “GPU 렌더링 강제 실행” 옵션을 켜보기도 하였습니다만 헛수고 였습니다.

이후 혼란과 함께 삽질에 빠지게 됩니다.(Alan Jeon님이 도와주셔서 그나마 해당 원인을 일찍 찾게 되었죠. 다시한번 감사드립니다.)

원인은 해당 프로젝트의 메니페스트 파일에 min, targetSdk 버전이 둘 다 명시되어 있지 않았기 때문입니다. (minSdk의 경우 targetSdk가 명시되어 있지 않을 경우 대신 targetSdk로 사용되어집니다.)

다시말해 <uses-sdk android:targetSdkVersion=”18"></uses-sdk> 와 같이 메니메스트에 사용할 targetSdk을 명시할 경우 (하드웨어 가속은 14 이후부터 디폴트로 적용됨) 해당 값을 참조하여 가속기능을 사용하게 됩니다.

그러나 이 경우처럼 버전을 명시하지 않을 경우에는 기준이 되는 참조 값이 존재하지 않기에 조건에 맞지 않아 하드웨어 가속기능을 활성화 하지 못해 메모리가 반환되었던 것입니다.

boolean hardwareAccelerated = sa.getBoolean(com.android.internal.R.styleable.AndroidManifestApplication_hardwareAccelerated,owner.applicationInfo.targetSdkVersion >= Build.VERSION_CODES.ICE_CREAM_SANDWICH);

위 코드는 PackageParser.java 클래스의 일부분으로 hardwareAccelerated 사용의 기준을 targetSdkVersion으로 삼고있는 것을 볼 수 있습니다.

즉, 하드웨어 가속이 켜질경우 GLES20DisplayList 가 비트맵을 참조하게 되어 메모리를 반환 시키지 못했던 것이였고 꺼져있는 경우에는 GLES20DisplayList 을 사용하지 않기에 비트맵의 메모리가 반환되게 되었던 것입니다.

그렇다면 GLES20DisplayList 은 무엇일까요?

허니컴(3.0) 이전 모델은 뷰에 대한 변경 사항이 발생되면 해당 내용을 무조건 ViewRoot에 전달한 후 뷰를 변경하는 문제가 있었습니다. 이러한 구조는 특히 뷰의 hierarchy가 복잡할 경우 여러단계를 거쳐야 하므로 굉장히 비효율적이었죠.

이에 허니컴에서는 “디스플레이 리스트” 라는것을 만들어서 ViewRoot까지 갈 필요없이 UI요소를 리스트에서 확인하고 사용하게 되어집니다. 이러한 디스플레이 리스트는 DisplayList와 GLES20DisplayList 객체로 구현되어있는데 그 중 GLES20DisplayList는 화면 구성을 위한 비트맵 등의 리소스를 가지고 있는 역할을 합니다. 이제 의문이 풀린것 같네요!

좀 더 자세한 내용은 달리나움님이 작성하신 안드로이드 렌더링의 특징: 디스플레이 리스트 글을 참조하시면 좋을 것 같습니다.

다음으로 메모리의 반환시기에 대한 답변입니다. 여기서는 MAT를 통해 메모리 상태를 체크하고 있습니다. MAT의 특성상 해당 시점만의 메모리 상태만을 분석하게 되어 모호한 점이 생기게 됩니다. 즉, 조금 뒤면 알아서 메모리가 반환되지 않을까? 너무 일찍 체크해놓고 계속 메모리를 잡고있는 것 처럼 말하는거 아닌가? 라는 의문이 생겼습니다. 이에 해당 테스트앱의 메모리를 시간대별로 체크하였습니다. (이 과정에서 테스트 앱 화면에서 홈키를 눌러 나간후 다른 앱을 구동시는 등 일반적인 사용자 패턴으로 사용) 결과는 다음과 같습니다.

결론적으로 해당 GLES20DisplayList가 속해있는 액티비티가 종료되지 않는이상 비트맵관련 메모리는 반환되지 않음을 알 수 있습니다. 물론 하루종일 테스트 하지 않아 확실하다 말씀드릴수는 없지만 적어도 한시간 동안은 메모리에 남아있는 것을 확인했기에 해당 현상에 대해 충분히 신경을 써야 할 것 같습니다.

마지막으로 4.4는 어떻게 해당 이슈들을 해결하였나 입니다. 메모리가 반환되지 않는 주범인 DisplayList에 대해 찾아본결과 Romain Guy가 해당이슈에서 대해서 수정한 부분을 찾아볼 수 있었습니다.

그렇다면 해당 코드가 4.4에 삽입되어져 있는지 18과 19의 view.java를 비교해 보았습니다.

오~ 반영되어 있네요. 그렇다면 해당 메서드가 실제로 메모리를 반환하는 역할을 하는지 확인사살 해보죠.

try {
Method m = View.class.getDeclaredMethod(“clearDisplayList”);
//m.invoke(d);//exception java.lang.IllegalAccessException
m.setAccessible(true);//Abracadabra
m.invoke(pictureView);//now its ok
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}

해당 코드를 적용한뒤 메모리 분석을 하면

위 이미지와 같이 메모리가 반환된 것을 보실 수 있습니다!

드디어 끝이네요. 긴 글 읽어주시느라 수고 많으셨습니다.☺ 늘 그렇지만 이번 글도 많은 분들에게 도움이 되었으면 합니다. 혹시 내용 중 궁금한 점이나 문의 사항이 있으시면 댓글이나 트위터(ID : @_marojun)로 글 남겨주시면 답변 드리겠습니다. 감사합니다.

--

--

marojun
marojun’s Android

전슬마로. KTH, SK Planet, NCSOFT 에서 iOS와 Android를 개발하고 있다. — 안드로이드 개발 그룹 https://www.facebook.com/groups/junsle/