textMeasurer 에 대한 고찰

조관희·2025년 1월 13일
0
post-thumbnail

textMeasurer는 Jetpack Compose에서 텍스트 크기를 측정하기 위한 API입니다. 텍스트를 측정해서 객체로 생성하고 기억할 수 있습니다.

  • 어떻게 기억할까요?

아래 코드처럼 remember를 통해서 텍스트 사이즈를 측정하고 기억할 수 있습니다.

val textMeasurer = rememberTextMeasurer()
private val DefaultCacheSize: Int = 8

@Composable
fun rememberTextMeasurer(
    cacheSize: Int = DefaultCacheSize
): TextMeasurer {
	...
	return remember(fontFamilyResolver, density, layoutDirection, cacheSize) {
    TextMeasurer(fontFamilyResolver, density, layoutDirection, cacheSize)
  }
}

이 부분에서 중요한 부분은 캐싱을 하고 있다는 점입니다. 기본 8개의 캐시 사이즈를 가지고 있습니다. 텍스트 사이즈를 측정하고 기억할 객체인 TextMeasurer를 생성했다면, 텍스트를 측정해줄 친구가 필요합니다. 그게 바로 measure() 메서드입니다.

[참고] 만약에 캐시를 사용하지 않고 싶다면, cacheSize의 값을 0을 사용할 경우 TextLayoutCache를 사용하지 않습니다.

private val textLayoutCache: TextLayoutCache? = if (cacheSize > 0) {
    TextLayoutCache(cacheSize)
} else null

measure 함수를 아래와 같이 호출하여 결과를 받아올 수 있습니다.

Jokwanhee 라는 텍스트의 16sp를 가진 텍스트를 측정하고 있습니다.

val textLayoutResult = textMeasurer.measure(
    text = "Jokwanhee",
    style = TextStyle(fontSize = 16.sp)
)
  • 위 measure 함수는 어떻게 동작할까요?

위에서 캐싱이 된다고 설명했습니다. 캐싱 역할을 measure 함수 내부에서 그 역할을 담당하고 처리해줍니다.

동작은 간단합니다.

  • skipCache : 캐시를 스킵할건지 유무입니다. (기본값은 false 입니다.)
  • textLayoutCache : 위에서 cacheSize가 0 보다 크다면 TextLayoutCache 객체를 생성하게 됩니다.

위 두 변수를 AND 연산을 통해 캐시에서 가져올 건지 null를 반활할 것인지에 대해서 판단하게 됩니다.

val cacheResult = if (!skipCache && textLayoutCache != null) {
  textLayoutCache.get(requestedTextLayoutInput)
} else null

초반이라면 skipCache는 당연하게 false이고, textLayoutCache는 null 이므로 캐시에 담을 객체를 생성해주어야 합니다.

그래서 null일 경우 만드는 로직은 아래와 같습니다.

layout 함수를 사용하여 TextLayoutResult를 가져옵니다. 그 이후에는 캐싱을 하게 됩니다.

layout(requestedTextLayoutInput).also { textLayoutResult ->
    textLayoutCache?.put(requestedTextLayoutInput, textLayoutResult)
}

위처럼 캐싱을 진행하면, measure 함수를 재호출하는 경우 같은 객체일 경우에는 캐시에 있는 값을 가져와 아래의 cacheResult객체를 복사하여 새로운 TextLayoutResult 값을 반환합니다.

return if (cacheResult != null) cacheResult.copy(
  layoutInput = requestedTextLayoutInput,
  size = constraints.constrain(
      IntSize(
          cacheResult.multiParagraph.width.ceilToInt(),
          cacheResult.multiParagraph.height.ceilToInt()
      )
  )
)

추가적으로 textLayoutCache는 LRU 알고리즘을 사용해서 캐싱을 좀 더 효율적으로 관리하게 됩니다.

저는 여기서 궁금증이 들었습니다. 리컴포지션으로 상태변화가 발생하면 불필요한 measure 함수가 불리면서 객체를 만드는 것 자체가 리소스 낭비이지 않을까?

그렇다면, 새로운 객체를 만드는 것을 개선해보고자 테스트를 진행해봤습니다.

우선 textMeasurer를 사용하는 이유는 아래의 UI처럼 테스트라는 텍스트의 영역만큼 그대로 가져오기 위해서 사용하게 됩니다.

아래 코드는 기본적으로 텍스트를 측정하기 위한 방법입니다. 아래의 과정에서 리컴포지션이 발생하고 measure함수가 계속 불린다면 어떻게 될까요?

val textMeasurer = rememberTextMeasurer()

val textLayoutResult = textMeasurer.measure(
  text = "테스트",
  style = TextStyle(fontSize = 12.sp),
)

위 measure 함수가 처음 호출될 때는 null이기 때문에 캐시에서 가져오지 않고 직접 객체를 만듭니다. 하지만 리컴포지션이 100번 발생했다면, 100번의 measure 함수가 불리며 캐싱되어있던 객체를 가져오는 방식으로 로직이 흘러갑니다.

그렇다면, measure 함수를 기억한다면? 현재 위에서 보여드린 “테스트”라는 텍스트를 변하지않고 정적이며, 텍스트 측정의 리컴포지션 대상이 되지 않아도 됩니다.

그래서 measure 함수를 사용하는 부분을 remember를 사용하여 기억하고 있으면 됩니다.

val textMeasurer = rememberTextMeasurer()

val textLayoutResult = remember(Unit) {
  textMeasurer.measure(
    text = "테스트",
    style = TextStyle(fontSize = 12.sp),
  )
}

아래의 사진은 위 코드를 사용한 부분이 리컴포지션이 발생하는 위치인데도 불구하고 맨 처음에 호출된 이후에는 호출되지 않는 모습을 알 수 있습니다.

불필요한 measure 함수 호출을 개선해보았습니다.

그런데 이런 질문을 할 수 있을 것 같아요. 왜 갑지가 텍스트 측정에 대한 호출을 개선해보았을까요?

Drawing Modifier 에 대해서 공부하면서 알게된 개념이 있습니다. (Drawing Modifier에 대해서 자세한 설명은 공식 문서를 참고해주세요. https://developer.android.com/develop/ui/compose/graphics/draw/modifiers)

바로 drawWithCache 입니다. (참고 문서 :
https://developer.android.com/develop/ui/compose/graphics/draw/overview#measure-text)

위 링크에서 자세한 설명이 있지만, 이야기해보자면 텍스트를 측정할 때 measure 작업의 비용이 많이들어서Canvas를 사용할 때, drawWithCache를 사용하여 measure를 그리기 영역의 크기가 변경될 때까지 호출되지 않도록 해당 람다에 배치합니다.

val textMeasurer = rememberTextMeasurer()

Spacer(
    modifier = Modifier
        .drawWithCache {
            val measuredText =
                textMeasurer.measure(
                    AnnotatedString(longTextSample),
                    constraints = Constraints.fixedWidth((size.width * 2f / 3f).toInt()),
                    style = TextStyle(fontSize = 18.sp)
                )

            onDrawBehind {
                drawRect(pinkColor, size = measuredText.size.toSize())
                drawText(measuredText)
            }
        }
        .fillMaxSize()
)
profile
Allright!

0개의 댓글

관련 채용 정보