Skip to content

Commit e5773f4

Browse files
committed
Add RIB worker
1 parent 12ca1f7 commit e5773f4

File tree

2 files changed

+214
-4
lines changed

2 files changed

+214
-4
lines changed

Sources/RIBs/Interactor.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ open class Interactor: ScopeLifecycleDependent, Interactable {
7474
return isActivePublisher.eraseToAnyPublisher()
7575
}
7676

77-
fileprivate var activenessCancellable: [Cancellable]?
77+
fileprivate var activenessCancellable: [AnyCancellable]?
7878

7979
override open func didBecomeActive(_ lifecyclePublisher: LifecyclePublisher) {
8080
super.didBecomeActive(lifecyclePublisher)
@@ -138,8 +138,8 @@ public extension Publisher {
138138
}
139139
}
140140

141-
/// Interactor related `Cancellable` extensions.
142-
public extension Cancellable {
141+
/// Interactor related `AnyCancellable` extensions.
142+
public extension AnyCancellable {
143143

144144
/// Cancels the subscription based on the lifecycle of the given `Interactor`. The subscription is cancelled
145145
/// when the interactor is deactivated.
@@ -156,7 +156,7 @@ public extension Cancellable {
156156
///
157157
/// - parameter interactor: The interactor to cancel the subscription based on.
158158
@discardableResult
159-
func cancelOnDeactivate(interactor: Interactor) -> Cancellable {
159+
func cancelOnDeactivate(interactor: Interactor) -> AnyCancellable {
160160
if let _ = interactor.activenessCancellable {
161161
interactor.activenessCancellable?.append(self)
162162
} else {

Sources/RIBs/Worker/Worker.swift

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
//
2+
// Copyright (c) 2021. Adam Share
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//
16+
17+
import Combine
18+
19+
/// The base protocol of all workers that perform a self-contained piece of logic.
20+
///
21+
/// `Worker`s are always bound to an `Interactor`. A `Worker` can only start if its bound `Interactor` is active.
22+
/// It is stopped when its bound interactor is deactivated.
23+
public protocol Working: AnyObject {
24+
25+
/// Starts the `Worker`.
26+
///
27+
/// If the bound `InteractorScope` is active, this method starts the `Worker` immediately. Otherwise the `Worker`
28+
/// will start when its bound `Interactor` scope becomes active.
29+
///
30+
/// - parameter interactorScope: The interactor scope this worker is bound to.
31+
func start(_ interactorScope: InteractorScope)
32+
33+
/// Stops the worker.
34+
///
35+
/// Unlike `start`, this method always stops the worker immediately.
36+
func stop()
37+
38+
/// Indicates if the worker is currently started.
39+
var isStarted: Bool { get }
40+
41+
/// The lifecycle of this worker.
42+
///
43+
/// Subscription to this stream always immediately returns the last event. This stream terminates after the
44+
/// `Worker` is deallocated.
45+
var isStartedStream: AnyPublisher<Bool, Never> { get }
46+
}
47+
48+
/// The base `Worker` implementation.
49+
open class Worker: Working {
50+
51+
/// Indicates if the `Worker` is started.
52+
public final var isStarted: Bool {
53+
return isStartedSubject.value
54+
}
55+
56+
/// The lifecycle stream of this `Worker`.
57+
public final var isStartedStream: AnyPublisher<Bool, Never> {
58+
return isStartedSubject
59+
.removeDuplicates()
60+
.eraseToAnyPublisher()
61+
}
62+
63+
/// Initializer.
64+
public init() {
65+
// No-op
66+
}
67+
68+
/// Starts the `Worker`.
69+
///
70+
/// If the bound `InteractorScope` is active, this method starts the `Worker` immediately. Otherwise the `Worker`
71+
/// will start when its bound `Interactor` scope becomes active.
72+
///
73+
/// - parameter interactorScope: The interactor scope this worker is bound to.
74+
public final func start(_ interactorScope: InteractorScope) {
75+
guard !isStarted else {
76+
return
77+
}
78+
79+
stop()
80+
81+
isStartedSubject.send(true)
82+
83+
// Create a separate scope struct to avoid passing the given scope instance, since usually
84+
// the given instance is the interactor itself. If the interactor holds onto the worker without
85+
// de-referencing it when it becomes inactive, there will be a retain cycle.
86+
let weakInteractorScope = WeakInteractorScope(sourceScope: interactorScope)
87+
bind(to: weakInteractorScope)
88+
}
89+
90+
/// Called when the the worker has started.
91+
///
92+
/// Subclasses should override this method and implment any logic that they would want to execute when the `Worker`
93+
/// starts. The default implementation does nothing.
94+
///
95+
/// - parameter interactorScope: The interactor scope this `Worker` is bound to.
96+
open func didStart(_ interactorScope: InteractorScope) {}
97+
98+
/// Stops the worker.
99+
///
100+
/// Unlike `start`, this method always stops the worker immediately.
101+
public final func stop() {
102+
guard isStarted else {
103+
return
104+
}
105+
106+
isStartedSubject.send(false)
107+
108+
executeStop()
109+
}
110+
111+
/// Called when the worker has stopped.
112+
///
113+
/// Subclasses should override this method abnd implement any cleanup logic that they might want to execute when
114+
/// the `Worker` stops. The default implementation does noting.
115+
///
116+
/// - note: All subscriptions added to the disposable provided in the `didStart` method are automatically cancelled
117+
/// when the worker stops.
118+
open func didStop() {
119+
// No-op
120+
}
121+
122+
// MARK: - Private
123+
124+
private let isStartedSubject = CurrentValueSubject<Bool, Never>(false)
125+
fileprivate var disposable: [AnyCancellable]?
126+
private var interactorBindingCancellable: Cancellable?
127+
128+
private func bind(to interactorScope: InteractorScope) {
129+
unbindInteractor()
130+
131+
interactorBindingCancellable = interactorScope.isActiveStream
132+
.sink(receiveValue: { [weak self] (isInteractorActive: Bool) in
133+
if isInteractorActive {
134+
if self?.isStarted == true {
135+
self?.executeStart(interactorScope)
136+
}
137+
} else {
138+
self?.executeStop()
139+
}
140+
})
141+
}
142+
143+
private func executeStart(_ interactorScope: InteractorScope) {
144+
disposable = []
145+
didStart(interactorScope)
146+
}
147+
148+
private func executeStop() {
149+
guard let _ = disposable else {
150+
return
151+
}
152+
153+
self.disposable = nil
154+
155+
didStop()
156+
}
157+
158+
private func unbindInteractor() {
159+
interactorBindingCancellable?.cancel()
160+
interactorBindingCancellable = nil
161+
}
162+
163+
deinit {
164+
stop()
165+
unbindInteractor()
166+
isStartedSubject.send(completion: .finished)
167+
}
168+
}
169+
170+
/// Worker related `AnyCancellable` extensions.
171+
public extension AnyCancellable {
172+
173+
/// Cancels the subscription based on the lifecycle of the given `Worker`. The subscription is cancelled when the
174+
/// `Worker` is stopped.
175+
///
176+
/// If the given worker is stopped at the time this method is invoked, the subscription is immediately terminated.
177+
///
178+
/// - note: When using this composition, the subscription closure may freely retain the `Worker` itself, since the
179+
/// subscription closure is cancelled once the `Worker` is stopped, thus releasing the retain cycle before the
180+
/// `worker` needs to be deallocated.
181+
///
182+
/// - parameter worker: The `Worker` to cancel the subscription based on.
183+
@discardableResult
184+
func cancelOnStop(_ worker: Worker) -> Cancellable {
185+
if let _ = worker.disposable {
186+
worker.disposable?.append(self)
187+
} else {
188+
cancel()
189+
print("Subscription immediately terminated, since \(worker) is stopped.")
190+
}
191+
return self
192+
}
193+
}
194+
195+
fileprivate class WeakInteractorScope: InteractorScope {
196+
197+
weak var sourceScope: InteractorScope?
198+
199+
var isActive: Bool {
200+
return sourceScope?.isActive ?? false
201+
}
202+
203+
var isActiveStream: AnyPublisher<Bool, Never> {
204+
return sourceScope?.isActiveStream ?? Just(false).eraseToAnyPublisher()
205+
}
206+
207+
fileprivate init(sourceScope: InteractorScope) {
208+
self.sourceScope = sourceScope
209+
}
210+
}

0 commit comments

Comments
 (0)