@NonNull privatestatic RequestManagerRetriever getRetriever(@Nullable Context context){ // Context could be null for other reasons (ie the user passes in null), but in practice it will // only occur due to errors with the Fragment lifecycle. Preconditions.checkNotNull( context, "You cannot start a load on a not yet attached View or a Fragment where getActivity() " + "returns null (which usually occurs when getActivity() is called before the Fragment " + "is attached or after the Fragment is destroyed)."); return Glide.get(context).getRequestManagerRetriever(); }
with()메서드를 호출하면 getRetriever() 메서드를 통해 RequestManagerRetriever 객체를 획득한다.
이후 get() 메서드를 이용해 RequestManager 객체를 생성하는 코드도 연달아 호출하게 된다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
@NonNull public RequestManager get(@NonNull Context context){ if (context == null) { thrownew IllegalArgumentException("You cannot start a load on a null Context"); } elseif (Util.isOnMainThread() && !(context instanceof Application)) { if (context instanceof FragmentActivity) { return get((FragmentActivity) context); } elseif (context instanceof Activity) { return get((Activity) context); } elseif (context instanceof ContextWrapper // Only unwrap a ContextWrapper if the baseContext has a non-null application context. // Context#createPackageContext may return a Context without an Application instance, // in which case a ContextWrapper may be used to attach one. && ((ContextWrapper) context).getBaseContext().getApplicationContext() != null) { return get(((ContextWrapper) context).getBaseContext()); } } return getApplicationManager(context); }
Context의 instance type에 대한 처리후 제일 마지막에 getApplicationManager()를 호출한다.
@NonNull private RequestManager getApplicationManager(@NonNull Context context){ // Either an application context or we're on a background thread. if (applicationManager == null) { synchronized (this) { if (applicationManager == null) { // Normally pause/resume is taken care of by the fragment we add to the fragment or // activity. However, in this case since the manager attached to the application will not // receive lifecycle events, we must force the manager to start resumed using // ApplicationLifecycle.
// T ODO(b/27524013): Factor out this Glide.get() call. Glide glide = Glide.get(context.getApplicationContext()); applicationManager = factory.build( glide, new ApplicationLifecycle(), new EmptyRequestManagerTreeNode(), context.getApplicationContext()); } } } return applicationManager; }
with() 메서드는 Global Scope에서 애플리케이션의 생명주기와 연동하여 Glide의 싱글턴 객체를 획득하는 것이 목적이라고 볼 수 있다.
@NonNull public ViewTarget<ImageView, TranscodeType> into(@NonNull ImageView view){ Util.assertMainThread(); Preconditions.checkNotNull(view);
BaseRequestOptions<?> requestOptions = this; if (!requestOptions.isTransformationSet() && requestOptions.isTransformationAllowed() && view.getScaleType() != null) { // Clone in this method so that if we use this RequestBuilder to load into a View and then // into a different target, we don't retain the transformation applied based on the previous // View's scale type. switch (view.getScaleType()) { case CENTER_CROP: requestOptions = requestOptions.clone().optionalCenterCrop(); break; case CENTER_INSIDE: requestOptions = requestOptions.clone().optionalCenterInside(); break; case FIT_CENTER: case FIT_START: case FIT_END: requestOptions = requestOptions.clone().optionalFitCenter(); break; case FIT_XY: requestOptions = requestOptions.clone().optionalCenterInside(); break; case CENTER: case MATRIX: default: // Do nothing. } } return into( glideContext.buildImageViewTarget(view, transcodeClass), /*targetListener=*/null, requestOptions, Executors.mainThreadExecutor()); }
Util.assertMainThread()에서 메인 쓰레드 여부를 검증한 뒤, Preconditions.checkNotNull(view)에서 파라미터로 주어진 ImageView에 대한 null check를 수행한다.
이후 ImageView의 scaleType에 대한 처리 후, into(target, targetListener, options, callbackExecutor)를 호출한다.
이때 target 자리에 GlideContext.buildImageViewTarget()이라는 메서드를 주입하는데, 파라미터로 주어진 ImageView를 이용해 BitmapImageViewTarget이나 DrawableImageViewTarget으로 변환하는 작업을 수행한다.
GlideContext.buildImageViewTarget()의 두 번째 파라미터인 transcodeClass의 값은 Glide.load()를 호출하였을 때 수행하는 as() 메소드의 파라미터인 Drawable.class로 이미 주입되어있다.
private <Y extends Target<TranscodeType>> Y into( @NonNull Y target, @Nullable RequestListener<TranscodeType> targetListener, BaseRequestOptions<?> options, Executor callbackExecutor){
Preconditions.checkNotNull(target); if (!isModelSet) { thrownew IllegalArgumentException("You must call #load() before calling #into()"); }
Request request = buildRequest(target, targetListener, options, callbackExecutor); Request previous = target.getRequest(); if (request.isEquivalentTo(previous) && !isSkipMemoryCacheWithCompletePreviousRequest(options, previous)) { // If the request is completed, beginning again will ensure the result is re-delivered, // triggering RequestListeners and Targets. If the request is failed, beginning again will // restart the request, giving it another chance to complete. If the request is already // running, we can let it continue running without interruption. if (!Preconditions.checkNotNull(previous).isRunning()) { // Use the previous request rather than the new one to allow for optimizations like skipping // setting placeholders, tracking and un-tracking Targets, and obtaining View dimensions // that are done in the individual Request. previous.begin(); } return target; }
target으로 주어진 ImageView에 리소스를 세팅하는 곳인데, 특기할만한 부분은 requestManager.clear(target)이다.
해당 메서드를 호출하면 현재 target으로 설정된 뷰에 대한 모든 로딩을 취소하고 모든 리소스를 해제한다.
Memory & Footprint
Bitmap를 다루다보면 필연적으로 OOM 관련하여 메모리 이슈를 피할 수 없다.
Image Library를 사용하지 않은 상태에서 많은 이미지를 사용하거나 고해상도 이미지를 이미지뷰에 로드해야하는 경우 메모리 부족으로 OOM이 발생하게 된다.
요즘 나오는 스마트폰은 굉장히 많은 메모리를 가지고 태어나는데 왜 이정도도 못 버티지(?) 라는 생각을 한다면, 안드로이드는 앱 내에서 사용할 수 있는 힙 메모리가 정해져있기 때문이다.
Android의 메모리 모델은 운영체제 버전에 따라 두가지로 나뉘게 된다.
Dalvik heap 영역 : java 객체를 저장하는 메모리
External 영역 : native heap의 일종으로 네이티브의 비트맵 객체를 저장하는 메모리 Dalvik heap 영역와 External 영역의 Dalvik heap footprint + External Limit을 합쳐 프로세스당 메모리 한계를 초과하면 OOM이 발생하게 된다.
Footprint
java의 footprint는 한 번 증가하면 크기가 다시 감소되지 않기 때문에, footprint가 과도하게 커지지 않게끔 잘 관리해야한다.
Dalvik VM은 처음에 동작에 필요한 만큼만 프로세스에 Heap을 할당하게 되고, 프로세스에 할당된 메모리보다 많은 메모리를 필요하게될 때마다 Dalvit Footfrint도 증가하게된다. 하지만 증가된 Footfrint는 결코 감소하지 않기 때문에 java 객체가 사용가능한 메모리 공간의 여유가 있어도, External heap의 크기가 증가되면 OOM이 발생할 수 있다. 하지만 이런 문제도 Honeycomb 이후부터는 Dalvik heap 과 External 영역이 합쳐졌기 때문에, 고려할 필요가 없어졌다.
External 영역을 사용하는 Honeycomb 미만 버전에서는 이미지를 많이 사용하고있는 화면에서 화면을 전환하는 행동이 발생했을 때도 OOM이 발생하면서 앱이 중지될 것이다. 화면을 전환하면 이전 액티비티 인스턴스에 있던 이미지뷰나 할당되었던 비트맵이 함께 소멸되어 메모리가 회수되고 새로운 액티비티 인스턴스를 생성할텐데, 이 과정에서 이전 액티비티 인스턴스의 비트맵 객체가 회수되지 않아 메모리 누수가 발생했기 때문이다.
비트맵 객체에 대한 참조가 없는데도 왜 회수가 될 수 없을까? Honeycomb 미만 버전에서는 Java 비트맵 객체는 실제 비트맵 데이터를 가지고 있는 곳을 가리키는 포인터일 뿐이고 실제 데이터는 External 영역인 Native Heap 영역에 저장되기 때문이다. Java 비트맵 객체는 참조가 없을 때 GC에 의해 회수되지만 Native Heap 영역은 GC 수행영역 밖이기 때문에 메모리 소멸 시점이 다르다.
이러한 문제점 때문에, Honeycomb 이후 버전에서는 External 영역이 없어지면서 Dalvik heap 영역에 비트맵 메모리를 올릴 수 있게 되었고 GC도 접근할 수 있게 되었다.
다시 돌아와, 만약 고해상도 이미지를 로드할 때 OOM이 발생하는 경우 BitmapFactory 객체를 이용해 다운샘플링, 디코딩 방식을 선택해 적절하게 뷰에 로드하면 된다. 하지만 많은 이미지를 사용하게 되면서 OOM이 발생한다면, 이미지 캐싱을 이용해보는 것이 어떨까?
Bitmap Caching
이미지가 화면에서 사라지고 다시 구성할 때 이미지를 매번 로드하는 것은 성능상으로나 사용자 경험에 좋지 않다. 이럴 때 메모리와 디스크 캐시를 이용하여 어디에선가 저장되어있던 비트맵을 다시 가져온다면 다시 로드하는 시간도 단축시킬 수 있으며 성능 개선도 가능할 것이다. 이 때 캐싱을 위해 Memory Cache와 Disk Cache 사용을 추천하는데 두가지가 어떤 차이점이 있는지 알아보자.
1. Memory Cache
Memory Cache는 어플리케이션 내에 존재하는 메모리에 비트맵을 캐싱하고, 필요할 때 빠르게 접근가능하다. 하지만 Memory Cache도 곧 어플리케이션 용량을 키우는 주범이 될 수 있기 때문에 많은 캐싱을 요구하는 비트맵의 경우에는 Disk Cache에 넣는 것이 더 좋을 수 있다.
2. Disk Cache
Memory Cache에 넣기엔 많은 캐시를 요구하는 경우, 혹은 앱이 백그라운드로 전환되어도 적재한 캐시가 삭제되지 않기를 바란다면 Disk Cache를 이용하는 것이 좋다. 하지만 Disk로부터 캐싱된 비트맵을 가져올 때는 Memory에서 로드하는 것보다 오랜시간이 걸린다.
BitmapPool
Memory Cache의 예시를 위해 소개할 것은 BitmapPool이다. BitmapPool의 원리는 사용하지 않는 Bitmap을 리스트에 넣어놓고, 추후에 동일한 이미지를 로드할 때 다시 메모리에 적재하지 않고 pool에 있는 이미지를 가져와 재사용하는 것이다.
보통 BitmapPool을 이용해 재사용 Pool을 만들게 될 때, LRU 캐싱 알고리즘으로 구현된 LinkedList (lruBItmapList)와 Byte Size 순으로 정렬된 LinkedList(bitmapList)를 사용하여 구현하게 된다. 이 둘은 들어있는 비트맵의 순서만 다를 뿐, 같은 비트맵이 담기게된다.
LRU 알고리즘을 이용해 오랫동안 참조되지않은 비트맵 객체는 맨 뒤로 밀리게되고, 맨 뒤에있는 객체를 회수하면서 BitmapPool을 유지시키는 것이다. LRU 알고리즘을 이용하지 않는다면 처음 BitmapPool이 가득 찰 때까지는 문제없이 동작하지만, 비트맵을 재사용하는 시점부터는 특정 비트맵만 재사용될 수 있으며, 앱이 끝날 때까지 메모리가 줄어들지 않게된다. 자세한 내용은 이 블로그를 참고하길 바란다.
대표적인 이미지 로더 라이브러리인 Glide에서 구현한 LruBitmapPool Class 내부를 보며, LruBitmapList와 bitmapList가 어떻게 쓰이고있는지 살펴보자.
dependencies { ... implementation "com.google.accompanist:accompanist-glide:0.11.1" implementation "com.google.accompanist:accompanist-coil:0.11.1" // Coil 에서 gif 를 사용하기 위해서 추가 implementation "io.coil-kt:coil-gif:1.2.2" ... }