[ScratchRevealCard] 2. 그림판 구현으로 기술적 검증을 해보자.

2025. 5. 31. 06:20·라이브러리 개발일기

기술적 검증은 필요한가?

항상 프로젝트에 들어가기전, 혹은 프로젝트 내에서 새로운 기능을 맡기전 해당 기능을 구현가능한지 여부를 검증하는것을 우선순위로 삼고 있습니다. 기획을 들었을 때 '아, 이렇게 하면 되겠다' 처럼 머리 속으로 구현방법이 그려지곤 합니다. 그러나 항상 프로젝트를 진행하다보면 예상치 못한 부분에 막히게 되고 이러한 부분을 해결하며 시간을 잡아먹히다 보면 개발일정에 차질이 생기게 됩니다. 또한 기한내에 당면한 문제를 해결하는데 급급한 결과 이상적인 방법이 아닌 일단 삐걱거리게 돌아가게만 구현하게 되면서 기능과 서비스의 완성도가 떨어지게 되기도 합니다.

따라서 사전에 해당 기능을 내가 상상한대로 구현가능한지, 작은 데모프로젝트로 작성해서 구현가능 여부를 검증해보고, 사전에 불가능한 점을 파악하게 된다면 다른 방법을 모색하여 회의시간에 제안하는 것이 개발자로서 가져야 할 좋은 모습일 것입니다.

 

1. 사용자 입력을 트래킹 해보자

기술적 검증을 위해 여러 단계로 나눠서 진행을 해보려고 합니다.

가장 먼저 이미지의 일부분을 손가락으로 긁어내는 행위를 위해서는 사용자의 드래그 동작을 감지하고 특정 동작을 수행해야 합니다.

따라서 사용자의 드래그 동작을 감지하여 획을 그리는 그림판 기능을 먼저 구현해보려고 합니다.

 

Compose 기반의 컴포넌트를 개발하고 있기 때문에 Modifier에서 지원하는 pointerInput 을 활용할 것입니다.

 

pointerInput 매서드의 경우 key와 block 이라는 파라미터를 필요로 하는데, key1 파라미터의 경우 리컴포지션을 감지하는 파라미터입니다. block 파라미터에는 동작을 감지하는 코드 블럭이 들어가야 하는데 이러한 동작감지는 key1에 해당하는 리컴포지션이 발생했을 때 중단되고 재시작 하게 됩니다.

 

LaunchedEffect(viewModel.sideEffect, lifecycleOwner) {
    viewModel.sideEffect.flowWithLifecycle(lifecycle = lifecycleOwner.lifecycle)
        .collect { signInSideEffect ->
            when (signInSideEffect) {
                is LoginContract.LoginSideEffect.NavigateToOnboarding -> navigateToOnboarding()
                is LoginContract.LoginSideEffect.NavigateToHome -> navigateToHome()
            }
        }
}

 

요기 LaunchedEffect() 에 들어가는 리컴포지션 트리거 역할을 하는 파라미터가 key1의 역할이라 생각하시면 됩니다.

 

따라서 우리가 관심가져야 할 부분은 사실 key1 보다는 block 부분이 될 것입니다.

 

block의 데이터 타입에 해당하는 PointerInputEventHandler는 다음과 같은 매서드를 가진 인터페이스 입니다.

따라서 block에 해당하는 람다식만 작성하여도 Single Abstract Method 변환을 통해 PointerInputEventHandler의 객체가 자동 생성되어 들어가게 됩니다.

 

따라서 우리는 PointerInputScope에 해당하는 하위매서드를 직접 구현하여 넘겨줌으로써 간단히 PointerInputEventHandler를 구현하여 Modifier.pointerInput 매서드에 넘겨줄 수 있습니다.

 

PointerInputScope의 하위 매서드들이 어떤것이 있는지 공식문서에서 찾아봤습니다.

 

 

이렇게 생긴 detectDragGestures를 활용한다면 사용자의 드래그 제스쳐를 감지하여 각각의 시점에 동작을 부여해줄 수 있을것 같습니다.

그렇다면 그림판에 해당하는 Box나 Canvas를 생성하여 Modifier에 detectDragGestures를 적절히 붙여주고, 각각의 동작에서 Canvas에 획을 그리도록 로직을 짠다면 그림판 구현이 가능할 것 같습니다.

 

 

@Composable
fun ScratchRevealCard(
    modifier: Modifier = Modifier,
    brushSize: Float = with(LocalDensity.current) { 10.dp.toPx() },            // 긁어 내는 크기
    onScratchProgress: (Float) -> Unit = {}, // 0f~1f
    onFullyRevealed: () -> Unit = {}        // 80% 이상 긁으면 콜백
) {
    var point by remember { mutableStateOf(Offset.Zero) } // point 위치 추적을 위한 State
    val points = remember { mutableListOf<Offset>() } // 새로 그려지는 path 표시하기 위한 points State
    var path by remember { mutableStateOf(Path()) } // 새로 그려지고 있는 중인 획 State
    val paths = remember { mutableStateListOf<Path>() } // 다 그려진 획 리스트 State

    Box(modifier = modifier) {
        Canvas(
            modifier = Modifier
                .fillMaxSize()
                .pointerInput(Unit) {
                    detectDragGestures(
                        onDragStart = { start ->
                            point = start
                            points.add(start)
                            path.moveTo(start.x, start.y)
                        },
                        onDrag = { _, dragAmount ->
                            path = Path()
                            point += dragAmount
                            points.add(point)
                            // onDrag가 호출될 때마다 현재 그리는 획을 새로 보여줌
                            points.forEachIndexed { index, point ->
                                if (index == 0) {
                                    path.moveTo(point.x, point.y)
                                } else {
                                    path.lineTo(point.x, point.y)
                                }
                            }
                        },
                        onDragEnd = {
                            paths.add(path)
                            points.clear()
                        },
                    )
                }
        ) {
            // 이미 완성된 획들
            paths.forEach { path ->
                drawPath(
                    path = path,
                    color = Color.Black,
                    style = Stroke(width = brushSize, cap = StrokeCap.Round, join = StrokeJoin.Round)
                )
            }
            // 현재 그려지고 있는 획
            drawPath(
                path = path,
                color = Color.Black,
                style = Stroke(width = brushSize, cap = StrokeCap.Round, join = StrokeJoin.Round)
            )
        }
    }
}

 

detectDragGestures의 인자로 들어가는 각 매서드를 구현하여 그림판 기능 구현을 해보았습니다.

드래그 중일 때 현재 위치를 고려하여 지속적으로 path를 업데이트하고, 해당 path를 누적하면서 Canvas가 지속적으로 리컴포지션 되게 구현하였습니다.

 

 

위 사진처럼 기능적으로는 그림판 기능을 구현했으나 이 코드에는 아주 비효율적인 부분이 존재합니다.

  1. onDrag는 매우 빠르게 여러번 호출되지만 그때마다 매번 Path() 객체를 새로 생성하여 처음부터 다시 point 정보들을 입력하고 있는 것
  2. paths에는 이전에 그렸던 획 들의 정보가 저장되나, 매 리컴포지션 마다 (onDrag 호출마다) 모든 path 정보를 처음부터 다시 그리고 있는 것.

상당히 마음에 들지 않습니다...

 

이런 부분들에 대해 최적화 작업을 진행할 건데

1번은 비즈니스 로직적인 부분으로 해결을 할 것이고

2번은 비트맵 캐싱을 활용할 것입니다.

 

+추가)

Path를 굳이 비트맵으로 다시 렌더링하는데 리소스가 더 들어갈 뿐더러 ScratchRevealCard에서는 적은양의 드로잉만 사용되기 때문에 비트맵 캐싱이 굳이 필요하지 않다 판단하여 진행하지 않겠습니다.

(실제로 복잡한 그림판을 구현한다면 드로잉이 진행되는 레이어, 드로잉이 진행됐던 레이어를 구분하여 각각 따로따로 리컴포지션 되게 한다면 더욱 효율적인 리컴포지션을 구현할 수 있을것 같습니다.)

과도한 Path() 객체 초기화 문제 최적화

// 문제의 코드
detectDragGestures(
    onDragStart = { start ->
        point = start
        points.add(start)
        path.moveTo(start.x, start.y)
    },
    onDrag = { _, dragAmount ->
        path = Path()
        point += dragAmount
        points.add(point)
        // onDrag가 호출될 때마다 현재 그리는 획을 새로 보여줌
        points.forEachIndexed { index, point ->
            if (index == 0) {
                path.moveTo(point.x, point.y)
            } else {
                path.lineTo(point.x, point.y)
            }
        }
    },
    onDragEnd = {
        paths.add(path)
        points.clear()
        onScratchProgress(1f)
    },
)

onDrag는 사용자가 손가락으로 획을 그리고 있는 매 프레임마다 호출됩니다.

따라서 onDrag 매서드 안에서 Path()를 초기화 하는 작업이 들어간다면 불필요하게 많은 리소스를 사용하게 됩니다. path는 onDragStart()에서 초기화한 Path를 활용하되 onDrag안에서는 path.lineTo() 를 통해 획 정보 업데이트만 수행해야 합니다. 그러나 이렇게만 한다면 path 객체에 변경이 없기 때문에 리컴포지션이 발생하지 않아 UI에는 그리고있는 획이 보여지지 않게 됩니다.

 

onDrag에서 path.lineTo() 매서드를 통해 드로잉 정보가 업데이트 되고 있긴 하지만, Compose에서 참조하고 있는 Path() 객체 자체에는 변경이 없기 때문에 (setter가 호출되지 않기 때문에) 리컴포지션이 일어나지 않습니다.

 

따라서 Path() 객체를 처음부터 초기화 하지 않으면서 리컴포지션을 유도할 수 있는 방법을 찾아야 합니다.

바로 SnapshotMutationPolicy를 활용하는 것입니다.

https://haeti.palms.blog/snapshot-mutation-policy

 

[Compose] Snapshot Mutation Policy를 분석하고 활용해보자.

Jetpack Compose에서 근본적으로 사용되는 SnapshotMutationPolicy을 알아보고 활용해보는 글입니다.

haeti.palms.blog

잘 정리되어 있는 블로그 글이 있기 때문에 한번 보고 오시면 좋을것 같습니다 :)

 

JetpackCompose에서는 리컴포지션을 위해 State를 구독하고 변화를 관찰합니다. 이러한 State의 스냅샷을 어떤 방식으로 관찰하여 리컴포지션을 유도할 것인지 정책을 지정해줄 수 있는데, 이러한 정책을 SnapshotMutationPolicy라 부르고 mutableState에 파라미터로 할당해줄 수 있습니다.

 

 

위처럼 제공하는 인터페이스를 활용하여 커스텀 정책을 구현할 수 있지만, 일단 기본적으로 제공해주는 정책중 neverEqualPolicy에 대해 눈여겨 보겠습니다.

 

 

 

 

따라서 해당 정책은 다음과 같은 특징을 가진다는것을 알 수 있습니다.

  • 값의 비교 없이 setter 호출만으로도 값의 변경이라고 감지
  • 멀티 스레딩 환경에서 동일한 값으로의 수정이 일어난다면 항상 conflict로 간주

그렇기에 위 정책을 사용할 때에는 두 번째 조건을 명확히 인지하고 사용해야 할것 같은데, 멀티 스레딩 환경에서 mutableState를 동시에 수정한다면 conflict가 일어나고 기준에 따라 하나의 값으로 merge 하려는 시도가 발생합니다.

그러나 neverEqualPolicy를 활용한다면 서로 같은값으로 수정했을 때에도 이러한 conflict, merge가 일어나기 때문에 필요없는 비용이 발생할 수 있습니다.

 

하지만 저는 UI 스레드에서만 해당 State를 다루므로 두 번째 조건은 의미가 없습니다.

따라서 첫 번째 조건인 setter 호출만으로도 값의 변경을 감지하여 리컴포지션을 유도할 수 있다는 특성을 활용하여 효율적으로 리컴포지션을 유도해보도록 하겠습니다.

@Composable
fun ScratchRevealCard(
    modifier: Modifier = Modifier,
    brushSize: Float = with(LocalDensity.current) { 10.dp.toPx() },
    onScratchProgress: (Float) -> Unit = {},
    onFullyRevealed: () -> Unit = {}
) {
    var point by remember { mutableStateOf(Offset.Zero) }
    var drawingPath by remember { mutableStateOf(Path(), neverEqualPolicy()) } // SnapshotMutationPolicy 활용
    val drawnPaths = remember { mutableStateListOf<Path>() }
    
    Box(modifier = modifier) {
        Canvas(
            modifier = Modifier
                .fillMaxSize()
                .pointerInput(Unit) {
                    detectDragGestures(
                        onDragStart = { start ->
                            drawingPath.reset()
                            point = start
                            drawingPath.moveTo(start.x, start.y)
                        },
                        onDrag = { _, dragAmount ->
                            point += dragAmount
                            drawingPath.lineTo(point.x, point.y)
                            drawingPath = drawingPath	// 스스로의 setter만 호출하여 리컴포지션 유도
                        },
                        onDragEnd = {
                            drawnPaths.add(drawingPath.copy())	// path를 재생성하지 않고 재사용하기 때문에 깊은복사 활용해서 리스트에 저장
                            onScratchProgress(1f)
                        },
                    )
                }
        ) {
            // 이미 완성된 획들
            drawnPaths.forEach { path ->
                drawPath(
                    path = path,
                    color = Color.Black,
                    style = Stroke(width = brushSize, cap = StrokeCap.Round, join = StrokeJoin.Round)
                )
            }
            // 현재 그려지고 있는 획
            drawPath(
                path = drawingPath,
                color = Color.Black,
                style = Stroke(width = brushSize, cap = StrokeCap.Round, join = StrokeJoin.Round)
            )
        }
    }
}

 

자 이렇게 하면 onDrag() 매서드에서 불필요하게 Path()를 처음부터 생성하는 과정을 생략하고 onDragStart()에서 초기화한 Path 정보를 업데이트만 할 수 있습니다. 그리고 Path()를 매번 생성하기 위해 경로를 저장하고 있던 points 배열도 필요없어졌기 때문에 메모리 사용량을 더 줄였습니다.

그러면서도 neverEqualPolicy 와 drawingPath에 대한 setter 호출만으로 리컴포지션을 유도하여 현재 그리고 있는 획이 잘 나타나는것을 확인할 수 있습니다!!

 

 

'라이브러리 개발일기' 카테고리의 다른 글

[ScratchRevealCard] 3. Modifier의 그래픽 수정자 - drawScope, graphicsLayer  (2) 2025.06.17
[ScratchRevealCard] 1. 라이브러리 개발할 때 의존성 세팅방법 (implementation vs api)  (1) 2025.05.15
'라이브러리 개발일기' 카테고리의 다른 글
  • [ScratchRevealCard] 3. Modifier의 그래픽 수정자 - drawScope, graphicsLayer
  • [ScratchRevealCard] 1. 라이브러리 개발할 때 의존성 세팅방법 (implementation vs api)
나는 커서 멋진 개발자가 될래요
나는 커서 멋진 개발자가 될래요
renovatio-dev-hyuns 님의 블로그 입니다.
이전 글
[ScratchRevealCard] 1. 라이브러리 개발할 때 의존성 세팅방법 (implementation vs api)
2025.05.15
다음 글
[ScratchRevealCard] 3. Modifier의 그래픽 수정자 - drawScope, graphicsLayer
2025.06.17
  • 나는 커서 멋진 개발자가 될래요
    Renovatio
    나는 커서 멋진 개발자가 될래요
  • 전체
    337
    오늘
    0
    어제
    0
    • 분류 전체보기 (14)
      • Pixionary 기술노트 (3)
      • 앗차 기술노트 (1)
      • 삽질일기 (1)
      • 라이브러리 개발일기 (3)
      • 작고 소듕한 깨달음 (0)
      • 시리즈 (4)
        • Android Jetpack Compose (0)
        • Hilt들고 MVVM정복 (4)
      • CS (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

    • [ScratchRevealCard] 3. Modifier의⋯
      2025.06.17
    • [ScratchRevealCard] 1. 라이브러리 개발할⋯
      2025.05.15
    • 네트워크 요청을 안전하게 해보자 (feat. Templat⋯
      2025.08.20
  • 태그

  • 최근 댓글

    • 잘 봤어요
      정보왕창
      ·08.15
    • 나는 커서 멋진 개발자가 될래요님 잘보고 갑니다. 공감꾹~
      빌게이츠야그
      ·08.15
    • 나는 커서 멋진 개발자가 될래요님 들러서 좋은 글 잘 보고⋯
      SojiReporter
      ·08.14
    • 나는 커서 멋진 개발자가 될래요님 오늘도 값진 정보 잘 얻⋯
      스마트홍홍
      ·08.14
    • 잘 봤어요
      한달동안
      ·08.14
  • 최근 글

    • 네트워크 요청을 안전하게 해보자 (feat. Templat⋯
      2025.08.20
    • [자료구조] 트라이(Trie)
      2025.08.13
    • [ScratchRevealCard] 3. Modifier의⋯
      2025.06.17
    • [ScratchRevealCard] 2. 그림판 구현으로 ⋯
      2025.05.31
    • [ScratchRevealCard] 1. 라이브러리 개발할⋯
      2025.05.15
  • hELLO· Designed By정상우.v4.10.4
나는 커서 멋진 개발자가 될래요
[ScratchRevealCard] 2. 그림판 구현으로 기술적 검증을 해보자.
상단으로

티스토리툴바