본문 바로가기

개발/Android

Custom View 만들 때 주의사항 - View 생성자

Java로 안드로이드 개발을 할 때는 알지 못했던 것들을

Kotlin 공부를 하다보니 알게 되기도 한다.

 

언어적 차이(?)를 경험하면서 시야가 넓어지는 것도 있지만

이렇게 모르던 내용을 알게 되니까 참 유익하다.

 

 

나는 보통 Custom View를 만들때 다음처럼 만들었다.

 

public class CustomView extends FrameLayout {

    public CustomView(Context context) {
        this(context, null);
    }

    public CustomView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public CustomView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        // 공통적으로 필요한 작업
    }
}

 

로우한 View를 상속해서 개발해본 적은 없기 때문에...

잘 만들어진 widget을 상속해서 마지막 constructor에서 super을 해줬다.

왜냐면 공통으로 필요한 작업을 모든 constructor에서 부르긴 좀 귀찮아서 그랬던 것 같다.

 

근데 그러면 원치않는 결과를 초래할 수도 있단다....

 

그걸 어떻게 알게 되었나 하면,,,

 

Kotlin으로는 Custom View를 어떻게 만드는지 찾아보다가 아래의 글을 읽었다.

 

medium.com/@mmlodawski/https-medium-com-mmlodawski-do-not-always-trust-jvmoverloads-5251f1ad2cfe

 

Do not always trust @JvmOverloads

You can lose your custom view style when subclassing a TextView, Button or other components. Here’s a cool way to do this in Kotlin.

medium.com

위 링크의 포스팅 제목만 보았을때는 Kotlin @JvmOverloads가 잘못 구현이 되어있으니 믿지 말라는 이야기 같아보인다....(역시 자극적인 제목이 최고야...)

 

@JvmOverloads를 쓸 수 있을 때와 그렇지 않을 때를 구분해서 사용해야 한다이고,

CustomView 개발에는 사용하기엔 적합하지 않을 수 있으며, 이것이 이슈를 야기할 수 있다는 이야기였다.

 

대강 봐도 이글과 저게 무슨 관련이야 싶을 수 있다.

하지만 나는 저 글에서 View 생성자에 대해 새로운 사실을 알게 되었다. ^_^


JvmOverloads

간단하게 @JvmOverloads에 대해 정리도 곁들여 보겠다. (의식의 흐름..)

이미 알고 있다면 다음 부분으로 넘어가도 좋다.

 

Java에서는 parameter 타입이 추가됨에 따라 메서드를 오버라이드 한다.

그 좋은 예시가 바로 안드로이드 View의 constructor이다.

 

깊게 들어가면 복잡할 것 같은데,, 일단 두 개의 constructor만 보자면,

이런 식으로 매개변수가 적은 constructor는 기본값을 넣어 매개변수가 많은 녀석을 부르는 방식으로 구현이 되어있다.

 

    public View(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        this(context, attrs, defStyleAttr, 0);
    }

 

물론 이 constructor들이 그저 매개변수에 기본값을 넣어주려는 이유로 만들어진 것은 아니다. (다음 부분에서 언급하겠다)

이로 인해 constructor만 네 개가 된다.

 

Kotlin에서는 이러한 비효율성(?)을 방지하고자 default argument를 만들었다.

 

class CustomView constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : FrameLayout(context, attrs, defStyleAttr)

 

만약 Kotlin과 Java를 혼합해서 사용하는 프로젝트라 가정하자.

그렇다면 Java에서 다음 CustomView 인스턴스를 만들기 위해서는 세 개의 매개변수를 모두 명시해야한다.

 

그래서 만들어진 어노테이션이 @JvmOverloads이다.

이 어노테이션을 constructor 키워드 앞에 붙이고 Kotlin bytecode를 decompile해보면 결과물은 다음과 같다.

 

public final class CustomView extends FrameLayout {
   @JvmOverloads
   public CustomView(@NotNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
      Intrinsics.checkNotNullParameter(context, "context");
      super(context, attrs, defStyleAttr);
   }

   // $FF: synthetic method
   public CustomView(Context var1, AttributeSet var2, int var3, int var4, DefaultConstructorMarker var5) {
      if ((var4 & 2) != 0) {
         var2 = (AttributeSet)null;
      }

      if ((var4 & 4) != 0) {
         var3 = 0;
      }

      this(var1, var2, var3);
   }

   @JvmOverloads
   public CustomView(@NotNull Context context, @Nullable AttributeSet attrs) {
      this(context, attrs, 0, 4, (DefaultConstructorMarker)null);
   }

   @JvmOverloads
   public CustomView(@NotNull Context context) {
      this(context, (AttributeSet)null, 0, 6, (DefaultConstructorMarker)null);
   }
}

 

마치 Java에서 여러 constructor를 overrride 한듯 사용이 가능해졌다.

 

이 결과물은 내가 가장 앞에서 보여준 CustomView를 만든 Java 코드와 동일하다.

 

내가 위 글을 읽으면서 처음에 의문을 가지게 된 이유이다.

 

Kotlin이 원래 내가 개발하던 CustomView 코드로 잘 만들어주는데??

왜 원치않은 결과가 나올 수 있다는 거야!!! 이해가 되지 않았다.

 

왜냐하면... 내가 View에 대한 이해가 부족했기 때문이다.


View의 Style attribute

developer.android.com/reference/android/view/View#public-constructors

 

View  |  Android 개발자  |  Android Developers

 

developer.android.com

View 생성자에 대해서도 역시 Android developers 사이트에 잘 정리되어있다.

 

View의 생성자는 총 4가지가 있고 설명은 다음과 같다.

 

View(Context context) 코드 상에서 View 객체를 생성할때 사용하는 생성자
View(Context context, AttributeSet attrs) XML로부터 View를 객체를 생성(inflate)할때 사용되는 생성자
View(Context context, AttributeSet attrs, int defStyleAttr) XML inflate + theme attribute로 기본 스타일을 적용할때 사용되는 생성자
View(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) XML inflate + theme attribute와 style attribute로 기본 스타일을 적용할때 사용되는 생성자

 

예시로 Button의 두 번째 생성자 javadoc에 보면 코드 상에서 View를 생성할때는 이 생성자를 사용하지 않는게 컨벤션이라는 내용이 있다.

Android에서는 코드 안에서는 첫 번째 생성자를 쓰는 것을 권장하고 있다는 것을 알 수 있다.

 

어찌되었든 두 번째 생성자는 LayoutInflater가 xml로부터 View 객체를 생성(inflate)할 때에 사용이 된다.

매개변수인 AttributeSet은 XML 태그의 모든 attribute들의 모음이다.

 

그리고 이 생성자는 내부적으로 세 번째 생성자를 부르고 세 번째 생성자는 네 번째 생성자를 부른다.

매개변수인 defStyleAttr은 int 값인데 styleable 리소스 id를 의미한다.

 

styleable 리소스 안에는 해당 View를 style할 수 있는 속성들이 이런식으로 정의되어 있다.

 

    <resources>
       <declare-styleable name="PieChart">
           <attr name="showText" format="boolean" />
           <attr name="labelPosition" format="enum">
               <enum name="left" value="0"/>
               <enum name="right" value="1"/>
           </attr>
       </declare-styleable>
    </resources>

 

View가 inflate될 때, xml에서 파싱된 속성들은 AttributeSet으로 묶어진다. 이 묶어진 AttributeSet은 혼자만으로는 아무일도 할 수 없다. 그저 키와 값의 모음일 뿐이다.

 

TypedArray를 이용해서 AttributeSet에서 styleable 리소스 attribute에 정의된 녀석들만 가져와야 한다.

그래야 속성에 맞게 정해진 스타일을 입힐 수 있다.

 

예를 들면 TextView의 android:text 속성은 com.android.internal.R.styleable.TextView 리소스 안에 text라는 attribute name으로 정의되어 있다.

그러면 text 속성의 값을 가져와 설정하는 코드를 보자.

 

                case com.android.internal.R.styleable.TextView_text:
                    textIsSetFromXml = true;
                    mTextId = a.getResourceId(attr, Resources.ID_NULL);
                    text = a.getText(attr);
                    break;

 

text 속성은 위와 같이 com.android.internal.R.styleable.TextView_text에서 filter된다. (styleable의 name이 TextView이고 안에 attribute name이 text로 정의되어 있다. 규칙에 의해 TextView_text로 id가 만들어진다.)

 

그리고 text 속성의 CharSequence 값을 가져와서 text 변수에 저장한다.

이런식으로 속성마다 맞는 스타일을 긁어온 다음, 그에 맞게 뷰에 그리는 작업을 하게 된다.

 

안드로이드 플랫폼은 naive한 View가 아니라 기능을 넣어 기본적인 UI 컴포넌트들을 제공하는데 이걸 widget이라 한다.

TextView도 widget에 속하며 이들은 어마어마하게 많은 속성들을 제공해야 해서 각각 정의된 style이 존재한다.


View 상속시 주의할 점

xml으로 View를 inflate하는 상황에서는 두 번째 생성자가 사용된다고 했다.

 

두 번째 생성자는 Context와 AttributeSet만 매개변수로 받는다.

defStyleAttr 값을 매개변수로 받지 않기 때문에 어떤 속성들을 style해야할지 알 수가 없다.

 

그렇기 때문에 Button의 두 번째 생성자는 다음과 같이 구현되어 있다.

 

    public Button(Context context, AttributeSet attrs) {
        this(context, attrs, com.android.internal.R.attr.buttonStyle);
    }

 

세 번째 생성자에다가 기본 스타일 리소스 id를 넘겨준다.

 

그런데 만약 다음과 같이 Button를 상속한 CustomButton을 만들게 되면 어떻게 될까...

 

public CustomButton extends AppCompatButton {

    public CustomButton(Context context) {
        this(context, null);
    }

    public CustomButton(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }
    
    public CustomButton(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }
}

 

이걸 넣고 돌려보면... 버튼이 동작하지 않을 것이다.

 

바로 xml에서 가져온 속성들에 대한 style 처리가 될 수 없기 때문이다!!!!!

 

따라서 이러한 문제를 올바르게 해결하기 위해서는 이렇게 만들면 된다.

 

public CustomButton extends AppCompatButton {

    public CustomButton(Context context) {
        super(context);
        init();
    }
    
    public CustomButton(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }
    
    public CustomButton(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }
    
    private void init() {
        // 공통 작업
    }
}

 

layout을 상속해서 사용하는 경우에는 this를 연쇄적으로 사용해도 문제가 없는 것 같다.

왜냐면 맨 끝 생성자에서 default style을 가져와 입히는 코드가 들어가 있기 때문이다....

 

아무래도 기능이 있는 widget의 경우, default style을 변경하고 싶은 여지가 있으리라 생각해서 Button과 같이 구현해둔 것이 아닌가 싶다.

물론 아닌 widget도 있지만 상속을 타고 올라가보면 그렇게 구현되어 있기도 하고..

 

결론은 CustomView 생성자에서는 super를 쓰자!

하나하나 따지며 쓰기엔 머리아프다.

 

Kotlin에서도 괜히 @JvmOverloads를 쓸 필요가 없다.

그냥 생성자들을 모두 override하고 super를 불러주면 된다!


 

매우 돌아돌아 구구절절하게 결론에 도달한 것 같다...

 

두 번째 포스팅이니... 좀 더 기다려보자...

 

점점 나아지겠지~

'개발 > Android' 카테고리의 다른 글

Lifecycle aware component 수명주기 인식 컴포넌트  (0) 2020.10.06