느린 렌더링

UI 렌더링은 앱에서 프레임을 생성하여 화면에 표시하는 작업입니다. 사용자와 앱의 상호작용이 원활하게 이루어지도록 하려면 앱이 16ms 미만으로 프레임을 렌더링하여 초당 60프레임(왜 60fps일까요? 참조)을 달성해야 합니다. 앱의 UI 렌더링 속도가 느리면 시스템에서 강제로 프레임을 건너뛰고 사용자가 앱 끊김을 인지합니다. 이 현상을 버벅거림이라고 합니다.

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

이 페이지에서는 앱에 버벅거림이 발생하는 경우 문제를 진단하고 해결하는 방법에 관한 안내를 제공합니다.

버벅거림 식별

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

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

시각적 검사 사용

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

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

버벅거림을 생성하는 사용 사례를 찾았으면 앱의 버벅거림을 유발하는 원인을 알아볼 수 있습니다. 그러나 더 많은 정보가 필요한 경우 Systrace를 사용하여 상세히 살펴볼 수 있습니다.

Systrace 사용

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

기기에서 버벅거림의 사용 사례를 실행하는 동안 Systrace를 사용하여 트레이스를 기록하세요. Systrace 사용 방법에 관한 안내는 Systrace 둘러보기를 참조하세요. Systrace는 프로세스와 스레드로 구분됩니다. Systrace에서 앱의 프로세스를 찾으세요(그림 1 참조).

그림 1: Systrace

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

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

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

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

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

맞춤 성능 모니터링 사용

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

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

자세한 내용은 Android vitals에 Firebase Performance Monitoring 사용을 참조하세요.

버벅거림 해결

버벅거림을 해결하려면 16.7ms 이내에 완료되지 않은 프레임을 검사하여 문제를 찾으세요. 일부 프레임에서 Record View#draw가 비정상적으로 오래 걸립니까, 아니면 Layout에 문제가 있습니까? 이러한 문제 및 기타 문제는 아래 버벅거림의 일반적인 원인을 참조하세요.

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

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

버벅거림의 일반적인 원인

다음 섹션에서는 앱이 버벅거리는 일반적인 원인과 이를 해결하기 위한 권장사항을 설명합니다.

스크롤 가능 목록

ListView 및 특히 RecyclerView는 일반적으로 버벅거림이 가장 발생하기 쉬운 복잡한 스크롤 목록에 사용됩니다. 둘 다에 Systrace 마커가 포함되어 있으므로 Systrace를 사용하여 ListView와 RecyclerView가 앱 버벅거림의 원인이 되는지 여부를 알아볼 수 있습니다. RecyclerView에 트레이스 섹션과 추가한 트레이스 마커를 표시하려면 명령줄 인수 -a <your-package-name>을 전달해야 합니다. 제공되는 경우 Systrace 출력에 생성된 알림의 안내를 따르세요. Systrace 내부에서 RecyclerView 트레이스 섹션을 클릭하여 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()
    }
    

자바

    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)
    }
    

자바

    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에 알리려면 MyCallback을 DiffUtil.Callback 구현으로 정의하기만 하면 됩니다.

RecyclerView: 중첩된 RecyclerView

특히 Play 스토어 기본 페이지의 앱 그리드와 같이 가로로 스크롤하는 목록의 세로 목록과 RecyclerView를 중첩하는 것이 일반적입니다. 이러한 중첩은 잘 작동할 수 있지만 많은 뷰의 이동이 있습니다. 처음으로 페이지를 아래로 스크롤할 때 많은 내부 항목이 확장되는 경우 내부(가로) 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)
        }
        ...
    

자바

    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);

        }
        ...
    

추가로 최적화하려는 경우 내부 RecyclerView의 LinearLayoutManager에서 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 항목을 가져와서 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#doFrameLayout 세그먼트가 너무 많은 작업을 실행하거나 너무 자주 작업을 실행하는 것으로 표시되면 레이아웃 성능 문제가 발생한 것입니다. 앱의 레이아웃 성능은 레이아웃 매개변수 또는 입력을 변경하는 뷰 계층 구조의 부분에 따라 달라집니다.

레이아웃 성능: 비용

세그먼트가 몇 밀리초보다 길면 최악의 경우 RelativeLayouts 또는 weighted-LinearLayouts에 관한 중첩된 성능이 발생할 수 있습니다. 이러한 각 레이아웃은 하위 요소의 여러 측정/레이아웃 패스를 트리거할 수 있으므로 중첩하면 중첩 깊이에서 O(n^2) 동작이 발생할 수 있습니다. 계층 구조의 최하위 리프 노드를 제외한 모든 노드에서 RelativeLayout 또는 LinearLayout의 가중치 기능을 피하세요. 다음과 같은 방법을 사용하세요.

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

레이아웃 성능: 실행 빈도

새로운 콘텐츠가 화면에 나타날 때, 예를 들어 새 항목이 RecyclerView의 뷰로 스크롤할 때 레이아웃이 발생할 것으로 예상됩니다. 각 프레임에서 상당한 레이아웃이 발생하면 레이아웃을 애니메이션 처리하여 누락된 프레임이 발생할 수 있습니다. 일반적으로 애니메이션은 View의 그리기 속성(예: setTranslationX/Y/Z(), setRotation(), setAlpha())에서 실행되어야 합니다. 이러한 속성은 모두 레이아웃 속성(예: 패딩 또는 여백)보다 훨씬 더 저렴하게 변경할 수 있습니다. 또한 일반적으로 invalidate()를 트리거한 후 다음 프레임에서 draw(Canvas)를 트리거하는 setter를 호출하여 뷰의 그리기 속성을 변경하는 것이 훨씬 더 저렴합니다. 그러면 무효된 뷰의 그리기 작업이 다시 기록되며 일반적으로 레이아웃보다 훨씬 더 저렴합니다.

렌더링 성능

Android UI는 두 단계(UI 스레드의 Record View#draw 및 RenderThread의 DrawFrame)로 작동합니다. 첫 번째는 무효화된 모든 View에서 draw(Canvas)를 실행하며 맞춤 뷰 또는 코드로 호출할 수 있습니다. 두 번째는 네이티브 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 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 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)
    }
    

자바

    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 사용)을 위해 실행될 수도 있습니다.

비트맵을 캐시로 사용하는 등의 다른 이유로 비트맵에 그리는 경우 뷰 또는 드로어블에 직접 전달된 하드웨어 가속 캔버스에 그리세요. 또한 필요한 경우 setLayerType()LAYER_TYPE_HARDWARE와 함께 호출하여 복잡한 렌더링 출력을 캐시하고 GPU 렌더링을 활용하세요.

렌더링 성능: RenderThread

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

Canvas.saveLayer()

프레임마다 비용이 많이 들고 캐시되지 않는 화면 밖 렌더링을 트리거할 수 있으므로 Canvas.saveLayer()를 피하세요. Android 6.0에서는 성능이 개선되었지만(GPU에서 렌더링 타겟 전환을 피하도록 최적화된 경우) 가능하면 이 비용이 많이 드는 API를 피하는 것이 좋습니다. 또는 최소한 Canvas.CLIP_TO_LAYER_SAVE_FLAG를 전달하거나 플래그를 사용하지 않는 변형을 호출하도록 하세요.

큰 경로 애니메이션 처리

Canvas.drawPath()가 뷰에 전달된 하드웨어 가속 캔버스에서 호출되면 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()
    }
    

자바

    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에서 업로드 너비 x 높이 텍스처로 이를 볼 수 있습니다. 이 작업은 몇 밀리초가 걸릴 수 있지만(그림 2 참조) GPU로 이미지를 표시해야 합니다.

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

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

그림 2: 앱이 1.8메가픽셀 비트맵을 업로드하는 프레임에서 10ms 이상을 보냅니다. 크기를 줄이거나 prepareToDraw()로 디코딩할 때 사전에 트리거하세요.

스레드 예약 지연

스레드 스케줄러는 Android 운영체제의 일부이며 이 운영체제에서 실행할 스레드, 실행 시기 및 실행 기간을 결정합니다. 앱의 UI 스레드가 차단되었거나 실행 중이지 않아서 버벅거림이 발생하는 경우가 있습니다. Systrace에서는 다양한 색상(그림 3 참조)을 사용하여 스레드가 절전 모드(회색), 실행 가능(파란색, 실행될 수 있지만 스케줄러가 아직 실행하도록 선택하지 않은 상태), 실행 중(녹색) 또는 무중단 절전 모드(빨간색 또는 주황색)인지를 표시합니다. Systrace는 스레드 예약 지연으로 인해 발생하는 버벅거림 문제를 디버깅하는 데 매우 유용합니다.

그림 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()와 같은 무해하게 보이는 호출이 바인더 트랜잭션을 트리거할 수 있으며 자주 호출될 때 큰 문제를 유발할 수 있습니다. 주기적으로 추적하면 이러한 문제가 발생할 때 빠르게 찾아서 해결할 수 있습니다.

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

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

객체 할당 및 가비지 컬렉션

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

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

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

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