IT/Android

[Android/Refactoring] Memory Leak - 2. 자주 발생하는 안드로이드 메모리 누수 방지하기

Hodie! 2023. 7. 25. 07:08
반응형

[Android/Refactoring] Memory Leak - 2. 자주 발생하는 안드로이드 메모리 누수 방지하기

이어지는 글

2023.07.21 - [IT/Android] - [Android/Refactoring] Memory Leak - 1. 안드로이드 앱에서의 메모리 누수 찾기

 

[Android/Refactoring] Memory Leak - 1. 안드로이드 앱에서의 메모리 누수 찾기

배경 개발을 해놓고 나니 메모리가 이미지를 로딩하는 등 앱을 지속적으로 실행시키면서 메모리가 슬금슬금 증가하는 모습을 안드로이드 프로파일러를 통해서 발견했다. 앱을 사용하면서 지속

hodie.tistory.com

Static References

  • Activities, Fragments, views, context 에 대해 static references를 과도하게 사용하지 않아야 한다.
  • Static 객체Application이 실행되는 동안 살아있지만, View(또는 Context)에 대한 Static Reference제 때 지워지지 않을 확률이 높기 때문이다.
  • ApplicationClassJVM에 로드하면 해당 static member들이 메모리 할당되고 증가된 lifespan에 의해 Classgarbage collection의 대상이 될 때 까지 메모리에 남아있게 된다.

예시

  • Static variableTextView를 선언하고, 다음과 같이 TextView값을 업데이트하는 클래스가 있고 이를 onCreate() 메소드에서 실행한다고 하자
private static TextView textView;
private void changeText() {
    textView = (TextView) findViewById(R.id.testview);
    textView.setText("Update Hello World greetings!");
}
  • 해당 Static ViewchangeText() 클래스를 실행하는 동안 Activity의 일부분이다. 따라서, 특정 Activity에 대한 static referencehold된다. 해당 Static ViewActivityLifecycle 이후에도 실행되게 된다.
  • 따라서, 이경우 여전히 Activity에 대한 참조를 view가 가지고 있기 때문에 Activitygarbage collected되지 않는다. 이는 곧 메모리 누수가 발생함을 의미한다.
  • Static은 주어진 클래스에서 동일한 variable모든 객체에서 사용하기 위해 사용된다. 따라서, Viewstatically 유지 되어야만 하는 경우에는 onDestroy()에서 해당 reference를 null 처리하면 메모리 누수를 막을 수 있다.
      • 이 경우, activitydestroy되면, static referencedestroyed되기 때문에, activity가 garbage collected될 수 있다.
@Override
protected void onDestroy() {
  super.onDestroy();
  textView = null;
}
  • 위처럼 Destroy할 때 null 처리하여 메모리 누수를 막을 수도 있지만 가장 좋은 것은, 가능한 static keyword를 사용하지 않고 선언하는 것이다.

예시 2 (Static Context)

private static Context mContext;
...
.. onCreate() {
    mContext = this;
}
  • 위처럼 onCreate() 메소드 안에서 Activity ContextStatic reference하려는 경우 acitivty에 대한 leak을 발생시킬 수 있으며, 안드로이드 스튜디오도 이를 경고할 것이다.
  • 그러나, static field두어야만 한다면, virtual/weak reference를 사용해 hold할 수 있다.
private static WeakReference<Context> mContext;
  • 따라서 onCreate() 메소드 내부에서
    mContext = new WeakReference<> (this);

    를 사용할 수 있고, onDestroy() 메소드 내부에서 null 처리해 사용할 수 있다.
  • mContext = new WeakReference<> (this);

Binding leaks

  • ViewBinding을 사용하는 경우에 onDestroyView() lifecycle 메소드에서 binding = null 처리한다.

Context

  • ContextApplication에서 다른 components간에 communicate가 가능하도록 allow해준다.
  • 이를 통해, 새로운 객체를 만들거나, 리소스(layout, image, string 등)에 접근할 수 있도록 해주며, activity, database와 기기의 내부 저장소를 launch할 수 있도록 해준다.
  • 따라서 Context에 Access할 수 있는 방법에도 여러 방법이 있는데, thisgetApplicationContext가 있다.
  • Context는 다른 component에 대한 참조를 유지한다. 따라서, context를 사용하는 방법은 application에서 중요하다.

예시

public class SingletonClass {

    private static SingletonClass singletonClassInstance;

    private Context context;

    private SingletonClass(Context context){
        this.context = context;
    }

    public static void singletonClassInstance(Context context){

        if (singletonClassInstance == null){
            singletonClassInstance = new SingletonClass(context);

        }
    }
}
  • 해당 예시에서, SingletonClass.singletonClassInstance(this)를 사용해 MainActivitySingletonClass 클래스에 접근하게 된다.
    • 이때, SingletonClass 데이터를 가져오기 위해, parameter this를 사용해 해당 클래스의 context를 얻게 된다.
  • 이 경우, context는 Java 클래스이다. 이는, Application components나 다른 OS feature에 대한 정보를 얻기 위한 방법을 제공한다.
  • 그러나, 이처럼 this context를 사용해 MainActivity에서 SingletonClass를 exceuting하면 activity가 누수(leak)될 수 있다.

Context의 사용법

  • Context는 전체 Application의 lifecycle에 묶여있다. 따라서, Context를 잘못 사용하는 경우 메모리 누수로 이어질 수 있는 것이다.
    • 따라서, 다른 Context를 사용하는 경우 위치와 사용시기를 확인해야 한다.
  • 예를 들어, getApplicationContext는 객체가 Activity Lifecycle을 넘어서까지 살아있어야 하는 경우 사용할 수 있다. 그러나, UI와 관련된 component들에선 사용할 수 없다.
    • Singleton이 있는 경우, 항상 ApplicationContext를 사용해야 한다는 것이다.
  • 또한 this는 객체가 activity lifecycle의 과거에 살아 있지 않은 경우에만 사용할 수 있다. 다만, UI component들을 참조하는데는 사용 할 수 있다.
    • UI component들은 장시간 실행되는 작업이 아니고, activity lifecycle을 초과해 살아있을 수 없다.
    • this Context는 XML 레이아웃이나, dialogue, getting resources, starting activity를 하는 여러 operation에 사용될 수 있다.

예시 2 (ApplicationContext - Singleton)

  • SingletonClass를 사용하는 경우, Context-implementing 객체는 1개만 있을 수 있으므로, getApplicationContext를 사용하는 것이 적절하다.
  • getApplicationContextsingleton Context이다. 이는, 얼마나 많이 context에 접근하는 지 중요하게 생각하지 않으며 항상 같은 instance를 얻게 된다.
  • 따라서 해당 instance는 새로운 context를 만들지 않는다. 따라서 SingletonClass를 사용하는 경우 아래와 같이 사용하면 memory leak을 방지할 수 있다.
SingletonClass.singletonClassInstance(getApplicationContext());

Fragment Lifecycle vs ViewLifecycleOwner Lifecycle

Fragment에서 LiveData를 observing하는 경우, this 대신 viewLifecycleOwner 전달

  • viewLifecycleOwner은 UI에 있는 한(onCreateVew() ~ onDestroyView()), fragment에 묶여있는데(tied to fragment UI) 반해
  • 반면에 this는 fragment의 전체 lifecycle (onCreate() ~ onDestroy())에 묶여있기 때문에 아래의 그림처럼 viewLifecycleOwner보다 this를 사용하는 경우 더 오랜 시간 observe하게 된다.
    출처: https://write.agrevolution.in/memory-leaks-in-android-apps-45a27c6ac35d
  • 이것은 곧 thislifecycle owner로 전달(pass)하는 경우 메모리 누수를 일으키는 원인이 될 수 있음을 의미한다.
  • 예를 들어, viewdestroyed되었으나, (onDestroyView() is called), fragmentdestroy되지 않은 경우 (onDestroy() is not called), LiveData 객체의 모든 변경사항이 여전히 observed되기 때문에 Crush가 발생할 수 있다.

FragmentStateAdapter Constructor에서 lifecycle 객체 대신 viewLifecycleOwner.lifecycle 전달

  • 위와 비슷한 이유로, FragmentStateAdapter Constructor에서 Adapter의 범위를 Fragment가 아닌, Fragment의 View의 lifecycle로 Scope를 지정해야 하기 때문에 viewLifecycleOwner의 Lifecycle을 전달해야 한다.
    • FragmentPagerStateAdapter(fragment) 혹은 FragmentPagerStateAdapter(fragmentActivity)에서 FragmentPagerStateAdapter(fragmentManger, lifecycle)로 변경한다
    • 1번째와 2번째는 fragmentActivity.getLifecycle()fragment.getLifecycle()를 이용해 LifecycleEvent를 관찰한다. 따라서, previous memory leak이 일어나게 된다.
    • 3번째를 사용해 fragment의 viewLifecycleOwner를 2번째 인자로 넣어줘 사용할 수 있다.
    • 위의 식으로 인해, View가 destryoed되고 나서, 다른 fragment로 이동한 후, onDetach를 calling할 때 까지 response를 받는다.
    • viewModel.orders.collectLatest { adapter?.submitData(it) }
    • 이때 위와 같은 식이 있다면, fragment의 view가 destroy됐음에도 불구하고, recyclerview에 액세스하려고할 것이며 이는, adapter에 대한 참조를 가지고 있으면서 memory leak을 일으키는 원인이 된다.

Threaded Code

  • Threaded code는 메모리 누수를 일으킬 가능성이 매우 높다. Thread들은 실행 logic을 여러개의 동시 tasks로 decompose한다.
  • 안드로이드는 스레드를 사용해, 동시에 실행되는 여러 task들을 처리한다. 스레드에는 고유한 실행 환경이 없기 때문에, 부모 task로부터 실행 환경을 상속한다. 따라서 스레드는 단일 프로세스 범위 내에서 서로 데이터를 쉽게 통신하고 교환할 수 있다.

예시

        Thread Task를 초기화하고, threaded task를 set up한 뒤, onCreate() 메소드에서 task를 실행한다
private final ThreadedTask thread = new ThreadedTask();
  private class ThreadedTask extends Thread {
      @Override
      public void run() {
          // Run the ThreadedTask for some time
          SystemClock.sleep(1000 * 20);
      }
  }
  thread.start();
  • Thread Task가 시작되면 실행이 완료될 때까지 시간이 걸리게 된다.
  • 만약, task 실행이 끝나기 이전에 activity를 닫게되면, 실행되고 있는 ThreadedTask는 Activity가 garbage collected되는 것을 막을 것이다. Background에서 발생하는 view, activity또는 context에 대한 참조를 가지고 있는 것은 메모리 누수를 발생할 확률이 있다.
      • 이러한 누수를 해결하기 위해 static class를 사용할 수 있다. static class는 static class를 둘러싼 activity class에 대한 참조를 가지고 있지 않기 때문이다.
      // make ThreadedTask static to remove reference to the containing activity
      private static class ThreadedTask extends Thread {
          @Override
          public void run() {
              // check if the thread is interrupted
              while (!isInterrupted()) {
                  // Run the ThreadedTask for some time
                  SystemClock.sleep(1000 * 20);
              }
          }
      }
      • 또는 Activity가 destroy되는 onDestroy()에서 해당 스레드를 중단시킬수 있다.
      // If the activity is destroyed, isInterrupted() will return true, and the thread will be stopped:
    
      @Override
      protected void onDestroy() {
          super.onDestroy();
          //kill the thread in activity onDestroy
          thread.interrupt();
      }

Handler Threads

  • HandlerJava background Thread이다. 이것은 background에서 계속 실행되며, Appplication이 스레드 실행을 종료할 때 까지 순차적으로 다른 task들을 실행한다.
  • Handler는 주로 Application UI와 통신하고, 실행 스레드를 기반으로 다른 component들을 업데이트 한다.
    • 가장 대표적인 handler application 예시가 progress bar
  • Handler는 메시지큐를 만드는데 loopers를 사용하기에, 이를 이용해 메시지를 예약하고, 다른 반복 tasks를 기반으로 UI를 업데이트 할 수 있다.
  • 다만, Handler는 스레드이며, 여러번 반복해 실행되기 때문에, 어떻게 사용하느냐에 따라 메모리 누수가 발생할 가능성이 존재한다

예시

    • handler task를 선언하고, onCreate() 메소드에서 task를 실행한다. 
handler.postDelayed(new Runnable() {
   @Override
   public void run() {
      textView.setText("Handler execution done");
   }
   // delay its execution.
 }, 1000 * 10);
private final Handler handler = new Handler(Looper.getMainLooper());
    • 해당 handler가 실행되었을 때, 해당 handleractivity에 callback을 register한다. 이것은 activitygarbage collecting되는 것을 막고, 이는 곧 메모리 누수를 일으키게 된다.
@Override
protected void onDestroy() {
   super.onDestroy();
   //remove the handler references and callbacks.
   handler.removeCallbacksAndMessages(null);
}
  • 따라서, 메모리 누수를 해결하기 위해, 모든 callback들을 삭제해야 한다.
    • 스레드는 단일 프로세스 범위 내에서 서로 데이터를 통신하고 교환하기 때문에, onDestroy() 메소드가 호출될 때 관련 callback을 제거해야 한다.
    • 이렇게 하면 Handler references가 제거되고, 메모리 누수가 해결된다.
  • Application에서 스레드가 누수 될수 있는 가능성은 많다. 따라서 Threaded 실행이 잘 되도록 하려면, 스레드가 생성된 시점부터 제거된 시점까지 Thread Lifecycle이 완전히 실행될 수 있도록 해야 한다.
  • 또한, inner class에서 outer(parent) class에 대한 모든 implicit references를 관찰해야 한다.

Viewpager

  • ViewPager를 사용하는 경우 관련된 누수를 방지하기 위해 onDestroyView() 메소드 에서 다음과 같이 처리해주어야 한다.
      1. viewPager.adapter = null
      2. tabLayout이 존재하는 경우 tabs에 적용한 listener들 remove 처리
      3. TabLayoutMediator Detach
    override fun onDestroyView() {
    	super.onDestroyView()
    	binding.apply {
    		viewPager.adapter = null
    		tabLayout.removeOnTabSelectedListener(tabSelectedListener)
    	}
        _binding = null
        tabLayoutMediator?.detach()
        tabLayoutMediator = null
    }

RecyclerView

  • RecyclerView를 사용하는 경우 onDestroyView() Lifecycle 메소드에서 RecyclerView 객체에 대한 참조를 holding하는 것을 방지하기 위해 RecyclerView.adapter = null 처리를 해줘야 한다.
  • RecyclerView가 onCreateView() 에서 만들어 진뒤 해당 RecyclerView는, RecyclerViewAdapter에 대한 reference를 hold하고 있게 된다. 만약 fragment가 adapter의 reference를 hold하게 된다면, 이것은 Adapter를 통해 직접 및 간접적으로 RecyclerView를 hold하게 된다.
    • RecyclerView is created inside onCreateView(), and it holds a reference to RecyclerViewAdapter. If the fragment also holds the reference of the adapter, it holds RecyclerView both directly and indirectly through adapter.
  • onDestyoView()가 호출됐을 때, fragment는 RecyclerView에 대한 직접적인 reference는 해제할 수 있지만, 간접적인(indircet) reference는 해제할 수 없기 때문에, RecyclerView가 leak되게 된다.
  • 만약 Fragment가 Adapter의 reference를 hold해야 하는 경우, reference를 null로 set해줘야 하고 이것이 onDestroyView에서 null로 set해주는 것이다.
    출처: https://weidianhuang.medium.com/how-to-prevent-common-memory-leaks-inside-android-fragment-c243ed7074d6
    • null로 Set 해줌에 따라, reference를 break 해줄수 있다.
  • 이를 통해서, RecyclerView → Adapter로의 reference를 break해주는 것을 이해할 수 있는 있지만, Adapter → RecyclerView의 reference를 break하는 것에 대해선 부족한 설명일 수 있다. 이는 Android RecyclerView source code를 통해 확인할 수 있다.
      • setAdapter()코드를 확인해보면 다음과 같다
    public void setAdapter(@Nullable Adapter adapter) {
    	// bail out if layout is frozen
        setLayoutFrozen(false);
        setAdapterInternal(adapter, false, true);
        processDataSetCompletelyChanged(false);
        requestLayout();
    }
    • 위에서 setAdapterInternal를 호출하는 것을 알 수 있다.
      private void setAdapterInternal(@Nullable Adapter adapter, 
      	boolean compatibleWithPrevious,
      	boolean removeAndRecycleViews
      ) {
      	if (mAdapter != null) {
          mAdapter.unregisterAdapterDataObserver(mObserver);
          mAdapter.onDetachedFromRecyclerView(this);
          }
          ......
          final Adapter oldAdapter = mAdapter;
          mAdapter = adapter;
          ......
      }
      • 해당 메소드에서 mAdapter는 previous reference이고, new reference가 업데이트 되기 이전에, 이것은 unregisterAdapterDataObserver를 호출해 adapter 내부의 observable한 항목을 제거하도록 한다.
           → 이를 통해 Adapter에서 Adapter → recyclerView의 reference가 break됨을 알 수 있다.
  • Destroy하는 경우에 null 처리하는 것이 싫다면, RecyclerViewAdapter의 reference를 Fragment에서 유지하지 않고, 항상 recyclerView.adapter를 이용해 RecyclerViewAdapter에 acces 하는 것도 하나의 방법이다.

SwipeRefreshLayout

    • SwipeRefreshLayout의 경우 라이브러리 자체적으로 Leak을 가지고 있는 것이 Google Issue Tracker에서도 이미 보고되었다. 문제는 여전히 고쳐지지 않고 있다는 보고가 지속적으로 올라오고 있기 때문에 이에 대한 대비를 해둘 필요가 있다고 판단했다.
    • 이를 위해 onResume ~ onPause 동안만 사용할 수 있도록 코드 내부에서 자체적으로 enable를 true/false 처리 해줄필요가 있다.
      In OnResume
        swipeRefresh.isEnabled = false
      
      In onPause
      swipeRefresh.isEnabled = true

 

기타

  • 모든 listeners, broadcast receiver 등에 대해 적절한 lifecycle 메소드에서 리소스 누수를 방지하기 위해 unregister 처리를 해줘야 한다.
    • 예를 들어, onViewCreated()에서 receiver를 설정한 경우, onViewDestroyed()에서 unregister처리를 해줘야 한다.
반응형