MediaCodec - Decoding

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

0. 시작하기 전에 

 

MediaCodec을 사용하기 위해선 기본적인 비디오 영상 처리에 대한 지식이 필요하다. 영상처리도 깊이 들어가면 한도 끝도 없을 것 같은데 이 포스트에서는 MediaCodec 을 이용해 Decoding 할 때 반드시 알고 있어야 하는 지식 정도로만 간추려서 소개하려고 한다.

 

여기서 사용한 예제 코드는 grafika https://github.com/google/grafika 저장소의 MoviePlayer.java 코드에서 deprecated 된 부분만 바꿨다. 실제로 동작하는 코드를 확인하고 싶다면 여기서 프로젝트를 받아 실행해보면 될 것 같다

 

1. Definition

 

Decoding은 영상 파일이 가지고 있는 정보를 추출해내는 작업이다. 우리가 흔히 볼 수 있는 영상플레이어들은 모두 비디오 파일을 읽고 이 정보를 화면에 뿌리는 디코딩 작업을 거친다. 디코딩 작업에는 화면 프레임 정보 뿐만 아니라 비디오가 가지고 있는 음성 파일도 포함한다. 영상에서 긁어온 정보들을 컨트롤해서 화면에 뿌려주는게 영상플레이어의 역할이다. 안드로이드에서는 MediaCodec 라이브러리를 통해 영상과 음성에 대해서 디코딩을 할 수 있다.

 

2. Create

 

MediaCodec 라이브러리를 이용해서 디코딩 작업을 담당하는 객체를 생성할 수 있다. 아래 코드에서 createDecoderByType 함수가 Decoder를 생성하는 함수다. 생성 전에 수행하는 작업을 볼 필요가 있는데, 앞의 MediaExtractor 클래스가 하는 역할은 소스 파일에서 비디오의 Meta 정보를 가져오는 역할을 한다. 비디오가 가지고 있는 Meta 정보로는 비디오의 bitrate, width, size 등등을 가져올 수 있는데 디코딩 작업을 할때는 비디오의 현재 압축 방식인 MIME이 필요하다. 이 압축방식은 비디오의 확장자마다 다른데 거의 모든 영상을 mp4로 담고 있는 현재는 대부분 h264 방식을 따르고 있다. 정확히 이게 어떤 방식으로 압축되는지는 나도 잘 모른다. 아무튼 이 정보를 통해 비디오 디코더를 읽어올 수 있다.

 

extractor = new MediaExtractor();
extractor.setDataSource(mSourceFile.toString());
int trackIndex = selectTrack(extractor);
if (trackIndex < 0) {
	throw new RuntimeException("No video track found in " + mSourceFile);
}
extractor.selectTrack(trackIndex);

MediaFormat format = extractor.getTrackFormat(trackIndex);

// Create a MediaCodec decoder, and configure it with the MediaFormat from the
// extractor.  It's very important to use the format from the extractor because
// it contains a copy of the CSD-0/CSD-1 codec-specific data chunks.
String mime = format.getString(MediaFormat.KEY_MIME);
decoder = MediaCodec.createDecoderByType(mime);
decoder.configure(format, mOutputSurface, null, 0);
decoder.start();

 

decoder.configure 함수의 첫번째 인자로는 아까 추출한 압축 방식을 전달했고 두번째 인자로 mOutputSurface라는 값을 전달하고 있다. mOutputSurface는 decoder로 받은 정보를 화면에 뿌려줄 도화지 역할을 하는데 위와같이 configure함수에 두번째 인자로 넣으면 디코딩 된 정보를 자동으로 화면에 뿌려줄 수 있게 된다. 

 

2. Extract Data

 

다음으로 할 작업은 디코더를 이용해 실제로 비디오 파일에서 영상 정보를 추출하는 것이다. 아래 그림은 MediaCodec에 관한 구글 개발자 문서에서 가져온 것인데, 코덱의 일종인 디코더는 영상을 가져올때 크게 input 작업과 output 작업을 거친다. 초록색으로 칠해진 클라이언트는 비디오에서 정보를 읽는 작업이고, 작은 정사각형을 채워넣는 작업은 앞서 클라이언트에서 읽어온 정보를 디코더 버퍼에 읽어온 채워넣는 작업이다. 이것 모두 개발자가 해야하는 일이다. 이 작업도 아래의 그림처럼 크게 두가지 단계로 정리해볼 수 있을 것 같다.

 

2.1 Input Phase

 

비디오에서 읽어온 정보를 Input 버퍼에 채워넣는 일이다. 코드를 하나하나 살펴보자. dequeueInputBuffer 함수는 받아온 나중에 받아올 정보를 채워너 넣을 수 있는 공간을 할당받는 함수다. 리턴값으로 index를 주는데 이 index 값은 엄청 길다란 배열의 index 값으로 생각하면 된다. 이 index 값을 받아서 데이터를 쓸 위치를 넣을 수 있다.

 

다음 작업으로는 비디오에서 데이터를 읽어오는 작업이다. 여기서 사용된 extractor 변수는 앞서 생성 작업에서 선언한 변수와 동일하다. 객체 내에 읽은 부분에 대한 iterator가 포함되어 있어서 어디까지 읽었는지 정보를 담고 있다. 코드 맨 마지막에 advance 함수를 통해 읽을 위치를 변경하는 것이 가능하다.

 

마지막으로 extractor에서 뽑아온 정보를 Input 버퍼에 넣어야한다. 앞어 읽어온 sample data의 리턴 값에 따라서 Input 버퍼에 넣는 정보가 다른데 이 값이 마이너스인 경우에는 모든 데이터를 읽은 경우이기 때문에 Input Buffer에 플래그 값으로 END_OF_STREAM을 넣어준다. 그 외의 경우에는 유효한 데이터인 것으로 보고 Input Buffer에 넣고 플래그 값을 0으로 넣는다. 함수의 네번째 인자로 시간 정보 값을 주는데 이 값은 현재 읽어온 버퍼가 비디오에서 몇초대에 위치하고 있는지에 대한 정보다.

 

int inputBufIndex = decoder.dequeueInputBuffer(TIMEOUT_USEC);
if (inputBufIndex >= 0) {
    if (firstInputTimeNsec == -1) {
        firstInputTimeNsec = System.nanoTime();
    }

    ByteBuffer inputBuf = decoder.getInputBuffer(inputBufIndex);
    // Read the sample data into the ByteBuffer.  This neither respects nor
    // updates inputBuf's position, limit, etc.
    int chunkSize = extractor.readSampleData(inputBuf, 0);
    if (chunkSize < 0) {
        // End of stream -- send empty frame with EOS flag set.
        decoder.queueInputBuffer(inputBufIndex, 0, 0, 0L,
                MediaCodec.BUFFER_FLAG_END_OF_STREAM);
        inputDone = true;
        if (VERBOSE) Log.d(TAG, "sent input EOS");
    } else {
        if (extractor.getSampleTrackIndex() != trackIndex) {
            Log.w(TAG, "WEIRD: got sample from track " +
                    extractor.getSampleTrackIndex() + ", expected " + trackIndex);
        }
        long presentationTimeUs = extractor.getSampleTime();
        decoder.queueInputBuffer(inputBufIndex, 0, chunkSize,
                presentationTimeUs, 0 /*flags*/);
        if (VERBOSE) {
            Log.d(TAG, "submitted frame " + inputChunk + " to dec, size=" +
                    chunkSize);
        }
        inputChunk++;
        extractor.advance();
    }
} else {
    if (VERBOSE) Log.d(TAG, "input buffer not available");
}

 

2.2 Output Phase

 

Output Phase에서는 방금전 Input Phase에서 넣어둔 input buffer 정보를 추출하는 일을 한다. 각 비즈니스 로직에 따라서 추출한 정보를 화면에 뿌려주기도 하고 아니면 새로운 비디오를 만드는 작업으로 사용할 수도 있을 것 같다. dequeueOutputBuffer는 아까 dequeueInputBuffer 함수에서 넣어둔 정보를 가져오는 역할을 한다. 첫번째 인자는 out 타입으로 받아온 정보를 저장하고 리턴 값으로는 현재 디코더의 상태 값을 나타낸다.

 

TRY_AGAIN_LAYER는 현재 읽을 수 있는 Input buffer가 없을 때 발생한다. Input Buffer를 분명히 넣어 줬는데도 이 플래그 값이 발생하는데 input buffer를 여러차례 넣고 나면 제대로 읽을 수 있게 된다.  FORMAT_CHANGED는 디코더의 output format에 변화가 생겼다는 뜻인데 디코딩 작업에서는 딱히 중요한 점이 없다.

 

mBufferInfo 에서 받아온 정보의 플래그 값을 보게 되는데 END_OF_STREAM 가 포함되어 있으면 버퍼는 마지막인 것이다. 전에 Input Phase에서 END_OF_STREAM 플래그를 넣었던 바로 그녀석이 맞다. 

int decoderStatus = decoder.dequeueOutputBuffer(mBufferInfo, TIMEOUT_USEC);
if (decoderStatus == MediaCodec.INFO_TRY_AGAIN_LATER) {
    // no output available yet
    if (VERBOSE) Log.d(TAG, "no output from decoder available");
} else if (decoderStatus == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
    MediaFormat newFormat = decoder.getOutputFormat();
    if (VERBOSE) Log.d(TAG, "decoder output format changed: " + newFormat);
} else if (decoderStatus < 0) {
    throw new RuntimeException(
            "unexpected result from decoder.dequeueOutputBuffer: " +
                    decoderStatus);
} else { // decoderStatus >= 0
    if (firstInputTimeNsec != 0) {
        // Log the delay from the first buffer of input to the first buffer
        // of output.
        long nowNsec = System.nanoTime();
        Log.d(TAG, "startup lag " + ((nowNsec-firstInputTimeNsec) / 1000000.0) + " ms");
        firstInputTimeNsec = 0;
    }
    boolean doLoop = false;
    if (VERBOSE) Log.d(TAG, "surface decoder given buffer " + decoderStatus +
            " (size=" + mBufferInfo.size + ")");
    if ((mBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
        if (VERBOSE) Log.d(TAG, "output EOS");
        if (mLoop) {
            doLoop = true;
        } else {
            outputDone = true;
        }
    }

    boolean doRender = (mBufferInfo.size != 0);

    // As soon as we call releaseOutputBuffer, the buffer will be forwarded
    // to SurfaceTexture to convert to a texture.  We can't control when it
    // appears on-screen, but we can manage the pace at which we release
    // the buffers.
    if (doRender && frameCallback != null) {
        frameCallback.preRender(mBufferInfo.presentationTimeUs);
    }
    decoder.releaseOutputBuffer(decoderStatus, doRender);
    if (doRender && frameCallback != null) {
        frameCallback.postRender();
    }

    if (doLoop) {
        Log.d(TAG, "Reached EOS, looping");
        extractor.seekTo(0, MediaExtractor.SEEK_TO_CLOSEST_SYNC);
        inputDone = false;
        decoder.flush();    // reset decoder state
        frameCallback.loopReset();
    }
}

 

doRender 변수를 결정하는 요인은 mBufferInfo.size 가 0보다 클 때 인데, 이 정보는 받아온 정보가 화면에 뿌려줄 영상 정보인지 아닌지를 의미한다. 그래서 이 값이 유효하다면 각 비즈니스 로직에 따라서 화면에 뿌려주거나 음성을 재생하면 된다. 아래 코드에서는 frameCallback 함수에서 읽어온 정보에서 시간 정보만 추출해 가져가고 있다. 경우에 따라선 비디오 디코딩 정보를 담고 있는 mOutputSurface를 OpenGL에 그려주어 인코더의 input에 넣어주기도 한다. CTS 테스트 코드를 보면 추출한 오디오 버퍼 정보를 바로 인코더에 넣는 것을 볼 수 있다.

 

읽어온 정보에 대한 처리가 끝나면 releaseOutputBuffer를 통해 이 정보에 대한 처리가 완료 됐음을 처리한다.

 

2.3 release 

 

EndOfStream에 도달해 디코딩 작업이 완료되면 사용한 Decoder를 반드시 릴리즈 시켜줘야한다. 이것은 release 함수로 가능하다.

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

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

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 - 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

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  (0) 2020.04.06