AW208: APK크기 줄이는법 4가지, 메모리 누수 사례 정리

Android Weekly는 매주 발행되는 안드로이드 뉴스레터입니다. 영어 기사를 정독할 시간이 없는 분을 위해 핵심 꼭지를 요약했습니다.

주간 안드로이드 뉴스를 요약해 드립니다. Android Weekly 208 원문도 읽어보세요.


APK크기를 줄여 다운로드 받는 사용자의 UX 향상시키기!

앱이 점점 복잡해지고 화면도 더 많은 픽셀을 포함하면서 안드로이드가 릴리즈된 이후 꾸준히 앱의 크기가 커지고 있습니다. 보통 15, 20메가바이트이거나 심지어 50메가바이트가 넘는 앱도 있습니다.

크기가 크면 당연히 인스톨하는데도 시간이 오래 걸리는데요, 이렇게 앱을 다운로드 받을 때부터 사용자 경험(UX)은 시작합니다. 애셋을 줄이는 방법으로 APK 줄이기 (Leaner APKs with asset minification)에서 앱을 처음 마주하는 순간이 지루하지 않도록 즉, 설치를 빨리 할 수 있도록 여러가지 방법을 안내해줍니다.
지금까지 다른 글에서는 dex file 또는 resources.arsc를 줄이고 압축하는 방법들을 주로 다루고 있는 반면, 이 글에서는 Drawables을 줄이는 방법에 대해 초점을 두고 있습니다.

  • APK 분할 작은 APK를 만들기 위한 간단한 방법은 APK spilit입니다. 플레이 스토어나 아마존 앱스토어에서는 이렇게 배포되는 APK가 문제 없도록 지원해주고 있습니다.

  • WEBP APK 리소스의 자원을 줄이는 또 다른 방법은 WEBP을 사용하는 것입니다. 구글 드로잉의 VP8코덱으로부터 오픈소스 이미지 포맷을 생성해주는 서비스입니다. JPG와 PNG 각각 성능 비교시 WEBP을 사용하지 않을 이유가 없다는데요~ android 4.0 이상이라는 호환성 문제와 WEBP이미지는 런처 아이콘으로 사용할 수 없다는 점을 고려하세요!

  • Vector Drawables 앱에 포함되는 많은 이미지가 단순하고 단색인 경우가 많기 때문에 래스터 방식 대신 벡터 포맷을 사용할 수 있습니다. 롤리팝에서부터 벡터 드로어블을 지원하고 서포트 라리브러리 23.2.0덕분에 그 이전 버전에도 적용하실 수 있습니다!

  • 수동으로 drawables 줄이기 이미지를 줄이는 또 다른 방법은 res 폴더에 추가하기 전에 그 크기를 줄이는 방법입니다. 구글의 Zopfli 알고리즘을 사용한 ImageOptim 도구를 사용해보세요. 다만 Mac OS X에서만 사용하실 수 있습니다.

이런 개발 뉴스를 더 만나보세요

“그런데 위와 같은 도구를 중복해 사용하면 오히려 APK 크기는 커질 수 있습니다!!”

왜냐하면 두번째 처리를 할 때 처음에 최적화시킨 내용을 무효화시킬 수 있기 때문입니다. 따라서 안드로이드 APK패키징 툴인 AAPT의 cruncher가 여러분의 수고를 수포로 돌리지 않도록 cruncher 사용을 막으려면 여러분의 build.gradle 파일에 아래와 같이 설정하세요.

aaptOptions {
  cruncherEnabled = false
}

안드로이드 메모리 누수

자바와 같은 가비지 컬렉팅 언어를 사용하는 것의 장점은 외현적으로 할당된 메모리를 관리할 필요를 줄여주는 것입니다. 그럼에도 불구하고 논리적인 메모리 누수가 발생해서 여러분의 안드로이드 앱이 불필요한 메모리를 낭비하게되거나 OOM에러로 인한 크래시가 발생할 수 있습니다.
논리적인 메모리 누수는 특정 오브젝트에 더 이상 필요하지 않은 관련된 참조들을 풀어주는 것을 잊은 결과로 발생하게 됩니다. 만약 강한 참조(strong reference)라면 가비지 컬렉터가 메모리에서 해당 오브젝트를 제거 할 수 없기때문에 계속 존재할 수 있습니다. 특히 안드로이드 개발에서 문제가 되는 부분은 Context누수가 발생할 때입니다. 왜냐하면 Context는 액티비티와 뷰 위계, 다른 자원 등 많은 것을 포함하고 있기 때문이죠. 안드로이드는 대부분 모바일 디바이스에서 작동하기 때문에 상대적으로 제한된 메모리 용량을 지니는데, 누수가 많이 발생하게 되면 가용한 메모리를 다 사용하게 될 수도 있습니다.
논리적 메모리 누수를 탐지하는 것은 특정 오브젝트의 생명주기(life span)에 따라 임의적으로 진행되겠지만 액티비티의 생명주기가 명시적으로 정의되어 있는 것을 활용 할 수 있습니다. onDestroy() 메소드는 생명주기 가장 마지막으로 불리는 것으로 1. 프로그래머의 의도로 불리거나, 2. 메모리 회복을 위해 안드로가 호출 하는 것일 수 있습니다.
그런데 만약 이 액티비티 인스턴스가 힙 루트로부터 어떤 strong reference에 묶여있는 경우라면 가비지 컬렉터는 이를 메모리에서 제거할 수 없고, 해당 인스턴스는 leaked Activity 오브젝트가 됩니다.
안드로이드에서 메모리 누수가 발생하는 기본적인 두가지 상황은 아래와 같습니다.

  1. 프로세스-앱의 상태와 관계없이 글로벌 스태틱 오브젝트가 존재하고 액티비티의 참조들의 체인을 유지할 때
  2. 쓰레드-액티비티 생명주기를 무시하고 strong reference가 남아있을 때
    메모리 누수와 관련된 8가지 사항 (Eight Ways Your Android App Can Leak Memory)에서 제시하는 8가지 내용을 간단히 요약해드립니다.
  • Static Activities 메모리 누수가 발생되는 가장 쉬운; 방법입니다. 클래스 안에 스태틱 변수로 액티비티를 설정하고 그 액티비티 안에서 해당 인스턴스를 실행하도록 세팅하는 것 조심하세요!
void setStaticActivity() {
  activity = this;
}

View saButton = findViewById(R.id.sa_button);
saButton.setOnClickListener(new View.OnClickListener() {
  @Override public void onClick(View v) {
    setStaticActivity();
    nextActivity();
  }
});
  • Static Views 위와 비슷한 상황이 싱글톤 패턴을 구현하는 상황에서도 벌어질 수 있습니다. 같은 인스턴스를 유지하는게 상황에 따라 도움이 될 수 있지만, 액티비티의 기본 생명 주기를 벗어나는 상황에서도 계속 메모리를 잡고 있는 상황은 위험할 수 있습니다!!
void setStaticView() {
  view = findViewById(R.id.sv_button);
}

View svButton = findViewById(R.id.sv_button);
svButton.setOnClickListener(new View.OnClickListener() {
  @Override public void onClick(View v) {
    setStaticView();
    nextActivity();
  }
});
  • Inner Classes 코드 가독성을 높이거나 캡슐화를 위해 이너 클래스를 만드실텐데요, 이 이너클래스의 인스턴스를 생성하고 스태틱 레퍼런스로 유지하면 어떻게 될까요?? 메모리 누수가 발생합니다.
void createInnerClass() {
    class InnerClass {
    }
    inner = new InnerClass();
}

View icButton = findViewById(R.id.ic_button);
icButton.setOnClickListener(new View.OnClickListener() {
    @Override public void onClick(View v) {
        createInnerClass();
        nextActivity();
    }
});
  • Anonymous Classes 위와 비슷하게 AsyncTask 를 선언하고 인스턴스화하는 것을 액티비티 내 익명 클래스에서 진행할 경우, 액티비티가 종료되어도(destroy) 백그라운드에서 계속 존재하게 되며 가비지 컬렉터가 수거하지 못합니다.
void startAsyncTask() {
    new AsyncTask<Void, Void, Void>() {
        @Override protected Void doInBackground(Void... params) {
            while(true);
        }
    }.execute();
}

super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
View aicButton = findViewById(R.id.at_button);
aicButton.setOnClickListener(new View.OnClickListener() {
    @Override public void onClick(View v) {
        startAsyncTask();
        nextActivity();
    }
});
  • Handlers 익명의 러너블 오브젝트를 통해 백그라운드 테스크를 선언하고, 핸들러 오브젝트를 통해 실행을 위한 큐에 저장할 때도 비슷합니다. 핸들러의 메시지 큐에 있는 메시지들이 액티비티가 종료되기 전에 다 처리되지 않으면 레퍼런스 체인이 계속 액티비티를 메모리에서 잡고 있기 때문에 누수가 발생합니다.
void createHandler() {
    new Handler() {
        @Override public void handleMessage(Message message) {
            super.handleMessage(message);
        }
    }.postDelayed(new Runnable() {
        @Override public void run() {
            while(true);
        }
    }, Long.MAX_VALUE >> 1);
}


View hButton = findViewById(R.id.h_button);
hButton.setOnClickListener(new View.OnClickListener() {
    @Override public void onClick(View v) {
        createHandler();
        nextActivity();
    }
});

핸들러와 마친가지로 아래 쓰레드와 타임태스크에서도 같은 실수를 할 수 있습니다.

  • Threads
void spawnThread() {
    new Thread() {
        @Override public void run() {
            while(true);
        }
    }.start();
}

View tButton = findViewById(R.id.t_button);
tButton.setOnClickListener(new View.OnClickListener() {
  @Override public void onClick(View v) {
      spawnThread();
      nextActivity();
  }
});
  • Timer Tasks
void scheduleTimer() {
    new Timer().schedule(new TimerTask() {
        @Override
        public void run() {
            while(true);
        }
    }, Long.MAX_VALUE >> 1);
}

View ttButton = findViewById(R.id.tt_button);
ttButton.setOnClickListener(new View.OnClickListener() {
    @Override public void onClick(View v) {
        scheduleTimer();
        nextActivity();
    }
});
  • Sensor Manager getSystemService로 context에서 시스템 서비스에 접근할 수 있습니다. 이 서비스들은 고유한 프로세스에서 동작하는데요, 만약 센서 매니저에 대한 리스너를 사용하게 된다면 주의하셔야 합니다. 서비스가 액티비티의 참조를 유지하게 되면서 액티비티가 종료되어도 가비지 컬렉터가 수거할 수 없으니까요! destroy전에 꼭 리스너 해제하시는 것 잊지 마세요!
void registerListener() {
       SensorManager sensorManager = (SensorManager) getSystemService(SENSOR_SERVICE);
       Sensor sensor = sensorManager.getDefaultSensor(Sensor.TYPE_ALL);
       sensorManager.registerListener(this, sensor, SensorManager.SENSOR_DELAY_FASTEST);
}

View smButton = findViewById(R.id.sm_button);
smButton.setOnClickListener(new View.OnClickListener() {
    @Override public void onClick(View v) {
        registerListener();
        nextActivity();
    }
});

오픈소스 라이브러리

  • rxjava-mvp-giphy RxJava와 MVP모델에 대한 쇼케이스 앱입니다.

  • auto-value-redacted 구글의 AutoValue extension입니다. @Redacted 어노테이션을 사용해서 toString()에서 가리고 싶은 부분을 처리해보세요.

@Retention(SOURCE)
@Target({METHOD, PARAMETER, FIELD})
public @interface Redacted {
}
@AutoValue
public abstract class User {
  public abstract String name();
  @Redacted public abstract String phoneNumber();
}

더 읽을 거리

6월 둘째 주의 기사를 Android Weekly 208 영어 원문에서 볼 수 있습니다.

지난 뉴스가 궁금하다면 아래 링크를 참고해 주세요.

컨텐츠에 대하여

이 컨텐츠는 저자의 허가 하에 이곳에서 공유합니다.


Realm Korea

Realm Korea Team

4 design patterns for a RESTless mobile integration »

close