Skip to content

Commit 0898405

Browse files
authored
Display dependency graph in DOT language (#31)
* Add Snapshot/dotRepresentation() * Refactoring * Optimize the timing to emit Snapshots * Use source location for subscriber name * Improve logic * Revert the timing to call notifyUpdateToObservers for release * Add API doc * Fix test compilation * Change AtomCache custom description format * Add unit tests for Snapshot/dotRepresentation() * Renmae dotRepresentation to graphDescription * Gardening * Update README
1 parent 4fb1b34 commit 0898405

20 files changed

+293
-84
lines changed

Examples/Packages/iOS/Sources/iOSApp/iOSApp.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public struct iOSApp: App {
4646
.navigationViewStyle(.stack)
4747
}
4848
.observe { snapshot in
49-
print(snapshot)
49+
print(snapshot.graphDescription())
5050
}
5151
}
5252
}

README.md

Lines changed: 57 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@
2727
- [KeepAlive](#keepalive)
2828
- [Suspense](#suspense)
2929
- [Testing](#testing)
30+
- [Debugging](#debugging)
3031
- [Preview](#preview)
31-
- [Observability](#observability)
3232
- [Advanced Usage](#advanced-usage)
3333
- [Dealing with Known SwiftUI Bugs](#dealing-with-known-swiftui-bugs)
3434
- [Contributing](#contributing)
@@ -1212,57 +1212,74 @@ class FetchBookTests: XCTestCase {
12121212

12131213
---
12141214

1215-
### Preview
1215+
### Debugging
12161216

1217-
Even in SwiftUI previews, the view must have an `AtomRoot` somewhere in the ancestor. However, since This library offers the new solution for dependency injection, you don't need to do painful DI each time you create previews anymore. You can to override the atoms that you really want to inject substitutions.
1217+
This library defines a Directed Acyclic Graph (DAG) internally to centrally manage atom states, making it easy to analyze its dependencies and where they are (or are not) being used.
1218+
There are the following two ways to get a [Snapshot](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/snapshot) of the dependency graph at a given point in time.
1219+
1220+
The first is to get `Snapshot` through [@ViewContext](#atomviewcontext). This API is suitable for obtaining and analyzing debugging information on demand.
12181221

12191222
```swift
1220-
struct NewsList_Preview: PreviewProvider {
1221-
static var previews: some View {
1222-
AtomRoot {
1223-
NewsList()
1224-
}
1225-
.override(APIClientAtom()) { _ in
1226-
StubAPIClient()
1227-
}
1223+
@ViewContext
1224+
var context
1225+
1226+
var debugButton: some View {
1227+
Button("Dump dependency graph") {
1228+
let snapshot = context.snapshot()
1229+
print(snapshot.graphDescription())
12281230
}
12291231
}
12301232
```
12311233

1232-
---
1234+
Or, you can observe all updates of atoms and always continue to receive `Snapshots` at that point in time through `observe(_:)` modifier of [AtomRoot](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/atomroot) or [AtomRelay](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/atomrelay).
1235+
Note that observing in `AtomRoot` will receive all atom updates that happened in the whole app, but observing in `AtomRelay` will only receive atoms used in the descendant views.
12331236

1234-
### Observability
1237+
```swift
1238+
AtomRoot {
1239+
HomeScreen()
1240+
}
1241+
.observe { snapshot in
1242+
print(snapshot.graphDescription())
1243+
}
1244+
```
12351245

1236-
For debugging, you can observe updates with a snapshot that captures a specific set of values of atoms through the `observe(_:)` function in [AtomRoot](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/atomroot) or [AtomRelay](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/atomrelay).
1237-
Observing in `AtomRoot` will receive all atom updates that happened in the whole app, but observing in `AtomRelay` will only receive atoms used in the descendant views.
1246+
Calling the [restore()](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/snapshot/restore()) method of the obtained `Snapshot` will roll back to the states and dependency graph at that point in time to see what happened.
1247+
The debugging technique is called [time travel debugging](https://en.wikipedia.org/wiki/Time_travel_debugging), and the example application [here](Examples/Packages/iOS/Sources/ExampleTimeTravel) demonstrates how it works.
12381248

1239-
The [Snapshot](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/snapshot) passed to `observe(:_)` has a [restore()](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/snapshot/restore()) function that can be executed to restore a specific set of atom values. `Snapshot` can also be obtained on-demand through [AtomViewContext](#atomviewcontext).
1240-
This observability API can be applied to do [time travel debugging](https://en.wikipedia.org/wiki/Time_travel_debugging) and is demonstrated in one of the [examples](Examples).
1249+
In addition, [graphDescription()](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/snapshot/graphdescription()) method returns a string, that represents the dependencies graph and where they are used, as a String in [graph description language DOT](https://graphviz.org/doc/info/lang.html).
1250+
This can be converted to an image using [Graphviz](https://graphviz.org), a graph visualization tool, to visually analyze information about the state of the application, as shown below.
12411251

1242-
```swift
1243-
@main
1244-
struct ExampleApp: App {
1245-
var body: some Scene {
1246-
WindowGroup {
1247-
AtomRoot {
1248-
VStack {
1249-
NavigationLink("Home") {
1250-
Home()
1251-
}
1252+
<img src="assets/dependency_graph.png" alt="Dependency Graph" width="50%" align="right">
12521253

1253-
NavigationLink("Setting") {
1254-
AtomRelay {
1255-
Setting()
1256-
}
1257-
.observe { snapshot in // Observes setting related atoms only.
1258-
print(snapshot)
1259-
}
1260-
}
1261-
}
1262-
}
1263-
.observe { snapshot in // Observes all atoms used in the app.
1264-
print(snapshot)
1265-
}
1254+
```dot
1255+
digraph {
1256+
node [shape=box]
1257+
"FilterAtom"
1258+
"FilterAtom" -> "TodoApp/FilterPicker.swift" [label="line:3"]
1259+
"FilterAtom" -> "FilteredTodosAtom"
1260+
"TodosAtom"
1261+
"TodosAtom" -> "FilteredTodosAtom"
1262+
"FilteredTodosAtom"
1263+
"FilteredTodosAtom" -> "TodoApp/TodoList.swift" [label="line:5"]
1264+
"TodoApp/TodoList.swift" [style=filled]
1265+
"TodoApp/FilterPicker.swift" [style=filled]
1266+
}
1267+
```
1268+
1269+
---
1270+
1271+
### Preview
1272+
1273+
Even in SwiftUI previews, the view must have an `AtomRoot` somewhere in the ancestor. However, since This library offers the new solution for dependency injection, you don't need to do painful DI each time you create previews anymore. You can to override the atoms that you really want to inject substitutions.
1274+
1275+
```swift
1276+
struct NewsList_Preview: PreviewProvider {
1277+
static var previews: some View {
1278+
AtomRoot {
1279+
NewsList()
1280+
}
1281+
.override(APIClientAtom()) { _ in
1282+
StubAPIClient()
12661283
}
12671284
}
12681285
}

Sources/Atoms/Context/AtomTestContext.swift

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ public struct AtomTestContext: AtomWatchableContext {
1212
private let state: State
1313

1414
/// Creates a new test context instance with fresh internal state.
15-
public init() {
16-
state = State()
15+
public init(fileID: String = #fileID, line: UInt = #line) {
16+
let location = SourceLocation(fileID: fileID, line: line)
17+
state = State(location: location)
1718
}
1819

1920
/// A callback to perform when any of atoms watched by this context is updated.
@@ -264,16 +265,21 @@ private extension AtomTestContext {
264265
private let _store = Store()
265266
private let _container = SubscriptionContainer()
266267

267-
var overrides = Overrides()
268+
let location: SourceLocation
268269
let notifier = PassthroughSubject<Void, Never>()
270+
var overrides = Overrides()
269271
var onUpdate: (() -> Void)?
270272

273+
init(location: SourceLocation) {
274+
self.location = location
275+
}
276+
271277
var store: StoreContext {
272278
StoreContext(_store, overrides: overrides)
273279
}
274280

275281
var container: SubscriptionContainer.Wrapper {
276-
_container.wrapper
282+
_container.wrapper(location: location)
277283
}
278284

279285
func notifyUpdate() {

Sources/Atoms/Core/AtomCache.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ internal struct AtomCache<Node: Atom>: AtomCacheBase, CustomStringConvertible {
1414
}
1515

1616
var description: String {
17-
String(describing: Node.self) + "(\(value.map { "\($0)" } ?? "nil"))"
17+
value.map { "\($0)" } ?? "nil"
1818
}
1919

2020
func reset(with store: StoreContext) {

Sources/Atoms/Core/AtomKey.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@ internal struct AtomKey: Hashable, CustomStringConvertible {
99
}
1010

1111
var description: String {
12-
"\(typeKey.description)(\(identifier.hashValue))"
12+
typeKey.description
1313
}
1414
}

Sources/Atoms/Core/AtomTypeKey.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@ internal struct AtomTypeKey: Hashable, CustomStringConvertible {
77
getName = { String(describing: Node.self) }
88
}
99

10-
static func == (lhs: Self, rhs: Self) -> Bool {
11-
lhs.identifier == rhs.identifier
12-
}
13-
1410
var description: String {
1511
getName()
1612
}
1713

1814
func hash(into hasher: inout Hasher) {
1915
hasher.combine(identifier)
2016
}
17+
18+
static func == (lhs: Self, rhs: Self) -> Bool {
19+
lhs.identifier == rhs.identifier
20+
}
2121
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
internal struct SourceLocation: Equatable {
2+
let fileID: String
3+
let line: UInt
4+
}

Sources/Atoms/Core/StoreContext.swift

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,13 @@ internal struct StoreContext {
2525
let key = AtomKey(atom)
2626
defer { checkRelease(for: key) }
2727

28-
return getValue(of: atom, for: key)
28+
let (isNew, value) = getValue(of: atom, for: key)
29+
30+
if isNew {
31+
notifyUpdateToObservers()
32+
}
33+
34+
return value
2935
}
3036

3137
@usableFromInline
@@ -47,9 +53,15 @@ internal struct StoreContext {
4753

4854
// Add an `Edge` from the upstream to downstream.
4955
store.graph.dependencies[transaction.key, default: []].insert(key)
50-
store.graph.children[key, default: []].insert(transaction.key)
5156

52-
return getValue(of: atom, for: key)
57+
let isInserted = store.graph.children[key, default: []].insert(transaction.key).inserted
58+
let (isNew, value) = getValue(of: atom, for: key)
59+
60+
if isInserted || isNew {
61+
notifyUpdateToObservers()
62+
}
63+
64+
return value
5365
}
5466

5567
@usableFromInline
@@ -72,9 +84,15 @@ internal struct StoreContext {
7284

7385
// Register the subscription to both the store and the container.
7486
container.subscriptions[key] = subscription
75-
store.state.subscriptions[key, default: [:]].updateValue(subscription, forKey: container.key)
7687

77-
return getValue(of: atom, for: key)
88+
let isInserted = store.state.subscriptions[key, default: [:]].updateValue(subscription, forKey: container.key) == nil
89+
let (isNew, value) = getValue(of: atom, for: key)
90+
91+
if isInserted || isNew {
92+
notifyUpdateToObservers()
93+
}
94+
95+
return value
7896
}
7997

8098
@usableFromInline
@@ -111,8 +129,9 @@ internal struct StoreContext {
111129
let store = getStore()
112130
let graph = store.graph
113131
let caches = store.state.caches
132+
let subscriptions = store.state.subscriptions
114133

115-
return Snapshot(graph: graph, caches: caches) {
134+
return Snapshot(graph: graph, caches: caches, subscriptions: subscriptions) {
116135
let store = getStore()
117136
let keys = ContiguousArray(caches.keys)
118137
var obsoletedDependencies = [AtomKey: Set<AtomKey>]()
@@ -203,23 +222,20 @@ private extension StoreContext {
203222
return value
204223
}
205224

206-
func getValue<Node: Atom>(of atom: Node, for key: AtomKey) -> Node.Loader.Value {
225+
func getValue<Node: Atom>(of atom: Node, for key: AtomKey) -> (isNew: Bool, value: Node.Loader.Value) {
207226
let store = getStore()
208227
var cache = getCache(of: atom, for: key)
209228

210229
if let value = cache.value {
211-
return value
230+
return (isNew: false, value: value)
212231
}
213232
else {
214233
let value = getNewValue(of: atom, for: key)
215234

216235
cache.value = value
217236
store.state.caches[key] = cache
218237

219-
// Notify new value.
220-
notifyUpdateToObservers()
221-
222-
return value
238+
return (isNew: true, value: value)
223239
}
224240
}
225241

@@ -393,11 +409,11 @@ private extension StoreContext {
393409
store.state.states.removeValue(forKey: key)
394410
store.state.subscriptions.removeValue(forKey: key)
395411

396-
// Notify release.
397-
notifyUpdateToObservers()
398-
399412
// Check if the dependencies are releasable.
400413
checkReleaseDependencies(dependencies, for: key)
414+
415+
// Notify release.
416+
notifyUpdateToObservers()
401417
}
402418

403419
func checkRelease(for key: AtomKey) {

Sources/Atoms/Core/SubscriptionContainer.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,17 @@
33
internal final class SubscriptionContainer {
44
private var subscriptions = [AtomKey: Subscription]()
55

6-
var wrapper: Wrapper {
7-
Wrapper(self)
8-
}
9-
106
nonisolated init() {}
117

128
deinit {
139
for subscription in ContiguousArray(subscriptions.values) {
1410
subscription.unsubscribe()
1511
}
1612
}
13+
14+
func wrapper(location: SourceLocation) -> Wrapper {
15+
Wrapper(self, location: location)
16+
}
1717
}
1818

1919
internal extension SubscriptionContainer {
@@ -29,9 +29,9 @@ internal extension SubscriptionContainer {
2929
nonmutating set { container?.subscriptions = newValue }
3030
}
3131

32-
init(_ container: SubscriptionContainer) {
32+
init(_ container: SubscriptionContainer, location: SourceLocation) {
3333
self.container = container
34-
self.key = SubscriptionKey(container)
34+
self.key = SubscriptionKey(container, location: location)
3535
}
3636
}
3737
}
Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
11
internal struct SubscriptionKey: Hashable {
22
private let identifier: ObjectIdentifier
33

4-
init(_ container: SubscriptionContainer) {
5-
identifier = ObjectIdentifier(container)
4+
let location: SourceLocation
5+
6+
init(_ container: SubscriptionContainer, location: SourceLocation) {
7+
self.identifier = ObjectIdentifier(container)
8+
self.location = location
9+
}
10+
11+
// Ignores `location` because it is a debugging metadata.
12+
func hash(into hasher: inout Hasher) {
13+
hasher.combine(identifier)
14+
}
15+
16+
static func == (lhs: Self, rhs: Self) -> Bool {
17+
lhs.identifier == rhs.identifier
618
}
719
}

0 commit comments

Comments
 (0)