꿈돌이랜드

메서드 스위즐링을 적용하여 실수로부터 벗어나기 본문

Programming/iOS

메서드 스위즐링을 적용하여 실수로부터 벗어나기

loinsir 2025. 3. 17. 20:53
반응형

메서드 스위즐링이란?

  • 메서드 스위즐링은 런타임에 함수의 구현부를 뒤섞는 방법을 말합니다.
  • 구현 방법은 보통 다음과 같이 이뤄집니다.
    • 보통 class_getInstanceMethodclass_getClassMethod 와 같은 방법을 사용해서 각 메서드의 셀렉터를 가져오고
    • 각각을 method_exchangeImplementation으로 뒤바꾸어 구현을 바꾸어서 구현, appdelegate 같은 곳에서 swizzle을 한번 실행시켜줍니다.
    • 단, 각 메서드는 @objc 런타임에 노출되어야 하고, dynamic으로 마킹되어있어야 함
      • extension에 정의된 경우는 자동으로 dynamic처리가 된 것으로 칩니다.
import UIKit

fileprivate var swizzleEnabled = false

extension UIViewController {
    static let methodSwizzled: Void = {
        guard !swizzleEnabled else { return }
        swizzleEnabled = true

        // #selector: swift의 메소드나 함수를 참조하는 방법 (Selector 타입)
        let originalSelector = #selector(viewWillAppear(_:))
        // class_getInstanceMethod: Objecitve-C 런타임 라이브러리에서 제공하며, 메소드를 검색하는데 사용
        let originalMethod = class_getInstanceMethod(UIViewController.self, originalSelector)

        let swizzledSelector = #selector(swizzledViewWillAppear(_:))
        let swizzledMethod = class_getInstanceMethod(UIViewController.self, swizzledSelector)

        guard let originalMethod, let swizzledMethod else { return }
        method_exchangeImplementations(originalMethod, swizzledMethod)
    }()

    @objc private func swizzledViewWillAppear(_ animated: Bool) {
        // Swizzling된 메서드에서 추가적인 동작 수행
        print("Swizzled viewWillAppear called for \(self)")
    }
}
  • 메서드 스위즐링의 단점
    • 다른 프레임워크나 라이브러리를 가져와 사용할때 이미 스위즐링 되어있는 메서드를 또 스위즐링 하고 있지는 않은지 확인해야 합니다.
    • 새로이 iOS 버전업 되면 스위즐링이 실패할 가능성이 있습니다.
    • 이에 예상치 못한 버그를 발생시킬 수 있습니다.
    • 이미 짜여진 standard 메서드가 아닌 본인이 작성한 메서드가 호출되는지 확인해야합니다.

적용 사례

  • 현재 사내 앱은 아이패드를 지원하고 있는데, 아이패드에서 UIAlertController나, UIActivityController 사용시, 반드시 popoverPresentationController를 적용해줘야 하는 문제가 있습니다.
  • Apple 개발자 포럼에서 이 문제가 여러 번 언급되었습니다. 특히 UIAlertController를 iPad에서 사용할 때 popoverPresentationController 구성은 필수적이며, 구성하지 않으면 다음과 같은 크래시 메시지가 발생할 수 있습니다
  • 이러한 크래시를 방지하기 위해 iPad에서 액션 시트를 표시할 때는 항상 popoverPresentationController의 sourceView 및 sourceRect 또는 barButtonItem 속성을 설정해야 합니다.
  • 그렇지 않으면 다음과 같은 오류가 발생합니다.
    Terminating app due to uncaught exception 'NSGenericException', reason: 'Your application has presented a UIAlertController (<UIAlertController: ...>) of style UIAlertControllerStyleActionSheet. The modalPresentationStyle of a UIAlertController with this style is UIModalPresentationPopover. You must provide location information for this popover through the alert controller's popoverPresentationController. You must provide either a sourceView and sourceRect or a barButtonItem. If this information is not known when you present the alert controller, you may provide it in the UIPopoverPresentationControllerDelegate method -prepareForPopoverPresentation.'
  • 그래서 초기에 다음과 같이 UIAlertController 사용부 마다 ensureActionSheet 라는 메서드를 만들어 적용시켜주어야 했습니다.
extension UIAlertController {
    func ensureActionSheet(_ viewController: UIViewController) -> UIAlertController { 
      if UIDevice.current.userInterfaceIdiom != .pad { // 디바이스 타입이 iPad가 아닐때 
        return self 
      }
      guard let popover = self.popoverPresentationController else { 
        return self 
      }

      popover.sourceView = viewController.view 
      popover.sourceRect = CGRect(x: viewController.view.bounds.midX, y: viewController.view.bounds.midY, width: 0, height: 0) 
      popover.permittedArrowDirections = [] 

      return self
    }
}

// 사용법

let alert = UIAlertController(title: "알림", message: "알림창입니다.", preferredStyle: .alert).ensureActionSheet(self)
  • 하지만 실수로 .ensureActionSheet(self)호출을 누락하기라도 하면 크래시가 나는 단점이 있었습니다.
  • 그래서 이 부분에 메서드 스위즐링을 적용하면 좋겠다는 생각이 났고 다음과 같이 적용하게 되었습니다.
import UIKit

extension UIAlertController {
    // 앱 시작 시 한 번만 호출되는 메서드
    static let swizzleAlertController: Void = {
        let originalSelector = #selector(UIAlertController.init(title:message:preferredStyle:))
        let swizzledSelector = #selector(UIAlertController.swizzled_init(title:message:preferredStyle:))

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

        method_exchangeImplementations(originalMethod, swizzledMethod)
    }()

    // UIAlertController 초기화 시 자동으로 iPadOS에서 액션 시트 설정을 보장하는 메서드
    @objc private func swizzled_init(title: String?, message: String?, preferredStyle: UIAlertController.Style) -> UIAlertController {
        // 원래 초기화 메서드 호출 (swizzle로 인해 실제로는 원래 구현을 호출)
        let controller = self.swizzled_init(title: title, message: message, preferredStyle: preferredStyle)

        // preferredStyle이 actionSheet일 경우에만 적용
        if preferredStyle == .actionSheet && UIDevice.current.userInterfaceIdiom == .pad {
            if let popover = controller.popoverPresentationController {
                // 현재 키 윈도우에서 최상위 뷰 컨트롤러 가져오기
                if let viewController = UIApplication.shared.keyWindow?.rootViewController?.topMostViewController() {
                    popover.sourceView = viewController.view
                    popover.sourceRect = CGRect(x: viewController.view.bounds.midX, 
                                              y: viewController.view.bounds.midY, 
                                              width: 0, 
                                              height: 0)
                    popover.permittedArrowDirections = []
                }
            }
        }

        return controller
    }
}

// 최상위 뷰 컨트롤러를 가져오기 위한 확장
extension UIViewController {
    func topMostViewController() -> UIViewController {
        if let presented = self.presentedViewController {
            return presented.topMostViewController()
        }

        if let tabBarController = self as? UITabBarController, 
           let selected = tabBarController.selectedViewController {
            return selected.topMostViewController()
        }

        if let navigationController = self as? UINavigationController, 
           let visibleViewController = navigationController.visibleViewController {
            return visibleViewController.topMostViewController()
        }

        return self
    }
}

// 앱 시작 시 호출되어 swizzling 수행
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Method swizzling 실행
        _ = UIAlertController.swizzleAlertController

        return true
    }
}
  • 이를 통해, 사용처에서 특별한 처리 없이 메서드 스위즐링을 통해 크래시를 방지할 수 있게 되었습니다. 😊
반응형