Objective-C Associated Object & Method Swizzling

·

4 min read

연관객체(Associated Object)란?

기존 클래스에 동적으로 속성을 추가할 수 있는 Objective-C 런타임 기능입니다.

이를 활용하면 클래스를 직접수정하지 않고 새로운 프로퍼티를 추가할 수 있습니다.

  • 기존 클래스를 수정하지 않고 새로운 속성 추가 가능

  • extension과 함께 사용하여 새로운 속성 추가 가능

  • 런타임에서 동적으로 저장하고 관리

  • 객체가 해제될 때 자동으로 연관된 객체도 정리가능

연관객체(Associated Object)가 필요한 경우

  1. 기존 클래스를 수정할 수 없는 경우

    • UIScrollView, UIViewController와 같은 UIKit 클래스는 직접수정할 수 없음

    • extension을 통해 프로퍼티 추가 불가

  2. 런타임에서 프로퍼티를 추가해야하는 경우

    • 특정 view에 대한 추가적인 설정 값을 저장하는 경우

    • 객체의 라이프 사이클 동안 동적으로 데이터를 추가하고 관리할 필요가 있는 경우

  3. 새로운 동작을 추가하면서 서브클래싱을 피하고 싶은 경우

연관객체(Associated Object)를 활용한 Swift Method Swizzling

특정 view에 gesture를 추가해서 사용하는 경우 의도하지 않게 scroll action이 동작하지 않는 경우를 발견했습니다.

이를 해결하기 위해 UIScrollView에서 touchesShouldCancel(in:) 메서드 동작을 변경하고자 합니다.

(포인트는 모든 scrollView(tableView, collectionView포함)에 해당 기능을 각각 구현하지 않고 공통된처리를 하고자함)

  1. 연관객체(Associated Object) 생성

    •   extension UIScrollView {
            var touchesShouldCancelClass: AnyClass? {
                get {
                    withUnsafePointer(to: &AssociatedObjectKeys.touchesShouldCancelClass) {
                        return objc_getAssociatedObject(self, $0) as? AnyClass
                    }
                }
                set {
                    withUnsafePointer(to: &AssociatedObjectKeys.touchesShouldCancelClass) {
                        objc_setAssociatedObject(self, $0, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
                    }
                }
            }
        }
      
    • 역할: 특정 클래스(AnyClass) 타입을 저장하고 이를 기반으로 touchesShouldCancel(in:)를 변경하는 용도로 사용

    • UIScrollView에는 기본적으로 touchesShouldCancelClass가 없기 때문에 연관객체로 추가

    • withUnsafePointer를 활용하여 AssociatedObjectKeys.touchesShouldCancelClass를 메모리 주소 기반 키 값으로 사용

  2. 메서드 스위즐링(Method Swizzling)

    •   static func swizzleTouchesShouldCancel() {
            let originalSelector = #selector(touchesShouldCancel(in:))
            let swizzledSelector = #selector(swizzled_touchesShouldCancel(in:))
      
            guard let originalMethod = class_getInstanceMethod(UIScrollView.self, originalSelector),
                  let swizzledMethod = class_getInstanceMethod(UIScrollView.self, swizzledSelector)
            else { return }
      
            method_exchangeImplementations(originalMethod, swizzledMethod)
        }
      
    • 역할: UIScrollView의 기본 구현을 사용자 정의 메서드로 교체하는 작업을 수행

    • class_getInstanceMethod를 활용하여 기존의 touchesShouldCancel(in:) 메서드와 사용자 정의 swizzled_touchesShouldCancel(in:) 메서드를 가져옴

    • method_exchangeImplementations를 이용하여 원본 메서드와 교체된 메서드의 구현을 변경

  3. 새로운 메서드 정의

    •   @objc func swizzled_touchesShouldCancel(in view: UIView) -> Bool {
            if let touchesShouldCancelClass, view.isKind(of: touchesShouldCancelClass) {
                return true
            } else {
                return swizzled_touchesShouldCancel(in: view)
            }
        }
      
    • 역할: touchesShouldCancelClass에 저장된 클래스 타입과 비교하여 특정 클래스에 대해 true를 반환

  4. 연관 객체(Associated Object) 키 값 관리

    •   private struct AssociatedObjectKeys {
            @MainActor static var touchesShouldCancelClass = "touchesShouldCancelClass"
        }
      
    • 역할: 연관 객체를 get, set할 때 필요한 고유한 키 값을 생성하여 관리

  5. 사용 예시

    •   let scrollView = UIScrollView()
        UIScrollView.swizzleTouchesShouldCancel() // 스위즐링 적용
      
        scrollView.touchesShouldCancelClass = UIButton.self // UIButton에 대해 터치 취소 적용
      
    • UIScrollView.swizzleTouchesShouldCancel()의 경우 AppDelegate와 같은 곳에서 앱 전역에서 공통적으로 호출 할 수 있습니다.

objc_AssociationPolicy

연관 객체(Associated Object)를 set할 때 objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC을 사용한 이유는 뭘까요?

objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC는 objc_AssociationPolicy 중 하나의 선택지로, 연관 객체를 설정할 때 어떤 방식으로 메모리를 관리할지 결정하는 옵션입니다.

정책 값설명메모리 관리스레드 안정성
OBJC_ASSOCIATION_ASSIGN약한 참조(Weak Reference). 객체가 해제되면 garbage value가 남을 수 있음
OBJC_ASSOCIATION_RETAIN_NONATOMIC강한 참조(Strong Reference). 비원자적.
OBJC_ASSOCIATION_COPY_NONATOMIC객체를 복사(Copy). 비원자적.
OBJC_ASSOCIATION_RETAIN강한 참조. 원자적(Atomic).
OBJC_ASSOCIATION_COPY객체를 복사. 원자적(Atomic).

그렇다면 왜 retain nonatomic을 선택했을까요?

  1. 강한 참조(Strong Reference) 유지

    • AnyClass? 타입을 저장하기 때문에 값이 해제되지 않고 유지될 필요가 있음

    • OBJC_ASSOCIATION_ASSIGN을 사용하면 객체가 해제될 때 포인터가 가리키는 값이 사라질 수 있음(크래시)

    • 따라서 강한 참조가 필요한 상황이기 때문에 RETAIN 계열 정책이 필요!

  2. 비원자적(Non-Atomic) 연산 선택

    • OBJC_ASSOCIATION_RETAIN을 사용하면 스레드 동기화는 적용되지만, 불필요한 성능 오버헤드 초래

    • 해당 case는 멀티스레드 환경이 필요하지 않기 때문에(UI 스레드에서만 동작) 굳이 Atomic연산이 필요하지 않음

  3. COPY정책이 필요하지 않음

    • COPY 정책은 값 타입이거나 변경가능한 객체(NSMutableString 등)을 복사해야하는 경우 사용

    • AnyClass는 타입 메타데이터를 저장하는 것이므로 복사가 필요 없음

Recursion Issue

근데 코드를 보다보면 의문이 생기는 점이 있습니다.

@objc func swizzled_touchesShouldCancel(in view: UIView) -> Bool {
    if let touchesShouldCancelClass, view.isKind(of: touchesShouldCancelClass) {
        return true
    } else {
        return swizzled_touchesShouldCancel(in: view)
    }
}

else문을 타게되면 무한루프가 발생하지 않을까요?

결론적으로는 무한루프가 발생하지 않는데요, 이는 메서드 스위즐링 때문입니다.

메서드 스위즐링은 기존 메서드와 새로운 메서드를 교체하는 기법입니다.

static func swizzleTouchesShouldCancel() {
    let originalSelector = #selector(touchesShouldCancel(in:))
    let swizzledSelector = #selector(swizzled_touchesShouldCancel(in:))

    guard let originalMethod = class_getInstanceMethod(UIScrollView.self, originalSelector),
          let swizzledMethod = class_getInstanceMethod(UIScrollView.self, swizzledSelector)
    else { return }

    method_exchangeImplementations(originalMethod, swizzledMethod)
}

즉 이 코드를 실행하고 나면 UIScrollView의 touchesShouldCancel(in:) 메서드와 swizzled_touchesShouldCancel(in:) 메서드가 서로 교체됩니다.

요약하면 swizzled_touchesShouldCancel(in:) 재호출하는 것처럼 보이는 코드가 실제로는 touchesShouldCancel(in:)를 호출해주고 있는 것이라고 보면됩니다.