Give your simulator superpowers

RocketSim: An Essential Developer Tool
as recommended by Apple

Swift Concurrency Course

Why Swift Migration Tooling Matters

Swift offers migration tooling to help you migrate existing code to new upcoming features. Swift Evolution proposals on GitHub will mention an upcoming feature flag that you can use to opt in early. For most of these features, it’s likely that they’ll become the default in the future. Therefore, it’s essential to know how to adopt them gracefully.

Migrating manually to upcoming features is one of the 5 biggest mistakes iOS Developers make with async/await. For some features, it might be fine, but others can drastically impact your app’s performance. Let’s dive in!

How to access Swift’s Migration Tooling

Swift’s migration tooling works for both Xcode projects and Swift packages. Inside Xcode’s build settings you can search for Upcoming feature and use the dropdown to see if a feature supports migration:

Swift Migration Tooling helps you to gracefully migrate to upcoming features.
Swift Migration Tooling helps you to gracefully migrate to upcoming features.

The Migrate option is a temporary setting. You’ll only enable it while migrating.

How does the Migration Tooling work?

After selecting Migrate, you need to build your project. Xcode’s compiler will perform checks on your code, and it shows a warning for methods that an upcoming feature will impact:

The migration build shows a warning for an upcoming feature.
The migration build shows a warning for an upcoming feature.

As you can see, we get the option to apply a fix by using the @concurrent attribute. This makes sense as we’re migrating to an upcoming feature called nonisolated(nonsending) by default. I dive deeper into this specific feature in my article @concurrent explained with code examples.

Better understanding migration warnings

Some warnings might be intimidating. You’re migrating to a new feature, which comes with new learnings. Luckily enough, the Swift team thought about this, and they’ve added a question mark to the warning (see image above). Clicking this questionmark will navigate you to a detailed page for the warning:

Extended documentation helps you better understand migration tooling warnings.
Extended documentation helps you better understand migration tooling warnings.
FREE 5-day email course: The Swift Concurrency Playbook by Antoine van der Lee

FREE 5-Day Email Course: The Swift Concurrency Playbook

A FREE 5-day email course revealing the 5 biggest mistakes iOS developers make with with async/await that lead to App Store rejections And migration projects taking months instead of days (even if you've been writing Swift for years)

Migration Tooling for Swift Packages & automatically applying changes

You don’t have to do everything manually, and you can also apply changes automatically. Xcode’s build setting is just a little bit limited. On top of that, Xcode’s build settings only apply to your Xcode project. You’ll have to use a different approach for Swift packages.

I handle both automatic migration and the migration of Swift packages extensively in my Swift Concurrency Course.

Understanding the risks of migrating manually

Now that you know how to migrate using Swift’s migration tooling, I want you to understand better why it’s risky to migrate manually. Take the following code example:

class NotSendable {

    func performAsync() async {
        print("Task started on thread: \(Thread.currentThread)")
    }
}

This is a nonisolated class that does not conform to Sendable. It has a single async method called performAsync, and it prints out the current thread using a custom computed property on Thread (source code is in my free 5-day email course).

Imagine calling this method from a @MainActor attributed instance:

@MainActor
struct NewThreadingDemonstrator {
    
    func demonstrate() async {
        print("Starting on the main thread: \(Thread.currentThread)")
        // Prints: Starting on the main thread: <_NSMainThread: 0x6000006b4040>{number = 1, name = main}

        let notSendable = NotSendable()
        await notSendable.performAsync()
        
        /// Returning on the main thread.
        print("Resuming on the main thread: \(Thread.currentThread)")
        // Prints: Resuming on the main thread: <_NSMainThread: 0x6000006b4040>{number = 1, name = main}
    }
}

I’ve added print statements already for this code, since its threading won’t change due to the migration we’re about to do.

Now, let’s focus on the upcoming feature we’ve discussed before: nonisolated(nonsending) by default. This is the current threading behavior of our performAsync method before enabling the upcoming feature:

class NotSendable {

    func performAsync() async {
        print("Task started on thread: \(Thread.currentThread)")
        // Task started on thread: <NSThread: 0x600003694d00>{number = 8, name = (null)}
    }
}

As you can see, the task started on a background thread. In other words, it switched the isolation domain away from the @MainActor.

Manually enabling nonisolated(nonsending) by default

We’re now going to manually enable the upcoming feature. This means we’re not getting any help from Swift’s migration tooling and we’re not applying any code changes.

Our earlier defined method suddenly behaves differently:

class NotSendable {

    func performAsync() async {
        print("Task started on thread: \(Thread.currentThread)")
        // Task started on thread: <_NSMainThread: 0x6000006b4040>{number = 1, name = main}
    }
}

Suddenly, our method is no longer running on a background thread. Instead, it’s also performed on the main thread, potentially blocking the UI and decreasing the user’s experience.

If we would’ve used Swift’s migration tooling, it would have suggested us to apply @concurrent to retain the old behavior:

class NotSendable {

    @concurrent func performAsync() async {
        print("Task started on thread: \(Thread.currentThread)")
        // Task started on thread: <NSThread: 0x600003694d00>{number = 8, name = (null)}
    }
}

For those unfamiliar, nonisolated(nonsending) basically means:

  • not-isolated to any isolation domain
  • not-sending callers to a different isolation domain

In other words, methods attributed with nonisolated(nonsending) will take over the isolation domain of its callee, which is why we noticed a change to the main thread—we inherited it from the @MainActor attributed callee.

Conclusion

Swift’s migration tooling is a blessing for those with an existing codebase. I recommend adopting these upcoming features from the start for new projects, but definitely benefit from migration tooling in existing ones. Manually migrating large pieces of code can easily introduce new performance issues, making a migration much harder to complete.

This article demonstrates just one of the five biggest mistakes I see developers make when migrating to Swift Concurrency. If you’re ready to learn about the other 4 mistakes, I highly recommend to join my free 5-day email course.

Thanks!

 
Antoine van der Lee

Written by

Antoine van der Lee

iOS Developer since 2010, former Staff iOS Engineer at WeTransfer and currently full-time Indie Developer & Founder at SwiftLee. Writing a new blog post every week related to Swift, iOS and Xcode. Regular speaker and workshop host.

Are you ready to

Turn your side projects into independence?

Learn my proven steps to transform your passion into profit.