Skip to content

Commit 5010727

Browse files
authored
[feat]: add experimental WorkflowUI telemetry hooks (#221)
### Motivation we'd like to be able to build out some observation tooling that is aware of certain events that occur within WorkflowUI. specifically, view lifecycle data is of particular interest. currently the options for tying into UIViewController lifecycle events are either: 1. introduce new subclasses in client code that override the methods of interest and provide observation hooks there. 2. swizzle the methods of interest to allow client observation option `1.` may place a large burden on consumers that have many existing subclasses (of, say, ScreenViewController), and only works for the `open` types defined in `WorkflowUI`, which excludes `WorkflowHostingController` & `DescribedViewController`. option `2.` may require less code to implement, but adds indirection (swizzling is often not particularly discoverable), runtime overhead, and may not work for Swift-only methods. note: this is all currently implemented behind 'experimental' SPI declarations. the intent would be to treat this as sort of an 'alpha' version of this functionality, and to set expectations that it may have breaking changes going forward, without a major version increment in the library. ### Implementation to achieve this goal, we've implemented an approach that does the following: - unifies all `UIViewController` subtypes defined in `WorkflowUI` under a new parent class `WorkflowUIViewController` - instruments some view controller lifecycle events (viewDidAppear, etc) within the shared base class - exposes a static value that can be used to register a 'global' observer of these events - adds some new protocols and event data types to enable observation of WorkflowUI lifecycle events
1 parent ccece24 commit 5010727

File tree

8 files changed

+522
-5
lines changed

8 files changed

+522
-5
lines changed

WorkflowUI/Sources/Hosting/WorkflowHostingController.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import UIKit
2222
import Workflow
2323

2424
/// Drives view controllers from a root Workflow.
25-
public final class WorkflowHostingController<ScreenType, Output>: UIViewController where ScreenType: Screen {
25+
public final class WorkflowHostingController<ScreenType, Output>: WorkflowUIViewController where ScreenType: Screen {
2626
public typealias CustomizeEnvironment = (inout ViewEnvironment) -> Void
2727

2828
/// Emits output events from the bound workflow.
@@ -118,13 +118,13 @@ public final class WorkflowHostingController<ScreenType, Output>: UIViewControll
118118
updatePreferredContentSizeIfNeeded()
119119
}
120120

121-
public override func viewWillLayoutSubviews() {
121+
override public func viewWillLayoutSubviews() {
122122
super.viewWillLayoutSubviews()
123123
applyEnvironmentIfNeeded()
124124
}
125125

126126
override public func viewDidLayoutSubviews() {
127-
super.viewDidLayoutSubviews()
127+
defer { super.viewDidLayoutSubviews() }
128128
rootViewController.view.frame = view.bounds
129129
}
130130

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright 2023 Square Inc.
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+
#if canImport(UIKit)
18+
import Foundation
19+
import UIKit
20+
21+
/// Protocol that describes an observable 'event' that may be emitted from `WorkflowUI`.
22+
@_spi(ExperimentalObservation)
23+
public protocol WorkflowUIEvent {
24+
var viewController: UIViewController { get }
25+
}
26+
27+
// MARK: ViewController Lifecycle Events
28+
29+
/// Event emitted from a `WorkflowUIViewController`'s `viewWillLayoutSubviews` method.
30+
@_spi(ExperimentalObservation)
31+
public struct ViewWillLayoutSubviewsEvent: WorkflowUIEvent, Equatable {
32+
public let viewController: UIViewController
33+
}
34+
35+
/// Event emitted from a `WorkflowUIViewController`'s `viewDidLayoutSubviews` method.
36+
@_spi(ExperimentalObservation)
37+
public struct ViewDidLayoutSubviewsEvent: WorkflowUIEvent, Equatable {
38+
public let viewController: UIViewController
39+
}
40+
41+
/// Event emitted from a `WorkflowUIViewController`'s `viewWillAppear` method.
42+
@_spi(ExperimentalObservation)
43+
public struct ViewWillAppearEvent: WorkflowUIEvent, Equatable {
44+
public let viewController: UIViewController
45+
public let animated: Bool
46+
public let isFirstAppearance: Bool
47+
}
48+
49+
/// Event emitted from a `WorkflowUIViewController`'s `viewDidAppear` method.
50+
@_spi(ExperimentalObservation)
51+
public struct ViewDidAppearEvent: WorkflowUIEvent, Equatable {
52+
public let viewController: UIViewController
53+
public let animated: Bool
54+
public let isFirstAppearance: Bool
55+
}
56+
#endif
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Copyright 2023 Square Inc.
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+
#if canImport(UIKit)
18+
import Foundation
19+
20+
/// Protocol to observe events emitted from WorkflowUI.
21+
/// **N.B. This is currently part of an experimental interface, and may have breaking changes in the future.**
22+
@_spi(ExperimentalObservation)
23+
public protocol WorkflowUIObserver {
24+
func observeEvent<E: WorkflowUIEvent>(_ event: E)
25+
}
26+
27+
// MARK: - Global Observation
28+
29+
@_spi(ExperimentalObservation)
30+
public enum WorkflowUIObservation {
31+
/// The shared `WorkflowUIObserver` instance to which all `WorkflowUIEvent`s will be forwarded.
32+
public static var sharedUIObserver: WorkflowUIObserver?
33+
}
34+
35+
#endif
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* Copyright 2023 Square Inc.
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+
#if canImport(UIKit)
18+
import Foundation
19+
import UIKit
20+
21+
/// Ancestor type from which all ViewControllers in WorkflowUI inherit.
22+
open class WorkflowUIViewController: UIViewController {
23+
/// Set to `true` once `viewDidAppear` has been called
24+
public private(set) final var hasViewAppeared: Bool = false
25+
26+
// MARK: Event Emission
27+
28+
/// Observation event emission point.
29+
/// - Parameter event: The event forwarded to any observers.
30+
@_spi(ExperimentalObservation)
31+
public final func sendObservationEvent<E: WorkflowUIEvent>(
32+
_ event: @autoclosure () -> E
33+
) {
34+
WorkflowUIObservation
35+
.sharedUIObserver?
36+
.observeEvent(event())
37+
}
38+
39+
// MARK: Lifecycle Methods
40+
41+
override open func viewWillAppear(_ animated: Bool) {
42+
sendObservationEvent(ViewWillAppearEvent(
43+
viewController: self,
44+
animated: animated,
45+
isFirstAppearance: !hasViewAppeared
46+
))
47+
super.viewWillAppear(animated)
48+
}
49+
50+
override open func viewDidAppear(_ animated: Bool) {
51+
let isFirstAppearance = !hasViewAppeared
52+
if isFirstAppearance { hasViewAppeared = true }
53+
54+
super.viewDidAppear(animated)
55+
56+
sendObservationEvent(ViewDidAppearEvent(
57+
viewController: self,
58+
animated: animated,
59+
isFirstAppearance: isFirstAppearance
60+
))
61+
}
62+
63+
override open func viewWillLayoutSubviews() {
64+
sendObservationEvent(
65+
ViewWillLayoutSubviewsEvent(viewController: self)
66+
)
67+
super.viewWillLayoutSubviews()
68+
}
69+
70+
override open func viewDidLayoutSubviews() {
71+
super.viewDidLayoutSubviews()
72+
sendObservationEvent(
73+
ViewDidLayoutSubviewsEvent(viewController: self)
74+
)
75+
}
76+
}
77+
#endif

WorkflowUI/Sources/Screen/ScreenViewController.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import ViewEnvironment
3737
/// }
3838
/// }
3939
/// ```
40-
open class ScreenViewController<ScreenType: Screen>: UIViewController {
40+
open class ScreenViewController<ScreenType: Screen>: WorkflowUIViewController {
4141
public private(set) final var screen: ScreenType
4242

4343
public final var screenType: Screen.Type {

WorkflowUI/Sources/ViewControllerDescription/DescribedViewController.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
import UIKit
2020

21-
public final class DescribedViewController: UIViewController {
21+
public final class DescribedViewController: WorkflowUIViewController {
2222
var currentViewController: UIViewController
2323

2424
public init(description: ViewControllerDescription) {
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
* Copyright 2023 Square Inc.
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+
#if canImport(UIKit)
18+
import Combine
19+
import Workflow
20+
import XCTest
21+
22+
@_spi(ExperimentalObservation) import WorkflowUI
23+
24+
open class WorkflowUIObservationTestCase: XCTestCase {
25+
var publishingObserver: PublishingObserver!
26+
27+
var observedEvents: [WorkflowUIEvent] = []
28+
29+
private var cancellables: [AnyCancellable] = []
30+
31+
override open func invokeTest() {
32+
publishingObserver = PublishingObserver()
33+
defer { publishingObserver = nil }
34+
35+
// collect all events emitted during test invocation
36+
publishingObserver.subject
37+
.sink { [weak self] event in
38+
self?.observedEvents.append(event)
39+
}
40+
.store(in: &cancellables)
41+
42+
withGlobalObserver(publishingObserver) {
43+
super.invokeTest()
44+
}
45+
}
46+
47+
private func withGlobalObserver(_ globalObserver: WorkflowUIObserver, perform: () -> Void) {
48+
let oldObserver = WorkflowUIObservation.sharedUIObserver
49+
defer {
50+
WorkflowUIObservation.sharedUIObserver = oldObserver
51+
}
52+
53+
WorkflowUIObservation.sharedUIObserver = globalObserver
54+
perform()
55+
}
56+
57+
func observationEvents(
58+
from viewController: WorkflowUIViewController,
59+
perform: () -> Void
60+
) -> [WorkflowUIEvent] {
61+
var events: [WorkflowUIEvent] = []
62+
63+
let scopedObserver = publishingObserver
64+
.publisher
65+
.filter { $0.viewController === viewController }
66+
.sink { events.append($0) }
67+
defer { scopedObserver.cancel() }
68+
69+
perform()
70+
71+
return events
72+
}
73+
}
74+
75+
final class PublishingObserver: WorkflowUIObserver {
76+
let subject: PassthroughSubject<WorkflowUIEvent, Never>
77+
private(set) lazy var publisher = { subject.eraseToAnyPublisher() }()
78+
79+
init() {
80+
self.subject = .init()
81+
}
82+
83+
func observeEvent<E: WorkflowUIEvent>(_ event: E) {
84+
subject.send(event)
85+
}
86+
}
87+
88+
// MARK: Event Introspection Utilities
89+
90+
typealias EventDescriptor = String
91+
extension EventDescriptor {
92+
static var viewWillAppear: EventDescriptor = "\(ViewWillAppearEvent.self)"
93+
94+
static var viewDidAppear: EventDescriptor = "\(ViewDidAppearEvent.self)"
95+
96+
static var viewWillLayoutSubviews: EventDescriptor = "\(ViewWillLayoutSubviewsEvent.self)"
97+
98+
static var viewDidLayoutSubviews: EventDescriptor = "\(ViewDidLayoutSubviewsEvent.self)"
99+
}
100+
101+
extension WorkflowUIEvent {
102+
var descriptor: EventDescriptor {
103+
"\(type(of: self))"
104+
}
105+
}
106+
#endif

0 commit comments

Comments
 (0)