리액트 네이티브는 리액트 트리를 네이티브 뷰 트리로 만들어 주는 것뿐인데, 리액트 네이티브로 앱을 개발하다 보면, 버벅거리거나 응답이 느린 현상이 자주 발생했습니다.
문제의 원인을 파악하기 위해, 지금까지 경험상 자주 사용하던 빌트인 컴포넌트와 외부 라이브러리의 코드 내부를 살펴보면서 중간중간 해결방법에 대해 정리했습니다.
코드까지 입력하면 포스트 내용이 너무 길어지기 때문에, 분석에 사용한 코드는 대부분 링크로 남겨 두었습니다.
목차
1-1-3. updateCellsBatchingPeriod
1-1-4. windowWize, removeClippedSubviews
2-1-2. react-navigation/bottom-tabs
3-2.react-navigation/native-stack
3-3. wix/react-native-navigation
4-2. RecyclerListView와 FlashList에 대한 환상
1. 스크롤 버벅거림
브라우저에서 레이아웃 과정이 필요하지 않은 스크롤은 컴포지터 스래드에서 처리됩니다.
그리고 대부분의 dom 조작이 JS 스래드에서 수행되고, transform, opacity 등 일부 속성만 컴포지터 스래드에서 처리되기 때문에, dom 조작이 스크롤에 큰 영향을 미치지 않습니다.
하지만 리액트 네이티브에선 브라우저의 dom 조작에 해당되는 Native View 조작과 스크롤이 둘 다 UI 스래드에서 처리되기 때문에, Native View를 너무 많이 조작하면 스크롤이 끊기는 현상이 자주 발생합니다.
브라우저 환경에선 DOM 조작(추가/삭제/변경)이 가장 많은 리소스를 소모합니다.
마찬가지로 모바일 네이티브 환경에서도 네이티브 뷰 트리를 조작하는 작업이 가장 많은 리소스를 소모합니다.
리액트 네이티브는 리액트 앱으로 만든 리액트 트리를 네이티브 뷰 트리로 변환하는 것 말고는 네이티브 앱과 표면적으로는 다르지 않습니다.
리액트 네이티브 터치 이벤트 흐름에서 확인했지만, 이벤트는 JS 스래드가 받아서 처리하지만 사람이 느낄 정도의 지연이 발생하지 않았습니다.
그런데 문제는 리액트 네이티브 앱이 너무 많은 네이티브 뷰 조작을 발생시킨다는 것입니다.
대표적인 예가 VirtualizedList 기반의 컴포넌트들(FlatList, SectionList)입니다.
1-1. VirtualizedList
스크롤 이벤트를 받을 때마다 updateCellsBatchingPeriod 주기로 셀 범위를 계산하고, 위 아래 여백 길이를 변경시키고, 셀을 랜더링합니다.
여기서 버벅임을 발생시키는 요인은 일정 범위에서 벗어난 셀을 리액트 트리에서 제거하고, 새롭게 범위 안으로 들어온 셀을 추가하면서 네이티브 뷰를 생성/추가/제거하는 것입니다.
Optimizing FlatList Configuration 공식 문서에서 FlatList를 최적화하는 방법으로 memo, maxToRenderPerBatch, updateCellsBatchingPeriod, windowSize에 대해 설명합니다.
https://reactnative.dev/docs/optimizing-flatlist-configuration
결론 먼저 말하면, windowSize를 제외하고, 나머지는 UI 부하를 얼마나 분산할 것인지만 조정하고, 전체적인 UI 부하를 줄이진 못합니다.
그리고 추가되는 컴포넌트 자체가 복잡해서 순간적으로 UI 스래드에 가해지는 부하가 커지면 windowSize도 영향을 미치지 못합니다.
1-1-1. memo
리액트 네이티브는 Host 컴포넌트는 리액트 DOM과 달리 속성 값으로 종종 객체가 입력됩니다.
<Image source={{uri: "https://1.com/2.jpg"}} />
그리고 style 속성으로 배열을 받을 수도 있습니다.
<View style={[{width: 100}, props.style]} />
리액트 DOM은 이전 props와 새로운 props의 키를 순회하면서 style을 제외한 각 키의 값을 단순 비교하기 때문에, 같은 방식이었다면, 네이티브 뷰 업데이트가 발생했겠지만,
리액트 네이티브는 props를 재귀적으로 비교하기 때문에, 데이터가 동일하면 네이티브 뷰 변경까지 이어지지 않습니다.
즉, memo는 props 내용이 동일한 경우 UI 스래드에 직접적인 영향을 미치지 못합니다.
그럼에도 불구하고 memo를 사용하면 스크롤이 약간 부드러워질 수 있는데, 이것은 리랜더링이 빨라지면서, 아이템을 조금씩 더 자주 변경시키기 때문입니다.
즉, UI 스래드에 가해지는 전체 부하량는 그대로지만, 부하를 쪼개서 좀 더 부드럽게 보이는 효과가 있습니다.
하지만 1-2. worklet에서 설명할 특정 컴포넌트는 리랜더링 자체만으로 UI 스래드에 부하가 발생하는데, 이때는 memoization 효과가 있습니다.
1-1-2. maxToRenderPerBatch
VirtualizedList는 스크롤 이벤트가 발생할 때마다, 랜더링될 첫 번째 셀과 마지막셀 정보인 {first: number, last: number} 형태의 데이터를 만들어 냅니다.
만약 스크롤을 빠르게 해서 {first:0, last: 10}에서 {first: 5, last: 15}로 변경되었는데, maxToRenderPerBatch가 1인 경우, 1→11, 2→12, ..., 5→15로 5번에 걸쳐서 최종 상태로 만듭니다.
즉, 마운트/언마운트되는 네이티브 뷰의 양을 조절합니다.
1-1-3. updateCellsBatchingPeriod
셀을 재계산하고 랜더링하는 동작(_updateCellsToRender → callback)에 쓰로틀(updateCellsBatchingPeriod → _delay)을 겁니다.
- Batchinator 초기화 (_updateCellsToRender, updateCellsBatchingPeriod)
https://github.com/facebook/react-native/blob/v0.75.2/packages/virtualized-lists/Lists/VirtualizedList.js#L381-L382 - Batchinator에서 callback을 delay로 쓰로틀링
https://github.com/facebook/react-native/blob/v0.75.2/packages/virtualized-lists/Interaction/Batchinator.js#L61-L71 - _onScroll에서 Batchinator 객체의 schedule 메서드 호출하여 _updateCellsToRender 예약
https://github.com/facebook/react-native/blob/v0.75.2/packages/virtualized-lists/Lists/VirtualizedList.js#L1751
코드를 종합해 보면 네이티브 뷰 변경 주기를 조절합니다.
maxToRenderPerBatch와 updateCellsBatchingPeriod를 조합하여(둘 다 낮춰서) memo와 같이 조금씩 자주 업데이트하는 효과를 낼 수 있습니다.
1-1-4. windowSize, removeClippedSubviews
스크롤뷰 내부에 뷰가 많으면, 아이템이 추가/제거되지 않아도 스크롤 자체가 버벅입니다.
안드로이드에선 removeClippedSubviews 기본값이 true인데, 자식이 지정한 영역 밖에 위치한 경우 네이티브 뷰 트리에서 제거합니다.
이로 인해 리액트 트리와 네이티브 뷰 트리가 달라지는데, 리액트 트리가 많아보여도, 실제 UI 스래드에서 다루는 뷰 트리는 작을 수 있습니다.
- ScrollView에서 Rect 크기(부모 뷰 크기)와 offset(scrollY) 결정 (→mClippingRect)
https://github.com/facebook/react-native/blob/v0.75.2/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java#L397-L522 - Rect 크기(부모 뷰 크기) 결정
https://github.com/facebook/react-native/blob/v0.75.2/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactClippingViewGroupHelper.java#L37-L44 - offset(scrollY) 결정
https://github.com/facebook/react-native/blob/v0.75.2/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactClippingViewGroupHelper.java#L58-L59 - contentView에서 부모뷰 크기(mClippingRect)에서 벗어나는 아이템 제거
https://github.com/facebook/react-native/blob/v0.75.2/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java#L525
https://github.com/facebook/react-native/blob/v0.75.2/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.java#L384-L427
안드로이드에 대해 잘 모르지만, 부모 뷰를 invalidate하지 않으면, 클립 영역 안에 들어와 있는 나머지 자식의 위치가 재배치되지 않는 것 같습니다.
따라서 아래 있는 자식의 위치가 위로 올라가는게 아니라 그대로 있게 되는 것으로 추정됩니다.
결국 안드로이드에선 removeClippedSubviews 속성 덕분에 windowSize가 UI 부하에 큰 영향을 미치지 못하고, iOS에서는 removeClippedSubviews가 false지만 기본 스크롤뷰 성능이 좋기 때문에 windowSize가 큰 영향이 없습니다.
그러나 windowSize가 작으면 리액트 트리가 작아지고, 리랜더링 속도가 빨라지기 때문에, 다른 방법과 마찬가지로 UI 부하를 쪼갤 수 있습니다.
결론적으로 위의 속성들로는 전체 UI 부하량를 줄이지 못하며, 추가되는 아이템 하나하나가 복잡하다면 위의 어떤 방법도 소용없게 됩니다.
보통 네이티브에선 대량 리스트를 처리할 때 사용하는 안드로이드의 RecyclerView나 iOS의 UITableView는 가상화 작업에서 기존 뷰 재사용하는 방법을 사용하지만, 리액트 네이티브의 VirtualizedList는 가상화 작업에서 해당하는 뷰를 버리고 새로만들어 붙이는 매우 터프한 방법으로 처리하기 때문에, 네이티브 앱에 비해 끊김 현상이 더 빈번히 발생합니다.
위의 방법으로 스크롤 끊김 현상이 만족할 만한 수준으로 개선되지 않는다면, RecyclerListView, FlashList를 사용해 볼 수 있습니다.
RecyclerListView는 화면에 벗어난 뷰의 타입이 화면에 새로 들어온 뷰의 타입과 동일하다면, 기존 뷰를 재사용하고, 위치와 데이터만 변경시켜서 보여주는 리스트입니다.
FlashList는 RecyclerListView를 래핑해서 FlatList와 거의 동일한 인터페이스로 만든 리스트입니다.
RecyclerListView, FlashList는 4-3에서 추가로 설명하겠습니다.
1-2. worklet
react-native-reanimated는 UI 스래드에서 UI 스래드와 동기적으로 실행되는 자체 자바스크립트 런타임(ReanimatedRuntime)을 가지고 있습니다.
- 런타임 객체 생성
https://github.com/software-mansion/react-native-reanimated/blob/3.15.3/packages/react-native-reanimated/Common/cpp/worklets/WorkletRuntime/WorkletRuntime.cpp#L46-L62 - 런타임 객체 생성시 hermes로 생성
https://github.com/software-mansion/react-native-reanimated/blob/3.15.3/packages/react-native-reanimated/Common/cpp/worklets/WorkletRuntime/ReanimatedRuntime.cpp#L22-L31
그리고 'worklet' 지시어가 붙은 함수는 react-native-reanimated/plugin에 의해 클로져와 worklet 해시 정보가 추가됩니다.
그리고 사용측에서 worklet 해시 정보가 붙은 함수를 ReanimatedRuntime에 요청할 수 있습니다.
그런데 useAnimatedStyle, useDerivedValue, useAnimatedReaction 훅은 내부적으로 worklet 함수를 실행시키는 effect를 만듭니다.
- useAnimatedStyle
https://github.com/software-mansion/react-native-reanimated/blob/3.15.3/packages/react-native-reanimated/src/hook/useAnimatedStyle.ts#L473-L516 - useDerivedValue
https://github.com/software-mansion/react-native-reanimated/blob/3.15.3/packages/react-native-reanimated/src/hook/useDerivedValue.ts#L51-L60 - useAnimatedReaction
https://github.com/software-mansion/react-native-reanimated/blob/3.15.3/packages/react-native-reanimated/src/hook/useAnimatedReaction.ts#L54-L65
예를 들어, 다음 코드는 스크롤할 때마다 effect가 처리되면서, UI 스래드에 부하를 발생시키고, 스크롤이 툭툭 끊깁니다.
import {useEffect} from 'react';
import { View, ScrollView } from 'react-native';
import {runOnUI} from 'react-native-reanimated';
export default function App() {
const [state, setState] = useState(0);
useEffect(() => {
const lockUI = () => {
'worklet';
let i = 0;
while (i < 1000000) {
i += 1;
}
};
runOnUI(lockUI)();
}, [state]);
return (
<ScrollView
style={{flex: 1}}
onScroll={() => {
setState(prev => prev + 1);
}}>
<View style={{height: 500, backgroundColor: 'green'}} />
<View style={{height: 500, backgroundColor: 'blue'}} />
</ScrollView>
);
}
리액트 네이티브에서 Carousel을 구현하기 위해 자주 사용되는 react-native-reanimated-carousel 라이브러리가 위의 예제와 비슷한 현상을 발생시킵니다.
- https://github.com/dohooo/react-native-reanimated-carousel/blob/main/src/components/Carousel.tsx
- https://github.com/dohooo/react-native-reanimated-carousel/blob/v3.5.1/src/hooks/useCommonVariables.ts#L39-L66
- https://github.com/dohooo/react-native-reanimated-carousel/blob/v3.5.1/src/hooks/useCarouselController.tsx#L83-L118
- https://github.com/dohooo/react-native-reanimated-carousel/blob/v3.5.1/src/hooks/useOnProgressChange.ts#L22-L49
- https://github.com/dohooo/react-native-reanimated-carousel/blob/v3.5.1/src/layouts/BaseLayout.tsx#L74-L100
- https://github.com/dohooo/react-native-reanimated-carousel/blob/v3.5.1/src/hooks/useOffsetX.ts#L47-L85
일단 찾은 것만 이정도입니다.
7년 전 처음 안드로이드 앱을 개발할 때, UI 스래드에서 구현한 애니메이션이 너무 끊겨서, UI 스래드 밖에서 뷰를 조작할 수 있는 SurfaceView를 사용했었습니다.
https://joondong.tistory.com/28
https://joondong.tistory.com/35
UI 스래드는 가장 비싼 스래드라고 알고 있었는데, 처음 react-native-reanimated를 접했을 때, 이렇게 해도 될까 싶었는데도 불구하고, 대부분의 경우에 부드럽게 잘 작동했습니다.
아마 리액트 네이티브의 경우 JS 스래드가 원래 네이티브 앱이 했어야 할 역할을 상당 부분 가져갔고, worklet 단위로 잘게 쪼개서 사용하기 때문에, 문제없이 작동했을 것이라고 추정됩니다.
worklet도 매우 많이 호출되면 끊길 수 있습니다.
이 경우 ScrollView를 사용하거나, Carousel을 memoization해야 합니다.
1-3. 너무 긴 아이템
1-3-1. 섹션
FlatList를 사용해서 섹션을 구성할 때 특션 섹션을 너무 길게 만드는 경우가 있습니다.
특히 안드로이드에서 많이 문제가 됩니다.
이 경우 VirtualizedList의 중첩 기능과 내부 VirtualizedList에서 removeClippedSubviews 속성을 이용할 수 있습니다.
중첩된 VirtualizedList는 Context를 통해 부모 리스트에 등록되고,
ScrollView가 아닌, View로 랜더링됩니다.
부모 VirtualizedList는 스크롤 이벤트를 자식 리스트에 전파합니다.
부모 이벤트를 기반으로 자신이 표시해야 할 셀 범위를 결정합니다.
공식 문서에는 중첩에 대한 언급이 없어서, 좀 걱정스러운 부분이 있었는데, 코드상으로 확실히 지원하는 것을 확인할 수 있었습니다.
1-3-2. 웹뷰
스크롤뷰 안에 웹 컨텐츠를 보여줘야 하는 상황이 발생합니다.
<ScrollView>
<View style={{height: 100, backgroundColor: 'yellow'}} />
<WebView
style={{height: 300}}
source={{uri: 'https://youtube.com/'}} />
</ScrollView>
하지만 react-native의 스크롤뷰와 react-native-webview는 안드로이드의 NestedScroll API를 준수하지 않기 때문에, 부모 스크롤뷰와 자식 스크롤뷰가 따로 놉니다.
아마 대부분의 회사에서 웹뷰 높이를 컨텐츠 높이까지 늘려주는 react-native-autoheight-webview를 썼을 것으로 예상됩니다. (아니면 구현은 간단하므로 직접 만들거나)
웹뷰도 브라우저입니다.
이런 방법은 메인 스래드에서 만들어진 랜더 트리를 컴포지터 스래드에서 필요한 만큼만 잘라와 보여주는 최적화를 이용할 수 없어 너무 많은 리소스를 잡아먹습니다.
그리고 안드로이드에서 높이가 5000픽셀 정도를 넘어가면 스크롤이 이상한 곳으로 튀기는 현상이 발생했었습니다.
이 경우 nested-scroll과 nested-scroll-webview 라이브러리를 사용할 수 있습니다.
아마 좀 생소한 라이브러리일 겁니다.
그리고 nested-scroll-webview의 경우 최신 react-native-webview와 호환되지 않는 문제도 있습니다.
아래 포스트에서 코드 분석과 해결 방법을 정리해 두었습니다.
리액트 네이티브 스크롤뷰 중첩
안드로이드의 경우 네이티브 NestedScrollView를 사용하고, iOS는 네이티브 스크롤뷰를 조작하여 내부 스크롤 뷰와 함께 작동할 수 있게 만든 스크롤뷰입니다. react-native-troika/packages/nested-scroll at maste
joondong.tistory.com
리액트 네이티브 WebView 중첩
react-native-webview는 안드로이드에서 NestingScroll API를 준수하지 않기 때문에, ScrollView 중첩과 마찬가지로 ScrollView 안에 WebView를 랜더링하면 스크롤이 잘 작동하지 않습니다. 이 경우 WebView의 높이를
joondong.tistory.com
그리고 FlashList는 중첩 기능이 없는데, ScrollView 기반이기 때문에, 약간의 기교와 nested-scroll 라이브러리를 사용하면 충분히 복잡한 레이아웃도 충분히 그릴 수 있습니다.
flash-list로 복잡한 레이아웃 구현하기
FlashList로 복잡한 레이아웃을 쉽게 그리기 위해 "직접" 제작한 flash-section-list에 대한 내용입니다.목차1. 필요성2. demo3. workflow 3-1. 최소 공배수 3-2. 섹션 데이터 직렬화 3-3. overrideItemLayout 3-4. g
joondong.tistory.com
2. 탭 버튼
리액트 네이티브에서 JS와 Java가 통신하는 방법, 리액트 네이티브 터치 이벤트 흐름에서 리액트 네이티브에서 터치 반응속도가 느린 이유를 찾지 못했습니다.
네이티브의 터치 이벤트는 1ms도 안되어 자바스크립트 스래드로 도착합니다.
2-1. 리액트의 근본적인 문제
탭버튼은 다른 버튼과 달리 최종 상태를 시각적으로 표시해야 합니다.
문제는 리액트 특성상 리액트 트리를 완성후 UI 스래드에 그리기를 요청하기 때문에, 랜더링 시간이 오래 걸리면, 그만큼 탭 버튼의 상태 변화 등이 늦게 나타난다는 것입니다.
안좋은 예는 react-native-tab-view이고, 좋은 예는 react-navigation/bottom-tabs입니다.
2-1-1. react-native-tab-view
react-native-tab-view는 외부 네비게이션 상태가 변경되면 탭바와 스크린을 같이 리랜더링합니다.
이 경우 모든 스크린을 memoization하는 방법이 있습니다.
그런데 경험상 저를 포함해서 대부분 작업자가 네비게이션 상태에서 포커스 여부를 컨텐츠에 props로 전달했었습니다. (물론 필요에 의해서)
위의 예제에선 경우 탭 버튼 한 번에 2개의 스크린에 무거운 리랜더링이 발생하고, 그만큼 탭 상태 변경이 지연됩니다.
즉, memoization 효과가 감소되는 것입니다.
기본 탭바는 Animated.Value 객체(position)로 opacity를 변경하기 때문에 즉각적으로 반응하는 것처럼 보이지만, 위의 예제에서 두 탭을 빠르게 번갈아 터치하면 반응이 지연되는 것을 확인할 수 있습니다.
이때는 포커스 정보를 Context나 이벤트 핸들러 등 다른 방법으로 얻고, 가급적 탭 랜더링 다음 프레임에 처리하는 것이 좋습니다.
2-1-2. react-navigation/bottom-tabs
네비게이션 상태가 변경돼도 스크린까지 리랜더링하지 않습니다.
react-navigation는 순수 자바스크립트 라이브러리입니다.
하지만 내부적으로 아래 예제외 같이 react-native-screens의 ScreenContainer와 Screen 네이티브 컴포넌트를 사용하는데,
예제 코드 어디에도 FirstScreen이나 SecondScreen이 마운트되거나 언마운트되는 코드는 없습니다.
Screen과 ScreenContainer 컴포넌트도 마찬가지입니다.
그럼에도 불구하고 위의 예제에서 탭 버튼을 터치하면 FirstScreen과 SecondScreen이 사라지거나 나타납니다.
Screen 컴포넌트가 제어하는 Screen 네이티브 뷰는 상태가 변하면 ScreenContainer에 알립니다.
ScreenContainer 컴포넌트가 제어하는 ScreenContainer 네이티브 뷰는 Screen 네이티브 뷰를 RootView로 부터 얻은 FragmentTransaction에서 떼어내거나 붙입니다. (notifyChildUpdate → onUpdate)
즉, View의 removeClippedSubviews처럼 리액트 트리와 네이티브 뷰 트리가 달라지는 것입니다.
그리고 사용자가 정의한 스크린 컴포넌트는 StaticContainer로 래핑되기 때문에, 네비게이션 상태가 변경되더라도 ScreenContainer → Screen까지의 상태만 업데이트되고, 사용자 정의 스크린은 리랜더링되지 않습니다.
즉, 리랜더링을 빠르게 완료할 수 있고, 네이티브 측에서 보여야할 네이티브 뷰만 보여줍니다.
- TabView > SceneView
https://github.com/react-navigation/react-navigation/blob/f9031ec3eeeda46db5c5a55dca0f24f0ea021547/packages/react-native-tab-view/src/TabView.tsx#L135 - SceneView > StaticContainer
https://github.com/react-navigation/react-navigation/blob/f9031ec3eeeda46db5c5a55dca0f24f0ea021547/packages/core/src/SceneView.tsx#L125-L136 - StaticContainer
https://github.com/react-navigation/react-navigation/blob/f9031ec3eeeda46db5c5a55dca0f24f0ea021547/packages/core/src/StaticContainer.tsx#L6
이러한 특성으로 탭 상태 변경과 스크린 전환이 매우 빠르게 전환되고, 메모리 사용량이 감소됩니다.
참고로 탭뷰는 리액트 네이티브가 아니라, 네이티브에서도 좋은 성능을 기대하기 힘든 뷰입니다.
안드로이드에서 탭뷰를 만들 때 주로 사용되고, react-native-tab-view에서 사용하는 네이티브 뷰인 ViewPager2는 뷰가 컨테이너 밖으로 나갔을 때 뷰 트리에서 떼어내는 기능이 내장되어 있기 때문에, 다른 탭의 뷰의 복잡도가 현재 뷰에 영향을 주지 않습니다.
iOS는 화면에 보이지 않는 뷰는 성능에 영향을 주지 않는 것으로 보입니다.
문제는 현재 탭 또는 새로운 탭에 포함된 뷰가 너무 복잡하면 다른 탭으로 이동할 때 스와이프 애니메이션이 버벅거리거나 전환 속도가 느릴 수 있다는 것입니다.
이건 현재 탭에 포함된 뷰를 줄이고, 무거운 뷰(웹뷰, 동영상 등)를 제거하지 않으면 네이티브도 어쩔 수 없는 부분입니다.
2-2. useIsFocused
react-navigation에서 현재 컴포넌트가 포함된 스크린의 포커스 여부를 반환하는 훅입니다.
스크린 내부에서 useIsFocused 훅을 사용하면, react-navigation에서 스크린으로의 랜더링을 막아도 이것을 뚫고 스크린까지 랜더링되고, 탭바의 업데이트도 지연됩니다.
만약 이동전 스크린과 이동하는 스크린 모두에서 사용된다면, 두 스크린 모두 리랜더링되고, 탭바의 업데이트도 더 느려질 것입니다.
해당 훅은 가급적 useIsFocused 대신, 현재 랜더링 다음 프레임에 처리되는 useFocusEffect와 InteractionManager.runAfterInteractions를 함께 사용하고, 어쩔수 없이 사용되어야 한다면, 포커스에 의해 처리되는 랜더링을 분산시켜야 합니다.
useFocusEffect(() => {
InteractionManager.runAfterInteractions(() => {
// focus
})
return () => {
InteractionManager.runAfterInteractions(() => {
// unfocus
})
}
})
3. 스크린 이동
스크린 이동시 이동전후 스크린 전환 애니메이션이 버벅거리는 경우를 많이 봤습니다.
3-1. react-navigation/stack
react-navigation/stack은 빌트인 Animated 모듈을 사용하여 JS에서 애니메이션을 제어합니다.
- 미리 정의된 전환 애니메이션 보간 함수
https://github.com/react-navigation/react-navigation/blob/f9031ec3eeeda46db5c5a55dca0f24f0ea021547/packages/stack/src/TransitionConfigs/CardStyleInterpolators.tsx#L14 - 전환 애니메이션 값을 스타일로 변환
https://github.com/react-navigation/react-navigation/blob/f9031ec3eeeda46db5c5a55dca0f24f0ea021547/packages/stack/src/views/Stack/Card.tsx#L490-L504
react-navigation/stack은 리랜더링이 16ms 내에 처리되지 못하면 추가 렉이 발생합니다.
그리고 애니메이션이 필요할 땐 react-native-reanimated를 사용해서, 빌트인 Animated는 거의 안썼는데, 빌트인 Animated가 120hz를 지원하는지는 모르겠습니다.
공식 문서에는 JS에서 60 프레임으로 애니메이션을 제공한다고 되어 있습니다. (참고: https://reactnative.dev/docs/performance 마지막 부분, https://github.com/facebook/react-native/issues/29333)
코드상으로는 지원하지 않는 것 같습니다.
https://github.com/facebook/react-native/blob/v0.75.4/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/FrameBasedAnimationDriver.java#L25
3-2. react-navigation/native-stack
react-navigation/native-stack은 애니메이션을 네이티브측에 정의해 놓고 실행하는 것만 제외하면 react-navigation/stack과 동일합니다.
- ScreenContainer 대신 ScreenStack 사용
https://github.com/react-navigation/react-navigation/blob/f9031ec3eeeda46db5c5a55dca0f24f0ea021547/packages/native-stack/src/views/NativeStackView.native.tsx#L546 - ScreenStack은 ScreenContainer 상속
https://github.com/software-mansion/react-native-screens/blob/3.34.0/android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt#L15-L17 - 네비게이션 상태 변경시 미리 정의된 애니메이션 실행
https://github.com/software-mansion/react-native-screens/blob/3.34.0/android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt#L139-L254
3-3. wix/react-native-navigation
wix/react-native-navigation은 JS에서 push 함수 등으로 애니메이션 설정값을 전달하면 네이티브에서 처리하는 방식입니다. (참고: wix/react-native-navigation 코드 분석)
예제 코드
Navigation.push(props.componentId, { // 이동을 요청하는(이동하는X) 앱의 ID
component: {
name: 'Home',
options: {
animations: {
push: {
content: {
translationX: {
from: Dimensions.get('screen').width,
to: 0,
duration: 300,
},
},
},
pop: {
content: {
translationX: {
from: 0,
to: Dimensions.get('screen').width,
duration: 300,
},
},
},
},
},
},
});
위의 코드를 처리하는 네이티브 코드입니다.
- StackController push 메서드에서 애니메이션 설정값 추출후 애니메이터에 위임
https://github.com/wix/react-native-navigation/blob/8c64969f09657918e5840dca4f9ab8162f3bf356/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/stack/StackController.java#L170-L177 - AnimatorSet 객체에 신규 스크린(appearing)에 대한 애니메이션 요청
https://github.com/wix/react-native-navigation/blob/8c64969f09657918e5840dca4f9ab8162f3bf356/lib/android/app/src/main/java/com/reactnativenavigation/viewcontrollers/stack/StackAnimator.kt#L261-L266 - AnimatorSet.playTogether
https://developer.android.com/reference/android/animation/AnimatorSet#playTogether(android.animation.Animator[])
react-navigation/stack을 제외하면 네이티브에서 전환 애니메이션을 처리하는 방식이지만, 아무리 네이티브에서 처리한다고 해도, 한 번에 처리되는 네이티브 뷰가 많으면 애니메이션이 버벅거립니다.
이 경우 InteractionManager.runAfterInteractions를 사용하여 새로 생성되는 스크린 랜더링을 지연시키는 방법이 있습니다.
리액트 네이티브 개발자라면 대부분 알고 있겠지만, InteractionManager.runAfterInteractions는 사용자 인터렉션이나 애니메이션이 완료된 후에 콜백 함수를 실행시키는 리액트 네이티브 전용 기능입니다.
데이터를 미리 확보해 놓고, InteractionManager.runAfterInteractions의 콜백 함수에서 그리기를 시작하면 됩니다.
하지만 기존에 react-query와 Suspense를 사용하여 로딩처리를 하고 있다면, InteractionManager.runAfterInteractions을 사용하기 좀 애매해 집니다.
- 이미 Suspense가 useQuery에서 던진 Promise를 받아 로딩을 처리하고 있는데, 전환 시간 동안 로딩은 어떻게 처리할까? (다른 Promise를 던져서 기존의 Suspense에게 로딩 처리를 계속 위임할 것인지, 내부 상태로 처리할 것인지)
- 새로운 Promise를 던지는 방식으로 처리한다면, Promise가 resolve됐을 때(모든 상태 및 effect 초기화), 어떻게 Promise를 던졌는지 확인하고, 그 다음엔 던지지 않는 방법은?
- useQuery가 Promise를 던진 후에 사용해야 하므로, 위치를 강제하는 방법은 없을까?
- 의도하진 않았지만, 스크린 내부에서 언마운트를 한다면?
- 사용하기 쉬어야 한다.
이런 고민이 생기게 됩니다.
이를 위해 간단하게 WaitInteractions라는 컴포넌트를 만들어 봤습니다.
react-navigation과 의존성을 제거하기 위해 내부에서 사용되는 훅은 외부에서 주입받도록 만들었습니다.
wix/react-native-navigation도 위와 같은 훅을 만들 수 있을지는 모르겠습니다.
이 방법은 스크린 내부에서 사용되면 useQuery 이후에 사용되는 것을 보장할 수 있고, Promise가 resolve되면 상태와 effect가 초기화되기 때문에, 훅을 사용할 수 없어 외부 저장소(promises)를 사용해야 하는데, 이를 은닉할 수 있습니다.
4. 기타
4-1. 두 버튼을 빠르게 번갈아가며 터치할 때
아래 두 개의 Pressable 버튼이 구성된 상태에서(셀 안의 숫자는 네이티브 태그),
35 | 45 |
45번 버튼을 터치한 후, 다시 매우 빠르게 35 버튼을 터치하고,
다음 위치에서 출력해 보면,
console.log("topLevelType:", topLevelType, targetInst.stateNode._nativeTag, tnativeEvnet.pageX)
네이티브에선 터치된 X 좌표의 위치가 변했음에도, 45번 버튼만 터치된 것으로 인식합니다.
LOG topLevelType: topTouchStart 45 357.4687194824219 // 요기 !!
LOG topLevelType: topTouchMove 45 357.3060607910156
LOG topLevelType: topTouchMove 45 357.2343444824219
LOG topLevelType: topTouchMove 45 357.1874694824219
LOG topLevelType: topTouchMove 45 356.9062194824219
LOG topLevelType: topTouchStart 45 37.406246185302734 // 요기 !!
LOG topLevelType: topTouchMove 45 356.9062194824219
LOG topLevelType: topTouchMove 45 37.687496185302734
LOG topLevelType: topTouchEnd 45 356.9062194824219
LOG topLevelType: topTouchMove 45 38.13140106201172
LOG topLevelType: topTouchMove 45 38.156246185302734
LOG topLevelType: topTouchMove 45 38.015621185302734
LOG topLevelType: topTouchMove 45 37.640621185302734
LOG topLevelType: topTouchMove 45 37.124996185302734
LOG topLevelType: topTouchMove 45 36.187496185302734
LOG topLevelType: topTouchEnd 45 36.187496185302734
START 이벤트에서 태그가 지정되는데, 여기서 정확한 태그를 찾지 못합니다.
안드로이드 네이티브 코드는 node_modules/react-native에 있는 코드를 사용하는 것이 아니라, Maven에 미리 빌드되어 배포된 파일을 사용하기 때문에 디버깅을 하지 못해서 문제가 부분을 찾지 못했습니다.
https://mvnrepository.com/artifact/com.facebook.react/react-android
절대 좌표를 상대 좌표로 변환하는 과정이 약간 의심스럽습니다.
그리고 Pressable은 터치 위치가 버튼 영역 밖으로 벗어났다고 판단해서 핸들링을 취소합니다.
이것때문에 사용자가 실수로 어떤 버튼을 터치 후, 다른 버튼을 터치했을 때, 터치가 반응하지 않는다는 반응이 없다고 느낄 수 있습니다.
이런 현상은 react-navigation의 BottomTab에서 서로 다른 탭 버튼을 빠르게 바꿔 터치할 때 경험할 수 있습니다.
react-native-gesture-handler는 ReactRootView와 비슷하게 RNGestureHandlerRootView에서 이벤트를 캐치해서 터치 좌표의 Leaf 뷰까지 탐색하는데, react-native 터치 시스템과 달리 정확한 네이티브 태그를 반환합니다.
해당 기능을 사용하려면 react-native-gesture-handler의 BaseButton을 사용하거나 GestureDetector를 사용해 버튼을 직접 만들면 됩니다.
4-2. RecyclerListView와 FlashList에 대한 환상
4-2-1. RecyclerListView
LayoutProvider를 받은 VirtualRenderer로 가상 리스트(아이템은 레이아웃(x, y, width, height)과 key 등 껍데기로 구성)를 만들어 놓고, 실제 리스트를 그릴 때, VirtualRenderer로 부터 레이아웃(itemRect), key 정보를 가져와 아이템의 컨테이너인 ViewRenderer의 영역과 key로 사용합니다.
https://github.com/Flipkart/recyclerlistview/blob/4.2.1/src/core/RecyclerListView.tsx#L691-L701
key는 DataProvider에 입력한 getStableId(우선)와 LayoutManager from LayoutProvider에 입력한 getLayoutTypeForIndex에 의해서 결정됩니다.
https://github.com/Flipkart/recyclerlistview/blob/4.2.1/src/core/VirtualRenderer.ts#L212-L219
즉, RecyclerListView는 컨테이너의 타입(ViewRenderer)과 key를 사용해서 리액트가 아이템 순서만 변경된 것으로 인식하게 함으로써, 내부 트리의 재사용 기회만 제공하는 것입니다. (참고: React 톺아보기 - 05. Reconciler_3)
따라서 동일한 타입의 내부 트리에 대한 동일성은 개발자가 보장해야 합니다.
다음과 같이 타입이 한 개 밖에 없는 리스트를 랜더링할 때, 내부 아이템을 재사용하지 않으면, 아무리 RecyclerListView라도 스크롤할 때 네이티브 뷰 추가/생성/삭제가 발생하면서 매우 버벅거립니다.
const dataProvider = new DataProvider((r1, r2) => r1 !== r2);
dataProvider.cloneWithRows(Array.from({length: 300}));
const layoutProvider = new LayoutProvider(
() => 'a',
(type, dim, index) => {
dim.width = screenWidth;
dim.height = 110;
},
);
const colors = Array.from({length: 100}).map(getRandomColor);
const Item = () => {
return (
<View style={{flexDirection: 'row', flexWrap: 'wrap'}}>
{colors.map(color => {
return (
<View
key={Math.random()} // 요기 !!
style={{
width: screenWidth / 10 - 0.0001,
height: 10,
backgroundColor: color,
}}
/>
);
})}
</View>
);
};
function App(): React.JSX.Element {
return (
<RecyclerListView
dataProvider={dataProvider}
layoutProvider={layoutProvider}
rowRenderer={() => {
return <Item />;
}}
/>
);
}
그리고 RecyclerListView도 그리는 영역 안에서 재사용할 타입(key)를 찾지 못하면, 아무런 의미가 없습니다.
좀 더 쉽게 표현하면, 다양한 타입의 아이템을 드문드문 사용하면 FlatList와 별로 다를게 없습니다.
4-2-2. FlashList
FlashList는 getItemType을 LayoutProvider의 getLayoutTypeForIndex로 사용합니다.
FlashList는 getItemType이 입력되지 않으면, 무조건 0을 사용합니다.
https://github.com/Shopify/flash-list/blob/v1.7.1/src/FlashList.tsx#L235-L243
즉, 기본적으로 한 가지 종류의 아이템만 사용한다고 가정한 RecyclerListView입니다.
shopify는 이러한 가정이 대부분의 요구사항을 만족할 수 있다고 판단한 것 같습니다.
가끔씩 다른 타입의 뷰가 등장해서 재사용하지 못하더라도, FlatList보다 낫다고 판단한 것 같습니다.
FlashList에서 다양한 타입을 좀 더 빠르게 사용하려면, getItemType을 정의해야 합니다.
근본적으로 RecyclerListView와 FlashList는 재활용이 매우 많이 발생할 수 있는 디자인에서 사용되어야 제 역할을 합니다.
참고로 FlashList는 중첩 기능을 제공하지 않아서 FlatList처럼 레이아웃 구성이 자유롭지 못한데, flash-list로 복잡한 레이아웃 구현하기 포스트에서 어떻게 극복할 수 있는지 살펴보겠습니다.
5. 느낀점
사실 요즘 스마트폰 기준으로 위에서 언급한 FlatList, 리랜더링, worklet 등을 고려하지 않더라도 사용자가 크게 불편함을 느낄 정도로 버벅이지 않습니다.
그런데 제가 앱 개발자라서 그런지 몰라도 약간의 버벅임도 거슬리더군요.
저사양 폰에서도 부드럽게 작동하는 앱을 만들고 싶었습니다.
지금까지는 memoization만 하던게 고작이었는데, 이번 분석을 통해 리액트 네이티브 앱으로도 네이티브 앱에 가까운 (성능까진 아니고) 사용자 경험을 만들어 낼 수 있다는 자신감을 얻었습니다.
더하여 이번에 출시된 0.76.0에서 Bridgeless 모드의 interop layers가 개선되면서 New Arhitecture에서도 구 랜더러 기반으로 만들어진 라이브러리를 그대로 사용할 수 있게 되었습니다.
그리고 Callstack에서 개발한 repack을 사용하면 번들링 과정에 더 자유롭게 개입할 수 있게 되었습니다.
개발사의 적극적인 지원과 Callstack, Software Mansion, Shopify같은 훌륭한 파트너덕분에 앞으로의 리액트 네이티브 생태계의 더욱 큰 성장이 기대됩니다.
'리액트 네이티브' 카테고리의 다른 글
리액트 네이티브 WebView 중첩 (0) | 2024.10.03 |
---|---|
리액트 네이티브 스크롤뷰 중첩 (0) | 2024.10.03 |
리액트 네이티브 터치 이벤트 흐름 (0) | 2024.08.27 |
리액트 네이티브에서 JS와 Java가 통신하는 방법 (0) | 2024.08.27 |
RN Perspective Crop - 크롭 사이즈편 (1) | 2021.05.18 |