[android] canvas기반의 Drawable 활용

개요top

안드로이드 공식문서에는 Drawable을 상속하여 canvas에 직접 그리는 예제가 나옵니다.

맞춤 드로어블

결국 이 방법을 이용하면 draw메소드에서 받아온 canvas에 Canvas API를 이용해서 자유롭게 그림을 그리게 되므로 구형 OS에서도 Vector의 도움없이 자유롭게 복잡하고 아름다운 배경을 만들어낼 수 있습니다.

하지만 Canvas의 API는 복잡하므로 이를 추상층으로 래핑하고 뷰모델을 노출하여 뷰모델 렌더를 통해 그림을 그릴 수 있게 개조해보죠.

본 글의 내용은 사실 HTML의 canvas나 iOS의 canvas에서도 그대로 응용할 수 있습니다. canvas는 사실 거의 모든 구현체가 비슷한 API로 되어있기 때문이죠.

기본 골격top

위 링크의 예제를 따라 기본적인 골격을 작성해봅니다.

interface Observer{
  fun observe()
}
class CanvasDrawable:Drawable(), Observer{
  override fun draw(canvas: Canvas) {
    val width = bounds.width()
    val height = bounds.height()
  }
  override fun observe() = invalidateSelf()
  override fun setAlpha(alpha: Int){}
  override fun getOpacity() = PixelFormat.OPAQUE
  override fun setColorFilter(colorFilter: ColorFilter?){}
}

우선 Drawable을 상속하면 기본적으로 setAlpha, getOpacity, setColorFilter 세 가지 메소드를 구상해야 합니다. 이에 더해 추가될 뷰모델로부터 업데이트를 수신하기 위해 Observer인터페이스를 추가하고 observe에서는 invalidateSelf()를 통해 화면을 재갱신하게 합니다.

이제 뷰모델과 뷰모델을 소유할 수 있게 개조해보죠. 그 전에 뷰모델의 골격을 살펴보겠습니다.

뷰모델의 골격top

우선 뷰 모델은 간단히 map을 델리게이터로 하여 구현하는 것으로 리플렉션을 대체하게 하고, flush를 통해 한 번에 observer에게 통보할 수 있게 간단히 구현합니다.

class KV<T:Any>(private val k:String, private var value:T, private val map:MutableMap<String, Any>){
  init{map[k] = value}
  operator fun getValue(ref:Any?, prop: KProperty<*>) = value
  operator fun setValue(ref:Any?, prop: KProperty<*>, v:T){
    map[k] = v
    value = v
  }
}

abstract class VM{
  val map = mutableMapOf<String, Any>()
  val observer = mutableSetOf<Observer>()
  protected fun flush() = observer.forEach{it.observe()}
  protected fun <T:Any> kv(k:String, v:T) = KV(k, v, map)
}

위의 구현에서 VM이 사용할 속성용 델리게이터인 KV는 생성 시, 키, 값, 그리고 VM의 map을 받아들여 값을 갱신하는 경우 map을 같이 갱신해주도록 합니다.
VM에서는 kv함수를 제공하여 간단히 델리게이터를 등록해줄 수 있게 하고 observer를 공개해 등록삭제를 열어줍니다. 마지막으로 flush를 통해 일괄 통보를 가능하게 하죠.

이제 간단한 뷰모델을 하나 만들어볼까요.

//생성시는 델리게이터를 통해 생성
object Circle:VM(){
  var radius by kv("R", 10.5)
  var x by kv("X", 0.0)
  var y by kv("Y", 12.0)
}

//속성은 자유롭게 수정
Circle.x = 5.0
Circle.y = 5.0

//리플렉션 대신 map을 조사하면 됨
Circle.map.forEach{(k, v)->print("$k, $v")}
//R 10.5
//X 5.0
//Y 5.0
//출력되는 키는 필드명이 아니라 kv에 등록된 키의 이름

이를 통해 리플렉션없이 완전히 자유롭게 뷰모델을 작성하고 이를 이용할 수 있게 되었습니다. 이걸 위에서 구현했던 CanvasDrawble에 반영해보죠.

뷰모델과 연동top

우선 코드부터 볼까요.

class CanvasDrawable(
    private val r:Int, 
    private val g:Int, 
    private val b:Int
):Drawable(), Observer{
  private val vm = mutableMapOf<String, VM>()
  operator fun get(k:String) = vm[k]
  operator fun set(k:String, v:VM){
    v.observer += this
    vm[k] = v
  }
  operator fun minusAssign(k:String) = vm[k]?.let{
    it.obserser -= this
    vm -= k
  }
  override fun draw(canvas: Canvas) {
    val width = bounds.width()
    val height = bounds.height()
    canvas.drawRGB(r, g, b)
    vm.values.forEach {
      canvas.save()
      it.map.forEach {(k, v)->

      }
      canvas.restore()
    }
  }
  override fun observe() = invalidateSelf()
  override fun setAlpha(alpha: Int){}
  override fun getOpacity() = PixelFormat.OPAQUE
  override fun setColorFilter(colorFilter: ColorFilter?){}
}

업데이트된 코드는 몇 가지 파트로 이뤄져 있는데 우선 뷰모델과 관련되어

  1. vm이라는 필드가 추가되고 VM을 소유할 맵을 지정합니다.
  2. 이에 따라 외부에서 VM을 조회 추가 삭제할 오퍼레이터 get, set, minusAssign을 구현했는데
  3. 특히 set과 minusAssign에서는 해당 뷰모델의 옵져버로 본인을 등록하거나 해제하는 작업을 합니다.

이로써 CanvasDrawable은 여러 뷰모델을 소유하고 그 뷰모델 중 어느 하나에게라도 flush가 발생하면 invalidateSelf가 발생하게 될 것입니다.
두 번째는 draw의 골격이 나와있는데

  1. 우선 전체를 생성자에서 받은 배경색으로 칠해서 초기화를 합니다.
  2. 그리고 뷰모델을 순회하면서
  3. 캔버스의 상태를 초기화하고
  4. 각 뷰모델의 속성을 차근차근 해소하며 그린 뒤
  5. 다시 캔버스의 상태를 복원합니다.

캔버스에 대해 이해가 별로 없으신 분들을 위해 간략히 특성을 설명드릴건데 이미 캔버스의 이해가 있으시면 건너 뛰시면 됩니다.
하지만 왜 save, restore가 등장하는지 이해가 안간다면 한 번 읽어보세요.

캔버스란 구시대의 저수준 api로 캔버스 객체 내부의 단일한 속성값을 통해 상태를 관리하고 이 상태를 변경하지 않으면 계속 유지되는 특성을 갖고 있습니다.
예를 들어 좌표계를 30, 30으로 이동하고 그림을 그리게 시키면, 그 이후 모든 그림은 다 30, 30을 기준으로 그려집니다.
거기에 다시 30, 30으로 이동하라는 명령을 내리면 60, 60이 되어있는 상태로 변경되고 유지됩니다.
이렇게 유지되는 캔버스의 속성에는 행렬변환, 브러쉬 등 여러가지가 포함되어있는데 save메소드는 이전 상태를 저장하고 이러한 속성을 초기값을 돌리는 역할을 합니다. 이에 비해 restore는 save했던 직전 상태로 복원하는 기능을 하죠.
즉 캔버스 내부에는 단일 전역 상태를 갖는 대신 이러한 상태를 셋트로 하는 스택머신 기능도 제공하는 셈입니다.
개별 뷰모델을 그릴 때마다 이러한 전역상태를 초기화할 필요가 있는데 save만 하면 스택이 끝없이 쌓이는 결과를 갖게 되므로 save, restore를 반복하여 1스택을 유지하면서 각 뷰모델에게 초기화된 캔버스를 제공하게 됩니다.

이제 남은 건 뷰모델의 map을 순회하면서 실제 그림을 그리게 되는 키와 값을 정의하는 것입니다.

뷰모델의 속성을 이용해 실제 그림그리기top

사실 이는 커맨드 패턴의 정의나 마찬가지입니다. 실제 뷰모델의 map에 있는 내용은 하나하나가 커맨드 객체역할을 수행하게 되고 그 실행기는 일괄로 CanvasDrawable에 정의하면 됩니다. k에 따라 when으로 분기하면 OCP를 지킬 수 없으므로 간단히 매칭하는 람다 맵으로 빼고 외부에서 여러 속성처리기를 공급할 수 있게 개조합시다.

class CanvasDrawable(private val r:Int, ...):Drawable(), Observer{
  companion object{
    private val prop = mutableMapOf<String, (c:Canvas, w:Int, h:Int, v:Any)->Unit>()
    operator fun set(k:String, v:(c:Canvas, w:Int, h:Int, v:Any)->Unit) = prop.set(k, v)
  }
  private val vm = mutableMapOf<String, VM>()
  operator fun get(k:String) = vm[k]
  operator fun set(k:String, v:VM){...}
  operator fun minusAssign(k:String) = vm[k]?.let{...}
  override fun draw(canvas: Canvas) {
    val width = bounds.width()
    val height = bounds.height()
    canvas.drawRGB(r, g, b)
    vm.values.forEach {
      canvas.save()
      it.map.forEach {(k, v)->
        prop[k]?.invoke(canvas, width, height, v)
      }
      canvas.restore()
    }
  }
  override fun observe() = invalidateSelf()
  override fun setAlpha(alpha: Int){}
  override fun getOpacity() = PixelFormat.OPAQUE
  override fun setColorFilter(colorFilter: ColorFilter?){}
}

크게 추가된 부분은 companion object쪽과 draw의 루프 내부입니다.
우선 companion object에서는 prop맵이 추가되어 각 키별 처리기를 set을 통해 추가할 수 있게 되었습니다.
draw는 루프의 최종 처리에서 prop로부터 적절한 처리기를 찾아내어 canvas 및 값을 넘겨 처리를 위임하게 됩니다.
이제 남은 건 실제 키별로 처리할 개별 처리기들이죠.

캔버스 속성 처리기top

커맨드패턴의 구상객체를 간단히 람다로 구현하게 되는 형식이라 어려운 부분은 없습니다. 이제 실제 기능을 래핑하여 차근차근 속성처리기를 등록해보죠.

x, y 위치 지정자

근데 복합속성에 대한 단일 값 업데이트라는 고도의 작업들이 있는데 대표적으로 x, y속성을 생각해볼 수 있습니다.
만약 뷰모델에서 다음과 같이 x, y를 필드로 잡았다고 해보죠.

object Test:VM(){
  var x by kv("x", 0F)
  var y by kv("y", 0F)
}

그러면 속성처리기쪽도 다음과 같이 만들 수 있어야합니다.

CanvasDrawable["x"] = {canvas, w, h, v->
  (v as? Float).let{canvas.x = it}
}
CanvasDrawable["y"] = {canvas, w, h, v->
  (v as? Float).let{canvas.y = it}
}

하지만 이렇게는 되지 않습니다. 왜냐면 canvas에는 x나 y같은 속성이 존재하지 않고 동시에 받는 translate(x, y)라는 메소드만 갖기 때문이죠.
이 문제는 뷰모델을 복잡하게 구현하는 것으로 해결할 수는 있습니다.

object Test:VM(){
  private val pos by kv("pos", mutableListOf(0F, 0F))
  var x = 0F
    set(v){
      field = v
      pos[0] = v
    }
  var y = 0F
    set(v){
      field = v
      pos[1] = v
    }
}
CanvasDrawable["pos"] = {canvas, w, h, v->
  (v as? MutableList<Float>).let{canvas.translate(it[0], it[1])}
}

약간 정신나간 인간의 코드처럼 되어버렸습니다만 kv델리게이터를 직접 쓰지 않고 수동으로 map을 업데이트하는 방법으로 처리하게 된거죠.
하지만 이 기능이 VM레벨에서 제공된다면 문제없습니다. 따라서 최종적인 코드는 비슷할 지라도 Test에서 반복적으로 정의할 일은 없게 될 것입니다.

abstract class VM{
  val map = mutableMapOf<String, Any>()
  val observer = mutableSetOf<Observer>()
  protected fun flush() = observer.forEach{it.observe()}
  protected fun <T:Any> kv(k:String, v:T) = KV(k, v, map)
  private val pos by kv("pos", mutableListOf(0F, 0F))
  var x = 0F
    set(v){
      field = v
      pos[0] = v
    }
  var y = 0F
    set(v){
      field = v
      pos[1] = v
    }
}
CanvasDrawable["pos"] = {canvas, w, h, v->
  (v as? MutableList<Float>).let{canvas.translate(it[0], it[1])}
}

val test = object:VM(){}
test.x = 10F
test.y = 10F
test.flush()

위 코드에서는 x, y가 VM수준으로 이동하여 오히려 구상클래스에서는 별 생각없이 기본적으로 주어진 속성으로서 x, y를 컨트롤하고 이는 곧 pos로 변환되어 캔버스에 translate가 될 것입니다.
근데 여기서 한 단계만 더 나아가보죠. 우리가 x, y를 숫자값으로 줄 수 있을 때는 이미 캔버스의 사이즈를 알고 있을 때입니다.
하지만 view.background = CanvasDrawable(0,0,0) 같은 상황을 생각해보면 view의 크기에 따라 캔버스의 사이즈가 변하기 때문에 위치를 숫자로 지정하는 것이 불가능할 수 있습니다. 이럴 때 쓸 수 있는게 바로 퍼센트표기겠죠.
퍼센트와 숫자를 표기하는 것 두 개를 동시에 x나 y가 받아들이기 위해서는 ADT를 정의할 필요가 있습니다.
다음과 같은 간단하 ADT를 정의합니다.

sealed class Num(var v:Number){
  class F(v:Number):Num(v){
    override fun get(w:Int, h:Int) = v.toFloat()
  }
  class W(v:Number):Num(v){
    override fun get(w:Int, h:Int) = w.toFloat() * v.toFloat() / 100F
  }
  class H(v:Number):Num(v){
    override fun get(w:Int, h:Int) = h.toFloat() * v.toFloat() / 100F
  }
  abstract operator fun get(w:Int, h:Int):Float
}

이제 Num은 F(float)나 W(가로 퍼센트), H(세로 퍼센트) 값 중 하나를 나타낼 수 있게 되었고 처리기에서는 width, height를 넘겨 값을 얻을 수 있게 됩니다.
이를 이용하도록 VM와 처리기를 변경해보죠.

abstract class VM{
  ...
  private val pos:MutableList<Num> by kv("pos", mutableListOf(Num.V(0F), Num.V(0F)))
  var x:Num = Num.V(0)
    set(v){
      field = v
      pos[0] = v
    }
  var y:Num = Num.V(0)
    set(v){
      field = v
      pos[1] = v
    }
}
CanvasDrawable["pos"] = {canvas, w, h, v->
  (v as? MutableList<Num>).let{canvas.translate(it[0][w, h], it[1][w, h])}
}

val test = object:VM(){}
test.x = Num.W(100)
test.y = Num.H(100)
test.flush()

이제 손쉽게 가로 세로 크기에 따라 비율로 위치를 지정할 수 있게 되었습니다. 앞으로 거의 모든 숫자형 값은 이걸로 지정하게 될 것입니다.

rotate와 centerX, centerYtop

x, y만 그런 것은 아닙니다. 회전을 시키는 rotate의 경우도 마찬가지인데 일반적으로 캔버스에서 회전을 시키면 좌상단을 기준으로 회전하게 됩니다. 만약 중앙을 기준으로 회전하고 싶다면 중앙위치만큼 translate를 하고 회전한뒤 다시 역으로 translate해야 합니다.
이러한 보조 중앙 좌표값을 centerX, centerY라 하면 이를 이용해 canvas.rotate(r, x, y) 메소드에 대응할 수 있습니다.
같은 요령으로 VM에 내장시키죠.

abstract class VM{
  ...
  private val rotation:MutableList<Num> by kv("rotation", mutableListOf(Num.V(0F), Num.V(0F), Num.V(0F)))
  var rotate:Num = Num.V(0)
    set(v){
      field = v
      rotation[0] = v
    }
  var centerX:Num = Num.V(0)
    set(v){
      field = v
      rotation[1] = v
    }
  var centerY:Num = Num.V(0)
    set(v){
      field = v
      rotation[2] = v
    }
}
CanvasDrawable["rotation"] = {canvas, w, h, v->
  (v as? MutableList<Num>).let{
    canvas.rotate(it[0][w, h], it[1][w, h], it[2][w, h]);
  }
}

val test = object:VM(){}
test.centerX = Num.W(50)
test.centerY = Num.H(50)
test.rotate = Num.V(20)
test.flush()

x, y의 재탕이므로 설명을 생략합니다.

paint의 설정

안드로이드의 캔버스는 paint를 전역변수로 두지 않고 매번 draw계열 메소드에 인자로 보내도록 하고 있습니다.
이는 편리한 반면 불편하기도 한데, 이를 속성에서 처리할 때는 미리 지정된 페인트객체를 활용하는 편이 훨씬 편리하기 때문입니다.
따라서 CanvasDrawable이 실제 drawXXX작업을 할 때 참조할 Paint객체들의 map을 만들고 실제 draw용 필드에서는 키만 지정하는 편이 합리적이죠. 게다가 외곽선과 내부도 칠할 경우는 여러 개의 Paint객체로 몇 번씩이나 그리게 됩니다(추후에는 Paint객체도 빌더를 만들게 됩니다만 ^^)
그럼 Paint는 어떤 수준으로 정의하는게 좋을까요. 실무경험상 비슷한 스타일을 여러 Drawable에서 공유하니 Paint만 관리하는 object를 하나 만들어줍니다.

object Paints:MutableMap<Streing, Paint> by mutableMapOf()

이 정도면 충분하다고 생각됩니다. 이제 최초로 그리기를 감싸는 rect속성을 지정해보죠.

recttop

기본적인 canvas의 rect명령은 drawRect(rect, paint) 로 정의됩니다. 이를 감싼 속성을 뷰모델에 추가해줍니다.
그리고 나서 구상클래스에서 사용할지 말지를 결정하게 합니다. 부모의 속성은 open에 일반값으로 지정하면 map에 영향이 없는 상태인데 서브클래스에서 델리게이터로 잡으면 비로서 map에 등록되는 원리를 이용하는 것입니다.
우선 drawRect에 보낼 인자를 클래스로 정의합니다.

class RectNum(val left:Num, val top:Num, val right:Num, val bottom:Num, vararg paint:String){
  companion object{
    val EMPTY = RectNum(Num.V(0), Num.V(0), Num.V(0), Num.V(0))
  }
  val paints = paint.toSet()
  operator fun invoke(w:Int, h:Int) = listOf(left[w, h], top[w, h], right[w, h], bottom[w, h], paints)
}

paint에는 여러 개의 문자열이 들어갈 수 있습니다. lazy바인딩을 위해 현재는 문자열로 키만 잡아두고 실제 Paint를 얻는 것은 속성처리 시점에 하게 됩니다.
invoke의 경우는 속성처리기가 w, h를 넘겨 손쉽게 속성을 취득하여 해체구문을 사용할 수 있게 돕습니다.

이제 위에서 설명한대로 VM에는 rect를 open으로 설정하고 구상 클래스에서는 델리게이터로 덮어씁니다. 이때 이미 map용 키는 rect로 확정이니 확장클래스에서 쓰기 편한 도우미 함수도 같이 만들어줍니다.

abstract class VM{
  ..
  open var rect = RectNum.EMPTY
  protected fun rect(left:Num, top:Num, right:Num, bottom:Num, vararg paint:String) 
    = kv("rect", RectNum(left, top, right, bottom, *paint)
}

val test = object:VM(){
  override var rect by rect(Num.V(0), Num.V(0), Num.W(100), Num.H(100))
}

부모쪽에 먼저 open으로 선언해두는 것은 일종의 추상클래스 레벨의 플레이스 홀더입니다. VM을 상속받은 모든 서브클래스에 rect라는 속성이 있다는 것을 알려주는 것이죠. 하지만 실제 속성이 되는 것은 반드시 by를 통해 델리게이터로 map을 갱신했을 때 뿐이니 문제없습니다.
이제 속성처리기를 만들면 끝입니다.

CanvasDrawable["rect"] = {canvas, w, h, v->
  (v as? RectNum).let{
    val (left, top, right, bottom, paints) = it(w, h)
    paints.forEach{
      canvas.drawRect(left, top, right, bottom, Paints[it] ?: throw Throwable("invalid paint:$it");
    }
  }
}

RectNum의 invoke가 일괄로 w, h작업을 맡아주기 때문에 이를 해체 하여 paints만큼 drawRect를 반복하게 됩니다.
실제 drawRect시점에 VM의 paints를 검색하므로 이 시점에도 없으면 throw하게 됩니다.

중간 정리top

좀 길지만 여기까지의 코드를 정리하여 한 번에 보죠.

//특수한 값들
sealed class Num(var v:Number){
  class V(v:Number):Num(v){
    override fun get(w:Int, h:Int) = v.toFloat()
  }
  class W(v:Number):Num(v){
    override fun get(w:Int, h:Int) = w.toFloat() * v.toFloat() / 100F
  }
  class H(v:Number):Num(v){
    override fun get(w:Int, h:Int) = h.toFloat() * v.toFloat() / 100F
  }
  abstract operator fun get(w:Int, h:Int):Float
}

class RectNum(val left:Num, val top:Num, val right:Num, val bottom:Num, vararg paint:String){
  companion object{
    val EMPTY = RectNum(Num.V(0), Num.V(0), Num.V(0), Num.V(0))
  }
  val paints = paint.toSet()
  operator fun invoke(w:Int, h:Int) = listOf(left[w, h], top[w, h], right[w, h], bottom[w, h], paints)
}

//옵져버
interface Observer{
  fun observe()
}

//Paint관리
object Paints:MutableMap<Streing, Paint> by mutableMapOf()

//Drawable을 확장한 주 객체
class CanvasDrawable(
    private val r:Int, 
    private val g:Int, 
    private val b:Int
):Drawable(), Observer{
  companion object{
    private val prop = mutableMapOf<String, (c:Canvas, w:Int, h:Int, v:Any)->Unit>(
      "pos" to {canvas, w, h, v->
        (v as? MutableList<RectNum>).let{canvas.translate(it[0][w, h], it[1][w, h])}
      },
      "rotation" to {canvas, w, h, v->
        (v as? MutableList<Num>).let{
          canvas.rotate(it[0][w, h], it[1][w, h], it[2][w, h]);
        }
      },
      "rect" to {canvas, w, h, v->
        (v as? RectNum).let{
          val (left, top, right, bottom, paints) = it(w, h)
          paints.forEach{
            canvas.drawRect(left, top, right, bottom, Paints[it] ?: throw Throwable("invalid paint:$it");
          }
        }
      }
    )
    operator fun set(k:String, v:(c:Canvas, w:Int, h:Int, v:Any)->Unit) = prop.set(k, v)
  }
  private val vm = mutableMapOf<String, VM>()
  operator fun get(k:String) = vm[k]
  operator fun set(k:String, v:VM){
    v.observer += this
    vm[k] = v
  }
  operator fun minusAssign(k:String) = vm[k]?.let{
    it.obserser -= this
    vm -= k
  }
  override fun draw(canvas: Canvas) {
    val width = bounds.width()
    val height = bounds.height()
    canvas.drawRGB(r, g, b)
    vm.values.forEach {
      canvas.save()
      it.map.forEach {(k, v)->
        prop[k]?.invoke(canvas, width, height, v)
      }
      canvas.restore()
    }
  }
  override fun observe() = invalidateSelf()
  override fun setAlpha(alpha: Int){}
  override fun getOpacity() = PixelFormat.OPAQUE
  override fun setColorFilter(colorFilter: ColorFilter?){}
}

//뷰모델
abstract class VM{

  //map을 연동해주는 델리게이터
  class KV<T:Any>(private val k:String, private var value:T, private val map:MutableMap<String, Any>){
    init{map[k] = value}
    operator fun getValue(ref:Any?, prop: KProperty<*>) = value
    operator fun setValue(ref:Any?, prop: KProperty<*>, v:T){
      map[k] = v
      value = v
    }
  }
  companion object{
    val paints = mutableMap<String, Paint>
  }
  val map = mutableMapOf<String, Any>()
  val observer = mutableSetOf<Observer>()
  protected fun flush() = observer.forEach{it.observe()}
  protected fun <T:Any> kv(k:String, v:T) = KV(k, v, map)
  private val pos:MutableList<Num> by kv("pos", mutableListOf(Num.V(0), Num.V(0)))
  var x:Num = Num.V(0)
    set(v){
      field = v
      pos[0] = v
    }
  var y:Num = Num.V(0)
    set(v){
      field = v
      pos[1] = v
    }
  private val rotation:MutableList<Num> by kv("rotation", mutableListOf(Num.V(0), Num.V(0), Num.V(0)))
  var rotate:Num = Num.V(0)
    set(v){
      field = v
      rotation[0] = v
    }
  var centerX:Num = Num.V(0)
    set(v){
      field = v
      rotation[1] = v
    }
  var centerY:Num = Num.V(0)
    set(v){
      field = v
      rotation[2] = v
    }
  open var rect = RectNum.EMPTY
  protected fun rect(left:Num, top:Num, right:Num, bottom:Num, vararg paint:String) 
    = kv("rect", RectNum(left, top, right, bottom, *paint)
}

다 정리해보면 그리 많은 것이 등장하지는 않습니다.
1. 기저 데이터 형인 Num, RectNum
2. 옵저버 인터페이스 Observer
3. 주요 객체인 Paints, CanvasDrawable, VM 정도네요.

이후 drawXXX계열이나 기타 canvas의 다른 메소드를 감싸서 뷰모델의 일부로 만드는 것은 대동소이하기 때문에 생략합니다.
단지 코틀린의 MutableMap은 내부적으로 순서가 보장되는 LinkedHashMap을 기반으로 하고 있습니다. 따라서 다른 무엇보다 먼저 나오게 일부러 pos와 rotate를 VM에서 map에 선언해주고 있습니다. 다른 VM의 속성은 추가되더라도 그 뒤에 기술하는 것이 좋습니다.
이제 이걸 활용한 본격적인 예제를 만들어보겠습니다.

간단한 예제top

우선 레이아웃을 작성해보죠.

<androidx.appcompat.widget.AppCompatTextView
  android:layout_width="match_parent" 
  android:layout_height="70dp"
  android:id="@+id/test"/>

이제 test를 찾아 background를 할당할거지만 그 전에 사용할 페인트를 등록해둡니다. 빨강, 파랑 칠하기 페인트를 등록해보죠.

Paints["redFill"] = Paint().apply {
  style = Paint.Style.FILL
  color = Color.parseColor("#ff0000")
}
Paints["blueFill"] = Paint().apply {
  style = Paint.Style.FILL
  color = Color.parseColor("#0000ff")
}

그럼 이제 본격적으로 background에 CanvasDrawbal을 지정해줍니다.

findById(R.id.test).background = CanvasDrawable(255, 255, 255).also{
  it["box1"] = object:VM(){
    var rect by rect(Num.V(0), Num.V(0), Num.W(50), Num.H(100), "redFill") 
  }
  it["box2"] = object:VM(){
    var x = Num.W(50)
    var rect by rect(Num.V(0), Num.V(0), Num.W(50), Num.H(100), "blueFill") 
  }
}

위 코드에서 두 개의 박스를 등록했는데 box1은 가로 50%, 세로 100%의 박스를 0, 0 에 배치합니다.
box2도 같은 크기지만 x의 위치를 50%로 설정해서 오른쪽 절반을 차지하는 파란 박스가 되겠죠.

실제 렌더링 결과는 다음과 같습니다.

이제 위의 상태에서 vm의 속성을 바꾸고 flush를 해봅니다.

(findById(R.id.test).background as? CanvasDrawable)?.let{
  it["box1"]?.apply{
    x = Num.V(50)
    flush()
  }
}

이제 빨간 상자가 오른쪽으로 50만큼 밀려난 걸 볼 수 있습니다.

결론top

어떤 안드로이드 버전이라도 호환되는 Drawable의 draw메소드를 가상화하여 뷰모델을 통해 복잡한 그래픽을 표현할 수 있는 시스템을 구현해봤습니다.

실무에서 쓰려면 Num타입에 Num.DP(10) 처럼 DP로 바꿔주는 단위처리기도 있어야하고 rect외에도 다양한 캔버스의 drawXXX를 커버하는 처리기를 다 만들어야겠죠.

하지만 뷰모델의 속성을 바꾸고 flush를 해주면 일시에 반영된다는 점을 이용하면 다양한 애니메이션도 구현할 수 있고 복잡한 그래픽 표현도 자유롭게 할 수 있습니다.