├── Diagrams ├── data_flow.png └── ownership.png ├── Package.resolved ├── Package.swift ├── .gitignore ├── LICENSE ├── Sources └── IMVVM │ ├── ViewModel.swift │ ├── CancellableBuilder.swift │ ├── TypeErasedView.swift │ ├── View+Lifecycle.swift │ ├── View+Lifecycle+Model.swift │ ├── SynchronizedCancelBag.swift │ ├── AnyCancellable+Interactor.swift │ ├── Task+Interactor.swift │ ├── ViewModelInteractor.swift │ └── Interactor.swift ├── Tests └── IMVVMTests │ ├── Mocks.swift │ └── ViewModelInteractorTests.swift └── README.md /Diagrams/data_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neakor/swiftui-imvvm/HEAD/Diagrams/data_flow.png -------------------------------------------------------------------------------- /Diagrams/ownership.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neakor/swiftui-imvvm/HEAD/Diagrams/ownership.png -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-collections", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/apple/swift-collections.git", 7 | "state" : { 8 | "revision" : "48254824bb4248676bf7ce56014ff57b142b77eb", 9 | "version" : "1.0.2" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.6 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "IMVVM", 7 | platforms: [ 8 | .iOS(.v13), 9 | ], 10 | products: [ 11 | .library( 12 | name: "IMVVM", 13 | targets: ["IMVVM"] 14 | ), 15 | ], 16 | dependencies: [ 17 | .package(url: "https://github.com/apple/swift-collections.git", from: "1.0.2"), 18 | ], 19 | targets: [ 20 | .target( 21 | name: "IMVVM", 22 | dependencies: [ 23 | .product(name: "OrderedCollections", package: "swift-collections"), 24 | ] 25 | ), 26 | .testTarget( 27 | name: "IMVVMTests", 28 | dependencies: [ 29 | "IMVVM", 30 | ] 31 | ), 32 | ] 33 | ) 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Forked from https://github.com/github/gitignore/blob/master/Swift.gitignore 2 | 3 | # MacOS 4 | *.DS_Store 5 | 6 | ## User settings 7 | xcuserdata/ 8 | 9 | ## Obj-C/Swift specific 10 | *.hmap 11 | 12 | ## App packaging 13 | *.ipa 14 | *.dSYM.zip 15 | *.dSYM 16 | 17 | # Swift Package Manager 18 | # 19 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 20 | Packages/ 21 | .build/ 22 | 23 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 24 | # hence it is not needed unless you have added a package configuration file to your project 25 | .swiftpm 26 | 27 | build/ 28 | 29 | # Xcode, since the project is generated using SPM. 30 | *.xcodeproj 31 | *.xcworkspace 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Yi Wang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/IMVVM/ViewModel.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2022 Yi Wang 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Foundation 24 | import SwiftUI 25 | 26 | /// An `ObservableObject` that provides presentation data to the corresponding `View`. 27 | /// 28 | /// A `ViewModel` acts as the middleware between an `Interactor` and a `View`. The interactor provides network data 29 | /// to the view model. The view model transforms the data into presentation data stored within the view model. And 30 | /// since the view model is an `ObservableObject`, the view observes the view model's and displays the data. 31 | public protocol ViewModel: ObservableObject {} 32 | -------------------------------------------------------------------------------- /Sources/IMVVM/CancellableBuilder.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2022 Yi Wang 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Combine 24 | 25 | /// A result builder that allows a variadic number of `AnyCancellable` to be collected into an array. 26 | @resultBuilder 27 | public enum CancellableBuilder { 28 | public static func buildBlock(_ components: [AnyCancellable]...) -> [AnyCancellable] { 29 | components.reduce(into: [], +=) 30 | } 31 | 32 | public static func buildExpression(_ expression: Void) -> [AnyCancellable] { 33 | [] 34 | } 35 | 36 | public static func buildExpression(_ expression: AnyCancellable) -> [AnyCancellable] { 37 | [expression] 38 | } 39 | 40 | /// Convert regular cancellables to AnyCancellable to dispose on deinit. 41 | public static func buildExpression(_ expression: any Cancellable) -> [AnyCancellable] { 42 | [AnyCancellable(expression.cancel)] 43 | } 44 | 45 | public static func buildExpression(_ expression: [AnyCancellable]) -> [AnyCancellable] { 46 | expression 47 | } 48 | 49 | public static func buildEither(first component: [AnyCancellable]) -> [AnyCancellable] { 50 | component 51 | } 52 | 53 | public static func buildEither(second component: [AnyCancellable]) -> [AnyCancellable] { 54 | component 55 | } 56 | 57 | public static func buildArray(_ components: [[AnyCancellable]]) -> [AnyCancellable] { 58 | components.reduce(into: [], +=) 59 | } 60 | 61 | public static func buildOptional(_ component: [AnyCancellable]?) -> [AnyCancellable] { 62 | if let component = component { 63 | return component 64 | } else { 65 | return [] 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/IMVVM/TypeErasedView.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2022 Yi Wang 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Foundation 24 | import SwiftUI 25 | 26 | /// TypeErasedView is a drop in replacement for SwiftUI's `AnyView`. 27 | /// 28 | /// `AnyView` has two major issues that the `TypeErasedView` solves: 29 | /// 1. A view hierarchy containing many `AnyView` can have rendering performance issues. 30 | /// 2. Wrapping an `AnyView` within another `AnyView` can erase certain modifiers such as `onAppear` and 31 | /// `onDisappear`. This is quite error-prone as different portions of the codebase can unintentionally cause more 32 | /// than one wrapping. 33 | /// 34 | /// Using a TypeErasedView enables many architectural benefits: 35 | /// 1. Each feature can be written in its own package without exposing the individual classes as `public`. A single 36 | /// `public` factory can be used to return a TypeErasedView to the parent view package for presentation. 37 | /// 2. A parent feature containing many child views does not need to be modified when new child views are added or 38 | /// modified, when the child views are constructed as plugins. 39 | /// 3. A view/feature's lifecycle can be managed by storing and releasing the TypeErasedView reference. 40 | public struct TypeErasedView: View { 41 | // swiftlint:disable:next no_any_view 42 | private let content: AnyView 43 | 44 | public init(_ content: Content) { 45 | // swiftlint:disable:next no_any_view 46 | self.content = AnyView(content) 47 | } 48 | 49 | public init(_ alreadyErased: TypeErasedView) { 50 | self.content = alreadyErased.content 51 | } 52 | 53 | // swiftlint:disable:next no_any_view 54 | public init(_ anyView: AnyView) { 55 | self.content = anyView 56 | } 57 | 58 | public var body: some View { 59 | content 60 | } 61 | } 62 | 63 | extension View { 64 | /// Type erase this view. 65 | /// 66 | /// - Returns: The type erased view. 67 | public func typeErased() -> TypeErasedView { 68 | TypeErasedView(self) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/IMVVM/View+Lifecycle.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2022 Yi Wang 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Foundation 24 | import SwiftUI 25 | 26 | /// The observer of a SwiftUI view's lifecycle events. 27 | /// 28 | /// This protocol should be used in conjunction with the `bind(observer:)` function of a `View`. This allows the 29 | /// implementation of this protocol to receive the view's various lifecycle events to perform business logic accordingly. 30 | /// 31 | /// This protocol conforms to `ObservableObject` to support retaining this instance as a `@StateObject` in the 32 | /// view that performs the `bind(observer:)` function. 33 | public protocol ViewLifecycleObserver: ObservableObject { 34 | /// Notify the observer when the bound `View` has appeared. 35 | func viewDidAppear() 36 | 37 | /// Notify the observer when the bound `View` has disappeared. 38 | func viewDidDisappear() 39 | } 40 | 41 | extension View { 42 | /// Bind the given lifecycle observer to this view. 43 | /// 44 | /// - Parameters: 45 | /// - observer: The observer to be bound and receive this view's lifecycle events. 46 | /// - Returns: This view with the observer bound. 47 | public func bind(observer: Observer) -> some View { 48 | onAppear { 49 | observer.viewDidAppear() 50 | } 51 | .onDisappear { 52 | observer.viewDidDisappear() 53 | } 54 | } 55 | 56 | /// Bind the given lifecycle observer to this view. 57 | /// 58 | /// - Parameters: 59 | /// - observer: The observer to be bound and receive this view's lifecycle events. 60 | /// - Returns: The type erased version of thisview with the observer bound. 61 | @available(*, deprecated, message: "Binding a view with an observer should happen inside a view.") 62 | public func bindTypeErased(observer: Observer) -> TypeErasedView { 63 | onAppear { 64 | observer.viewDidAppear() 65 | } 66 | .onDisappear { 67 | observer.viewDidDisappear() 68 | } 69 | .typeErased() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/IMVVM/View+Lifecycle+Model.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2022 Yi Wang 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Foundation 24 | import SwiftUI 25 | 26 | /// The observer of a SwiftUI view's lifecycle events. 27 | /// 28 | /// This protocol should be used in conjunction with the `bind(observer:)` function of a `View`. This allows the 29 | /// implementation of this protocol to receive the view's various lifecycle events to perform business logic accordingly. 30 | /// 31 | /// This protocol conforms to `ObservableObject` to support retaining this instance as a `@StateObject` in the 32 | /// view that performs the `bind(observer:)` function. 33 | public protocol ViewWithModelLifecycleObserver: ObservableObject { 34 | /// The model of the view that this observer may mutate to provide data to the view. 35 | associatedtype ViewModelType: ViewModel 36 | 37 | /// Notify the observer when the bound `View` has appeared. 38 | /// 39 | /// - Parameters: 40 | /// - viewModel: The model of the view that this observer may mutate to provide data to the view. 41 | func viewDidAppear(viewModel: ViewModelType) 42 | 43 | /// Notify the observer when the bound `View` has disappeared. 44 | /// 45 | /// - Parameters: 46 | /// - viewModel: The model of the view that this observer may mutate to provide data to the view. 47 | func viewDidDisappear(viewModel: ViewModelType) 48 | } 49 | 50 | extension View { 51 | /// Bind the given lifecycle observer to this view. 52 | /// 53 | /// - Parameters: 54 | /// - observer: The observer to be bound and receive this view's lifecycle events. 55 | /// - viewModel: The model of this view that this observer may mutate to provide data to the view. 56 | /// - Returns: This view with the observer bound. 57 | public func bind( 58 | observer: Observer, 59 | viewModel: Observer.ViewModelType 60 | ) -> some View { 61 | onAppear { 62 | observer.viewDidAppear(viewModel: viewModel) 63 | } 64 | .onDisappear { 65 | observer.viewDidDisappear(viewModel: viewModel) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Tests/IMVVMTests/Mocks.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2022 Yi Wang 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Combine 24 | import Foundation 25 | @testable import IMVVM 26 | 27 | class MockViewModel: ViewModel {} 28 | 29 | class MockViewModelInteractor: ViewModelInteractor { 30 | private let _onLoad: () -> [AnyCancellable] 31 | private let _onLoadViewModel: (MockViewModel) -> [AnyCancellable] 32 | private let _onViewAppear: () -> [AnyCancellable] 33 | private let _onViewAppearViewModel: (MockViewModel) -> [AnyCancellable] 34 | 35 | init( 36 | onLoad: @escaping () -> [AnyCancellable] = { [] }, 37 | onLoadViewModel: @escaping (MockViewModel) -> [AnyCancellable] = { _ in [] }, 38 | onViewAppear: @escaping () -> [AnyCancellable] = { [] }, 39 | onViewAppearViewModel: @escaping (MockViewModel) -> [AnyCancellable] = { _ in [] } 40 | ) { 41 | self._onLoad = onLoad 42 | self._onLoadViewModel = onLoadViewModel 43 | self._onViewAppear = onViewAppear 44 | self._onViewAppearViewModel = onViewAppearViewModel 45 | } 46 | 47 | var onLoadCallCount = 0 48 | override func onLoad() -> [AnyCancellable] { 49 | onLoadCallCount += 1 50 | return _onLoad() 51 | } 52 | 53 | var onLoadViewModelCallCount = 0 54 | override func onLoad(viewModel: MockViewModel) -> [AnyCancellable] { 55 | onLoadViewModelCallCount += 1 56 | return _onLoadViewModel(viewModel) 57 | } 58 | 59 | var onViewAppearCallCount = 0 60 | override func onViewAppear() -> [AnyCancellable] { 61 | onViewAppearCallCount += 1 62 | return _onViewAppear() 63 | } 64 | 65 | var onViewAppearViewModelCallCount = 0 66 | override func onViewAppear(viewModel: MockViewModel) -> [AnyCancellable] { 67 | onViewAppearViewModelCallCount += 1 68 | return _onViewAppearViewModel(viewModel) 69 | } 70 | 71 | var onViewDisappearCallCount = 0 72 | override func onViewDisappear() { 73 | onViewDisappearCallCount += 1 74 | } 75 | 76 | var onViewDisappearViewModelCallCount = 0 77 | override func onViewDisappear(viewModel: MockViewModel) { 78 | onViewDisappearViewModelCallCount += 1 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/IMVVM/SynchronizedCancelBag.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2022 Yi Wang 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Combine 24 | import Foundation 25 | 26 | /// A collection providing thread-safe access to a set of cancellables. 27 | /// 28 | /// All the stored cancellabels are cancelled when this instance deinits. 29 | public class SynchronizedCancelBag { 30 | private var cancellables: [ObjectIdentifier: Cancellable] = [:] 31 | private let cancellablesLock = NSRecursiveLock() 32 | 33 | public init() {} 34 | 35 | /// Store the given cancellable. 36 | /// 37 | /// - Parameters: 38 | /// - cancellable: The cancellable to store. 39 | public func store(_ cancellable: AnyCancellable) { 40 | cancellablesLock.lock() 41 | defer { 42 | cancellablesLock.unlock() 43 | } 44 | 45 | cancellables[cancellable.id] = cancellable 46 | } 47 | 48 | /// Store the given cancellables. 49 | /// 50 | /// - Parameters: 51 | /// - cancellables: The cancellables to store. 52 | public func store(_ sequence: S) where S.Element == AnyCancellable { 53 | cancellablesLock.lock() 54 | defer { 55 | cancellablesLock.unlock() 56 | } 57 | 58 | for cancellable in sequence { 59 | cancellables[cancellable.id] = cancellable 60 | } 61 | } 62 | 63 | public func cancelAll() { 64 | cancellablesLock.lock() 65 | defer { 66 | cancellablesLock.unlock() 67 | } 68 | 69 | for cancellable in cancellables.values { 70 | cancellable.cancel() 71 | } 72 | cancellables.removeAll() 73 | } 74 | 75 | public func remove(_ cancellable: C) where C.ID == ObjectIdentifier { 76 | cancellablesLock.lock() 77 | defer { 78 | cancellablesLock.unlock() 79 | } 80 | 81 | cancellables[cancellable.id] = nil 82 | } 83 | 84 | public func contains(_ cancellable: C) -> Bool where C.ID == ObjectIdentifier { 85 | cancellablesLock.lock() 86 | defer { 87 | cancellablesLock.unlock() 88 | } 89 | 90 | return cancellables[cancellable.id] != nil 91 | } 92 | 93 | public func isEmpty() -> Bool { 94 | cancellablesLock.lock() 95 | defer { 96 | cancellablesLock.unlock() 97 | } 98 | 99 | return cancellables.isEmpty 100 | } 101 | } 102 | 103 | extension AnyCancellable: Identifiable { 104 | public func store(in bag: inout SynchronizedCancelBag) { 105 | bag.store(self) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Sources/IMVVM/AnyCancellable+Interactor.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2022 Yi Wang 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Combine 24 | import Foundation 25 | 26 | extension AnyCancellable { 27 | /// When the interactor deinits, the subscription is cancelled. 28 | /// 29 | /// This function provides the utility to manage Combine subscriptions inside a `Interactor` implementation. For 30 | /// example: 31 | /// 32 | /// class MyInteractor: Interactor { 33 | /// func buttonDidTap() { 34 | /// somePublisher 35 | /// .sink { ... } 36 | /// .cancelOnDeinit(of: self) 37 | /// } 38 | /// } 39 | /// 40 | /// - Note: Because this function causes the given interactor to stongly retain the subscription, this means the 41 | /// subscription itself should not strongly retain the interactor. Otherwise a retain cycle would occur causing 42 | /// memory leaks. 43 | /// 44 | /// This function is thread-safe. Invocations of this function to the same interactor instance can be performed on 45 | /// the different threads. 46 | /// 47 | /// This function can only be invoked after the given interactor has loaded. This is done via the interactor's 48 | /// `viewDidAppear` function. Generally speaking, this interactor should be bound to the lifecycle of a `View`. 49 | /// See `ViewLifecycleObserver` for more details. 50 | /// 51 | /// - Parameters: 52 | /// - interactor: The interactor to bind the subscription's lifecycle to. 53 | public func cancelOnDeinit(of interactor: InteractorType) { 54 | if !interactor.isLoaded { 55 | fatalError("\(interactor) has not been loaded") 56 | } 57 | interactor.deinitCancelBag.store(self) 58 | } 59 | 60 | /// When the interactor's view disappears, the subscription is cancelled. 61 | /// 62 | /// This function provides the utility to manage Combine subscriptions inside a `Interactor` implementation. For 63 | /// example: 64 | /// 65 | /// class MyInteractor: Interactor { 66 | /// func buttonDidTap() { 67 | /// somePublisher 68 | /// .sink { ... } 69 | /// .cancelOnViewDidDisappear(of: self) 70 | /// } 71 | /// } 72 | /// 73 | /// - Note: Because this function causes the given interactor to stongly retain the subscription, this means the 74 | /// subscription itself should not strongly retain the interactor. Otherwise a retain cycle would occur causing 75 | /// memory leaks. 76 | /// 77 | /// This function is thread-safe. Invocations of this function to the same interactor instance can be performed on 78 | /// the different threads. 79 | /// 80 | /// This function can only be invoked after the given interactor has received notification that its view has 81 | /// appeared. This is done via the interactor's `viewDidAppear` function. Generally speaking, this interactor 82 | /// should be bound to the lifecycle of a `View`. See `ViewLifecycleObserver` for more details. 83 | /// 84 | /// - Parameters: 85 | /// - interactor: The interactor to bind the subscription's lifecycle to. 86 | public func cancelOnViewDidDisappear(of interactor: InteractorType) { 87 | if !interactor.hasViewAppeared { 88 | fatalError("\(interactor)'s view has not appeared") 89 | } 90 | interactor.viewAppearanceCancelBag.store(self) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/IMVVM/Task+Interactor.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2022 Yi Wang 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Combine 24 | import Foundation 25 | 26 | extension Task: Cancellable {} 27 | 28 | extension Task { 29 | public var anyCancellable: AnyCancellable { 30 | AnyCancellable(cancel) 31 | } 32 | } 33 | 34 | extension Task { 35 | /// When the interactor deinits, the task is cancelled. 36 | /// 37 | /// This function provides the utility to manage Task lifecycle inside a `Interactor` implementation. For 38 | /// example: 39 | /// 40 | /// class MyInteractor: Interactor { 41 | /// func buttonDidTap() { 42 | /// Task { 43 | /// ... 44 | /// } 45 | /// .cancelOnDeinit(of: self) 46 | /// } 47 | /// } 48 | /// 49 | /// - Note: Because this function causes the given interactor to stongly retain the task, this means the task 50 | /// itself should not strongly retain the interactor. Otherwise a retain cycle would occur causing memory leaks. 51 | /// 52 | /// This function is thread-safe. Invocations of this function to the same interactor instance can be performed on 53 | /// the different threads. 54 | /// 55 | /// This function can only be invoked after the given interactor has loaded. This is done via the interactor's 56 | /// `viewDidAppear` function. Generally speaking, this interactor should be bound to the lifecycle of a `View`. 57 | /// See `ViewLifecycleObserver` for more details. 58 | /// 59 | /// - Parameters: 60 | /// - interactor: The interactor to bind the task's lifecycle to. 61 | public func cancelOnDeinit(of interactor: InteractorType) { 62 | if !interactor.isLoaded { 63 | fatalError("\(interactor) has not been loaded") 64 | } 65 | interactor.deinitCancelBag.store(AnyCancellable(self)) 66 | } 67 | 68 | /// When the interactor's view disappears, the task is cancelled. 69 | /// 70 | /// This function provides the utility to manage Task lifecycle inside a `Interactor` implementation. For 71 | /// example: 72 | /// 73 | /// class MyInteractor: Interactor { 74 | /// func buttonDidTap() { 75 | /// Task { 76 | /// ... 77 | /// } 78 | /// .cancelOnViewDidDisappear(of: self) 79 | /// } 80 | /// } 81 | /// 82 | /// - Note: Because this function causes the given interactor to stongly retain the task, this means the task 83 | /// itself should not strongly retain the interactor. Otherwise a retain cycle would occur causing memory leaks. 84 | /// 85 | /// This function is thread-safe. Invocations of this function to the same interactor instance can be performed on 86 | /// the different threads. 87 | /// 88 | /// This function can only be invoked after the given interactor has received notification that its view has 89 | /// appeared. This is done via the interactor's `viewDidAppear` function. Generally speaking, this interactor 90 | /// should be bound to the lifecycle of a `View`. See `ViewLifecycleObserver` for more details. 91 | /// 92 | /// - Parameters: 93 | /// - interactor: The interactor to bind the task's lifecycle to. 94 | public func cancelOnViewDidDisappear(of interactor: InteractorType) { 95 | if !interactor.hasViewAppeared { 96 | fatalError("\(interactor)'s view has not appeared") 97 | } 98 | interactor.viewAppearanceCancelBag.store(AnyCancellable(self)) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Sources/IMVVM/ViewModelInteractor.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2022 Yi Wang 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Combine 24 | import Foundation 25 | 26 | /// An `Interactor` that has an associated view model used to transform the data from this interactor to presentation 27 | /// data for the corresponding view to display. 28 | /// 29 | /// - Important: Please see `Interactor` documentation for binding requirements. 30 | open class ViewModelInteractor: Interactor { 31 | /// Override this function to setup the subscriptions this interactor requires on start. 32 | /// 33 | /// All the created subscriptions returned from this function are bound to the deinit lifecycle of this interactor. 34 | /// 35 | /// class MyInteractor: Interactor { 36 | /// @CancellableBuilder 37 | /// override func onLoad(viewModel: MyViewModel) -> [AnyCancellable] { 38 | /// myDataStream 39 | /// .sink {...} 40 | /// 41 | /// mySecondDataStream 42 | /// .sink {...} 43 | /// } 44 | /// } 45 | /// 46 | /// - Parameters: 47 | /// - viewModel: The view model used to transform and provide presentation data for the corresponding view to 48 | /// display. 49 | /// - Returns: An array of subscription `AnyCancellable`. 50 | @CancellableBuilder 51 | open func onLoad(viewModel: ViewModelType) -> [AnyCancellable] {} 52 | 53 | /// Override this function to perform logic or setup the subscriptions when the view has appeared. 54 | /// 55 | /// All the created subscriptions returned from this function are bound to the disappearance of this interactor's 56 | /// corresponding view. 57 | /// 58 | /// class MyInteractor: Interactor { 59 | /// @CancellableBuilder 60 | /// override func onViewAppear(viewModel: MyViewModel) -> [AnyCancellable] { 61 | /// myDataStream 62 | /// .sink {...} 63 | /// 64 | /// mySecondDataStream 65 | /// .sink {...} 66 | /// } 67 | /// } 68 | /// 69 | /// - Parameters: 70 | /// - viewModel: The view model used to transform and provide presentation data for the corresponding view to 71 | /// display. 72 | /// - Returns: An array of subscription `AnyCancellable`. 73 | @CancellableBuilder 74 | open func onViewAppear(viewModel: ViewModelType) -> [AnyCancellable] {} 75 | 76 | /// Override this function to perform logic when the interactor's view disappears. 77 | /// 78 | /// - Parameters: 79 | /// - viewModel: The view model used to transform and provide presentation data for the corresponding view to 80 | /// display. 81 | open func onViewDisappear(viewModel: ViewModelType) {} 82 | } 83 | 84 | // MARK: - ViewWithModelLifecycleObserver Conformance 85 | 86 | extension ViewModelInteractor: ViewWithModelLifecycleObserver { 87 | public final func viewDidAppear(viewModel: ViewModelType) { 88 | let occurrence = processViewDidAppear() 89 | guard occurrence != .invalid else { 90 | return 91 | } 92 | 93 | if occurrence == .firstTime { 94 | deinitCancelBag.store(onLoad(viewModel: viewModel)) 95 | } 96 | 97 | viewAppearanceCancelBag.store(onViewAppear(viewModel: viewModel)) 98 | } 99 | 100 | public final func viewDidDisappear(viewModel: ViewModelType) { 101 | let occurrence = processViewDidDisappear() 102 | guard occurrence != .invalid else { 103 | return 104 | } 105 | 106 | onViewDisappear(viewModel: viewModel) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Tests/IMVVMTests/ViewModelInteractorTests.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2022 Yi Wang 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Combine 24 | import Foundation 25 | @testable import IMVVM 26 | import XCTest 27 | 28 | class ViewModelInteractorTests: XCTestCase { 29 | func test_viewLifecycleEvents() { 30 | weak var interactorWeakRef: MockViewModelInteractor? 31 | var onLoadCancellableCallCount = 0 32 | var onLoadViewModelCancellableCallCount = 0 33 | var onViewAppearCancellableCallCount = 0 34 | 35 | autoreleasepool { 36 | let onLoadCancellable = AnyCancellable { 37 | onLoadCancellableCallCount += 1 38 | } 39 | let onLoadViewModelCancellable = AnyCancellable { 40 | onLoadViewModelCancellableCallCount += 1 41 | } 42 | 43 | let interactor = MockViewModelInteractor( 44 | onLoad: { 45 | [onLoadCancellable] 46 | }, 47 | onLoadViewModel: { viewModel in 48 | XCTAssertNotNil(viewModel) 49 | return [onLoadViewModelCancellable] 50 | } 51 | ) 52 | interactorWeakRef = interactor 53 | let viewModel = MockViewModel() 54 | 55 | XCTAssertNotNil(interactorWeakRef) 56 | 57 | XCTAssertEqual(interactor.onLoadCallCount, 0) 58 | XCTAssertEqual(interactor.onLoadViewModelCallCount, 0) 59 | XCTAssertEqual(interactor.onViewAppearCallCount, 0) 60 | XCTAssertEqual(interactor.onViewAppearViewModelCallCount, 0) 61 | XCTAssertEqual(interactor.onViewDisappearCallCount, 0) 62 | XCTAssertEqual(interactor.onViewDisappearViewModelCallCount, 0) 63 | XCTAssertEqual(onLoadCancellableCallCount, 0) 64 | XCTAssertEqual(onLoadViewModelCancellableCallCount, 0) 65 | XCTAssertEqual(onViewAppearCancellableCallCount, 0) 66 | XCTAssertTrue(interactor.deinitCancelBag.isEmpty()) 67 | XCTAssertTrue(interactor.viewAppearanceCancelBag.isEmpty()) 68 | 69 | interactor.viewDidAppear(viewModel: viewModel) 70 | 71 | var didReceiveValue = false 72 | let subject = PassthroughSubject() 73 | subject 74 | .handleEvents(receiveCancel: { 75 | onViewAppearCancellableCallCount += 1 76 | }) 77 | .sink { _ in 78 | didReceiveValue = true 79 | // Strongly retain interactor here creating a retain cycle of: 80 | // interactor->cancelBag->cancellable->subscription->interactor. 81 | XCTAssertNotNil(interactor) 82 | } 83 | .cancelOnViewDidDisappear(of: interactor) 84 | 85 | XCTAssertEqual(interactor.onLoadCallCount, 1) 86 | XCTAssertEqual(interactor.onLoadViewModelCallCount, 1) 87 | XCTAssertEqual(interactor.onViewAppearCallCount, 1) 88 | XCTAssertEqual(interactor.onViewAppearViewModelCallCount, 1) 89 | XCTAssertEqual(interactor.onViewDisappearCallCount, 0) 90 | XCTAssertEqual(interactor.onViewDisappearViewModelCallCount, 0) 91 | XCTAssertEqual(onLoadCancellableCallCount, 0) 92 | XCTAssertEqual(onLoadViewModelCancellableCallCount, 0) 93 | XCTAssertEqual(onViewAppearCancellableCallCount, 0) 94 | XCTAssertTrue(interactor.deinitCancelBag.contains(onLoadCancellable)) 95 | XCTAssertTrue(interactor.deinitCancelBag.contains(onLoadViewModelCancellable)) 96 | XCTAssertFalse(interactor.viewAppearanceCancelBag.isEmpty()) 97 | 98 | subject.send(1) 99 | 100 | XCTAssertTrue(didReceiveValue) 101 | 102 | interactor.viewDidDisappear(viewModel: viewModel) 103 | 104 | XCTAssertEqual(interactor.onLoadCallCount, 1) 105 | XCTAssertEqual(interactor.onLoadViewModelCallCount, 1) 106 | XCTAssertEqual(interactor.onViewAppearCallCount, 1) 107 | XCTAssertEqual(interactor.onViewAppearViewModelCallCount, 1) 108 | XCTAssertEqual(interactor.onViewDisappearCallCount, 1) 109 | XCTAssertEqual(interactor.onViewDisappearViewModelCallCount, 1) 110 | XCTAssertEqual(onLoadCancellableCallCount, 0) 111 | XCTAssertEqual(onLoadViewModelCancellableCallCount, 0) 112 | XCTAssertEqual(onViewAppearCancellableCallCount, 1) 113 | XCTAssertTrue(interactor.deinitCancelBag.contains(onLoadCancellable)) 114 | XCTAssertTrue(interactor.viewAppearanceCancelBag.isEmpty()) 115 | } 116 | 117 | XCTAssertEqual(onLoadCancellableCallCount, 1) 118 | XCTAssertEqual(onLoadViewModelCancellableCallCount, 1) 119 | XCTAssertEqual(onViewAppearCancellableCallCount, 1) 120 | XCTAssertNil(interactorWeakRef) 121 | } 122 | 123 | func test_sinkWithAutoCancelOnDeinit_removeBoundCancellable() { 124 | let interactor = MockViewModelInteractor() 125 | interactor.viewDidAppear(viewModel: MockViewModel()) 126 | 127 | XCTAssertTrue(interactor.deinitCancelBag.isEmpty()) 128 | 129 | let subject = PassthroughSubject() 130 | subject 131 | .ignoreOutput() 132 | .sink(receiveValue: { _ in }) 133 | .cancelOnDeinit(of: interactor) 134 | 135 | XCTAssertFalse(interactor.deinitCancelBag.isEmpty()) 136 | 137 | subject.send(1) 138 | 139 | XCTAssertFalse(interactor.deinitCancelBag.isEmpty()) 140 | 141 | subject.send(completion: .finished) 142 | } 143 | 144 | func test_sinkWithAutoCancelOnDeinit_subscriptionRetainInteractor_NoRetainInteractor() { 145 | weak var interactorWeakRef: MockViewModelInteractor? 146 | 147 | autoreleasepool { 148 | let interactor = MockViewModelInteractor() 149 | interactor.viewDidAppear(viewModel: MockViewModel()) 150 | interactorWeakRef = interactor 151 | 152 | XCTAssertNotNil(interactorWeakRef) 153 | 154 | var didReceiveValue = false 155 | let subject = PassthroughSubject() 156 | subject 157 | .sink { _ in 158 | didReceiveValue = true 159 | // Strongly retain interactor here creating a retain cycle of: 160 | // interactor->cancelBag->cancellable->subscription->interactor. 161 | XCTAssertNotNil(interactor) 162 | } 163 | .cancelOnDeinit(of: interactor) 164 | 165 | subject.send(1) 166 | // Completion event should break the retain cycle by removing the strong retain between cancelBag to cancellable. 167 | subject.send(completion: .finished) 168 | 169 | XCTAssertTrue(didReceiveValue) 170 | } 171 | 172 | XCTAssertNil(interactorWeakRef) 173 | } 174 | 175 | func test_activate_shouldCallDidBecomeActive() { 176 | let interactor = MockViewModelInteractor() 177 | 178 | XCTAssertEqual(interactor.onLoadCallCount, 0) 179 | XCTAssertFalse(interactor.isLoaded) 180 | 181 | interactor.viewDidAppear(viewModel: MockViewModel()) 182 | 183 | XCTAssertEqual(interactor.onLoadCallCount, 1) 184 | XCTAssertTrue(interactor.isLoaded) 185 | 186 | interactor.viewDidDisappear(viewModel: MockViewModel()) 187 | 188 | XCTAssertEqual(interactor.onLoadCallCount, 1) 189 | XCTAssertTrue(interactor.isLoaded) 190 | } 191 | 192 | func test_sinkWithAutoCancelOnDisappear_removeBoundCancellable() { 193 | let interactor = MockViewModelInteractor() 194 | interactor.viewDidAppear(viewModel: MockViewModel()) 195 | 196 | XCTAssertTrue(interactor.deinitCancelBag.isEmpty()) 197 | 198 | let subject = PassthroughSubject() 199 | subject 200 | .ignoreOutput() 201 | .sink(receiveValue: { _ in }) 202 | .cancelOnViewDidDisappear(of: interactor) 203 | 204 | XCTAssertFalse(interactor.viewAppearanceCancelBag.isEmpty()) 205 | 206 | subject.send(1) 207 | 208 | XCTAssertFalse(interactor.viewAppearanceCancelBag.isEmpty()) 209 | 210 | subject.send(completion: .finished) 211 | 212 | XCTAssertTrue(interactor.deinitCancelBag.isEmpty()) 213 | } 214 | 215 | func test_sinkWithAutoCancelOnDisappear_subscriptionRetainInteractor_NoRetainInteractor() { 216 | weak var interactorWeakRef: MockViewModelInteractor? 217 | 218 | autoreleasepool { 219 | let interactor = MockViewModelInteractor() 220 | interactor.viewDidAppear(viewModel: MockViewModel()) 221 | interactorWeakRef = interactor 222 | 223 | XCTAssertNotNil(interactorWeakRef) 224 | 225 | var didReceiveValue = false 226 | let subject = PassthroughSubject() 227 | subject 228 | .sink { _ in 229 | didReceiveValue = true 230 | // Strongly retain interactor here creating a retain cycle of: 231 | // interactor->cancelBag->cancellable->subscription->interactor. 232 | XCTAssertNotNil(interactor) 233 | } 234 | .cancelOnViewDidDisappear(of: interactor) 235 | 236 | subject.send(1) 237 | // Completion event should break the retain cycle by removing the strong retain between cancelBag to cancellable. 238 | subject.send(completion: .finished) 239 | 240 | XCTAssertTrue(didReceiveValue) 241 | } 242 | 243 | XCTAssertNil(interactorWeakRef) 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /Sources/IMVVM/Interactor.swift: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2022 Yi Wang 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | import Combine 24 | import Foundation 25 | 26 | /// The base class of an object providing data to a view's interactor and functionality to handle user events such as 27 | /// button taps. 28 | /// 29 | /// An interactor is a helper object in the MVVM pattern that contains the application business logic. It should 30 | /// perform network operations that supply data to a `ViewModel` for display. It also provides functionality to the 31 | /// view to handle user events such as button taps. Because of this relationship, the view generally holds a strong 32 | /// reference to the interactor to handle user interactions. The interactor holds a strong reference to the view model 33 | /// to send network data for display. 34 | /// 35 | /// The `Interactor` base implementation provides utility functions that help manage business logic, such as handling 36 | /// subscription cancellables. Subclasses should override the lifecycle functions such as `onLoad` and `onViewAppear` 37 | /// to setup all the necessary subscriptions. 38 | /// 39 | /// - Important: An `interactor` is a `ViewLifecycleObserver`. It should be bound to the associated 40 | /// view via the view's `bind(observer:)` modifier, inside the view implementation. This allows the interactor to 41 | /// activate its lifecycle functions. 42 | /// 43 | /// struct MyView: View { 44 | /// @StateObject var interactor: MyInteractor 45 | /// 46 | /// body: some View { 47 | /// myContent 48 | /// .bind(observer: interactor) 49 | /// } 50 | /// } 51 | /// 52 | /// The view must declare and reference the interactor as a `@StateObject`. This allows different 53 | /// instances of the view to reference the same interactor instance, therefore maintaining the states of the view's 54 | /// data. This occurs when multiple instances of the same view are instantiated over time by SwiftUI, as the view 55 | /// updates and changes due to data changes or user interactions. And because the interactor is a `@StateObject`, 56 | /// the binding of the interactor via the `bind(observer:)` modifier must be inside the view's `body`. 57 | /// 58 | /// In order to avoid SwiftUI retaining duplicate instances of the interactor, when the interactor is passed into the 59 | /// view's constructor, it might be passed in as an `@autoclosure`. In other words, invoking the interactor's 60 | /// constructor must be nested within the view's constructor: 61 | /// 62 | /// func makeMyView() -> some View { 63 | /// MyView(interactor: MyInteractor(...)) 64 | /// } 65 | /// 66 | /// - Note: An `interactor` should NOT directly provide view data to the associated view. It should only contain 67 | /// business logic. If the interactor needs to update the view with new data, it should provide the data to a 68 | /// `ViewModel` that transforms it into presentation data for the view to display. Please see `ViewModelInteractor` 69 | /// for details on this use case. 70 | open class Interactor { 71 | /// Stores cancellables until deinit. 72 | let deinitCancelBag = SynchronizedCancelBag() 73 | /// Stores cancellables from view appearance and cancels all on view disappears. 74 | let viewAppearanceCancelBag = SynchronizedCancelBag() 75 | 76 | public private(set) var isLoaded = false 77 | // To avoid duplicate lifecycle function invocations. 78 | public private(set) var hasViewAppeared = false 79 | 80 | public init() {} 81 | /// Override this function to setup the subscriptions this interactor requires on start. 82 | /// 83 | /// All the created subscriptions returned from this function are bound to the deinit lifecycle of this interactor. 84 | /// 85 | /// class MyInteractor: Interactor { 86 | /// @CancellableBuilder 87 | /// override func onLoad() -> [AnyCancellable] { 88 | /// myDataStream 89 | /// .sink {...} 90 | /// 91 | /// mySecondDataStream 92 | /// .sink {...} 93 | /// } 94 | /// } 95 | /// 96 | /// - Returns: An array of subscription `AnyCancellable`. 97 | @CancellableBuilder 98 | open func onLoad() -> [AnyCancellable] {} 99 | 100 | /// Override this function to perform logic or setup the subscriptions when the view has appeared. 101 | /// 102 | /// All the created subscriptions returned from this function are bound to the disappearance of this interactor's 103 | /// corresponding view. 104 | /// 105 | /// class MyInteractor: Interactor { 106 | /// @CancellableBuilder 107 | /// override func onViewAppear() -> [AnyCancellable] { 108 | /// myDataStream 109 | /// .sink {...} 110 | /// 111 | /// mySecondDataStream 112 | /// .sink {...} 113 | /// } 114 | /// } 115 | /// 116 | /// - Returns: An array of subscription `AnyCancellable`. 117 | @CancellableBuilder 118 | open func onViewAppear() -> [AnyCancellable] {} 119 | 120 | /// Override this function to perform logic when the interactor's view disappears. 121 | open func onViewDisappear() {} 122 | 123 | deinit { 124 | deinitCancelBag.cancelAll() 125 | viewAppearanceCancelBag.cancelAll() 126 | } 127 | } 128 | 129 | // MARK: - ViewLifecycleObserver Conformance 130 | 131 | extension Interactor: ViewLifecycleObserver { 132 | enum ViewEventOccurrence { 133 | case invalid 134 | case valid 135 | case firstTime 136 | } 137 | 138 | @discardableResult 139 | func processViewDidAppear() -> ViewEventOccurrence { 140 | guard !hasViewAppeared else { 141 | return .invalid 142 | } 143 | hasViewAppeared = true 144 | 145 | var occurrence = ViewEventOccurrence.valid 146 | if !isLoaded { 147 | isLoaded = true 148 | occurrence = .firstTime 149 | 150 | deinitCancelBag.store(onLoad()) 151 | } 152 | 153 | viewAppearanceCancelBag.store(onViewAppear()) 154 | 155 | return occurrence 156 | } 157 | 158 | @discardableResult 159 | func processViewDidDisappear() -> ViewEventOccurrence { 160 | guard hasViewAppeared else { 161 | return .invalid 162 | } 163 | hasViewAppeared = false 164 | 165 | viewAppearanceCancelBag.cancelAll() 166 | 167 | onViewDisappear() 168 | 169 | return .valid 170 | } 171 | 172 | public final func viewDidAppear() { 173 | processViewDidAppear() 174 | } 175 | 176 | public final func viewDidDisappear() { 177 | processViewDidDisappear() 178 | } 179 | } 180 | 181 | // MARK: - Why the responsibility of binding is left to the callsites 182 | 183 | /// Instead of leaving the responsibility of binding to the callsites, a few other alternative implementations have 184 | /// been attempted: 185 | /// 186 | /// 1. Using a wrapper view. This largely functions the same way as the view modifier. The wrapper view takes in an 187 | /// interactor as an initializer parameter, and invokes the `bind` function on the content view in the the wrapper 188 | /// view's `body` property. The interactor is then passed into the view builder to allow the content view to use 189 | /// without having to separately instantiate the interactor. This however resulted in the callsites being quite 190 | /// confusing to read: 191 | /// InteractableView(interactor) { interactor 192 | /// ContentView(interactor: interactor) 193 | /// } 194 | /// 195 | /// 2. Perform the binding using plugin structures such as `OneOfPlugin` and `ForEachPlugin`. This approach required 196 | /// significant compromises to get it to work. 197 | /// - One implementation strategy is to declare a `View` sub-protocol that provides an interactor instance. This 198 | /// would allow the plugin structures to invoke `bind` on the view using the view's interactor property. However, 199 | /// since if the plugin wrapper view's `viewBuilder` implementation may contain conditionals, the resulting SwiftUI 200 | /// internal `_ConditionalContent` would be required to conform to the sub-protocol. And since the 201 | /// `_ConditionalContent` structure is an internal type, this extension conformance cannot be implemented. 202 | /// - A second implementation strategy is to pass in the view builder and interactor builder closures separately. 203 | /// This allows the plugin wrapper view to internally stitch together the two by mapping with the feature flag as 204 | /// keys. Even though this implementation can work, it does make the callsite somewhat confusing by separating the 205 | /// instantiations of views and their corresponding interactors: 206 | /// ForEachPlugin( 207 | /// featureFlags: MyFeatureFlag.allCases, 208 | /// viewBuilder: { featureFlag in 209 | /// switch featureFlag{ 210 | /// case .flag1: View1() 211 | /// case .flag2: View2() 212 | /// }, 213 | /// interactorBuilder: { featureFlag in 214 | /// switch featureFlag{ 215 | /// case .flag1: Interactor1() 216 | /// case .flag2: Interactor2() 217 | /// } 218 | /// ) 219 | /// A a major drawback to this approach is that in order to avoid instantiating two separate instances of an 220 | /// interactor without making the callsite much more difficult, the interactor needs be assigned to the 221 | /// corresponding view internally in the plugin wrapper view. There are two issues that make this assignment 222 | /// difficult: 223 | /// 1. The `_ConditionalContent` issue of the plugin wrapper's view generic type described in the section above 224 | /// still applies here. The view type cannot be constrained to anything other than the SwiftUI's native `View`. 225 | /// 2. The interactor cannot be assigned to the view via `@EnvironmentObject` since it would require a concrete 226 | /// interactor type. And the plugin wrapper view cannot declare a generic constrain type for the interactor as it 227 | /// would lock the all the views to a single interactor type, 228 | /// The only viable solution to assign an interactor to its view, internally within the plugin wrapper view, is to 229 | /// use a global map that stores the interactors with their names as keys. Then the view can retrieve the interactor 230 | /// instance by first declaring an `associatedtype` of the interactor, then converting that type into a string key, 231 | /// and finally retrieve the interactor from the global map with that key. This is NOT a compile-time safe operation 232 | /// and error-prone. 233 | /// 3. Declare a `InteractableView` protocol and a default `interactable()` view modifier to perform the binding. 234 | /// This unfortunately does not work due to Swift's type system. If the `InteractableView` protocol declares an 235 | /// `associatedtype InteractorType: ViewLifecycleObserver`, the concrete view implementation is then required to 236 | /// provide the `typealias` with a concrete class type. This means the view cannot be injected with a protocol. 237 | /// In that case, the view is tightly coupled with the interactor implementation making use cases such as SwiftUI 238 | /// previews impossible, since a concrete interactor with all of its dependencies must be instantiated to create the 239 | /// preview. If the the `InteractableView` protocol declares the interactor property as 240 | /// `interactor: ViewLifecycleObserver { get }`, then it cannot be used as an argument for the `bind` function. 241 | /// This is due to https://github.com/apple/swift-evolution/blob/main/proposals/0352-implicit-open-existentials.md 242 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | At a high-level the architectural patterns used are composed of the following pieces: 3 | - [iMVVM with SwiftUI](#imvvm-with-swiftui) 4 | - [Dependency Injection](#dependency-injection) 5 | - [Plugins](#plugins) 6 | 7 | # iMVVM with SwiftUI 8 | This section describes each of the components and how they should be used. The source code in the Blueprint app is 9 | built using this architecture and can be used as a source code reference. 10 | 11 | ![Data Flow Diagram](Diagrams/data_flow.png) 12 | ![Ownership Diagram](Diagrams/ownership.png) 13 | [Source Lucid chart](https://lucid.app/lucidchart/1598fe18-df29-4243-bb04-d92df74e1a6b/edit?viewport_loc=-11%2C-11%2C1753%2C1768%2C0_0&invitationId=inv_2b437cc5-d5a6-4841-a7f2-9a5035b01cd3#) 14 | 15 | ## Model 16 | Model is the data model representing the raw data. This is generally returned from the backend or loaded from the disk 17 | cache. Models are typically generated by some code generator such as the [GQL generator](#gql-generator-integration). 18 | 19 | ## View 20 | View is the UI component that utilizes Apple's UI frameworks. In almost all cases, views should be written with the 21 | modern [SwiftUI](https://developer.apple.com/documentation/swiftui/) framework. At the time of writing, SwiftUI does 22 | lack some features and flexibilities compared to the old UIKit framework. However given that it is clearly the 23 | direction Apple is pushing, it is reasonable and more future-proof to adopt it. In rare cases where SwiftUI cannot 24 | satisfy the product needs, UIKit may be used and then embedded into a SwiftUI view hierarchy. 25 | 26 | ### TypeErasedView 27 | `TypeErasedView` is a drop in replacement for SwiftUI's `AnyView`. `AnyView` has two major issues that the 28 | `TypeErasedView` solves: 29 | 1. A view hierarchy containing many `AnyView` instances can have rendering performance issues. 30 | 2. Wrapping an `AnyView` within another `AnyView` can erase certain modifiers such as `onAppear` and `onDisappear`. 31 | This is quite error-prone as different portions of the codebase can unintentionally cause more than one wrapping. 32 | 33 | Using a `TypeErasedView` enables many architectural benefits: 34 | 1. Each feature can be written in its own package without exposing the individual classes as `public`. A single 35 | `public` factory can be used to return a `TypeErasedView` to the parent view package for presentation. 36 | 2. A parent feature containing many child views does not need to be modified when new child views are added or 37 | modified, when the child views are constructed as [plugins](#plugins). 38 | 3. A view/feature's lifecycle can be managed by retaining and releasing the `TypeErasedView` reference. 39 | 40 | ## ViewModel 41 | ViewModel is a helper object that encapsulates the logic of transforming data models into presentation models that the 42 | view can display. In SwiftUI, ViewModels are `ObservableObject` types that publish presentation data to views. It is 43 | important to note that ViewModels should not handle business logic such as network or user interactions. That logic 44 | is encapsulated by the [Interactor](#interactor). 45 | 46 | Presentation data is not only texts and images that are displayed on the screen. It also includes states of the view 47 | such as navigation states. For example, a ViewModel may publish an enum property. The corresponding view observes the 48 | property and shows different subviews depending on the value of the property. In this case, the ViewModel's property 49 | can be considered as view states. 50 | 51 | ViewModels are optional objects in this architecture. Not all Views have a corresponding ViewModel. Simple views that 52 | encapsulate static UI elements do not have to have a corresponding ViewModel. 53 | 54 | ## Interactor 55 | An Interactor is an object, typically a class, designed to encapsulate the application business logic such as network 56 | and user interactions. An interactor may subscribe to a network data stream to fetch models from the backend as a view 57 | first appears. It may send network requests to the backend when a user performs an action. After network data is 58 | received, the Interactor passes this data to the ViewModel. The ViewModel then transforms the network data into 59 | presentation data for the View to observe and display. 60 | 61 | An Interactor is bound to the lifecycle of its corresponding View. As the view appears and disappears, the Interactor's 62 | `onLoad`, `onViewAppear` and `onViewDisappear` functions are invoked accordingly. These functions are overridable, 63 | providing extension points for the concrete implementations to perform various logic during these different lifecycle 64 | stages. Concrete implementations of Interactor should extend from the base `Interactor` or `ViewModelInteractor` class 65 | in the `IMVVMFoundation` package, depending on if the interactor has an associated ViewModel. 66 | 67 | Interactors are optional objects in this architecture. Not all Views have a corresponding Interactor. Simple views that 68 | encapsulate static UI elements do not have to have a corresponding Interactor. 69 | 70 | ## Ownership 71 | As the diagram above illustrated, the ownership of View, ViewModel and Interactor flow in the opposite direction as the 72 | data. View owns both ViewModel and Interactor. Interactor receives a reference to its ViewModel via its lifecycle 73 | methods such as `onLoad`, `onViewAppear`, etc. Interactors do not directly own their ViewModels. 74 | 75 | --- 76 | **IMPORTANT** 77 | 78 | A View must reference its Interactor and ViewModel via the `@StateObject` property wrapper. This allows SwiftUI to 79 | properly manage the ownerships. 80 | ``` 81 | @StateObject var interactor: MyInteractor 82 | @StateObject var viewModel: MyViewModel 83 | ``` 84 | Please see [Why use @StateObject for Interactor and ViewModel](#why-use-stateobject-for-interactor-and-viewmodel) below for detailed reasoning. 85 | 86 | --- 87 | 88 | ## Initialization 89 | In order to decouple all the different types, initialization should be performed via a factory pattern. This means 90 | none of the types, View, ViewModel or Interactor directly instantiates any other types. Instead a separate factory is 91 | used to instantiate each type and constructor inject them into each other. This factory is generally the component 92 | object in the [Dependency Injection](#dependency-injection) system. For example: 93 | ``` 94 | public class MyComponent: Component { 95 | public func make() -> some View { 96 | MyView(interactor: MyInteractor(...), viewModel: MyViewModel(...)) 97 | } 98 | } 99 | ``` 100 | 101 | --- 102 | **IMPORTANT** 103 | 104 | The initialization of Interactor and ViewModel must be embedded within the initialization of their owning View. This 105 | allows the `@StateObject` property wrapper to correctly track the references. Initializing either outside of the 106 | View's initialization will result in memory-leaks. 107 | ``` 108 | public class MyComponent: Component { 109 | public func make() -> some View { 110 | let interactor = MyInteractor(...) // !!! Memory-leak !!! 111 | return MyView(interactor: interactor, viewModel: MyViewModel(...)) 112 | } 113 | } 114 | ``` 115 | Please see [Why embed Interactor and ViewModel initialization](#why-embed-interactor-and-viewmodel-initialization) below for detailed reasoning. 116 | 117 | --- 118 | 119 | If the feature does not have a DI component, then a custom factory object can be created to provide the same 120 | `make` function implementation as above. 121 | 122 | ## Lifecycle 123 | Lifecycle refers to different stages of an object that can be used to perform different operations. 124 | 125 | ### View 126 | The View's lifecycle is directly controlled by SwiftUI. It has four stages: `init`, `onAppear`, `onDisappear` and 127 | `deinit`. Generally a view does not need to utilize these stages beyond the most obvious cases. `let` properties are 128 | set during the `init` stage and released during the `deinit` stage. 129 | 130 | ### ViewModel 131 | The ViewModel only has two stages, `init` and `deinit`. Generally a ViewModel only needs to be aware of the `init` 132 | stage. It sets initial values for its published properties. 133 | 134 | ### Interactor 135 | The Interactor has five stages that it can use to perform business logic: `init`, `onLoad`, `onViewAppear`, 136 | `onViewDisappear` and `deinit`. The `init` stage should only involve setting `let` properties and initial values. The 137 | `onLoad` stage can be used to setup persistent operations such as subscriptions to data streams. It can also utilize 138 | this stage to perform initial network requests. The `onViewAppear` stage can be used to perform business logic that is 139 | only required after its corresponding view has appeared on the screen. Similarly the `onViewDisappear` stage can be 140 | used to perform business logic that is only required after its corresponding view has disappeared from the screen. 141 | Finally the `deinit` stage can be used to release any resources. 142 | 143 | For Combine subscriptions, the Interactor base implementation provides utility operators that help bind a 144 | subscription's lifecycle to either the Interactor's `onViewDisappear` or `deinit` lifecycle. This allows the 145 | subscription to be automatically cancelled when either the corresponding view disappears or the interactor deinits. 146 | ``` 147 | @CancellableBuilder 148 | override func onLoad(viewModel: MyViewModel) -> [AnyCancellable] { 149 | publisher 150 | .sink { 151 | ... 152 | } 153 | .bindToDeinit(of: self) // self is the interactor instance. 154 | } 155 | ``` 156 | ``` 157 | @CancellableBuilder 158 | override func onViewAppear(viewModel: MyViewModel) -> [AnyCancellable] { 159 | publisher 160 | .sink { 161 | ... 162 | } 163 | .bindToViewDidDisappear(of: self) // self is the interactor instance. 164 | } 165 | ``` 166 | 167 | If the Interactor does not have a corresponding ViewModel, it should inherit from the `Interactor` base class instead 168 | of the `ViewModelInteractor` class. The lifecycle methods are the same, except in this case the `viewModel` parameter 169 | is omitted. 170 | 171 | ### Tying everything together 172 | Both ViewModel and Interactor are tied to the lifecycle of the View. This means that fundamentally there is only a 173 | single lifecycle driven by SwiftUI directly. 174 | 175 | --- 176 | **IMPORTANT** 177 | 178 | A View that has an Interactor must bind the interactor via the `bind(observer:)` or `bind(observer: viewModel:)` 179 | modifier of the view. This establishes the lifecycle connection between the View, the Interactor and the ViewModel. 180 | This operation must be performed within the View's `body` property. 181 | ``` 182 | struct MyView & MyViewHandler>: View { 183 | @StateObject var interactor: Interactor 184 | @StateObject var viewModel: MyViewModel 185 | 186 | var body: some View { 187 | content 188 | .bind(observer: interactor, viewModel: viewModel) 189 | } 190 | } 191 | ``` 192 | ``` 193 | struct MyView: View { 194 | @StateObject var interactor: Interactor 195 | 196 | var body: some View { 197 | content 198 | .bind(observer: interactor) 199 | } 200 | } 201 | ``` 202 | --- 203 | 204 | ## Decoupling View and Interactor 205 | There are many reasons why it is a good practice to decouple the View from its Interactor's concrete implementation. 206 | The [SwiftUI previews](#swiftui-previews) section below demonstrates such a use case. The decoupling can be simply 207 | implemented via an interactor protocol. The View only references the protocol and never the concrete implementation. 208 | The implementation can conform to the protocol. 209 | 210 | ### In the view file 211 | ``` 212 | protocol MyViewHandler { 213 | func doStuff(viewModel: MyViewModel) 214 | } 215 | 216 | struct MyView & MyViewHandler>: View { 217 | @StateObject var interactor: Interactor 218 | @StateObject var viewModel: MyViewModel 219 | 220 | var body: some View { 221 | Button("My Button") { 222 | interactor.dofStuff(viewModel: viewModel) 223 | } 224 | } 225 | } 226 | ``` 227 | 228 | ### In the interactor file 229 | ``` 230 | class MyInteractor: Interactor, MyViewHandler { 231 | func doStuff(viewModel: MyViewModel) { 232 | ... 233 | } 234 | } 235 | ``` 236 | 237 | ## SwiftUI previews 238 | SwiftUI previews can significantly improve the development process of SwiftUI views. The iMVVM architecture is 239 | designed to support building SwiftUI previews. The previews can be created by providing fixture ViewModel data and 240 | mocked Interactor types. 241 | 242 | ``` 243 | class MyViewModel: ObservableObject { 244 | @Published var ... 245 | ... 246 | } 247 | protocol MyViewHandler { 248 | func doStuff(viewModel: MyViewModel) 249 | ... 250 | } 251 | struct MyView & MyViewHandler>: View { 252 | @StateObject var interactor: Interactor 253 | @StateObject var viewModel: MyViewModel 254 | } 255 | 256 | #if DEBUG 257 | class MyViewPreviewsInteractor: ViewModelInteractor MyViewHandler { 258 | func doStuff(viewModel: MyViewModel) { 259 | viewModel.property = "new value" 260 | } 261 | } 262 | 263 | struct MyViewPreviews: PreviewProvider { 264 | static var previews: some View { 265 | MyView(interactor: MyViewPreviewsInteractor(), viewModel: MyViewModel()) 266 | } 267 | } 268 | #endif 269 | ``` 270 | 271 | ## Further details 272 | This section provides further details on some of the information described above. 273 | 274 | ### Why use @StateObject for Interactor and ViewModel 275 | In SwiftUI `@StateObject` is used to declare a View is the owner of an object. At runtime, SwiftUI replaces old 276 | instances of Views with new ones when the contents of the View changes. By declaring an Interactor with the `@StateObject` 277 | property wrapper, SwiftUI ensures the same instance of the Interactor is retained in memory and linked to the new 278 | instance of the View when such a replacement occurs. 279 | 280 | Because Interactors are typically stateful, this ensures the data already fetched from the backend is retained properly. 281 | Otherwise unnecessary data fetches would have to occur for a new instance of the Interactor to populate the View. The 282 | exact same reasoning applies to the ViewModel as well. 283 | 284 | Beyond properly retaining state, because the Interactor is bound to the View's `onAppear` and `onDisappear` lifecycle 285 | methods, the Interactor only "activates" when the View's `onAppear` method is invoked by SwiftUI. When a View instance 286 | is replaced by SwiftUI due to content changes, as SwiftUI considers the two instances of the View are the same, the new 287 | View instance's `onAppear` method is NOT invoked after the replacement. This means if the Interactor is not referenced 288 | as a `@StateObject`, a new instance of Interactor is created but it will never "activate" to perform any work. A clear 289 | symptom of this issue is after a View's content changes, the View gets stuck in its loading state. 290 | 291 | ### Why embed Interactor and ViewModel initialization 292 | A quirk of the `@StateObject` property wrapper is that its initializer is declared with `@autoclosure`. When an 293 | Interactor or ViewModel is declared an `@StateObject` and passed into the View's initializer, whatever is passed in 294 | is automatically wrapped with a closure. This allows the `@StateObject` property wrapper to lazily instantiate the 295 | actual object after the View has been properly installed in the view hierarchy. 296 | 297 | If an instance of the Interactor is instantiated outside the View's initializer: 298 | ``` 299 | func make() -> some View { 300 | let interactor = MyInteractor() 301 | return MyView(interactor: interactor) 302 | } 303 | ``` 304 | SwiftUI would retain that instance via the closure. The first time when the View and its Interactor is instantiated, 305 | everything works as expected. Once the View updates and SwiftUI instantiates the second instances however, a 306 | memory-leak would occur. As the function that instantiates the View and Interactor is invoked for the second time, a 307 | new instance of the Interactor and a new instance of the View is created. SwiftUI first replaces the old instance of 308 | the View with the new instance. At this time, only a single instance of the View exists. The old instance of Interactor 309 | is still retained by `@StateObject`, and so is the new instance. SwiftUI then installs the old instance of Interactor 310 | to the new instance of the View as expected. This completes the update process. However, the new instance of Interactor 311 | which has already been created before the replacement even occurs, is still retained by the `@StateObject` closure, 312 | as `@StateObject`s are managed as a global cache by SwiftUI. This leaks the new instance of the Interactor. 313 | 314 | If the Interactor is properly initialized by embedding into the View's initializer: 315 | ``` 316 | func make() -> some View { 317 | MyView(interactor: MyInteractor()) 318 | } 319 | ``` 320 | whenever the function is invoked, no instances of the Interactor is actually created. The `@autoclosure` only retains 321 | the initializer function of the Interactor. Once the replacement is completed, no new instances of the Interactor was 322 | ever created. Therefore, no memory-leak! 323 | 324 | ## Alternatives 325 | This architectural pattern provides a reasonable separation between data, view and business logic without adding too 326 | much complexity or overhead. For comparison with other patterns, please see the [Alternative architectures](#alternative-architectures) 327 | section below. 328 | 329 | # Dependency Injection 330 | Dependency injection, or DI, is used to decouple unrelated code and enable unit testing. Instead of coupling classes 331 | together with concrete implementations, DI should be used to link disparate code together. 332 | 333 | In order to achieve compile-time safety, the framework [Needle](https://github.com/uber/needle) is used. For further 334 | details on the benefits of using DI, please see Needle's [documentation](https://github.com/uber/needle/blob/master/WHY_DI.md). 335 | 336 | Please refer to Needle's official documentation on how to use the framework. 337 | 338 | As mentioned above, the DI component of a feature generally acts as the factory of the feature. It declares a `make` 339 | function that returns the View with its Interactor already bound. 340 | ``` 341 | public class MyComponent: Component { 342 | public func make() -> some View { 343 | MyView(interactor: MyInteractor(), viewModel: MyViewModel()) 344 | } 345 | } 346 | ``` 347 | 348 | ## Scopes 349 | Scopes naturally emerge with iMVVM and DI patterns. A scope can be defined generally in three ways: 350 | - An iMVVM set of classes. 351 | - A node in the DI graph. 352 | - A state in the application. 353 | 354 | For example, a basic app can be divided into `LoggedIn` and `LoggedOut` scopes. From an iMVVM perspective, each scope 355 | has their own iMVVM objects such as `LoggedInView`, `LoggedInViewModel`, etc. From a DI perspective, both `LoggedIn` 356 | and `LoggedOut` represent nodes in the DI graph. Using Needle specific terminologies, there is a `LoggedInComponent` 357 | and a `LoggedOutComponent`. From an application state perspective, the `LoggedIn` scope represents the state where the 358 | user has successfully signed into the app, whereas the `LoggedOut` scope represents the state when the user has not 359 | been authenticated. 360 | 361 | # Plugins 362 | The plugin pattern allows separation between disjointed parts of the application to be decoupled yet integrated with 363 | clean interfaces. The architectural approach we decided on is that "(almost) everything is a plugin". This means every 364 | set of features such as items in a feed, tabs in a tab view are implemented as plugins. Utility objects such as 365 | workers objects can also be plugins. 366 | 367 | Each plugin has its own feature flag. The plugin is only instantiated and integrated with its parent if the 368 | corresponding feature flag is turned on. All plugins are defaulted to the "on" state. This means when the feature flag 369 | framework fails to retrieve a value for a specific feature flag, the associated plugin is by default instantiated and 370 | integrated. This optimistic approach of assuming feature flags are default "on" simplifies the understanding of most 371 | runtime code paths. At the same time any instabilities in the feature flag framework will not affect the entire app. 372 | This implies turning off a plugin is a best-effort operation, due to the potential instabilities in the feature flag 373 | framework. 374 | 375 | Please refer to the `PluginFramework` package for the base implementations. Most commonly, a set of SwiftUI `View` 376 | based structures such as `Plugin` and `ForEachPlugin` provide integrations between standard SwiftUI `View` and the 377 | plugin pattern. 378 | ``` 379 | struct ParentView: View { 380 | let pluginViewBuilder: (MyFeatureFlag) -> TypeErasedView 381 | 382 | var body: some View { 383 | ForEachPlugin(featureFlags: MyFeatureFlags.allCases, viewBuilder: pluginViewBuilder) 384 | } 385 | } 386 | ``` 387 | 388 | # Xcode Project Generation & Tools Integration 389 | In order to codify the Xcode project configurations and avoid project file merge conflicts, all apps' Xcode projects 390 | should be generated from manifest files. (Tuist)[https://docs.tuist.io/] is a good tool for project generation. 391 | 392 | In order to create a new app, a `Project.swift` manifest file should be created to define the structure and 393 | configuration of the Xcode project. Please refer to 394 | [Tuist's documentation](https://tuist.github.io/tuist/latest/documentation/projectdescription/project/) for details. 395 | The manifest file for the Blueprint app can be used as a quick-start reference. 396 | 397 | ## Needle generator integration 398 | To streamline the local development process, it is generally a best practice to integrate Needle DI code generation 399 | with the Xcode project. Because DI code is both required to compile the application and verify the DI graph is setup 400 | correctly, it needs to be integrated as both the app scheme's pre-build action and the target's pre-build phase. In 401 | order to fail the build process in case the DI graph is not properly setup, the pre-build phase integration is used. 402 | This helps to detect DI graph issues such as missing dependencies during local development. Xcode scheme's pre-action 403 | does not fail the build process regardless of the result from the integrated actions. 404 | 405 | ### Scheme pre-build action 406 | ``` 407 | schemes: [ 408 | Scheme( 409 | name: "APP_NAME", 410 | shared: true, 411 | buildAction: .buildAction( 412 | targets: ["APP_TARGET_NAME"], 413 | preActions: [ 414 | ExecutionAction( 415 | title: "Generate Needle", 416 | scriptText: "cd \"$SRCROOT\" && /usr/local/bin/needle-generator \"$SRCROOT\"/needle.json", 417 | target: "APP_TARGET_NAME" 418 | ), 419 | ... 420 | ``` 421 | 422 | ### Target pre-build phase 423 | ``` 424 | targets: [ 425 | Target( 426 | name: "Otter", 427 | platform: .iOS, 428 | scripts: [ 429 | .pre(script: "cd \"$SRCROOT\" && /usr/local/bin/needle-generator \"$SRCROOT\"/needle.json", name: "Generate Needle"), 430 | ], 431 | ... 432 | ``` 433 | 434 | ## GQL generator integration 435 | Since application code relies on GQL generated code to compile, it is generally a best practice to integrate the GQL 436 | generator with the Xcode project to streamline the development process. Unlike Needle however, GQL generation is a 437 | slower process. Because of this, it is better to only integrate it as the app scheme's pre-build action. This means 438 | that if the generation process fails due to validation errors, the build process will not be affected. Fortunately, in 439 | most scenarios, if the generation fails, the application source code would not compile properly either. 440 | 441 | ### Scheme pre-build action 442 | ``` 443 | schemes: [ 444 | Scheme( 445 | name: "APP_NAME", 446 | shared: true, 447 | buildAction: .buildAction( 448 | targets: ["APP_TARGET_NAME"], 449 | preActions: [ 450 | ExecutionAction( 451 | title: "Generate GQL", 452 | scriptText: "cd \"$SRCROOT\" && /usr/local/bin/gql-generator \"$SRCROOT\"", 453 | target: "APP_TARGET_NAME" 454 | ), 455 | ... 456 | ``` 457 | 458 | # Mock Generation 459 | In order to write unit tests, mocks are necessary in most cases. [Mockingbird](https://github.com/birdrides/mockingbird) 460 | is a good tool to provide this functionality. 461 | 462 | There are times where the generated mocks are insufficient for the tests cases. In these cases, manually writing mocks 463 | is the solution. 464 | 465 | # Additional Context 466 | This section provides additional context on the decisions described above. This information isn't crucial in developing 467 | iOS apps. It is provided here for prosperity. 468 | 469 | ## Alternative architectures 470 | The following alternatives were considered and compared to the chosen iMVVM pattern. 471 | 472 | ### RIBs 473 | For large scale apps that concentrates many features into a single UI, such as Uber, RIBs is a great pattern to use. 474 | For most other apps, smaller or has natural UI separation of concerns, RIBs is too complicated for the job. 475 | 476 | ### MVC/(B)VIPER 477 | MVC isn’t really applicable for the SwiftUI world. It is also pretty much the same as the chosen iMVVM pattern anyways. 478 | (B)VIPER is a more complex and more boilerplate version of MVx. It doesn’t really provide much advantages over iMVVM 479 | given iMVVM already has good separation of concerns. 480 | 481 | ## Uber's core vs non-core approach of plugins 482 | At Uber, apps were partially built as plugins. Some features are built directly without using the plugin API. It aimed 483 | to address the distinction between “core” features and “non-core” features. The downsides of this approach are: 484 | - Inconsistency across different parts of the app. 485 | - Disagreements around what is core and what is non-core. 486 | - Core features cannot be turned off. 487 | To address these issues, the proposal is to build all the features of the app as plugins! 488 | 489 | The app contains a basic structure of starting up. From there on, all features are built as plugins. From an 490 | architectural perspective, the distinction between core and non-core is completely removed. Only the skeleton of the 491 | app remains outside of the plugin API. 492 | 493 | ## Uber's default off approach of plugins 494 | Drawing from the experience at Uber, it is very difficult to reason about what the end user actually sees since all 495 | plugins are default off. And because the backing feature flag system cannot be 100% reliable, some users may be left 496 | in a broken state without us even knowing. 497 | 498 | Instead of being pessimistic about our own code, we choose to be optimistic. All plugins are default on! Each plugin 499 | has a feature flag automatically generated on the client side. In case of outages, engineers may configure the backend 500 | feature flag service to turn off plugins. Since the plugin service is not 100% reliable, turning off plugins is a best 501 | effort operation. 502 | --------------------------------------------------------------------------------