안녕하세요. 와디즈 앱개발팀 Android 앱 개발자 입니다.
최근 인스타그램 스토리에 펀딩 또는 스토어 영수증을 뽐낼 수 있는 기능을 추가했어요. 그 개발 과정을 공유하고자 합니다.
펀딩을 하거나 스토어 구매를 하면 아래와 같이 제품의 이미지, 제목, 날짜 정보가 포함된 영수증이 출력되어 손에 잡히는 모션을 볼 수 있어요. 우측의 캐릭터 전환 버튼을 누르면 와디즈의 대표 캐릭터 진국이와 지니, 조이 중 한 가지를 선택해 바꿀 수도 있죠. 이렇게 마음에 드는 영수증을 고른 뒤에는 다운로드하거나 인스타그램 스토리로 공유할 수 있습니다.
개발 과정은 크게 애니메이션과 렌더링으로 나눌 수 있어요. Android에서 제공하는 MotionLayout과 MediaMuxer, MediaCodec을 이용해 구현할 수 있었습니다.
애니메이션 구현 : MotionLayout
public class MotionLayout extends ConstraintLayout
2018년 Google I/O에서 처음 소개된 MotionLayout은 ConstraintLayout을 상속받는 서브 클래스로, 다양한 애니메이션을 추가할 수 있는 ViewGroup이에요. 이전에도 애니메이션을 만들 수 있는 여러 방법이 있었어요. 하지만 MotionLayout이 혼합된 기능을 제공하면서 사용 방법도 간편하기 때문에 매우 편리합니다.
기존 화면이 ConstraintLayout로 되어있었다면 MotionLayout 적용 방법은 매우 간단해요. 우선, ConstraintLayout에서 MotionLayout으로 바꿉니다. 그리고 app:layoutDescription
속성으로 motionScene을 정의한 XML 리소스 파일을 넣어줍니다. 만약, MotionLayout 하위 뷰에 대한 정의가 MotionScene에도 되어있다면, MotionScene 안에 있는 정의가 더 우선으로 적용됩니다.
<?xml version="1.0" encoding="utf-8"?>
<!-- activity_main.xml --> <androidx.constraintlayout.motion.widget.MotionLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutDescription="@xml/motion_scene">
<!-- 하위 뷰 -->
<View
android:id="@+id/button"
android:layout_width="64dp"
android:layout_height="64dp"
android:text="Button" />
</androidx.constraintlayout.motion.widget.MotionLayout>
Motion Scene XML 파일은 루트인 <MotionScene>
안에 ConstraintSet과 Transition, 그 하위 요소들로 구성되어있어요.
<ConstraintSet>
: 모션 시퀀스의 한 지점을 기준으로 여러 개의<Constraint>
을 포함하여, 모든 뷰의 위치와 속성을 지정합니다.<Constraint>
: 뷰의 위치와 속성을 정의합니다.<Transition>
: 정의된 ConstraintSet 간의 시작과 종료, 사용자 상호작용 등을 정의합니다.<onClick>
: 특정 뷰를 클릭할 때 동작할 작업을 정의합니다.<onSwipe>
: 특정 뷰를 스와이프할 때 동작할 작업을 정의합니다.<KeyFrameSet>
,<KeyPosition>
,<KeyAttribute>
: 추가적인 디테일한 모션을 프레임별로 지정합니다.
정리하면, 애니메이션의 시작 A와 B에 대하여 ConstraintSet
을 각각 만듭니다. 그 뒤, A와 B를 Transition
으로 연결하면 MotionLayout은 두 상태를 자연스럽게 애니메이션으로 이어져 보이게 하죠.
MotionLayout 도입 초기에는 XML 파일에 MotionScene을 직접 작성해야 했어요. 하지만 Android Studio 4.0 업데이트로 Motion Editor가 추가되면서 좀 더 쉽게 애니메이션을 디자인할 수 있게 되었습니다.
총 4개의 카드가 있어요. 각 카드는 모션 시퀀스 중 한 지점을 정의하는 ConstraintSet
이에요. 각 카드를 연결하는 화살표는 모션의 시작과 끝을 나타내는 Transition
입니다. 점선 화살표는 다른 ConstraintSet에서 분기된 모션(deriveConstraintsFrom
)을 나타내고요.
영수증이 출력되는 애니메이션을 쪼개고 각 모션에 총 4개의 ConstraintSet을 정의한 뒤, Transition으로 연결했어요.
모든 Transition은 autoTransition="animatedToEnd"
속성이 정의되어 있어, 애니메이션이 끝나면 자동으로 다음 Transition으로 넘어가게 돼요.
첫 번째 Transition : 영수증이 아래로 내려오고 중간 지점부터 아래에서 위로 손이 올라옵니다.
영수증의 높이가 다른 ConstraintSet을 연결하여, 영수증이 위에서 아래로 내려오게 됩니다.
keyFrameSet을 설정하여, 프레임 중간 지점부터 손이 올라오도록 했어요.
motionInterpolator="easeInOut"
속성을 정의하여, 애니메이션 가속도를 지정했습니다.
두 번째 Transition : 영수증이 시계 반대 방향으로 틀어지면서, 뜯어집니다.
- KeyFrameSet을 설정하여, 영수증이 회전해요.
- 0 프레임에서 영수증 회전 각도 0°
- 100 프레임에서 영수증 회전 각도 -2°
세 번째 Transition : 1초간 멈춤
- ConstrainSet을 만들 때, deriveConstraintsFrom 속성을 사용하면 지정한 ConstrainSet에서 분기된 모션이 되어 추가적인 속성 지정 없이 중복 코드를 줄일 수 있어요.
duration="1000"
속성만 주어, 1초 동안 정지된 상태로 보입니다.
네 번째 Transaction : 다시 처음으로 돌아가 애니메이션 반복
autoTransition="jumpToEnd"
속성을 정의하면, 모션 없이 바로 constraintSetEnd 로 이동돼요.
렌더링 : MediaCodec & MediaMuxer
애니메이션을 모두 구현했다면, 이후 화면을 영상으로 변환해야 해요. MediaProjection을 사용할 수도 있지만 화면 녹화 방법은 사용성에 있어 적합하지 않아요. 그래서 애니메이션 프레임별로 이미지를 추출한 뒤 영상으로 만드는 방법을 선택했어요. 이 과정을 렌더링(Rendering)이라고 하고, 간단한 영상은 Android에서 제공하는 API를 통해 만들 수 있습니다.
Android API
MediaCodec: 동영상/음성을 인코딩/디코딩하는 역할
MediaMuxer: 인코드 된 버퍼를 작성하는 역할
MediaFormat: 어떠한 형식의 영상인지 정보가 담겨있으며 MediaCodec이 사용
렌더링에는 크게 다음과 같은 과정이 필요합니다. 각 항목에 대해 간단히 정리해볼게요.
- mp4 파일 생성
- MediaCodec 과 MediaMuxer 준비
- Bitmap 인코딩하여, MediaMuxer에 넣기
- 종료
1. mp4 파일 생성
생성할 파일의 제목, mimeType, 저장 경로를 지정한 뒤에 contentResolver의 insert 함수를 호출합니다.
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, title)
put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 1)
} else {
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES)
.toString()
val videoFile = File(dir, "${title}.mp4")
contentValues.put(MediaStore.Video.Media.DATA, videoFile.absolutePath)
}
val videoContentUri: Uri =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
} else {
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
}
return context.contentResolver.insert(videoContentUri, contentValues)
2. MediaCodec과 MediaMuxer 준비
기기에서 지원하는 인코더를 찾기 위해, MediaCodecList에서 mimeType(“video/avc”)에 맞는 인코더를 가져옵니다.
private fun getCodecForMimeType(): MediaCodecInfo? {
val mediaCodecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
val codecInfoList = mediaCodecList.codecInfos.filter {
it.supportedTypes.contains(mimeType)
}
return codecInfoList.find { it.isEncoder }
}
가져온 코덱의 이름으로 MediaCodec 객체를 생성합니다.
MediaCodec.createByCodecName(mediaCodecName)
MediaCodec를 생성한 후에는 MediaFormat을 생성하여 코덱에 넣어 구성을 해줘요.
mediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
MediaFormat에는 영상의 mimeType, width, height, bitRate, frameRate, color 데이터가 입력됩니다.
- bitRate : 1초당 처리되는 bit 수
- frameRate : 1초당 보이는 이미지 개수
mediaCodec.start()
를 호출하면 코덱 사용 준비가 완료돼요.
앞서 생성한 파일의 Uri를 통해 FileDescriptor를 생성하여, OutputFormat 설정값과 함께 생성자에 넣어줍니다.
MediaMuxer(fileDescriptor, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
3. Bitmap 인코딩하여, MediaMuxer에 넣기
애니메이션 프레임 수만큼 View에서 Bitmap을 추출합니다.
애니메이션은 MotionLayout으로 만들었기 때문에 setProgress()
함수를 통해 특정 프레임 지점을 화면에 보여줄 수 있어요.
recordCopyViewBinding.clRecordView.progress = progress
getBitmapFromView(width, height)?.let { // 추출해야 하는 타깃 뷰로부터 Bitmap을 생성
bitmapToVideoEncoder.encode(it)
}
추출된 Bitmap은 MediaCodec의 InputBuffer에 넣어지고, 코덱으로부터 인코딩됩니다.
mediaCodec.queueInputBuffer(inputBufIndex, 0, byteConvertFrame.size, ptsUsec, 0)
인코딩된 데이터는 MeidaCodec의 OuputBuffer에서 꺼낼 수 있어요.
mediaCodec.getOutputBuffer(encoderStatus)
MediaMuxer에 넣어줍니다.
mediaMuxer.writeSampleData(mTrackIndex, encodedData, mBufferInfo)
사용 완료된 OuputBuffer는 재사용을 위해, release()를 호출해줍니다.
mediaCodec.releaseOutputBuffer(encoderStatus, false)
4. 종료
MeidaCodec, MediaMuxer의 stop()과 release()를 차례대로 호출합니다.
mediaCodec.stop()
mediaCodec.release()
mediaMuxer.stop()
mediaMuxer.release()
contentResolver를 이용하여, 파일을 닫아줍니다.
private fun closeMedia(uri: Uri) {
val contentValues = ContentValues()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
}
try {
context.contentResolver.update(uri, contentValues, null, null)
} catch (e: Exception) {
e.printStackTrace()
}
}
프로젝트 진행하면서
가장 어려웠던 부분은 처음 시도해본 영상 렌더링이었어요. 그중에서도 다양한 기기에 최적화하는 것이 고려해야 할 것이 많아 고민도 많았죠. 버그도 많았는데, 대표적으로 아래 두 가지가 인상 깊었습니다.
첫 번째, 비트맵 메모리 관리
비트맵 메모리 관리 | Android 개발자 | Android Developers
Android 개발 문서에서 별도로 페이지가 존재할 정도로 비트맵 메모리는 중요한 요소예요. 이번 프로젝트는 부드러운 영상 프레임을 위해 100개 이상의 비트맵을 생성하게 되었는데요. 이때 한 번에 모든 비트맵을 리스트에 갖고 있게 된다면 OutOfMemoryError
에러가 발생할 수도 있었어요. 이를 주의해 MediaCodec과 MediaMuxer를 먼저 시작한 뒤 비트맵을 한 개씩 넣었어요. 메모리를 빨리 회수하기 위해 recycle()
을 호출하도록 했고요.
동기식으로 구현하다 보니 다소 시간이 걸린다는 단점이 있지만, 저사양 기기에서도 문제없이 동작을 보장할 수 있게 되었답니다.
두 번째, 저사양 기기 코덱 최적화
저사양 기기에서는 지원하는 코덱 성능에 제한이 있어 렌더링을 못 하거나, 영상을 재생하지 못하는 문제가 발생할 수 있어요. (갤럭시j7 코덱 캡쳐) 이를 해결하기 위해, MediaCodecList를 통해 각 코덱의 정보에서 적절한 해상도 값으로 인코딩되도록 수정했습니다.
고정 해상도나 너무 낮은 해상도로 인코딩하게 될 경우, 픽셀이 깨져 보이는 현상이 있어요. 그래서 기기 최적화가 중요합니다!
도전적이고 흥미로운 업무를 맡게 되어 개발하면서 즐거웠어요. 배운 점도 많고 의미 있는 프로젝트였습니다. 많은 도움 주신 프로님들께 감사드립니다. 😊
궁금한 내용이 남아 있나요? 👀
앱개발팀의 조직 문화가 궁금하다면? 👉 클릭
앱개발팀에 와디즈 칭찬 릴레이 주인공이? 👉 클릭