[코드탐험기] UIColor, 테스트 그리고 어셈블리

Kwanghoon Choi·2020년 6월 20일
7

테스트는 결과만 본다면, 어떤 행위의 결과가 기대하는 값과 동일한지 비교해보는 일입니다. 어떤 함수의 입력값이 1이면 기대값이 2일 때, 출력값을 2와 비교해서 맞는지 틀리는지 검증하는 방법이죠.
UIColor와 같은 경우에는 이게 좀 복잡해집니다. 색의 객체인 UIColor는 red, blue, green, alpha, 그리고 Color Space를 제공합니다. UIColor에서 제공하는 색이 디바이스의 디스플레이에서 동일하게 보이더라도 막상 객체를 비교하거나 색의 RGBA 값을 비교하면 기대하지 않는 결과를 만나는 경우가 생깁니다.
여기서부터는 이제 어떤걸 기대할지 선택해야 합니다. 절대적 값을 비교하느냐, 눈에 보이는 대로의 값을 비교하느냐죠.

UIView에서 사용하는 UIColor라면 눈에 보이는 대로의 값 비교가 충분하고 또 중요합니다. 여러분이 생성하거나 혹은 asset에서 가져오는 UIColor는 사실 단순한 UIColor가 아닐 수 있거든요. 그러면 UIColor에서 가져오는 색은 뭐가 문제인지, 눈에 보이는 색은 또 무엇인지, 비교할 때는 어떤 문제들이 발생하는지, 설명을 시작해보겠습니다.

*이 글의 모든 코드는 Xcode 11.5, iPhone Simulator 13.5를 기반으로 작성하고 테스트했습니다.

*약간의 어셈블리어도 만나게 됩니다.

시작하기 전에

UIColor의 레퍼런스 설명은 색에 대한 여러가지 용어를 사용하고 있습니다. 설명을 제대로 이해하기 위해서 다음의 세 가지 용어에 대해서 간단히 알고 가시면 좋을거 같습니다.

  1. 색모델(Color Model)
    색 모델 몇 가지 숫자들로 색을 표현하는 수학적인 방법들을 말합니다. 일반적으로는 3개에서 4개의 값으로 표현하게 되는데, UIColor를 사용하는 여러분은 다음의 모델을 주로 사용하게 되실 겁니다.
    • RGB 모델
    • HSB(HSL or HSV) 모델
  2. 색공간(Color Space)
    색모델을 기반으로 만들어진 컬러 세트를 말합니다.
    예를 들자면, RGB 모델을 기반으로 하는 색공간에는 sRGB, extended sRGB, Display P3 등이 있습니다. 각각은 서로 다른 색의 범위를 가지지만, RGB의 계산 방식을 기반으로 하고 있습니다.
  3. 색역(Color Gamut)
    UIColor에서 색역은 기기의 디스플레이에서 어떤 색모델의 어떤 색공간이 표현 가능한 범위를 가르킵니다.

UIColor에서는 단순히 색모델 > 색공간 > 색역 순으로 색의 표현 범위가 좁아진다고 생각하셔도 괜찮을거 같습니다.

UIColor

UIColor에서 색을 가져올 때는 다음과 같은 다섯가지 범주안에서 색을 가져오거나 만들게 됩니다.

  • UI Element Colors - label, text, background, link등에서 표준으로 사용하게끔 준비되어 있는 UIColor 객체들을 사용할 수 있습니다.
  • Standard Colors(System Colors) - 빨강, 파랑, 초록, 검정, 흰색 등 특정 색조를 가진 미리 준비된 UIColor 객체들을 사용할 수 있습니다.
  • Create Color - 커스텀한 색을 만들 수 있습니다.
    • sRGB 색 공간에서 0.0부터 1.0까지 gray, alpha 혹은 0.0부터 1.0까지 red, green, blue, alpha의 조합으로 색을 만들 수 있습니다.
    • Display P3 색 공간에서 0.0부터 1.0까지 red, green, blue, alpha의 조합으로 색을 만들 수 있습니다.
    • hue, saturation, brightness, alpha의 조합으로
  • Create Color from Another Color Object - CGColor, CIColor로 UIColor를 만들 수 있습니다.
  • Creating a Pattern-Based Color - UIImage로 색을 만들고 패턴을 사용할 수 있습니다.

비교할 색상을 만들자

붉은 색을 다음과 같이 여러가지 방식으로 만들어보겠습니다.

let red = UIColor.red
let redRGB = UIColor(red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)
let redDP3RGB = UIColor(displayP3Red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)
let redHSB = UIColor(hue: 0.0, saturation: 1.0, brightness: 0.5, alpha: 1.0)
let redAsset = UIColor(named: "red")
  • red는 standard color에 속한 UIColor.red를 가져왔습니다.
  • redRGB는 iOS 10 이상이라면 extended sRGB의 색 공간으로 UIColor를 생성하게 됩니다. iOS 10 미만이라면 device sRGB 색 공간으로 가져오게 되는데 아이폰에서는 sRGB 색공간과 동일합니다.
  • redDP3RGB는 Display P3의 색 공간으로 UIColor를 생성합니다. Display P3는 애플에서 사용하는 비공식 색공간으로 기존 sRGB와 비교해서 약간 더 넓은 색역(color gamut)을 가집니다. *Display P3 ColorSpace의 CGColor로 생성하는 경우와 차이가 있습니다.
  • redHSB는 HSB 색공간으로 UIColor를 생성합니다.
  • redAsset은 asset에서 설정한 UIColor를 가져옵니다.
    • asset에서 color set을 처음 만들면 sRGB의 색공간으로 설정되어있습니다. 여기서 색 "red"는 sRGB의 색공간을 가진다고 가정하겠습니다.

비교해보자

테스트를 할 때 중요한 것중에 하나는 기대하는 값이 맞는지를 검증하는 한편 기대하지 않은 값이 나오면 틀릴 수 있는지도 검증할 수 있어야 한다는 것입니다.
red1과 red2를 비교하면 사실 당연히 true값을 기대를 할 수 있습니다. 다만 == 함수가 실제로 무엇을 가지고 비교하는지는 의심해볼 가치가 있습니다. 혹시나 타입으로 비교할 수도 있는것 아니겠습니까?(혹시나 말이죠)
그러니 실제 색의 값을 비교하는 것이 맞는지 먼저 확인을 하기 위해 red1과 조금 다른 색을 먼저 비교해보겠습니다.

red1 == UIColor(red: 1.0, green: 0.1, blue: 0.0, alpha: 0.0)

의 결과는

false

입니다.

그리고

red1 == red2

의 결과는

true

입니다. 값을 비교하고 있는것이 확실해졌습니다.

나머지를 비교해보겠습니다. 여기서부터는 비교할 때 XCTest의 XCTAssertEqual등의 테스트 함수를 이용해서 테스트 환경에서 비교해보겠습니다. 테스트니 테스트 함수를 사용하는 것도 있지만, 이 함수들은 기대하던 결과가 아닐때 각 오브젝트의 description을 표시해주기 때문에 살펴보기가 편한것이 이유입니다.

*참고로 객체를 문자열로 변환할 때 CustomStringConvertible과 CustomDebugStringConvertible 프로토콜을 구현하고 있는 경우 CustomStringConvertible구현을 우선시하게 됩니다. 즉, XCTAssertXXX 함수가 알려주는 메시지는 일반적으로 객체의 description 프로퍼티가 반환하는 값을 이용하게 됩니다. 이 설명을 하는 이유는 이 글을 계속 보시다보면 알게 됩니다.

XCTAssertEqual(red, redRGB) - 성공입니다.
XCTAssertEqual(red, redDP3RGB) - 실패입니다.
XCTAssertEqual(red, redHSB) - 성공입니다.
XCTAssertEqual(red, redAsset) - 실패입니다. ??

실패와 성공의 이유는 무엇일까

저는 여기서 몇 가지 궁금증이 생겼습니다.
첫번째, 실제로 색은 어떻게 비교하고 있을까?
두번째, redDP3RGB, redHSB, redAsset은 색을 어떻게 들고 있을까?

첫번째 궁금증을 풀어봅시다

그 전에 하나 짚고 넘어갈 게 있습니다. 아시는 분들도 많이 계실거라 생각하는데, 사실 UIColor는 UIColor가 아닙니다.
지금 만든 색들의 타입을 한 번 살펴보겠습니다.

타입의 상속관계를 출력하기 위해 다음과 같은 함수를 만들어 봤습니다.

func types<O>(of: O) -> String {
    var m = Mirror(reflecting: of)
    var types: [String] = ["\(m.subjectType)"]
    
    while let mirror = m.superclassMirror {
        types.append("\(mirror.subjectType)")
        m = mirror
    }
    
    return types.joined(separator: " -> ")
}

그리고 준비한 UIColor들을 다 출력해보겠습니다.
red, redRGB, redDP3RGB, redHSB, redAsset의 차례로 다음과 같은 결과가 나옵니다.

"UICachedDeviceRGBColor -> UIDeviceRGBColor -> UIColor -> NSObject" <- red
"UIDeviceRGBColor -> UIColor -> NSObject" <- redRGB
"UIDisplayP3Color -> UIColor -> NSObject" <- redDP3RGB
"UIDeviceRGBColor -> UIColor -> NSObject" <- redHSB
"UICGColor -> UIColor -> NSObject" <- redAsset

네 그 동안 우리는 속아왔습니다. 순수한 UIColor는 존재하지 않았던 것입니다. 이것을 알아야 하는 이유는 실제로 색을 비교하기 위해 NSObject를 상속하는 객체들이 isEqual을 사용하는데, 이 내부를 들여다보려면 실제 객체들의 메소드 심볼에 브레이크 포인트를 걸어야 하기 때문입니다. UIColor.isEqual에 백날 걸어도 각각의 UIColor를 상속하고 있는 private 객체들이 저마다의 isEqual을 구현하고 super의 isEqual은 사용하지 않기 때문에 알 수가 없습니다. (하여간 좋은건 자기네만 쓰지)

그럼 갑니다.

브레이크포인트

먼저 red, redRGB를 비교하는 부분을 살펴보기 위해 UIDeviceRGBColor의 isEqual 메소드에 symbolic breakpoint를 걸어보겠습니다.

Xcode Menu > Debug > Breakpoints > Create Symbolic Breakpoint를 선택해주세요. 그리고 다음과 같이 입력하시면 됩니다.

그리고 다음의 코드를 실행할 수 있게 작성해서 실행해봅니다.

red == redRGB

어셈블리

*raywenderlich의 Assembly Register Calling Convention Tutorial를 읽어보시는것도 좋습니다.

이제 UIDeviceRGBColor 클래스의 isEqual 함수의 frame에서 멈추고 disassembly code가 나타납니다.
저는 아직 이들의 의미와 논리를 잘 알지못합니다. 하지만 심볼정보 등 알아볼 수 있는 정보들도 있고, 알고 있는 몇개의 assembly instruction과 lldb 디버그 명령어를 이용해서 하나하나 추적해보겠습니다. *메모리 주소와 Objective C reference count와 관련한 코드는 생략합니다.

UIKitCore`-[UIDeviceRGBColor isEqual:]:
... 1
    0x? <+?>:  movq   0x?(%rip), %rbx    ; "colorSpaceName"
    0x? <+?>:  movq   0x?(%rip), %r14    ; (void *)0x?: objc_msgSend
    0x? <+?>:  movq   %r12, %rdi
    0x? <+?>:  movq   %rbx, %rsi
    0x? <+?>:  callq  *%r14
... 2
    0x? <+?>: movq   %rax, %rbx
    0x? <+?>: movq   0x?(%rip), %rsi    ; "isEqualToString:"
    0x? <+?>: movq   %r13, %rdi
    0x? <+?>: movq   %rax, %rdx
    0x? <+?>: callq  *%r14
    0x? <+?>: movl   %eax, %r14d
    0x? <+?>: movq   %rbx, %rdi
    0x? <+?>: callq  *0x?(%rip)         ; (void *)0x?: objc_release
    0x? <+?>: testb  %r14b, %r14b
    0x? <+?>: je     0x?            ; <+284>

1번 블록은 이름만 봐도 colorSpace의 이름을 가져올거 같습니다.(하여간 좋은건 자기네만 쓰지).
잠깐 UIDeviceRGBColor에 해당 메소드가 구현되어있는지 확인해보겠습니다. lldb에는 정규표현식으로 심볼 정보를 가져올 수 있는 명령어가 있습니다.

lldb) image lookup -rn 'UIDeviceRGBColor\ colorSpaceName'

실행하시면 다음과 같은 결과를 보실 수 있습니다.

/Applications/Xcode.app/.../UIKitCore.framework/UIKitCore:
        Address: UIKitCore[0x?] (UIKitCore.__TEXT.__text + ?)
        Summary: UIKitCore`-[UIDeviceRGBColor colorSpaceName]

대충 봐도 아 그렇구나 하고 이해가 가실듯 합니다.

그럼 인스트럭션을 하나씩 아는 한에서 분석해보겠습니다.

UIKitCore`-[UIDeviceRGBColor isEqual:]:
... 1
    0x1 <+?>:  movq   0x?(%rip), %rbx    ; "colorSpaceName" // rbx 레지스터에 colorSpaceName 셀렉터의 주소를 옮깁니다. rip어드레스는 프레임에서 instruction명령의 주소를 담고 있습니다. 0x?(%rip)의 의미는 rip(다음 명령줄의 주소값 = 0x2)에 0x?를 더한 만큼의 주소값이고 movq instruction은 그 주소를 rbx 레지스터에 옮라는 명령입니다.
    0x2 <+?>:  movq   0x?(%rip), %r14    ; (void *)0x?: objc_msgSend // r14 레지스터에 objc_msgSend 함수의 주소를 옮깁니다. 이 함수는 이름만 봐도 Objective C 객체에 메시지(셀렉터 정보)를 전달할 수 있을거 같죠?
    0x3 <+?>:  movq   %r12, %rdi // r12 레지스터에는 생성한 UIDeviceRGBColor의 주소가 들어가있습니다. rdi레지스터는 objc_msgSend에 전달할 첫번째 인수를 담는 레지스터입니다.
    0x4 <+?>:  movq   %rbx, %rsi // rsi인자는 두번째 인수입니다. rbx 레지스터에는 colorSpaceName 셀렉터가 담겨 있고, 이 명령을 통해 rsi 레지스터로 옮겨집니다.
    0x5 <+?>:  callq  *%r14 // call(q) instruction은 레지스터 주소의  프로시저를 호출합니다. 이 경우 objc_msgSend를 호출하게 되죠. 참고로 objc_msgSend는 객체, 셀렉터, 기타 인수들의 variadic arguments의 차례로 인수를 가집니다.
... 2
    0x6 <+?>: movq   %rax, %rbx
    0x7 <+?>: movq   0x?(%rip), %rsi    ; "isEqualToString:"
    0x8 <+?>: movq   %r13, %rdi
    0x9 <+?>: movq   %rax, %rdx
    0x10 <+?>: callq  *%r14
    0x11 <+?>: movl   %eax, %r14d
... 3
    0x12 <+?>: testb  %r14b, %r14b // r14 레지스터의 마지막 1바이트값을 and 연산하고 그 결과를 rflags(제어 레지스터)에 저장합니다. 이 경우 연산 결과가 0이면 rflags에 ZF(zero flag)를 1로 저장하고 그 외에는 0으로 저장합니다.
    0x13 <+?>: je     0x주소1            ; <+284> // ZF가 1이면 주소1으로 점프하고 아니라면 다음 인스트럭션인 0x14로 이동합니다.
    0x14 <+159>: movq   0x?(%rip), %rax    ; UIDeviceRGBColor.redComponent
  • 1번 블록에서는 colorSpaceName getter 메소드를 통해 color space의 이름을 문자열 객체로 가지고 옵니다. 분석을 빼놓긴 했는데, objc_msgSend함수를 통해 redredRGB의 colorSpaceName을 모두 가져온 상태입니다.
  • 2번 블록에서는 objc_msgSend함수에 rdi(red의 색공간의 이름), rsi(__NSCFConstantString의 isEqualToString: 셀렉터), rdx(redRGB의 색공간의 이름)을 인수로 건네줍니다. 그리고 결과를 r14 레지스터에 저장합니다.
  • 3번 블록에서는 두 UIColor객체의 생공간의 문자열을 비교합니다. 문자열이 같다면 다음으로 rgb의 값을 비교하는 단계로 넘어가게 됩니다. 참고로 이 두 색의 색공간은 UIExtendedSRGBColorSpace로 동일합니다.
    0x? <+?>: movq   0x?(%rip), %rax    ; UIDeviceRGBColor.redComponent
    0x? <+?>: movsd  (%r15,%rax), %xmm0        ; xmm0 = mem[0],zero 
    0x? <+?>: ucomisd (%r12,%rax), %xmm0
    0x? <+?>: jne    0x7fff4885be98            ; <+459>
    0x? <+?>: jp     0x7fff4885be98            ; <+459>
    0x? <+?>: movq   0x40f6200e(%rip), %rax    ; UIDeviceRGBColor.greenComponent
    0x? <+?>: movsd  (%r15,%rax), %xmm0        ; xmm0 = mem[0],zero 
    0x? <+?>: ucomisd (%r12,%rax), %xmm0
    0x? <+?>: jne    0x7fff4885be98            ; <+459>
    0x? <+?>: jp     0x7fff4885be98            ; <+459>
    0x? <+?>: movq   0x40f61ff7(%rip), %rax    ; UIDeviceRGBColor.blueComponent
    0x? <+?>: movsd  (%r15,%rax), %xmm0        ; xmm0 = mem[0],zero 
    0x? <+?>: ucomisd (%r12,%rax), %xmm0
    0x? <+?>: jne    0x7fff4885be98            ; <+459>
    0x? <+?>: jp     0x7fff4885be98            ; <+459>
    0x? <+?>: movq   0x40f61fe0(%rip), %rax    ; UIDeviceRGBColor.alphaComponent
    0x? <+?>: movsd  (%r12,%rax), %xmm0        ; xmm0 = mem[0],zero 
    0x? <+?>: jmp    0x7fff4885be87            ; <+442>
    ...
    0x? <+?>: retq
  • xmm0~n 레지스터는 소숫점을 위한 레지스터입니다.
  • UIDeviceRGBColor.[red, green, blue, alpha]Component는 UIDeviceRGBColor의 instance variables 입니다.

이 블럭에서는 red1과 redRGB의 red, green, blue, alpha값을 비교하게 됩니다. 모든 값이 같으면 최종적으로 rax레지스터에 0x01 즉 true값을 저장하고 ret(q) instruction을 통해 현재 frame에서 나가게 됩니다.

결론

'중간에 디테일 좀 빠진거 같습니다만?'

(그냥 이 이미지를 써보고 싶었습니다.) 어셈블리만 너무 파고들면, 글의 주제가 멀어지는거 같고, 디버깅시 만나게 되는 어셈블리에 대해서 저도 공부를 더 하고 따로 글을 써보려고 합니다. 이 정도면 어느 정도는 디버깅에 도움이 되시지 않을까 싶어요.

어쨌건, 이로써 색공간의 이름의 문자열과 red, green, blue, alpha의 값을 비교한다는 것을 알게 되었습니다.

다시 비교

위에서 본 내용을 되짚어보면

let red = UIColor.red
let redRGB = UIColor(red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)
let redDP3RGB = UIColor(displayP3Red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)
let redHSB = UIColor(hue: 0.0, saturation: 1.0, brightness: 0.5, alpha: 1.0)
let redAsset = UIColor(named: "red")
  • red == redRGB는 색공간도 색의 값도 동일하기 때문에 true입니다.
  • red == redDP3RGB는 색공간이 달라, 색의 값도 다를 것입니다. 따라서 false가 맞습니다. (기대하던 색공간은 Display P3였습니다만, 막상 출력해보면, 기본 색공간인 )
  • red == redHSB는 생성자단계에서 HSB->RGB의 값 계산이 발생하고, 이는 red의 값과 동일하기 때문에 true가 맞습니다.
  • red == redAsset은 어라?

asset에서 색을 가져오면 뭐가 다를까

먼저 redAsset의 색공간을 출력해보겠습니다.

print(redAsset.cgColor.colorSpace.name)
결과는
kCGColorSpaceModelRGB

가 되네요. 아하 처음에 어셋을 생성할 때 기본 색공간이 SRGB라고 했던것을 기억하실수 있으실겁니다. 색공간이 달라 false가 나왔을 겁니다. 색공간을 red와 동일하게 바꾸면 true가 나오겠네요.

print(red == redAsset) 실행해보면
true 를 출력합니다.

뭔가 싱겁네요?

네 이 정도로 끝나면 제가 이 글을 쓸 일은 없었을 겁니다.

그러면 살짝 코드 도약을 해보겠습니다.
먼저 redRGB를 다음과 같이 수정합니다.

let redRGB = UIColor(red: 0.9, green: 0.0, blue: 0.0, alpha: 1.0)

그리고 redAsset의 red를 1.0에서 0.9로 다음과 같이 수정합니다.

이 두 색을 다음과 같이 테스트 케이스에서 비교해보겠습니다.

XCTAssertEqual(redRGB, redAsset)

그리고 테스트를 실행하면 다음과 같이 실패하게 됩니다.

XCTAssertEqual failed: ("UIExtendedSRGBColorSpace 0.9 0 0 1") is not equal to ("UIExtendedSRGBColorSpace 0.9 0 0 1")

뭘까요, 같다면서 틀리다는 이 결과는?

일단 각 색의 실제 값을 출력해보겠습니다.

  • redRGB의 rgb 컬러값은 각각 0.9, 0.0, 0.0, 1.0
  • redAsset의 rgb 컬러값은 각각 0.8999999761581421, 0.0, 0.0, 1.0

결국 실제값이 다름에도 불구하고 description에서는 red의 값을 둘다 동일하게 0.9로 표시하고 있습니다.

UIColor의 description

UIColor의 description에서는 색을 출력할 때, 좀 다른 방식을 쓰고 있는거 같군요. 이번에는 UIDeviceRGBColor의 description에 심볼릭 브레이크포인트를 걸어보겠습니다.

UIKitCore`-[UIDeviceRGBColor description]:
->  0x? <+?>: pushq  %rbp
    0x? <+?>: movq   %rsp, %rbp
    0x? <+?>: pushq  %r15
    0x? <+?>: pushq  %r14
    0x? <+?>: pushq  %r12
    0x? <+?>: pushq  %rbx
    0x? <+?>: movq   %rdi, %rbx
    0x? <+?>: movq   0x40f488b1(%rip), %r14    ; (void *)0x00007fff87b502e8: NSString
    0x? <+?>: movq   0x40f1a91a(%rip), %rsi    ; "colorSpaceName"
    0x? <+?>: movq   0x3e051c93(%rip), %r12    ; (void *)0x00007fff50ba4400: objc_msgSend
    0x? <+?>: callq  *%r12
    0x? <+?>: movq   %rax, %rdi
    0x? <+?>: callq  0x7fff49241ca0            ; symbol stub for: objc_retainAutoreleasedReturnValue
    0x? <+?>: movq   %rax, %r15
    0x? <+?>: movq   0x40f621ce(%rip), %rax    ; UIDeviceRGBColor.redComponent
    0x? <+?>: movsd  (%rbx,%rax), %xmm0        ; xmm0 = mem[0],zero 
    0x? <+?>: movq   0x40f621ca(%rip), %rax    ; UIDeviceRGBColor.greenComponent
    0x? <+?>: movsd  (%rbx,%rax), %xmm1        ; xmm1 = mem[0],zero 
    0x? <+?>: movq   0x40f621c6(%rip), %rax    ; UIDeviceRGBColor.blueComponent
    0x? <+?>: movsd  (%rbx,%rax), %xmm2        ; xmm2 = mem[0],zero 
    0x? <+?>: movq   0x40f621c2(%rip), %rax    ; UIDeviceRGBColor.alphaComponent
    0x? <+?>: movsd  (%rbx,%rax), %xmm3        ; xmm3 = mem[0],zero 
    0x? <+?>: movq   0x40ef404e(%rip), %rsi    ; "stringWithFormat:"
    0x? <+?>: leaq   0x3e0ae7df(%rip), %rdx    ; @"%@ %g %g %g %g"

뭔가 많이 보이지만, 짧게 요약해보면 다음과 같은 코드가 됩니다.

return [NSString stringWithFormat:@"%@ %g %g %g %g", (colorSpaceName), redComponent, greenComponent, blueComponent]

0.8999999761581421 가 0.9로 보이던 마법은 %g 문자열 포맷이 이유였군요. (참고 String Format Specifiers)

한 번, 소숫점을 출력하는 모든 문자열 포맷으로 출력해보겠습니다.

0.8999999761581421
%f -> 0.900000
%e -> 9.000000e-01
%E -> 9.000000E-01
%g -> 0.9
%G -> 0.9
%a -> 0x1.ccccccp-1
%A -> 0X1.CCCCCCP-1
%F -> 0.900000

뭘 쓰던 a, A를 제외하면 0.9로 출력을 합니다

  • 출력시 소숫점 자릿수를 제대로 지정하게되면 제대로 출력이 가능합니다.
  • 0.9가 0.8xxx가 되는 문제는 개발자라면 컴퓨터의 실수 표현과 관련한 익숙한 문제라고 생각합니다. 별도의 설명이나 참고문서는 달지 않겠습니다.

어셋은 색을 어떻게 저장할까?

아까 어셋을 바꿀때 red 색을 0.900으로 바꿨습니다. 혹시 색이 0.8xxxx로 들어간것일까요? 확인해보겠습니다.
asset의 red색을 파인더로 찾아가게 되면, Contents.json 파일이 보입니다. 들여다보면 다음과 같네요.

{
  "colors" : [
    {
      "color" : {
        "color-space" : "extended-srgb",
        "components" : {
          "alpha" : "1.000",
          "blue" : "0.000",
          "green" : "0.000",
          "red" : "0.900"
        }
      },
      "idiom" : "universal"
    }
  ],
  "info" : {
    "author" : "xcode",
    "version" : 1
  }
}

여기서는 문제가 없습니다.

저는 이 케이스를 만났을 때, 어셋에서 색을 불러올 때 실수 변환의 문제가 있을거라 추측했습니다.

자 그러면, 또 다시 어셈블리의 세상으로 들어가 보겠습니다.

UIColor asset

먼저 +[UIColor colorNamed:]로 심볼릭 브레이크포인트를 걸어보겠습니다.

0x? <+?>:  movq   0x?(%rip), %rsi    ; "colorNamed:inBundle:compatibleWithTraitCollection:"
...
0x? <+?>: jmpq   *0x?(%rip)         ; (void *)0x?: objc_msgSend

그러면 해당 클래스 메소드가 다시 클래스 메소드 colorNamed:inBundle:compatibleWithTraitCollection:를 호출하는 것을 알 수 있습니다.
+[UIColor colorNamed:inBundle:compatibleWithTraitCollection:]에 심볼릭 브레이크 포인트를 걸어보겠습니다.
그러면 나오는 어셈블리 코드 라인에서 다음이 색을 가져올거라는 것을 쉽게 추측이 가능합니다.

0x? <+?>: movq   0x?(%rip), %rsi    ; "colorNamed:withTraitCollection:"

이때, 해당 메소드를 가지고 있는 객체를 레지스터에서 찾아보면, _UIAssetManager 라는 클래스에서 호출하는 것을 알 수 있습니다. 이제부터는 private class를 추적하게 됩니다.
-[_UIAssetManager colorNamed:withTraitCollection:]로 심볼릭 브레이크 포인트를 걸어봅시다.

UIKitCore`-[_UIAssetManager colorNamed:withTraitCollection:]:
...
    0x7fff49039cd0 <+210>: movq   0x40760e21(%rip), %rsi    ; "colorWithName:displayGamut:deviceIdiom:"
    0x7fff49039cd7 <+217>: movq   %r14, %rdx
    0x7fff49039cda <+220>: movq   %rbx, %rcx
    0x7fff49039cdd <+223>: callq  *0x3d873b65(%rip)         ; (void *)0x00007fff50ba4400: objc_msgSend

colorWithName:displayGamut:deviceIdiom:메소드는 CUICatalog의 메소드입니다. 이 메소드는 CUINamedColor 객체를 생성하고 그 객체에서는 CGColor를 가지고 있습니다. 이 CGColor 구조체를 메모리에서 64비트 실수값으로 읽어보면 많은 숫자들이 나오지만, 중요한 부분만 남겨 보겠습니다.. (*참고 CUINamedColor.h)

  • 참고할 실수의 바이트값은 다음과 같고, Swift에서는 Double형입니다.
    • 0.8999999761581421 -> 0 0 0 C0 CC CC EC 3F
    • 0.9 -> CD CC CC CC CC CC EC 3F

메모리에서 실수를 출력할 명령은 다음과 같습니다.
lldb) memory read <address> -f float64

출력은 다음과 같습니다.
... 0.899999976158142 0 0 1 ....
rgba의 색이 제대로 들어가 있다는 것을 확인할 수 있습니다.

저와 여러분을 괴롭히는 일은 이쯤에서 그만두는게 좋겠네요. CUICatalog 에서 더 들어가면 private framework인 Bom.framework(참조: Reverse engineering the .car file format (compiled Asset Catalogs)의 함수들을 사용하여 Assets.car 파일에서 색 데이터를 추출하는데까지 도달하는데, 이때 red의 값은 0.8999999761581421 -> 0 0 0 C0 CC CC EC 3F로 넘어옵니다.
이유는 Xcode에서 asset을 컴파일하는데 사용하는 actool이 이미 0.9를 0.89999997615814209로 저장해버리기 때문입니다.
생성한 앱 파일에서 Assets.car 파일을 찾아서 다음 명령을 통해 출력해보겠습니다.

> assetutil -I Assets.car

[
  {
    "Appearances" : {
      "UIAppearanceAny" : 0
    },
    "AssetStorageVersion" : "Xcode 11.5 (11E608c) via IBCocoaTouchImageCatalogTool",
    "Authoring Tool" : "@(#)PROGRAM:CoreThemeDefinition  PROJECT:CoreThemeDefinition-447.1\n",
    "CoreUIVersion" : 609,
    "DumpToolVersion" : 609.4,
    "Key Format" : [
      "kCRThemeScaleName",
      "kCRThemeIdentifierName",
      "kCRThemeElementName",
      "kCRThemePartName"
    ],
    "MainVersion" : "@(#)PROGRAM:CoreUI  PROJECT:CoreUI-609.4\n",
    "Platform" : "ios",
    "PlatformVersion" : "13.5",
    "SchemaVersion" : 2,
    "StorageVersion" : 17,
    "ThinningParameters" : "optimized <idiom 1> <subtype 569> <scale 2> <gamut 1> <graphics 7> <graphicsfallback (6,5,4,3,2,1,0)> <memory 4> <deployment 5> <hostedIdioms (4)>",
    "Timestamp" : 1592676227
  },
  {
    "AssetType" : "Color",
    "Color components" : [
      0.89999997615814209,
      0,
      0,
      1
    ],
    "Colorspace" : "extended srgb",
    "Name" : "red",
    "NameIdentifier" : 40330,
    "Scale" : 1
  }
]

하하하. 저는 actool을 생각하지 못하고 일주일정도 꼬박 퇴근 후 남는 시간을 디버깅에 투자했는데, 정말 얼마나 허무하던지요.
하지만, 덕분에 저는 디버깅시에 이전 보다는 좀 더 코드를 읽기가 수월해질거 같습니다.

마지막으로

UIColor를 테스트하다 여기까지 왔습니다. 결국 테스트를 어떻게 하느냐 결론을 내야할텐데, 결론적으로 말하자면 비교시에는 display p3 색공간으로 색을 변환해서 비교하면 됩니다.

UIColor에 색을 Display P3 색공간으로 변환하는 프로퍼티를 extension으로 추가했습니다.
그리고 비교시에 해당 프로퍼티를 사용하도록 하면,

extension UIColor {
    var dp3: UIColor {
        let converted = self.cgColor.converted(to: CGColorSpace(name: CGColorSpace.displayP3)!, intent: .defaultIntent, options: nil)!
        let rgba = converted.components!
        return UIColor(displayP3Red: rgba[0], green: rgba[1], blue: rgba[2], alpha: rgba[3])
    }
}
...
XCTAssertEqual(redRGB.dp3, redAsset.dp3)

테스트 케이스가 드디어 성공합니다.

테스트 하나 성공시키기 위해 꽤 돌아왔습니다. 여기까지 봐주신 분들에게 고맙습니다. 일하고 공부하면서 짬짬이 쓰느라 두 주가 조금 넘게 걸렸는데, 누군가에게는 조금이라도 유용한 글이었으면 싶습니다.

오류 지적이나 좋은 정보, 개선 사항은 얼마든지 환영합니다.

이 다음에는 또 뭘 분석해볼까 찾아봐야겠네요.

profile
iOS Developer

2개의 댓글

comment-user-thumbnail
2020년 8월 4일

좋아요 를 누르기 위해 가입하고 왔습니다.
테스트가 잘 안되네?! 하고 넘겼는데 이런 분석을..!
멋진 글이네요!

1개의 답글