스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
Swift concurrency: Update a sample app
Discover Swift concurrency in action: Follow along as we update an existing sample app. Get real-world experience with async/await, actors, and continuations. We'll also explore techniques for migrating existing code to Swift concurrency over time. To get the most out of this code-along, we recommend first watching “Meet async/await in Swift” and “Protect mutable state with Swift actors” from WWDC21. Note: To create an async task in Xcode 13 beta 3 and later, use the Task initializer instead.
리소스
관련 비디오
WWDC22
WWDC21
- Explore structured concurrency in Swift
- Meet async/await in Swift
- Protect mutable state with Swift actors
- Swift concurrency: Behind the scenes
- SwiftUI의 새로운 기능
WWDC20
-
다운로드
♪ Bass music playing ♪ ♪ Ben Cohen: Hi, I'm Ben from the Swift team, and in this video, I'm going to walk through porting an existing application over to use Swift's new concurrency features. We'll see how these new features help you write clearer asynchronous code, and protect against possible race conditions, as well as look at some techniques for gradually moving your code to this new way of operating.
I'm going to be using an app called Coffee Tracker, and it's based on a talk from WWDC 2020 about creating and updating watch complications. This is a simple app that lets you track all the coffee you've drunk today, as well as a complication to show your current caffeine level on a watch face. It's a nice example to use for our purposes because although it's a small app, it shows lots of different things we want to think about, including how concurrency works with SwiftUI, delegate callbacks from the watch SDK, some I/O, and interfacing with asynchronous APIs in Apple's SDK. Now let's take a quick tour of the app. It's roughly broken up into three layers. First, there's the UI layer. This is mostly SwiftUI views, but in this we can also consider things like the complication data source as part of the UI layer. Next there's the model layer that comprises a couple of simple value types to represent caffeine drinks, as well as a model type called "Coffee model." Now, this is what you might call the UI Model. That is, the place where you hold data for display by the UI layer. It's an observable object that feeds our SwiftUI view, and all of the updates on it will need to be made on the main thread. I'm referring to it as a UI model because it might not be the full model of all of your application's data. It might just be a projection of your data model or a subset of it; just what you need to display on the UI at this moment. Finally, we have what you can think of as the back-end layer: processing that happens -- probably in the background -- to populate your model or talk to the world outside your app. In our case, that's represented by this HealthKitController type that manages communication to HealthKit to save and load the user's caffeine intake. Now, before we start looking at code, let's talk about how concurrency is managed in the app. This architecture looks fairly clean, but when we layer on how concurrency is handled, we get a much messier picture. This app is essentially broken up into three concurrent queues on which code can be executing. Work in the UI and on the model is done in the main queue. The app also has a dispatch queue for doing work in the background. And finally, certain callbacks into completion handlers -- like those returning results from HealthKit -- are done on arbitrary queues. This is a pretty common situation. What seems like a simple application architecture ignores a lot of the hidden complexity in how it handles concurrency. Now, for a quick spoiler. In adopting Swift concurrency, we're going to go from this ad hoc concurrency architecture to something that will look like this. We're going to put our UI views and model on what's called the main actor. We're going to create new actors to operate in the background, and these types will pass thread-safe values between each other using the async/await feature. When we're done, the concurrency architecture should be as clear and easy to describe as the type architecture. Now we've used a few terms here you might not be familiar with, like "async/await" and "actors," and we'll explain them briefly as we use them in code. But for a more in-depth explanation of these features, there are several other talks available that go into much more detail. So now we've seen the overall architecture, let's dive into the code.
Here we have the different files. First, of some Swift UI views. And then we have our watch complication controller's extension delegate. We have the simple model type that represents a caffeine drink and a CoffeeData UI model that holds an array of these drinks. And finally, we have the HealthKitController. And it's in this layer I'm going to get started introducing some of Swift's new concurrency features into our app. The HealthKitController contains a few different calls into the HealthKit SDK that take completion handlers. Let's start by looking at this controller's save operation. So I just hit Control-6, and that brings up the list of functions in this file, and we're going to go to the save operation. Now, before we even get into the new concurrency features, let's talk a little bit about thread safety in Swift today. This code accesses some variables, isAvailable and store. Now, it looks like we're only reading these variables in this function. Is that safe? Well no, not if other code could be writing to them at the same time. To know whether or not this code is thread-safe, I need more information than just looking at this function. There's no use of dispatch queues or locks in it, so whatever makes this code thread-safe -- assuming it is -- must be elsewhere. Maybe the caller serializes all calls to save through a queue. Or maybe the app is constructed in some way that means it's OK. But I can't know this just by looking at this function. Being able to do that -- to look at this function and know certain things without having to go look at other parts of my program -- is what we call local reasoning, and it's a really important goal for Swift. For example, Swift's emphasis on value types is about local reasoning. Unlike a reference type, you don't have to worry that a value type you've been passed is being mutated elsewhere in your program. A lot of the language features being introduced for concurrency in Swift 5.5 are about giving you more opportunity to reason locally about your code. Now, as it happens, this function is entirely thread-safe, but I figured that out myself; the compiler isn't helping me spot these issues. So down here, we have a call to the HealthKit SDK to save a caffeineSample into the user's health data. And this takes a completion handler, and that completion handler takes two values: success or error. If the operation succeeds, there's no error; the error will be nil. This means we need to remember to check the status and then, when appropriate, unwrap the optional error down here. Now, this isn't normally how we handle errors in Swift. This would be much better as a method that can throw on failure. But that approach doesn't work with completion handlers. But now, with async methods, we can have asynchronous functions that can throw.
This HealthKit save method now has an async equivalent that does exactly that, so let's switch over to using it. To do that, first we remove the completion handler, and we write "await" in front of the method call.
This reminds us that it's an async function call, and the code will suspend at this point and allow other code to run. We'll come back to why this is important a little bit later. So if we compile, now that we've done this, we see that the compiler is telling us that we need a "try." And this is a big benefit of async functions; they can throw. There's no more having to remember to check an optional error.
So we can add a "try" in front of this method call, and we want to handle the error right away. So we wrap this call in a do block... and then catch the error. We can remove this guard. And now that we're catching the error, it's no longer optional, so we can remove the unwrap. This also means we can rearrange our code to keep the happy path at the top so we can move the success logging line up right underneath the save and then just handle the error logging in the cache.
Notice that save no longer returns a value. Returning success/failure was really duplicative with the error, so our new function only either throws or it's succeeded.
Now that we've added the try-catch, we're getting one more error from the compiler. We're calling an asynchronous function, but we're calling it from within a synchronous function. This does not work. Asynchronous functions have a capability that synchronous functions don't have: the ability to give up control of the thread they're running on while they await. To do this, they have a separate way of handling their stack frame, and this isn't compatible with synchronous functions. So one option is to make this function async instead. So we can just add the async keyword after the function definition. And now, this file compiles.
But the whole project doesn't compile yet. Making this function async has pushed the problem up a level to where it's being called.
Here in my data model, I'm now getting that same compiler error because this function isn't async. Now I could keep going up the chain, but for now, let's see another technique to keep the changes localized. To call my async function, I'm going to spin off a new asynchronous task which will be allowed to call async functions. This async task is very similar to calling async on the global dispatch queue. You can't return a value back from it to the outer function because the block executes concurrently. So whatever you do with the detached closure needs to be self-contained. In this case, we're just calling save, which doesn't return a value, so that's fine. You also need to be careful that you're not touching global state that might get mutated simultaneously from other threads. And this is where it was relevant that save was completely thread-safe, otherwise we might be accidentally introducing new race conditions by adding this new task.
Now that we've put it inside an async task, our awaited function compiles, and we've finished our first use of async/await in this app, we could run it right now.
Let's do another one of those refactors and this time, look at some other techniques when migrating to async. So let's have a look at the call to request authorization of access to the user's health data. This similarly calls HealthKit with a completion handler. But unlike before, this function takes, itself, a completion handler. What I'm going to do is I'm going to create a second version of the function to become async while keeping the completion handler version. That way other parts of our code that call it with a completion handler can keep on working while we refactor. I can do this easily by using the "Create Async Alternative" refactoring action. That's available in the Code Action menu -- which I can bring up with Command-Shift-A -- and choose the option to add the async alternative. So this has added a second async version of the original call.
And it's replaced the original completion handler code with code that creates a new async task...
...and then just awaits the async version of the function. Notice that the async refactoring has added a deprecation warning to the original one. These are going to help guide me to parts of my code that could next benefit from refactoring to call this new async version. Let's undo and go back for a moment to that original completion handler version. Inside this requestAuthorization callback, this callback can happen on an arbitrary thread. So you need to know that the code inside it is thread-safe. But I don't think it is. This assignment here might happen simultaneously with other code reading this value on other threads. And there's another example of a lack of local reasoning in this code. After that assignment, this completion handler is called, and I have no idea whether the code inside that completion handler is thread-safe. I'd have to go and look at all of the call sites to this function to look at how their completion handlers are written to know that this is OK. Now, let's redo again and see the refactored version. Now remember, an async task also runs on an arbitrary thread similar to a dispatch callback. So this forwarding version has similar problems to the completion handler version we had before. We haven't made our code any safer quite yet. We'll fix this soon by introducing actors into our code. But for now, we should note that just because we've converted this function to async does not mean we're free from race conditions. In fact, you should be aware of the risk of introducing new race conditions into your code if you only perform refactoring to introduce async functions. Now let's look at this new async function. The refactoring action has already converted the call with a completion handler here to call the new async version of this SDK API.
But converting this function to async has highlighted something interesting. Here, when we were using the completion handler technique, we had a return without calling the completion handler. And this was probably a bug. The caller would have been left hanging. But with async functions, you must return a value, so now we're getting a compilation error, which we can resolve just by returning false for failure.
And just like before, this new async version of requestAuthorization doesn't actually return a value, it either succeeds or it throws. So we just need to delete this return value. And instead on this path, we need to assume that it succeeded... ...and on the failure path, we need to return false. If I try and compile now, the project compiles because the old code elsewhere can still continue to call the completion handler version and we're now seeing these deprecation warnings when we're doing that, which can lead us to a next place that we might want to refactor. OK, let's do one more async conversion. Let's find the function to load data from HealthKit. We'll start -- just like before -- by creating a stub for the old code to call. Then moving to the async version, incidentally, this one takes an optional completion handler the async equivalent of this would be to make this function have a discardable result. So next, we start moving down, replacing any use of the completion handler with returns. So for example, we can delete this completion handler and just return false. But once we move a little further, we start to hit a snag, and it has to do with how the HealthKit query API is arranged. Here, there's a completion handler but it's on this query object; whereas really, what I want to await is the execution of the query all the way down here at the bottom of the function. Incidentally, this hopping up and down around the function is another thing that async/await is great at helping resolve. So what I want to do is create a single async function that both creates the query and then executes it. We're going to do that now using a technique called a continuation. So I'm to go back up to the top of this function and I'm going to create a helper function called "queryHealthKit." I could do all of this work inside the existing function but it might get a little bit messy, so I like to keep it separate in a helper function. This function will be async, so we can await it, and it will throw because the query operation can fail. And this function is going to return the useful values here that are currently being passed into the completion handler on the query. So I'm going to take the logic for performing the query, cut it, and move it into the helper function. And I'm also going to take the execution of the query and move that too. Now, I need to somehow invert this code so that it can await the completion handler and return these values passed into the completion handler back from my new asynchronous function. And this is where I use a continuation. So in this function, we're going to return the results of trying to await a call to a withCheckedThrowingContinuation function. And this takes a block that takes a continuation. And we're going to move this code inside that block and then within the block, we're going to use the continuation to pass data back out of this function either by using the continuation to resume throwing the error here... ...or... ...to resume returning the values that we received into the completion handler.
Now that we have this awaitable function, we can use it in our original code.
So we assign the results from calling the function. And we need to handle the error that can be thrown. In fact, actually, I'm going to go up here and take this bit of logging and do that in the handler.
And then we just need to move all of this success code up into the happy path. Finally, we need to address this closure.
Here, we're using dispatch async back to the main thread. But we've ditched our completion handler, so there's no way of relaying this information back to the main thread using it. We need a different way. To resolve this, we're going to make our first use of actors. In Swift's concurrency model, there is a global actor called the main actor that coordinates all of its operations on the main thread. We can replace our dispatch main.async call with a call to main actor's run function. And this takes a block of code to run on the main actor. run is an async function, so we need to await it. Awaiting it is necessary because this function may need to suspend until the main thread is ready to process this operation. But because we await it, we can just remove our completion handler, and instead, return a value. OK, finally, the compiler is now giving me an error about a captured variable.
This is a new error that only occurs inside asynchronous functions. Because closures in Swift capture variables by reference, when you capture a mutable variable -- in this case, our newDrinks array -- you create the possibility for shared mutable state, and that can be the source of race conditions. So when doing this, you need to ensure that you're making a copy of this value. One way to do this is to add newDrinks to the closure's capture list, like this. But often it's better to just avoid this problem by not having mutable variables in the first place. Here, we can do this by changing the code above instead. It's written this way because samples is optional. But instead what we could do is change newDrinks to an immutable value and either set the value here in the if branch or an add an "else" to set it to the empty array. We could also have done this with a nil coalescing operator if we prefer. Since this value is now declared with let instead of var, it's immutable and that resolves the issue without needing to make an additional capture.
Now let's keep talking about the main actor by taking a look at this function that needed to be called on the main thread.
At the top of this function, there's something that's a really great idea: there's an assert that the function is correctly running on the main thread. If you had ever made a mistake and called this function without wrapping it in a dispatch async to the main thread, you'd get an error in your debug builds, and you should definitely adopt this practice in some of your existing code. But this approach does have some limitations. You might forget to put an assert everywhere it's needed, and you can't assert on access to stored properties, or at least not without lots of boilerplate. It's much better if the compiler can enforce some of these rules for you, so you can't make mistakes like this at all. And that's how we use the main actor. I can annotate functions with "@MainActor." And that will require that the caller switch to the main actor before this function is run.
Now that I've done this, I can remove the assertion because the compiler won't let this function be called anywhere other than on the main thread. We can prove that this is working by going back to the caller and moving this call outside of this MainActor.run block. And you see the compiler tells us, no, we can't call that from here because we're not on the main actor. Here's a way to think about this feature: it's a lot like optional values. We used to have values like pointers and had to remember check for nil, but it was easy to forget, and it's much better to let the compiler make sure this check always happens along with some language syntactic sugar to make it easier. Here, we're doing a similar thing, only instead of enforcing nil checks, it's enforcing what actor you're running on.
Now that we've put this function on the main actor, we don't, strictly speaking, need this MainActor.run anymore. If you're outside of an actor, you can always run functions on that actor by awaiting them. And in fact, that's what the compiler is telling us here. It's saying that we need an await keyword in order to make the switch over to the main actor to run this function. So if we add it, then the code will compile even without this call being inside the run block. Here, we're using await on a synchronous function -- updateModel is synchronous -- but the await indicates that the function we're in may need to suspend to get itself onto the main actor. Think of this as similar to making a DispatchQueue.sync call, except with await, your function suspends instead of blocking and then resumes after the call to the main thread is complete.
So, we don't need it here anymore, but this MainActor.run technique is still important for another reason. At each await, your function might suspend and other code might run. That's the point of awaiting: to let other code run instead of blocking. In this case, we only had one function to await, so it doesn't really matter, but sometimes you might want to run multiple calls on the main thread. For example, if you're working on UI updates, such as updating entries in a table view, you might not want the main run loop to turn in between the operations you perform. In that case, you would want to use MainActor.run to group together multiple calls to the main actor to ensure that each ran without any possible suspensions in between.
So, we're now using the main actor to protect the code that needs to run on the main thread. But what about other code in this class? In particular, the code that mutates local variables, like the query anchor that we saw assigned to here.
How can we guarantee those are free from race conditions? Or one way would be to just put everything in the HealthKitController on the main actor. If I go to the HealthKit controller definition, and just write "@MainActor" here on the class instead of on individual methods, that would protect every method on this type, and every stored property on it would be coordinated on the main thread. And for a simple application like this one, that would probably be an OK choice. But that also seems a little bit wrong. This HealthKitController is really the back end of our app; it seems unnecessary to be doing all of its work on the main thread. We want to leave that thread free to do UI-focused activities. So instead, we can change this class to, itself, be an actor. Unlike the main actor, which is a global actor, this actor type can be instantiated multiple times. In my project, I'm still only going to create one of them, but there are many other uses of actors where you might instantiate multiple copies of the same actor. For example, you might make each room in a chat server be its own actor. So now that we've made this class into an actor, let's see what the compiler says. OK. So we're getting some compilation errors. Now let's take a pause here and talk about compiler errors. These errors are guiding you towards the places in your code you need to update when you're migrating code to the new concurrency model. When you get these errors, make sure you understand what they're telling you. Resist the temptation to mash the fix-it button when you're not sure how or why it'll fix the issue. One thing to be wary of is getting into a cascade of errors. Sometimes you'll make a change -- like converting a class to be an actor like we just did, or making a method async -- and it'll generate some compiler errors. So you go to the site of those errors, and it's tempting to make more changes to fix those errors, like making that method async or putting it on the main actor. The trouble is that this can lead to even more errors, and quickly, you can feel overwhelmed. Instead, use techniques like we're using here in this walk-through, and try and keep the change isolated and done one step at a time with your project compiling and running in between. Add shims to allow your old code to keep working, even though you might end up deleting them later. That way, you can gradually move out from a point, tidying up the code as you go. Incidentally, what I did here is first convert the HealthKitController's methods to async and then make it into an actor. And I find it works out easiest if you do it that way around, rather than starting with the actor conversion. OK, so let's look at these errors by jumping down to them. They're down here on the function we put on the main actor. And this makes sense, because in this function, we're touching a stored property of our new HealthKitController actor, the model property. The actor protects its state and won't let functions, not on the actor -- such as this function that we've explicitly put on the main actor -- touch its stored properties. Now looking at this function, it looks like the only state on the actor that it touches is the model object. Everything else is passed in as a function argument. And to me, this suggests that this function belongs on the model; that the model here actually ought to be self. So let's move it to the model. So we can take this function, cut it, go over to our CoffeeData UI model, and paste it in.
It's going to be internal rather than private, so it can be called from the HealthKitController. And we just need to go through and remove all references to the model because that's now self. Finally, we need to go to where it was being called... ...and replace self here with a call to the model. So now, this HealthKitController file compiles, and I get a new set of errors from other files. Let's look at those errors. So here we're calling into those completion handler shims that we called earlier to allow us to keep passing in the completion handler even though we've rewritten this function to the async. These functions are now being protected by the actor so I can't call them directly. But they don't touch any other part of the actor's state if we look at them. All they're doing is spinning off a task and then awaiting a call to the async version of the function. Since they don't touch any part of the actor's internal state, I can mark them as what's called "nonisolated." Do the same down here. Marking something as nonisolated tells the compiler that you're not going to touch any of the isolated state, and therefore, this function can be called from anywhere. The switch over to the actor is actually happening automatically when we await the call into the async version of the function. Note that the compiler will check that this nonisolated claim is true. If I were to try and access some state of the actor -- such as, for example, printing out the authorization status -- the compiler will stop me.
So now, I've completed my work converting the HealthKitController into an actor that protects its internal state from race conditions. Next, let's follow those deprecation breadcrumbs to the next file to work on, which is our CoffeeData model type. Now this class implements ObservableObject, and it has a Published property. Any updates to properties that are published to a SwiftUI View must be done on the main thread, so this class is probably a good candidate to put on the main actor.
But there's also this background DispatchQueue for doing some work in the background. Let's have a look at how that's used.
And it's just in two functions: load and save. That makes sense; you probably don't want to do your loading and saving on the main thread. When you see a pattern like this -- where a queue is being used to coordinate some specific activities, but the rest of the class needs to be on the main thread -- that's a sign that you want to factor that background code out into a separate actor. So let's do that. So let's go to the top of the file and create a new private actor... ...which we'll call "CoffeeDataStore." And let's open CoffeeData in another window. and start moving code across into our new actor. So we can give it its own logger.
And let's tweak the category so we can tell when the actor is in use. And next, instead of this DispatchQueue, we're going to instantiate a copy of our new actor. Next, let's go to the save operation, and move that over. So we can take this function...
...cut it from here, and move it into the actor.
Let's start by compiling and seeing what issues come up. So first, there's this currentDrinks property. This was a property of the model type before we moved this method out of the model into its actor. So how can we access it now? Well, the way actors relay information is they pass values between each other. So we should have this function take currentDrinks as an argument. This argument to save receives the list of currentDrinks to save that's passed in by the model type. So that resolves that.
Next, savedDrinks. This is a copy of the values last saved to avoid saving unnecessarily when nothing has changed. This value is mutated by both the save and the load function, so it definitely needs protecting by the actor. So let's find it in our model... ...and move it over.
OK, what's next? All right, this property dataURL, that's actually being used just by the load and save operation, so we can just move it over to be a private helper on the actor.
OK, last issues to resolve. Now, here we're getting errors, and if we look, it appears that there's a closure that's capturing some state from the actor, so we need to fix that. So why is there a closure here? Well, if you look down, it's because the same piece of code is getting called in two places. And it turns out the compiler has flagged something really interesting for us. What this code is doing is checking if the watch extension is running in the background. And the idea is, if it's running in the background already, then don't go into the background queue; just stay on the main thread and perform the task of saving synchronously. But this doesn't seem right. You should never block the main thread to perform an I/O operation like saving, even when your app is running in the background. Why does the app do this? Well, we can trace it back to where the save operation is being called.
It's being called from a didSet down here on currentDrinks. And that fires so that whenever the property is assigned, it saves the new values. Now, didSets are super convenient, but they can be a little too tempting. Let's look at all of the callers of the currentDrinks property. If we drill in all the way down here...
...we find that the save operation is ultimately synchronous because of the way it's being called from this function which is the call to handle background tasks from the WatchKit extension. Now this handle API has a contract. You're supposed to do all of your work, and then, when all your work is done, call this setTaskCompletedWithSnapshot method. And you must guarantee all your work is done when you call this because your watch app will be suspended. You can't have some I/O operation, like our save operation, still running when you say that you're done. This is a perfect example of how asynchrony forces global reasoning throughout your code. Let's visualize what's happening here. We start in handle(backgroundTasks:) which calls the load from HealthKit function. This takes a completion handler. But then we switch to updateModel(), which is performed synchronously, and so synchronously calls didSet, which synchronously saves. Once this is done, the completion handler is called and that notifies WatchKit that it's all done. It's the synchronous parts that force us to perform synchronous I/O on the main thread. How can we fix this? To fix it with completion handlers, you'd have to update each currently synchronous method to now take a completion handler. But you can't do that with a didSet; it doesn't take arguments, it just fires automatically when you update the property. But the good news is that all our async refactoring is going to pay off now, because updating functions from synchronous to be async is way easier. So first, let's go to the published property, currentDrinks, and let's change it to a private(set) so that we know all mutation is happening only from this file. And then let's take this didSet operation and instead move that logic into a new function... ...called "drinksUpdated()." We'll make it async, because it's going to call our save operation on our actor. And that needs to be an await... ...on the CoffeeData store, into which we're going to pass the new currentDrinks value. Then, we need to go to where currentDrinks is updated and ensure we call drinksUpdated afterwards. Now in this function, there's one thing to note. It's important that this operation -- that takes a copy of currentDrinks, mutates it, and then writes it back -- all happens atomically. And this is why the await keyword is vital; it indicates that at this point, this operation might suspend, and other functions -- functions that might also update currentDrinks -- could run. So we need to make sure that our entire mutation and writeback happens before any awaits, otherwise, other functions coming and mutating currentDrinks could lead to an inconsistent state.
So this function needs to be async. We can go to our save operation and eliminate this unnecessary background and foreground branch and just do the operation on the actor every time.
OK. Finally, let's look at the load operation. Now here, the logic is split between code that needs to run in the background and code that needs to run on the main thread.
So let's first take the first half -- the background portion -- and move that into the actor.
Now doing this, we notice another possible race condition.
savedValues here was being mutated on the main queue, but if you remember the save operation, it was both read and written from the background queue. Now, as it happens, the way the app was constructed, the load only ever happened on startup, so this was fine. But again, that's relying on global reasoning, and it's the kind of assumption that can break in subtle ways when you make future changes. It's much better to let the actor ensure the program will always be correct.
So we're going to fix that now. So first, let's remove this queue management...
...reindent the function, remove this other queue management. And just like with save, we need a way to pass back the loaded values, which we do just by returning a value from this load function on the actor. So now, let's hop back to the original load.
And we've moved this logic, so we can just delete it...
and replace it with a call to await... ...loading the drinks from the actor.
Now, because we're awaiting the actor, that means this function needs to be async. And while we're here, we can clean up these deprecation warnings. And finally, because this load is now async, we need to await it here. And because we're awaiting it here, we need to create a task. But at this point, if we just used an async task, we're potentially introducing a new race condition. Remember, outside of an actor, this new task just runs on an arbitrary thread. We shouldn't mutate shared state, like currentDrinks, from an arbitrary thread.
Now, one way to resolve this would be to put the load function on the main actor, But it's much better to move the entire of model type to the main actor. So we go to our CoffeeModel definition and add "MainActor" to our model type. By putting the model on the main actor, we're now guaranteeing that all access to CoffeeData's properties are going to be made from the main thread. And this is good because, as we noted earlier, it's an observable object and it has a published property. And properties published to SwiftUI must only be updated on the main thread. It also means any calls to async from the actor will also run on the actor. so we can remove any other "MainActor" annotations such as the one we added earlier. So now you might notice that when we compile we don't hit any compilation errors, unlike when we moved other things into an actor earlier. And that's because the places we're calling into our model are things like SwiftUI views.
For example, let's go to DrinkListView. Now, this type displays a list of buttons on the screen. And then it calls addDrink, which is on our model type. But this DrinkListView is itself also on the main actor. And so its methods...
...can call into the CoffeeData model without an await. What is it that determines that this SwiftUI view is on the main actor? Well, it's inferred from its use of EnvironmentObject here. Any SwiftUI view that accesses shared state -- such as environmental objects or an observed object -- will always be on the main actor.
Elsewhere...
...we're also accessing our model from this extension delegate call.
Since this extension delegate is guaranteed to be called on the main thread, it's been annotated by WatchKit as running on the main actor, and so it can also call into our model type directly.
Finally, now that we're here, let's refactor this method and get rid of this deprecated completion handler usage. We can instead wrap this section in a new asynchronous task.
Remember, this handler is running on the main thread, so when we create a task, that task will also be running on the main thread.
And inside this new task, we can now await... ...our call to load new data from HealthKit. And a really nice thing about getting rid of completion handlers is you can now compose functions together. So if you wanted, you could just move this await directly into the if statement.
Once this function call returns, we know that all our work is complete because, inside it, we will awaiting the save operation. And so, we can now confidently call background.Task.setTaskCompleted knowing that we finished all of our I/O operations. We now have this nice, structured, top-down approach to waiting for an asynchronous operation before completing any more work. Incidentally, this structured approach to concurrency is another really important part of Swift's concurrency feature. To learn more, watch the associated talk, which covers how you can take advantage of this feature to structure more complex examples, such as waiting on multiple asynchronous operations to complete before continuing.
If, while you watch this talk, you wondered exactly how some of these new features work, check out our under-the-hood talk that explores some of the technology in detail. So let's recap. We've taken some code that had sound type architecture but complex concurrency architecture that had some hidden race conditions that were really hard to spot. And with the help of the new concurrency features, we've rearchitected it so that the concurrency and type architecture are nicely aligned. And the compiler helped us find some hidden potential race conditions along the way. There's a lot more to Swift 5.5 we haven't covered, like structured concurrency with task groups, async sequences, and some great new asynchronous APIs in the SDK. There's also a few more refactorings we didn't do in this project that you might want to try yourself. The best way to learn these techniques is to try them out in your own apps, so have fun and enjoy these cleaner, safer ways to code. ♪
-
-
0:07:34 - Call the async version of the HKHealthKitStore save(_:) method
do { try await store.save(caffeineSample) self.logger.debug("\(mgCaffeine) mg Drink saved to HealthKit") } catch { self.logger.error("Unable to save \(caffeineSample) to the HealthKit store: \(error.localizedDescription)") }
-
0:09:38 - Change save(drink:) to be an async function
public func save(drink: Drink) async {
-
0:10:15 - Create a new asynchronous task
Task { await self.healthKitController.save(drink: drink) }
-
0:12:13 - Add an async alternative for requestAuthorization(completionHandler:)
@available(*, deprecated, message: "Prefer async alternative instead") public func requestAuthorization(completionHandler: @escaping (Bool) -> Void ) { Task { let result = await requestAuthorization() completionHandler(result) } }
-
0:14:55 - Update the async version of requestAuthorization()
public func requestAuthorization() async -> Bool { guard isAvailable else { return false } do { try await store.requestAuthorization(toShare: types, read: types) self.isAuthorized = true return true } catch let error { self.logger.error("An error occurred while requesting HealthKit Authorization: \(error.localizedDescription)") return false } }
-
0:15:43 - Add an async alternative for loadNewDataFromHealthKit(completionHandler:)
@available(*, deprecated, message: "Prefer async alternative instead") public func loadNewDataFromHealthKit(completionHandler: @escaping (Bool) -> Void = { _ in }) { Task { completionHandler(await self.loadNewDataFromHealthKit()) } }
-
0:17:43 - Create a queryHealthKit() helper function that uses a continuation
private func queryHealthKit() async throws -> ([HKSample]?, [HKDeletedObject]?, HKQueryAnchor?) { return try await withCheckedThrowingContinuation { continuation in // Create a predicate that only returns samples created within the last 24 hours. let endDate = Date() let startDate = endDate.addingTimeInterval(-24.0 * 60.0 * 60.0) let datePredicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate, options: [.strictStartDate, .strictEndDate]) // Create the query. let query = HKAnchoredObjectQuery( type: caffeineType, predicate: datePredicate, anchor: anchor, limit: HKObjectQueryNoLimit) { (_, samples, deletedSamples, newAnchor, error) in // When the query ends, check for errors. if let error = error { continuation.resume(throwing: error) } else { continuation.resume(returning: (samples, deletedSamples, newAnchor)) } } store.execute(query) } }
-
0:20:17 - Update the async version of loadNewDataFromHealthKit()
@discardableResult public func loadNewDataFromHealthKit() async -> Bool { guard isAvailable else { logger.debug("HealthKit is not available on this device.") return false } logger.debug("Loading data from HealthKit") do { let (samples, deletedSamples, newAnchor) = try await queryHealthKit() // Update the anchor. self.anchor = newAnchor // Convert new caffeine samples into Drink instances. let newDrinks: [Drink] if let samples = samples { newDrinks = self.drinksToAdd(from: samples) } else { newDrinks = [] } // Create a set of UUIDs for any samples deleted from HealthKit. let deletedDrinks = self.drinksToDelete(from: deletedSamples ?? []) // Update the data on the main queue. await MainActor.run { // Update the model. self.updateModel(newDrinks: newDrinks, deletedDrinks: deletedDrinks) } return true } catch { self.logger.error("An error occurred while querying for samples: \(error.localizedDescription)") return false } }
-
0:25:09 - Annotate updateModel(newDrinks:deletedDrinks:) with @MainActor
@MainActor private func updateModel(newDrinks: [Drink], deletedDrinks: Set<UUID>) {
-
0:26:43 - Remove MainActor.run from the call site of updateModel(newDrinks:deletedDrinks:)
await self.updateModel(newDrinks: newDrinks, deletedDrinks: deletedDrinks)
-
0:29:24 - Change HealthKitController to be an actor
actor HealthKitController {
-
0:32:31 - Move updateModel(newDrinks:deletedDrinks:) to CoffeeData
@MainActor public func updateModel(newDrinks: [Drink], deletedDrinks: Set<UUID>) { guard !newDrinks.isEmpty && !deletedDrinks.isEmpty else { logger.debug("No drinks to add or delete from HealthKit.") return } // Remove the deleted drinks. var drinks = currentDrinks.filter { deletedDrinks.contains($0.uuid) } // Add the new drinks. drinks += newDrinks // Sort the array by date. drinks.sort { $0.date < $1.date } currentDrinks = drinks }
-
0:33:18 - Update the call site of updateModel(newDrinks:deletedDrinks:)
await model?.updateModel(newDrinks: newDrinks, deletedDrinks: deletedDrinks)
-
0:34:01 - Mark the deprecated completion handler methods as nonisolated
@available(*, deprecated, message: "Prefer async alternative instead") nonisolated public func requestAuthorization(completionHandler: @escaping (Bool) -> Void ) { // ... } @available(*, deprecated, message: "Prefer async alternative instead") nonisolated public func loadNewDataFromHealthKit(completionHandler: @escaping (Bool) -> Void = { _ in }) { // ... }
-
0:36:20 - Create a private CoffeeDataStore actor for loading and saving
private actor CoffeeDataStore { }
-
0:36:43 - Add a dedicated logger for CoffeeDataStore
let logger = Logger(subsystem: "com.example.apple-samplecode.Coffee-Tracker.watchkitapp.watchkitextension.CoffeeDataStore", category: "ModelIO")
-
0:37:05 - Add an instance of the actor to CoffeeData
private let store = CoffeeDataStore()
-
0:38:37 - Move the savedValue property from CoffeeData to CoffeeDataStore
private var savedValue: [Drink] = []
-
0:39:00 - Move the dataURL property from CoffeeData to CoffeeDataStore
private var dataURL: URL { get throws { try FileManager .default .url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false) // Append the file name to the directory. .appendingPathComponent("CoffeeTracker.plist") } }
-
0:42:42 - Move the didSet for currentDrinks to a new async function
@Published public private(set) var currentDrinks: [Drink] = [] private func drinksUpdated() async { logger.debug("A value has been assigned to the current drinks property.") // Update any complications on active watch faces. let server = CLKComplicationServer.sharedInstance() for complication in server.activeComplications ?? [] { server.reloadTimeline(for: complication) } // Begin saving the data. await store.save(currentDrinks) }
-
0:44:00 - Update addDrink(mgCaffeine:onData:) to call drinksUpdated()
// Save drink information to HealthKit. Task { await self.healthKitController.save(drink: drink) await self.drinksUpdated() }
-
0:44:09 - Update updateModel(newDrinks:deletedDrinks:) to call drinksUpdated()
await drinksUpdated()
-
0:44:17 - Mark the updateModel(newDrinks:deletedDrinks:) method as async
@MainActor public func updateModel(newDrinks: [Drink], deletedDrinks: Set<UUID>) async {
-
0:45:26 - Complete the move of the save() method into CoffeeDataStore
// Begin saving the drink data to disk. func save(_ currentDrinks: [Drink]) { // Don't save the data if there haven't been any changes. if currentDrinks == savedValue { logger.debug("The drink list hasn't changed. No need to save.") return } // Save as a binary plist file. let encoder = PropertyListEncoder() encoder.outputFormat = .binary let data: Data do { // Encode the currentDrinks array. data = try encoder.encode(currentDrinks) } catch { logger.error("An error occurred while encoding the data: \(error.localizedDescription)") return } // Save the data to disk as a binary plist file. do { // Write the data to disk. try data.write(to: self.dataURL, options: [.atomic]) // Update the saved value. self.savedValue = currentDrinks self.logger.debug("Saved!") } catch { self.logger.error("An error occurred while saving the data: \(error.localizedDescription)") } }
-
0:46:20 - Move the top part of the load() method into CoffeeDataStore
func load() -> [Drink] { logger.debug("Loading the model.") var drinks: [Drink] do { // Load the drink data from a binary plist file. let data = try Data(contentsOf: self.dataURL) // Decode the data. let decoder = PropertyListDecoder() drinks = try decoder.decode([Drink].self, from: data) logger.debug("Data loaded from disk") } catch CocoaError.fileReadNoSuchFile { logger.debug("No file found--creating an empty drink list.") drinks = [] } catch { fatalError("*** An unexpected error occurred while loading the drink list: \(error.localizedDescription) ***") } // Update the saved value. savedValue = drinks return drinks }
-
0:48:01 - Update the load() method in CoffeeData to use the actor
func load() async { var drinks = await store.load() // Drop old drinks drinks.removeOutdatedDrinks() // Assign loaded drinks to model currentDrinks = drinks // Load new data from HealthKit. let success = await self.healthKitController.requestAuthorization() guard success else { logger.debug("Unable to authorize HealthKit.") return } await self.healthKitController.loadNewDataFromHealthKit() }
-
0:49:08 - Update the CoffeeData initializer to use an async task
Task { await load() }
-
0:50:03 - Annotate CoffeeData with @MainActor
@MainActor class CoffeeData: ObservableObject {
-
0:52:18 - Replace the completion handler usage in the handle(_:) method of ExtensionDelegate
// Check for updates from HealthKit. let model = CoffeeData.shared Task { let success = await model.healthKitController.loadNewDataFromHealthKit() if success { // Schedule the next background update. scheduleBackgroundRefreshTasks() self.logger.debug("Background Task Completed Successfully!") } // Mark the task as ended, and request an updated snapshot, if necessary. backgroundTask.setTaskCompletedWithSnapshot(success) }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.