[Swift] Swift에서 NSInvocation, NSMethodSignature 사용하기

서론

Swift로 앱 개발을 할 때 사용할 수 없는 메소드가 있습니다. 대표적으로 NSInvocation, NSMethodSignature 이 두 개인데요, 헤더를 참고해보면 아래와 같이 되어있습니다.

NS_SWIFT_UNAVAILABLE 구문으로 인해 Swift에서는 사용할 수 없도록 제약을 둔 것인데요(Obj-C는 가능합니다), 함수 역할 자체가 안정성과 거리가 먼 런타임에서 임의로 함수 호출을 수행하다 보니 Apple에서 제약을 두었습니다. 사실 안쓰는게 안정성 측면에서도 더 이점이기도 하지만 사용해야하는 경우가 가끔 생기는데요, 바로 Private API를 호출하는 경우입니다. 저의 경우 Polka 라는 iOS 개발 생산성 향상을 위한 앱을 개발하고 있는데 Private API 호출이 필요한 경우가 생겨서 누가 만든게 없나? 싶어서 찾아보았습니다. 그런데..!

본론

역시 누군가 만든 프로젝트가 있었습니다. SwiftyInvocation 이라는 프로젝트인데, 아쉽게도 6~7년전에 개발된 자료라 Swift Package Manager를 지원하지 않고 있었습니다. 옛날 자료지만 테스트 코드도 나름 작성되어 있었고, Obj-C는 이제 개발 중단된 언어니까 상관없을 것이라 생각해서 레포지토리 Fork후 제가 직접 SPM을 지원할 수 있도록 환경을 구성할 생각입니다. 일단 package를 생성해야겠죠? git clone 명령어 실행 후 아래의 명령어를 실행합니다.

$ swift package init

위 명령어를 실행했다면 Sources, Tests 폴더가 프로젝트 루트에 생성되었을 겁니다.

첫번째 이슈: CocoaPods 폴더 <-> SPM 폴더 sync

그런데.. 현재 프로젝트 구조를 살펴보면 CocoaPods 지원을 위해 SwiftyInvocation 폴더 안에 모든 파일이 다 들어가 있습니다. 이걸 그대로 복사 붙여넣기 하기엔 코드가 중복되는 것 같고, 추후 다른 곳을 수정했는데 CocoaPods에는 수정본이 반영안되는 경우도 생길 것 같습니다. 이럴 때 좋은 방법이 있습니다. 바로 심볼릭 링크를 이용해서 CocoaPods에 구현된 소스들은 Sources 폴더에 옮기는 겁니다.

이제 SPM에서 제공되는 소스 코드를 수정해도 CocoaPods에 반영됩니다.

두번째 이슈: SPM에서 Obj-C 지원

SwiftyInvocation 라이브러리는 Obj-C와 Swift를 동시에 사용하고 있고, SPM에서는 하나의 타겟에 두 개의 언어가 들어갈 수 없습니다. 그러므로 두개의 타겟을 만들고 Swift, Obj-C를 각각 붙여줘야 합니다.

아래와 같이 Package.swift 파일을 수정해줍니다.

.target(name: "SwiftyInvocation",
        dependencies: ["SwiftyInvocationObjC"]),
.target(name: "SwiftyInvocationObjC",
        dependencies: [])

그리고 Sources 폴더 내 SwiftyInvocation 폴더와 SwiftyInvocationObjC 폴더를 각각 생성해줍니다.

SwiftyInvocation 폴더에는 swift 파일만 포함하도록 하고 SwiftyInvocationObjC 폴더엔 .h, .m 파일이 포함되도록 합니다.

위처럼 제대로 나눠줬다면 ObjC 폴더에 include라는 디렉토리를 생성하고 SwiftyInvocationObjC.h 파일과 module.modulemap 파일을 생성해줍니다. 원본 Target만 참조해도 ObjC를 사용할 수 있게 modulemap 파일을 만드는 과정입니다.

module SwiftyInvocationObjC {
    umbrella header "SwiftyInvocationObjC.h"
    export *
}
//
//  SwiftyInvocationObjC.h
//
//
//  Created by Insu Byeon on 1/21/24.
//

#import "../SIInvocation.h"
#import "../NSObject+SwiftyInvocation.h"
#import "../SIMethodSignature.h"
#import "../SIMethodSignature_Private.h"

참고로 헤더 파일을 선언할 때 import 순서도 주의해야합니다. 순서에 따라 컴파일이 안되는 경우가 발생합니다. (SIMethodSignature_Private.h 가 SIMethodSignature.h 보다 먼저 참조되면 찾을 수 없음)

이제 Swift 파일을 손 볼 차례입니다. Sources/SwiftyInvocation.swift 파일을 열어서 상단에 아래의 코드를 넣어주세요.

#if canImport(SwiftyInvocationObjC)
@_exported import SwiftyInvocationObjC
#endif

@_exported 구문으로 ObjC 모듈까지 접근하게 할 수 있습니다. 그런데 ObjC target은 SPM에서만 존재하는 Target이므로 canImport 구문으로 기존에 구현된 CocoaPods, Carthage에서는 해당 타겟이 없을테니 분기 처리를 해주어야합니다.

위 작업을 마치고 나면 동작이 잘 되는지 확인해보고, 테스트 코드도 정상적으로 통과되는 지 확인된 후 깃에 업로드 하면 이제 SPM을 지원하게 되었습니다.

그러면 이제 SwiftyInvocation을 사용해봅시다.

기존 Obj-C 코드로는 아래와 같이 NSInvocation을 호출했습니다.

+ (NSArray<UIWindow *> *)allWindows {
    BOOL includeInternalWindows = YES;
    BOOL onlyVisibleWindows = NO;

    // Obfuscating selector allWindowsIncludingInternalWindows:onlyVisibleWindows:
    NSArray<NSString *> *allWindowsComponents = @[
        @"al", @"lWindo", @"wsIncl", @"udingInt", @"ernalWin", @"dows:o", @"nlyVisi", @"bleWin", @"dows:"
    ];
    SEL allWindowsSelector = NSSelectorFromString([allWindowsComponents componentsJoinedByString:@""]);

    NSMethodSignature *methodSignature = [[UIWindow class] methodSignatureForSelector:allWindowsSelector];
    NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSignature];

    invocation.target = [UIWindow class];
    invocation.selector = allWindowsSelector;
    [invocation setArgument:&includeInternalWindows atIndex:2];
    [invocation setArgument:&onlyVisibleWindows atIndex:3];
    [invocation invoke];

    __unsafe_unretained NSArray<UIWindow *> *windows = nil;
    [invocation getReturnValue:&windows];
    return windows;
}

위 코드를 이제 아래와 같이 Swift로 작성할 수 있습니다.

import SwiftyInvocation

var includeInternalWindows: Bool = true
var onlyVisibleWindows: Bool = false

let selector: Selector = NSSelectorFromString("allWindowsIncludingInternalWindows:onlyVisibleWindows:")

let methodSignature = UIWindow.si_methodSignature(for: selector)
let invocation = SIInvocation(methodSignature: methodSignature)

invocation.target = UIWindow.self
invocation.selector = selector
invocation.setArgument(&includeInternalWindows, at: 2)
invocation.setArgument(&onlyVisibleWindows, at: 3)
invocation.invoke()

var returnValue: Unmanaged<NSString>?
invocation.getReturnValue(&returnValue)
let resultAsArray = unsafeBitCast(returnValue, to: NSArray.self)
let windows = resultAsArray as? [UIWindow]

또는 SwiftyInvocation 만의 방법인 아래의 코드로도 사용할 수 있습니다.

let selector: Selector = NSSelectorFromString("allWindowsIncludingInternalWindows:onlyVisibleWindows:")
typealias Type = @convention(c) (AnyObject, Selector, Bool, Bool) -> Unmanaged<AnyObject>
let implementation = try! swift_getImplementation(object: UIWindow.self, selector: selector, implType: Type.self)
let result = implementation(UIWindow.self, selector, true, false).takeUnretainedValue()
let resultAsArray = unsafeBitCast(result, to: NSArray.self)
let windows = resultAsArray as? [UIWindow]

결론

이 글을 읽고 있는 분들은 위의 삽질을 할 필요 없이 https://github.com/chorim/SwiftyInvocation 를 사용하시면 됩니다.

그리고 꼭! Private API 호출 목적으로만 사용하세요!