UI 스레드

안드로이드에서 UI의 조작은 메인 스레드에서만 가능하게 되어있다. 왜냐면 여러 스레드에서 동시에 UI를 변경하려고 하면, 결과를 예측할 수 없기 때문이다. 그래서 메인 스레드를 UI 스레드라고 부르기도 한다.

UI 충돌

스레드 통신

멀티 스레드 환경에서 작업 스레드의 결과를 UI에 반영하려면 어떻게 해야 할까? 메인 스레드로 작업을 전달하는 것을 생각해볼 수 있다. 즉, 스레드 간 통신이 필요한데, 이것을 Looper와 Handler를 통해서 해결할 수 있다.

Looper

Looper는 하나의 스레드에 연결되어, 스레드의 MessageQueue에 들어오는 Message를 관리하는 객체를 말한다.

 

Message는 또 뭘까? Message는 스레드가 주고 받는 데이터 조각으로, 작업 내용과 필요한 데이터를 담을 수 있다.

 

Looper는 이름처럼 반복문을 돌며 스레드의 MessageQueue에 Message가 들어오는지 확인한다. Message가 들어오면, Message의 처리를 누군가에게 맡기는데, 그 누군가를 Handler라고 한다. Handler는 말 그대로 Message를 다루는 객체이다.

Handler

Handler는 스레드의 메시지큐에 Message를 적재하고, Looper의 호출로 Message를 처리하는 역할을 한다. Handler는 오직 하나의 스레드와 메시지큐에 연결된다.

스레드 통신 과정

스레드 통신 과정 (메인 스레드←작업 스레드)

  1. 작업 스레드에서 결과물을 담은 Message를 생성한다.
  2. 메인 스레드에 연결된 Handler의 sendMeesage(Message)로 메인 스레드에 메시지를 전송한다
  3. Handler가 메인 스레드의 MessageQueue에 Message를 적재한다.
  4. Looper가 Message를 확인한다. Handler.handleMessage(Message)를 호출하여 Handler에게 메시지 처리를 맡긴다.

Message와 Runnable

스레드 간 통신은 Message로 이뤄진다고 했는데, Runnable을 통해서도 가능하다. 즉, Message 전송, Runnable 전송 2가지 방식이 있다. Message는 Handler.sendMessage(Message), Runnable은 Handler.post(Runnable)로 전송한다.

Thread + Looper 생성 방법

1. 기본 Thread 생성, Handler에 Looper 암시적 연결

var handler: Handler? = null
val thread1 = Thread {  // Runnable 익명 객체 구현
    Looper.prepare()
    handler = Handler() // 생성한 스레드의 Looper와 MessageQueue에 암시적으로 연결된다 
    Looper.loop()
}
thread.start()
  • 스레드를 생성하고, Looper.prepare()로 Looper와 MessageQueue를 생성한다.
  • handler = Handler()를 실행하면 Handler를 생성한 스레드의 looper와 MessageQueue에 Handler가 연결된다.
  • Looper.loop()를 실행하면 looper가 MessageQueue를 돌기 시작한다.

이렇게 하면 외부 스레드에서 handler를 통해 thread1에게 메시지를 보낼 수 있다.

하지만 위 Handler->Looper 연결법은 암시적인 방법이라 deprecated 되었다. 

2. HandlerThread 생성, Handler에 Looper 명시적 연결

val thread2 = HandlerThread("Handler Thread2")
var handler = Handler(thread2.looper)
  • HandlerThread는 Looper를 기본적으로 탑재하고 있는 스레드이다.
    • Thread는 Looper를 갖지 않을 수도 있다. 통신이 필요 없는 스레드도 있기 때문이다. 1번 예제의 Looper.prepare()가 Looper를 생성하는 과정이다.
  • 외부에서 Handler를 만들 때 명시적으로 thread2의 Looper를 전달하여 명시적으로 Handler를 Thread2에 연결하고 있다.

3. 작업 스레드에서 메인 스레드용 Handler 생성

val thread3 = Thread {
    val handler = Handler(Looper.getMainLooper())
    handler.post { 
        // Runnable 
    }
}
thread3.start()
  • 작업 스레드에서 메인 스레드에 작업을 전달하기 위한 Handler를 생성했다.
  • Looper.getMainLooper(): 메인 스레드의 Looper를 가져온다.
  • handler.post(Runnable): 메인 스레드에 실행 블록을 전송한다.

참조

결과 미리보기

뷰페이저 미리보기 + 애니메이션

1. 가운데 페이지(currentItem)의 width 설정하기

ViewPager2로 좌우 미리보기를 구현할 때, ViewPager2의 width는 가운데 페이지가 차지하는 너비가 된다. 그럼 좌우 페이지는 어떻게 되는 걸까? 일단 계속 따라가보자. 

<androidx.viewpager2.widget.ViewPager2
    android:id="@+id/view_pager_poster"
    android:layout_width="0dp"
    android:layout_height="0dp"
    android:clipChildren="false"
    app:layout_constraintWidth_percent="0.725"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintHeight_percent="0.45"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@id/tv_hint" />

ConstraintLayout을 사용해서 width를 전체 레이아웃의 72.5%로 설정했다.

2. 좌우에 페이지가 보이도록 설정하기

2-1. clipChildren="false"

ViewPager2 속성 중 clipChildren="false"가 눈에 띈다. 문서에 "Defines whether a child is limited to draw inside of its bounds or not."라고 설명되어 있다. Child가 그려지는 공간을 ViewGroup의 영역 내로 제한할 것인지를 의미한다. 기본 값은 true이며, 제한한다는 뜻이다.

False로 설정하면 ViewPager2의 영역을 넘어 양쪽에 페이지가 그려질 수 있다.

 

(비슷한 속성으로 clipToPadding이 있는데, 자식 뷰의 영역을 ViewGroup의 padding 안쪽으로 제한할 것인지를 묻는다. 기본 값은 true이며, 자식 뷰의 그림자 같은 모서리 효과도 패딩에 의해 잘리게 된다.)

2-2. offscreenPageLimit

clipChildren="false"를 설정해고 어댑터에 데이터를 연결해도 양쪽에 페이지가 보이지 않는다. 왜냐면 ViewPager 하위 View 계층에 child가 하나씩만 유지되기 때문이다. 슬라이드를 하면 다음 View가 계층에 추가되고, 이전 View는 사라진다. (Adapter의 ViewHolder는 남아있다)

 

이럴 때 offscreenPageLimit 속성을 사용한다. offscreenPageLimit는 현재 페이지에서 좌우(상하) 각각 몇 개의 View를 유지할 것인지 결정한다. 화면에 보이지 않더라도 View가 생성되어 ViewPager2 하위 계층에 추가된다.

binding.viewPagerPoster.offscreenPageLimit = 2

현재 페이지를 제외하고 왼쪽에 2개, 오른쪽에 2개의 페이지가 미리 로딩된다.

 

여기까지 하면 이런 화면을 볼 수 있다.

좌우 미리보기

3. 좌우 여백 두기

딱 붙어 있는 게 별로라서 페이지의 좌우에 여백을 두려고 한다. ViewPager의 MarginPageTransformer를 활용한다. 문서에는 아래와 같이 쓰여있다.

Adds space between pages via the ViewPager2.PageTransformer API.
Internally relies on View.setTranslationX and View.setTranslationY.
// ...
public MarginPageTransformer(@Px int marginPx) { /*..*/ }

여백을 두는 API이고, 내부적으로는 translation 속성을 사용한다고 한다. PagerTransformer는 페이지를 슬라이드 할 때마다 동작하는데, 슬라이드 할 때마다 각 View의 좌표를 원래 위치에서 Margin만큼 이동시키는 듯 하다.

binding.viewPagerPoster.setPageTransformer(
	MarginPageTransformer(
		resources.getDimensionPixelOffset(R.dimen.game_poster_margin)
    )
)

4. 확대/축소 애니메이션 적용하기

이번에도 PageTransformer를 사용한다. PageTransformer는 슬라이드 할 때마다 동작하고, 좌우 페이지가 현재 페이지로 슬라이드 되면 확대하고 좌우 페이지로 이동하면 축소해야 하기 때문이다.

MarginPageTransformer도 사용해야 해서, 여러 PageTransformer를 혼합할 수 있는 CompositePageTransformer를 사용한다. 

CompositePageTransformer().also {
    it.addTransformer(
        MarginPageTransformer(resources.getDimensionPixelOffset(R.dimen.game_poster_margin))
    )
    it.addTransformer { eachPageView: View, positionFromCenter: Float ->
        val scale = 1 - abs(positionFromCenter)
        eachPageView.scaleY = 0.85f + 0.15f * scale
    }
}

2번째의 addTransformer 문장이 확대/축소 애니메이션이다.

  • positionFromCenter: 가운데(current) 페이지와의 상대적 위치를 뜻한다. 0이면 가운데 페이지, -1이면 왼쪽으로 페이지 하나만큼, +1이면 오른쪽으로 페이지 하나만큼 떨어져 있음을 의미한다. 따라서, 페이지의 위치가 가운데와 가까워질수록 abs(positionFromCenter) = 0에 가까워진다.
  • val scale = 1 - abs(positionFromCenter): 가운데 페이지에 가까워질수록 1, 멀어질수록 0, 더 멀어지면 음수가 된다
  • eachPageView.scaleY = 0.85f + 0.15f * scale: 가운데 페이지는 1, 좌우 페이지는 0.85의 scale을 갖게 된다. 따라서, 미리 보이는 좌우 페이지는 85%만큼 작게 보이며, 가운데로 슬라이드 될수록 1로 커진다.

5. ViewPager2 최종 코드

<androidx.viewpager2.widget.ViewPager2
            android:id="@+id/view_pager_poster"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:clipChildren="false"
            app:layout_constraintWidth_percent="0.725"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHeight_percent="0.45"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/tv_hint" />

binding.viewPagerPoster.run {
    removeOverScroll()
    adapter = Adapter()
    offscreenPageLimit = 2
    setPageTransformer(buildPageTransformer())
	registerOnPageChangeCallback(onPageChange)
}

private fun buildPageTransformer() = CompositePageTransformer().also {
    it.addTransformer(
        MarginPageTransformer(resources.getDimensionPixelOffset(R.dimen.game_poster_margin))
    )
    it.addTransformer { eachPageView: View, positionFromCenter: Float ->
        val scale = 1 - abs(positionFromCenter)
        eachPageView.scaleY = 0.85f + 0.15f * scale
    }
}

6. 결과

뷰페이저 미리보기 + 애니메이션

 

https://leetcode.com/problems/rotate-image/

 

Rotate Image - LeetCode

Level up your coding skills and quickly land a job. This is the best place to expand your knowledge and get prepared for your next interview.

leetcode.com

 

문제) n x n 배열의 시계방향 90도 회전

제한 조건) 입력 외의 2차원 배열은 사용하지 않는다

풀이)

 

(1) 행렬을 전치(행과 열 교환)시킨다 그리고 (2) 대칭되는 열들을 교환한다. 그러면 시계방향으로 90도 회전한 모습이 된다. 이렇게 풀 수 있는 이유는 입력 배열 안에서 회전시키려면 배열 내 원소들을 교환하는 방식으로 값을 바꿔야 하는데, 전치와 대칭열 교환 모두 그렇게 동작하기 때문이다.

코드

class Solution {
    fun rotate(matrix: Array<IntArray>): Unit {
        transpose(matrix)
        reflect(matrix)
    }
    
    // 행렬 전치(행 <-> 열)
    fun transpose(matrix: Array<IntArray>) {
        for (row in 0 until matrix.lastIndex) {
            for (col in row + 1 until matrix.size) {
                val temp = matrix[row][col]
                matrix[row][col] = matrix[col][row]
                matrix[col][row] = temp
            }
        }
    }
    
    // 대칭 col 간 스위칭
    fun reflect(matrix: Array<IntArray>) {
        for (col in 0 until matrix.size / 2) {
            for (row in matrix.indices) {
                val temp = matrix[row][col]
                matrix[row][col] = matrix[row][matrix.lastIndex - col]
                matrix[row][matrix.lastIndex - col] = temp
            }
        }
    }
}

https://leetcode.com/problems/odd-even-linked-list/

 

Odd Even Linked List - LeetCode

Level up your coding skills and quickly land a job. This is the best place to expand your knowledge and get prepared for your next interview.

leetcode.com

풀이

문제)

  • 입력: 단방향 연결리스트가 주어진다.
  • 요구사항: 홀수 인덱스 노드들을 앞으로, 짝수를 뒤로 보내라
  • 조건:
    1. 인덱스는 1부터 시작한다
    2. 각 그룹 안에서의 순서는 처음 입력 그대로 유지한다
    3. 공간 복잡도: O(1) -> 새 배열을 선언하지 않고 변수 몇 개만 사용 하라는 것 같다.
    4. 시간 복잡도: O(n)

아이디어)

  • odd 연결리스트, even 연결리스트를 만들고, odd 연결리스트의 끝을 even 연결리스트 헤드에 연결한다.
  • 새로운 연결리스트의 생성은 포인터 변수(head, tail)만 새로 생성하면 된다.
    • odd 헤드, odd 테일, even 헤드, even 테일 4개의 변수가 필요하다.

절차)

  1. odd 헤드,테일이 1번째를, even 헤드,테일은 2번째 노드를 가리킨다.
  2. 인덱스에 따라서 odd/even tail.next가 현재 노드를 가리키도록 바꾸고, tail도 현재 노드를 가리키도록 업데이트 한다
  3. 반복문이 끝나면 각 tail.next를 null로 바꾸고, odd의 tail을 even의 head에 연결한다.

코드

/**
 * Example:
 * var li = ListNode(5)
 * var v = li.`val`
 * Definition for singly-linked list.
 * class ListNode(var `val`: Int) {
 *     var next: ListNode? = null
 * }
 */

class Solution {
    fun oddEvenList(head: ListNode?): ListNode? {
        var oddHead: ListNode? = head
        var oddTail: ListNode? = head
        var evenHead: ListNode? =  head?.next ?: null
        var evenTail: ListNode? = head?.next ?: null
        var currentNode: ListNode?  = evenTail?.next ?: null
        var idx = 3 // 현재 노드의 인덱스, 1부터 시작
      
        while (currentNode != null) {
            if (idx++ % 2 == 1) { // 홀수 인덱스
                oddTail?.next = currentNode
                oddTail = currentNode
            } else { // 짝수 인덱스
                evenTail?.next = currentNode
                evenTail = currentNode
            }
            
            currentNode = currentNode.next
        }
        
        oddTail?.next = evenHead
        evenTail?.next = null // 이걸 안해주면 노드가 홀수 개 있을 때 evenTail이 oddTail을 가리키고 있어서 연결리스트에 사이클이 생긴다.
        
        return oddHead
    }
}

+ Recent posts