RichUtils 1.2.5 Update Release

일단 어느정도 실무에서 바로 쓸 수 있을 정도로는 근접하게 만들고, 문서도 구체화 하고 있습니다.

계획상으론 다음 업데이트는
* 앱 종료 로그 수집 RichCrashCollector 통합 및 수정
* 시간이 부족해 구현하지 못했던 (자바)샘플 구현
* 지금까지 나눠올린 라이브러리 통합 예정

차후 규모가 커진다면 그레들 모듈 별로 나눠서 배포하는 등의 작업을 거칠 생각은 있으나, 지금은 단순히 하나만 하면 다 나오는 식으로 진행하려 합니다.

PR, 이슈 언제든 환영합니다.
이메일 문의: pyxis@uzuki.live

RichUtils 1.2.5 Patch Note

New Utils
* RFont: apply TextView’s typeface, RSystemFontEngine Wrapper
* RVibrate: Vibrate, generate vibrate pattern
* RUnReadCount: display unreadcount on launcher
* RHintSpinner: extension of spinner, add hint feature in Dropdown widget

New Module
* RInAppBilling: Google IAP v3 Module
* ImageSliderActivity: Image Slide Activity (for mockup application)

New widget
* RollingViewPager: automatically rolling viewpager for banner widget
* CombinedTextView: SpannableString Utils (on XML)

Improve old Utils
* RBitmap: add resize feature

Fix bug
* RPickMedia: fix about runtime permission
* RPickMedia: add @JvmField into PICK_FAILED, PICK_SUCCESS
* RPermission: edit fragment default constductor

Improve usabillty
* improve web document that attach android plugin

RichUtils 1.2.5 패치노트

새로운 유틸
* RFont: 텍스트뷰 폰트 적용, RSystemFontEngine Wrapper
* RVibrate: 진동, 진동 패턴 생성
* RUnReadCount: 언리드 카운터 표시
* RHintSpinner: 드롭다운 위젯에 힌트 기능 추가 (기존 스피너 확장식)

새로운 모듈
* RInAppBilling: Google IAP v3 연동 모듈
* ImageSliderActivity: 이미지 슬라이더 모듈

새로운 위젯
* RollingViewPager: 자동으로 돌아가는 뷰페이저
* CombinedTextView: SpannableString Utils (on XML)

기존 유틸 강화
* RBitmap: resize 기능 추가

버그 수정
* RPickMedia: 권한 관련 버그 수정
* RPickMedia: PICK_FAILED, PICK_SUCCESS 등지에 @JvmField 추가
* RPermission: 프래그먼트 생성자 수정

사용성 강화
* 웹 문서에 안드로이드 플러그인 장착 -> 문서 세부화

Advanced Kotlin – Invoke operator

Invoke operator 란?

Invoke (작동시키다 또는 불러오다) operator (연산자) 로, 한마디로 +(plus), *(times), a..b(rangeTo) 등과 같이 기호로 쓸 수 있는 것을 의미한다.

문제

class Request(val method: String, val query: String, val contentType: String)

class Status(var code: Int, var descriprtion: String)

class Response(var contents: String, var status: Status) {
    fun status(status: Status.() -> Unit) {

    }
}

class RouteHandler(val request: Request, val response: Response) {
    var executeNext = false
    fun next() {
        executeNext = true
    }
}

fun routeHandler(path: String, f: RouteHandler.() -> Unit): RouteHandler.() -> Unit = f

fun response(response: Response.() -> Unit) {}

fun main(args: Array<String>) {
    routeHandler("/index.html") {
        if (request.query != "") {
            // process
        }

        response {
            status {
                code = 404
                descriprtion = "not found"
            }
        }
    }
}

RouteHandler 라는 클래스가 있는데, 이 클래스는 Request 클래스와 Response 클래스를 각각 속성으로 가지고 있고, Response는 또 Status라는 클래스를 속성으로 가지고 있다.

그리고 이 클래스를 메소드로 wrap 한 것이 밑의 routeHandler, response 등이다.

자 그러면 28번째 줄 부터 봐보자. response 라는 메소드는 Response 클래스를 인수로 가지는 람다 파라미터를 가지고 있다. Response는 Status라는 클래스를 속성으로 가지고 있다고 했는데, status를 설정하려면 저런 식으로 status { }  로 감싸야 한다.

간단한 클래스면 문제가 없겠지만 언제나와 그렇듯이 짧은 코드만 존재할 수는 없다. 저런게 몇개나 더 들어간다고 하면 훌륭한 웨이브가 생기지 않을까.

그래서, 우리는 저 status { } 를 없애고 response { } 단독으로만 쓰고 싶다. 그럴 때 등장하는 것이 Invoke operator 이다.

일단 response 메소드를 제거해보자.

Invoke Operator : Expression 'response' of type 'Respose' cannot be invoked as a function. The function 'invoke()' is not found

이런 오류가 뜬다. Quick Fix를 해보자.

이제 Response 클래스에 invoke 메소드가 생긴다. 우리가 활용하고 싶은건 Status 속성이므로 invoke가 Status를 인수로 가지는 람다 파라미터로 만들어보자. 겸사겸사 위에 safe delete 가 가능하다고 나오는 status 메소드도 같이.

class Response(var contents: String, var status: Status) {
    operator fun  invoke(function: Status.() -> Unit) { // modifier operator

    }
}

이런 모양새가 되었으면, 이제 밑의 main 메소드에 있는 status { } 를 제거해보자.

response {
            code = 404
            descriprtion = "not found"
}

자, 이제 우리가 원하는 방식으로 되었다.

왜 이것이 가능할까?

원리

Manager란 클래스를 하나 만들어보고, main에 그에 해당하는 참조 변수를 만들어보자.

fun main(args: Array<String>) {
    val manager = Manager()
}

class Manager {

}

그리고 만들어진 manager 라는 변수에 인수로 “Do something for me!” 를 넘겨보자. manager("Do something for me!") 이런 식으로 하고, Quick Fix를 해 줄 경우 Manager 클래스에 invoke 메소드가 생긴다. 인수로는 String를 받을거니 String로 선언하고, println 하는 식으로 구현을 해보면..

fun main(args: Array<String>) {
    val manager = Manager()
    manager("Do something for me!")
}

class Manager {
    operator fun  invoke(value: String) {
        println(value)
    }
}

이런 식이 될 것이다.

operator 는 메소드에 붙는 Modifier(수식어)의 일종으로, +, *, .. 와 같은 기호의 기능을 확장하는 기능을 수행한다.

예를 들면, 이런 식이다.

  • a + b 는 a.plus(b)
  • a * b 는 a.times(b)
  • a..b는 a.rangeTo(b)
  • a in b 는 a.contains(b)

그리고 우리의 invoke는.

manager.invoke("Do something for me!") 를 manager("Do something for me!") 식 으로 작동한다.

즉, 실제로 위의 response 에서도 response.invoke(Status.() -> Unit) 가 되어야 할 것을, response { }  로 줄인 셈이다. 당연히 Response 클래스는 Status 를 속성으로 가지고 있고, invoke 메소드는 Status 를 람다 파라미터로 가지고 있으니 람다 익스텐션 (Lambda Extension) 으로 활용이 가능, Status의 속성에 접근이 가능한 것이다.

이러한 Operator들을 확장하는 것을 Operator overloading (연산자 오버로드) 라고 부르며, 코틀린에서는 이러한 연산자 오버로드 기능을 적극적으로 활용할 수 있도록 다양한 operator (+, *와 같은 고정된 기호 표현과 코틀린에서 제공하는 우선 기호 등) 를 제공한다.

결론

이 Invoke operator를 가지고 DSL(Domain-specific Language) 구현에도 쉽게 사용할 수도 있고, 사용할 수 있는 곳이 매우 많다. 진작에 알았으면 하는 아쉬움은 있다. 이제라도 알았으니 남은건 라이브러리에 직접 활용하는 것이다.

사용 가능한 operator는 invoke 외에도 매우 많다. 이 것을 적절히 활용해서 짧고 효율적인 코드를 짜는 데에 큰 도움이 될 수 있도록 노력해야겠다.

참조 링크: https://kotlinlang.org/docs/reference/operator-overloading.html

Android – Video Transcoding

Transcoding ?

Transcoding 는 영화 파일, 오디오 파일, 문자 인코딩 등 하나에서 다른 하나로의 디지털 변환 작업을 의미한다. 보통 동영상에서는 동영상의 비트열을 변환하는 뜻으로 사용된다.

기존 안드로이드에서는 FFmpeg 등을 사용했었는데, 아래와 같은 문제가 있었다.

  • mp4를 인코딩 하기 위해 libx264를 컴파일 하면 GPL에 걸림
  • mp4 인코딩에 사용되는 H.264 컨테이너를 동봉할 경우 로열티를 지급해야 함.
  • 애초 NDK를 사용한다는 것 자체가…

하지만 API 18 (4.3부터) FFmpeg 를 사용하지 않고도 Video Transcoding가 가능하게 되었다.

바로, MediaMuxer, MediaCodec, MediaExtractor 등이다.

그리고 이런 API 등을 쉽게 사용할 수 있게 만들어진 라이브러리가 android-transcoder 이다.

README에 잘 나왔긴 했지만 삽질하면서 얻은 기록을 이쪽에 정리하는 것이 좋을거라 생각해서 쓰게 되었다.

생각해야 할 것

앱 단에서 신경써야 할 것은 어느정도 있다.

인코딩은 많은 시간을 소비한다.

그래서 해당 라이브러리도 Callback 형태로 결과값을 넘겨주고 있다. 내 경우에는 영상 업로드 프로세스 전에 타야 되었기 때문에 어쩔 수 없이 UI Blocking를 해야 할 필요가 있었다.

그런고로 ProgressDialog 도입.

인수로 FileDescriptor 등을 받는다.

 private FileDescriptor getFileDescriptor() {
        File file = new File(incomePath);
        try {
            FileInputStream stream = new FileInputStream(file);
            return stream.getFD();
        } catch (IOException e) {
            return null;
        }
 }

내 경우에는 업로드 전에 시행했기 때문에 로컬 경로를 가지고 있었고, 이에 바로 FileDescriptor 로 변환이 가능했다.

16:9 영상 이외엔 받지 않는 것 같다.

16:9 이외의 영상 (960×540, iOS가 보통 이걸로 인코딩 한다) 는 다른 안드로이드 기기에서 작동을 보증하지 않는다.

안드로이드 기기들은 CTS (Compatibility Test Suite) 를 통과해야 하는데,  이 CTS는 1280×720 해상도로 지원하는 미디어 포맷 문서 에 있는 포맷들을 테스트한다.

즉 이외 해상도는 지원해야 될 의무가 없으며 이는 하드웨어 코덱의 부재 및 제한으로 이어질 수 있다.

이를 위해서는 자르거나 합쳐서 16:9로 만들어야 하는데, 이를 아직 android-transcoder 에선 지원하지 않는다.

사용할 경로와 결과 경로가 달라야 한다.

어떻게 보면, 이 문제로 2시간 이상 삽질했다.

즉, 결과 경로가 다른 파일로 저장되어야 한다. 만일 같은 파일로 할 경우에는 해상도만 변경된 채 비트레이트 등 정보가 바뀌지 않아 용량이 줄어들지 않는다.

그래서…

나중에도 활용할 수 있도록 만든 클래스가 아래와 같다.

import android.app.Activity;
import android.app.ProgressDialog;
import android.graphics.Bitmap;
import android.media.MediaFormat;
import android.media.MediaMetadataRetriever;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.util.Log;

import net.ypresto.androidtranscoder.MediaTranscoder;

import java.io.File;
import java.io.FileDescriptor;
import java.io.FileInputStream;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;

/**
 * VideoTranscodingUtils
 * Created by Pyxis on 2017-07-11.
 */
public class VideoTranscodingUtils implements MediaTranscoder.Listener {
    private Activity activity;
    private ProgressDialog progressDialog;
    private String incomePath;
    private String outcomePath;
    private OnResultListener listener;

    public static final int TRANSCODING_SUCCESS = 1;
    public static final int TRANSCODING_FAILED = 2;

    public VideoTranscodingUtils(Activity activity, String path) {
        this.activity = activity;
        this.incomePath = path;
        File folder = new File(Environment.getExternalStorageDirectory(), "encoding_output/");
        folder.mkdir();

        try {
            this.outcomePath = File.createTempFile("encoding", ".mp4", folder).getAbsolutePath();
        } catch (IOException e) {
            Log.d(VideoTranscodingUtils.class.getSimpleName(), "encoding failed");
        }

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            progressDialog = new ProgressDialog(activity, android.R.style.Theme_Material_Light_Dialog);
        } else {
            progressDialog = new ProgressDialog(activity, android.R.style.Theme_Holo_Light_Dialog);
        }
        progressDialog.setProgressStyle(ProgressDialog.STYLE_SPINNER);
        progressDialog.setMessage("Transcoding...");
        progressDialog.setCancelable(false);
    }

    public void transcode(final OnResultListener listener) {
        this.listener = listener;
        FileDescriptor descriptor = getFileDescriptor();

        MediaTranscoder.getInstance().transcodeVideo(descriptor, outcomePath,
                MediaFormatStrategyPresets.createAndroid720pStrategy(8000 * 1000, 128 * 1000, 1), this);
        
        activity.runOnUiThread(new Runnable() {
            @Override
            public void run() {
                progressDialog.show();
            }
        });

    }

    private FileDescriptor getFileDescriptor() {
        File file = new File(incomePath);
        try {
            FileInputStream stream = new FileInputStream(file);
            return stream.getFD();
        } catch (IOException e) {
            return null;
        }
    }

    private void dismissDialog() {
        activity.runOnUiThread(new Runnable() {
            @Override
            public void run() {
                try {
                    if (activity.isDestroyed() && !progressDialog.isShowing())
                        return;
                    progressDialog.dismiss();
                } catch (Exception e) {
                }
            }
        });
    }

    @Override
    public void onTranscodeProgress(final double progress) {
    }

    @Override
    public void onTranscodeCompleted() {
        dismissDialog();
        listener.onResult(TRANSCODING_SUCCESS, outcomePath);
    }

    @Override
    public void onTranscodeCanceled() {
        dismissDialog();
        listener.onResult(TRANSCODING_FAILED, incomePath);
    }

    @Override
    public void onTranscodeFailed(Exception exception) {
        dismissDialog();
        listener.onResult(TRANSCODING_FAILED, incomePath);
    }

    public interface OnResultListener {
        void onResult(int resultCode, String outPath);
    }
}

비디오 비트레이트, 오디오 비트레이트, 오디오 채널을 변경하려면 MediaFormatStrategyPresets.createAndroid720pStrategy(8000 * 1000, 128 * 1000, 1) 의 파라미터 값을 조정하면 된다.  여기서는 비디오 비트레이트 8mbps, 오디오 128kbps, 1번 오디오 채널을 사용했고, 바꿀 해상도는 720p (1280×720) 이다.

결론

깔끔하게 인코딩에 성공했다. 확실히 FFmpeg를 사용하는 것 보다는 위험 부담도 적고, 매우 쉽게 되어있었다.