Kotlin - filterIsInstance with reflection (NumberPicker text color)

WindSekirun (wind.seo)·2022년 4월 26일
0

이 글은 기존 운영했던 WordPress 블로그인 PyxisPub: Development Life (pyxispub.uzuki.live) 에서 가져온 글 입니다. 모든 글을 가져오지는 않으며, 작성 시점과 현재 시점에는 차이가 많이 존재합니다.

작성 시점: 2017-09-02

최근 개발하면서 NumberPicker 를 사용하는 일이 잦아졌는데, NumberPicker의 텍스트 색상까지 변경할 수 있어야 했었다.

대충 인터넷에 찾아본 바로는 선택된 텍스트 색상은 NumberPicker의 child 중에서 EditText 인 것을 찾아 텍스트 색상을 설정하면 되고, 선택되지 않은 부분(바퀴 부분) 은 mSelectorWheelPaint 라는 private field 에 접근하여 수정한다는 것 같다.

1. 기존에는?

보통 자바로 짜면, 아래와 비슷한 코드가 나올 것이다.

public class TestPicker extends NumberPicker{
    public TestPicker(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public TestPicker(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    public void setTextColor(int color) {
        int count = getChildCount();

        for (int i = 0; i < count - 1; i++) {
            View view = getChildAt(i);
            if (view instanceof EditText) {
                try {
                    EditText editText = (EditText) view;
                    editText.setTextColor(color);

                    Field selectorWheelPaint = NumberPicker.class.getDeclaredField("mSelectorWheelPaint");
                    selectorWheelPaint.setAccessible(true);
                    ((Paint)selectorWheelPaint.get(this)).setColor(color);
                    invalidate();
                } catch (Exception e) {
                    Log.e(TestPicker.class.getSimpleName(), "Reflection failed!");
                }
            }
        }
    }
}

NumberPicker 클래스를 상속받는 클래스를 만들어, setTextColor 란 메소드를 추가적으로 만든다.

setTextColor 안에는 자식의 갯수만큼 반복을 돌려, 해당 View의 실제 타입이 EditText인지 체크해서 텍스트 색상을 설정하거나, mSelectorWheelPaint에 접근해 색상을 설정하는 것이다.

2 .코틀린에서

코틀린에서 instanceOf의 역할을 하는 것은 is인데, 이런 식으로 사용한다.if (view is EditText)

위에 있는 자바를 그대로 변환해보자.

class TestPicker constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : NumberPicker(context, attrs, defStyleAttr) {

    fun setTextColor(color: Int) {
        val count = childCount

        for (i in 0 until count) {
            val view = getChildAt(i)
            if (view is EditText) {
                tryCatch {
                    view.setTextColor(color)

                    val selectorWheelPaint = NumberPicker::class.java.getDeclaredField("mSelectorWheelPaint")
                    selectorWheelPaint.isAccessible = true
                    (selectorWheelPaint.get(this) as Paint).color = color
                    invalidate()
                }
            }
        }
    }
}

지난 글에서 코드를 조금이라도 줄여줄 수 있게 만든 tryCatch 메소드를 사용하고,  until 이라는 infix 메소드를 사용해서 간편하게 표현했다.

3. filterIsInstance 소개

코틀린에도 자바8에서 소개된 Stream API가 있는데, .map,  .filter, .flatMap  등을 전부 제공한다.

이 기능을 사용하여 일일히 for-loop를 돌리지 않아도 처리가 가능하다.

3-1. 그전에 Stream API 가 뭐지?

Stream API는 자바8부터 소개된 기능으로 컬렉션의 요소를 하나씩 참조해서 처리할 수 있게 하는 객체다.

이해하려면 이 글을 보는 것이 아니라 다른 글을 보는 것이 좋을 정도기는 한데, 일단 예제 코드를 짜보자.

A, a, B, b 4개의 문자열이 담긴 ArrayList를 모두 대문자로 바꿔 출력하는 코드이다.

public static void main(String... args) {
        ArrayList<String> list = new ArrayList();
        list.add("A");
        list.add("a");
        list.add("B");
        list.add("b");

        System.out.println("original list");
        for (String item : list) {
            System.out.println(item);
        }
        System.out.println();

        System.out.println("~JDK7, Modify UPPERCASE");
        for (String item : list) {
            System.out.println(item.toUpperCase());
        }

        System.out.println();

        System.out.println("Using Stream API, Modify UPPERCASE");
        list.stream()
                .map(String::toUpperCase)
                .forEach(System.out::println);
    }

출력 결과는 아래와 같다.

original list
A
a
B
b

~JDK7, Modify UPPERCASE
A
A
B
B

Using Stream API, Modify UPPERCASE
A
A
B
B

3-2 조금 더 복잡한 예제

지금은 간단한 예제지만, 만일 아래와 같은 로직을 구현한다고 해보자.

  • 유저 리스트를 데이터베이스로부터 가져온다.
  • 유저 타입이 BANNED 인 유저를 필터링한다.
  • 필터링한 유저 리스트를 오름차순 정렬한다.
  • 한명 한명씩 출력한다.

유저 클래스는 아래와 같다.

 public class User {
        public static final int USER_OK = 0;
        public static final int USER_BANNED = 1;
        
        private int status = USER_OK;
        private long id = 0;
        private String name = "";
        private String email = "";
        public String getName() {
            return name;
        }

        @Override
        public String toString() {
            return "USER{id = " + id + " , name = " + name + " , email = " + email + ")";
        }
}

그러면, 기존 방식대로 구현해보자.

List<User> userList = db.getAllUserList();

ArrayList<User> bannedList = new ArrayList<>();
for (User user : userList) {
    if (user.status == User.USER_BANNED) {
        bannedList.add(user);
    }
}

Collections.sort(bannedList, new Comparator<User>() {
    @Override
    public int compare(User o1, User o2) {
        return o1.getName().compareTo(o2.getName());
    }
});

for (User user : bannedList) {
    System.out.println(user.toString());
}

이번엔 스트림으로 구현해보자.

List<User> userList = db.getAllUserList();

userList.stream()
        .filter(user -> user.status == User.USER_BANNED)
        .sorted(Comparator.comparing(User::getName))
        .forEach(System.out::println);

조금 그림과 같이 설명하자면...

아이디어 출처: Processing Data with Java SE 8 Streams, Part 1 by Raoul-Gabriel Urm (http://www.oracle.com/technetwork/articles/java/ma14-java-se-8-streams-2177646.html)

  • 맨 위에서는 데이터베이스에서 가져온 유저 전체 리스트가 있다.
  • 그 다음, filter 로 type가 USER_BANNED 인 유저만 가져온다.
  • 그다음, Comparator.comparing 로 유저의 이름을 기준으로 정렬한다.
  • 마지막으로 한 개씩 꺼내서 순서대로 출력한다.

이전까지 구현했던 코드에 비해 많이 간결해졌음을 알 수 있다.

이처럼 스트림은 처리 코드를 람다로 제공하고, 중간 과정이 별도로 있어 변수를 추가로 선언하지 않아도 사용이 가능하다는 장점이 있다.

그 외에도 병렬 처리 프로그래밍에서 동시성에 대한 보장을 해주기도 한다.


... 이제 본론으로 돌아와서, 코틀린의 스트림(?)도 자바 스트림과 비슷하다.

다만 List의 확장 메소드로 제공하기에 사용하기 전에 Stream 객체를 얻을 필요와 없다는 점과 다양한 필터를 제공한다는 점에서 약간 다르다.

filterIsInstance 는 말 그대로 Instance, 즉 주어진 객체의 실제 타입을 체크해서 맞는 객체만 리턴한다.

자바라면, .filter(user -> user instanceof User) 정도가 된다.

실제 사용할 때에는 .filterIsInstance<Int>() 식으로 체크하고 싶은 타입을 선언하면 된다.

4. 실제로 사용해보자.

class TestPicker constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : NumberPicker(context, attrs, defStyleAttr) {
    fun setTextColor(color: Int) {
        val count = childCount

        (0 until count)
                .map { getChildAt(it) }
                .filterIsInstance<EditText>()
                .forEach {
                    tryCatch {
                        it.setTextColor(color)
                        val selectorWheelPaint = NumberPicker::class.java.getDeclaredField("mSelectorWheelPaint")
                        selectorWheelPaint.isAccessible = true
                        (selectorWheelPaint.get(this) as Paint).color = color
                        invalidate()
                    }
                }
    }
}

(0 until count) 라는 조금 특별한 문구가 보이는데, 0 에서 부터 count - 1 까지의 IntRange 를 리턴한다.

IntRange는 Itreable 를 구현하고 있어 Iterable.map의 사용이 가능하다.

원본 코드는 아래와 같다.

/**
 * Returns a range from this value up to but excluding the specified [to] value.
 * [this] 로 주어진 값부터 [to] 로 주어진 범위를 리턴하는데, value 자체는 제외한다.
 * 
 * If the [to] value is less than or equal to [Int.MIN_VALUE] the returned range is empty.
 * 만일 [to] 가 Int.MIN_VALUE 보다 적거나 같을 경우, 리턴되는 Range는 비어있습니다.
 */
public infix fun Int.until(to: Int): IntRange {
    if (to <= Int.MIN_VALUE) return IntRange.EMPTY
    return this .. (to - 1).toInt()
}

그다음 map로 자식들의 리스트를 넣고, filterIsInstance로 타입이 EditText 인 것을 찾아 forEach로 하나씩 수행한다.

그 뒤는 변환한 코틀린 코드와 다를 바가 없어진다.

마무리

내가 코틀린을 적극적으로 쓰게 된 이유중 하나가 당시 안드로이드는 Java 8 language feature 지원이 매우 빈약했기에 나름대로 대안을 찾은 것이다.

그나저나, 정작 글의 키워드였던 filterIsInstance 보다도 스트림 소개 글이 더 길어진 것 같은 기분이다(..)

profile
Android Developer @kakaobank

0개의 댓글