공부/frontend

[React Native] 좌충우돌 리액트 네이티브에서 이미지 스프라이트 기법 사용하기

버번통 2024. 12. 25. 18:29

1. 사용 계기

프론트엔드 개발 중 동적인 이미지 구현을 하고 싶을 때가 있음. 이번 프로젝트에서 사용할 일이 생겼었다.

 

개발을 모르던 나도 알았던 gif를 사용해볼까도 했는데, gif의 단점인 동적 제어(재생, 정지)가 안되는 점이 이번 프로젝트에서 가장 큰 문제여서 다른 방식을 찾아보던 중 이미지 스프라이트 기법을 알게 됨.

 

gpt가 그려준 캐릭터를 열심히 도트찍어 만든 걸작 중 하나..

 

이미지를 행과 열로 배치하고, 그걸 번갈아서 보여줌으로서 동적인 느낌을 선사할 수 있다. 

 

장점은 여러가지가 있는데 일단 이미지 하나니까 gif 대비 가볍고, 여러가지 이미지를 배치하면 그 순서와 조합에 따라 다양한 동적인 움직임을 선사할 수 있다!(하지만 시간은 그리 많지 않아서 결국 저 둘만...)

 

1번 사진과 2번사진을 왔다갔다~

 

나는 흔히 사용하는 기법이라고 생각해서 이미 라이브러리들이 많지 않을까 싶어서 찾아보았다.

그 결과 있긴 한데 우리 RN버전에 맞는 라이브러리가 없었음...

 

우리는 헬스데이터를 받아와야되서 RN 버전을 라이브러리에 맞게 낮출 수는 없고, 짧은 프로젝트 기간 내에 이걸 직접 구현하는 건 진짜 무리수였다. 그래서 생각한 점이.. 

 

라이브러리에서 구현에 필요한 코드만 갖고와서 프로젝트에 붙이자! 였다 ㅎㅎㅎ..

 

내가 원하는건 두가지

1. 짧은 기간 내 내가 이해할 만한 코드들로 이루어진 라이브러리

2. 사람들이 많이 쓰거나 최신의 라이브러리

 

2의 이유는 서드파티의 라이브러리가 갖고 있는 위험성(사실 알고보니 해킹툴이 들어가있을지도..?)과 안정성을 고려한 선택이었음

 

그래서 내가 선택한 라이브러리는 이거였다.

https://github.com/kaissaroj/react-native-spritesheet

 

GitHub - kaissaroj/react-native-spritesheet: React Native Spritesheet

React Native Spritesheet. Contribute to kaissaroj/react-native-spritesheet development by creating an account on GitHub.

github.com

 

많은 사람들의 선택을 받지 않긴 했지만 내가 생각한 고려점을 다 갖춘 라이브러리였음.

 

첫번째 단기간에 이해하기 쉽게 기본적인 구조만을 가졌음 

(좌측: 내가 선택한 라이브러리)src 폴더안의 components 부분만 이해하면 내가 갖다 쓸 수 있음! / (우측: 가장 사람들이 많이 사용되는 라이브러리) android랑 ios 폴더까지 있어서 저거까지 파고들어야할 수도 있는 상황..

 

두번째 안정적으로 쓸 수 있을 것으로 판단됨.

weekly download 수는 저조하지만 README 상 구현에 사용되는 라이브러리가 매우 안정적인 라이브러리로 판단됨

react-native-reanimated 괜찮아보여!

https://docs.swmansion.com/react-native-reanimated/

 

React Native Reanimated

A powerful animation library that makes it easy to create smooth animations and interactions that run in the UI thread.

docs.swmansion.com

2. 그럼 구현해보자

내가 필요한 건 움직이는 거니까 아래와 같은 방식으로 진행을 해봄

      1. 라이브러리의 AnimatedSprite.tsx를 분석

      2. 구현에 필요한 라이브러리(React Native Reanimated) 설치

      3. 분석한 걸 바탕으로 프로젝트에서 쓸 컴포넌트 생성

2-1. 라이브러리의 AnimatedSprite.tsx를 분석

분석의 경우 약식으로 어떤 방식으로 구동하는지? + 어떤 것들이 필요한지(의존성)를 확인함

이 부분은 문제가 발생하지 않았다.

단순 어떻게 쓰면 되는지 확인하는 부분이라, 다만 우리 프로젝트가 javascript로 만들어져서 type 부분을 제외하고 써야겠다 정도의 변화만 주고 테스트 해야겠다 생각을 했는데..

2-2. 구현에 필요한 라이브러리(React Native Reanimated) 설치 (문제 발생!)

일단 README와 tsx에서 import한 라이브러리인 두 라이브러리 React Native Reanimated와 expo-image를 설치하려했는데 문제가 발생함

 

React Native Reanimated의 경우 까니 build가 실패하는 오류가 발생함.

cmake 관련 오류(정확한 오류 이름이 기억이 안난다.. 이래서 일이 생길때마다 적어놔야하는데..)였는데, RN에서 문제가 발생할따마다 하는 gradle clean이랑 cache를 지워도 오류가 발생함.

 

github issue창에서 나와 같은 문제가 발생한 사람들을 발견할 수 있었는데, React Native Reanimated가 ver2에서 ver3로 바뀌고 나서 문제가 발생하는 경우라고 하는 사람들이 많았다. 그때는 그냥 그런건가 하면서 거기서 사람들이 제시한 해결방법들을 따라해봤고, 의외로 경로 깊이 문제였었다.

 

ssafy에서 하는 프로젝트들의 경우 별도의 폴더로 나누어서 진행했는데, 솔직히 경로의 깊이가 ssafy>project>frontend여서 나는 경로 길이 문제는 아니라고 생각했는데 그 문제가 맞았다..

결국 경로를 짧게 수정해서 다시 앱을 빌드하니 잘 돌아가서 다른 팀원들의 폴더 구조에 맞게 push 후 조정이 필요하다고 전달함.

 

아마 이 issue였던 것 같긴 함

https://github.com/software-mansion/react-native-reanimated/issues/4712

 

Task :react-native-reanimated:configureCMakeDebug[arm64-v8a] FAILED for Android In empty project · Issue #4712 · software-mans

Description We recently upgraded to latest React-Native (0.72.2) and upgraded Reanimated to 3.3.0. After this upgrade, we get the following error when running assembleDebug for Android: Task :react...

github.com

 

expo-image의 경우도 문제가 발생했는데 import { Image } from 'expo-image';로 import한 Image가 안 먹음.

이 경우는 위 React Native Reanimated 버전이 변경되서가 크지 않을까 짐작을 하고 수정하기 전, 혹시나 React Native에서 기본적으로 제공하는 Image로 쓰니 원하는 대로 작동했다!

 

나에게 두 가지 선택지가 생겼는데 첫번째, expo-image가 작동 안하는 원인을 찾아 분석해서 해결해 expo-image의 강력한 성능을 프로젝트에 탑재한다. 두번째 그냥 되는 React Native의 Image를 활용한다. 

 

난 이 중 후자인 React Native의 Image를 활용한다. 를 선택함. 이유는 아래와 같음

 - 단기간의 프로젝트에서 빠르게 적용이 가능한 방향이 있는데, 성능이 얼마나 좋아질지도 모르는 비확실한 걸 선택하는 건 좋은 선택이 아니라고 판단함

- 그럴 시간에 차라리 기능 명세서에 적은 2,3 순위의 기능들을 구현하는게 나을거라 판단함

2-3. 분석한 걸 바탕으로 프로젝트에서 쓸 컴포넌트 생성

솔직히 처음에는 그냥 복사, 붙여넣기로 AnimatedSprite.tsx를 활용하려 했는데, 문제가 발생함.

분명 최근(2024년도)에 만들어진 라이브러리 코드였는데, 화면에서 잘 작동하다가도 백엔드와 통신한다던가 다른 페이지로 갔다가 돌아오면 움직임의 버벅이는 것을 확인함.

 

나는 2-2.의 문제 원인이였던 React Native Reanimated 버전 변경이 아닐까 판단함. 단순 코드 작동 원리만 보면 문제가 발생할 껀덕지가 보이지 않았음. 그래서 라이브러리 공식 문서를 보면서 문제를 파악하던 중 아래 부분을 봄.

React Compiler를 사용시 value에 직접 접근하지 말고 get이랑 set과 같은 방식으로 쓰라는 이야기

이걸 보고 라이브러리를 보니  currentAnimationName.value = animationName; 와 같이 직접 접근하는 부분들이 있다는 걸 확인함.

그래서 그것들을 get이나 set 문법으로 수정하니 성공적으로 작동함! 누더기로라도 돌아가게 만들었다! 

3. 만든 코드

솔직히 많이 부끄러운 코드인데 일단 만든 코드는 아래와 같다.

 

AnimatedSprite.jsx

import React, {
  forwardRef,
  useCallback,
  useEffect,
  useImperativeHandle,
} from 'react';
import { View, StyleSheet, Image } from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
  withRepeat,
  Easing,
} from 'react-native-reanimated';

const AnimatedImage = Animated.createAnimatedComponent(Image);

const AnimatedSprite = forwardRef((props, ref) => {
  const {
    source,
    spriteSheetWidth,
    spriteSheetHeight,
    frameRate = 10,
    width,
    height,
    frames,
    inLoop = true,
    autoPlay = true,
    animations,
    defaultAnimationName,
  } = props;

  const currentAnimationName = useSharedValue(defaultAnimationName);
  const frameIndex = useSharedValue(0);

  const ToggleAnimation = useCallback(
    (animationName, loop = false, customFrameRate = 10) => {
      if (!animations[animationName]) {
        console.warn(`Invalid animation name: ${animationName}`);
        return;
      }
      currentAnimationName.set(animationName);
      frameIndex.set(0);
      const selectedFramesIndices = animations[animationName] ?? [];
      frameIndex.value = withRepeat(
        withTiming(selectedFramesIndices.length - 1, {
          duration: (1000 / customFrameRate) * selectedFramesIndices.length,
          easing: Easing.linear,
        }),
        loop ? -1 : 1,
        false
      );
    },
    [animations, currentAnimationName, frameIndex]
  );

  useImperativeHandle(
    ref,
    () => ({
      startAnimation: (animationName, loop = false, customFrameRate = 10) => {
        ToggleAnimation(animationName, loop, customFrameRate);
      },
      getCurrentAnimationName: () => currentAnimationName.get(),
    }),
    [ToggleAnimation, currentAnimationName]
  );

  useEffect(() => {
    if (autoPlay) {
      ToggleAnimation(defaultAnimationName, inLoop, frameRate);
    }
  }, [ToggleAnimation, autoPlay, defaultAnimationName, frameRate, inLoop]);

  const animatedStyle = useAnimatedStyle(() => {
    const selectedFrames =
      animations[currentAnimationName.get()]?.map((index) => frames[index]) ??
      [];
    const frame = selectedFrames[Math.floor(frameIndex.get())];
    if (!frame) {
      return {};
    }

    const scaleX = width / frame.w;
    const scaleY = height / frame.h;
    const positionX = frame.x * scaleX;
    const positionY = frame.y * scaleY;

    return {
      width: spriteSheetWidth * scaleX,
      height: spriteSheetHeight * scaleY,
      transform: [{ translateX: -positionX }, { translateY: -positionY }],
    };
  });

  return (
    <View
      key={`animation-block-${width}-${height}`}
      style={[
        {
          width,
          height,
        },
        styles.container,
      ]}
    >
      <AnimatedImage source={source} style={animatedStyle} contentFit="cover" />
    </View>
  );
});

const styles = StyleSheet.create({
  container: {
    overflow: 'hidden',
  },
});

export default AnimatedSprite;

 

그리고 활용은 아래처럼

 

import React from 'react';
import { View } from 'react-native';
import AnimatedSprite from '@components/AnimatedSprite'; // 내가 저장한 jsx 위치였음

function ExampleScreen() {
    const petSpriteImage = require('@assets/pets/lion_sprite.png'); // 사용할 도트로 만든 스프라이트 이미지 경로
    // 실제 스프라이트 크기나 위치 정보, Aseprite를 활용시 아래와 같이 json 데이터로 좌표 값을 지정해줘서 편리했다
    const petSpriteData = {
        frames: {
            0: {
                frame: { x: 0, y: 0, w: 256, h: 256 },
                rotated: false,
                trimmed: false,
                spriteSourceSize: { x: 0, y: 0, w: 256, h: 256 },
                sourceSize: { w: 256, h: 256 },
                duration: 100,
            },
            1: {
                frame: { x: 0, y: 256, w: 256, h: 256 },
                rotated: false,
                trimmed: false,
                spriteSourceSize: { x: 0, y: 0, w: 256, h: 256 },
                sourceSize: { w: 256, h: 256 },
                duration: 100,
            },
        },
        meta: {
            app: 'https://www.aseprite.org/',
            version: '1.x-dev',
            image: 'lion_sprite.png',
            format: 'RGBA8888',
            size: { w: 256, h: 512 },
            scale: '1',
            frameTags: [],
            layers: [{ name: 'Layer 1', opacity: 255, blendMode: 'normal' }],
            slices: [],
        },
    };

    const frames = Object.values(petSpriteData.frames).map((frame) => ({
        x: frame.frame.x,
        y: frame.frame.y,
        w: frame.frame.w,
        h: frame.frame.h,
        duration: frame.duration,
    }));

    // 원하는 방식으로 이미지 왔다갔다를 설정가능함
    const animations = {
        walk: [0, 1, 0],
    };

    return (
        <View>
            <AnimatedSprite
                source={petSpriteImage} // 이미지 소스
                spriteSheetWidth={256} // 실제 스프라이트 크기
                spriteSheetHeight={512}
                width={300} // 프레임의 너비
                height={300} // 프레임의 높이
                frames={frames} // 프레임 데이터
                animations={animations} // 애니메이션 설정
                defaultAnimationName="walk" // 기본 애니메이션 이름
                inLoop={true} // 루프
                autoPlay={true} // 자동 시작
                frameRate={3} // 프레임 속도
            />
        </View>
    );
}

export default ExampleScreen;

 

4. 개선점

1. 타입스크립트로 안정적으로 만들기

- 솔직히 타입스크립트를 써보다가 개발속도를 위해 자바스크립트로 하기로 결정해서 돌아오니, 가끔 숨이 턱 막힘..

이 문제가 타입 문제일까? 코드의 구현에서 문제인가? 이런 식으로 원인이 제대로 안나오고 이게 돌아갈까? 하는데 돌아가긴하네.. 이런 걸 느낄때마다 미궁에서 문제를 푸는 느낌이라 많이 스트레스였다.

2. expo-image 활용 또는 속도 개선?

- 빠른 개발을 위해 포기한 이미지 캐시 처리 관련해서도 가능하면 수정하면 좋지 않을까 싶었다.. 솔직히 얼만큼 차이가 나는지 가늠이 안되서 굳이인가 싶기도 하지만

3. React Native reanimated 라이브러리 필요한 부분만 설치

- 이게 단순 라이브러리치곤 매우 무거워서 이거 설치한 후 빌드 시 시간이 오래 걸렸음. python slim이나 lite 버전 과 같이 기본만 설치하면 빌드가 가벼워졌을 것 같음

5. 느낀점

1. React native 좋은 선택이었는지 의문?

- 의존성 문제가 진짜 많이 발생해서 골골 앓았다. React를 하던 입장에서 네이티브 단을 제외하고는 익숙해서 그 부분은 편했는데, 문제는 다른데서 많이 발생함

- 빠른 개발 속도를 위해서 라이브러리 활용 부분이 문제가 제일 많이 발생함. React Native의 버전에 따라 호환성 문제, 패키지 이름 충돌 등으로 인해 라이브러리가 제대로 작동하지 않는 애들이 많음. 이럴 때는 개발자가 직접 수정하거나, 기다리거나, 다른 해결책을 찾아야 했던 점이 불편함

 

2. 이슈는 생길때마다 적어놓기

- 이제 ssafy 1년을 끝맺어서 하고 싶었던 생각 정리를 하면서 좀 공부나 하려했는데 역시 기억이 안나는 부분이 많다..