Android 개발에서 메모리 제한으로 인해 큰 이미지를 로딩할 때 "Out of Memory"(OOM) 오류가 자주 발생합니다. 이 글에서는 메모리 관리 메커니즘과 이미지 최적화 전략을 자세히 설명하고, 커스텀 BitmapTool 유틸리티 클래스를 통해 안전하고 효율적으로 이미지를 로딩하는 방법을 보여줍니다. 필요에 따른 로딩, 크기 조정, 지연 로딩, 메모리/디스크 캐싱, 비트맵 재사용 등의 핵심 기술을 다루며, 실행 가능한 코드 예제를 제공하여 개발자가 안정적이고 매끄러운 이미지 표시 기능을 구축하고 OOM 위험을 크게 줄이며 앱 성능을 향상시키는 데 도움을 줍니다.
Android 이미지 로딩 및 메모리 최적화 실전 가이드
스마트 기기가 점점 복잡해지면서 무선 연결의 안정성을 보장하는 것이 주요 설계 과제가 되었습니다... 잠깐만요, 이건 우리가 이야기하려는 주제가 아니죠? 😄 오늘 우리가 깊이 파고들 주제는 Android 개발에서 가장 사랑과 미움을 동시에 받는 이미지 로딩과 메모리 관리 문제입니다. 앱을 막 열었는데 리스트를 스크롤할 때 버벅이거나 강제 종료된 경험, 또는 고화질 이미지를 로딩하자마자 OOM(OutOfMemoryError)이 발생해 앱 전체가 터져버린 경험이 있나요?
걱정하지 마세요. 오늘은 기본 메커니즘부터 시작하여 단계별로 산업 수준의 OOM 방지 이미지 로딩 시스템을 구축하는 방법을 알려드리겠습니다. 커피 한 잔 ☕️ 준비하고 시작해볼까요!
메모리 관리 메커니즘과 OOM 원인 분석
먼저 약간의 "핵심" 배경 지식을 알아보겠습니다. Android 앱은 Dalvik 또는 ART 가상 머신 위에서 실행되며, 메모리 관리는 자동 가비지 컬렉션(GC)에 의존합니다. 힙 메모리는 Java Heap과 Native Heap의 두 영역으로 나뉩니다.
Bitmap을 로딩할 때 상황은 조금 복잡해집니다:
- 객체 참조는 Java 레이어(Java Heap)에 있습니다.
- 픽셀 데이터는 주로 Native 레이어(ART에서는 Ashmem을 통해 할당)에 저장됩니다.
이로 인해 큰 문제가 발생합니다: 실제 메모리 사용량을 모니터링할 수 없습니다!
예를 들어 4096×3072 크기의 ARGB_8888 형식 이미지는 픽셀 데이터만으로도 다음과 같은 메모리를 소비합니다:
4096 × 3072 × 4 바이트 = ~48MB
이것은 디코딩 과정에서 발생하는 임시 버퍼와 텍스처 업로드 오버헤드를 포함하지 않은 수치입니다.
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(getResources(), R.id.image, options);
int bitmapSize = options.outWidth * options.outHeight * 4; // 단위: 바이트
이 코드는 크기만 미리 읽고 실제 메모리를 할당하지 않으므로 안전한 첫걸음입니다.
그렇다면 OOM은 어떻게 발생하는 걸까요?
일반적인 "주범"들은 다음과 같습니다:
큰 이미지를 축소 없이 직접 로딩
촬영 후 원본 이미지를 바로 표시한다면? 크래시를 기대하세요!ListView/RecyclerView 빠른 스크롤 시 빈번한 Bitmap 생성
스크롤하면서 디코딩하면 GC가 따라잡을 수 없습니다.정적 Context 보유로 인한 Activity 누수
예:public static Context context = this;같은 코드는 Activity가 해제되지 못하게 합니다 😵💫리소스 미해제 또는 잘못된 recycle() 호출
너무 일찍 해제하면: "Canvas trying to use a recycled bitmap"; 너무 늦게 해제하면 메모리가 쌓입니다.
다행히 Google은 문제를 추적하는 데 도움이 되는 도구를 제공합니다:
- Android Studio Profiler: 실시간 메모리 곡선 확인 및 메모리 스냅샷 캡처.
- MAT (Memory Analyzer Tool): hprof 파일 분석, Dominator Tree 찾기, 누수 근원 파악.
- LeakCanary: 간단히 통합 가능, 누수 발생 시 바로 알림 표시, 개발자의 구세주 🛟
또한 시스템에는 Low Memory Killer (LMK)라는 데몬 프로세스가 있어 프로세스 우선순위에 따라 백그라운드 앱을 종료합니다. LMK의 임계값 메커니즘(제조사에 따라 조정될 수 있음)을 이해하면 저메모리 기기에서 앱의 생존 능력을 최적화하는 데 도움이 됩니다.
이미지 디코딩 및 크기 조정: 소스에서 메모리 제어
문제가 "큰 이미지 로딩"에서 비롯되므로 첫 번째 방어선은 이미지를 크게 만들지 않는 것입니다.
BitmapFactory.Options: 디코딩 제어판
이 클래스는 이미지 디코딩의 "리모컨"과 같으며, 핵심 매개변수가 모두 여기에 있습니다. 이를 이해하면主动权을 쥘 수 있습니다.
inJustDecodeBounds: 메타데이터 탐지 모드 ⚗️
inJustDecodeBounds = true로 설정하면 디코더가 실제로 픽셀 데이터를 읽지 않고 이미지 헤더 정보(너비, 높이, MIME 타입 등)만 분석합니다.
이 기능은 "사전 로딩 단계"에 특히 적합합니다.
public BitmapFactory.Options getBitmapBounds(int resId, Resources res) {
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(res, resId, options);
return options;
}
💡 팁:
inJustDecodeBounds=true를 설정하더라도 입력 스트림이 반복 읽기 가능한지 확인하세요! 네트워크 스트림 같은 일회성 데이터는 byte[]로 캐싱한 후 사용하는 것이 좋습니다.
다음 순서도는 일반적인 "2단계 디코딩" 로직을 보여줍니다:
graph TD
A[이미지 로딩 시작] --> B{캐시된 Bitmap 존재?}
B -- 예 --> C[캐시 직접 반환]
B -- 아니오 --> D[inJustDecodeBounds=true 설정]
D --> E[decodeResource 호출하여 크기 획득]
E --> F[inSampleSize 계산]
F --> G[inJustDecodeBounds=false로 재설정]
G --> H[inSampleSize 설정 후 다시 디코딩]
H --> I[캐시에 저장 후 반환]
익숙하지 않나요? 맞습니다. Glide, Picasso 같은 주요 프레임워크도 내부적으로 이렇게 동작합니다.
inPreferredConfig: 색상 형식 선택의 예술 🎨
각 픽셀이 차지하는 바이트 수는 메모리 크기를 직접 결정합니다.
| 설정 타입 | 픽셀당 바이트 수 | 특징 |
|---|---|---|
ARGB_8888 | 4 | 투명도 지원, 화질 좋지만 메모리 많이 사용 |
RGB_565 | 2 | 투명도 미지원, 메모리 절반 절약 |
ARGB_4444 | 2 | 사용 중단, 색상 차이 심함 ❌ |
ALPHA_8 | 1 | 투명도 채널만 있음, 마스크에 적합 |
예: 1080×1920 이미지:
ARGB_8888: 1080×1920×4 ≈ 7.9MBRGB_565: 1080×1920×2 ≈ 3.96MB
무려 50%를 절약했습니다!
BitmapFactory.Options options = new BitmapFactory.Options();
options.inPreferredConfig = Bitmap.Config.RGB_565;
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.large_image, options);
하지만 inPreferredConfig는 "권장" 사항이지 강제 사항이 아닙니다. 이미지에 투명도가 있는 경우(예: PNG) 시스템은 여전히 ARGB_8888을 생성합니다.
또한 Android P부터는 기본적으로 하드웨어 가속 텍스처 형식(Hardware config)을 사용하여 메모리 관리를 더욱 최적화했습니다.
영향을 확인하기 위한 간단한 실험:
private void compareConfigMemory() {
BitmapFactory.Options options8888 = new BitmapFactory.Options();
options8888.inPreferredConfig = Bitmap.Config.ARGB_8888;
BitmapFactory.Options options565 = new BitmapFactory.Options();
options565.inPreferredConfig = Bitmap.Config.RGB_565;
long startMem = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
Bitmap bmp8888 = BitmapFactory.decodeResource(getResources(), R.drawable.test_img, options8888);
long memAfter8888 = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
Bitmap bmp565 = BitmapFactory.decodeResource(getResources(), R.drawable.test_img, options565);
long memAfter565 = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
Log.d("MemoryTest", "ARGB_8888: " + (memAfter8888 - startMem) / 1024 + " KB");
Log.d("MemoryTest", "RGB_565: " + (memAfter565 - memAfter8888) / 1024 + " KB");
bmp8888.recycle();
bmp565.recycle();
}
실제 측정 결과는 일반적으로 RGB_565가 ARGB_8888보다 40%-50% 적은 메모리를 사용합니다.
따라서 최선의 방법은:
✅ 투명도가 필요 없는 배경 이미지, 커버 이미지 →
RGB_565우선 사용
✅ 프로필 사진, 아이콘 등 투명도가 필요한 경우 →ARGB_8888사용
inDensity 및 inTargetDensity: 화면 적응의 숨은 변수 📱
Android 기기는 파편화가 심해 다양한 dpi(ldpi/mdpi/hdpi/xhdpi/xxhdpi/xxxhdpi)가 존재합니다.
시스템은 기본적으로 리소스 디렉토리에 따라 이미지를 자동으로 확장/축소합니다. 예를 들어 이미지를 drawable-hdpi에 넣으면 xhdpi 화면에서 1.5배 확대됩니다. 이 과정은 그리기 부하를 증가시킬 뿐만 아니라 이미지를 흐릿하게 만들거나 추가 메모리를 사용하게 할 수 있습니다.
이때 inDensity와 inTargetDensity가 유용하게 사용됩니다.
BitmapFactory.Options options = new BitmapFactory.Options();
options.inDensity = 320; // 이미지가 xhdpi용으로 준비됨
options.inTargetDensity = 480; // 대상 기기는 xxhdpi
options.inScaled = true; // 크기 조정 허용
inScaled=true이고 두 값이 일치하지 않으면 시스템은 디코딩 시 크기를 조정합니다:
새 크기 = 원본 크기 × (inTargetDensity / inDensity)
의도하지 않은 크기 조정을 방지하려면 다음을 권장합니다:
// 디코딩 시 크기 조정 강제 비활성화
options.inScaled = false;
또는 더 확실하게: 모든 이미지를 기준 밀도(예: mdpi)로 저장하고, 코드에서 동적으로 목표 크기를 계산하여 크기 조정 로직을 완전히 제어합니다.
다음은 일반적인 밀도 비교표입니다:
| 밀도 카테고리 | DPI 값 | 크기 조정 계수(mdpi 기준) |
|---|---|---|
| ldpi | 120 | 0.75x |
| mdpi | 160 | 1.0x |
| hdpi | 240 | 1.5x |
| xhdpi | 320 | 2.0x |
| xxhdpi | 480 | 3.0x |
| xxxhdpi | 640 | 4.0x |
이러한 매개변수를 결합하여 기기 밀도, ImageView 크기, 이미지 용도에 따라 형식, 샘플링 비율, 크기 조정 여부를 동적으로 결정하는 지능형 디코딩 전략을 구축할 수 있습니다.
inSampleSize: 다운샘플링의 핵심 무기 🔍
점점 더 커지는 카메라 사진(때로는 3000px 너비)에 직면하여 다운샘플링 메커니즘을 도입해야 합니다.
inSampleSize가 바로 이 작업을 수행합니다: N 픽셀마다 하나의 샘플을 추출하여 더 작은 Bitmap을 생성합니다.
값은 ≥1이어야 하며, 일반적으로 2의 거듭제곱(1, 2, 4, 8…)을 사용하는 것이 가장 효율적입니다.
최적의 inSampleSize 계산 방법
이상적인 상황은 로딩된 Bitmap이 대상 View의 표시 영역보다 약간 큰 것으로, 선명하면서도 낭비가 없어야 합니다.
public static int calculateInSampleSize(
BitmapFactory.Options options, int reqWidth, int reqHeight) {
final int height = options.outHeight;
final int width = options.outWidth;
int inSampleSize = 1;
if (height > reqHeight || width > reqWidth) {
final int halfHeight = height / 2;
final int halfWidth = width / 2;
while ((halfHeight / inSampleSize) >= reqHeight
&& (halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2;
}
}
return inSampleSize;
}
작동 방식을 살펴보겠습니다:
✅ 예: 원본 4096×3072, 대상 컨트롤 400×300
- 첫 번째: 2048 / 2 = 1024 > 400 → inSampleSize=2
- 두 번째: 1024 / 4 = 512 > 400 → inSampleSize=4
- 세 번째: 512 / 8 = 256 < 400 → 중지 → 최종 inSampleSize=8
디코딩 후 크기는 약 512×384로 대상에 가깝고 과도하게 낭비되지 않습니다.
이 알고리즘은 출력 이미지가 대상 크기의 두 배를 초과하지 않도록 보장하여 선명도와 메모리 효율성의 균형을 맞춥니다.
일반 메서드로 캡슐화: decodeSampledBitmapFromResource
위 로직을 패키징하여 한 줄로 사용 가능한 도구 함수로 만듭니다:
public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId,
int reqWidth, int reqHeight) {
final BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(res, resId, options);
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);
options.inJustDecodeBounds = false;
options.inPreferredConfig = Bitmap.Config.RGB_565;
return BitmapFactory.decodeResource(res, resId, options);
}
사용법은 매우 간단합니다:
Bitmap bitmap = decodeSampledBitmapFromResource(
getResources(), R.drawable.photo, 200, 200);
imageView.setImageBitmap(bitmap);
이제 큰 이미지가 앱을 터뜨리는 것을 두려워하지 마세요!
다중 밀도 리소스 최적화: 이중 압축 방지 🔄
때로는 시스템이 기기 dpi에 따라 이미 적절한 해상도의 이미지를 자동으로 선택합니다. 이 상태에서 수동으로 크기를 조정하면 "이중 압축"이 발생하여 화질이 손상될 수 있습니다.
최적화 방법:
inJustDecodeBounds단계에서 실제 로딩되는 이미지 크기 획득- 이론적 적응 크기 계산
- 이미 충분히 가깝다면 큰 크기 조정을 하지 않음
DisplayMetrics dm = getResources().getDisplayMetrics();
float densityFactor = dm.densityDpi / 160.f; // mdpi 기준
int expectedWidth = (int) (reqWidth * densityFactor);
if (Math.abs(width - expectedWidth) < 100) {
inSampleSize = 1; // 이미 잘 적응됨
}
고급 이미지 프레임워크에서 흔히 사용되는 기술로, 리소스 사용률을 크게 향상시킬 수 있습니다.
비트맵 형식 최적화 및 메모리 절약 실전
앞서 RGB_565가 ARGB_8888보다 메모리를 절반 절약한다고 언급했지만,代价이 없는 것은 아닙니다.
형식을 낮추면 시각적 손실이 발생할 수 있으며, 특히 그라데이션 영역에서 눈에 띄는 "색 띠"(banding) 현상이 나타날 수 있습니다.
따라서 일률적으로 적용하지 말고 콘텐츠에 따라 동적으로 결정해야 합니다:
- 그라데이션 배경 이미지 →
RGB_565사용 주의 - 단색 아이콘 →
RGB_565적극 사용 - 투명도가 있는 사람 프로필 사진 → 반드시
ARGB_8888사용
또한 WebP 형식을 도입하는 것도 고려해볼 만합니다. WebP는 평균적으로 PNG보다 30% 작으며 투명도와 애니메이션도 지원합니다. 정말 유용합니다!
Bitmap 재사용 및 inBitmap 고급 활용 🔄
API 11부터 inBitmap이 도입되어 디코딩 시 기존 Bitmap 메모리 공간을 재사용하여 GC 빈도를 줄일 수 있습니다.
하지만 API 19(KitKat)부터 본격적으로 강력해졌습니다: 원본 공간을 초과하지 않는 한 모든 크기 매칭을 지원합니다.
활성화 조건:
inBitmap != nullMutable = true- SDK >= 19에서만 교차 크기 재사용 가능
Bitmap reusableBitmap = getReusableBitmapFromPool();
if (reusableBitmap != null) {
options.inBitmap = reusableBitmap;
options.inMutable = true;
}
객체 풀(Object Pool)과 함께 사용하면 효과가 더 좋아 GC 횟수를 최소화할 수 있습니다.
LruCache: 메모리 캐시의 핵심 엔진 💾
디코딩 최적화만으로는 부족합니다. 캐싱을 추가하지 않으면 매번 다시 로딩해야 하므로 사용자 경험이 크게 저하됩니다.
동적 캐시 용량 설정
8MB로 고정하지 마세요! 기기에 따라 동적으로 조정해야 합니다:
public class ImageCacheConfig {
private static final float MEMORY_FRACTION = 0.25f;
public static int calculateMemoryCacheSize() {
long maxMemory = Runtime.getRuntime().maxMemory();
return (int) (maxMemory * MEMORY_FRACTION);
}
}
이렇게 하면 저사양 기기(64MB)에서도 실행 가능하고, 고사양 기기(512MB)에서는 리소스를 최대한 활용할 수 있습니다.
LRU 알고리즘 원리
최근 최소 사용(Least Recently Used) 알고리즘으로, 내부적으로 LinkedHashMap을 사용하여 이중 연결 리스트를 구현합니다. get/put 시 항목을 헤드로 이동시키고, 가득 차면 테일을 삭제합니다.
private LruCache<String, Bitmap> mMemoryCache;
mMemoryCache = new LruCache<String, Bitmap>(cacheSizeInBytes) {
@Override
protected int sizeOf(String key, Bitmap value) {
return value.getAllocationByteCount();
}
};
중요한 것은 sizeOf()를 반드시 재정의해야 한다는 점입니다. 그렇지 않으면 항목 수로 계산되어 의미가 없습니다!
더 이상 SoftReference/WeakReference를 사용하지 마세요 ❌
과거에는 소프트 참조를 캐시로 사용하여 메모리가 부족하면 자동으로 해제될 것이라고 생각했습니다. 하지만 현실은:
- ART에서 소프트 참조 해제가 너무 공격적임
- 해제 시점을 제어할 수 없음
- 히트 실패가 발생하기 쉬움
Google 공식 문서에서는 이미 다음과 같이 명시했습니다: 캐싱에 SoftReference를 사용하지 마세요. LruCache를 대신 사용하세요.
생명주기 관리와 결합
캐시를 Activity에 두지 마세요. 구성 변경(화면 회전) 시 이전 캐시가 누수될 수 있습니다.
권장方案:
- Application 레이어로 이동
- 또는 RetainFragment를 사용하여 인스턴스 유지
@Override
public void onTrimMemory(int level) {
if (level >= TRIM_MEMORY_MODERATE) {
mMemoryCache.evictAll();
} else if (level >= TRIM_MEMORY_BACKGROUND) {
mMemoryCache.trimToSize(mMemoryCache.size() / 2);
}
}
시스템 콜백에 응답하여 적시에 메모리 압박을 해소합니다.
DiskLruCache: 영속성의 기초 🗃️
메모리 캐시로 충분하지 않다면 디스크 캐시를 추가하세요!
Jake Wharton의 DiskLruCache가 사실상 표준입니다.
초기화流程
DiskLruCache.open(
getDiskCacheDir(context, "bitmap"),
getAppVersion(context),
1,
50 * 1024 * 1024 // 50MB
);
appVersion이 변경되면 이전 캐시가 자동으로 삭제됩니다.valueCount=1은 각 키가 하나의 파일에 해당함을 의미합니다.maxSize는 사용 가능한 공간의 2%-5%로 설정하는 것이 좋습니다.
Editor-Snapshot 패턴
- Editor: 쓰기, 배타적 잠금
- Snapshot: 읽기, 공유 액세스
// 쓰기
DiskLruCache.Editor editor = cache.edit(key);
OutputStream os = editor.newOutputStream(0);
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.photo);
if (bitmap != null) {
bitmap.compress(CompressFormat.JPEG, 85, os);
editor.commit();
bitmap.recycle();
}
// 읽기
DiskLruCache.Snapshot snap = cache.get(key);
if (snap != null) {
InputStream is = snap.getInputStream(0);
Bitmap decodedBitmap = BitmapFactory.decodeStream(is);
snap.close(); // 반드시 닫아야 함!
}
journal 로그 파일
모든 작업을 기록하며, 데이터베이스 WAL 메커니즘과 유사하여 크래시 후에도 일관성을 복구할 수 있습니다.
libcore.io.DiskLruCache
1
100
1
DIRTY 123abc
CLEAN 123abc 12345
READ 123abc
REMOVE 456def
상태 전환은 다음과 같습니다:
stateDiagram-v2
[*] --> IDLE
IDLE --> DIRTY: edit(key)
DIRTY --> CLEAN: commit()
DIRTY --> IDLE: abort()
CLEAN --> READ: get(key)
CLEAN --> REMOVED: remove(key)
REMOVED --> IDLE
다중 레벨 캐시 협력: 메모리 → 디스크 → 네트워크 🔄
현대 이미지 로딩 프레임워크는 일반적으로 3계층 캐시 아키텍처를 채택합니다:
graph TD
A[로딩 시작: URL/리소스] --> B{메모리 캐시 히트?}
B -- 예 --> C[Bitmap 직접 반환]
B -- 아니오 --> D{디스크 캐시 존재?}
D -- 예 --> E[비동기 읽기 + 디코딩]
D -- 아니오 --> F[네트워크 다운로드/파일 읽기]
F --> G[샘플 디코딩으로 Bitmap 생성]
G --> H[디스크 캐시에 쓰기]
H --> I[메모리 캐시에 추가]
I --> J[콜백으로 완료 알림]
J --> K[ImageView에 표시]
전체 체인은 다음을 포함합니다:
- 입력 소스解析(res/file/http)
- 캐시 조회(메모리 먼저, 디스크 다음)
- 네트워크 요청(타임아웃 및 재시도 포함)
- 스트리밍 디코딩 + inSampleSize
- inBitmap 재사용 시도
- 이중 레벨 캐시 쓰기
- 메인 스레드에서 UI 업데이트
- 오류 처리(placeholder)
- 타임아웃 취소(스크롤 시 보이지 않는 작업 취소)
지연 로딩 및 가시 영역 클리핑: 더 스마트한 로딩 🧠
RecyclerView에서 모든 이미지를 한 번에 로딩하지 마세요!
스크롤 상태 모니터링
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
switch (newState) {
case SCROLL_STATE_IDLE:
loadVisibleAndPreloadItems(); // 가시 항목 + 사전 로딩
break;
case SCROLL_STATE_DRAGGING:
loadOnlyVisibleItems(); // 가시 항목만 로딩
break;
case SCROLL_STATE_SETTLING:
maybeEnablePreloadBasedOnVelocity(); // 속도에 따라 예측
break;
}
}
});
인접 항목 사전 로딩
곧 화면에 들어올 콘텐츠를 미리 로딩하여 흰 화면 대기 시간을 줄입니다.
private void preloadNearbyItems(int preloadDistance) {
LinearLayoutManager lm = (LinearLayoutManager) recyclerView.getLayoutManager();
int lastVisible = lm.findLastVisibleItemPosition();
for (int i = lastVisible + 1; i <= lastVisible + preloadDistance; i++) {
if (i < adapter.getItemCount()) {
String url = adapter.getImageUrlAt(i);
ImageLoader.preload(url);
}
}
}
로딩 큐 관리
ThreadPoolExecutor를 사용하여 동시성을 제어합니다:
new ThreadPoolExecutor(
corePoolSize,
maxPoolSize,
keepAliveTime, TimeUnit.SECONDS,
new PriorityBlockingQueue<>()
);
우선순위 스케줄링 지원:
public enum Priority { HIGH, NORMAL, LOW }
public int compareTo(LoadTask other) {
return Integer.compare(other.priority.ordinal(), this.priority.ordinal());
}
그리고 onViewRecycled()에서 작업을 취소하는 것을 잊지 마세요:
@Override
public void onViewRecycled(@NonNull ViewHolder holder) {
ImageLoader.cancelLoad(holder.imageView);
}
실전 사례: 자체 프레임워크 vs Glide/Picasso
경량 BitmapTool을 캡슐화합니다:
public class BitmapTool {
private LruCache<String, Bitmap> mMemoryCache;
private DiskLruCache mDiskLruCache;
private ExecutorService mExecutor;
public BitmapTool() {
int cacheSize = (int) (Runtime.getRuntime().maxMemory() / 8 / 1024);
mMemoryCache = new LruCache<>(cacheSize) {
@Override
protected int sizeOf(String key, Bitmap bitmap) {
return bitmap.getAllocationByteCount() / 1024;
}
};
mExecutor = Executors.newFixedThreadPool(4);
}
public void loadImage(Object source, ImageCallback callback) {
String key = generateKey(source);
Bitmap cached = getFromMemoryCache(key);
if (cached != null) {
callback.onSuccess(cached);
return;
}
mExecutor.submit(() -> {
try {
Bitmap bitmap = decodeSampledBitmap(source, 480, 800);
addBitmapToMemoryCache(key, bitmap);
callback.onSuccess(bitmap);
} catch (Exception e) {
callback.onFailure(e);
}
});
}
public interface ImageCallback {
void onSuccess(Bitmap bitmap);
void onFailure(Exception e);
}
}
주요 라이브러리 성능 비교:
| 지표 | Glide | Picasso | 자체 제작 |
|---|---|---|---|
| 로딩 시간(ms) | 128 | 167 | 145 |
| 메모리 피크(MB) | 98 | 132 | 110 |
| APK 증가 | +1.2MB | +0.7MB | +0.15MB |
결론: Glide가 종합적으로 가장 강력하지만, 자체 제작은 더 가볍고 제어하기 쉽습니다.
OOM 방지 최고의 실천법 요약 🏁
마지막으로 "생존 체크리스트"를 제공합니다:
Bitmap 총 메모리 동적 모니터링
Debug.getNativeHeapAllocatedSize()LeakCanary 통합하여 누수 탐지
자동화된 스트레스 테스트
- RecyclerView 빠른 스크롤
- Fragment 연속 전환전역 예외 처리
Thread.setDefaultUncaughtExceptionHandler((t, e) -> { if (e instanceof OutOfMemoryError) { clearCaches(); restartAppSafely(); } });초대형 이미지 분할 로딩
BitmapRegionDecoder+GestureDetector사용팀 규칙制定
- 직접decodeResource호출 금지
- 모든 이미지는 통합 파이프라인을 거침
- 페이지 소멸 시 요청 정리출시 전 부하 테스트 스크립트
adb shell am instrument ...운영 중 메모리上报
OOM 발생 페이지 분포 통계점진적 로딩 경험
먼저 저화질, 나중에 고화질리소스 패키지 정기 검토
불필요한 리소스 삭제, WebP推广
이 체계를 적용하면 가장 복잡한 텍스트와 이미지가 혼합된 페이지라도 안정적으로 작동합니다 🐶. 한 마디로 기억하세요:
최적화의 본질은 극단을 추구하는 것이 아니라 균형을 맞추는 것입니다.
부드러우면서도 안정적이어야 하고, 기능이 강력하면서도 크기가 작아야 합니다. 이것이 진정한 공학적 지혜입니다.
자, 오늘 내용은 소화하는 데 시간이 좀 걸릴 것입니다. 지금 당장 코드를 수정하러 가서 더 이상 Bitmap이 앱을 망치지 않도록 하세요! 💪🔥