MediaCodec - Getting Started

컴퓨터공부/안드로이드 2020. 5. 24. 10:19 Posted by 아는 개발자

 

0. FFmpeg - 한계

 

동영상과 관련된 작업을 처리하는 툴로 가장 유명한 것은 아마 FFmpeg 일 것이다. 이 라이브러리에서는 영상의 트랜스코딩(압축)을 지원할 뿐만 아니라 영상내 텍스트/이미지 삽입 또는 영상을 회전시키고 자를 수 있는 기능도 제공하며 실행도 대부분의 개발자들에게 익숙한 형태인 커맨드라인 딱 한줄만 입력하면돼 비디오에 대해서 잘 모르는 사람들도 쉽게 사용할 수 있다. 하지만 FFmpeg은 이 모든 작업들이 소프트웨어적으로 구현되어 있어 느리다는 단점이 있고 C, C++인 로우 레벨로 만들어진 빌드 파일을 JVM 위에 돌아가는 자바 언어단에서 별개의 도입하는 것은 꺼리낌이 있다. 개발 외적으로는 GPL 라이센스를 가지고 있어서 이 라이브러리의 수정사항을 공개해야한다는 법적 이슈가 있고 무엇보다 대중적으로 사용하는 h264 압축 방식을 사용하는 경우(mp4 파일을 생성하는 경우) 특허 이슈가 있다고 한다. 이 특허문제에 대해선 인터넷상에서 갑론을박이 많은데 가장 중요한 주체인 FFmpeg 공식 홈페이지에서도 "자기들은 변호사는 아니라 잘 모르겠다"고 답변하는 것으로 보아 자유롭게 사용하기에는 찝찝한 툴이다.

 

ffmpeg 공식 홈페이지 Patent issue에 대한 답변. "We do not know" 문구가 눈에 띈다.

 

1. MediaCodec - 어쩔 수 없이 써야하는 존재

 

 

플랫폼 개발자들도 이런 문제점을 인식해서인지 영상을 처리할 수 있는 고유의 라이브러리를 도입했는데 안드로이드의 경우 MediaCodec 라이브러리가 이에 해당한다. FFmpeg의 한계점을 극복하고자 도입한 라이브러리이기에 더이상 라이센스 문제도 없고 JVM위에서 동작하는 안드로이드에서 사용하기에 적합한 형태이며 하드웨어 가속을 지원해 소프트웨어적으로 돌아가는 FFmpeg보다 빠르다.

 

하지만 단점도 만만치 않다. FFmpeg은 트랜스코딩 뿐만 아니라 다양한 툴을 포함하고 있고 영상에 대해서 잘 몰라도 쉽게 사용할 수 있었다. 그러나 MediaCodec은 영상 정보를 추출하는 디코딩 작업과 바이너리 정보를 조합해 새로운 영상을 만드는 인코딩 작업만 제공할 뿐이며 원래 FFmpeg에 있었던 텍스트를 삽입하고 영상을 자르는 기능은 모두 스스로 만들어야 한다. 즉 이제는 디코딩/인코딩이 무엇인지, 영상 파일은 어떤식으로 이뤄져 있는지 그리고 텍스트를 삽입하고 영상을 자를 수 있는 그래픽의 기본 지식까지 겸비해야 한다는 뜻. 평소에 게임을 만들어본 사람이나 영상쪽에 관심있는 사람이 아니면 이쪽에 대해서 아마 잘 모를 것이다. 그리고 공부하려고 해도 진입장벽이 있는 부분이라 러닝 커브가 높다.

 

아쉽게도 단점은 이것 만이 아니다(ㅠㅠ). MediaCodec 라이브러리는 직접 하드웨어 장비와 연계된 부분이기 때문에 구글은 API만 뚫어주고 퀄컴, 삼성 LSI와 같은 칩 제조사에서 이 부분을 직접 구현했는데 이 부분이 칩(AP)에 따라서 다르다. 똑같은 갤럭시 스마트폰, 동일한 모델임에도 불구하고 국내에서 주로 사용하는 엑시노스 칩에서는 동작하는 반면 해외에서 사용하는 퀄컴 칩에서는 동작이 안될 수가 있다. 그리고 똑같은 안드로이드 버전이고 퀄컴칩을 사용하는데도 불구하고 사용하는 칩의 버전이 달라 갤럭시 노트8은 되고 갤럭시 S9은 되는 현상도 발생한다. 물론 이 경우는 코드를 잘못짠 것에 해당하기는 하나... 같은 플랫폼에서 똑같은 코드가 칩마다 다르게 동작할 수 있다는 점은 플랫폼 개발자로서 영 찝찝한 점이다. 안드로이드 버전별로 대응해왔던 것에서 이제는 국내용, 해외용도 모두 다 봐야 한다는 뜻이니까. 칩제조사와 플랫폼 벤더가 통합된 iOS 개발자들이 부러워지는 순간이다.

 

더 난감한 점은 게다가 이쪽 부분은 제조사에서 코드를 숨겨놔 에러가 발생해도 코드도 볼 수 없다는 사실이다... Logcat 메시지에서도 에러가 발생하면 알려주는 정보가 0x 로 시작하는 16진수의 플래그값 외에 알려주는게 더 없다. 스택오버플로우에라도 의지해볼 수 있다면 좋으련만 이상하게도 MediaCodec 관련 정보는 별로 없다. MediaCodec이 2012년도에 등장했는데도 아직까지 이렇게 정보가 많지 않다는 것을 보면 다들 MediaCodec으로 개발한 정보를 숨겨놓는건지 아니면 쓰려다가 지레 포기하고 외부 라이브러리를 사용한 것인지. MediaCodec을 이용한 오픈소스 프로젝트가 몇몇 있기는 한데 코드에서 정작 중요한 정보들은 byte code로 꽁꽁 숨겨놨다.(이럴거면 왜 공개했다고 한건지) 인터넷 상에서 정보를 찾기는 어렵고 개발하는데 난감하지만 대안이 없어 어쩔 수 없이 사용해야하는 라이브러리다.

 

 

2. MediaCodec - 개발 참고 자료

 

 

개발하기 어렵지만 그래도 참고할 만한 자료가 전혀 없는 것은 아니다. 단, 다른 라이브러리들처럼 친절한 문서 페이지는 기대하지 않는게 좋다.

 

 

2.1 CTS 코드

 

구글에서는 CTS (호환성 테스트) 검증에 사용한 코드를 공개하고 있다. 이 테스트 코드는 모든 제조사들이 출시하기 전에 PASS 해야하기 때문에 여기 코드들은 칩 디펜던시가 없이 모두 안정적으로 동작한다고 봐도 될 것 같다. https://bigflake.com/mediacodec/ 라는 사이트에서 MediaCodec과 관련된 CTS 테스트코드 주소와 테스트 목적에 대해서 짤막하게 소개해주고 있으니 여기서 구현하려는 것과 가장 가까운 테스트 코드를 참고하자. 테스트 코드를 보면 알겠지만 구글에서도 테스트 코드는 거지같이짜서  한눈에 보기가 쉽진 않다.

 

2.2 grafika 

 

구글에서 MediaCodec관련 문의가 하도 많이 들어와 만든 것인지 모르겠으나 MediaCodec 개발자로서는 한여름의 에어컨과도 같은 오픈소스다. https://github.com/google/grafika 여기에는 MediaCodec 라이브러리를 이용해 응용할 수 있는 무비 플레이어, 카메리 영상 처리, 비디오 트랜스코딩과 같은 다양한 예제를 담고 있다. README 페이지에 이 코드는 구글 공식 프로덕트가 아니고(그럼 구글 저장소에는 왜 있는건지?) 테스트를 제대로 하지 않아 안정적이지 않다고도 크게 써놔서 이 코드들이 모든 디바이스에서 동작할지는 확신 할 수 없지만, MediaCodec을 기본적으로 어떻게 써야할지 감을 익힐때 사용하면 유용하다. 

 

2013, 2014년도에 주로 작성되고 그 이후에는 최신 안드로이드버전 호환만 관리했기 때문에 모든 코드가 JAVA로 되어 있어 Kotlin으로 옮길 때 린트가 많이 생기는 단점이 있다.

 

'컴퓨터공부 > 안드로이드' 카테고리의 다른 글

MediaCodec - Encoding  (0) 2020.06.21
MediaCodec - Decoding  (0) 2020.05.24
MediaCodec - Getting Started  (0) 2020.05.24
Navigator - Getting Started  (0) 2020.04.20
안드로이드 그림자(Shadow) 효과 넣기  (0) 2020.04.18
Kotlin - Coroutine  (0) 2020.04.15

Navigator - Getting Started

컴퓨터공부/안드로이드 2020. 4. 20. 23:21 Posted by 아는 개발자

안드로이드 Navigator 라이브러리는 프래그먼트를 이용해서 화면을 전환하는 작업을 돕는 라이브러리다. 로그인후 메인 화면으로 이동하거나 글 작성하는 UX의 경우 저장하는 작업 까지 여러 화면을 거치게 되는데 이런 경우 여러개의 액티비티를 쓰거나, 매번 FragmentManager를 이용해서 메인 뷰를 차지하고 있는 Fragment를 교체(replace)해줘야 했다. 하지만 Navigator 라이브러리를 사용하면 이런 화면 전환 과정을 XML 파일로 관리할 수 있고 시각화도 가능해서 유지 관리에 도움이 된다.

 

먼저 XML 파일로 표시하면 이렇고,

 

fragment_nav_graph.xml

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/fragment_navi_example"
    app:startDestination="@id/start_fragment">

    <fragment
        android:id="@+id/start_fragment"
        android:name="kwony.kotlin.navigate.StartFragment">
        <action
            android:id="@+id/action_first_fragment"
            app:destination="@id/first_fragment"
            app:exitAnim="@anim/fragment_close_exit"/>
    </fragment>

    <fragment
        android:id="@+id/first_fragment"
        android:name="kwony.kotlin.navigate.FirstFragment">
        <action
            android:id="@+id/action_second_fragment"
            app:destination="@id/second_fragment"
            app:enterAnim="@anim/slide_in_left"/>
    </fragment>

    <fragment
        android:id="@+id/second_fragment"
        android:name="kwony.kotlin.navigate.SecondFragment"/>
</navigation>

 

이 정보는 미리보기로 이렇게 표시된다.

 

 

XML파일을 쭉 훑어보고 난 후 사진을 보면 대강 감이 올텐데 먼저 가장 최상위 startDestination은 시작하는 프래그먼트의 이름이다. 위의 사진에서는 start_fragment 가 이 화면 구성의 시작점이 된다. start_frament에서 action 속성이 하나 있는데 여기서 destination 값은 first_fragment, 바로 앞 start_fragment에서 화살표로 가리키는 클래스다. 마찬가지로 first_fragment 에서도 action 속성이 하나 있는데 여기서의 destination은 second_fragment이고 사진상에서는 second_fragment를 화살표로 가리키고 있다. 이처럼 navigatior 에서는 fragment의 action 속성을 통해 어떤 fragment로 이동해야하는지 정해줄 수 있다. 

 

app:exitAnim 속성 값은 프래그먼트가 사라질 때 줄 애니메이션 효과다. 기본으로 등록되어 있는 것을 사용해도 되고 직접 커스텀해서 넣을 수도 있다. 반대로 enterAnim은 프래그먼트가 생겨날 때 줄 수 있는 효과다. 간단하게 XML 파일의 형태로 넣을 수 있어서 쉽다.

 

위에서 설명한 내용을 적용하려면 navigator를 Activity에 넣고 선언한 Fragment들은 action 속성값에 선언된대로 이동하도록 코드를 호출 해야한다. 먼저 Activity 작업에 대한 코드는 다음과 같다.

 

0. Activity

 

Class쪽 수정 없이 XML 파일에 이미 만든 navigation 리소스를 넣는 구문만 추가하면 된다. 아래 소스만 넣으면 처음에 StartFragment 를 클래스에서 생성하지 않아도 자동으로 FragmentContainerView가 잡고 있는 영역에 StartFragment가 추가된다. 

 

그리고 여기서 app:defaultNavHost="true" 로 선언했는데 이렇게 두면 시스템상의 백버튼 액션을 가로채서 이 Navigator에서 사용할 수 있다. 이 말은 즉 SecondFragment로 까지 이동한 상태에서 백버튼을 누르면 그 이전에 stack에 쌓여 있는 FirstFragment로 이동하고 다시 한 번 백버튼을 누르면 그전에 stack에 있는 StartFragment로 이동할 수 있다는 것이다. 프래그먼트가 화면에서 비중있는 역할을 하는 경우 필수적인 속성이 된다.

 

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="kwony.kotlin.di.activity.DaggerRootActivity">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/nav_host"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:defaultNavHost="true"
        app:navGraph="@navigation/fragment_nav_di_graph" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

1. StartFragment

 

StartFragment에서는 FirstFragment로 이동할 수 있는 작업이 있어야 하는데 임의로 TextView를 누르면 그 작업이 호출 되도록 했다. Click Listener 내부를 보면 findnavController().navigate 함수가 부르는데 여기의 인자가 XML에서 StartFragment 내부에 선언한 action 이다.

 

class StartFragment: DaggerFragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        fr_nav_tv.text = "StartFragment"

        fr_nav_tv.setOnClickListener {
            findNavController().navigate(R.id.action_first_fragment)
        }
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.fragment_nav_children, container, false)
    }
}

 

2. FirstFragment

 

StartFragment와 코드는 거의 흡사하고 차이가 있는 부분은 아까 선언한 action의 id 값을 바꿔주는 부분만 다르다.

 

class FirstFragment: DaggerFragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        fr_nav_tv.text = "FirstFragment"

        fr_nav_tv.setOnClickListener {
            findNavController().navigate(R.id.action_second_fragment)
        }
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.fragment_nav_children, container, false)
    }
}

 

이 포스트는 Navigator의 아주 기본적인 기능에 대해서만 소개한 것이라 아직 라이브러리의 장점을 모두 말하지 못했다. 숨겨진 기능을 확인해보고 싶으시다면 구글 문서를 참고하거나 이 카테고리의 다음 글을 기대해도 좋을 것 같다.

'컴퓨터공부 > 안드로이드' 카테고리의 다른 글

MediaCodec - Decoding  (0) 2020.05.24
MediaCodec - Getting Started  (0) 2020.05.24
Navigator - Getting Started  (0) 2020.04.20
안드로이드 그림자(Shadow) 효과 넣기  (0) 2020.04.18
Kotlin - Coroutine  (0) 2020.04.15
Kotlin으로 깔끔한 Builder를 만들어보자  (0) 2020.04.14

안드로이드에서 그림자 효과를 넣는 방법으로는 UI의 elevation 속성 값을 조정하는 것과 직접 그림자용 리소스 파일을 만드는 방법이 있다. 이번 포스트에서는 이 두가지의 사용 방법과 각각의 장단점을 소개해보려고 한다.

 

1. elevation 값 조정하기 

 

UI에 가장 쉽게 섀도우 효과를 입힐 수 있는 방법이다. 안드로이드 API21 부터 UI 뷰들에 elevation 이라는 속성값이 추가 됐는데 이 값을 넣으면 UI가 Z축으로 위로 튀어나와 그림자 효과를 줄 수 있게 된다. 

 

elevation 값을 조정해 그림자 효과를 준 경우.

코드는 다음과 같다.

 

<androidx.constraintlayout.widget.ConstraintLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <FrameLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent">

        <ImageView
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:layout_margin="50dp"
            android:elevation="20dp"
            android:background="@color/colorPrimary" />
    </FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

 

elevation 값을 조정해서 효과를 줄 때는 주의해야할 점이 두가지 있는데 첫번째는 elevation 값에 넣어준 수치 만큼 View 주변에 여백을 충분하게 주어야 한다는 것이다. elevation으로 만든 그림자는 View의 width/height 영역 밖에서 발생하기 때문에 이 부분의 여백을 주지 않으면 그림자 효과가 발생하지 않는다. 

 

미리보기 화면을 통해 확인해보면 FrameLayout 내부의 ImageView 주변에 여백이 있는 것을 확인 할 수 있다.

두번째로는 background값이 투명하면 안된다. 불투명한 값으로 셋팅을 해줘야한다. 왜 불투명한 background 값을 셋팅해줘야하는지는 아직 잘 모르겠다; 하지만 투명한 값으로 세팅하면 그림자 효과가 나타나지 않는다.

 

이 방법은 편하긴 하지만 API21 버전부터 사용할 수 있고 하단부에만 그림자 효과를 줄 수 있다는 단점이 있다. 상하좌우 모두 그림자 효과를 주어야 할 때는 사용 할 수 가 없다. 이런 경우에는 직접 리소스 파일로 그림자 효과를 만들어야 한다.

 

2. 그림자용 리소스 파일 만들기

 

선이나 사각형을 코드로 만들 때 사용했던 XML 파일을 이용해서 그림자 효과를 줄 수 있다. 설명에 앞서서 아래 예시 코드와 이 코드를 입힌 UI 결과물을 먼저 보자. 코드가 길지만 반복 구문이 많으니 대강 훓어보는 것을 추천한다

 

shadow_test.xml

 

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android" >

    <!-- Drop Shadow Stack -->
    <item>
        <shape>
            <padding
                android:bottom="2.5dp"
                android:left="2.5dp"
                android:right="2.5dp"
                android:top="2.5dp" />

            <solid android:color="#00CCCCCC" />
        </shape>
    </item>
    <item>
        <shape>
            <padding
                android:bottom="2.5dp"
                android:left="2.5dp"
                android:right="2.5dp"
                android:top="2.5dp" />

            <solid android:color="#06CCCCCC" />
        </shape>
    </item>
    <item>
        <shape>
            <padding
                android:bottom="2.5dp"
                android:left="2.5dp"
                android:right="2.5dp"
                android:top="2.5dp" />

            <solid android:color="#09CCCCCC" />
        </shape>
    </item>
    <item>
        <shape>
            <padding
                android:bottom="2.5dp"
                android:left="2.5dp"
                android:right="2.5dp"
                android:top="2.5dp" />

            <solid android:color="#0BCCCCCC" />
        </shape>
    </item>
    <item>
        <shape>
            <padding
                android:bottom="2.5dp"
                android:left="2.5dp"
                android:right="2.5dp"
                android:top="2.5dp" />

            <solid android:color="#0DCCCCCC" />
        </shape>
    </item>
    <item>
        <shape>
            <padding
                android:bottom="2.5dp"
                android:left="2.5dp"
                android:right="2.5dp"
                android:top="2.5dp" />

            <solid android:color="#10CCCCCC" />
        </shape>
    </item>
    <item>
        <shape>
            <padding
                android:bottom="2.5dp"
                android:left="2.5dp"
                android:right="2.5dp"
                android:top="2.5dp" />

            <solid android:color="#12CCCCCC" />
        </shape>
    </item>
    <item>
        <shape>
            <padding
                android:bottom="2.5dp"
                android:left="2.5dp"
                android:right="2.5dp"
                android:top="2.5dp" />

            <solid android:color="#15CCCCCC" />
        </shape>
    </item>
    <item>
        <shape>
            <padding
                android:bottom="2.5dp"
                android:left="2.5dp"
                android:right="2.5dp"
                android:top="2.5dp" />

            <solid android:color="#17CCCCCC" />
        </shape>
    </item>
    <item>
        <shape>
            <padding
                android:bottom="2.5dp"
                android:left="2.5dp"
                android:right="2.5dp"
                android:top="2.5dp" />

            <solid android:color="#1ACCCCCC" />
        </shape>
    </item>

    <!-- Background -->
    <item>
        <shape>
            <solid android:color="@android:color/white" />
        </shape>
    </item>

</layer-list>

 

ImageView에 위에서 만든 리소스를 background로 넣었다.

 

<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.appcompat.widget.LinearLayoutCompat
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:ignore="RtlSymmetry">

        <ImageView
            android:layout_width="150dp"
            android:layout_height="150dp"
            android:layout_gravity="center"
            android:background="@drawable/shadow_test"/>
    </androidx.appcompat.widget.LinearLayoutCompat>
</androidx.constraintlayout.widget.ConstraintLayout>

 

이 코드를 이용해 그림자 효과를 입혀본 결과는 다음과 같다.

 

예제 코드를 사용해 그림자 효과를 입힌 결과

사진을 자세히 보면 흰 사각형 바깥쪽에 촘촘히 작은 선들이 있는 것을 볼 수가 있다. 이건 위 예제 코드에서 2.5dp 기준으로 각각 색깔이 다른 사각형을 넣었기 때문이다. 여러개의 작은 장면들을 조합해서 연속된 애니메이션 효과로 보이게 한 것 처럼 이 그라데이션 효과도 작은 사각형들을 합해서 그림자처럼 보이게 만든 효과다. 

 

이 방법은 약간의 노가다가 필요하긴 하지만 개발자가 그림자 효과를 자유자재로 커스텀이 가능하다는 장점이 있다. 어떤 부분에 좀더 강조를 세게 주고 싶다거나 좌측 상단, 우측 하단, 상화좌우 전체에 그림자 효과를 선택해서 줄 수 있다. 

 

elevation을 이용한 방법과 차이가 있다면 이 방법은 그림자 영역이 뷰의 영역에 포함되어 있다는 것이다. 아래 그림을 보면 왼쪽 그림의 보라색 사각형이 elevation을 이용해서 그림자 효과를 준 경우고 하얀색 사각형이 리소스를 이용해서 그림자 효과를 준 경우인데, 미리보기 상으로는 하얀색 사각형이 더 작아보이지만 두 ImageView의 가로 세로 너비 값은 오른쪽 그림에서도 알 수 있듯이 동일하다. 리소스를 사용하면 그림자 영역을 View 내부에서 사용하기 때문에 원래 생각했던 ImageView의 크기와 약간 차이가 발생 할 수 있다. 상황에 따라서 단점이 될 수도 있고 장점이 될 수 도 있는 기능이라 섣불리 판단 할 수는 없을 것 같다. 단 차이점은 유의해서 알고가는 것이 좋을 것 같다.

 

'컴퓨터공부 > 안드로이드' 카테고리의 다른 글

MediaCodec - Getting Started  (0) 2020.05.24
Navigator - Getting Started  (0) 2020.04.20
안드로이드 그림자(Shadow) 효과 넣기  (0) 2020.04.18
Kotlin - Coroutine  (0) 2020.04.15
Kotlin으로 깔끔한 Builder를 만들어보자  (0) 2020.04.14
Exoplayer2 사용하기  (0) 2020.04.12

Kotlin - Coroutine

컴퓨터공부/안드로이드 2020. 4. 15. 22:47 Posted by 아는 개발자

Kotlin의 Coroutine은 비동기 작업을 지원하는 "lightweight threads" 인 컴포넌트다. 이미 안드로이드에 있는 AsyncTask와 비슷한 역할을 수행하지만 Coroutine은 특별한 오버라이드 함 수 없이 간단하게 구현이 가능하고 깊게 들어가면 세부 동작 방식과 구현 철학은 다르다. 이번 포스트에서는 Kotlin의 Coroutine에 대해서 전반적인 소개와 사용 방법을 소개하려고 한다.

 

 

 

먼저 Coroutine은 하나의 task가 아니라, 여러 개 순서가 정해진 sub-tasks의 집합이다. Coroutine에서는 여러 개의 sub-task가 존재하는데 이들의 실행 순서는 보장된(guaranteed) 순서로 실행이 된다. 즉 코드 상에서는 얘네들이 sequential 하게 짜여져 있어도 코드를 어떻게 짜느냐에 따라서 실행 순서는 다를 수 있다. 이게 무슨 뚱딴지 같은 소리인가 싶을 수도 있지만 이건 asynchronous 작업을 좀더 유연하게 지원하기 위한 철학 정도로 이해하면 될 것 같다. 글로 보면 이해가 잘 되지 않을 수 있는데 예제 코드를 본다면 감이 어느정도 잡힐 것이다.

 

0. Quick Example

 

GlobalScope.launch {
    async { withContext(this.coroutineContext) { printLog("1") } }
    async { withContext(this.coroutineContext) { printLog("2") } }
    async { withContext(this.coroutineContext) { printLog("3") } }
}

 

안드로이드에서 Coroutine을 사용한 예제 코드다. 아직 Coroutine에 대해서 잘 모르더라도 느낌상으로는 로그를 찍는 함수 호출로 미뤄 볼 때 로그는 1 -> 2 -> 3 의 순서로 찍혀야 할 것 같다. 하지만 실제 출력 결과 매번 실행할 때마다 순서가 다르게 나온다.

 

2020-04-15 20:38:06.190 24595-24791/kwony.study D/CoroutineSample: 1
2020-04-15 20:38:06.191 24595-24790/kwony.study D/CoroutineSample: 3
2020-04-15 20:38:06.191 24595-24791/kwony.study D/CoroutineSample: 2

 

그런데 이번에는 로그 출력용 객체에 await 함수를 붙여주면

 

GlobalScope.launch {
    async { withContext(this.coroutineContext) { printLog("1") } }.await()
    async { withContext(this.coroutineContext) { printLog("2") } }.await()
    async { withContext(this.coroutineContext) { printLog("3") } }.await()
}

 

 이렇게 결과가 달라지고 값도 고정되게 나온다.

 

2020-04-15 20:40:12.687 25004-25078/kwony.study D/CoroutineSample: 1
2020-04-15 20:40:12.689 25004-25078/kwony.study D/CoroutineSample: 2
2020-04-15 20:40:12.689 25004-25078/kwony.study D/CoroutineSample: 3

 

Coroutine을 사용하면 작업의 순서를 요리조리 조정할 수 있다. 맛보기로 이정도면 좋을 것 같다.

 

1. Coroutine Builder 

 

Coroutine을 사용하려면 Coroutine 객체를 생성하는 작업을 먼저해야한다. Coroutine Builder는 Coroutine을 생성하기 위한 팩토리 함수다. Kotlin에서는 Coroutine을 생성하기위한 기본 빌더 함수를 제공한다.

 

runBlocking

 

현재 시작중인 thread를 block 시키고 Coroutine 작업을 시작하게 하는 Coroutine 빌더다. Main Thread에서 이 함수가 호출 됐으면 UI가 잠시 멈추고 coroutine 함수가 실행된다. 테스트를 위해 onCreate 함수에서 runBlocking Coroutine을 생성하고 3초간 딜레이를 주었다. 그 결과 화면이 초기화되는 시간이 3초간 딜레이되는 현상이 발생한다.

 

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)

    runBlocking {
        delay(3000)
    }

 

synchronous하게 처리해야하는 작업에 사용되는데 현재 실행중인 thread를 block시키는 문제가 꽤 골치 아프기 때문에 가능하면 쓰지 않을 것을 추천하는 문서도 있다. 정말로 필요한 경우가 아니면 가급적 사용하지 않는 것이 좋을 수도.

 

GlobalScope 

 

runBlocking 빌더와 달리 현재 실행중인 Thread를 block 시키지 않고 백그라운드에서 작업을 실행하는 Coroutine이다. 최상위 Coroutine을 생성 할 때 사용하는데 이 Coroutine은 애플리케이션과 동일한 생명주기를 갖게 된다. 이에 대한 자세한 내용은 CoroutineScope 에서 소개하는 것이 좋을 것 같다. 예제 코드를 한번 보자.

 

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    
    GlobalScope.launch {
        delay(3000)
        Log.d(coroutineSampleTag(), "World")
    }
    Log.d(coroutineSampleTag(), "Hello")
}

 

GlobalScope 으로 만들어진 Coroutine 에서는 3초간 딜레이를 주고 World를 출력하고 생성 다음에는 Hello를 출력하는 코드다. 실행 결과는 아래와 같다. Hello가 먼저 출력되고 Coroutine 내부의 World 문자열은 3초뒤에 출력되는 것을 확인 할 수 있다.

 

2020-04-15 21:21:45.224 30001-30001/kwony.study D/CoroutineSample: Hello
2020-04-15 21:21:48.234 30001-30069/kwony.study D/CoroutineSample: World

 

CoroutineScope(context: CoroutineContext)

 

Coroutine이 동작할 context를 지정해서 Coroutine을 만드는 함수다. 저기 안에 인자에 특정 CoroutineContext를 넣을 수 있는데, 이에 대해서 자세하게 설명하는 것보다는 어떤 Thread에서 동작할 것인지 지정하는 것 정도로만 보면 좋을 것 같다. 대표적으로 Dispatchers.Main과 Dispatchers.IO가 있다.

 

CoroutineScope(Dispatchers.Main).launch {}
CoroutineScope(Dispatchers.IO).launch {}

 

Dispatchers.Main으로 넣은 경우는 해당 Coroutine 작업이 MainThread 즉 UI를 관장하는 Thread에서 돌아가도록 선언한 것이고 Dispatchers.IO인 경우에는 Blocking IO 용 공유 쓰레드 풀에서 동작하도록 지정한 것이다. RxJava에서 subscribeOn 함수에 넣은 값들과 유사한 개념이다. 이 Builder 함수들도 GlobalScope 처럼 진행중인 Thread를 block 하지 않고 동작한다.

 

2. async { }

 

Coroutine 초기 예제에서도 확인 했던 것 처럼 Coroutine 작업들은 비동기적으로 처리하는 것이 가능하다. 비동기로 처리하려는 Coroutine들은 async { } 로 작업을 선언하면 된다.

 

CoroutineScope(Dispatchers.IO).launch {
    printLog("Coroutine Start")
    val deferred1 = async {
        delay(3000)
        printLog("1")
        return@async 1
    }
    val deferred2 = async {
        delay(3000)
        printLog("2")
        return@async 2
    }
    val deferred3 = async {
        delay(3000)
        printLog("3")
        return@async 3
    }
    printLog("total sum: " + (deferred1.await() + deferred2.await() + deferred3.await()))
}

 

예제에서 확장해서 async Coroutine에 딜레이 3초와 리턴값을 넣고 마지막에 각 Coroutine 결과의 총합을 출력하는 코드를 추가했다.

 

2020-04-15 22:18:29.090 3877-3990/? D/CoroutineSample: Coroutine Start
2020-04-15 22:18:32.108 3877-3996/kwony.study D/CoroutineSample: 2
2020-04-15 22:18:32.109 3877-3993/kwony.study D/CoroutineSample: 3
2020-04-15 22:18:32.112 3877-3991/kwony.study D/CoroutineSample: 1
2020-04-15 22:18:32.125 3877-3996/kwony.study D/CoroutineSample: total sum: 6

 

Coroutine 의 로그 출력 순서는 실행할 때마다 변경되는 반면 마지막에 Coroutine 반환 값의 합을 출력하는 부분은 항상 마지막에 출력되는데 이는 각 async 객체에 await() 함수를 사용해서 리턴값을 반환했기 때문이다. await() 함수는 async 함수가 모두 종료될 때 까지 구문 실행을 기다리도록 하는 함수다. 합을 출력하는 부분은 세개의 Coroutine의 연산결과를 모두 기다려야하기 때문에 제일 마지막에 실행 될 수 밖에 없다.

 

또 주목할 만한 점은 모든 Coroutine에 3초씩 딜레이를 주었는데 각 Coroutine의 로그 출력 시간은 3초씩 차이가 나지 않는다. 이는 각각의 Coroutine이 Sequential하게 동작하지 않고 Parallel하게 동작했기 때문이다. Coroutine의 로그 출력 순서가 바뀌는 이유도 Parallel 하게 동작했기 때문이다.

 

3. suspend fun method() = coroutineScope { }

 

Kotlin 함수 선언 앞에 suspend와 coroutineScope 으로 body를 만들어주면 Coroutine 에서 실행가능한 함수로 선언 해줄 수 있다. 아래 코드는 앞서 예제로 소개한 코드랑 동일한 로직으로 동작하며 출력되는 결과도 동일하다 (Coroutine 로그 출력 순서만 빼면)

 

private suspend fun coroutineMsg(msg: String, ret: Int): Int = coroutineScope {
    delay(3000)
    printLog(msg)
    return@coroutineScope ret
}

CoroutineScope(Dispatchers.IO).launch {
    printLog("Coroutine Start")
    val deferred1 = async { coroutineMsg("1", 1) }
    val deferred2 = async { coroutineMsg("2", 2) }
    val deferred3 = async { coroutineMsg("3", 3) }
    printLog("total sum: " + (deferred1.await() + deferred2.await() + deferred3.await()))
}
2020-04-15 22:42:07.182 7959-8043/kwony.study D/CoroutineSample: Coroutine Start
2020-04-15 22:42:10.197 7959-8045/kwony.study D/CoroutineSample: 1
2020-04-15 22:42:10.197 7959-8048/kwony.study D/CoroutineSample: 3
2020-04-15 22:42:10.198 7959-8043/kwony.study D/CoroutineSample: 2
2020-04-15 22:42:10.217 7959-8044/kwony.study D/CoroutineSample: total sum: 6

 

'컴퓨터공부 > 안드로이드' 카테고리의 다른 글

Navigator - Getting Started  (0) 2020.04.20
안드로이드 그림자(Shadow) 효과 넣기  (0) 2020.04.18
Kotlin - Coroutine  (0) 2020.04.15
Kotlin으로 깔끔한 Builder를 만들어보자  (0) 2020.04.14
Exoplayer2 사용하기  (0) 2020.04.12
FragmentManagers Android  (1) 2020.04.06

Kotlin에서 제공하는 apply 범위 함수를 이용해서 클래스 내부 속성 값을 간결하게 선언할 수 있지만 DSL(Domain Specific Language) 언어인 점을 응용하면 여러 클래스를 중첩한 클래스의 속성값에 대해서 더욱 간결하게 값을 설정 할 수 있다. 얼마나 간결한지 글로 길게 설명하는 것 보다는 간단한 예시로 보는게 좋을 것 같다.

 

Kotlin의 Builder 패턴을 사용하면 아래와 같이 선언된 data 클래스들을

 

data class Group(
    val name: String,
    val company: Company,
    val members: List<Member>
)

data class Company(
    var name: String = ""
)

data class Member(
    val name: String,
    val alias: String,
    val year: Int
)

 

이렇게 선언 하는 것이 가능하다.

 

val redVelvet = group {
    name { "레드벨벳" }
    company {
        name { "SM Entertainment" }
    }
    members {
        member {
            name { "슬기" }
            alias { "곰슬기" }
            year { 1994 }
        }

        member {
            name { "아이린" }
            alias { "얼굴 천재" }
            year { 1991 }
        }

        member {
            name { "웬디" }
            alias { "천사" }
            year { 1994 }
        }
    }
}

 

이렇게 간결하게 코드를 만들기 위해선 각각의 클래스에 대해서 Builder 클래스와 lambda 함수를 사용한 내부에 셋팅 함수를 선언해야한다. Member 함수부터 구현 방법을 살펴보자.

 

1. MemberBuilder

 

class MemberBuilder {
    private var name: String = ""
    private var alias: String = ""
    private var year: Int = 0

    fun name(lambda: () -> String) {
        name(lambda)
    }

    fun alias(lambda: () -> String) {
        alias(lambda)
    }

    fun year(lambda: () -> Int) {
        year(lambda)
    }

    fun build() = Member(name, alias, year)
}

 

MemberBuilder 클래스 내부에는 Member 데이터 클래스와 동일하나 name, alias, year 를 변수로 가지는데 보면 셋팅하는 함수들의 인자가 lambda로 선언되어 있고 바로 내부 변수를 초기화해준다는 점만 다르다. lambda가 포함된 함수는 아까 레드벨벳 초기화 코드에서 확인 할 수 있듯이 간단히 primitive 인자값을 전달하는 방식 만으로도 선언이 가능하다. build() 함수는 현재까지 초기화된 정보로 Member 클래스를 만드는 작업이다. 다른 곳에서 호출 받게 된다.

 

2. MemberListBuilder

 

class MemberListBuilder {
    private val employeeList = mutableListOf<Member>()

    fun member(lambda: MemberBuilder.() -> Unit) =
        employeeList.add(MemberBuilder().apply(lambda).build())

    fun build() = employeeList
}

 

Group의 데이터 클래스에 Member가 리스트 형태로 선언돼있기 때문에 Member의 개수는 1개 이상이 될 수 있다. 그래서 복수의 Member에 대해서 처리할 수 있는 MemberListBuilder가 이 부분을 담당한다. 레드벨벳 초기화 코드에서 member { ... } 로 호출한 부분은 바로 이 MemberListBuilder 클래스의 내부 함수를 호출한 것이다. 함수 내부를 보면 받아온 정보를 가지고 바로 MemberBuilder() 클래스 내부의 build() 함수를 통해 멤버를 생성하고 내부 배열 변수(employeeList)에 추가한다. build() 함수에서는 가지고 있는 배열 정보를 리턴한다.

 

3. CompanyBuilder

 

class CompanyBuilder {
    private var name = ""

    fun name(lambda: () -> String) {
        this.name = lambda()
    }

    fun build() = Company(name)
}

 

CompanyBuilder는 말단 노드라 MemberBuilder랑 생긴게 거의 비슷하다. 굳이 다시 한번 설명하지 않아도 될 것 같다. lambda 인자로 값을 받고 build() 에서 현재 클래스 값을 전달한다.

 

4. GroupBuilder 

 

class GroupBuilder {
    private var name = ""
    private var company = Company("")
    private val employees = mutableListOf<Member>()

    fun name(lambda: () -> String) {
        name = lambda()
    }

    fun company(lambda: CompanyBuilder.() -> Unit) {
        company = CompanyBuilder().apply(lambda).build()
    }

    fun members(lambda: MemberListBuilder.() -> Unit) =
        employees.addAll(MemberListBuilder().apply(lambda).build())

    fun build() = Group(name, company, employees)
}

 

GroupBuilder 클래스에는 지금까지 만들어왔던 builder를 포함하고 있다. name 함수에서는 Group의 이름을 정하고, company 함수에서는 CompanyBuilder를 통해서 company 속성값 정보를 셋팅한다. members 함수에서도 마찬가지로 MemberListBuilder 클래스를 통해 현재 입력된 모든 멤버의 정보를 입력한다. GroupBuilder() 또한 build() 함수를 호출해서 현재 Group 클래스를 최종적으로 반환한다. 

 

GroupBuilder 클래스의 build() 함수를 효출하는 부분은 따로 함수를 만들어야하는데 이렇게 만들면 된다.

 

fun group(lambda: GroupBuilder.() -> Unit): Group {
    return GroupBuilder().apply(lambda).build()
}

 

선언부에서 알 수 있듯이 가장 먼저 호출한 함수는 group {..} 이었다.

 

전체 코드는 다음과 같다.

 

Builder 패턴 구현 부분 코드

 

데이터 초기화 부분 코드

 

 

'컴퓨터공부 > 안드로이드' 카테고리의 다른 글

안드로이드 그림자(Shadow) 효과 넣기  (0) 2020.04.18
Kotlin - Coroutine  (0) 2020.04.15
Kotlin으로 깔끔한 Builder를 만들어보자  (0) 2020.04.14
Exoplayer2 사용하기  (0) 2020.04.12
FragmentManagers Android  (1) 2020.04.06
ViewModelProviders.of deprecated  (0) 2020.04.06