Skip to main content

Using Native Driver for Animated

· 12 min read
Janic Duplessis
Software Engineer at App & Flow

지난 1년 동안, 우리는 Animated 라이브러리를 사용한 애니메이션의 성능을 개선하기 위해 노력해 왔다. 애니메이션은 아름다운 사용자 경험을 만드는 데 매우 중요하지만, 올바르게 구현하기 쉽지 않다. 우리는 개발자가 성능 저하를 일으킬 수 있는 코드를 걱정하지 않고도 효율적인 애니메이션을 쉽게 만들 수 있도록 하고 싶다.

이 기능의 목적

Animated API는 매우 중요한 제약 조건을 염두에 두고 설계되었다. 바로 직렬화 가능하다는 점이다. 이는 애니메이션이 시작되기 전에 모든 정보를 네이티브로 보낼 수 있음을 의미한다. 따라서 네이티브 코드가 UI 스레드에서 애니메이션을 수행할 때, 매 프레임마다 브리지를 거칠 필요가 없다. 이 기능은 매우 유용한데, 애니메이션이 시작된 후에도 JS 스레드가 블로킹되더라도 애니메이션이 부드럽게 실행될 수 있기 때문이다. 실제로 사용자 코드가 JS 스레드에서 실행되고 React 렌더링이 JS를 오랫동안 잠글 수 있기 때문에 이러한 상황은 자주 발생할 수 있다.

약간의 역사...

이 프로젝트는 약 1년 전 Expo가 Android용 li.st 앱을 개발하면서 시작되었다. Krzysztof Magiera는 Android에서 초기 구현을 담당했다. 결과는 성공적이었고, li.st는 Animated를 사용해 네이티브로 구동되는 애니메이션을 탑재한 첫 번째 앱이 되었다. 몇 달 후, Brandon Withrow가 iOS에서 초기 구현을 완성했다. 이후 Ryan Gomba와 나는 Animated.event 지원 같은 누락된 기능을 추가하고, 실제 프로덕션 앱에서 발견한 버그를 수정하는 작업을 진행했다. 이는 진정한 커뮤니티의 노력이었으며, 관련된 모든 분들과 개발의 상당 부분을 후원한 Expo에 감사드린다. 이제 이 기술은 React Native의 Touchable 컴포넌트와 최근 출시된 React Navigation 라이브러리의 네비게이션 애니메이션에서 사용되고 있다.

원리 이해

먼저, JS 드라이버를 사용하는 Animated로 애니메이션이 어떻게 동작하는지 살펴보자. Animated를 사용할 때는 수행하려는 애니메이션을 나타내는 노드 그래프를 선언한 후, 드라이버를 사용해 미리 정의된 곡선에 따라 Animated 값을 업데이트한다. 또한 Animated.event를 사용해 View의 이벤트에 Animated 값을 연결해 업데이트할 수도 있다.

애니메이션의 단계와 각 단계가 어디서 일어나는지 정리하면 다음과 같다:

  • JS: 애니메이션 드라이버는 requestAnimationFrame을 사용해 매 프레임마다 실행되고, 애니메이션 곡선을 기반으로 계산한 새로운 값으로 드라이브되는 값을 업데이트한다.
  • JS: 중간 값이 계산되어 View에 연결된 props 노드로 전달된다.
  • JS: setNativeProps를 사용해 View가 업데이트된다.
  • JS에서 Native로 브리지를 거친다.
  • Native: UIView 또는 android.View가 업데이트된다.

보이는 것처럼 대부분의 작업은 JS 스레드에서 일어난다. JS 스레드가 블로킹되면 애니메이션이 프레임을 건너뛰게 된다. 또한 매 프레임마다 JS에서 Native로 브리지를 거쳐 네이티브 뷰를 업데이트해야 한다.

네이티브 드라이버는 이 모든 단계를 네이티브로 옮긴다. Animated가 애니메이션 노드 그래프를 생성하기 때문에, 애니메이션이 시작될 때 한 번만 네이티브로 직렬화하여 전송할 수 있다. 이렇게 하면 JS 스레드로 다시 콜백할 필요가 없어지고, 네이티브 코드가 매 프레임마다 UI 스레드에서 직접 뷰를 업데이트할 수 있다.

애니메이션 값과 보간 노드를 직렬화하는 예제를 살펴보자 (정확한 구현은 아니고 예제일 뿐이다).

네이티브 값 노드를 생성한다. 이 값이 애니메이션될 값이다:

NativeAnimatedModule.createNode({
id: 1,
type: 'value',
initialValue: 0,
});

네이티브 보간 노드를 생성한다. 이 노드는 네이티브 드라이버에게 값을 어떻게 보간할지 알려준다:

NativeAnimatedModule.createNode({
id: 2,
type: 'interpolation',
inputRange: [0, 10],
outputRange: [10, 0],
extrapolate: 'clamp',
});

네이티브 props 노드를 생성한다. 이 노드는 네이티브 드라이버에게 뷰의 어떤 prop에 연결되었는지 알려준다:

NativeAnimatedModule.createNode({
id: 3,
type: 'props',
properties: ['style.opacity'],
});

노드들을 연결한다:

NativeAnimatedModule.connectNodes(1, 2);
NativeAnimatedModule.connectNodes(2, 3);

props 노드를 뷰에 연결한다:

NativeAnimatedModule.connectToView(3, ReactNative.findNodeHandle(viewRef));

이렇게 하면 네이티브 애니메이션 모듈은 JS로 값을 계산할 필요 없이 네이티브 뷰를 직접 업데이트하는 데 필요한 모든 정보를 갖게 된다.

남은 일은 어떤 타입의 애니메이션 곡선을 사용할지와 어떤 애니메이션 값을 업데이트할지 지정해 애니메이션을 실제로 시작하는 것이다. 타이밍 애니메이션은 JS에서 미리 모든 프레임을 계산해 네이티브 구현을 더 작게 만들 수 있다.

NativeAnimatedModule.startAnimation({
type: 'timing',
frames: [0, 0.1, 0.2, 0.4, 0.65, ...],
animatedValueId: 1,
});

이제 애니메이션이 실행될 때 일어나는 일을 정리하면 다음과 같다:

  • Native: 네이티브 애니메이션 드라이버는 CADisplayLink 또는 android.view.Choreographer를 사용해 매 프레임마다 실행되고, 애니메이션 곡선을 기반으로 계산한 새로운 값으로 드라이브되는 값을 업데이트한다.
  • Native: 중간 값이 계산되어 네이티브 뷰에 연결된 props 노드로 전달된다.
  • Native: UIView 또는 android.View가 업데이트된다.

보이는 것처럼 더 이상 JS 스레드와 브리지가 필요 없으므로 애니메이션이 더 빨라진다! 🎉🎉

앱에서 사용하는 방법

일반적인 애니메이션의 경우 답은 간단하다. 애니메이션을 시작할 때 설정에 useNativeDriver: true를 추가하면 된다.

이전 코드:

Animated.timing(this.state.animatedValue, {
toValue: 1,
duration: 500,
}).start();

변경 후 코드:

Animated.timing(this.state.animatedValue, {
toValue: 1,
duration: 500,
useNativeDriver: true, // <-- 이 부분 추가
}).start();

애니메이션 값은 하나의 드라이버와만 호환된다. 따라서 어떤 값에 대해 애니메이션을 시작할 때 네이티브 드라이버를 사용한다면, 해당 값에 대한 모든 애니메이션도 네이티브 드라이버를 사용해야 한다.

이것은 Animated.event에서도 동작한다. 스크롤 위치를 따라야 하는 애니메이션에 매우 유용하다. 네이티브 드라이버를 사용하지 않으면 React Native의 비동기적 특성 때문에 제스처보다 항상 한 프레임 뒤처지게 된다.

이전 코드:

<ScrollView
scrollEventThrottle={16}
onScroll={Animated.event(
[{ nativeEvent: { contentOffset: { y: this.state.animatedValue } } }]
)}
>
{content}
</ScrollView>

변경 후 코드:

<Animated.ScrollView // <-- Animated ScrollView 래퍼 사용
scrollEventThrottle={1} // <-- 이벤트가 누락되지 않도록 1로 설정
onScroll={Animated.event(
[{ nativeEvent: { contentOffset: { y: this.state.animatedValue } } }],
{ useNativeDriver: true } // <-- 이 부분 추가
)}
>
{content}
</Animated.ScrollView>

주의사항

Native Animated에서 Animated의 모든 기능을 사용할 수 있는 것은 아니다. 주요 제한 사항은 레이아웃 속성이 아닌 프로퍼티만 애니메이션으로 처리할 수 있다는 점이다. 예를 들어 transformopacity는 작동하지만, 플렉스 박스와 위치 관련 속성은 지원하지 않는다. 또 다른 제한은 Animated.event인데, 이벤트 버블링이 아닌 직접 이벤트에서만 작동한다. 즉, PanResponder와는 호환되지 않지만 ScrollView#onScroll 같은 이벤트에서는 정상적으로 동작한다.

Native Animated는 React Native에 오랫동안 포함되어 있었지만, 실험적인 기능으로 간주되어 문서화되지 않았다. 따라서 이 기능을 사용하려면 React Native의 최신 버전(0.40 이상)을 사용해야 한다.

리소스

애니메이션에 대해 더 자세히 알고 싶다면 Christopher Chedeau이 강연을 추천한다.

애니메이션을 네이티브로 오프로딩하면 사용자 경험이 어떻게 개선되는지 깊이 있게 알고 싶다면 Krzysztof Magiera이 강연도 참고하면 좋다.