Jetpack Compose Optimation
Jetpack Compose의 Collection Fast API 사용하기
ui-util의 random-access 활용
Computer Science에서 데이터에 접근하는 방법은 여러 가지가 있지만 이번 글에서는 2가지의 방법을 다룹니다.
- Sequential Access
- Random Access
Sequential Access는 특정 요소에 접근하기 위해 순차적으로 접근하여야 함을 나타내고, Random Access는 특정 요소로 바로 접근 가능함을 나타냅니다.
Sequential Access를 사용하는 자료 구조는 대표적으로 Linked List가 있고, Random Access를 사용하는 자료 구조는 대표적으로 Array가 있습니다.
코틀린으로 설명한다면 Array
는 Random Access, List
는 Sequential Access를 지원합니다.
fun main() {
arrayOf(1, 2).forEach(::println)
listOf(3, 4).forEach(::println)
}
위 코드를 자바로 디컴파일해 본다면 다음과 같습니다.
Object[] $this$forEach$iv = new Integer[]{1, 2};
for(int var3 = $this$forEach$iv.length; var2 < var3; ++var2) {
Object element$iv = $this$forEach$iv[var2];
int p1 = ((Number)element$iv).intValue();
System.out.println(p1);
}
Iterable $this$forEach$iv = (Iterable)CollectionsKt.listOf(new Integer[]{3, 4});
Iterator var8 = $this$forEach$iv.iterator();
while(var8.hasNext()) {
Object element$iv = var8.next();
int p1 = ((Number)element$iv).intValue();
System.out.println(p1);
}
Array
는 Random Access를 지원하기에 Array#get
으로 요소를 바로 가져온 반면, List
는 Sequential Access를 지원하기에 Iterable#hasNext -> Iterable#next
로 요소를 조회하며 가져옴을 확인할 수 있습니다.
또한 Array
와 List
의 정의에서도 이를 확인할 수 있습니다. JVM에서 돌아가는 코틀린의 Array
는 자바의 ArrayList
를 나타냅니다.
// Java
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
// AbstractList<E> <- AbstractCollection<E> <- Collection<E> <- Iterable<E>
ArrayList
는 RandomAccess
를 확장하고 있지만,
// Kotlin
public interface List<out E> : Collection<E>
public interface Collection<out E> : Iterable<E>
List
는 단순히 Iterable
만 확장하고 있습니다. 이러한 차이에서 위와 같은 다른 결과가 나타나게 됩니다.
Jetpack Compose의 내부 구현을 살펴보면 List#fastForEach
확장 함수가 종종 보입니다. 이는 List
에도 Random Access 접근을 추가하여 조금이라도 빠르게 컴포지션을 마치기 위함으로 보입니다.
@Suppress("BanInlineOptIn")
@OptIn(ExperimentalContracts::class)
inline fun <T> List<T>.fastForEach(action: (T) -> Unit) {
contract { callsInPlace(action) }
for (index in indices) {
val item = get(index)
action(item)
}
}
실제 구현은 위와 같으며 Array
의 Random Access 구현과 동일하게 작동하도록 설계돼 있습니다. 그렇다면 Sequential Access와 Random Access의 작동 시간 차이는 얼마나 발생할까요? JMH 벤치마크를 돌려 보았습니다.
- M1 램 16gb 기준
- JVM에는 4g 할당
- JIT를 위해 1번의 warmup을 진행함
- avgt 모드로 5번의 iteration을 진행하였고 평균값을 사용함
모든 경우에서 List#fastForEach
가 적은 시간이 소요됐으며, 적게는 약 800ns, 많게는 약 300ms까지 차이가 발생하였습니다. 제가 진행한 벤치마크 기준으론 저는 큰 차이를 느끼지 못해 정말 속도가 중요한 서비스가 아니면 stdlib인 List#forEach
를 계속 사용할 거 같습니다. (첫 벤치마크 작성이다 보니 잘못됐을 수 있습니다. 이런 경우라면 댓글로 가르침 부탁드립니다.)
이런 속도 차이가 발생하는 원인은 Iterator#next
의 구현을 보면 이해할 수 있습니다.
public E next() {
checkForComodification();
int i = cursor;
if (i >= size) throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length) throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
next
요청이 들어오면 ConcurrentModification을 감지하기 위해 현재 요소의 사이즈와 요청된 인덱스를 비교하는 assertion을 진행합니다. 이 과정에서 사이즈를 매번 재계산하므로 추가 시간을 요구하는 오버헤드가 발생됨으로 이해하였습니다.
Jetpack Compose의 fast api는 ui-util
아티팩트에 모두 public으로 작성되었습니다. 따라서 아래와 같이 의존성을 추가하여 사용할 수 있습니다.
// 이 글을 쓰는 시점에서 최신 버전은 1.3.3 입니다.
implementation("androidx.compose.ui:ui-util:${version}")
1.3.3 버전 기준으로 다음과 같은 api를 제공합니다.
- List<T>.fastForEach(action: (T) -> Unit)
- List<T>.fastForEachReversed(action: (T) -> Unit)
- List<T>.fastForEachIndexed(action: (Int, T) -> Unit)
- List<T>.fastAll(predicate: (T) -> Boolean): Boolean
- List<T>.fastAny(predicate: (T) -> Boolean): Boolean
- List<T>.fastFirstOrNull(predicate: (T) -> Boolean): T?
- List<T>.fastSumBy(selector: (T) -> Int): Int
- List<T>.fastMap(transform: (T) -> R): List<R>
- List<T>.fastMaxBy(selector: (T) -> R): T?
- List<T>.fastMapTo(destination: C, transform: (T) -> R): C
끝!
ui-util
아티팩트는 컴포즈에 의존성이 걸려있지 않습니다. 진행하는 프로젝트에서 List
의 빠른 접근이 필요하다면 ui-util
도입을 컴포즈와 무관하게 고려해 볼 수도 있습니다. 끝까지 읽어주셔서 감사합니다.
안드로이드 개발자분들을 위한 카카오톡 오픈 채팅방을 운영하고 있습니다.
벤치마킹에 사용된 전체 코드는 여기서 확인하실 수 있습니다.