서론
오늘은 3편에 이어 리사이클러뷰의 성능을 개선하기 위해 시도했던 다양한 방법들에 대해서 소개해볼까 합니다. 3편 마지막에 짧게 언급한 GPU 렌더링 프레임부터 시작해서 레이아웃 평탄화 등등 제가 시도한 과정들을 소개해드리고 결과를 한번 리뷰해보겠습니다.
1. 렌더링 측정 도구들에 대해 알아보자
1.1 프로필 GPU 렌더링
안드로이드 스마트폰의 개발자 옵션에서 설정할 수 있는 프로필 GPU 렌더링은 현재 화면에서 실행중인 어플리케이션의 렌더링 시간을 막대 형태로 보여줍니다. 각각의 수직 막대는 한 프레임인데 높이가 높을수록 렌더링에 오랜 시간이 소요됐음을 뜻합니다.
하단에 수평으로된 하나의 녹색선이 있습니다. 이 라인은 16.67ms를 나타냅니다. 초당 60 프레임이 되려면 이 수직 막대들이 16ms 라인에 닿지 않도록 하면 됩니다. 만약 이 라인 위로 막대가 튀어나오면 해당 프레임이 드랍되어 스크롤시 버벅임이나 Jank가 발생할 수 있습니다.
여기서 말하는 초당 60 프레임이란 사람의 눈은 일반적으로 초당 60프레임 이상의 화면 움직임을 부드럽게 인지합니다. 60프레임 미만으로 떨어지면 화면이 끊기거나 버벅이는 것처럼 보여 사용자 경험을 저해할 수 있습니다.
막대를 보시면 다양한 색상으로 이루어져 있는 것을 알 수 있습니다. 먼저 안드로이드 공식 문서에서 설명하는 각 막대가 의미하는 바는 다음과 같습니다.
위 내용을 알기 쉽게 정리하면 다음과 같습니다.
버퍼 전환 | GPU가 실제로 화면에 그릴 작업을 수행하는 시간입니다. 앱이 GPU에서 너무 많은 작업을 하고 있음을 의미합니다. |
GPU에 복잡한 뷰 로직이 많아 오픈 GL 렌더링 명령어가 많이 필요하다는 의미입니다. |
명령어 사용 | CPU가 렌더링 작업을 수행하는데 걸린 시간입니다. | 복잡한 뷰이거나 대량의 뷰를 다시 그리라는 확률이 높습니다. |
동기화 및 업로드 | Bitmap 정보를 GPU에 업로드하는데 걸린 시간입니다. | 앱이 많은 양의 그래픽을 로드하는데 시간이 오래 걸립니다. |
그리기 | 뷰의 표시 목록을 만들고 업데이트 하는데 걸린 시간입니다. | onDraw 메서드에서 복잡하거나 많은 양의 커스텀뷰 작업이 있을 수 있습니다. |
측정/레이아웃 | 안드로이드에서 뷰를 스크린에 그리기 위해 측정하고 배치하는데 걸리는 시간을 측정한 시간입니다. | 배치할 뷰가 너무 많거나 혹은 계층구조의 잘못된 장소에서 중복 계산이 이뤄집니다. |
애니메이션 | 해당 프레임을 실행 중인 모든 애니메이터를 평가하는데 걸린 시간입니다. | 애니메이션의 속성 변경으로 인해 발생한다. |
기타 시간지연 | 렌더링 시스템이 작업을 수행하는 시간 외에도 메인 스레드에서 발생하는 렌더링과 관련 없는 다른 스레드에서의 작업이 있습니다. | UI 스레드에서 너무 많은 처리가 발생합니다. |
다온길 프로젝트에서 측정해본 결과 다음과 같이 입력처리/애니메이션의 프레임이 유독 길게 측정되고 있습니다. 예상해본 바로는 리사이클러뷰의 뷰홀더에서 서버에서 가져온 이미지를 그리는 과정과 레이아웃 계산하는데 시간이 너무 오래 걸리는 것으로 추측되었습니다.
1.2 오버드로 디버그
다음 사용해본 도구는 오버드로 디버그 입니다. 오버드로란 앱이 한 프레임 내에 같은 픽셀을 두 번 이상 그리는 경우를 의미합니다. 오버드로는 사용자가 화면에서 보는 것에 기여하지 않는 픽셀을 렌더링하기 위해 GPU 시간을 낭비할 때 성능 문제가 됩니다.
공식 문서 에 따르면 OS가 최적화되었기 때문에 오버드로는 2015년 google I/O에서 논의되었던 것처럼 크게 중요한 문제가 아니라고 하지만 작은 부분도 놓치지 않기 위해 이 부분도 짚고 넘어갈 필요가 있다고 생각합니다.
GPU 오버드로 디버그는 다음과 같이 개발자 옵션 -> 하드웨어 가속 렌더링 섹션 -> GPU 오버드로 디버그로 설정할 수 있습니다.
오버드로 디버그를 활성화하면 현재 실행중인 앱의 화면이 여러 색 영역으로 나타납니다. 이러한 색 영역은 오버드로 횟수에 따라 나누어지며 각 색상에 따른 오버로드 횟수는 다음과 같습니다.
- 트루컬러 : 오버드로 없음
- 파란색 : 오버드로 1회
- 녹색 : 오버드로 2회
- 분홍색 : 오버드로 3회
- 빨간색 : 오버드로 4회 이상
안드로이드 공식문서에서 제시하는 오버드로를 줄이는 방법엔 크게 3가지가 있습니다.
1. 레이아웃에서 불필요한 background 속성은 제거하자
backgound 색상을 그리는 작업은 안드로이드 레이아웃이 가지고 있는 기본 배경 색상에 개발자가 지정한 색상을 또 한번 그리는 즉, 오버드로우가 발생한 상황입니다.
2. View 계층을 평탄화하자
LinearLayout, RelativeLayout 등을 사용해 다수의 중첩되는 뷰를 그려 dept가 깊어지면 작업은 안드로이드에서는 뷰의 위치를 계산하는 작업이 늘어나기 때문에 레이아웃을 그리는데 계산하는 시간이 늘어나게 됩니다. 이에 대한 해결책으로 ConstraintLayout을 사용하면 LinearLayout처럼 View들을 중첩해서 배치하지 않고 제약조건 걸어 동일한 dept에서 View들을 배치할 수 있습니다.
Android Developers Blog에 따르면 기존 RelativeLayout을 사용한 화면에서 렌더링 시 약 90번의 Two-pass process가 일어났지만 ConstraintLayout을 활용한 화면에선 20번의 Two-pass process가 일어나는 것을 볼 수 있습니다.
해당 내용을 이해하기 위해선 안드로이드에서 Layout이 그려지는 원리에 대한 이해가 필요합니다. 본 파트의 글은 이현우님의 블로그를 참고했습니다. 일전에 GDG 상사를 하면서 멘토와 멘티로 만나 뵀었는데 너무나 좋은 글을 남겨주셔서 제 글에 녹여보려합니다. 본 내용 외에도 더욱 깊은 내용이 많아 자세한 내용은 별도의 글로 작성하겠습니다.
위 구조도는 Android를 처음 공부할 때 맨 처음 봤던 기억이 납니다. 각각의 컴포넌트를 담당하는 View와 이 View들을 하나의 집합으로 묶은 ViewGroup, 그리고 이 View와 ViewGroup들을 하나로 묶은 최상위 계층에 위치한 ViewGroup으로 이루어져있습니다. 이러한 안드로이드 뷰 시스템은 트리 구조로 이루어져 있으며 ViewGroup은 자식 뷰들을 포함하는 노드 역할을 합니다.
안드로이드는 화면이 foreGround 상태가 되면 root Node부터 시작해 하위 컴포넌트들의 Node들을 전위 순회하며 새로 그려야하는 View들을 그리며 안드로이드 시스템은 변경된 부분만 효율적으로 다시 그리기 위해 invalidate() 메커니즘을 사용합니다. invalidate()는 뷰의 내용이 변경되면 invalidate() 메서드를 호출하여 해당 뷰가 다시 그려지도록 요청합니다. 이 때, View를 포함하는 ViewGroup들은 draw( ) 함수를 사용해 자식 View들이 그려질 수 있도록 View에게 요청하는 책임을 가지고 있습니다.
정리하자면 ViewGroup은 draw( ) 함수를 호출해 자식 View들의 그리기 과정을 시작하며 View는 onDraw( ) 함수를 사용해 자신의 내용을 화면에 그립니다. 예를 들어, LinearLayout ViewGroup은 자식 View들을 순서대로 배치하고, 각 자식 View는 자신의 onDraw() 함수를 통해 텍스트, 이미지 등을 그립니다. LinearLayout은 자신의 onDraw() 함수를 통해 배경색등을 칠할 수 있지만, 자식 View의 내용에는 관여하지 않습니다.
안드로이드는 레이아웃을 그릴 때 "Two-pass Process"라는 과정을 거칩니다.
👏Two-pass process
렌더링 성능을 향상시키기 위해 Android 시스템에서 사용하는 기법입니다. 특히 RecyclerView와 같이 복잡한 뷰 계층 구조를 가진 뷰를 렌더링할 때 유용하며 Two-pass process는 다음 두 단계로 구성됩니다.
📐 측정 패스 (Measure pass)
모든 뷰의 크기를 측정하고 계산하며 measure 함수에서 실행됩니다.
측정 패스는 View Tree의 루트에서 시작해 하향식으로 진행되며 각 뷰는 자신의 크기를 계산하고 자식 뷰에게 전달합니다.
측정 패스가 종료되면 모든 View는 자신의 크기와 부모 뷰의 크기를 알게 됩니다.
📐 레이아웃 패스 (Layout pass)
모든 뷰의 위치를 계산하며 onLayout 함수에서 실행됩니다.
측정 패스에서 계산된 크기를 사용해 모든 하위 View들을 배치합니다.
일반적인 렌더링 과정에서는 측정과 레이아웃이 번갈아 가며 발생합니다. 즉, 부모 뷰는 자식 뷰의 크기를 알아야 자신의 크기를 계산할 수 있고, 자식 뷰는 부모 뷰의 크기를 알아야 자신의 위치를 계산할 수 있습니다. 이러한 의존성 때문에 뷰 계층 구조가 복잡할수록 측정/레이아웃 과정이 반복적으로 수행되어 렌더링 성능이 저하될 수 있습니다.
Two-pass process는 이러한 문제를 해결하기 위해 측정 단계와 레이아웃 단계를 분리합니다. 먼저 측정 단계에서 모든 뷰의 크기를 계산하고, 그 후 레이아웃 단계에서 모든 뷰의 위치를 계산합니다.
Two-pass process는 RecyclerView에서 항목의 크기가 동적으로 변경되는 경우 특히 유용합니다. 예를 들어, RecyclerView의 항목에 이미지가 포함되어 있고 이미지의 크기가 로드된 후에 결정되는 경우, Two-pass process를 사용하면 이미지 로딩이 완료된 후 측정/레이아웃을 한 번만 수행하여 렌더링 성능을 향상시킬 수 있습니다.
대부분의 경우에는 화면을 그릴 때 Two-Pass Process를 단 한번만 거칩니다. 그러나 개발자가 만든 Layout 구조에 따라서 여러번 실행될 수 있는데요, 이것이 바로 Layout의 dept를 깊게 만들면 안되는 결정적인 이유입니다. 패스가 중복되는 케이스를 한번 살펴보겠습니다.
👆RealtiveLayout
RelativeLayout은 뷰들을 서로 상대적인 위치에 배치하는 레이아웃으로 패스가 항상 두번씩 일어납니다. 상대적으로 배치한다는 말에 포인트가 있는데요, RelativeLayout은 뷰들간의 상대적인 위치를 기반으로 배치를 결정하기 때문에 모든 뷰의 크기와 위치 정보를 미리 알아야합니다. 즉, 첫 번째 패스를 통해 각 뷰의 크기를 파악하고, 두 번째 패스에서 이 정보를 활용하여 뷰들을 적절히 배치합니다.
- 첫 번째 패스(가상 배치)
- RelativeLayout은 먼저 각 뷰가 원하는 크기대로 배치될 수 있는지 확인합니다.
- 이때 다른 뷰와의 관계는 고려하지 않고, 각 뷰가 독립적으로 크기를 계산합니다.
- 마치 뷰들이 각자 원하는 자리를 임시로(가상으로) 잡는 것과 같습니다.
- 두 번째 패스 (실제 배치)
- 첫 번째 패스에서 얻은 정보를 바탕으로 뷰들 간의 상대적인 위치를 고려하여 최종적인 위치와 크기를 결정합니다.
- 뷰들끼리 겹치거나 공간이 부족한 경우, RelativeLayout은 자동으로 조정하여 최적의 배치를 찾습니다.
저는 레이아웃을 만들 때 LinearLayout 중첩을 굉장히 많이 했었습니다. 이 내용을 보고나니 단순히 xml을 만드는 것도 신중하게 사용하겠단걸 깨달았습니다. 다만 모든 상황에서 ConstraintLayout이 최선은 아닙니다. ConstraintLayout은 초기 생성 비용이 크기 때문에 정말 간단한 레이아웃일 경우 오히려 LinearLayout이 더 좋은 성능을 낼 수 도 있습니다.
3. 투명도를 줄이자
투명도와 관련된 속성은 내부적으로 투명 픽셀을 렌더링 합니다. 이러한 렌터링을 알파 렌더링 이라고 하고 이러한 알파 렌더링은 오버드로의 주요 원인 입니다. 투명 애니메이션, 페이드 인/아웃, 그림자 효과등 모두 투명도를 포함하기 때문에 오버드로의 원인이 될 수 있습니다. 확실히 elevation을 사용한 부분에서 오버로드가 다수 발생하는 것을 확인할 수있습니다.
2. 결과를 리뷰해보자
지만... 사실 ConstraintLayout으로 레이웃을 평탄화해도 오버로드 횟수는 줄어들지 않았습니다. 그림자나 백그라운드 컬러를 제거하는 것도 디자이너분이 만드신 디자인이 있기 때문에 함부로 건들 수 도 없었습니다. 무엇보다 오버로드 관련한 이슈는 구글에서 대응하지 않아도 된다 하니 다른 부분에서 더 개선점을 찾아보도록 하겠습니다.
기능 개선 과정은 정말 어려운 것 같습니다. 많은 레퍼런스들과 정보들을 찾아보고 나름대로의 적용을 시켜봤는데 아직까진 크게 개선된 점이 보이진 않습니다. 기능 개선에 성공하는 그날까지 힘내봅시다...
'Android' 카테고리의 다른 글
안드로이드에서 실시간으로 네트워크 상태를 대응해보자 (2) | 2024.09.15 |
---|---|
안드로이드 접근성 개선기 (0) | 2024.08.26 |
안드로이드 리사이클러뷰 성능 개선 일지 3편 (0) | 2024.08.10 |
안드로이드 리사이클러뷰 성능 개선 일지 2편(부제 : DiffUtil Deep Dive) (0) | 2024.08.04 |
안드로이드 리사이클러뷰 성능 개선 일지 1편(부제: Recyclerview Deep Dive) (0) | 2024.08.03 |