IT_Programming/Android_Java

[펌] RecyclerView에서 텍스트 레이아웃 미리 계산하기

JJun ™ 2018. 8. 7. 10:04



 [출처]

 : https://developers-kr.googleblog.com/2018/08/prefetch-text-layout-in-recyclerview.html




블로그 원문은 이 곳에서 확인해보실 수 있고 리뷰에는 양찬석(Google)님이 도와주셨습니다.

텍스트를 적절히 배치하기 위해 Android 시스템은 복잡한 작업을 수행합니다. 폰트, 언어, 크기, 폰트 특징(예: 굵게 또는 기울임꼴)에 따라 개별 글자 모양을 분석합니다. 글자가 모여 단어를 형성할 때 글자가 정렬, 조합 또는 병합되는 방식에 대한 규칙을 분석합니다. 이러한 작업을 끝난 후에만 단어를 공간에 배치할 수 있습니다.



텍스트를 표시하기 위해 필요한 공간을 계산하고 화면에 배치시키는데는 많은 시스템 자원이 사용됩니다. 전체 과정을 효율화하기 위해 계산된 각 단어의 크기를 캐시에 저장해 재 사용하지만 한계가 있습니다. 화면에 새로운 단어가 표시되거나 새로운 글꼴을 적용하거나 텍스트 크기를 조정하면 다시 작업이 이루어져야합니다.



RecyclerView는 레이아웃 성능에 특히 민감합니다. 화면에 새로운 항목이 나타나면 해당 항목을 보여주는 첫 번째 프레임 단계에서 레이아웃 작업도 수행해야 합니다. 예를들어 RecyclerView 항목 중 복잡한 텍스트 단락이 포함되어 있고, 해당 단락의 크기를 측정하는데 12ms이 걸린다면, 틀림없이 버벅거림 및 프레임 드랍(Frame Drop) 문제가 발생할 것 입니다.

지난 Google I/O에서 새로운 PrecomputedText API를 사용하여 미리 텍스트 레이아웃을 계산하는 방법이 공개되었습니다. RecyclerView에 대량의 텍스트를 표시할 목적으로 특별히 설계된 Jetpack의 새로운 Text Prefetch API와 이 API를 사용하여  UI 스레드 상에서 텍스트 레이아웃 비용을 95% 절감할 수 있는 방법을 살펴보겠습니다.




복잡한 텍스트 레이아웃

아래 이미지는 복잡한 텍스트 단락을 보여주는 간단한 RecyclerView입니다. TextView에서 80단어(최대 520자)의 크기를 계산(measure) 하는 경우 약 15.6ms가 걸립니다 (Pixel2 / Android P 실행, CPU 성능 1GHz). 정말 긴 시간입니다. 60FPS를 위해 각 프레임은 16.67ms안에 그려져야 함으로 UI 스레드가 위와 같이 복잡한 TextView의 크기를 하나라도 계산하는 경우 데드라인을 넘어서게 될 것입니다.



TextView에 텍스트가 많지 않은 경우라도, 저사양 디바이스에서 실행되거나 로마자보다 복잡한 내용을 표시하거나 다양한 글꼴과 스타일을 사용할 경우 마찬가지로 긴 시간이 걸릴 수 있습니다. Developer Options > Monitoring > Profile GPU rendering를 사용하면 이 문제를 시각적으로 확인할 수 있습니다. 녹색의 수평 막대는 16.67ms 기준선이며, 각 프레임은 소요 시간에 따라 세로 방향으로 쌓입니다. 왼쪽의 일반 TextView를 사용할 때는 놓치는 프레임이 많은데, 새 항목이 화면에 나타날 때 그래프에서 큰 스파크 모양이 이에 해당합니다.


<왼쪽에는 일반 TextView 측정 결과, 오른쪽에는 PrecomputedText 측정 결과가 나와 있습니다.>



오른쪽 캡처 화면은 같은 앱을 보여주지만, UI 스레드에서 값비싼 텍스트 레이아웃 작업을 피하기 위해 PrecomputedText를 사용한 경우입니다. 나머지 작업은 그대로이므로 작은 스파크 모양들이 생기지만, 더 이상 버벅거림 문제는 발생하지 않습니다. TextView.onMeasure가 0.9ms로 감소되는데, 이는 16배나 더 빨라진 것입니다!



PrecomputedText

올해 I/O에서 발표한 PrecomputedText는 Android P의 새로운 API이며, 하위 버전에서 사용할 수 있도록 Jetpack 내 호환 버전을 포함하고 있습니다. 이 API를 사용하면 백그라운드 스레드에서 대부분의 측정/레이아웃 작업을 미리 수행할 수 있습니다.



UI 스레드에서는 TextView 레이아웃 매개변수를 다음과 같이 설정합니다.
val params : PrecomputedTextCompat.Params =
       TextViewCompat.getTextMetricsParams(myTextView)



백그라운드 스레드에서 다음과 같이 비용이 많이 드는 텍스트 레이아웃 작업을 수행하세요.

val precomputedText : Spannable =
       PrecomputedTextCompat.create(expensiveText, params)



다음과 같이 생성된 PrecomputedText를 TextView에 사용할 수 있습니다.
TextViewCompat.setPrecomputedText(myTextView, precomputedText)




이렇게 하면 UI 스레드의 작업 중 90% 이상을 덜 수 있으므로 성능이 크게 향상됩니다! AndroidX 구현 버전인 PrecomputedTextCompat은 L(API 21) 이상에서 작동하므로 현재 시장에서 사용되고 있는 전체 디바이스 중 약 85%에 적용할 수 있습니다(올해 5월 현재 기준).



이 개선 효과는 훌륭하지만, 위의 비동기 패턴은 텍스트를 표시하는데 그대로 적용하기에는 적합하지 않습니다. 예를 들어 RecyclerView에서 항목에 있는 텍스트는 화면을 절반쯤 위로 스크롤한 후가 아니라 즉시 표시되어야 합니다. 이런 결과를 얻기 위해서는 텍스트 매개변수를 일찍 파악하여 TextView가 표시되기 전에 백그라운드 스레드 작업을 수행할 필요가 있습니다.
어떻게 하면 텍스트 표시가 지연되지 않도록 PrecomputedText 작업을 일찍 시작할 수 있을까요?



첫 번째 접근 방법 — 레이아웃을 미리 계산

텍스트 데이터를 이미 비동기 방식으로 로드하고 있다면, 텍스트를 미리 처리하는 해당 백그라운드 스레드를 사용할 수 있습니다. 예를 들어 흔히 네트워크에서 데이터를 로드하여 역직렬화(deserialization) 하는데, 이 때 데이터를 UI 스레드로 보내기 전에 스팬으로 스타일 지정할 수 있을 것입니다.



역직렬화 직후에 PrecomputedText도 사용하면 어떨까 하는 유혹을 무척 강하게 느끼게 됩니다. 다음과 같이 UI 스레드 작업을 최소화하기 위해 텍스트에 관한 작업을 모두 마친 후에 UI로 보내고자 합니다.



/* 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)



그러면 PrecomputedText를 바로 UI에 나타낼 수 있습니다.



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



여기서 PrecomputedTextCompat.Params를 얻는 방법은 무시하고 넘어갔습니다. 사실 이 부분이 복잡한 문제입니다.


크기 조정 문제

PrecomputedText는 개별 글자 (glyph) 크기를 알아야 레이아웃을 측정을 수행할 수 있습니다. 즉, (일반적으로 XML에서 지정되는 것처럼) sp가 아니라 텍스트 밀도를 고려하여 조정된 픽셀 단위의 크기가 필요합니다.  sp를 픽셀로 변환할 때는 DisplayMetrics.scaledDensity가 사용되는데, 이 것을 백그라운드 스레드로 전달하는 것이 쉽지 않을 수 있습니다.



플랫폼은 ‘사용자 텍스트 크기 조정' 기능을 지원하기 위해 실행 중에도 scaledDensity값을 변경 할 수 있습니다. DisplayMetrics를 한 번만 쿼리하고 백그라운드 스레드로 전달할 경우, 사용자가 시스템 전체의 글꼴 크기 변경에서 값을 변경해버리면 문제가 발생합니다. TextView의 절반만 올바른 크기를 가진채로 앱이 실행될 수 있는데, 이는 사용자로선 유쾌한 경험이 아닙니다.



이 문제를 올바르게 처리할 수 있지만 매우 신중을 기해야 합니다. Activity가 다시 생성될 때마다 반드시 scaledDensity를 쿼리하고, scaledDensity가 바뀐 경우에는 캐시된 PrecomputedText를 전부 삭제하고 다시 생성해야 합니다.



두 번째 접근 방법 — Future<>를 이용한 프리페치

코드를 조금만 변경하거나 스레드 전반에 걸쳐 텍스트 크기 조정 정보를 전달하는 문제를 걱정하지 않고, PrecomputedText의 이점을 누릴 수 있다면 좋을 것입니다. TextView를 설정하는 일반적인 RecyclerView 바인딩 코드를 살펴봅시다.



override fun onBindViewHolder(vh: ViewHolder, position: Int) {
   val itemData = getData(position)
   vh.textView.textSize = if (item.isImportant) 14 else 10
   vh.textView.text = itemData.text
}



이런 종류의 코드를 쉽게 수정하여 PrecomputedText를 사용할 수 있다면 이상적일 것입니다.
텍스트 사전 계산을 어렵게 만드는 것은 TextView가 setText() 호출 직후에 레이아웃을 수행하기 때문에, 그 직전까지도 텍스트 스타일 지정이 완료되지 않는다는 점입니다(동적 textSize 속성에 유의). 즉, 백그라운드에서 텍스트 레이아웃 작업을 수행할 시간적 공백이 매우 제한적입니다.
하지만 이러한 예상을 깨는 한 가지 중요한 기능이 있는데, 그것이 바로 RecyclerView Prefetch입니다. RecyclerView는 프리페치 때문에 실제로 항목 레이아웃보다 여러 프레임 앞서 onBindViewHolder()를 호출합니다. 이는 콘텐츠가 막 표시되려는 마지막 찰나에 값비싼 뷰 생성 및 바인딩 작업이 수행되지 않도록 하기 위함입니다.



PrecomputedText의 관점에서는 다행스럽게도, 바인드와 레이아웃 사이에 뜻하지 않게 수십 밀리초의 시간적 공백이 생기며, 이 공백을 활용해 백그라운드 스레드에서 텍스트를 미리 계산할 수 있습니다.



가장 최근의 Jetpack 베타 버전에서 정확히 이러한 사용 사례를 지원하는 기능을 새로 추가했습니다. 그래서 이제는 Future<PrecomputedTextCompat>을 생성하고 AppCompatTextView에 이를 바로 설정할 수 있습니다. AppCompatTextView는 다음 onMeasure 콜백이 호출될 때 까지 결과값을 기다릴 수 있습니다.



override fun onBindViewHolder(vh: ViewHolder, position: Int) {
   val itemData = getData(position)
   // first, modify item-dependent properties
   vh.textView.textSize = if (item.isImportant) 14 else 10
   // Pass text computation future to AppCompatTextView,
   // which awaits result before measuring.
   textView.setTextFuture(PrecomputedTextCompat.getTextFuture(
           item.text,
           TextViewCompat.getTextMetricsParams(textView),
           /*optional custom executor*/ null))
}



Future를 생성함으로써 앱은 백그라운드 스레드에서 PrecomputedText 작업이 시작됩니다. 바인드 콜백 코드 내에서 작업이 끝나기를 기다리는 대신, RecyclerView가 스크롤함에 따라 필요한 View가 생성되거나 값이 바인딩됩니다. PrecomputedText는 이 기간동안 텍스트 레이아웃 작업을 완료하고 비용을 숨깁니다. 실제 TextView가 화면에 그려지는 순간 onMeausre 콜백이 호출되고 사전에 계산된 PrecomputedText를 이용해 텍스트가 표시됩니다. 단 몇 줄의 코드를 이용해 텍스트 프리페치를 추가함으로써 UI 스레드 상에서의 TextView 측정 시간을 95%나 줄였습니다!




주의 사항

Future를 생성할 때 TextViewCompat.getTextMetricsParams()를 쿼리하여 값을 확인하고 백그라운드 스레드로 전달합니다. 따라서, 다시 바인드하여 다시 setTextFuture()를 호출하지 않는 한 setTextFuture()가 호출 된 후에 TextView 속성을 변경할 수 없습니다. 속성을 수정하면 PrecomputedText가 TextView와 호환되지 않을 수 있고, 예외가 발생합니다.



PrecomputedTextCompat은 Lollipop 릴리스(API 21)가 나올 때까지는 존재하지 않았던 Android의 단어 레이아웃 캐시 구현에 의존합니다. 이런 이유로, PrecomputedTextCompat은 Lollipop보다 오래된 플랫폼 버전에서는 사전 계산을 수행하지 않습니다.



프리페치를 비활성화했거나 프리페치를 명시적으로 지원하지 않는 사용자 설정 LayoutManager를 사용 중인 경우에는 이 접근 방법이 도움이 되지 않을 것입니다. 사용자 설정 LayoutManager를 사용하는 경우에는 RecyclerView가 어떤 항목을 프리페치할지 알 수 있도록 LayoutManager의  collectAdjacentPrefetchPositions()를 구현해야 합니다. 또한 프리페치는 화면에서 스크롤하는 뷰에만 적용됩니다. 다행히도 View가 스크롤 되는 순간이 가장 성능에 민감한 순간입니다.



Data Binding

Android Data Binding 사용자는 사용자설정 BindingAdapter를 사용해 PrecomputedText future의 동일한 이점을 누릴 수 있습니다. XML에서 Data Binding을 평소와 같이 사용하겠지만, 다음과 같이 ‘asyncText’ 속성을 사용합니다.
<layout
   xmlns:tools="http://schemas.android.com/tools"
   xmlns:android="http://schemas.android.com/apk/res/android">
   <data>
       <variable name="item" type="com.example.ItemData"/>
   </data>
   <TextView
       android:id="@+id/item_text"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:textSize="@{item.isImportant ? 14 : 10}"
       app:asyncText="@{item.text}"/>
</layout>



다음과 같이 BindingAdapter에서 asyncText 속성을 정의하고 PrecomputedText와 setTextFuture를 자동으로 사용하도록 구현할 수 있습니다.



       "app:asyncText",
       "android:textSize",
       requireAll = false)
fun asyncText(view: TextView, text: CharSequence, textSize: Int?) {
   // first, set all measurement affecting properties of the text
   // (size, locale, typeface, direction, etc)
   if (textSize != null) {
       // interpret the text size as SP
       view.textSize = textSize.toFloat()
   }
   val params = TextViewCompat.getTextMetricsParams(view)
  (view as AppCompatTextView).setTextFuture(
          PrecomputedTextCompat.getTextFuture(text, params, null)
}



다른 BindingAdapter가 텍스트 레이아웃 관련 속성을 처리하지 못하도록 해당 속성을 전부 사용하는 데 신중을 기합니다. TextView가 다른 모든 속성을 바인드한 후 getTextMetricsParams에 대한 호출이 실행되도록 보장할 필요가 있는데, Data Binding은 어떤 어댑터가 언제 호출되는지에 대해서는 보장하지 않기 때문입니다. 안전을 위해 Data Binding이 제어할 수도 있는 TextView 속성을 전부 선택하여 어댑터를 시작할 때 모두 적용합니다.



override fun onBindViewHolder(holder: Holder, position: Int) {
   holder.binding.item = getItem(position)
   holder.binding.executePendingBindings()
}



마지막으로, 다음 레이아웃 단계를 기다리지 않고 목록 항목이 업데이트되도록 executePendingBindings()를 호출하겠습니다. PrecomputedText를 사용하지 않더라도 RecyclerView 내에서 Data Binding을 사용할 때 이 작업이 필수적입니다.



결론

PrecomputedText는 스크롤 성능에서 가장 중대한 성능 문제 중 하나인 RecyclerView의 텍스트 레이아웃 성능 문제를 해결해 줍니다.
최신 Jetpack을 사용하면 몇 줄의 코드만으로 텍스트 측정 비용을 95%나 절감할 수 있습니다!



현재로서는 흔히 200자 이상을 표시하는 목록 항목 TextView와 함께 PrecomputedText를 사용해 보실 것을 권장합니다.
개발자 여러분이 작업을 수행하면서 경험한 바와 앱의 어떤 부분에서 가장 도움이 되는지 들려주시면 정말 고맙겠습니다.
이는 Jetpack의 베타 릴리스 중 일부이므로, 일단 사용해 보시고솔직한 의견을 들려주세요!



참고

Pixel 2, Android P 환경에서 성능 측정을 수행했으며, 측정의 안정성을 위해 1GHz로 고정한 상태에서 측정했습니다. debuggable = false로 설정한 상태로 앱을 실행하므로, ART는 실제 (릴리스) 성능으로 작동합니다.


Nexus 5, Android M, 1.2GHz 환경에서 같은 앱을 실행한 결과, 동일한 80단어 입력에 대한 이전/이후 시간은 각각 20.3ms와 1.2ms로 측정되며, 94%의 비용 절감 효과를 보여 거의 비슷한 결과를 얻었습니다.