diff --git a/Example/RIBs.xcodeproj/project.pbxproj b/Example/RIBs.xcodeproj/project.pbxproj index 45519b9..aeea937 100644 --- a/Example/RIBs.xcodeproj/project.pbxproj +++ b/Example/RIBs.xcodeproj/project.pbxproj @@ -382,6 +382,7 @@ MODULE_NAME = ExampleApp; PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.demo.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_SWIFT3_OBJC_INFERENCE = Default; SWIFT_VERSION = 5.0; }; @@ -398,6 +399,7 @@ MODULE_NAME = ExampleApp; PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.demo.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_STRICT_CONCURRENCY = complete; SWIFT_SWIFT3_OBJC_INFERENCE = Default; SWIFT_VERSION = 5.0; }; diff --git a/Package.swift b/Package.swift index 53d3f9b..8508f69 100644 --- a/Package.swift +++ b/Package.swift @@ -1,10 +1,10 @@ -// swift-tools-version:5.5 +// swift-tools-version:6.0 import PackageDescription let package = Package( name: "RIBs", platforms: [ - .iOS("15.0"), + .iOS(.v15) ], products: [ .library(name: "RIBs", targets: ["RIBs"]), @@ -20,7 +20,11 @@ let package = Package( .product(name: "RxSwift", package: "RxSwift"), .product(name: "RxRelay", package: "RxSwift") ], - path: "RIBs" + path: "RIBs", + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("StrictConcurrency"), + ] ), .testTarget( name: "RIBsTests", diff --git a/RIBs.podspec b/RIBs.podspec index 38f8ecd..3f41d28 100644 --- a/RIBs.podspec +++ b/RIBs.podspec @@ -12,8 +12,8 @@ RIBs is the cross-platform architecture behind many mobile apps at Uber. This ar s.ios.deployment_target = '15.0' s.swift_version = '5.0' s.source_files = 'RIBs/Classes/**/*' - s.dependency 'RxSwift', '~> 6.0' - s.dependency 'RxRelay', '~> 6.0' + s.dependency 'RxSwift', '~> 6.9.0' + s.dependency 'RxRelay', '~> 6.9.0' s.test_spec 'Tests' do |test_spec| test_spec.source_files = 'RIBsTests/**/*.swift' diff --git a/RIBs/Classes/Builder.swift b/RIBs/Classes/Builder.swift index 9f16e9d..d87c02f 100644 --- a/RIBs/Classes/Builder.swift +++ b/RIBs/Classes/Builder.swift @@ -17,10 +17,10 @@ import Foundation /// The base builder protocol that all builders should conform to. -public protocol Buildable: AnyObject {} +nonisolated public protocol Buildable: AnyObject {} /// Utility that instantiates a RIB and sets up its internal wirings. -open class Builder: Buildable { +nonisolated open class Builder: Buildable { /// The dependency used for this builder to build the RIB. public let dependency: DependencyType diff --git a/RIBs/Classes/DI/Component.swift b/RIBs/Classes/DI/Component.swift index 4a178ff..4a3663c 100644 --- a/RIBs/Classes/DI/Component.swift +++ b/RIBs/Classes/DI/Component.swift @@ -23,7 +23,7 @@ import Foundation /// /// A component subclass implementation should conform to child 'Dependency' protocols, defined by all of its immediate /// children. -open class Component: Dependency { +nonisolated open class Component: Dependency { /// The dependency of this `Component`. public let dependency: DependencyType diff --git a/RIBs/Classes/Interactor.swift b/RIBs/Classes/Interactor.swift index d80ded2..635a365 100644 --- a/RIBs/Classes/Interactor.swift +++ b/RIBs/Classes/Interactor.swift @@ -16,10 +16,9 @@ import Foundation import RxSwift -import UIKit /// Protocol defining the activeness of an interactor's scope. -public protocol InteractorScope: AnyObject { +nonisolated public protocol InteractorScope: AnyObject { // The following properties must be declared in the base protocol, since `Router` internally invokes these methods. // In order to unit test router with a mock interactor, the mocked interactor first needs to conform to the custom @@ -37,7 +36,7 @@ public protocol InteractorScope: AnyObject { } /// The base protocol for all interactors. -public protocol Interactable: InteractorScope { +nonisolated public protocol Interactable: InteractorScope { // The following methods must be declared in the base protocol, since `Router` internally invokes these methods. // In order to unit test router with a mock interactor, the mocked interactor first needs to conform to the custom @@ -64,7 +63,8 @@ public protocol Interactable: InteractorScope { /// active. /// /// An `Interactor` should only perform its business logic when it's currently active. -open class Interactor: Interactable { +nonisolated open class Interactor: Interactable, @unchecked Sendable { + /// Indicates if the interactor is active. public final var isActive: Bool { diff --git a/RIBs/Classes/LaunchRouter.swift b/RIBs/Classes/LaunchRouter.swift index d50dc20..b570c44 100644 --- a/RIBs/Classes/LaunchRouter.swift +++ b/RIBs/Classes/LaunchRouter.swift @@ -22,6 +22,7 @@ public protocol LaunchRouting: ViewableRouting { /// Launches the router tree. /// /// - parameter window: The application window to launch from. + @MainActor func launch(from window: UIWindow) } @@ -39,6 +40,7 @@ open class LaunchRouter: ViewableRouter: Interactor { +nonisolated open class PresentableInteractor: Interactor, @unchecked Sendable { /// The `Presenter` associated with this `Interactor`. public let presenter: PresenterType @@ -30,6 +30,56 @@ open class PresentableInteractor: Interactor { public init(presenter: PresenterType) { self.presenter = presenter } + + + /// A helper method to safely call presenter methods on the main thread. + /// Use this when you encounter main actor isolation warnings when calling methods on the presenter object. + /// All presenter methods should be executed on the main thread because they ultimately trigger UI rendering. + /// This method captures the presenter object in a MainActor task, allowing you to safely call methods on it. + /// The closure you pass contains the code for your custom presenter method calls, and the closure provides + /// you with the presenter instance/reference of this interactor. + /// + /// You can use this method to call presenter UI callbacks in RxSwift observable subscriptions or from Tasks. + /// + /// Example usage in RxSwift subscription: + /// ```swift + /// stream.dataObservable + /// .subscribe(on: backgroundScheduler) + /// .observe(on: MainScheduler.instance) + /// .subscribe(onNext: { _ in + /// self.presentOnMainThread { presenter in + /// presenter.presentStuff() + /// } + /// }).disposeOnDeactivate(interactor: self) + /// ``` + /// + /// Example usage in async Task: + /// ```swift + /// Task { + /// try? await Task.sleep(for: .seconds(2)) + /// + /// await MainActor.run { + /// presenter.presentStuff() + /// } + /// + /// await presenter.presentStuff() + /// + /// Task { @MainActor in + /// presenter.presentStuff() + /// } + /// + /// self.presentOnMainThread { presenter in + /// presenter.presentStuff() + /// } + /// } + /// ``` + public nonisolated func presentOnMainThread(_ block: @escaping @MainActor (_ presenter: PresenterType) -> Void) { + nonisolated(unsafe) let presenter = self.presenter + + Task { @MainActor in + block(presenter) + } + } // MARK: - Private diff --git a/RIBs/Classes/Presenter.swift b/RIBs/Classes/Presenter.swift index edc1d4f..27b8653 100644 --- a/RIBs/Classes/Presenter.swift +++ b/RIBs/Classes/Presenter.swift @@ -17,12 +17,21 @@ import Foundation /// The base protocol for all `Presenter`s. -public protocol Presentable: AnyObject {} +@MainActor +public protocol Presentable: AnyObject & SendableMetatype { + associatedtype Listener + + nonisolated var listener: Listener? { get set } +} /// The base class of all `Presenter`s. A `Presenter` translates business models into values the corresponding /// `ViewController` can consume and display. It also maps UI events to business logic method, invoked to /// its listener. open class Presenter: Presentable { + nonisolated(unsafe) public weak var listener: Listener? + + public typealias Listener = AnyObject + /// The view controller of this presenter. public let viewController: ViewControllerType diff --git a/RIBs/Classes/Router.swift b/RIBs/Classes/Router.swift index de593d0..b678831 100644 --- a/RIBs/Classes/Router.swift +++ b/RIBs/Classes/Router.swift @@ -24,7 +24,7 @@ public enum RouterLifecycle { } /// The scope of a `Router`, defining various lifecycles of a `Router`. -public protocol RouterScope: AnyObject { +nonisolated public protocol RouterScope: AnyObject { /// An observable that emits values when the router scope reaches its corresponding life-cycle stages. This /// observable completes when the router scope is deallocated. @@ -32,7 +32,7 @@ public protocol RouterScope: AnyObject { } /// The base protocol for all routers. -public protocol Routing: RouterScope { +nonisolated public protocol Routing: RouterScope { // The following methods must be declared in the base protocol, since `Router` internally invokes these methods. // In order to unit test router with a mock child router, the mocked child router first needs to conform to the @@ -73,7 +73,7 @@ public protocol Routing: RouterScope { /// Router drives the lifecycle of its owned `Interactor`. /// /// Routers should always use helper builders to instantiate children routers. -open class Router: Routing { +nonisolated open class Router: Routing { /// The corresponding `Interactor` owned by this `Router`. public let interactor: InteractorType diff --git a/RIBs/Classes/ViewControllable.swift b/RIBs/Classes/ViewControllable.swift index 0e27040..211cf31 100644 --- a/RIBs/Classes/ViewControllable.swift +++ b/RIBs/Classes/ViewControllable.swift @@ -17,6 +17,7 @@ import UIKit /// Basic interface between a `Router` and the UIKit `UIViewController`. +@MainActor public protocol ViewControllable: AnyObject { var uiviewController: UIViewController { get } diff --git a/RIBs/Classes/ViewableRouter.swift b/RIBs/Classes/ViewableRouter.swift index 82488b4..2e998fb 100644 --- a/RIBs/Classes/ViewableRouter.swift +++ b/RIBs/Classes/ViewableRouter.swift @@ -54,7 +54,82 @@ open class ViewableRouter: Router Void) { + nonisolated(unsafe) let thisRouterViewController = self.viewController + + Task { @MainActor in + block(thisRouterViewController) + } + } + + /// A helper method to safely call viewController methods for UI navigation on the main thread with a child view controller. + /// This overload provides both the current router's view controller and a child view controller to the closure, + /// ensuring all UI operations happen on the main thread. + /// + /// When routing in RIBs that have UI (using ViewableRouter), the plumbing of routing such as clearing out + /// child RIB references, setting them up, or attaching/detaching child routers can happen on any thread. + /// However, the actual physical/mechanical navigation of the UI - such as pushing or popping child RIB's UI + /// onto/off the navigation controller stack, modally presenting or dismissing it, or attaching/detaching it + /// from the UI hierarchy via custom implementation using child containment API - must all be done on the main thread + /// since they manipulate the UI tree and trigger UI rendering. + /// + /// This method ensures that your viewController method calls run on the main thread. + /// Use this method when you encounter compiler warnings such as: + /// "Main actor-isolated property 'uiviewController' cannot be referenced from a nonisolated context" + /// + /// This is an overload of the basic `navigateOnMainThread(_:)` method that includes a child view controller parameter. + /// Use this method when you need to perform UI navigation that involves both the current router's view controller + /// and a child view controller, such as presenting, pushing, or custom containment operations. + /// For navigation operations that only involve the current router's view controller, use the basic `navigateOnMainThread(_:)` method instead. + /// + /// Example usage - routing to child RIB: + /// ```swift + /// func routeToChildRIB() { + /// let childRIBRouter = childRIBBuilder.build(withListener: interactor) + /// self.childRIBRouter = childRIBRouter + /// navigateOnMainThread(with: childRIBRouter.viewControllable) { thisRouterViewController, childViewController in + /// thisRouterViewController.attachChildRIBViewController(childViewController.uiviewController) + /// } + /// attachChild(childRIBRouter) + /// } + /// ``` + + public nonisolated func navigateOnMainThread(with childViewController: ViewControllable, _ block: @escaping @MainActor (_ thisRouterViewController: ViewControllerType, _ childViewController: ViewControllable) -> Void) { + nonisolated(unsafe) let thisRouterViewController = self.viewController + nonisolated(unsafe) let childVC = childViewController + + Task { @MainActor in + block(thisRouterViewController, childVC) + } + } + // MARK: - Internal override func internalDidLoad() { @@ -68,28 +143,36 @@ open class ViewableRouter: Router! + private var interactor: PresentableInteractor! override func setUp() { super.setUp() @@ -26,7 +32,7 @@ final class PresentableInteractorTests: XCTestCase { // given let presenterMock = PresenterMock() let disposeBag = DisposeBag() - interactor = PresentableInteractor(presenter: presenterMock) + interactor = PresentableInteractor(presenter: presenterMock) var status: LeakDetectionStatus = .DidComplete LeakDetector.instance.status.subscribe { newStatus in status = newStatus diff --git a/RIBsTests/LaunchRouterTests.swift b/RIBsTests/LaunchRouterTests.swift index dba33ae..e35876c 100644 --- a/RIBsTests/LaunchRouterTests.swift +++ b/RIBsTests/LaunchRouterTests.swift @@ -17,6 +17,7 @@ @testable import RIBs import XCTest +@MainActor final class LaunchRouterTests: XCTestCase { private var launchRouter: LaunchRouting! @@ -26,8 +27,8 @@ final class LaunchRouterTests: XCTestCase { // MARK: - Setup - override func setUp() { - super.setUp() + override func setUp() async throws { + try await super.setUp() interactor = InteractableMock() viewController = ViewControllableMock() diff --git a/RIBsTests/LeakDetector/LeakDetectorMock.swift b/RIBsTests/LeakDetector/LeakDetectorMock.swift index dc767b4..7ef5e27 100644 --- a/RIBsTests/LeakDetector/LeakDetectorMock.swift +++ b/RIBsTests/LeakDetector/LeakDetectorMock.swift @@ -19,21 +19,45 @@ final class LeakDetectionHandleMock: LeakDetectionHandle { final class LeakDetectorMock: LeakDetector { - var expectDeallocateCallCount = 0 + private let queue = DispatchQueue(label: "com.LeakDetectorMock.state") + + private var _statusCallCount = 0 + var statusCallCount: Int { + queue.sync { self._statusCallCount } + } + override var status: Observable { + // Note: The get block for a computed property is synchronous. + queue.sync { + self._statusCallCount += 1 + } + return super.status + } + + + var onDeallocateCalled: (() -> Void)? + private var _expectDeallocateCallCount = 0 + var expectDeallocateCallCount: Int { + queue.sync { self._expectDeallocateCallCount } + } override func expectDeallocate(object: AnyObject, inTime time: TimeInterval) -> LeakDetectionHandle { - expectDeallocateCallCount += 1 + queue.sync { + self._expectDeallocateCallCount += 1 + } + onDeallocateCalled?() return LeakDetectionHandleMock() } - var expectViewControllerDisappearCallCount = 0 + + var onViewControllerDisappearCalled: (() -> Void)? + private var _expectViewControllerDisappearCallCount = 0 + var expectViewControllerDisappearCallCount: Int { + queue.sync { self._expectViewControllerDisappearCallCount } + } override func expectViewControllerDisappear(viewController: UIViewController, inTime time: TimeInterval) -> LeakDetectionHandle { - expectViewControllerDisappearCallCount += 1 + queue.sync { + self._expectViewControllerDisappearCallCount += 1 + } + onViewControllerDisappearCalled?() return LeakDetectionHandleMock() } - - var statusCallCount = 0 - override var status: Observable { - statusCallCount += 1 - return super.status - } } diff --git a/RIBsTests/Mocks.swift b/RIBsTests/Mocks.swift index 08fe1bd..4f06dea 100644 --- a/RIBsTests/Mocks.swift +++ b/RIBsTests/Mocks.swift @@ -40,11 +40,12 @@ class WindowMock: UIWindow { private var internalRootViewController: UIViewController? } +@MainActor class ViewControllableMock: ViewControllable { let uiviewController = UIViewController(nibName: nil, bundle: nil) } -class InteractorMock: Interactor { +class InteractorMock: Interactor, @unchecked Sendable { var didBecomeActiveHandler: (() -> ())? var didBecomeActiveCallCount: Int = 0 var willResignActiveHandler: (() -> ())? diff --git a/RIBsTests/Router/ViewableRouterTests.swift b/RIBsTests/Router/ViewableRouterTests.swift index 88d3f0e..6b5caeb 100644 --- a/RIBsTests/Router/ViewableRouterTests.swift +++ b/RIBsTests/Router/ViewableRouterTests.swift @@ -11,6 +11,7 @@ import XCTest import CwlPreconditionTesting +@MainActor final class ViewControllerMock: ViewControllable { var uiviewController: UIViewController { @@ -36,21 +37,42 @@ final class ViewableRouterTests: XCTestCase { let viewController = ViewControllerMock() router = ViewableRouter(interactor: interactor, viewController: viewController) router.load() + + let disappearExpectation = self.expectation(description: "Wait for view controller to disappear") + + leakDetectorMock.onViewControllerDisappearCalled = { [weak leakDetectorMock] in + if leakDetectorMock?.expectViewControllerDisappearCallCount == 1 { + disappearExpectation.fulfill() + } + } + // when interactor.deactivate() + // then - XCTAssertEqual(leakDetectorMock.expectViewControllerDisappearCallCount, 1) + wait(for: [disappearExpectation], timeout: 2.0) } - + func test_deinit_triggers_leakDetection() { // given let interactor = PresentableInteractor(presenter: PresenterMock()) let viewController = ViewControllerMock() router = ViewableRouter(interactor: interactor, viewController: viewController) router.load() + // when + let deallocationExpectation = self.expectation(description: "Expect deallocate to be called twice") + + leakDetectorMock.onDeallocateCalled = { [weak leakDetectorMock] in + + if leakDetectorMock?.expectDeallocateCallCount == 2 { + deallocationExpectation.fulfill() + } + } + // when router = nil + // then - XCTAssertEqual(leakDetectorMock.expectDeallocateCallCount, 2) + wait(for: [deallocationExpectation], timeout: 5.0) } }