Primary/Compose

[Relay] Under the hood

해스끼 2022. 11. 27. 22:16

지난 글에서 만들었던 버튼을 활용하여 Relay가 내부적으로 어떻게 구현되는지 공부해 보자. UI 구성을 약간 수정했는데, 버튼을 감싸는 흰색 사각형을 없애고 버튼 자체를 root component로 만들었다.

Before
After

``ui-packages``

``ui-packages`` 폴더에는 Figma에서 가져온 UI 패키지가 저장되어 있다. 패키지 하나당 폴더 하나씩이다.

``fonts`` 폴더가 패키지 폴더의 하위 디렉토리에 있는데, 현재 시점에서는 패키지끼리 ttf 파일을 공유하지 않는다. 따라서 여러 패키지에서 동일한 폰트를 사용하더라도 동일한 폰트 파일을 여러 번 다운로드하게 된다. 향후 개선이 필요한 부분.

 

다른 파일은 볼 거 없고, UI 정보가 저장된 ``sample_button.json`` 파일을 보자. 파일이 길기 때문에 위에서부터 나눠서 보겠다.

{
  "name": "sample_button",
  "version": 32,
  "source-key": {
    "type": "figma",
    "file": "<file key>",
    "node": "118:2",
    "version": "2669197158"
  },
  "default": "Sample button",
  "design": {
      ...
  }
}

UI의 메타데이터를 담고 있는 부분이다. 딱히 중요한 부분은 없는 듯하다.

 

UI 정보는 ``design`` 필드에 담겨 있다. 우선 ``design.atom``에서는 UI의 구조가 표현된다. Compose의 composition tree와 비슷하다.

딱히 구조랄 게 없긴 한데

{
  "design": {
    "atoms": [
      {
        "type": "group",
        "id": "top_level",
        "root": "true"
      },
      {
        "type": "text",
        "id": "sample"
      }
    ],
}

 

이제 root component의 디자인이 어떻게 매핑됐는지 살펴보자.

{
  "design": {
    "modes": {
      "Sample button": {
        "rules": [
          {
            "id": "top_level",
            "padding": {
              "left": "19.0",
              "top": "7.0",
              "right": "19.0",
              "bottom": "7.0"
            },
            "border-radius": "15.0",
            "size-constraints": {
              "width-constraints": {
                "sizing-mode": "shrink"
              },
              "height-constraints": {
                "sizing-mode": "shrink"
              }
            },
            "children": [
              "sample"
            ],
            "item-spacing": "10.0",
            "background-color": {
              "alpha": "1.0",
              "hue": "276.5217391304348",
              "saturation": "0.18039215686274512",
              "value": "1.0"
            },
            "clip-content": "false"
          },
        ]
      }
    }
  },
}

Padding, size constraints 등이 모두 잘 정의되어 있다. UI 정보 자체에는 hierarchy가 없고, ``children`` 필드에 자식 UI의 id를 저장하는 방식으로 구현되었다.

 

색을 표현할 때 RGB가 아닌 HSV 방식으로 표현한 부분이 눈에 띈다. HSV는 색을 hue(색상), saturation(채도), value(명도)로 나타내는 방식이다. 솔직히 처음 봤다.

 

텍스트는 어떻게 표현되어 있을까?

{
  "design": {
    "modes": {
      "Sample button": {
        "rules": [
          // ...
          {
            "id": "sample",
            "color": {
              "alpha": "1.0",
              "hue": "0.0",
              "saturation": "0.0",
              "value": "0.0"
            },
            "text-content": "$text",
            "overflow": "visible",
            "text-align": "left",
            "text-size": "12.0",
            "line-height": "1.2102272510528564",
            "typeface": "Inter"
          }
        ]
      }
    }
  },
}

Compose에서 text를 다루는 방법과 유사하게 표현되어 있다. ``text-content``가 하드코딩된 값이 아닌 매개변수로 표현되어 있는데, Figma에서 매개변수 설정을 했기 때문에 그렇다. 매개변수 표현 방식이 Kotlin과 유사하다.

실제로 이 부분 바로 밑에 매개변수 정보가 표현되어 있다.

{
    "modes": {
      "Sample button": {
        "rules": [
          {
            "id": "sample",
            // ...
          }
        ]
      }
    }
  },
  "parameters": {
    "text": {
      "data-type": "text",
      "required": false,
      "description": "text to show"
    }
  },
}

이 밑에는 미리보기 정보 등 별로 중요하지 않은 내용이라 생략하겠다. 이제 실제로 구현된 Compose 코드를 살펴보자.

생성된 Compose 코드

거두절미하고 코드부터 보자.

@Composable
fun SampleButton(
    modifier: Modifier = Modifier,
    text: String
) {
    TopLevel(modifier = modifier) {
        Sample(text = text)
    }
}

루트 component인 ``SampleButton``의 코드이다. ``TopLevel``이라는 composable 아래에 텍스트가 정의되어 있는데, ``TopLevel``은 뭐지?

@Composable
fun TopLevel(
    modifier: Modifier = Modifier,
    content: @Composable RelayContainerScope.() -> Unit
) {
    RelayContainer(
        backgroundColor = Color(
            alpha = 255,
            red = 237,
            green = 209,
            blue = 255
        ),
        padding = PaddingValues(
            start = 19.0.dp,
            top = 7.0.dp,
            end = 19.0.dp,
            bottom = 7.0.dp
        ),
        itemSpacing = 10.0,
        clipToParent = false,
        radius = 15.0,
        content = content,
        modifier = modifier
    )
}

루트의 디자인 정보가 선언된 composable이다. Compose에서는 HSV가 아닌 RGB를 사용하기 때문에, HSV 색이 RGB로 변환되어 있다. 그런데 너무 하드코딩이 많은 거 아닌지..?

@Composable
fun Sample(
    text: String,
    modifier: Modifier = Modifier
) {
    RelayText(
        content = text,
        fontSize = 12.0.sp,
        fontFamily = inter,
        color = Color(
            alpha = 255,
            red = 0,
            green = 0,
            blue = 0
        ),
        height = 1.2102272510528564.em,
        textAlign = TextAlign.Left,
        modifier = modifier
    )
}

텍스트 composable에도 하드코딩된 값이 매우 많다. 크기나 색, 높이까지 모두 상수로 선언되어 있다. 사실 그 이유는 내가 Figma에서 저 속성들을 모두 하드코딩했기 때문이다. 

 

당연히 구글은 해결책을 마련해 놓았다. Figma에서 Compose의 테마 값에 접근할 수 있는 방법이 있는데, 어떻게 접근하는지는 다음 글에서 공부해 보겠다.

 

그런데 코드를 보면 ``Relay``로 시작하는 composable이 보인다. 

Relay wrapper composables

``Relay``로 시작하는 composable 내부를 보면 ``Text``와 같은 기본 composable을 사용하는 코드도 있지만, ``RelayBox``, ``RelayColumn``에서는 ``Layout``을 통해 커스텀 레이아웃으로 직접 구현하기도 한다. Figma를 한 치의 오차도 없기 그대로 재현하겠다는 의지인 듯.

@Composable
inline fun RelayColumn(
    modifier: Modifier = Modifier,
    verticalArrangement: Arrangement.Vertical = Arrangement.Top,
    horizontalAlignment: Alignment.Horizontal = Alignment.Start,
    content: @Composable RelayContainerScope.() -> Unit
) {
    val measurePolicy = columnMeasurePolicy(verticalArrangement, horizontalAlignment)
    Layout(
        content = { ColumnScopeInstance.content() },
        measurePolicy = measurePolicy,
        modifier = modifier
    )
}

다만 내부 코드를 하나하나 읽기는 어렵다. 솔직히 가독성이 좋은 편은 아니라서..

으악

직접 짜는 게 더 깔끔하다고 생각할 수도 있겠지만, 나는 이 코멘트에 동의한다.

Of course my code is more concise and easier to read, but does it really matter if no developer is going to manually update the composable?

Relay의 본질은 자동화이지 clean code가 아니라는 사실..


아직 alpha라 그런지 TODO 코드도 가끔씩 보인다.

@Composable
fun RelayImage(
    ...
) {
    RelayBaseComposable(
        ...
    ) {
        // TODO: Include a contentDescription for accessibility
        // as required by Image.
        Image(image, "image description", modifier = it, contentScale = contentScale)
    }
}

지금까지 Relay가 코드를 어떻게 생성하는지 공부했다. 다음 글에서는 더 자세한 Relay의 활용 예시에 대해 공부해 보겠다.