본문 바로가기

안드로이드 Android

안드로이드 개발 (34) RecyclerView 성능 향상

 


Android 개발을 하다보면 불가피 하게 RecyclerView를 사용해야 하는 순간이 대부분 옵니다. 요새 Compose열풍이 휩쓸고 있지만 아직은 회사에서 사용하는 목록형 UI는 RecyclerView를 통해 만들었을 겁니다.

저 또한 마찬가지로 RecyclerView를 활용하여 목록형 UI를 만들던 도중이었습니다. 하지만 테스트 기기중에 저사양 기기는 RecyclerView를 단순하게 사용하면 버벅거림을 피할 수 없었습니다. 저사양 기기에서 최상의 퍼포먼스를 내기 위해 연구했습니다.

내가 만든 소프트웨어는 기기에 차별받지 않고 항상 사랑받고 싶은 욕심으로.. 연구했던 내용을 블로그에 정리하려고 합니다.

 

0. 느린 렌더링


"UI 렌더링은 앱에서 프레임을 생성하여 화면에 표시하는 작업입니다. 사용자와 앱의 상호작용이 원활하게 이루어지도록 하려면 앱이 16ms 미만으로 프레임을 렌더링하여 초당 60프레임을 달성해야 합니다. 앱의 UI 렌더링 속도가 느리면 시스템에서 프레임을 건너뛰게 되고 사용자는 앱에서 끊김을 인식합니다. 이런 현상을 버벅거림 이라고 합니다."
- 안드로이드 공식문서

안드로이드에서 말하는 성능이란 무엇일까요? 안드로이드는 사용자와 직접적으로 맞닿아 있기 때문에 사용자가 느낄 때 UI 변경시 끊김 현상을 눈치채지 못하는 것이 가장 중요합니다.
안드로이드는 16ms 미만으로 프레임을 렌더링해서 초당 60프레임을 달성해야 사용자 눈에는 전혀 버벅거림을 느낄 수 없다고 합니다.

UI Thread 와 Render Thread(롤리팝 이후) 의 최상의 컨디션으로 초당 60프레임을 달성 해야합니다.
위 조건을 위해 우리 안드로이드 개발자는 최선의 방법을 항상 찾아야 합니다.

추천 링크 -
https://medium.com/google-developers/recyclerview-prefetch-c2f269075710

 

RecyclerView Prefetch

Smoother Flings and Scrolls by Doing Stuff Sooner

medium.com

(RecyclerView에서 UI 쓰레드와 렌더 스레드의 연관 되는 행위들에 대한 설명이 잘 섞여있습니다.)

 

 

 

1. RecyclerView 및 Adapter에 포함된 함수를 효율적으로 응용하기

첫번째 주제는 너무 유명하고 쉽게 알 수 있는 권장하는 방식들이라서 긴 설명없이 짧은 설명만 남기고 넘기도록 하겠습니다.


(1) setHasStableIds 와 getItemId를 사용하자.

 

class RecyclerViewAdapter : RecyclerView.Adapter<...> { init {
    setHasStableIds(true)
} ...
    override fun getItemId(position: Int): Long {
        return position.toLong() // or data id } }


RecyclerView의 성능향상 중에 가장 기본은 onBindViewHolder 호출을 최소화 하는것입니다.

setHasStableIds(true) 를 사용하면 각각 아이템 position에 지정된 Id를 기준으로 상황에 따라 onBindViewHolder() 호출을 제외시킵니다. 값이 변경된 id만 onBindViewHolder를 호출하거나 호출된 아이템의 id가 이전 position 아이템에 이미 존재할 시 onBindViewHolder 함수를 호출 하지 않고 이전에 같은 id를 가진 뷰를 대신 보여줍니다.

아래 간단하게 설명이 된 블로그 링크가 있습니다.
https://blog.kmshack.kr/Stable-Id%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-RecyclerView-%EC%84%B1%EB%8A%A5-%ED%96%A5%EC%83%81%EB%B2%95/


(2) 새로운 데이터 추가/삭제 특정 포지션 변경 함수를 사용하기 or DiffUtil 사용하기

 

//기본적인 셋팅 없이 해당 함수 사용시 목록 전체가 깜빡이는 현상이 일어남
adapter.notifyDataSetChanged()


notifyDataSetChanged() 는 모든 데이터를 전체적으로 변경할 때 사용합니다. 그래서 전체적으로 onBindViewHolder를 다시 순회를 해야되기 때문에 UI가 깜빡임이 생길 수 있습니다. (setHasStableIds 를 사용하면 깜빡임이 없지만) 그래서 좋은 UI퍼포먼스를 위해 다른 데이터 변경 함수를 사용하도록 권장합니다.

데이터 변경 -> notifyItemChanged, notifyItemRangeChanged
데이터 추가 -> notifyItemInserted, notifyItemRangeInserted
데이터 삭제 -> notifyItemRemoved, notifyItemRangeRemoved
데이터 이동 -> notifyItemMoved

데이터 변경알림 종류
https://todaycode.tistory.com/55

불필요한 전체 데이터 셋을 다시 하지 않아도 되고, 심지어 기본적인 애니메이션도 사용되고 있어서 심플한 리스트에서 사용하기에 나쁘지 않습니다. 하지만 위 데이터 변경알림 함수를 상황에 맞게 골라 사용하다보면 코드가 많아지고 신경 써야 되는 부분이 늘어납니다.

그래서 이 대안으로 Diff Util를 사용할 수 있습니다.
https://developer.android.com/reference/androidx/recyclerview/widget/DiffUtil
DiffUtil은 Eugene W. Myers의 차분 알고리즘을 통해서 두 개의 데이터 목록을 가지고 비교하여서 서로 다른 차이점이 있는 아이템만 업데이트 합니다.

즉, 새로운 데이터 목록을 추가하게 된다면 기존 목록과 새로운 목록이 비교하여 서로 다른 아이템만 따로 업데이트를 합니다.

그래서 데이터 추가, 삭제, 변경의 함수를 구분할 필요없이 새로운 데이터 목록만 삽입한다면 알아서 업데이트가 됩니다.
DiffUtil 방식으로 많은 함수를 쓰지 않아도 되서 코드상으로도 깔끔하고 실수할 확률도 줄어듭니다.


 

DiffUtil  |  Android Developers

DiffUtil public class DiffUtil extends Object java.lang.Object    ↳ androidx.recyclerview.widget.DiffUtil DiffUtil is a utility class that calculates the difference between two lists and outputs a list of update operations that converts the first list

developer.android.com



보통의 경우 ListAdapter를 어댑터에 상속받아 DiffUtil를 많이 사용합니다.

 

[Android] ListAdapter로 RecyclerView 효율적으로 사용하기 (DiffUtil, AsyncListDiffer)

안드로이드에서 동일한 레이아웃을 반복적으로 뿌려주는 리스트형 UI를 만들기 위해 RecyclerView가 사용된다. RecyclerView를 사용하려면 Adapter, LayoutManager, ViewHolder 이렇게 세가지 준비물이 필요하다.

zion830.tistory.com

class RecyclerView:ListAdapter<T,VH>(DiffUtil.Callback...){ .... }


ListAdapter 는 기본적으로 AsyncListDiffer를 통해 데이터 계산은 Background Thread 에서 하기 때문에 반복 상용구 코드가 필요없이 ListAdapter 하나로 많은 효율을 얻을 수 있습니다.

자세한 사용법은 아래링크를 추천합니다.
https://zion830.tistory.com/86

 

[Android] ListAdapter로 RecyclerView 효율적으로 사용하기 (DiffUtil, AsyncListDiffer)

안드로이드에서 동일한 레이아웃을 반복적으로 뿌려주는 리스트형 UI를 만들기 위해 RecyclerView가 사용된다. RecyclerView를 사용하려면 Adapter, LayoutManager, ViewHolder 이렇게 세가지 준비물이 필요하다.

zion830.tistory.com

정리하자면 특정 데이터 삽입,변경,삭제를 매우 심플하게만 해도 되는 상황일 시 notIDataSet 함수를 사용하면 좋고,
데이터의 삽입 삭제 변경이 번번하게 일어난다면 DiffUtil로 한번에 관리해도 좋습니다.

참고로 DiffUtil를 사용한다면 setHasStableId(true) , getItemId()는 사용하지 않아도 됩니다. 차이 알고리즘에 의해 필요한 부분만 업데이트가 이미 되기 때문 입니다.

그래서 DiffUtil를 많이 쓰는 추세이긴 하지만, 상황에 따라
간혹 notiDataSet을 사용하는 것이 더 좋은 경우도 있습니다.

notiDataSet 혹은 DiffUtil 중에 무엇을 사용할지는 본인 선택에 달려있습니다.

(3) 고정된 크기의 아이템 UI 을 사용하는 것이라면 setHasFixedSize를 true 로 하자

 

void onItemsInsertedOrRemoved() { 
if (hasFixedSize) layoutChildren(); else requestLayout();


RecyclerView는 데이터가 삽입, 삭제가 될 때 각각 아이템의 레이아웃을 다시 계산해야할지 정합니다.

hasFixedSize가 true 일 경우 고정값으로 인식하고,
false일 경우 requestLayout() 호출이 되어 아이템의 레이아웃 계산이 다시 이루어집니다.

View와 관련된 작업은 모두 UI Thread가 위임합니다. 글 초입부에 언급한 60프레임을 달성 하려면 최대한 UI Thread를 효율적으로 활용 해야합니다. 그래서 아이템UI의 사이즈가 고정이라면 true로 하는게 좋습니다.

만약 CustomView를 View를 상속받아 처음부터 만들어본 사람이라면 저 requestLayout()이 얼마나 많은 작업을 할 것인지 짐작이 될겁니다.

(4) setItemViewCacheSize 로 캐쉬 크기 조정하기


스크롤을 통해 화면에서 UI가 사라졌을 때 사라진 View를 다시 사용 하는 recycled view pool에 들어가지 않고,
Cache에 저장되어 다시 화면에 나왔을 때 onBindViewHolder 호출 없이 View 보여집니다.
즉, 동일한 뷰를 다시 그리지 않습니다.

recyclerView.setItemViewCacheSize(int n)


이 함수를 pool과 함께 차이를 이해하려면 아래 링크를 추천합니다.
https://medium.com/android-news/anatomy-of-recyclerview-part-1-a-search-for-a-viewholder-404ba3453714

 

Anatomy of RecyclerView: a Search for a ViewHolder

Intro

medium.com

 

(5) 끝없는 스크롤을 해야하는 List 라면 animation 을 제거 하자

 

recyclerView.setItemAnimator(null)


RecyclerView의 아이템이 한정적이고 추가 , 삭제가 이루어진다면 해당 기능이 필요할 수 있겠지만 깜빡임 현상의 원인이 될 수도 있고 저사양 기기의 앱의 버벅거림을 생기게 하는 기능이 될 수도 있습니다.

(6) 중첩 RecyclerView는 Pool 를 공유하자



중첩 RecyclerView에서 RecyclerView 간의 pool를 공유하여 성능을 향상 시킬 수 있습니다.
바깥쪽과 안쪽 pool를 공유하면 됩니다.

override fun onCreateViewHolder(...): RecyclerView.ViewHolder {
    val innerLm = LinearLayoutManager(...) innerRv.apply{
        layoutManager = innerLm recyclerViewPool = sharedPool
    } return OuterAdapter.ViewHolder(innerRv)
}

 

 

2. View 및 onBindViewHolder 최적화

 

(1) onBindViewHolder 에서 복잡한 계산, loop 계산, 콜백 및 리스너 set 하지말 것



- onBindViewHolder는 가능하면 순수 data set만 하는 것이 가장 이상적 입니다.
data를 외부에서 미리 가공해서 onBindViewHolder에서 set 하는 것이 가장 안정적입니다.

- onBindViewHolder에서 가능하면 for, while 과 같은 반복문은 피해야합니다.
- onBindViewHolder에서 특별한 경우를 제외하고 콜백,리스너 정의를 하지 않아야 합니다. (ex: onClickListener)


ovrride fun onBindViewHolder(
    holder: RecyclerView.ViewHolder,
    position: Int
) { 
// 앱 성능 저하의 원인이 됩니다. 
    for (...) {...}
    
// 불 필요한 코드 입니다.
    view.setOnClickListenr { ... }
    
//아래 처럼 set만 하는 코드만 존재하는 것이 가장 이상적 입니다.
    imageView.set...(data[position].url)
    textView.text = data[position].text
}

 

(2) ViewHolder 및 onCreateViewHolder에서 가능하면 미리 값을 처리 해놓을 것


- 예시로 width를 디바이스 기기의 3분의 1 크기로 고정값으로 사용해야하는 경우

class ViewHolder(v: View) :
    RecyclerView.ViewHolder(v) {
    init {
        // 가능하면 미리 정할 수 있는 값은 ViewHolder나 onCreateView에서 처리하는 것이 좋다.
        v.layoutParms = LayoutParms(....)
        v.itemView.setOnClickListener { ... }
    }

    override fun onBindViewHolder(...) {
        //별로 좋지않는 코드, 성능 저하의 원인 중 일부분이 될 수 있음 
        holder.layoutParms = LayoutParms(....)
        holder.itemView.setOnClickListener { ... }
    }

}


위 처럼 가능하면 ViewHolder에서 미리 처리할 수 있는 것은 set을 해두는 것이 좋고 onBindViewHolder에 최대한 부담을 없애야 한다.

(3) OnBindViewHolder에서 Html.fromHtml() 사용하지 말것


html 를 사용하기 때문에 마찬가지로 onBindViewHolder에 적합하지 않습니다. 성능저하의 원인 중 하나가 됩니다.

(4) 아이템 레이아웃 UI 계층을 단순화 하기


안드로이드 xml은 View가 위치한 depth가 한단계 씩 깊어질 수록 UI 연산이 배로 증가합니다.
이는 곧 성능저하로 이어지는 원인이 됩니다.

특별한 경우를 제외하고 가능하면 xml 에서 ConstraintLayout 를 사용해서 1 depth로 View를 그리는 것이 좋습니다.

<android.support.constraint.ConstraintLayout> 
<ImageView /> 
<ImageView /> 
<TextView /> 
<EditText /> 
<TextView /> 
<TextView /> 
<EditText /> 
<Button /> 
<Button /> 
<TextView /> 
</android.support.constraint.ConstraintLayout>


https://android-developers.googleblog.com/2017/08/understanding-performance-benefits-of.html

 

Understanding the performance benefits of ConstraintLayout

Posted by Takeshi Hagikura, Developer Programs Engineer Since announcing ConstraintLayout at Google I/O last year, we've continued to im...

android-developers.googleblog.com

 

(5) 부모 계층 뷰 그룹이 스크롤 뷰 or 리싸이클러 뷰 일 경우

recyclerView.setNestedScrollingEnabled(false)

상위 뷰에 따라 스크롤이 부드럽지 않는 경우도 있습니다. 그럴 때 위 코드를 적용하면 한층 더 좋아짐을 알 수가 있으며 xml에서도 셋팅이 가능합니다.

(6) item에 복잡한 drawable를 가능하면 사용 하지 말기


<layout-list> 로 이루어진 복잡한 drawable을 item xml에 가능하면 사용하지 말아야합니다. 저가용 기기에서 GPU 에 부하를 주기 때문에 가능하면 심플하게 사용해야합니다.

(7) ViewStub 을 사용하기


ViewStub으로 지연 인플레이트를 사용하는 것이 좋습니다. visible 로 설정된 경우에 inflate 하기 때문에 성능 컨트롤에 효율적 입니다.

(8) 투명색은 가능하면 지양하는 것이 좋다.


투명색 사용은 저가용 기기에서 상당한 부하로 다가옵니다.
투명색 처리는연산처리가 많기 때문에 가급적 피하는 것이 좋습니다.

 

3. Image 최적화

RecyclerView를 사용할 때 일반적으로 Image 처리 때문에 버벅거림이 생기는 경우가 많습니다. 대표적인 예시로 GridLayoutManager로 SpanCount를 3으로 지정했을 때 별도의 이미지 최적화를 하지않으면 국내 점유율이 큰 갤럭시 S10 에서 Debug 모드로 실행 했을 때 스크롤하면 살짝의 버벅거림이 일어나는 것을 확인 할 수 있었습니다.

일반적으로 Image를 어떻게 효율적으로 컨트롤 하는가에 따라 RecyclerView의 성능이 좌우 되기도 합니다.
이 글에서 안드로이드 개발에서 많이 쓰는 이미지 라이브러리 Glide를 기준으로 설명하도록 하겠습니다.

다른 이미지 라이브러리도 마찬가지로 흡사한 기능이 있으니 Glide를 사용하지 않더라도 같은 방법으로 사용할 방법이 분명히 있을 것 입니다.

(라이브러리를 사용하지 않아도 공수가 많이 들지만 Bitmap 으로 직접 컨트롤이 가능하기도 합니다. )

(1) Cache 를 사용하자


Glide 의 경우 기본적으로 Cache처리가 되어있습니다. Cache사용으로 인해 빠른 이미지 호출 및 불필요한 네트워크 호출을 줄일 수 있습니다. skipMemoryCacheOf()를 true, false의 경우 확연하게 성능차이를 보이기도 합니다. 가능하면 특별한 경우에만 true로 해놓는 것이 좋습니다.

 Glide. .with(imageView) .load(uri) ... 
 //true일 때 버벅거림이 생성되었고, false일 때 버벅거림이 덜 했습니다.
 //Glide는 기본적으로 false가 디폴트 값이니 true로 변경할일이 없으면 따로 셋팅하지 않아도 됩니다. 
 .skipMemoryCacheOf(false)...



아래는 Cache 처리를 안했을 때 확연히 메모리 소모가 다른 결과를 보여준 테스트 결과 입니다. (감자튀김님 블로그)
https://gamjatwigim.tistory.com/91

 

Android RecyclerView에서 OOM 방지하기

0. 상황과 궁금증 설명 상황 : RecyclerView에서 리스트를 보여줄 때, 많은 양의 이미지를 사용한다. 나는 그러던 중, 스크롤이 버벅이는 현상을 발견하거나 심하면 OOM에러를 마주한 경험이 있다. 그

gamjatwigim.tistory.com



(2) RGB 565 사용


이미지를 그리는 픽셀의 포맷을 표시할 때 요즘 이미지 라이브러리는 기본적으로 ARGB_8888가 적용되어 있습니다. 포맷을 RGB_565 로 바꾼다면 메모리 효율을 50% 올릴 수 있습니다. 하지만 ARGB_8888에 비해 이미지 색상 품질 저하가 생깁니다. 여러 이미지를 사용하지 않는 목록에서 사진 크기가 썸네일 수준이라면 ARGB_8888 와 육안으로 큰 차이는 나지 않습니다.

그래서 고해상도의 이미지를 아이템 당 큰 사이즈로 보여주는 것이라면 ARGB_8888 를,
저해상도의 이미지를 여러장 보여주고 크기가 작은 사이즈로 여러장을 보여주는 것이라면 RGB_565 사용을 권장 합니다.

RGB 565와 ARGB 8888 차이
https://hanburn.tistory.com/140

 

[graphic] RGB565와 RGBA8888에 대해서

android에서 bitmap을 다루다보면 Bitmap의 config에 bitmap의 픽셀포멧을 표시하는 부분 있다. ALPHA_8, RGB_565, ARGB_4444, ARGB_8888 이렇게 4가지가 있는데 주로 사용되는 RGB_565와 ARGB_8888에 대해서 알아..

hanburn.tistory.com

Glide 라이브러리는 아래와 같이 적용할 수 있습니다.

@GlideModule
class MyAppGlideModule : AppGlideModule() {
    override fun applyOptions(context: Context, builder: GlideBuilder) {
        // Glide default Bitmap Format is set to RGB_565 since it
        // consumed just 50% memory footprint compared to ARGB_8888.
        // Increase memory usage for quality with: 
        builder.setDefaultRequestOptions(RequestOptions().format(DecodeFormat.PREFER_ARGB_565))
    }
}

따로 Glide Module 를 만드는 방법 외에

 Glide. .with(imageView) .load(uri)
 ... 
 .format(DecodeFormat.PREFER_RGB_565)

사용시 직접 지정하는 방법도 있습니다.

(3) ImageView 의 크기를 match_parent 혹은 고정으로 지정하기


Glide를 통해 Loading된 Image를 적용하려는 ImageView의 가로 높이가 wrap_content 일 경우 문제가 생깁니다.
별다른 셋팅을 하지 않는다면 bitmap의 크기가 wrap_content 일때 사용중인 기기 기준으로 이미지 해상도가 결정 됩니다.

예를 들어, 내 장치의 화면 크기가 1080x1794이고 여기에 2000x1000 이미지를로드하려는 경우 로드 된 비트 맵의 크기는 1794 x 1196 가 됩니다. 이때 ARGB_8888 품질이라면 8.18 MB 용량을 가집니다.
이는 엄청난 메모리 소비로 이어지고 GC의 호출이 잦아져 결국 성능 저하로 이어집니다.

 

(4) glide에서 로딩하는 이미지의 해상도 모를 때 centerInside 사용하기


예를들어 로딩된 이미지의 해상도가 150 x 250 이라고 할 때 ImageView 의 크기가 1000 x 1000 이라면
ImageView의 사이즈에 맞게 이미지 해상도가 확대 됩니다.

이미지 해상도를 억지로 확대 시킨 것이기 때문에 품질이 좋아지도 않을 뿐더러, 불필요하게 이미지 용량이 커집니다.
그래서 300kb 였던 용량이 2.50mb 까지 확대 되고 저품질 해상도는 유지가 됩니다.

이때 centerInside를 사용한다면 이미지 자체의 해상도가 ImageView 크기의 맞게 변경될 일이 없고
이미지 사이즈만 재조정 됩니다.

Glide.with(imageView)
     .load(IMAGE_URL) 
     .centerInside() 
     .into(imageView)


즉, 300kb 를 유지한채 ImageView 사이즈에 맞게 조절이 가능해집니다.

(5) glide Image Size 직접 조절하기

 

Glide.with(imageView) 
     .load(IMAGE_URL) 
     .override(200,200) 
     .into(imageView)


.override()를 통해서 직접 이미지 사이즈를 조절 할 수 있습니다. 기존 이미지가 고해상도 였다면 이 기능을 사용하면 RecyclerView 의 Adapter Init이나 Scroll 할때 확연한 차이를 확인 할 수 있습니다.

(6) RecyclerView의 LifeCycler에 따라 수동으로 Glide 이미지 로딩 취소


기본적으로 Glide는 context를 인자로 받아서 해당 LifeCycle에 따라 알아서 이미지 로딩이 취소 되지만
혹시 예외적인 경우가 있을 것 같아서 이 내용을 적어놓습니다.

RecyclerView의 뷰 홀더 생성 및 제거 Lifecycle은 다음과 같습니다.
onCreateViewHolder -> onBindViewHolder ->
onViewAttachedToWindow -> onViewDetachedFromWindow -> onViewRecycled -> onCreateViewHolder(반복)..


override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
    super.onViewRecycled(holder)
    Glide.with(context)
        .clear(holder.imageView)

}

// 혹은.... 
override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) {
    super.onViewDetachedFromWindow(holder)
    Glide.with(context)
         .clear(holder.imageView)
}



자세한 내용은 삐질님의 글을 추천합니다.
https://ppizil.tistory.com/38

 

[안드로이드] Recyclerview 제대로 알고 쓰자 !

안녕하세요 개발자 삐질입니다 오늘 제가 소개하고 싶은 내용은 제대로 된 리사이클러뷰의 개념 입니다. 여러분, 리사이클러뷰는 리스트뷰 -> 리사이클러뷰 즉 리스트형식의 UI를 구성하는 데

ppizil.tistory.com


(7) Glide를 사용함에도 불구하고 OutOfMemory 발생하는 경우

Activity 나 기본적으로 Application Class 에 onLowMemory() 와 onTrimMemory() 를 오버라이드 할 수 있습니다.


overrid fun onLowMemory() {
    super.onLowMemory();
    Glide.get(this).clearMemory();
}
overrid fun onTrimMemory(int level) {
    super.onTrimMemory(level);
    Glide.get(this).trimMemory(level);
}

메모리 상태에 따라 호출되는 함수인데, Glide를 위와 같이 셋팅하면 OOM를 방지 할 수 있습니다.

4. 다양한 시도 및 관련 유틸

(1) PrecomputedText


Android는 Text를 표시할 때 필요한 레이아웃 계산을 하고 위치를 배치 시킬때 많은 시스템 자원을 사용하게 됩니다.
특히나 RecyclerView가 아이템을 생성할 때 레이아웃 계산을 하게 되는데 Text의 공간 계산인 경우 많은 자원을 소비하게 됩니다.

그래서 구글에서 비동기로 Text의 공간 계산을 background thread로 진행한 뒤 UI 렌더링을 해주는 API를 소개 했었습니다.

/* Worker Thread */ // resolve spans on worker thread to reduce load on UI thread
val expensiveSpanned = resolveIntoSpans(networkData.item.textData)

// pre-compute measurement work to reduce load on UI thread
val textParams: PrecomputedTextCompat.Params = ...

// we’ll get to this
val precomputedText: PrecomputedTextCompat =
    PrecomputedTextCompat.create(expensiveSpanned, params)

/* UI Thread */
myTextView.setTextMetricParams(precomputedText.getParams())
myTextView.setPrecomputedText(precomputedText)

하지만 200자 내의 텍스트에서는 기존 setText 방식과 큰 차이는 없다고 합니다.
많은 글을 포함한 아이템 형식의 목록에서라면 좋은 방법 인듯 합니다.

그외 자세한 설명은 아래 링크에서 확인 할 수 있습니다.

https://proandroiddev.com/async-text-loading-in-android-with-precomputedtext-93aa131b0e5b

 

(2) 레이아웃매니저 커스텀을 해서 온 크레이트 뷰 호출을 여러번 미리 가져오기 (PreCache)


뷰홀더 생성 ~ 제거 Lifecycle에서 onCreateViewHolder 는 최초로 뷰홀더를 생성합니다.
하지만 [생성] 을 미리 해놓으면 다음 LifeCycle에서 onCreateViewHolder 호출 횟수를 줄이는 방법이 존재합니다.

바로 RecyclerView가 내부적으로 인식하는 화면 사이즈를 크게 잡는 것 입니다.

RecyclerView는 사용자가 눈으로 볼 수 있는 화면 + 보이지 않는 위아래 가상의 영역의 사이즈가 있는데 이 전체 사이즈를 내부적으로 확 늘려버리면 defalut로 갖는 아이템의 양의 수보다 더 많은 아이템을 onCreateViewHolder로 생성 해 놓을 수 있습니다.

스크롤을 하게 되면 이미 onCreateViewHolder 로 생성한 아이템들로 인해 onCreateViewHolder 함수의 호출이 적어집니다.

class PreCacheLayoutManager(context: Context, private val extraLayoutSpace: Int) :
    LinearLayoutManager(context) {
    override fun getExtraLayoutSpace(state: RecyclerView.State?) = extraLayoutSpace
} 
//Activity 에서.. 
recyclerView.adapter = PreCacheLayoutManager(context,600)

하지만 단점이 존재합니다. 사이즈를 너무 크게 잡아버리면 init 시 호출되는 onCreateViewHolder가 많아 지고 가상으로 인식하는 사이즈가 매우 넓기 때문에 init 타이밍에 버벅거림이 존재할 수 있습니다. 그래서 이 방식은 적재적소하게 사용하는 것이 좋습니다.

(3) LayoutManager.setItemPrefetchEnabled(true, false) 사용


RecyclerView 25.0.0 부터 기본적으로 setItemPrefetchEnabled() 이 true로 설정되어 있습니다. 그래서 true로 하고 싶다면 별도의 셋팅이 필요없습니다.

// false 하고 싶을 때만 직접적으로 명시한다. 
layoutManager.itemPrefetchEnabled = false

이 기능은 onBindViewHolder를 몇 프레임 앞서서 미리 호출하는 방식 입니다. 그래서 유저가 빠른 스크롤을 할때 미리 onBindViewHolder를 미리 호출한 것을 보여주기 때문에 스무스한 스크롤이 가능합니다.

UI thread에서 View의 inflate 와 bind가 완료되면 순차적으로 GPU Render Thread에서 렌더링 작업을 하게 되는데, 이때 UI Thread는 유후 상태가 됩니다.

스크롤 할때 이 순서를 반복하게 됩니다.

하지만 스크롤 할때 새로운 View가 등장하기 위해 UI Thread는 다시 infalte & bind 작업을 하게 되는데,
문제는 위 작업이 Render Thread 에서 렌더링이 끝나고 UI가 사용자 눈에 표시 되려 하는 순간과 겹치게 됩니다.

그래서 UI Thread 의 View infate & bind 작업은 비용이 매우 크면서 동시에 렌더링 된 UI가 표시가 되기 때문에 순간적으로 버벅거림이 생기게 되기 마련입니다.

그래서 Prefetch 방식은 스크롤 할때 inflate 가 필요한 경우, UI 스레드가 유휴상태에 들어가는 동안 미리 추가적으로 onBindViewHolder() 를 호출합니다.

그렇기 때문에 RecyclerView 버전이 업데이트 되면서 setItemPrefetchEnabled() 가 기본적으로 true로 셋팅 되어 있습니다. 하지만 ViewCache 메모리를 추가적으로 사용하게 됩니다. 이 또한 나름의 비용이 들어갑니다.

그래서 예를들어 스크롤이 조금만 발생을 하도록 유도한 UX라던가, 전체 아이템 갯수가 매우 적을 경우에 false로 하면 좋고, 반대로 많은 스크롤이 일어나는 일반적인 경우에는 true로 하면 좋습니다.

 

(4) init 시에만 AsycLayoutInflate 사용, UI 스케줄러 제작 (안드로이드 개발자 Phil Olson의 방식 )


RecyclerView 에 adapter를 set하면서 동시에 RecyclerView Adapter 를 사용하는 경우가 아닌, 어댑터에 뒤늦게 아이템이 insert 되는 경우라면 AsycLayoutInflate 를 사용하여 미리 onCreateViewHolder에서 사용할 View를 만들어 두고 onCreateViewHolder의 부담을 덜해버리는 방법도 있습니다.

class SmoothListAdapter(val context: Context) :
    ListAdapter<ListItem, SmoothListAdapter.ListItemViewHolder>(ListItemViewHolder.MyDiffCallback()) {
    data class ListItem(val id: String, val text: String)
    class ListItemViewHolder(view: View) : RecyclerView.ViewHolder(view) {

        private val asyncLayoutInflater = AsyncLayoutInflater(context)
        private val cachedViews = Stack<View>()
        init {
            //Create some views asynchronously and add them to our stack 
            for (i in 0..NUM_CACHED_VIEWS) {
                asyncLayoutInflater.inflate(
                    R.layout.list_item,
                    null
                ) { view, layoutRes, viewGroup -> cachedViews.push(view) }
            }
        }
        
        override fun onCreateViewHolder(
            parent: ViewGroup,
            viewType: Int
        ): ListItemViewHolder {
            //Use the cached views if possible, otherwise if we ran out of cached views inflate a new one 
            val view = if (cachedViews.isEmpty()) {
                LayoutInflater.from(context).inflate(R.layout.list_item, parent, false)
            } else {
                cachedViews.pop().also {
                    it.layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
                }
            }
            return ListItemViewHolder(view)
        }

        fun populateFrom(listItem: ListItem) {
            //TODO: populate your view 
        }
        override fun onBindViewHolder(viewHolder: ListItemViewHolder, position: Int) =
            viewHolder.populateFrom(getItem(position))

        class MyDiffCallback : DiffUtil.ItemCallback<ListItem>() {
            override fun areItemsTheSame(firstItem: ListItem, secondItem: ListItem) =
                firstItem.id == secondItem.id override fun areContentsTheSame(
                    firstItem: ListItem,
                    secondItem: ListItem
                ) = firstItem == secondItem
        }

        companion object {
            const val NUM_CACHED_VIEWS = 5
        }
    }
}

만약 위 코드 처럼 ListAdapter를 사용한다면 미리 adapter를 초기화 해두고 submitList(colleaction<T>) 를 뒤늦게 사용하게 된다면 미리 만들어둔 view를 stack 에 담아놧던걸 그대로 사용하면 됩니다.

또한 UI 스케줄러를 따로 만들어 사용할 수 있습니다.


object UIJobScheduler {
    private const val MAX_JOB_TIME_MS: Float = 4f private
            var elapsed = 0L private
                    val jobQueue = ArrayDeque<() -> Unit>() private
                            val isOverMaxTime get () = elapsed > MAX_JOB_TIME_MS * 1_000_000 private
                            val handler = Handler()

    fun submitJob(job: () -> Unit) {
        jobQueue.add(job) if (jobQueue.size == 1) {
            handler.post { processJobs() }
        }
    }

    private fun processJobs() {
        while (!jobQueue.isEmpty() && !isOverMaxTime) {
            val start =
                System.nanoTime() jobQueue . poll ().invoke() elapsed += System . nanoTime () - start
        } if (jobQueue.isEmpty()) {
            elapsed = 0
        } else if (isOverMaxTime) {
            onNextFrame { elapsed = 0 processJobs () }
        }
    }

    private fun onNextFrame(callback: () -> Unit) =
        Choreographer.getInstance().postFrameCallback { callback() }
}

위 코드는 최대 4ms를 사용 합니다. MAX_JOB_TIME_MS 상수를 바꿔서 원하는 최대 ms 를 설정할 수 있습니다.

 UIJobScheduler.submitJob { setupText2() } 
 UIJobScheduler.submitJob { setupImage2() }


자세한 내용은 아래에서 확인하시면 됩니다.
https://medium.com/@polson55/smooth-recyclerview-scrolling-in-android-57e7a9b71ca7

 

Smooth RecyclerView scrolling in Android

Tips for using complex views without skipping frames

medium.com

 

(5) AsyncLayoutInflater 를 사용해서 infalte, bind 하기 (Dmitrii Kachan 의 방식)


AsyncLayoutInflater 를 사용해서 onCreateViewHolder 에서 뷰홀더를 생성하고 생성 후 onBindViewHolder에서 bind 까지 하는 방식 입니다. 기존에 UI Thread 가 하던일을 background Thread 에 맡기는 형식입니다. background Thread 에서 레이아웃 계산부터 모두 비동기 방식으로 사용이 가능 합니다.

open class AsyncCell(context: Context) : FrameLayout(context, null, 0, 0) {
    init {
        layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT)
    }

    open val layoutId = -1

    // override with your layout Id 
    private var isInflated = false
    private val bindingFunctions: MutableList<AsyncCell.() -> Unit> = mutableListOf()

    fun inflate() {
        AsyncLayoutInflater(context).inflate(layoutId, this) { view, _, _ ->
            isInflated = true addView (createDataBindingView(view)) bindView ()
        }
    }

    private fun bindView() {
        with(bindingFunctions) {
            forEach { it() }
            clear()
        }
    }

    fun bindWhenInflated(bindFunc: AsyncCell.() -> Unit) {
        if (isInflated) {
            bindFunc()
        } else {
            bindingFunctions.add(bindFunc)
        }
    }

    // override for usage with dataBinding
    open fun createDataBindingView(view: View): View? = view
}

위에 FrameLayout 를 더미로 만들고 addView를 해서 아래처럼 사용합니다.

class RecyclerViewAsyncAdapter(private val items: List<TestItem>) :
    RecyclerView.Adapter<RecyclerView.ViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
        SmallItemViewHolder(SmallItemCell(parent.context).apply { inflate() })

    override fun getItemCount(): Int = items.size
    override fun onBindViewHolder(
        holder: RecyclerView.ViewHolder,
        position: Int
    ) {
        if (holder is SmallItemViewHolder) {
            setUpSmallViewHolder(holder, position)
        }
    }

    private fun setUpLargeViewHolder(holder: LargeItemViewHolder, position: Int) {
        (holder.itemView as LargeItemCell).bindWhenInflated {
            items[position].let { item ->
                holder.itemView.binding?.item = item
            }
        }
    }

    private fun setUpSmallViewHolder(holder: SmallItemViewHolder, position: Int) {
        (holder.itemView as SmallItemCell).bindWhenInflated {
            items[position].let { item ->
                holder.itemView.binding?.item = item
            }
        }
    }

    private inner class SmallItemViewHolder internal constructor(view: ViewGroup) : RecyclerView.ViewHolder(view)
    
    private inner class SmallItemCell(context: Context) : AsyncCell(context) {
        var binding: SmallItemCellBinding? = null override
        val layoutId =
            R.layout.small_item_cell override fun createDataBindingView(view: View): View? {
                binding = SmallItemCellBinding.bind(view) return view.rootView
            }
    }
}

단점으로 복잡한 뷰를 가진 아이템에서는 간혹 UI가 제대로 렌더링 못하는 경우가 있거나, 오히려 더 느려지는 문제점이 생기기도 합니다. 마찬가지로 상황에 맞게 사용하면 좋은 방법이 될 듯 합니다.

자세한 건 아래 링크에 있습니다.

https://proandroiddev.com/improve-ui-performance-async-recyclerview-layout-loading-7eb525ab19d0

 

Improve UI Performance with Async RecyclerView Layout Loading

RecyclerView is one of the most commonly used Android UI components. It can be very powerful but unfortunately it sometimes becomes very…

proandroiddev.com

 

(6) ViewHolder 인자로 넘어가는 View 를 inflate 해서 생성하지 않고 class 로 만들기


onCreateViewHolder에서 기본적으로 xml를 LayoutInflate로 View로 inlfate 해서 사용하는게 일반적인 경우이지만
아예 xml 를 사용하지 않고 아이템 뷰 자체를 class로 직접 만들어서 뷰홀더 인자로 넘기는 방법이 있습니다.
inflate 을 하지않고 바로 View를 넘기기 때문에 뷰홀더 생성 비용을 아낄 수 있습니다.


class ProductCardView : MaterialCardView { 
    ...
}
abstract class MyAdapter : RecyclerView.Adapter<MyAdapter.ViewHolder>() {
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return RecyclerView.ViewHolder(
            ProductCardView(parent.context)
        )
    }
    ...
}

하지만 유지보수 측면에서 xml 에 비해 효율은 좋지 않은 듯 합니다. 성능을 중심으로 코딩 하는 상황이 아니라면 가급적 사용하지 않는 방법 입니다.

 

5. Compose LazyComposable 사용

RecyclerView를 최적화 하기 위해 온갖 고생하는 것보다 Compose 를 통해서 목록형 UI를 만드는 방법도 권장합니다. 늦은 초기화를 통해서 View가 사용자 눈에 보일 때 UI 렌더링 합니다. 복잡한 Adpater 관리도 사라지며 composable function 안에서 쉽게 List를 관리 할 수 있습니다.


LazyColumn {

// Add a single item
    item { Text(text = "First item") }
    
// Add 5
    items items (5) { index -> Text(text = "Item: $index") }

// Add another single 
    item item { Text(text = "Last item") }

}

 

Compose 는 xml 과 혼용해서 사용할 수 있는데 List 부분만 LazyComposable 를 이용해서 만드는 것도 하나의 방법 입니다.

https://gift123.tistory.com/m/44

 

안드로이드 개발 (16) Compose Lists

안녕하세요 안드로이드 개발자 Loner입니다. Compose의 정리를 이어서 진행해보도록 하겠습니다. List 기존 Xml방식으로 List는 주로 리싸이클러 뷰 혹은 리스트뷰로 많이 구현을 해왔습니다. Compose에

gift123.tistory.com


이상 RecyclerView 성능 향상에 대해 알아봤습니다. 오늘도 내가 만든 소프트웨어는 사랑 받기를 원합니다.