Skip to content

Commit

Permalink
Merge pull request #7 from thoughtbot/objc-runtime-swift
Browse files Browse the repository at this point in the history
Reimplement ObjectiveC runtime code in Swift
  • Loading branch information
sharplet authored Aug 16, 2020
2 parents 805bc33 + 82195cd commit 7f253f1
Show file tree
Hide file tree
Showing 12 changed files with 193 additions and 149 deletions.
6 changes: 3 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
]
)
32 changes: 32 additions & 0 deletions Sources/CombineViewModel/ClassHierarchy.swift
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions Sources/CombineViewModel/MethodList.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#if canImport(ObjectiveC)
import ObjectiveC.runtime

struct MethodList {
private let buffer: UnsafeBufferPointer<Method>

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
82 changes: 82 additions & 0 deletions Sources/CombineViewModel/ObjCRuntime.swift
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
#if canImport(UIKit)
import Combine
import CombineViewModelObjC
import UIKit

extension UIViewController {
Expand Down
3 changes: 1 addition & 2 deletions Sources/CombineViewModel/ViewModel.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import Combine
import Dispatch
#if canImport(UIKit)
import CombineViewModelObjC
import UIKit
#endif

Expand Down Expand Up @@ -37,7 +36,7 @@ public struct ViewModel<Object: ObservableObject> {
#if canImport(UIKit)
if let viewController = observer as? UIViewController {
dispatchPrecondition(condition: .onQueue(.main))
_combinevm_hook_viewDidLoad(viewController)
viewController.hookViewDidLoad()

objectDidChange = newValue.observe(on: DispatchQueue.main)
.combineLatest(viewController.viewDidLoadPublisher) { _, _ in }
Expand Down
115 changes: 0 additions & 115 deletions Sources/CombineViewModelObjC/CombineViewModelObjC.m

This file was deleted.

16 changes: 0 additions & 16 deletions Sources/CombineViewModelObjC/include/CombineViewModelObjC.h

This file was deleted.

26 changes: 16 additions & 10 deletions Tests/CombineViewModelTests/HookedViewDidLoadTests.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#if canImport(UIKit)
import CombineViewModelObjC
import ObjectiveC.runtime
@testable import CombineViewModel
import ObjCTestSupport
import UIKit
import XCTest

Expand All @@ -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() {
Expand All @@ -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
3 changes: 1 addition & 2 deletions Tests/CombineViewModelTests/ViewModelTests.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import Combine
import CombineViewModel
@testable import CombineViewModel
import XCTest

private final class TestViewModel: ObservableObject {
Expand Down
15 changes: 15 additions & 0 deletions Tests/ObjCTestSupport/TestObjCViewController.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#import "ObjCTestSupport.h"

#ifdef COMBINEVM_HAS_UIKIT

@implementation TestObjCViewController

- (void)viewDidLoad {
[super viewDidLoad];

_viewDidLoadSelector = _cmd;
}

@end

#endif // COMBINEVM_HAS_UIKIT
16 changes: 16 additions & 0 deletions Tests/ObjCTestSupport/include/ObjCTestSupport.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#if defined(__has_include)
# if __has_include(<UIKit/UIKit.h>)
# define COMBINEVM_HAS_UIKIT
# endif
#endif

#ifdef COMBINEVM_HAS_UIKIT
#import <UIKit/UIKit.h>

@interface TestObjCViewController : UIViewController

@property (nonatomic, readonly, nullable) SEL viewDidLoadSelector;

@end

#endif // COMBINEVM_HAS_UIKIT

0 comments on commit 7f253f1

Please sign in to comment.