느린 렌더링

UI 렌더링은 앱에서 프레임을 생성하여 화면에 표시하는 작업입니다. 사용자와 앱의 상호작용이 원활하게 이루어지도록 하려면 앱이 16ms 미만으로 프레임을 렌더링하여 60fps를 달성해야 합니다. 60fps가 선호되는 이유를 알아보려면 Android 성능 패턴: 60fps를 사용해야 하는 이유를 참고하세요. 90fps를 달성하려고 하면 이 시간은 11ms로 내려가고 120fps의 경우에는 8ms로 내려갑니다.

이 시간을 1ms 초과하면 프레임이 1ms 늦게 표시되는 것이 아니라 Choreographer가 프레임을 완전히 삭제합니다. 앱의 UI 렌더링 속도가 느리면 시스템에서 프레임을 건너뛰게 되고 사용자는 앱에서 끊김 현상을 인식합니다. 이를 버벅거림이라고 합니다. 이 페이지에서는 버벅거림을 진단하고 해결하는 방법을 보여줍니다.

View 시스템을 사용하지 않는 게임을 개발 중인 경우 Choreographer를 우회하세요. 이 경우 Frame Pacing 라이브러리OpenGL 게임과 Vulkan 게임이 Android에서 원활한 렌더링과 올바른 프레임 속도를 달성하는 데 도움이 됩니다.

앱 품질을 개선하기 위해 Android에서는 앱에 버벅거림이 있는지 자동으로 모니터링하고 Android vitals 대시보드에 정보를 표시합니다. 데이터 수집 방법에 관한 자세한 내용은 Android vitals로 앱의 기술 품질 모니터링을 참고하세요.

버벅거림 식별

버벅거림을 유발하는 앱의 코드를 찾는 것은 어려울 수 있습니다. 이 섹션에서는 버벅거림을 식별하는 다음과 같은 세 가지 방법을 설명합니다.

시각적 검사를 통해 몇 분 이내에 앱의 모든 사용 사례를 살펴볼 수 있지만 시각적 검사는 Systrace만큼 세부적인 정보를 제공하지 않습니다. Systrace는 더 세부적인 정보를 제공하지만 앱의 모든 사용 사례에 Systrace를 실행하면 데이터가 너무 많아져서 분석하기 어려울 수 있습니다. 시각적 검사와 Systrace는 모두 로컬 기기에서 버벅거림을 감지합니다. 로컬 기기에서 버벅거림을 재현할 수 없는 경우 맞춤 성능 모니터링을 빌드하여 현장에서 실행되는 기기의 특정 앱 부분을 측정할 수 있습니다.

시각적 검사

시각적 검사는 버벅거림을 생성하는 사용 사례를 식별하는 데 도움이 됩니다. 시각적 검사를 실행하려면 앱을 열고 앱의 여러 부분을 수동으로 검토한 다음 UI에서 버벅거림이 있는지 찾아보세요.

다음은 시각적 검사 실행 시 필요한 몇 가지 도움말입니다.

  • 출시 버전(또는 최소한 디버그 불가능한 버전)의 앱을 실행하세요. ART 런타임은 디버깅 기능을 지원하기 위해 몇 가지 중요한 최적화를 중지하므로 사용자에게 표시되는 것과 유사한 내용을 보고 있는지 확인하세요.
  • GPU 렌더링 프로파일링을 사용 설정하세요. GPU 렌더링 프로파일링은 프레임당 16ms의 벤치마크를 기준으로 UI 창의 프레임을 렌더링하는 데 걸리는 시간을 시각적으로 표현하는 막대를 화면에 표시합니다. 각 막대에는 렌더링 파이프라인의 단계로 매핑되는 색상이 지정된 구성요소가 있으므로 가장 오래 걸리는 부분을 확인할 수 있습니다. 예를 들어 프레임이 입력을 처리하는 데 많은 시간을 보내는 경우 사용자 입력을 처리하는 앱 코드를 살펴봅니다.
  • RecyclerView와 같이 버벅거림의 일반적인 원인인 구성요소를 실행합니다.
  • 콜드 스타트에서 앱을 실행합니다.
  • 더 느린 기기에서 앱을 실행하여 문제를 악화시킵니다.

버벅거림을 생성하는 사용 사례를 찾으면 앱에서 버벅거림을 유발하는 원인을 파악할 수도 있습니다. 추가 정보가 필요한 경우 Systrace를 사용하여 원인을 더 자세히 살펴볼 수 있습니다.

Systrace

Systrace는 전체 기기에서 실행되는 작업을 표시하는 도구이지만 앱의 버벅거림을 식별하는 데 유용할 수 있습니다. Systrace에는 최소한의 시스템 오버헤드가 있으므로 계측 중에 현실적인 버벅거림을 경험할 수 있습니다.

기기에서 버벅거림의 사용 사례를 실행하는 동안 Systrace를 사용하여 트레이스를 기록하세요. Systrace 사용 방법에 관한 안내는 명령줄에서 시스템 트레이스 캡처를 참고하세요. Systrace는 프로세스와 스레드로 분할됩니다. 그림 1과 같이 Systrace에서 앱의 프로세스를 찾습니다.

Systrace 예
그림 1. Systrace 예

그림 1의 Systrace 예에는 버벅거림을 식별하기 위한 다음 정보가 포함되어 있습니다.

  1. Systrace는 각 프레임이 그려진 시점을 표시하고 각 프레임에 색상을 코딩하여 느린 렌더링 시간을 강조 표시합니다. 이를 통해 버벅거림이 발생한 개별 프레임을 시각적 검사보다 더 정확하게 찾을 수 있습니다. 자세한 내용은 UI 프레임 및 알림 검사를 참고하세요.
  2. Systrace는 앱의 문제를 감지하고 개별 프레임과 알림 패널 모두에 알림을 표시합니다. 알림의 안내를 따르는 것이 가장 좋습니다.
  3. RecyclerView와 같은 일부 Android 프레임워크 및 라이브러리에는 트레이스 마커가 포함되어 있습니다. 따라서 Systrace 타임라인에는 이러한 메서드가 UI 스레드에서 실행되는 시점과 실행에 걸리는 시간이 표시됩니다.

Systrace 출력을 확인한 후 앱에서 버벅거림을 유발하는 것으로 의심되는 메서드를 찾을 수 있습니다. 예를 들어 오랜 시간이 걸리는 RecyclerView로 인해 느린 프레임이 발생한 것으로 타임라인에 표시되면 관련 코드에 맞춤 트레이스 이벤트를 추가하고 Systrace를 다시 실행하여 자세한 정보를 확인할 수 있습니다. 새 Systrace의 타임라인에는 앱의 메서드가 호출된 시간과 실행하는 데 걸리는 시간이 표시됩니다.

Systrace에 UI 스레드 작업이 오래 걸리는 이유에 관한 세부정보가 표시되지 않으면 Android CPU 프로파일러를 사용하여 샘플링되거나 계측된 메서드 트레이스를 기록하세요. 일반적으로 메서드 트레이스는 과도한 오버헤드로 인해 거짓양성 버벅거림을 생성하고 스레드가 실행 중인 때와 차단된 때를 확인할 수 없으므로 버벅거림을 식별하는 데 적합하지 않습니다. 하지만 메서드 트레이스는 앱에서 시간이 가장 오래 걸리는 메서드를 식별하는 데 도움이 될 수 있습니다. 이러한 메서드를 식별한 후 트레이스 마커를 추가하고 Systrace를 다시 실행하여 이러한 메서드가 버벅거림의 원인이 되는지 확인하세요.

자세한 내용은 Systrace 이해를 참고하세요.

맞춤 성능 모니터링

로컬 기기에서 버벅거림을 재현할 수 없는 경우 앱에 맞춤 성능 모니터링을 빌드하여 현장에서 기기의 버벅거림 원인을 식별할 수 있습니다.

이를 위해 FrameMetricsAggregator를 사용하여 앱의 특정 부분에서 프레임 렌더링 시간을 수집하고 Firebase Performance Monitoring을 사용하여 데이터를 기록하고 분석하세요.

자세한 내용은 Android 성능 모니터링 시작하기를 참고하세요.

정지된 프레임

정지된 프레임은 렌더링하는 데 700ms보다 오래 걸리는 UI 프레임입니다. 정지된 프레임은 앱이 멈춘 것처럼 보이고 프레임이 렌더링되는 거의 1초 동안 사용자 입력에 응답하지 않기 때문에 문제가 됩니다. 원활한 UI를 보장하려면 16ms 이내에 프레임을 렌더링하도록 앱을 최적화하는 것이 좋습니다. 그러나 앱이 시작되는 동안이나 다른 화면으로 전환하는 중에는 초기 프레임을 그리는 데 16ms보다 오래 걸리는 것이 일반적입니다. 앱이 뷰를 확장하고 화면을 배치하고 초기 그리기를 모두 처음부터 실행해야 하기 때문입니다. 이러한 이유로 Android가 느린 렌더링과 별도로 정지된 프레임을 추적합니다. 렌더링하는 데 700ms보다 오래 걸리는 앱 프레임이 없어야 합니다.

앱 품질을 개선하기 위해 Android는 앱에 정지된 프레임이 있는지 자동으로 모니터링하고 Android vitals 대시보드에 정보를 표시합니다. 데이터 수집 방법에 관한 자세한 내용은 Android vitals로 앱의 기술 품질 모니터링을 참고하세요.

정지된 프레임은 느린 렌더링의 극단적인 형태이므로 문제를 진단하고 해결하는 절차는 동일합니다.

버벅거림 추적

FrameTimeline Perfetto를 사용하면 느린 속도나 만들 수 있습니다

느린 프레임, 정지된 프레임, ANR 간의 관계

느린 프레임, 정지된 프레임, ANR은 모두 앱에서 발생할 수 있는 다양한 형태의 버벅거림입니다. 차이점을 확인하려면 아래 표를 참고하세요.

느린 프레임 정지된 프레임 ANR
렌더링 시간 16밀리초~700밀리초 700밀리초~5초 5초 초과
표시되는 사용자 영향 영역
  • RecyclerView 스크롤이 갑자기 동작함
  • 복잡한 애니메이션이 제대로 작동하지 않는 화면
  • 앱 시작 중
  • 한 화면에서 다른 화면으로 이동(예: 화면 전환)
  • 활동이 포그라운드에 있는 동안 앱이 입력 이벤트 또는 BroadcastReceiver(예: 키 누름 또는 화면 탭 이벤트)에 5초 이내에 응답하지 않았습니다.
  • 포그라운드에 활동이 없을 때 BroadcastReceiver가 상당한 시간 내에 실행을 완료하지 못했습니다.

느린 프레임과 정지된 프레임을 별도로 추적

앱이 시작되는 동안이나 다른 화면으로 전환하는 중에는 초기 프레임을 그리는 데 16ms보다 오래 걸리는 것이 일반적입니다. 앱이 뷰를 확장하고 화면을 배치하고 처음부터 초기 그리기를 실행해야 하기 때문입니다.

버벅거림의 우선순위 지정 및 해결 권장사항

앱에서 버벅거림을 해결할 때는 다음 권장사항에 유의하세요.

  • 가장 쉽게 재현 가능한 버벅거림 인스턴스를 식별하고 해결합니다.
  • ANR의 우선순위를 지정합니다. 느린 프레임이나 정지된 프레임으로 인해 앱이 느리게 보일 수 있지만 ANR로 인해서는 앱이 더 이상 응답하지 않습니다.
  • 느린 렌더링을 재현하기는 어렵지만, 700ms의 정지된 프레임을 종료하여 시작할 수 있습니다. 이는 앱이 화면을 시작하거나 변경할 때 가장 일반적입니다.

버벅거림 해결

버벅거림을 해결하려면 16ms 이내에 완료되지 않은 프레임을 검사하여 잘못된 부분을 찾으세요. Record View#draw 또는 Layout이 일부 프레임에서 비정상적으로 오래 걸리는지 확인합니다. 이러한 문제 및 기타 문제는 버벅거림의 일반적인 원인을 참고하세요.

버벅거림을 피하려면 장기 실행 작업을 UI 스레드 외부에서 비동기적으로 실행하세요. 코드가 실행 중인 스레드를 항상 파악하고, 중요한 작업을 기본 스레드에 게시할 때는 주의해야 합니다.

앱에 복잡하고 중요한 기본 UI(예: 중앙 스크롤 목록)가 있는 경우 느린 렌더링 시간을 자동으로 감지하고 테스트를 자주 실행하여 회귀를 방지할 수 있는 계측 테스트를 작성해 보세요.

버벅거림의 일반적인 원인

다음 섹션에서는 View 시스템을 사용하는 앱에서 발생하는 버벅거림의 일반적인 원인과 이를 해결하기 위한 권장사항을 설명합니다. Jetpack Compose의 성능 문제 해결에 관한 자세한 내용은 Jetpack Compose 성능을 참고하세요.

스크롤 가능 목록

ListView 및 특히 RecyclerView는 일반적으로 버벅거림이 가장 발생하기 쉬운 복잡한 스크롤 목록에 사용됩니다. 둘 다 Systrace 마커를 포함하고 있으므로 Systrace를 사용하여 앱의 버벅거림에 영향을 미치는지 확인할 수 있습니다. RecyclerView의 트레이스 섹션과 추가된 트레이스 마커를 표시하려면 명령줄 인수 -a <your-package-name>을 전달하세요. 제공되는 경우 Systrace 출력에 생성된 알림의 안내를 따르세요. Systrace 내부에서 RecyclerView-traced 섹션을 클릭하여 RecyclerView가 실행하는 작업에 관한 설명을 볼 수 있습니다.

RecyclerView: notifyDataSetChanged()

RecyclerView의 모든 항목이 한 프레임에서 다시 결합되어 다시 배치되고 다시 그려지는 것을 발견하는 경우 부분 업데이트를 위해 notifyDataSetChanged(), setAdapter(Adapter) 또는 swapAdapter(Adapter, boolean)를 호출하지 않아야 합니다. 이러한 메서드는 전체 목록 콘텐츠가 변경되었다는 신호를 보내고 Systrace에 RV FullInvalidate로 표시됩니다. 대신 SortedList 또는 DiffUtil을 사용하여 콘텐츠가 변경되거나 추가될 때 최소한의 업데이트를 생성하세요.

서버에서 새 버전의 뉴스 콘텐츠 목록을 받는 앱을 예로 들어 보겠습니다. 어댑터에 이 정보를 게시할 때 다음 예와 같이 notifyDataSetChanged()를 호출할 수 있습니다.

Kotlin

fun onNewDataArrived(news: List<News>) {
    myAdapter.news = news
    myAdapter.notifyDataSetChanged()
}

Java

void onNewDataArrived(List<News> news) {
    myAdapter.setNews(news);
    myAdapter.notifyDataSetChanged();
}

단점은 상단에 추가된 단일 항목과 같이 사소한 변경사항이 있는 경우 RecyclerView가 인식하지 못한다는 것입니다. 따라서 캐시된 항목 상태를 전부 삭제하라는 지시를 받게 되므로 모든 항목을 다시 결합해야 합니다.

최소한의 업데이트를 계산하고 전달하는 DiffUtil을 사용하는 것이 좋습니다.

Kotlin

fun onNewDataArrived(news: List<News>) {
    val oldNews = myAdapter.items
    val result = DiffUtil.calculateDiff(MyCallback(oldNews, news))
    myAdapter.news = news
    result.dispatchUpdatesTo(myAdapter)
}

Java

void onNewDataArrived(List<News> news) {
    List<News> oldNews = myAdapter.getItems();
    DiffResult result = DiffUtil.calculateDiff(new MyCallback(oldNews, news));
    myAdapter.setNews(news);
    result.dispatchUpdatesTo(myAdapter);
}

목록을 검사하는 방법을 DiffUtil에 알리려면 MyCallbackCallback 구현으로 정의합니다.

RecyclerView: 중첩된 RecyclerView

특히 가로 스크롤 목록의 세로 목록에서 RecyclerView의 여러 인스턴스를 중첩하는 것이 일반적입니다. Play 스토어 기본 페이지에 있는 앱의 그리드를 예로 들 수 있습니다. 이러한 중첩은 잘 작동할 수 있지만 많은 뷰의 이동이 있습니다.

처음으로 페이지를 아래로 스크롤할 때 많은 내부 항목이 확장되는 경우 내부(가로) RecyclerView 인스턴스 간에 RecyclerView.RecycledViewPool을 공유하고 있는지 확인하는 것이 좋습니다. 기본적으로 각 RecyclerView에는 고유한 항목 풀이 있습니다. 하지만 동시에 여러 개의 itemViews가 화면에 있으면 모든 행이 유사한 유형의 뷰를 표시하는 경우 여러 가로 목록에서 itemViews를 공유할 수 없을 때 문제가 발생합니다.

Kotlin

class OuterAdapter : RecyclerView.Adapter<OuterAdapter.ViewHolder>() {

    ...

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        // Inflate inner item, find innerRecyclerView by ID.
        val innerLLM = LinearLayoutManager(parent.context, LinearLayoutManager.HORIZONTAL, false)
        innerRv.apply {
            layoutManager = innerLLM
            recycledViewPool = sharedPool
        }
        return OuterAdapter.ViewHolder(innerRv)
    }
    ...

Java

class OuterAdapter extends RecyclerView.Adapter<OuterAdapter.ViewHolder> {
    RecyclerView.RecycledViewPool sharedPool = new RecyclerView.RecycledViewPool();

    ...

    @Override
    public void onCreateViewHolder(ViewGroup parent, int viewType) {
        // Inflate inner item, find innerRecyclerView by ID.
        LinearLayoutManager innerLLM = new LinearLayoutManager(parent.getContext(),
                LinearLayoutManager.HORIZONTAL);
        innerRv.setLayoutManager(innerLLM);
        innerRv.setRecycledViewPool(sharedPool);
        return new OuterAdapter.ViewHolder(innerRv);

    }
    ...

추가로 최적화하려는 경우 내부 RecyclerViewLinearLayoutManager에서 setInitialPrefetchItemCount(int)를 호출할 수도 있습니다. 예를 들어 항상 3.5개의 항목이 행에 표시되는 경우 innerLLM.setInitialItemPrefetchCount(4)를 호출하세요. 이는 가로 행이 화면에 나타날 때 UI 스레드에 여유 시간이 있으면 내부 항목을 미리 가져오려고 시도해야 한다고 RecyclerView에 알립니다.

RecyclerView: 확장이 너무 많거나 생성에 시간이 너무 오래 걸림

대부분의 경우 RecyclerView의 미리 가져오기 기능은 UI 스레드가 유휴 상태일 때 미리 작업을 실행하여 확장 비용을 해결하는 데 도움이 될 수 있습니다. 프레임 중에 확장이 나타나는데 RV Prefetch 라벨이 지정된 섹션에는 나타나지 않는 경우 지원되는 기기에서 테스트하고 최신 버전의 지원 라이브러리를 사용하고 있는지 확인하세요. 미리 가져오기는 Android 5.0 API 수준 21 이상에서만 지원됩니다.

새 항목이 화면에 표시될 때 버벅거림을 유발하는 확장이 자주 발생하면 필요한 것보다 더 많은 뷰 유형이 있지 않은지 확인하세요. RecyclerView 콘텐츠의 뷰 유형이 적을수록 새 항목 유형이 화면에 표시될 때 실행되어야 하는 확장이 줄어듭니다. 가능하다면 적합한 경우 뷰 유형을 병합하세요. 유형 간에 아이콘, 색상 또는 텍스트만 변경되는 경우 결합 시 변경하여 확장을 방지할 수 있으므로 동시에 앱의 메모리 사용량을 줄일 수 있습니다.

뷰 유형이 적절해 보이면 확장 비용을 줄이세요. 불필요한 컨테이너와 구조 뷰를 줄이는 것이 도움이 될 수 있습니다. 구조 뷰를 줄이는 데 도움이 되는 ConstraintLayout으로 itemViews를 빌드하는 것이 좋습니다.

성능을 위해 추가로 최적화하려고 하는데 항목 계층 구조가 단순하고 복잡한 테마 설정 및 스타일 기능이 필요하지 않다면 생성자를 직접 호출해 보세요. 그러나 XML의 단순성과 기능을 포기할 만한 가치가 없는 경우가 많습니다.

RecyclerView: 결합이 너무 오래 걸림

결합(즉, onBindViewHolder(VH, int))은 간단해야 하며 가장 복잡한 항목을 제외한 모든 항목에 1밀리초 미만이 소요되어야 합니다. 어댑터의 내부 항목 데이터에서 POJO(Plain Old Java Object)를 가져오고 ViewHolder의 뷰에서 setter를 호출해야 합니다. RV OnBindView에 오랜 시간이 걸리는 경우 결합 코드에서 최소한의 작업을 실행 중인지 확인하세요.

기본 POJO 객체를 사용하여 데이터를 어댑터에 보존하는 경우에는 데이터 결합 라이브러리를 사용하여 onBindViewHolder에 결합 코드를 작성하지 않아도 됩니다.

RecyclerView 또는 ListView: 레이아웃 또는 그리기가 너무 오래 걸림

그리기 및 레이아웃 문제는 레이아웃 성능렌더링 성능 섹션을 참고하세요.

ListView: 확장

주의하지 않으면 ListView에서 실수로 재활용을 사용 중지할 수 있습니다. 항목이 화면에 표시될 때마다 확장이 발생하면 Adapter.getView()의 구현이 convertView 매개변수를 사용하고 다시 결합하여 반환하는지 확인하세요. getView() 구현이 항상 확장되는 경우 앱이 ListView에서 재활용의 이점을 얻지 못합니다. getView()의 구조는 거의 항상 다음 구현과 유사해야 합니다.

Kotlin

fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
    return (convertView ?: layoutInflater.inflate(R.layout.my_layout, parent, false)).apply {
        // Bind content from position to convertView.
    }
}

자바

View getView(int position, View convertView, ViewGroup parent) {

    if (convertView == null) {
        // Only inflate if no convertView passed.
        convertView = layoutInflater.inflate(R.layout.my_layout, parent, false)
    }
    // Bind content from position to convertView.
    return convertView;
}

레이아웃 성능

Systrace에 Choreographer#doFrame레이아웃 세그먼트가 너무 많이 작동하거나 너무 자주 작동하는 것으로 표시되면 레이아웃 성능 문제가 발생한 것입니다. 앱의 레이아웃 성능은 레이아웃 매개변수 또는 입력을 변경하는 뷰 계층 구조의 부분에 따라 달라집니다.

레이아웃 성능: 비용

세그먼트가 몇 밀리초보다 길면 RelativeLayouts 또는 weighted-LinearLayouts의 최악의 중첩 성능이 발생할 수 있습니다. 이러한 각 레이아웃은 하위 요소의 여러 측정 및 레이아웃 패스를 트리거할 수 있으므로 이를 중첩하면 중첩 깊이에서 O(n^2) 동작이 발생할 수 있습니다.

계층 구조의 최하위 리프 노드를 제외한 모든 노드에서 RelativeLayout 또는 LinearLayout의 가중치 기능을 사용하지 마세요. 방법은 다음과 같습니다.

  • 구조 뷰를 재구성합니다.
  • 맞춤 레이아웃 로직을 정의합니다. 구체적인 예는 레이아웃 계층 구조 최적화를 참고하세요. 성능상의 단점 없이 유사한 기능을 제공하는 ConstraintLayout으로의 변환을 시도할 수 있습니다.

레이아웃 성능: 실행 빈도

새 항목이 RecyclerView의 뷰로 스크롤될 때와 같이 새로운 콘텐츠가 화면에 나타나는 경우 레이아웃이 발생할 것으로 예상됩니다. 각 프레임에서 상당한 레이아웃이 발생하면 레이아웃을 애니메이션 처리하여 누락된 프레임이 발생할 수 있습니다.

일반적으로 애니메이션은 다음과 같이 View의 그리기 속성에서 실행되어야 합니다.

이러한 모든 속성을 패딩이나 여백과 같은 레이아웃 속성보다 훨씬 저렴하게 변경할 수 있습니다. 또한 일반적으로 invalidate()를 트리거한 후 다음 프레임에서 draw(Canvas)를 트리거하는 setter를 호출하여 뷰의 그리기 속성을 변경하는 것이 훨씬 더 저렴합니다. 그러면 무효화된 뷰의 그리기 작업이 다시 기록되며 일반적으로 레이아웃보다 훨씬 더 저렴합니다.

렌더링 성능

Android UI는 두 단계로 작동합니다.

  • UI 스레드의 Record View#draw: 무효화된 모든 뷰에서 draw(Canvas)를 실행하고 맞춤 뷰 또는 코드를 호출할 수 있습니다.
  • RenderThreadDrawFrame: 네이티브 RenderThread에서 실행되지만 Record View#draw 단계에서 생성된 작업을 기반으로 작동합니다.

렌더링 성능: UI 스레드

Record View#draw가 오래 걸리는 경우 비트맵이 UI 스레드에서 페인팅되는 경우가 일반적입니다. 비트맵에 페인팅하면 CPU 렌더링이 사용되므로 가능한 경우 일반적으로 이를 피해야 합니다. Android CPU 프로파일러에서 메서드 추적을 사용하여 이것이 문제인지 확인할 수 있습니다.

앱에서 비트맵을 표시하기 전에 장식하려는 경우(때로는 둥근 모서리 추가와 같은 장식) 비트맵에 페인팅하는 경우가 있습니다.

Kotlin

val paint = Paint().apply {
    isAntiAlias = true
}
Canvas(roundedOutputBitmap).apply {
    // Draw a round rect to define the shape:
    drawRoundRect(
            0f,
            0f,
            roundedOutputBitmap.width.toFloat(),
            roundedOutputBitmap.height.toFloat(),
            20f,
            20f,
            paint
    )
    paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.MULTIPLY)
    // Multiply content on top to make it rounded.
    drawBitmap(sourceBitmap, 0f, 0f, paint)
    setBitmap(null)
    // Now roundedOutputBitmap has sourceBitmap inside, but as a circle.
}

자바

Canvas bitmapCanvas = new Canvas(roundedOutputBitmap);
Paint paint = new Paint();
paint.setAntiAlias(true);
// Draw a round rect to define the shape:
bitmapCanvas.drawRoundRect(0, 0,
        roundedOutputBitmap.getWidth(), roundedOutputBitmap.getHeight(), 20, 20, paint);
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.MULTIPLY));
// Multiply content on top to make it rounded.
bitmapCanvas.drawBitmap(sourceBitmap, 0, 0, paint);
bitmapCanvas.setBitmap(null);
// Now roundedOutputBitmap has sourceBitmap inside, but as a circle.

UI 스레드에서 실행 중인 작업인 경우 대신 백그라운드의 디코딩 스레드에서 실행할 수 있습니다. 위 예와 같이 경우에 따라 그리기 시간에 작업을 실행할 수도 있습니다. 따라서 Drawable 또는 View 코드가 다음과 같다면

Kotlin

fun setBitmap(bitmap: Bitmap) {
    mBitmap = bitmap
    invalidate()
}

override fun onDraw(canvas: Canvas) {
    canvas.drawBitmap(mBitmap, null, paint)
}

자바

void setBitmap(Bitmap bitmap) {
    mBitmap = bitmap;
    invalidate();
}

void onDraw(Canvas canvas) {
    canvas.drawBitmap(mBitmap, null, paint);
}

다음과 같이 바꿀 수 있습니다.

Kotlin

fun setBitmap(bitmap: Bitmap) {
    shaderPaint.shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
    invalidate()
}

override fun onDraw(canvas: Canvas) {
    canvas.drawRoundRect(0f, 0f, width, height, 20f, 20f, shaderPaint)
}

Java

void setBitmap(Bitmap bitmap) {
    shaderPaint.setShader(
            new BitmapShader(bitmap, TileMode.CLAMP, TileMode.CLAMP));
    invalidate();
}

void onDraw(Canvas canvas) {
    canvas.drawRoundRect(0, 0, width, height, 20, 20, shaderPaint);
}

이는 비트맵을 수정하여 실행되는 두 개의 다른 일반 작업, 즉 배경 보호(예: 비트맵 위에 그라데이션 그리기) 및 이미지 필터링(ColorMatrixColorFilter 사용)을 위해 실행될 수도 있습니다.

다른 이유로 비트맵에 그리는 경우(캐시로 사용할 수 있음) View 또는 Drawable에 직접 전달된 하드웨어 가속 Canvas에 그려 보세요. 필요한 경우 LAYER_TYPE_HARDWAREsetLayerType()을 호출하여 복잡한 렌더링 출력을 캐시하면서도 GPU 렌더링을 활용하는 것도 좋습니다.

렌더링 성능: RenderThread

일부 Canvas 작업은 적은 비용으로 기록할 수 있지만 RenderThread에서 비용이 많이 드는 계산을 트리거합니다. Systrace는 일반적으로 알림과 함께 이러한 작업을 호출합니다.

큰 경로 애니메이션 처리

Canvas.drawPath()View에 전달된 하드웨어 가속 Canvas에서 호출되면 Android에서 이러한 경로를 먼저 CPU에 그리고 GPU에 업로드합니다. 큰 경로가 있는 경우 효율적으로 캐시되고 그려질 수 있도록 프레임마다 수정하지 마세요. 그리기 호출을 더 많이 사용하더라도 drawPoints(), drawLines(), drawRect/Circle/Oval/RoundRect()가 더 효율적이고 사용하기 좋습니다.

Canvas.clipPath

clipPath(Path)는 비용이 많이 드는 클리핑 동작을 트리거하므로 일반적으로 사용하지 않아야 합니다. 가능한 경우 직사각형이 아닌 도형으로 클리핑하는 대신 도형을 그리도록 선택하세요. 이렇게 하면 성능이 향상되고 앤티앨리어싱이 지원됩니다. 예를 들어 다음 clipPath 호출은 다르게 표현할 수 있습니다.

Kotlin

canvas.apply {
    save()
    clipPath(circlePath)
    drawBitmap(bitmap, 0f, 0f, paint)
    restore()
}

Java

canvas.save();
canvas.clipPath(circlePath);
canvas.drawBitmap(bitmap, 0f, 0f, paint);
canvas.restore();

대신 앞의 예를 다음과 같이 표현하세요.

Kotlin

paint.shader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
// At draw time:
canvas.drawPath(circlePath, mPaint)

자바

// One time init:
paint.setShader(new BitmapShader(bitmap, TileMode.CLAMP, TileMode.CLAMP));
// At draw time:
canvas.drawPath(circlePath, mPaint);
비트맵 업로드

Android는 비트맵을 OpenGL 텍스처로 표시하며 비트맵이 처음 프레임에 표시될 때 GPU로 업로드됩니다. Systrace에서 텍스처 업로드(id) 너비 x 높이로 이를 확인할 수 있습니다. 그림 2에 표시된 것처럼 몇 밀리초가 걸릴 수 있지만 GPU로 이미지를 표시해야 합니다.

시간이 오래 걸리는 경우 먼저 트레이스에서 너비와 높이 수를 확인하세요. 표시할 비트맵이 해당하는 화면의 영역보다 훨씬 더 크지 않도록 하세요. 표시할 비트맵이 훨씬 크면 업로드 시간과 메모리가 낭비됩니다. 일반적으로 비트맵 로드 라이브러리를 사용하면 적절한 크기의 비트맵을 요청할 수 있습니다.

Android 7.0에서는 일반적으로 라이브러리에서 실행되는 비트맵 로드 코드가 prepareToDraw()를 호출하여 필요하기 전에 미리 업로드를 트리거합니다. 이렇게 하면 RenderThread가 유휴 상태인 동안 업로드가 미리 발생합니다. 이 작업은 디코딩 후, 또는 비트맵을 알고 있는 경우 비트맵을 뷰와 결합할 때 실행될 수 있습니다. 비트맵 로드 라이브러리에서 자동으로 이 작업을 실행하는 것이 이상적이지만 자체적으로 업로드를 관리 중이거나 최신 기기에서 업로드가 발생하지 않도록 하려면 자체 코드에서 prepareToDraw()를 호출하면 됩니다.

앱이 큰 비트맵을 업로드하는 프레임에서 상당한 시간을 소요함
그림 2. 앱이 큰 비트맵을 업로드하는 프레임에서 상당한 시간을 보냅니다. 크기를 줄이거나 prepareToDraw()로 디코딩할 때 미리 트리거하세요.

스레드 예약 지연

스레드 스케줄러는 Android 운영체제의 일부이며 이 운영체제에서 실행해야 하는 스레드와 실행 시기, 실행 기간을 결정합니다.

앱의 UI 스레드가 차단되었거나 실행 중이지 않아서 버벅거림이 발생하는 경우가 있습니다. Systrace는 그림 3과 같이 다양한 색상을 사용하여 스레드가 절전 모드(회색), 실행 가능(파란색: 실행될 수 있지만 스케줄러가 실행하려고 아직 선택하지 않음), 활발하게 실행 중(녹색) 또는 무중단 절전 모드(빨간색 또는 주황색)인지 나타냅니다. Systrace는 스레드 예약 지연으로 인해 발생하는 버벅거림 문제를 디버깅하는 데 매우 유용합니다.

UI 스레드가 절전 모드인 기간을 강조 표시합니다.
그림 3. UI 스레드가 절전 모드인 기간 강조 표시

Android의 프로세스 간 통신(IPC) 메커니즘인 바인더 호출로 인해 앱 실행이 오랫동안 일시중지되는 경우가 많습니다. 최신 버전의 Android에서는 이것이 UI 스레드가 실행을 중지하는 가장 일반적인 이유 중 하나입니다. 일반적으로 해결 방법은 바인더 호출을 실행하는 함수를 호출하지 않는 것입니다. 불가피한 경우 값을 캐시하거나 작업을 백그라운드 스레드로 이동합니다. 코드베이스가 커질수록 주의하지 않으면 일부 하위 수준 메서드를 호출하여 실수로 바인더 호출을 추가할 수 있습니다. 하지만 추적으로 이를 찾아 해결할 수 있습니다.

바인더 트랜잭션이 있는 경우 다음 adb 명령어를 사용하여 호출 스택을 캡처할 수 있습니다.

$ adb shell am trace-ipc start
… use the app - scroll/animate ...
$ adb shell am trace-ipc stop --dump-file /data/local/tmp/ipc-trace.txt
$ adb pull /data/local/tmp/ipc-trace.txt

getRefreshRate()와 같이 무해하게 보이는 호출이 바인더 트랜잭션을 트리거하고 자주 호출될 때 큰 문제를 일으킬 수 있습니다. 주기적으로 추적하면 이러한 문제가 발생할 때 찾아서 해결할 수 있습니다.

RV 플링의 바인더 트랜잭션으로 인한 UI 스레드 절전 모드를 보여줍니다. 결합 로직을 단순하게 유지하고 trace-ipc를 사용하여 바인더 호출을 추적하여 삭제하세요.
그림 4. RV 플링의 바인더 트랜잭션으로 인해 UI 스레드가 절전 모드입니다. 결합 로직을 단순하게 유지하고 trace-ipc를 사용하여 바인더 호출을 추적하여 삭제하세요.

바인더 활동이 보이지 않지만 UI 스레드가 여전히 실행되지 않는 경우 다른 스레드의 다른 작업 또는 잠금을 대기 중이지 않은지 확인하세요. 일반적으로 UI 스레드는 다른 스레드의 결과를 기다릴 필요가 없습니다. 다른 스레드가 UI 스레드에 정보를 게시해야 합니다.

객체 할당 및 가비지 컬렉션

Android 5.0에서 ART가 기본 런타임으로 도입된 이후로 객체 할당 및 가비지 컬렉션(GC) 문제가 크게 줄었지만 이 추가 작업으로 스레드에 부담을 줄 수 있습니다. 사용자가 버튼을 탭하는 것과 같이 초당 여러 번 발생하지 않는 드문 이벤트에 관한 응답으로 할당하는 것은 괜찮지만 각 할당에는 비용이 따른다는 점을 기억하세요. 자주 호출되는 단단한 루프에 있는 경우 GC의 부하를 줄이기 위해 할당을 피하세요.

Systrace는 GC가 자주 실행되는지 보여주고 Android 메모리 프로파일러는 할당이 발생하는 위치를 보여줄 수 있습니다. 특히 단단한 루프에서 가능한 한 할당을 피하면 문제가 발생할 가능성이 줄어듭니다.

HeapTaskDaemon의 94ms GC를 보여줍니다.
그림 5. HeapTaskDaemon 스레드의 94ms GC

최신 버전의 Android에서는 GC가 일반적으로 HeapTaskDaemon이라는 백그라운드 스레드에서 실행됩니다. 그림 5와 같이 할당량이 많으면 GC에 더 많은 CPU 리소스가 사용될 수 있습니다.