2017년 6월 14일 수요일

안드로이드 UI 성능분석 툴 - Hierarchy VIewer

Hierarchy VIewer

준비작업

  1. 개발할 컴퓨터에 환경변수로 ANDROID_HVPROTO=ddm을 추가한다. (이미 추가되었다면 다음단계부터 진행)
  2. Tools -> Android -> Android Device Monitor를 실행한다.
스크린샷 2017-04-21 오후 12.21.51.png
  1. 아래와 같은 팝업이 뜬다면 YES를 선택한다.(안뜨면 다음단계부터 진행)
스크린샷 2017-04-21 오후 12.24.11.png
  1. Android Device Monitor가 떴으면, 오른쪽 상단에서 Hierarchy Viewer 탭 선택
    • 만약 탭이 없다면, 그림에서 표시된 부분의 가장 왼쪽 버튼(Open Perspective)를 선택하여 Hierarchy Viewer를 선택한다.
스크린샷 2017-04-21 오후 12.28.12.png
  1. 왼쪽의 Windows 탭에서 디바이스이름과 밑으로 현재 실행중인 엑티비티목록이 뜨는것을 확인한다. 만약, 목록이 갱신되지 않았다면 Refresh 버튼 클릭!
    • 확인할 앱은 반드시 debug모드로 수행되어야 한다.(build.gradle에서 debuggable 속성 true)
  1. 확인하고싶은 엑티비티를 더블클릭하면, Tree View 앱에서 레이아웃 구조를 확인할 수 있다. 루트를 선택한 후에 아래 스크린샷에 표시된 버튼(Obtain Layout TIme)을 선택한다.
스크린샷 2017-04-21 오후 1.59.10.png
  1. 확인하고싶은 뷰를 선택한 후에, Measure, Layout, Draw에 걸린 시간과 그 시간의 정도를 나타내는 3개의 점을 확인한다.
    • Measure : 자식뷰를 포함하여 선택한 뷰의 크기 결정 시간 (왼쪽 점)
    • Layout : 자식뷰를 포함하여 선택한 뷰의 레이아웃 배치 시간 (중간 점)
    • Draw : 자식뷰를 포함하여 선택한 뷰의 렌더링 시간 (오른쪽 점)
스크린샷 2017-04-21 오후 5.23.09.png

분석방법

  • 리프노드, 혹은 자식뷰가 많지 않은 노드 중에 빨간점으로 표기된 노드를 찾는다. 대체적으로 이 뷰가 렌더링을 느리게한다. SystraceTraceview를 이용하면 추가정보를 더 얻을 수 있다.
  • 자식뷰가 많은 노드이고 Measure가 빨간점으로 표기되었다면, 자식들의 노드를 살펴본다.
  • 점이 노란색 / 빨간색 이더라도 실제로는 성능에 큰 영향을 미치지 않는 요소일 수 있다. 측정된 시간을 비교하면 도움이 된다. SystraceTraceview를 이용하면 추가정보를 더 얻을 수 있다.
  • 루트 뷰노드는 모든 자식뷰가 완료되고 난 후에야 완료되므로, 일반적으로 Measure, Layout이 빨간점이고, Draw가 노란점이다.
  • 20개 이상의 노드를 가진 뷰계층에서 Draw가 빨간점으로 표시된 리프노드가 있다면, 그 뷰는 Draw에 문제가 있으므로, onDraw 등의 Draw 메서드를 확인한다.

2017년 5월 31일 수요일

안드로이드 Soft Keyboard 상태변화 시점 감지

안드로이드 Soft Keyboard 상태변화 감지하기

발단

안드로이드에서는 소프트키보드 상태를 변경하기 위해, 2가지 방법을 사용할 수 있을 것이다.
  • EditText에 focus를 변경하여(클릭 등) 시스템에서 자동으로 상태변화 수행
  • InputMethodManager의 showSoftInput() / hideSoftInputFromXXX() 메서드를 이용하여 명시적으로 수행
2번째 방법인 InputMethodManager를 이용한다면, 메서드의 파라미터로 ResultReceiver 를 전달하여 상태변화에 따른 콜백을 쉽게 받을수 있다. 하지만, 특별한 케이스가 아니라면 당연히 1번 방법처럼 EditText를 클릭하는 등의 방법으로 시스템이 자동으로 소프트키보드를 제어하도록 구현하는게 일반적이다. 이 때, 소프트키보드의 상태변화와 키보드의 높이를 감지하여 구현해야 할 일이 있다면....??

아이디어

구글에서 검색하던 중, 스택오버플로우에 ViewTreeObserver의 GlobalLayoutListener를 이용하는 방법을 발견했다.
http://stackoverflow.com/questions/4745988/how-do-i-detect-if-software-keyboard-is-visible-on-android-device

코드

// 파라미터로 엑티비티의 가장 최상위 layout을 받는다..
private void registerView(final View rootView) {
    if (mGlobalListener == null) {
        mGlobalListener = new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                Rect r = new Rect();
                // 해당 루트뷰에서 윈도우가 보이는 영역을 얻어옴
                rootView.getWindowVisibleDisplayFrame(r);

                // 루트뷰의 실제 높이와, 윈도우 영역의 높이를 비교
                // 키보드는 윈도우 영역에 위치하므로 뷰와 윈도우의 높이비교를 통해 키보드의 여부를 알 수 있다.
                int heightDiff = rootView.getRootView().getHeight() - (r.bottom - r.top);

                // keyboardThreshold는 윈도우가 기본적으로 차지하고있는 영역(StatusBar / Soft Back Button)
                int keybordHeight = heightDiff - keyboardThreshold;
                if (heightDiff > keyboardThreshold) {
                    if (!mIsKeyboardVisible) {
                        mIsKeyboardVisible = true;
                        //TODO::Keyboard invisible -> visible
                    }
                } else {
                    if (mIsKeyboardVisible) {
                        mIsKeyboardVisible = false;
                        //TODO::Keyboard visible -> invisible
                    }
                }
            }
        };
    }
    // ViewTreeObserver에 리스너 등록
    rootView.getViewTreeObserver().addOnGlobalLayoutListener(mGlobalListener);
}

2017년 3월 9일 목요일

'안드로이드] UI 레이아웃에 퍼센트 값 주기

UI 레이아웃에서 퍼센트 값 주기

기존 안드로이드에서는 UI 레이아웃을 할 때, 퍼센트 값을 직접 사용할 수 없었다.(코드에서 직접 퍼센트 수치를 계산하는 것 제외) 그러나, Support Library 23버전에 추가된 PercentFrameLayoutPercentRelativeLayout을 사용하면, 퍼센트값을 이용해 레이아웃을 구성할 수 있다.

PercentFrameLayout, PercentRelativeLayout

각각 FrameLayout, RelativeLayout을 상속받았으므로 해당 레이아웃의 특성을 모두 가지고 있고, 추가적으로 자식 뷰들을 레이아웃 할 때 Percent 값을 지원한다.

사용할 수 있는 Percent 속성

Percent 수치를 사용할 수 있는 속성은 다음과 같다.
* layout_widthPercent
* layout_heightPercent
* layout_marginPercent
* layout_marginLeftPercent
* layout_marginTopPercent
* layout_marginRightPercent
* layout_marginBottomPercent
* layout_marginStartPercent
* layout_marginEndPercent
* layout_aspectRatio
  • 각 속성의 이름을 보면 직관적으로 어떤 의미인지 알 수 있다.
  • layout_widthPercent(heightPecent) 가 있으면 기존의 layout_width(height)를 생략해도 된다.
    • 만약, 명시된 width/height의 퍼센트가 뷰의 내용보다도 작을 때, 리사이징 되도록 하고 싶다면, layout_width/height 속성을 wrap_content로 준다.
  • layout_aspectRatio는, 뷰의 width/height 비율을 지정하는 옵션이다.
android:layout_width="300dp"
app:layout_aspectRatio="250%"       <!-- width의 250%만큼 height가 설정됨 -->

적용방법

Library 추가

app의 build.gradle에 다음의 dependancy를 추가한다.
dependencies {
    ...
    // 2017.3.8일 기준 25.2.0버전
    compile "com.android.support:percent:25.2.0"
    ...
} 

xml에서 사용

1.부모 레이아웃을 PercentFrameLayoutPercentRelativeLayout으로 추가한다.
2.http://schemas.android.com/apk/res-auto xml 네임스페이스를 추가한다.
3.자식 뷰에 필요한 속성을 추가한다.
<android.support.percent.PercentFrameLayout
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <TextView
        app:layout_widthPercent="50%"
        app:layout_heightPercent="20%"
        app:layout_marginTop="5%"
        android:layout_gravity="left|center_vertical" />
    <TextView
        app:layout_widthPercent="20%"
        app:layout_heightPercent="20%"
        app:layout_marginTop="5%"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="right|center_vertical" />
</android.support.percent.PercentFrameLayout>

코드에서 사용

1.Percent Layout의 자식뷰로부터 LayoutParams 객체를 얻는다.
2.getPercentLayoutInfo() 메서드를 호출하여 PercentLayoutInfo 객체를 얻는다.
3.얻은 PercentLayoutInfo 객체의 필드를 이용해 값을 적용한다.
PercentFrameLayout.LayoutParams params = (PercentFrameLayout.LayoutParams) mChild.getLayoutParams();
PercentLayoutHelper.PercentLayoutInfo layoutInfo = params.getPercentLayoutInfo();
layoutInfo.widthPercent = 0.20f;        // 20%
mChild.setLayoutParams(params);

2017년 2월 14일 화요일

[안드로이드] apk 위/변조 대비하기

APK 변조 대비하기

1.런타임에 Signing 인증서 확인하기

PackageInfo 를 이용해 런타임에 Signing 인증서를 가져올 수 있다. 이를 서버로 보내어, 현재 앱의 인증서가 제대로 된 인증서인지 변조된 인증서인지를 분별하여 앱의 실행을 제한하는 방법이다.

방법

  1. keystore파일의 SHA-1키를 확인하여 서버에 저장한다.
keytool -exportcert -alias <Alias 이름> -keystore <키스토어 파일경로> | openssl sha1 -binary | openssl base64

//예시
keytool -exportcert -alias DISC -keystore JBS.keystore  | openssl sha1 -binary | openssl base64
  1. 앱에서 현재 Signing된 인증서 정보를 가져온다.
public static void checkAppSignatureInServer(Context context, Callback callback) {
    try {
        @SuppressLint("PackageManagerGetSignatures")
        PackageInfo packageInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES);
        
        //안드로이드는 이론상 여러 키스토어로 Signing 가능하다고 하지만,
        //일반적으로는 하나의 키스토어로만 Signing을 하므로, signatures의 첫번째 값을 이용한다.
        Signature signature = packageInfo.signatures[0];
        final String currentSignature = obtainSignatureString(signature);
        fetchServer(currentSignature, callback);
    } catch (PackageManager.NameNotFoundException | NoSuchAlgorithmException e) {
        e.printStackTrace();
    }
}

private static void fetchServer(String signature, Callback callback) {
    //TODO::Fill in server logic
    callback.onFinishCheck(ValidationEnum.VALID);
}

2.인스톨러 확인

모든 앱은 자신이 어떤 인스톨러로부터 인스톨되었는지를 알 수 있도록 인스톨러의 identifier를 가지고 있다. 이 값을 확인하여, 등록된 인스톨러가 아닐 경우에 앱의 사용을 제한하는 방법이 있다.

방법

  1. 인스톨러의 identifier를 확인한다.
  • 구글 : com.android.vending
  • 다른 스토어의 인스톨러 id 확인 필요.
  1. 앱의 인스톨러를 확인한다.
public static boolean verifyGoogleInstaller(Context context) {
    final String installer = context.getPackageManager().getInstallerPackageName(context.getPackageName());
    return installer != null && installer.startsWith("com.android.vending");
}

2017년 1월 24일 화요일

화면전환시 갑자기 ArrayIndexOutOfBoundsException을 내며 뻗을 때

화면전환시 갑자기 ArrayIndexOutOfBoundsException을 내며 뻗을 때

배경

안드로이드 5.0 롤리팝 이하 디바이스에서 종종 화면전환을 시도할 때, ArrayIndexOutOfBoundsException 을 뿜으며 크래쉬되는 현상을 발견했다. 그런데 로그를 보면... 어디가 원인인지 도저히 알수가 없어서 난감한 상황이 생겼다.
ava.lang.ArrayIndexOutOfBoundsException: length=125; index=-1
at android.text.StaticLayout.calculateEllipsis(StaticLayout.java:756)
at android.text.StaticLayout.out(StaticLayout.java:720)
at android.text.StaticLayout.generate(StaticLayout.java:428)
at android.text.StaticLayout.<init>(StaticLayout.java:140)
at android.widget.TextView.makeSingleLayout(TextView.java:5884)
at android.widget.TextView.makeNewLayout(TextView.java:5741)
at android.widget.TextView.onMeasure(TextView.java:6098)
at android.view.View.measure(View.java:15172)
at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:4814)
at android.widget.LinearLayout.measureChildBeforeLayout(LinearLayout.java:1390)
at android.widget.LinearLayout.measureVertical(LinearLayout.java:681)
at android.widget.LinearLayout.onMeasure(LinearLayout.java:574)
at android.view.View.measure(View.java:15172)
at android.widget.RelativeLayout.measureChildHorizontal(RelativeLayout.java:617)
at android.widget.RelativeLayout.onMeasure(RelativeLayout.java:399)
at android.view.View.measure(View.java:15172)
at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:4814)
at android.widget.LinearLayout.measureChildBeforeLayout(LinearLayout.java:1390)
at android.widget.LinearLayout.measureVertical(LinearLayout.java:681)
at android.widget.LinearLayout.onMeasure(LinearLayout.java:574)
at android.view.View.measure(View.java:15172)
at android.widget.ScrollView.measureChildWithMargins(ScrollView.java:1196)
at android.widget.FrameLayout.onMeasure(FrameLayout.java:310)
at android.widget.ScrollView.onMeasure(ScrollView.java:318)
at android.view.View.measure(View.java:15172)
at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:4814)
at android.widget.FrameLayout.onMeasure(FrameLayout.java:310)
at android.view.View.measure(View.java:15172)
at android.widget.RelativeLayout.measureChildHorizontal(RelativeLayout.java:617)
at android.widget.RelativeLayout.onMeasure(RelativeLayout.java:399)
at android.view.View.measure(View.java:15172)
at android.support.v4.widget.DrawerLayout.onMeasure(DrawerLayout.java:1081)
at android.view.View.measure(View.java:15172)
at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:4814)
at android.widget.FrameLayout.onMeasure(FrameLayout.java:310)
at android.support.v7.widget.ContentFrameLayout.onMeasure(ContentFrameLayout.java:139)
at android.view.View.measure(View.java:15172)
at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:4814)
at android.widget.LinearLayout.measureChildBeforeLayout(LinearLayout.java:1390)
at android.widget.LinearLayout.measureVertical(LinearLayout.java:681)
at android.widget.LinearLayout.onMeasure(LinearLayout.java:574)
at android.view.View.measure(View.java:15172)
at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:4814)
at android.widget.FrameLayout.onMeasure(FrameLayout.java:310)
at android.view.View.measure(View.java:15172)
at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:4814)
at android.widget.LinearLayout.measureChildBeforeLayout(LinearLayout.java:1390)
at android.widget.LinearLayout.measureVertical(LinearLayout.java:681)
at android.widget.LinearLayout.onMeasure(LinearLayout.java:574)
at android.view.View.measure(View.java:15172)
at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:4814)
at android.widget.FrameLayout.onMeasure(FrameLayout.java:310)
at com.android.internal.policy.impl.PhoneWindow$DecorView.onMeasure(PhoneWindow.java:2148)
at android.view.View.measure(View.java:15172)
at android.view.ViewRootImpl.performMeasure(ViewRootImpl.java:1848)
at android.view.ViewRootImpl.measureHierarchy(ViewRootImpl.java:1100)
at android.view.ViewRootImpl.performTraversals(ViewRootImpl.java:1273)
at android.view.ViewRootImpl.doTraversal(ViewRootImpl.java:998)
at android.view.ViewRootImpl$TraversalRunnable.run(ViewRootImpl.java:4212)
at android.view.Choreographer$CallbackRecord.run(Choreographer.java:725)
at android.view.Choreographer.doCallbacks(Choreographer.java:555)
at android.view.Choreographer.doFrame(Choreographer.java:525)
at android.view.Choreographer$FrameDisplayEventReceiver.run(Choreographer.java:711)
at android.os.Handler.handleCallback(Handler.java:615)
at android.os.Handler.dispatchMessage(Handler.java:92)
at android.os.Looper.loop(Looper.java:137)
at android.app.ActivityThread.main(ActivityThread.java:4745)
at java.lang.reflect.Method.invokeNative(Native Method)
at java.lang.reflect.Method.invoke(Method.java:511)
at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:786)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:553)
at dalvik.system.NativeStart.main(Native Method)

원인

구글링을 해본 결과, 이것은 젤리빈(4.1~4.3)부터 있던 버그로, TextView에서 maxLines=1 / lines=1속성을 사용할 때, ellipsize속성값을 start로 할 경우에 해당 익셉션이 발생하여 크래쉬되는 증상이었다.
https://code.google.com/p/android/issues/detail?id=33868

해결방법

TextView에 singleLine=true속성을 추가해주면 크래쉬가 나지 않는다.
참고로, 이 버그는 6.0에서는 재현되지 않는 것으로 보아, 6.0에서 고쳐진 것 같다.

2017년 1월 13일 금요일

[안드로이드] Activity Leak 확인 툴 - LeakCanary

엑티비티 Leak

엑티비티의 참조가 남아있어서 Destroy 후에도 GC되지 않는 증상으로, 안드로이드에서 메모리 누수의 주 원인으로 꼽힌다. 엑티비티 인스턴스는 다른 인스턴스에 비해서 메모리를 많이 차지하고, 일반적으로 뷰 등 여러가지 멤버필드를 가지고있기 때문에 엑티비티 Leak이 발생하면 자칫 치명적인 성능저하를 유발할 수도 있다.

LeakCanary

Square 사(BetterKnife 만든 회사)에서 만든 무료 라이브러리로, 그나마 쉽게 엑티비티 Leak을 감지할 수 있도록 도와준다.

적용방법

  1. app의 build.gradle에 다음의 dependancy를 추가한다.
dependencies {
    //Debug용 컴파일
   debugCompile 'com.squareup.leakcanary:leakcanary-android:1.5'
   //Release용 컴파일 - Release시에는 아무 동작도 안하도록
   releaseCompile 'com.squareup.leakcanary:leakcanary-android-no-op:1.5'
}
2.Application의 onCreate() 메서드에서 초기화한다.
public class ExampleApplication extends Application {

  @Override public void onCreate() {
    super.onCreate();
    if (LeakCanary.isInAnalyzerProcess(this)) {
      // This process is dedicated to LeakCanary for heap analysis.
      // You should not init your app in this process.
      return;
    }
    LeakCanary.install(this);
    // 앱 초기화 로직
  }
}

이용방법

앱을 테스트하다보면 다음 이미지와 같이, 추가한적 없는 아이콘이 보이는 경우가 있는데, 저 아이콘이 보이고 Notification이 존재한다면 현재 앱은 엑티비티 Leak이 존재하는 것이다.
Notification을 클릭하면, 왜 Leak이 발생했는지를 보여주는 화면이 나타난다.

장/단점

  • 장점
    • 적용이 매우 쉽다.
    • Leak이 발생하면 시각적으로 알 수 있어서 직관적이다.
  • 단점
    • Activity Leak를 확인하는 것만 가능하다. - 다른 메모리 Leak은 불가능
    • Activity의 Destroy 시점에서 검사를 진행하므로 화면 전환속도가 조금 느리다. - release 버전에서는 LeakCanary가 동작하지 않도록 빈 라이브러리를 컴파일하거나, 라이브러리를 제거해야 한다.

참고자료

2017년 1월 4일 수요일

[안드로이드] 무선 ADB 연결

선 없이 ADB 연결하기

안드로이드 개발을 하다보면, 여러 디바이스를 동시에 테스트 해야할 일이 많은데... USB포트의 개수는 한정되어 있어서 컴퓨터에 연결되는 디바이스의 개수가 제한되기 마련이다. ADB는 TCP 기반의 무선연결을 제공하므로, 이 기능을 통해서 USB포트보다 많은 디바이스를 연결할 수 있다.

준비

연결할 디바이스와 ADB가 있는 컴퓨터는 같은 네트워크에 속해있어야 한다. (즉, 3G/LTE일 때는 무선연결을 이용할 수 없다.)

ADB 연결

  1. 연결할 스마트폰을 컴퓨터에 USB로 연결한다.
  2. Terminal / cmd 를 열고, 다음과 같이 입력한다.
    • 이 때, 입력할 명령어는 adb가 설치되어 있는 디렉터리에서 수행되거나, 그 디렉터리가 환경변수로 등록되어 있어야 한다.
    • 아래 예제에서 5555는 포트번호이다. 포트번호는 임의로 설정해준다.
    • 성공시 restarting in TCP mode port: 5555 라는 메시지가 나타난다.
    • 이미 여러개의 장치가 컴퓨터에 연결되어 있을 경우, -s 옵션을 이용하여 device id를 추가로 입력해준다. device id는 adb devices 명령을 이용해 확인할 수 있다.
// Windows
adb tcpip 5555
adb -s <device id> tcpip 5555       // 여러개의 장치가 연결되어 위의 명령어가 안될 경우
// Mac or Linux
./adb tcpip 5555
./adb -s <device id> tcpip 5555      // 여러개의 장치가 연결되어 위의 명령어가 안될 경우
  1. USB를 연결해제한 후, 스마트폰의 ip를 확인한다.
  2. 확인한 ip를 이용해 다음과 같이 adb 명령을 수행한다.
    • 아래 예제에서 192.168.0.100은 스마트폰의 ip, 5555는 위에서 설정한 포트번호이다. 각자의 상황에 맞게 바꿔적어야 한다.
    • 성공시 connect to 192.168.0.100:5555라는 메시지가 나타난다.
// Windows
adb connect 192.168.0.100:5555
// Mac or Linux
./adb connect 192.168.0.100:5555
  1. 안드로이드 스튜디오에서 로그가 제대로 보이는지 확인한다. 혹은 다음의 명령어를 이용한다.
// Windows
adb devices
// Mac or Linux
./adb devices