[Android] Custom View로 생산성 높이기

양현진·2022년 11월 15일
2

Oh My Android

목록 보기
19/22
post-thumbnail
post-custom-banner

앱을 개발하는 과정에서 중후반쯤 느끼는 것이 있다.

어 이거 다른화면에 똑같이 쓰던 뷰형태네? 이걸 또 언제 만들어 복붙해야겠다~

단순히 두 세개 정도면 저러고 다음단계를 진행하는것도 무리는 없다. 하지만 어떤 개발을 하던 중복이 쌓이고 예상치 못한 변경사항이 생기면 머리가 터지는 건 국룰이다.

코드를 짜면서 반복되는 일을 모듈화할 땐 클래스로 정의하여 빼면 된다.
그리고, 안드로이드의 xml(view)에서의 모듈화는 지금 소개할 Custom View로 해결하면 된다.

그럼 주로 어떤 view를 Custom View로 빼내야 할까?
특별한 UI기획없이 진행하는 경우(주로 사이드 프로젝트) 어쩔 수 없이 반복이 닥치게 되면 해야할 듯 하다.

Figma또는 Jeplin으로 한 경우 전체적인 UI를 살펴보면서 레이아웃이 같은 형태를 Custom View로 빼낸다.

예를 들면 소셜 로그인 버튼같은 형태가 반복적인 경우가 있고, 내가 자주 사용하는 progress bar또한 가능하다.

view 하나인 경우 style로 정하지 뭐하러 Custom View로 빼내나 싶지만, 보통 로딩화면은 전체 화면을 가린 상태에서 로딩표시만 보이기에 view가 아닌 viewGroup(Constraint Layout)으로 작업하면 편하기 때문이다.

Class 이름

Custom View를 만들 Class를 생성해준다. Class의 이름은 후에 xml에서의 tag가 됨으로 사용 목적에 따라 명확하게 할 필요가 있다.

상속 타입 및 생성자

Class 생성 후 상속 받을 부모 Class를 정한다. 나는 주로 동일한 레이아웃을 가진 뷰들을 지정하기에 Layout(ViewGroup)을 상속 받는다.

ViewGroup(View)를 상속받게 되면 기본적으로 context를 부모에 전달해야하고, 그 외 3가지 부 생성자 AttributeSet, defStyleAttr, defStyleRes들이 있는데 필요에 따라AttributeSet을 받는 것 외 커스텀 용으로 사용하지를 않기에 2개의 생성자만 정의하는 편이다.

class CustomView1 : ConstraintLayout {
    constructor(context: Context) : super(context) {
        ...
    }

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
		...
    }
	...
}

Inflate

각 view들에게 속성을 정의해주기 위해 우선 inflate를 진행한다. 평소 ViewBinding이 익숙하여 ViewBinding을 이용한다.
Activity나 Fragment와는 다르게 생성 시점으로부터 context를 받아오기에 밑 코드처럼 전역변수로 생성해도 무관한 듯 하다.
하지만 난 쫄보라 lazy init으로 한 타임 늦췄다.

그 후 addView로 attach 시킨다.

class CustomView1 : ConstraintLayout {
    constructor(context: Context) : super(context) {
        initView()
    }

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
        initView()
    }
    
    private val binding: CustomView1Binding by lazy {
        CustomView1Binding.bind(
            LayoutInflater.from(context).inflate(R.layout.custom_view_1, this, false)
        )
    }
    private fun initView() {
        addView(binding.root)
    }
}

속성 초기화 및 사용

AttributeSet을 추가적으로 사용한 경우 obtainStyledAttributes으로 TypeArray을 가져온다. 해당 객체는 xml에서 정의한 속성을 담고 있다. 이제 getString, getResourceId 등 정의한 format을 가져와 view에 적용시키면 된다.

각 속성을 가져온 후 마지막에 TypeArray을 recycle()시키는 코드가 있다. 검색을 통해 이해한 바로는 TypeArrray는 다른 호출자가 재사용할 수 가 있다고 한다. 일반적으로 재사용되지 않는 객체는 GC에 의해 청소되는데, 이 객체(TypeArray)는 나중에 호출할 시 재사용을 위해 GC에 의해 제거되지 않기 위헤 recycle함수를 사용하는 듯 하다. 메모리에서 제거 된 후 새롭게 생성되면 메모리 부담이나 어떤 리소스 낭비가 있는 특이한 인스턴스인 듯 하다.
https://stackoverflow.com/questions/7252839/what-is-the-use-of-recycle-method-in-typedarray

class CustomView1 : ConstraintLayout {
    constructor(context: Context) : super(context) {
        initView()
    }

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
        initView()
        getAttrs(attrs)
    }
    
    private val binding: CustomView1Binding by lazy {
        CustomView1Binding.bind(
            LayoutInflater.from(context).inflate(R.layout.custom_view_1, this, false)
        )
    }
    private fun initView() {
        addView(binding.root)
    }

    private fun getAttrs(attrs: AttributeSet) {
        val typedArray = context.obtainStyledAttributes(attrs, R.styleable.CustomView1)
        setTypeArray(typedArray)
    }

    private fun setTypeArray(typedArray: TypedArray) {
        binding.run {
            tvTitle.text = typedArray.getString(R.styleable.CustomView1_title)
            tvContent.text = typedArray.getString(R.styleable.CustomView1_content)
        }
        typedArray.recycle()
    }
}

View Event

Custom View안에 있는 View들의 이벤트들은 Activity나 Fragment에서 어떻게 받을까. 대중적인 방법으론 interface나 람다를 이용하는 방법이다. 편의를 위해 람다를 적용해봤다.

ViewGroup을 상속받은 Custom View는 어짜피 interface나 람다로 통신을 해야하지만, View를 상속받은 Custom View는 (ex - TextView) 기존에 있는 setOnClickListener같은 리스너들을 이용하면 될줄 알았다.

하지만 예상외로 Activity나 Fragment에서 커스텀된 뷰에 바로 리스너를 꼽았지만 이벤트가 오지 않았고, 상속받은 Class에서 이벤트가 확인되었다. 상속으로 인해 경로가 틀어진 듯하다.

하고픈 말은 ViewGroup이나 View나 외부 객체를 통해서 통신을 해야한다는 뜻이다.

예전엔 람다 리스너를 정의할 때 lateInit으로 했었다. 하지만 객체 유효성을 체크하는 부분에서 if문과 더블콜론과 isInitialized을 사용하니 좀 더러워 지는것 같아 요즘 null check으로 대신하고 있다.

class CustomView1 : ConstraintLayout {
    private var titleClickListener: (() -> Unit)? = null
    
    ...

    private fun setTypeArray(typedArray: TypedArray) {
        binding.run {
            tvTitle.text = typedArray.getString(R.styleable.CustomView1_title)
            tvContent.text = typedArray.getString(R.styleable.CustomView1_content)
            
            tvTitle.setOnClickListener { _ ->
                titleClickListener?.let { it() }
            }
        }
        
        typedArray.recycle()
    }
    
	fun setOnTitleClickListener(listener: () -> Unit) {
    	this.titleClickListener = listener
    }
}

팁 및 주의점

1. 중복 속성 선언

Custom View를 하나만 만들고 끝내면 상관이 없다. 하지만 여러 개의 Custom View를 만들고자 하는데 아마 속성의 역할이 같아 변수 이름이 겹치는 일이 있을 것이다. 예를 들면 제목: title, 부제: sub_title 등 자주 쓰이는 이름들이 있다. 이를 위해 다른 styleable에서 동일한 속성명을 만들어보고 빌드한 결과

대강 하나 이상의 같은 속성을 찾았다는 에러갔다.

에러 로그대로 같은 이름의 속성을 2번이상 정의할 수 없다. 그럼 Custom View마다 고유의 이름을 prefix로 지정해야 할까?
아무리 봐도 이건 아닌 것 같아 방법을 찾아봤다.

1. 상단에 같은 속성명이 있으면 아래 속성은 자동으로 적용된다.

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="CustomView1">
        <attr name="title" format="string" />
    </declare-styleable>
    
    <declare-styleable name="CustomView2">
        <attr name="title" />
    </declare-styleable>
</resources>

해결된 듯 하지만 뭔가 찜찜하다. 좀 더 보기 편하고 관리하기 편한 방법이 하나 더 있다.

2. 공통 속성을 모아둔 xml파일을 생성 후 정의한다.

commom_attr.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <attr name="title" format="string" />
    <attr name="content" format="string" />
</resources>

attr.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="CustomView1">
        <attr name="title" />
        <attr name="content" />

    </declare-styleable>
    <declare-styleable name="CustomView2">
        <attr name="title" />
        <attr name="content" />
    </declare-styleable>
</resources>

훨씬 유지보수가 편해보인다. 이제 속성이 중복되도 format만 같으면 편히 쓸 수 있다.

2. Custom View는 View의 state를 담고 있으면 안된다.

Custom View는 이름 그대로 View의 역할만을 수행해야 한다. 데이터를 받으면 UI에 표시하는 것 외에 나머지는 비즈니스 레이어에서 처리해야 한다.

View의 state를 가지고 있는 상황에서의 단점은 Custom View는 여러 곳에서 쓰일 가능성이 매우 높은 Class이다. 레이아웃 구조만 같지 데이터를 나타내는 로직은 충분히 달라질 수 있기 때문에 state관련한 코드를 분기하는 순간부터 코드의 가독성은 떨어지고 말 것이다.

또 하나는 View에게 UI제어권을 넘겨주면 안된다. 무슨 말이냐면 한 Custom View가 시간 정보를 받아 표시하는 역할을 한다고 치자.
2개 중 1개는 시간을 나타내고, 다른 1개는 시각을 나타낸다.

이를 편하게 한답시고 Custom View 내부에 함수를 등록한다.

단편적으로만 보면 매우 편해보인다. 근데 갑자기 다른 포맷의 유형의 데이터가 들어온다고 하면 위와 같은 함수를 하나 더 작성해야한다. 이와 같은 일이 많아질수록 관리도 힘들어질 뿐더러 혼동을 유발할 상황도 오게 된다.

위 코드는 view하나에 데이터를 넣는 구조지만, 여러개의 view가 덧붙혀질 수도 있을 것이다.

이를 방지하기 위해 Activity나 Fragment에서 presenter 또는 viewmodel에서 가공된 데이터만을 custom view에 주입시키는 것을 권장한다.

전체 코드

class CustomView1 : ConstraintLayout {
    private var titleClickListener: (() -> Unit)? = null

    constructor(context: Context) : super(context) {
        initView()
    }

    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
        initView()
        getAttrs(attrs)
    }
    private val binding: CustomView1Binding by lazy {
        CustomView1Binding.bind(
            LayoutInflater.from(context).inflate(R.layout.custom_view_1, this, false)
        )
    }
    private fun initView() {
        addView(binding.root)
    }

    private fun getAttrs(attrs: AttributeSet) {
        val typedArray = context.obtainStyledAttributes(attrs, R.styleable.CustomView1)
        setTypeArray(typedArray)
    }

    private fun setTypeArray(typedArray: TypedArray) {
        binding.run {
            tvTitle.text = typedArray.getString(R.styleable.CustomView1_title)
            tvContent.text = typedArray.getString(R.styleable.CustomView1_content)

            tvTitle.setOnClickListener { _ ->
                titleClickListener?.let { it() }
            }
        }
        typedArray.recycle()
    }
}
profile
Android Developer
post-custom-banner

0개의 댓글