00:06 In our workshops, there are a few specific links we always want to
share with our students, and today we'd like to build a tiny browser that lets
us bookmark and maybe even crawl these sites, as well as enable both a full-text
search and a visual way of searching through them. We want to take a snapshot of
a webpage and store it so that we can very quickly navigate to the link when
people ask a question about the page's topic.
00:44 At least, that's the long-term goal. As a first step, we'll further
explore Swift's structured concurrency, actors, and sendable types. We've
already used async/await a lot, and we've also used workers and child tasks, but
we're still relatively new to the concept of global actors and their execution
contexts. And when we switch to the Swift 6 language mode, the compiler can give
more errors when we try to move between those contexts, but the compiler cannot
guarantee that it's safe to do so.
02:25 Let's begin by setting up a basic skeleton for a Mac app so that
we have something to play with, and then we'll look into the concurrency
concepts.
Mac App Skeleton
02:32 At the root of our app, we create a navigation split view, with a
list of URLs on the left, and a detail view on the right:
struct ContentView: View {
var body: some View {
NavigationSplitView(sidebar: {
List {
}
}, detail: {
Text("Detail view")
})
}
}
02:56 We model a webpage as a Page
struct, which we conform to
Identifiable
by giving it an identifier:
struct Page: Identifiable, Hashable {
var id = UUID()
var url: URL
}
03:21 We also write a model class to store the pages. To mark this store
as @Observable
, we have to import the Observation framework:
import Observation
@Observable
class Store {
var pages: [Page] = []
}
03:47 We add a store instance to our navigation view, and we loop over
its pages in the sidebar:
struct ContentView: View {
@State var store = Store()
var body: some View {
NavigationSplitView(sidebar: {
List {
ForEach(store.pages) { page in
Text(page.url.absoluteString)
}
}
}, detail: {
Text("Detail view")
})
}
}
04:26 To add a page to the store, we'll need a text field to input a URL
string. Just so we don't have to keep typing in a URL, we use our website's URL
as a default value of the state property. That way, we only have to press enter
to add the page while we build our app. We add the text field to the toolbar,
and in an onSubmit
action, we send the entered string to the store, and we
clear the text field:
struct ContentView: View {
@State var store = Store()
@State var currentURLString: String = "https://www.objc.io"
var body: some View {
NavigationSplitView(sidebar: {
List {
ForEach(store.pages) { page in
Text(page.url.absoluteString)
}
}
}, detail: {
Text("Detail view")
})
.toolbar {
ToolbarItem(placement: .principal) {
TextField("URL", text: $currentURLString)
.onSubmit {
if let u = URL(string: currentURLString) {
store.submit(u)
currentURLString = ""
}
}
}
}
}
}
06:06 In the store's submit
method, we wrap the received URL
in a
Page
, which we then append to the pages
array:
@Observable
class Store {
var pages: [Page] = []
func submit(_ url: URL) {
pages.append(Page(url: url))
}
}
07:01 We can now add pages to the store, but we also have to be able to
select a page in the sidebar. We add a state property to store a selected page's
ID, and we pass a binding to that property to the List
view's selection
parameter. Because we loop over Page
values, and because Page
is an
Identifiable
type, it's inferred that we want to store a page's ID when we tap
on its view in the list:
struct ContentView: View {
@State var store = Store()
@State var currentURLString: String = "https://www.objc.io"
@State var selectedPage: Page.ID?
var body: some View {
NavigationSplitView(sidebar: {
List(selection: $selectedPage) {
ForEach(store.pages) { page in
Text(page.url.absoluteString)
}
}
}, detail: {
Text("Detail view")
})
.toolbar {
}
}
}
07:57 For the detail view, we return a text view with the selected page
ID if there is one. Otherwise, we display a ContentUnavailableView
:
struct ContentView: View {
@State var store = Store()
@State var currentURLString: String = "https://www.objc.io"
@State var selectedPage: Page.ID?
var body: some View {
NavigationSplitView(sidebar: {
List(selection: $selectedPage) {
ForEach(store.pages) { page in
Text(page.url.absoluteString)
}
}
}, detail: {
if let s = selectedPage {
Text(s.uuidString)
} else {
ContentUnavailableView("No page selected", systemImage: "globe")
}
})
.toolbar {
}
}
}
08:51 We can now submit a URL to add a page to the sidebar, and we can
select the page to see its ID in the detail view:
Main Actor
08:59 That'll do for the app's skeleton. Let's move our attention to
structured concurrency and actors, specifically what happens when we submit a
URL to our store. Right now, everything seems to work fine: when we submit the
text field, the closure defined in the onSubmit
modifier is executed, and the
page is added to the store.
09:29 But what happens when we enable the Swift 6 language mode? We open
the build settings, we look for the Swift language version setting, and we set
it to Swift 6. We don't get any new compiler errors, which means we aren't doing
anything bad so far.
10:01 Let's look at each of our components. The store is @Observable
,
but that has nothing to do with its execution context; in other words, Store
is non-isolated to a particular actor.
10:31 If we look into the View
protocol, we'll see that it's
attributed with @MainActor
. That means the properties and methods of our
navigation view, like all views, are isolated to the main actor. Therefore, the
closure in the onSubmit
modifier is also executed by the main actor, because
it inherits that context from the view in which it's defined.
11:39 This works because the Store
class is non-isolated, and its
submit
method can be executed by the main actor. This basically works the same
way as if we'd add @MainActor
to our model object. That wouldn't change
anything, because we're already running on the main actor in the onSubmit
closure.
12:39 But it would make our code safer, because now we're not allowed to
mutate the store from other actors. We then make it explicit that the model code
has to run on the main actor, which means we can't pass the object around to
other execution contexts, like a background queue.
13:04 However, the compiler would already stop us from passing the model
object around even without it being isolated to the main actor, because it's a
class and, unlike with structs, the compiler won't synthesize a Sendable
conformance for it.
13:33 We can see this when we try to dispatch to a global queue and
print the model object to the console from there:
struct ContentView: View {
@State var store = Store()
@State var currentURLString: String = "https://www.objc.io"
@State var selectedPage: Page.ID?
var body: some View {
NavigationSplitView(sidebar: {
})
.toolbar {
ToolbarItem(placement: .principal) {
TextField("URL", text: $currentURLString)
.onSubmit {
if let u = URL(string: currentURLString) {
store.submit(u)
currentURLString = ""
}
DispatchQueue.global().async {
print(store)
}
}
}
}
}
}
13:58 We get a compiler error: "Main actor-isolated property 'store' can
not be referenced from a Sendable closure." In other words, the closure we
dispatch to the global queue is marked as Sendable
because it's sent to a
different execution context, and we're not allowed to reference the store
property of our main actor-isolated view inside a Sendable
closure.
14:26 Removing the @MainActor
attribute from Store
doesn't make a
difference, because as the error states, it's not the model object that's being
sent to a different execution context, but the reference to the view's property.
14:43 Let's see if we can create a global, non-isolated variable and
pass that around:
let model = Store()
struct ContentView: View {
@State var store = model
}
15:00 Now the compiler complains that it isn't safe to do this, because
the Store
class is non-Sendable
and it may have shared mutable state. It
looks like it's not so easy to break the concurrency checks of the Swift 6
compiler.
15:32 We can also try to make a copy of the store
and send that to the
global background queue instead:
struct ContentView: View {
@State var store = Store()
var body: some View {
NavigationSplitView(sidebar: {
})
.toolbar {
ToolbarItem(placement: .principal) {
TextField("URL", text: $currentURLString)
.onSubmit {
let copy = store
DispatchQueue.global().async {
print(copy)
}
}
}
}
}
}
15:48 This gives a different error: "Capture of 'copy' with non-sendable
type 'Store' in a @Sendable
closure." Before, when we accessed the view's
store
, the compiler said we couldn't do so because the property is isolated to
the main actor. Now that we reference the model object with a different variable
— one that's decoupled from the main actor-isolated property — the compiler
complains about the Store
type not conforming to Sendable
.
16:26 This can be fixed by isolating the Store
class to the main
actor:
@Observable
@MainActor
class Store {
var pages: [Page] = []
nonisolated init() {
}
func submit(_ url: URL) {
pages.append(Page(url: url))
}
}
16:31 Now we can send the model object to the background queue, and we
can print it, but we get another error if we try to call submit
on it:
struct ContentView: View {
@State var store = Store()
var body: some View {
NavigationSplitView(sidebar: {
})
.toolbar {
ToolbarItem(placement: .principal) {
TextField("URL", text: $currentURLString)
.onSubmit {
let copy = store
DispatchQueue.global().async {
print(copy)
copy.submit(.init(string: "")!)
}
}
}
}
}
}
17:02 We're first dispatching to a background queue, and then we're
calling into the main actor-isolated Store
class. That means we're crossing
over to a different execution context, so we'd have to call the method with
await
, unless we can prove to the compiler that we're on the main actor — for
example, by dispatching back to the main queue:
struct ContentView: View {
@State var store = Store()
var body: some View {
NavigationSplitView(sidebar: {
})
.toolbar {
ToolbarItem(placement: .principal) {
TextField("URL", text: $currentURLString)
.onSubmit {
let copy = store
DispatchQueue.global().async {
print(copy)
DispatchQueue.main.async {
copy.submit(.init(string: "")!)
}
}
}
}
}
}
}
Global Actor
17:42 The main actor is a global actor, but we can also create our own
global actor. Let's see what happens when we isolate our store to a global
actor. We first define an actor, and we give it the attribute @globalActor
.
This requires us to define a shared instance on the actor:
@globalActor
actor MyGlobalActor {
static let shared = MyGlobalActor()
}
18:51 Instead of marking our store with @MainActor
, we can now tie
the class to our own global actor with @MyGlobalActor
:
@Observable
@MyGlobalActor
class Store {
var pages: [Page] = []
func submit(_ url: URL) {
pages.append(Page(url: url))
}
}
19:02 A few new errors pop up. Where we initialize the store
state
property, it says "MyGlobalActor-isolated default value in a main actor-isolated
context." And where we access the store — in the ForEach
and in the onSubmit
closure — the compiler doesn't let us do so because these accesses can no longer
be done synchronously; they'd have to be called with await
.
19:41 To use await
, we have to be in an asynchronous context — for
example, by creating a Task
:
struct ContentView: View {
@State var store = Store()
var body: some View {
NavigationSplitView(sidebar: {
})
.toolbar {
ToolbarItem(placement: .principal) {
TextField("URL", text: $currentURLString)
.onSubmit {
if let u = URL(string: currentURLString) {
currentURLString = ""
Task {
await store.submit(u)
}
}
}
}
}
}
}
20:11 The error around the initialization of the state property can be
fixed by adding a nonisolated
initializer to Store
:
@Observable
@MyGlobalActor
class Store {
var pages: [Page] = []
nonisolated init() {
}
func submit(_ url: URL) {
pages.append(Page(url: url))
}
}
20:36 It's more difficult to make accessing the store's pages
in the
ForEach
view work, because the view requires the access to be synchronous,
while the Store
type is isolated to a different actor. Here, we can't make a
context switch like we do in the onSubmit
closure's task.
Next
21:29 This is complicated stuff, but we hope it'll make more sense as
we get more used to working with structured concurrency. Our next step in this
project would be to take a snapshot of a webpage. We'll probably need to wrap a
WKWebView
for that, and we'll hit the problem that NSImage
is non-sendable.