[android] main looper 구현

개요top

메인 쓰레드를 이용하는 가장 빠른 방법은 뭘까요?
오래 전부터 어떤 단일 쓰레드가 특별한 권한을 갖고 있는 경우 다른 쓰레드에서 이 쓰레드에게 일을 시키기 위한 멀티쓰레드 패턴은 몇 가지로 정해진 편입니다.
그 중에 유명한 것으로는 워커쓰레드 패턴이 있고 프로듀스컨슈머 패턴이 있습니다. 이 두 가지 패턴은 워낙에 자주 쓰이는 지라 안드로이드에서도 기본으로 제공합니다. 핸들러의 post메소드에 Runnable을 보낸다면 이는 사실 워커쓰레드 패턴과 같은 원리로 동작하고 sendMessage를 통해 메세지를 보낸다면 이는 프로듀스컨슈머 패턴을 사용하는 것이라 볼 수 있죠. 하지만 이 모든 것들은 여러가지 부하를 일으킵니다.
패턴의 구조 상 synchronized블록을 최소화하여 동기화 구간을 줄여주긴 하지만 그 대신 스케쥴러, 큐검사기, 부가적인 객체구조물을 다수 필요로 하게 됩니다.
이 정도로는 네트웍 대기 후 UI일괄 업데이트 정도에는 쓸만하겠지만 프레임 단위의 반응성이나 애니메이션 레벨의 섬세한 타이밍 컨트롤을 제어하기에는 느립니다.
사실 앱의 최적화는 일반적으로 초반부터 진행할 이유는 없습니다만 애니메이션처럼 프레임당 엄청나게 많은 처리를 하는 경우는 다릅니다.
보다 빠르고 기민하게 반응하게 만들 방법이 필요합니다. 이는 Read-Write Lock패턴을 이용하는 메인루퍼 구현으로 시도해 볼만한 주제입니다.

렌더루프top

왜 모바일 OS들이 UI를 그리는 메인쓰레드를 별도 지정하고 여기에 블록킹이 일정 시간 이상 발생하면 무조건 앱을 죽이려고 할까요.
간단하게 말해 렌더링 성능 정확하게는 렌더링을 담당하는 쓰레드가 더 많이 루프를 돌 수 있게 보장하기 위해서입니다. 그게 바로 사용자가 느끼는 고성능의 쾌적한 OS이기 때문이죠. 이 기법은 단지 모바일 OS의 문제가 아닙니다.
보통의 게임엔진도 최대한의 타격감을 내기 위해 그래픽 렌더링에 최대한의 머신 파워 즉 쓰레드 우선 순위를 몰아주려고 하기 때문에 과거 멀티쓰레드가 아니었던 시절부터 ‘빈자의 쓰레드’라는 기법을 사용해왔습니다.

이러한 오래된 기법으로부터 고성능의 범용 메인쓰레드 루프를 만들어내는 방법을 착안할 수 있습니다. 바로 렌더링 시스템 그 자체를 렌더 루프로 사용하는 거죠.
아래와 같은 간단한 커스텀 뷰를 정의해보죠.

private class Ani(ctx: Context): View(ctx){
  override fun onDraw(canvas: Canvas?){
    //something      
    invalidate()
  }
}

이 간단해 보이는 커스텀뷰는 실제 onDraw에서 아무 일도 하지 않지만 invalidate를 호출하기 때문에 지속적으로 실행됩니다. 이 실행 빈도는 초당 60프레임에 근접한 성능을 보이게 됩니다. 그 뿐만이 아닙니다.
이 코드는 절대로 렌더루프에서 실행되기 때문에 별도의 장치가 없어도 메인쓰레드에서 UI를 업데이트할 수 있게 됩니다.
우리는 그저 이 객체가 실행할 리스트를 넘겨주는 것으로도 충분히 그 일을 할 수 있습니다.

이 Ani라는 작은 뷰를 액티비티에 삽입하면 그것만으로 오직 메인쓰레드에서 고성능으로 작동하는 라이브러리를 만들 수 있는 셈입니다.
보통 뷰객체는 컨텍스트를 물고 태어나기 때문에 액티비티 수준에서 새롭게 만들어지면 기존 뷰는 자동으로 파기해야 합니다.
다라서 저 Ani객체 자체에 로직을 탑재하는 것은 어리석은 짓이죠. Ani와 관계를 맺고 액티비티에 Ani를 넣어주는 클래스를 만드는 편이 안전합니다.
이제 이 클래스를 Looper라고 하고 간단히 작성해보죠.

루퍼top

우선 루퍼는 여러 개의 아이템을 루프 돌면서 매 프레임마다 실행해주는 기능을 수행합니다. 여기까지만 표현하면

class Looper{
  private val lock =  ReentrantReadWriteLock()
  private val items = mutableListOf<Item>()

  fun add(item:Item){
    lock.write{items += item}
  }
  fun loop(){
    if(items.isEmpty()) return
    lock.read{
      var i = 0
      while(i < items.size) {
        val item = items[i++]
        //item something
      }
    }
  }
}

요 정도로 나타낼 수 있습니다. List의 동시성을 확보하기 위해 ReentrantReadWriteLock을 사용하고 있습니다. 코틀린에서는 이 lock객체에 대해 편리한 read, write 확장 함수를 제공하고 있으므로 코드를 훨씬 간결하게 작성할 수 있죠. 이제 이 루퍼를 위에서 만들었던 뷰객체와 연결해주는 activity 설정 메소드를 달아줍니다.

class Looper{
  private val lock =  ReentrantReadWriteLock()
  private val items = mutableListOf<Item>()

  fun add(item:Item){
    lock.write{items += item}
  }
  fun loop(){
    if(items.isEmpty()) return
    lock.read{
      var i = 0
      while(i < items.size) {
        val item = items[i++]
        //item something
      }
    }
  }
  fun act(act: AppCompatActivity){
    val root = act.window.decorView as ViewGroup
    if(root.findViewWithTag<Ani>("@@@ANI") != null) return
    root.addView(Ani(act, this))
  }
}

마지막의 act함수를 보면 중복 등록을 막기 위해 @@@ANI라는 태그로 검색을 때리고, 없으면 데코뷰에 간단히 Ani뷰를 넣어줍니다.
이때 인자로 루퍼를 보내주므로 Ani에서는 루퍼의 loop메소드를 호출할 수 있게 됩니다. 최종적으로 정리하면 다음과 같습니다.

class Looper{
  private class Ani(ctx: Context, val looper:Looper): View(ctx){
    init{tag = "@@@ANI"}
    override fun onDraw(canvas: Canvas?){
      looper.loop()
      invalidate()
    }
  }

  private val lock =  ReentrantReadWriteLock()
  private val items = mutableListOf<Item>()

  fun add(item:Item){
    lock.write{items += item}
  }
  fun loop(){
    if(items.isEmpty()) return
    lock.read{
      var i = 0
      while(i < items.size) {
        val item = items[i++]
        //item something
      }
    }
  }
  fun act(act: AppCompatActivity){
    val root = act.window.decorView as ViewGroup
    if(root.findViewWithTag<Ani>("@@@ANI") != null) return
    root.addView(Ani(act, this))
  }
}

이제 모든 구조는 확립되었으니 Item의 블록 정의만 하면 되겠네요.

Item추가와 삭제top

Item의 추가하는 것은 간단히 루퍼의 add메소드로 가능합니다만 삭제는 언제 되는 것이 좋을까요?
만약 remove도 루퍼의 메소드가 되려면 add시점에 레퍼런스를 전달하여 그 레퍼런스를 외부에서 유지해야만 remove의 인자로 보낼 수 있을 것입니다.
이러한 번거로움을 줄이고 add한 Item스스로가 삭제되게 하려면 Item이 반환하는 반환값에 따라 삭제할지 지속할지를 결정할 수 있게 하면 됩니다.

typealias ItemBlock = ()->Boolean

이렇게 정의했다면 true일 때는 계속 looper안에 남겨두고 false면 제거하는 것으로 정해보죠. 이제 loop메소드의 마지막 부분을 채울 수 있습니다.

fun loop(){
  val removed = mutableListOf<Item>()
  if(items.isEmpty()) return
  lock.read{
    var i = 0
    while(i < items.size) {
      val item = items[i++]
      if(!item()) removed += item
    }
  }
  if(removed.size > 0) lock.write{
    items -= removed
  }
}

이 코드에서는 lock을 최소화하기 위해 removed를 지역객체로 선언하고 여기에 쭉 삭제될 item을 담아 write락 상태에서 일거에 제거하게 됩니다.
이제 이를 활용하여 다양한 코드를 작성할 수 있습니다.

var looper = Looper()
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?){
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        //1
        looper.act(this)
        //2
        target.translationX = 0.0;
        looper.add add@{
            if(layout.width > 0){
               if(target.translationX < 100){
                 target.translationX += 1.0
               }else{
                 return@add false
               }
            }
            return@add true
        }
    }
}

보통 액티비티에서 oncreate에 setContentView를 해도 onMeasure에 의한 계산이 다 끝난 상태가 아니라서 조사해보면 아직 화면의 크기가 0입니다.
위 코드에서는 두 개의 뷰 객체를 미리 id로 선언해두었습니다.

  1. layout – 전체를 감사는 contraint layout
  2. target – 작은 사각형 박스

이제 코드의 //1번 부분에서 루퍼의 activity를 초기화하는 것을 볼 수 있습니다. 이 시점에 Ani가 데코뷰에 삽입되어 루퍼가 작동하기 시작합니다.
//2번 부분에서 우선 레이아웃의 실제 크기가 확보될 때까지 기다리다가 박스를 100이 될 때까지 옮긴 뒤 루프에서 빠져나가는 것을 볼 수 있습니다.

결론top

이 방식의 루퍼 구현은 사실 거의 모든 OS에서 통용됩니다. 여기에는 아직 다양한 주제가 남아있습니다. 라이프 사이클과의 연동이나 루퍼가 사용하는 Item에 적용될 다양한 서비스와 애니메이션 관련 기능 등이죠. 이것과 관련된 내용은 또 다른 포스팅에서 다루도록 하겠습니다(..과연..)