From 82195cd24bed4ac68a94dd9ddb9d5f0b27fa767c Mon Sep 17 00:00:00 2001 From: Adam Sharp Date: Sun, 16 Aug 2020 09:49:02 -0400 Subject: [PATCH] Reimplement ObjectiveC runtime code in Swift This gives us a number of benefits: - We can delete the private `CombineViewModelObjC` altogether. This simplifies the build and removes the need to hide or obfuscate this implementation detail. - We can wrap the ObjectiveC.runtime module using the Swift standard library, enabling much more expressive code. - The Swift implementation is less code, easier to understand and will be easier to maintain. --- Package.swift | 6 +- Sources/CombineViewModel/ClassHierarchy.swift | 32 +++++ Sources/CombineViewModel/MethodList.swift | 27 ++++ Sources/CombineViewModel/ObjCRuntime.swift | 82 +++++++++++++ ...IViewController+ViewDidLoadPublisher.swift | 1 - Sources/CombineViewModel/ViewModel.swift | 3 +- .../CombineViewModelObjC.m | 115 ------------------ .../include/CombineViewModelObjC.h | 16 --- .../HookedViewDidLoadTests.swift | 26 ++-- .../ViewModelTests.swift | 3 +- .../ObjCTestSupport/TestObjCViewController.m | 15 +++ .../ObjCTestSupport/include/ObjCTestSupport.h | 16 +++ 12 files changed, 193 insertions(+), 149 deletions(-) create mode 100644 Sources/CombineViewModel/ClassHierarchy.swift create mode 100644 Sources/CombineViewModel/MethodList.swift create mode 100644 Sources/CombineViewModel/ObjCRuntime.swift delete mode 100644 Sources/CombineViewModelObjC/CombineViewModelObjC.m delete mode 100644 Sources/CombineViewModelObjC/include/CombineViewModelObjC.h create mode 100644 Tests/ObjCTestSupport/TestObjCViewController.m create mode 100644 Tests/ObjCTestSupport/include/ObjCTestSupport.h diff --git a/Package.swift b/Package.swift index a1d1cfa..0b62582 100644 --- a/Package.swift +++ b/Package.swift @@ -12,8 +12,8 @@ let package = Package( .library(name: "CombineViewModel", targets: ["CombineViewModel"]), ], targets: [ - .target(name: "CombineViewModel", dependencies: ["CombineViewModelObjC"]), - .target(name: "CombineViewModelObjC"), - .testTarget(name: "CombineViewModelTests", dependencies: ["CombineViewModel", "CombineViewModelObjC"]), + .target(name: "CombineViewModel"), + .target(name: "ObjCTestSupport", path: "Tests/ObjCTestSupport"), + .testTarget(name: "CombineViewModelTests", dependencies: ["CombineViewModel", "ObjCTestSupport"]), ] ) diff --git a/Sources/CombineViewModel/ClassHierarchy.swift b/Sources/CombineViewModel/ClassHierarchy.swift new file mode 100644 index 0000000..e66bc0f --- /dev/null +++ b/Sources/CombineViewModel/ClassHierarchy.swift @@ -0,0 +1,32 @@ +#if canImport(ObjectiveC) +import ObjectiveC.runtime + +struct ClassHierarchy: Sequence { + struct Iterator: IteratorProtocol { + var `class`: AnyClass? + + init(class: AnyClass) { + self.class = `class` + } + + mutating func next() -> AnyClass? { + guard let next = `class` else { return nil } + `class` = class_getSuperclass(next) + return next + } + } + + var `class`: AnyClass + + func makeIterator() -> Iterator { + Iterator(class: `class`) + } +} + +extension ClassHierarchy { + init?(object: Any) { + guard let `class` = object_getClass(object) else { return nil } + self.init(class: `class`) + } +} +#endif diff --git a/Sources/CombineViewModel/MethodList.swift b/Sources/CombineViewModel/MethodList.swift new file mode 100644 index 0000000..f45e9e4 --- /dev/null +++ b/Sources/CombineViewModel/MethodList.swift @@ -0,0 +1,27 @@ +#if canImport(ObjectiveC) +import ObjectiveC.runtime + +struct MethodList { + private let buffer: UnsafeBufferPointer + + init?(class: AnyClass) { + var count = UInt32(0) + guard let list = class_copyMethodList(`class`, &count) else { return nil } + self.buffer = UnsafeBufferPointer(start: list, count: Int(count)) + } +} + +extension MethodList: RandomAccessCollection { + var startIndex: Int { + buffer.startIndex + } + + var endIndex: Int { + buffer.endIndex + } + + subscript(position: Int) -> Method { + buffer[position] + } +} +#endif diff --git a/Sources/CombineViewModel/ObjCRuntime.swift b/Sources/CombineViewModel/ObjCRuntime.swift new file mode 100644 index 0000000..26d9ba6 --- /dev/null +++ b/Sources/CombineViewModel/ObjCRuntime.swift @@ -0,0 +1,82 @@ +#if canImport(ObjectiveC) +import ObjectiveC.runtime +import Foundation + +private let _UIViewController: AnyClass? = NSClassFromString("UIViewController") +private var _isHookedKey = UInt8(0) + +private typealias ViewDidLoadBlock = @convention(block) (Any) -> Void +private typealias ViewDidLoadFunction = @convention(c) (Any, Selector) -> Void + +func combinevm_isHooked(_ object: Any) -> Bool { + objc_getAssociatedObject(object, &_isHookedKey) as? Bool == true +} + +private func combinevm_setIsHooked(_ object: Any, _ isHooked: Bool) { + objc_setAssociatedObject(object, &_isHookedKey, isHooked, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) +} + +#if canImport(UIKit) +import class UIKit.UIViewController + +extension UIViewController { + @nonobjc static let viewDidLoadNotification = Notification.Name("CombineViewModelViewDidLoad") + + @nonobjc func hookViewDidLoad() { + guard !combinevm_isHooked(type(of: self)) else { return } + + var originalIMP: IMP! + let `class`: AnyClass + let method: Method + let selector = #selector(self.viewDidLoad) + + (method, `class`) = object_getInstanceMethod(self, name: selector)! + + let block: ViewDidLoadBlock = { `self` in + let viewDidLoad = unsafeBitCast(originalIMP!, to: ViewDidLoadFunction.self) + viewDidLoad(self, selector) + NotificationCenter.default.post(name: UIViewController.viewDidLoadNotification, object: self) + } + + originalIMP = method_setImplementation(method, imp_implementationWithBlock(block)) + + combinevm_setIsHooked(`class`, true) + } +} +#endif + +private func object_getInstanceMethod(_ object: Any, name: Selector) -> (method: Method, class: AnyClass)? { + guard var hierarchy = ClassHierarchy(object: object)?.makeIterator() else { return nil } + var stop = false + + while !stop, let `class` = hierarchy.next() { + if `class` === _UIViewController { + stop = true + } + + guard let methods = MethodList(class: `class`) else { continue } + + if let method = methods.first(where: { method_getName($0) == name }) { + return (method, `class`) + } + } + + return nil +} + +private func object_isHooked(_ object: Any) -> Bool { + guard let hierarchy = ClassHierarchy(object: object) else { return false } + + for `class` in hierarchy { + if combinevm_isHooked(`class`) { + return true + } + + if `class` === _UIViewController { + break + } + } + + return false +} +#endif diff --git a/Sources/CombineViewModel/UIViewController+ViewDidLoadPublisher.swift b/Sources/CombineViewModel/UIViewController+ViewDidLoadPublisher.swift index cd4e055..1ae0ad1 100644 --- a/Sources/CombineViewModel/UIViewController+ViewDidLoadPublisher.swift +++ b/Sources/CombineViewModel/UIViewController+ViewDidLoadPublisher.swift @@ -1,6 +1,5 @@ #if canImport(UIKit) import Combine -import CombineViewModelObjC import UIKit extension UIViewController { diff --git a/Sources/CombineViewModel/ViewModel.swift b/Sources/CombineViewModel/ViewModel.swift index 75239fd..be48847 100644 --- a/Sources/CombineViewModel/ViewModel.swift +++ b/Sources/CombineViewModel/ViewModel.swift @@ -1,7 +1,6 @@ import Combine import Dispatch #if canImport(UIKit) -import CombineViewModelObjC import UIKit #endif @@ -45,7 +44,7 @@ public struct ViewModel { #if canImport(UIKit) if let viewController = observer as? UIViewController { dispatchPrecondition(condition: .onQueue(.main)) - _combinevm_hook_viewDidLoad(viewController) + viewController.hookViewDidLoad() observations = newValue.observe(on: DispatchQueue.main) .combineLatest(viewController.viewDidLoadPublisher) { object, _ in checkObserverReady(object) } diff --git a/Sources/CombineViewModelObjC/CombineViewModelObjC.m b/Sources/CombineViewModelObjC/CombineViewModelObjC.m deleted file mode 100644 index d13bb62..0000000 --- a/Sources/CombineViewModelObjC/CombineViewModelObjC.m +++ /dev/null @@ -1,115 +0,0 @@ -#import "CombineViewModelObjC.h" -#ifdef COMBINEVM_HAS_FOUNDATION -#import -#import - -NSNotificationName CombineViewModelViewDidLoad = @"CombineViewModelViewDidLoad"; - -void *CombineViewModelIsHookedKey = &CombineViewModelIsHookedKey; -static Class _UIViewController; - -__attribute__((constructor)) -static void -_combinevm_init(void) -{ - _UIViewController = NSClassFromString(@"UIViewController"); -} - -static void -_combinevm_enumerate_hierarchy(id object, void (^block)(Class klass, BOOL *stop)) -{ - Class class_iter = object_getClass(object); - BOOL stop = NO; - - while (class_iter != nil && !stop) { - block(class_iter, &stop); - class_iter = class_getSuperclass(class_iter); - } -} - -static Method -_combinevm_object_getInstanceMethod(id object, SEL selector, Class *actual_class) -{ - __block Class found_class; - __block Method found_method; - - _combinevm_enumerate_hierarchy(object, ^(Class klass, BOOL *stop) { - Method method, *method_list; - SEL method_selector; - unsigned int i, count; - - method_list = class_copyMethodList(klass, &count); - - for (i = 0; i < count; i++) { - method = method_list[i]; - method_selector = method_getName(method); - - if (method_selector == selector) { - found_class = klass; - found_method = method; - *stop = YES; - break; - } - } - - if (klass == _UIViewController) { - *stop = YES; - } - }); - - if (actual_class != nil) { - *actual_class = found_class; - } - - return found_method; -} - -static BOOL -_combinevm_object_hooked(id object) -{ - __block BOOL isHooked = NO; - _combinevm_enumerate_hierarchy(object, ^(Class klass, BOOL *stop) { - if ([objc_getAssociatedObject(klass, CombineViewModelIsHookedKey) boolValue]) { - isHooked = YES; - *stop = YES; - } else if (klass == _UIViewController) { - *stop = YES; - } - }); - return isHooked; -} - -void -_combinevm_hook_viewDidLoad(id object) -{ - const Class object_class = object_getClass(object); - const SEL viewDidLoad_SEL = sel_registerName("viewDidLoad"); - __block IMP original_viewDidLoad_IMP; - IMP new_viewDidLoad_IMP; - Class viewDidLoad_class; - Method viewDidLoad; - - NSCAssert([object isKindOfClass:_UIViewController], @"Object '%@' is not an instance of UIViewController", object); - - if (_UIViewController == nil || _combinevm_object_hooked(object)) { - return; - } - - viewDidLoad = _combinevm_object_getInstanceMethod(object, viewDidLoad_SEL, &viewDidLoad_class); - NSCAssert(viewDidLoad != nil, @"Object '%@' appears to subclass UIViewController, but does not implement -viewDidLoad.", object); - - new_viewDidLoad_IMP = imp_implementationWithBlock(^(id object, SEL _cmd) { - NSNotificationCenter *center = [NSNotificationCenter defaultCenter]; - void (*viewDidLoad)(id, SEL) = (void (*)(id, SEL))original_viewDidLoad_IMP; - viewDidLoad(object, _cmd); - [center postNotificationName:CombineViewModelViewDidLoad object:object]; - }); - original_viewDidLoad_IMP = method_setImplementation(viewDidLoad, new_viewDidLoad_IMP); - objc_setAssociatedObject(viewDidLoad_class, CombineViewModelIsHookedKey, @(YES), OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} -#else // COMBINEVM_HAS_FOUNDATION -static void -_combinevm_dummy(void) -{ -} -#endif // COMBINEVM_HAS_FOUNDATION diff --git a/Sources/CombineViewModelObjC/include/CombineViewModelObjC.h b/Sources/CombineViewModelObjC/include/CombineViewModelObjC.h deleted file mode 100644 index d9dd541..0000000 --- a/Sources/CombineViewModelObjC/include/CombineViewModelObjC.h +++ /dev/null @@ -1,16 +0,0 @@ -#if defined(__has_include) -# if __has_include() -# define COMBINEVM_HAS_FOUNDATION -# endif -#endif - -#ifdef COMBINEVM_HAS_FOUNDATION -#import - -@class UIViewController; - -FOUNDATION_EXPORT NSNotificationName CombineViewModelViewDidLoad NS_SWIFT_NAME(UIViewController.viewDidLoadNotification); -FOUNDATION_EXPORT void *CombineViewModelIsHookedKey; - -void _combinevm_hook_viewDidLoad(id object); -#endif // COMBINEVM_HAS_FOUNDATION diff --git a/Tests/CombineViewModelTests/HookedViewDidLoadTests.swift b/Tests/CombineViewModelTests/HookedViewDidLoadTests.swift index f8cd9ed..98052e4 100644 --- a/Tests/CombineViewModelTests/HookedViewDidLoadTests.swift +++ b/Tests/CombineViewModelTests/HookedViewDidLoadTests.swift @@ -1,6 +1,6 @@ #if canImport(UIKit) -import CombineViewModelObjC -import ObjectiveC.runtime +@testable import CombineViewModel +import ObjCTestSupport import UIKit import XCTest @@ -13,9 +13,8 @@ final class HookedViewDidLoadTests: XCTestCase { } let object = ViewController() - _combinevm_hook_viewDidLoad(object) - let isHooked = objc_getAssociatedObject(ViewController.self, CombineViewModelIsHookedKey) as? Bool == true - XCTAssertTrue(isHooked) + object.hookViewDidLoad() + XCTAssertTrue(combinevm_isHooked(ViewController.self)) } func testItHooksBaseClassViewDidLoad() { @@ -28,11 +27,18 @@ final class HookedViewDidLoadTests: XCTestCase { class Sub: Base {} let object = Sub() - _combinevm_hook_viewDidLoad(object) - let isBaseHooked = objc_getAssociatedObject(Base.self, CombineViewModelIsHookedKey) as? Bool == true - let isSubHooked = objc_getAssociatedObject(Sub.self, CombineViewModelIsHookedKey) as? Bool == true - XCTAssertTrue(isBaseHooked) - XCTAssertFalse(isSubHooked) + object.hookViewDidLoad() + XCTAssertTrue(combinevm_isHooked(Base.self), "Expected base class to be hooked") + XCTAssertFalse(combinevm_isHooked(Sub.self), "Expected sub class not to be hooked") + } + + func testViewDidLoadSelector() { + let controller = TestObjCViewController() + controller.hookViewDidLoad() + + _ = controller.view + + XCTAssertEqual(controller.viewDidLoadSelector, #selector(UIViewController.viewDidLoad)) } } #endif diff --git a/Tests/CombineViewModelTests/ViewModelTests.swift b/Tests/CombineViewModelTests/ViewModelTests.swift index 7e59bd2..88a2c69 100644 --- a/Tests/CombineViewModelTests/ViewModelTests.swift +++ b/Tests/CombineViewModelTests/ViewModelTests.swift @@ -1,5 +1,4 @@ -import Combine -import CombineViewModel +@testable import CombineViewModel import XCTest private final class TestViewModel: ObservableObject { diff --git a/Tests/ObjCTestSupport/TestObjCViewController.m b/Tests/ObjCTestSupport/TestObjCViewController.m new file mode 100644 index 0000000..c998d60 --- /dev/null +++ b/Tests/ObjCTestSupport/TestObjCViewController.m @@ -0,0 +1,15 @@ +#import "ObjCTestSupport.h" + +#ifdef COMBINEVM_HAS_UIKIT + +@implementation TestObjCViewController + +- (void)viewDidLoad { + [super viewDidLoad]; + + _viewDidLoadSelector = _cmd; +} + +@end + +#endif // COMBINEVM_HAS_UIKIT diff --git a/Tests/ObjCTestSupport/include/ObjCTestSupport.h b/Tests/ObjCTestSupport/include/ObjCTestSupport.h new file mode 100644 index 0000000..42adc73 --- /dev/null +++ b/Tests/ObjCTestSupport/include/ObjCTestSupport.h @@ -0,0 +1,16 @@ +#if defined(__has_include) +# if __has_include() +# define COMBINEVM_HAS_UIKIT +# endif +#endif + +#ifdef COMBINEVM_HAS_UIKIT +#import + +@interface TestObjCViewController : UIViewController + +@property (nonatomic, readonly, nullable) SEL viewDidLoadSelector; + +@end + +#endif // COMBINEVM_HAS_UIKIT