Introducing Swift Concurrency to Merpay code

This post is for Day 23 of Merpay Advent Calendar 2022, brought to you by takeshi from Merpay iOS Team.

Introduction

Since Swift 5.5 was released in 2021, we are able to use Swift Concurrency.
Swift Concurrency prevents your code from data races and provides a simple way to implement async code. It is big news for iOS developers.
Merpay iOS team attempts to introduce Swift Concurrency into our code from 2022 September. It is a tough task we expect.
Merpay iOS code is large code. It is composed of a thousand files and more than 20 modules.

If you are interested in Merpay iOS module strategy, see this article,
Multi-module development to support Merpay scalability

At first we planned to modify a small part and then expand to another part, but we had some domino effects introducing Swift Concurrency. So we ended up needing to change the core code that all of the modules depend on.
Although our project is its own way, we have a lot of knowledge.
I am going to show why we try to introduce Swift Concurrency and how we process.
I hope it encourages you to introduce Swift Concurrency to your projects.

Motivation

Swift 6 requires concurrency code.
Almost all non-concurrency code would not build in Swift 6.
We don’t know in how many years Swift 6 will be released, but the sooner we prepare for Swift Concurrency the better Swift compile prevents our code from data races.
This project started in September 2022 and we still were in progress in December.
Main Xcode version we used was 13.4.1 but in late November we updated to Xcode 14.1.
Swift Concurrency is a developing feature, so a lot of bugs are fixed in Xcode 14 or later with Swift 5.7.
In this article, I am going to explain what we do with the Xcode version or Swift version.

Compiler options

Swift 5.5 or later provides incremental adoption for Swift Concurrency. Some compiler options are available: -warn-concurrency and -enable-actor-data-race-checks.

  • -warn-concurrency: with this option, the compiler checks code and emits Concurrency warnings that are invalid under the Swift 6 rules.
    • It checks the adaptability of Sendable.
    • This check is adopted throughout modules. Even if some modules don’t adopt Concurrency code and you can’t modify them, the compiler emits the warnings.
  • -enable-actor-data-race-checks: with this option, the compiler diagnoses data races that Swift 5 misses in runtime.
    • If Swift 5 code calls a non-@Sendable closure multiple at the same time, this option tells you at runtime that an actor-isolated function was called on the wrong executor.

The way to set these options is here.
We need OTHER_SWIFT_FLAGS like that.

OTHER_SWIFT_FLAGS = -Xfrontend -warn-concurrency -Xfrontend -enable-actor-data-race-checks

For more information, see the forum post, Concurrency in Swift 5 and 6.

And Swift 5.7 has an update. About -warn-concurrency, Xcode 14 replaces this flag to SWIFT_STRICT_CONCURRENCY and we don’t need to use -warn-concurrency any more.
SWIFT_STRICT_CONCURRENCY can specify the strength of compile checks in more steps than warn flag
SWIFT_STRICT_CONCURRENCY has three modes.
This description is from Swift code.

  • minimal: Enforce Sendable constraints where it has been explicitly adopted and perform actor-isolation checking wherever code has adopted concurrency.
    • It’s the default value with Xcode 14
  • targeted: Enforce Sendable constraints and perform actor-isolation checking wherever code has adopted concurrency, including code that has explicitly adopted Sendable.
    • The scope of the check is limited.
  • complete: Enforce Sendable constraints and actor-isolation checking throughout the entire module.
    • This effect is equivalent to -warn-concurrency

While we were using -warn-concurrency to support Swift Concurrency, we encountered many warnings that we could not fix. For example, -warn-concurrency emitted some warnings of Apple Frameworks, which we couldn’t update of course. Because -warn-concurrency checks throughout the entire module.
When we updated to Xcode 14.1, SWIFT_STRICT_CONCURRENCY became available.
We debated which level to specify and we chose one level below, targeted mode because complete mode, which is the same level as -warn-concurrency, is a very strong restriction.

SWIFT_STRICT_CONCURRENCY = targeted

Combine -warn-concurrency and SWIFT_STRICT_CONCURRENCY

When we combine -warn-concurrency flag and SWIFT_STRICT_CONCURRENCY with minimal, the compiler checks our code as complete level.
Thus, to proceed with the project with Xcode 14, we need to remove the -warn-concurrency option for each module.

Roadmap

Merpay code is divided into modules by features.
The modules are mainly divided into shared and feature modules.
Shared modules include core module and api module. These modules are very basic modules and each feature module depends on them.
The feature modules are divided by Merpay’s functionality and number more than 20.
For example, QR module provides QR payment feature, and Coupon module provides coupon service, or Settings module shows a settings screen.
And all of the feature modules refer to Core module and API module like that.

merpay-module-structure

Thus, we had a plan on how we would proceed with our concurrency project.

  • Part1: We set -warn-concurrency option to each module and fix all of concurrency build errors.
    • Some errors happen on MainActor issues.
  • Part2: We silence concurrency warnings as possible.
    • We try to fix Sendable issues on this part.
  • Part3: We introduce async function into API module

We are currently in the middle of part 2.

Part1: -warn-concurrency errors and how to fix them

When we set -warn-concurrency option the compiler emits some concurrency errors.
I am going to share the errors and how to fix them.

Errors in initializer(Bugs up to Swift 5.6)

The first error we experienced was the errors of the initializer method in subclasses with UIViewController and UIView.
UIViewController and UIView are actually isolated MainActor already. You can see @MainActor annotated in the documentation.

note: MainActor is a special actor for main thread. See SE-0316

But up to Swift 5.6, Swift has a bug.
When we implement an initializer for a subclass of MainActor, the compiler emits the error like Property 'xxxxxxx' isolated to global actor 'MainActor' can not be mutated from this context

// built with Swift 5.6
final class SomeView: UIView {
    init() {
        super.init(frame: .zero)
        let view = UIView()
        view.backgroundColor = .blue
        // error: Property 'backgroundColor' isolated to global actor 'MainActor' can not be mutated from this context
        self.addSubview(view)
    }
}

To fix the bug, we need to annotate @MainActor to the initializer.

// built with Swift 5.6
final class SomeView: UIView {
    @MainActor // add the line
    init() {
        super.init(frame: .zero)
        let view = UIView()
        view.backgroundColor = .blue
        self.addSubview(view)
    }
}

Swift 5.7 fixes the problem. We can implement the initializer without annotating MainActor.

// built with Swift 5.7
final class SomeView: UIView {
    init() {
        super.init(frame: .zero)
        let view = UIView()
        view.backgroundColor = .blue
        // No more error!
        self.addSubview(view)
    }
}

for more information see the below.

The strategy of adapting MainActor

Most of the errors that occur with -warn-concurrency option are related to @MainActor.
If you call @MainActor method or property in a sync function’s body, the compiler emits errors.
For example, this code would not be built due to MainActor issues.

class SomeViewModel {
    func createView(completion: @escaping (UIView) -> Void) {

        let view = UIView() // error: Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context

        view.translatesAutoresizingMaskIntoConstraints = false // error: Main actor-isolated property 'translatesAutoresizingMaskIntoConstraints' can not be mutated from a non-isolated context

        completion(view)
    }
}

We need to properly adapt @MainActor for sync functions.
There are three main ways to adopt it.
They all have their advantages and disadvantages, so we need to select them appropriately.

  • Task { @MainActor in }
  • await MainActor.run {} in Task {}’s body
  • Annotating @MainActor to your method or type

Task { @MainActor in }

The first way is Task { @MainActor in }.
You can use @MainActor in the Task initializer closure.

class SomeViewModel {
    func createView(completion: @escaping @Sendable (UIView) -> Void) {
        Task { @MainActor in
            let view = UIView()
            view.translatesAutoresizingMaskIntoConstraints = false
            completion(view)
        }
    }

    func configureView() {
        // no effect the caller of createView mothod
        createView { view in
        }
    }

Here are the Pros and Cons for Task { @MainActor in }.

  • Pros
    • Changes will be partial. You don’t need to change the caller method.
      • The above code, you keep the caller method of createView(configureView) as it is.
  • Cons
    • The timing of execution for the closure is the next run loop. The calling order would change. You should check whatever your code changes the original behavior.
    • For example, before code, the calling order follows from top to bottom. But after code, the calling order jumps Task { @MainActor in } closure and then back to its closure block. The numbers in below code are in the order they are called.
// before
func createView(completion: @escaping @Sendable (UIView) -> Void) {
    // 1
    let view = UIView() // 2 
    view.translatesAutoresizingMaskIntoConstraints = false // 3 
    completion(view) // 4 
    // 5
}

// after
func createView(completion: @escaping @Sendable (UIView) -> Void) {
    // 1
    Task { @MainActor in
        let view = UIView() // 3 
        view.translatesAutoresizingMaskIntoConstraints = false // 4 
        completion(view) // 5
    }
    // 2 
}

This causes some bugs for Merpay code. Merpay code has a lot of Snapshot testing.
Changing calling order with Task { @MainActor in } affected Snapshot testing and we couldn’t get the right screen in the test.
You should use Task { @MainActor in } carefully.
It might change your original behavior.

await MainActor.run {} in Task {}’s body

The second way is await MainActor.run {} in Task {}’s body.
You can use await MainActor.run {} in the Task initializer closure.
Compared to Task { @MainActor in }, it is a good way for me.

  • Pros
    • Changes will be partial. You don’t need to change the caller method.
    • Unlike Task { @MainActor in }, the calling order in Task’s closure is executed from the top. You can call some Task not calling in Main Thread, and then you call some task in Main Thread in sequence.
  • Cons
    • Nothing so far compared to Task { @MainActor in }
func calculateWidth() -> CGFloat {
    .zero
}
func createView(completion: @escaping @Sendable (UIView) -> Void) {
// The numbers are in the order they are called.
    Task {
        let size = calculateWidth() // 1
        await MainActor.run {
            let view = UIView() // 2
            view.translatesAutoresizingMaskIntoConstraints = false // 3
            view.frame.size.width = size // 4
            completion(view)  // 5
        }
    }
}

In Merpay Code, we use it when we need to take care of calling orders.

Annotating @MainActor to your method or type

It is the final way of adopting @MainActor.
You can just annotate @MainActor in your method or types.

@MainActor // <- add
func createView(completion: @escaping (UIView) -> Void) {
    let view = UIView()
    view.translatesAutoresizingMaskIntoConstraints = false
    completion(view)
}

@MainActor // <- also add
func configureView() {
    createView { view in
        // do something
    }
}
  • Pros
    • It is a simple way to fix the MainActor issue.
    • It is easy to understand which methods or types are called by MainActor.
  • Cons
    • Your fix propagates the caller. In the above code, you need to annotate the configureView method as well. You should annotate your method up to the place where the caller is MainActor in.

Merpay iOS team decided we annotated @MainActor to all of ViewModels.
Although the amount of code to be changed is very large, we decided that it would be easier to understand than making partial modifications.

Annotating @MainActor to all of ViewModels

Merpay iOS code uses the MVVM architecture. ViewModel detects UIViewController events and calls API and then sends the data to ViewController.
Now we decided we annotated @MainActor to all of ViewModel.
At this time, you may have a question.
“Is it okay? you call some API request in Main Thread?”
Basically, there is no problem.
Our team checked the MainActor’s behavior.
A method isolated by MainActor is called in the main thread.
but the callback of the DispatchQueue.global is called in a non-main thread.

@MainActor
func foo() {
    // call in main thread
    DispatchQueue.global(qos: .background).async {
        // call in not main thread
    }
}

The API call was not called from the main thread even if the API call was made by the actual API client from the ViewModel with MainActor annotated.
So we could safely annotate @MainActor to ViewModels.

Errors with Default Values

Some default values for your method don’t work when the values are isolated by MainActor.
For example, we have a method with default value to set the key window.

// built with Swift 5.7
@MainActor
public final class SomeClass {
    public let keyWindow: UIView
    public init(keyWindow: UIView = UIApplication.shared.windows.first { $0.isKeyWindow }!) {
    // error: Class property 'shared' isolated to global actor 'MainActor' can not be referenced from this synchronous context
   // error: Property 'isKeyWindow' isolated to global actor 'MainActor' can not be referenced from a non-isolated synchronous context
   // error: Property 'windows' isolated to global actor 'MainActor' can not be referenced from this synchronous context

        self.keyWindow = keyWindow
    }
}

default-value-error

note: UIApplication.shared.windows is deprecated by iOS 16. if you use keywindow in iOS 16, use UIApplication.shared.connectedScenes

This behavior is reported to Swift repository as well.

In this issue, using static property is suggested.
But we didn’t use static property.
To fix the error, we can add a static property, but if the class we want to fix is public, the static property needs to be public as well.

// built with Swift 5.7
@MainActor
public final class SomeClass {
    public let keyWindow: UIView

    // it works but can we allow defaultWindow to be public? 
    @MainActor
    public static let defaultWindow = UIApplication.shared.windows.first { $0.isKeyWindow }!

    public init(keyWindow: UIView = defaultWindow) {
        self.keyWindow = keyWindow
    }
}

And we can’t set a private static property to a public method with default value.

// built with Swift 5.7
@MainActor
public final class SomeClass {
    public let keyWindow: UIView

    @MainActor
    private static let defaultWindow = UIApplication.shared.windows.first { $0.isKeyWindow }!
    // it becomes private  

    public init(keyWindow: UIView = defaultWindow) {
    // error: Static property 'defaultWindow' is private and cannot be referenced from a default argument value

        self.keyWindow = keyWindow
    }
}

We can’t allow adding unnecessary property to be public.
Thus, we use another solution, optional value.
We define an optional value to method’s argument, and when the value is nil, we set the default value.

// built with Swift 5.7
@MainActor
public final class SomeClass {
    public let keyWindow: UIView
    public init(overrideKeyWindow: UIView?) {
        if let overrideKeyWindow = overrideKeyWindow {
            self.keyWindow = overrideKeyWindow
        } else {
            self.keyWindow = UIApplication.shared.windows.first { $0.isKeyWindow }!
        }
    }
}

With optional value, we kept the original behavior and avoided concurrency errors.

Part 2: -warn-concurrency warnings and how to silence them

In Part 2 task, we try to silence concurrency warnings with -warn-concurrency.

MainActor and Protocols

When a type that is isolated by MainActor adopts a synchronous function with a protocol, concurrency warnings happen. A synchronous function means a function/method without async keyword.

For example, here is InputAppliable protocol and SubView class.

public protocol InputAppliable {
    associatedtype Input
    func apply(input: Input)
}

public final class SubView: UIView, InputAppliable {
    public struct Input {
        var name: String
    }

    public func apply(input: Input) { // warnings: Main actor-isolated instance method 'apply(input:)' cannot be used to satisfy nonisolated protocol requirement
        // do something
    }
}

SubView inherits UIView, so apply(input:) method is isolated by MainActor.
It means the apply method is seen as an async function from another type.

public final class SubView: UIView, InputAppliable {
    public struct Input {
        var name: String
    }

    public func apply(input: Input) async { // another types recognizes the method as async function
    }

but InputAppliable requires apply(input:) method, not apply(input:) async method.
so SubView doesn’t satisfy the corresponding requirement from InputAppliable.
This is the reason for the warnings.

To silence the warnings, we have two solutions.
First, we can add a nonisolated keyword to apply(input: ) method in SubView.

public final class SubView: UIView, InputAppliable {
    nonisolated
    public func apply(input: Input) { 
        // do something
    }

But this solution doesn’t work when we need to call the MainActor method in the body.
Second solution, we update the protocol itself with MainActor.

public protocol InputAppliable {
    associatedtype Input
    @MainActor // add
    func apply(input: Input)
}

Step up rollout

We have some fundamental protocols including InputAppliable that are referred to by all of the modules in Core module.
If we update these protocol directory, a lot of errors occur in a module that we don’t start concurrency tasks in.
For example, if an existing ViewModel without MainActor adopts InputAppliable and InputAppliable annotates MainActor, the existing ViewModel will not be builded.

public protocol InputAppliable {
    associatedtype Input
    @MainActor // add
    func apply(input: Input)
}

// some ViewModel without MainActor
public final class SomeViewModel: InputAppliable {
    public struct Input {
        var name: String
    }
    init() {}
    public func apply(input: Input) {

    }

    func requestAPI(completion: @escaping () -> Void) {
        completion()

    }

    func viewDidLoad() {
        requestAPI { [weak self] in
            self?.apply(input: .init(name: "")) // error: Call to main actor-isolated instance method 'apply(input:)' in a synchronous nonisolated context
        }
    }
}

So we created an async version of the protocols and deprecated the existing one.
This code is an example for InputAppliable protocol.

@available(*, deprecated, message: "Please use AsyncInputAppliable instead, which conforms to concurrency", renamed: "AsyncInputAppliable")
public protocol InputAppliable {
    associatedtype Input
    func apply(input: Input)
}

public protocol AsyncInputAppliable {
    associatedtype Input
    @MainActor
    func apply(input: Input)
}

We created AsyncInputAppliable with MainActor.
We use it in MainActor types.
And the existing code still refers to the old one, InputAppliable.
So we can build our code properly while some modules are updating to Concurrency.

Apple frameworks don’t support Concurrency feature yet

The -warn-concurrency is checked through all module.
But some Apple frameworks don’t support Concurrency feature yet, so we have warnings that we can’t fix.
We have a ViewController adopting AVCapturePhotoCaptureDelegate protocol.
But AVCapturePhotoCaptureDelegate doesn’t have async method yet, so we have a warning like that.

extension CameraViewController: AVCapturePhotoCaptureDelegate {
    func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
// warning: Instance method 'photoOutput(_:didFinishProcessingPhoto:error:)' isolated to global actor 'MainActor' can not satisfy corresponding requirement from protocol 'AVCapturePhotoCaptureDelegate'
    }
}

Of course, we can’t update AVCapturePhotoCaptureDelegate code directory since it belongs to Apple code.
We have to wait for them to update. Until they do, we leave our code as it is.
If you set SWIFT_STRICT_CONCURRENCY option to targeted, this warning will disappear.

Using @preconcurrency with non-supported Concurrency module

If you need to import a module that doesn’t support Concurrency yet, you can use @preconcurrency.
In Merpay code, to define API structure we use Protocol Buffers via swift-protobuf.
Unfortunately, the version we use of swift-protobuf is a little old and doesn’t support Sendable.
When we use a type that doesn’t support Sendable in a type adopting Error protocol, a warning occurs.

import PlatformProto
public enum ResponseError: Error {
   ...
   case apiError(Mercari_Gateway_V1_Error) // warnings: Associated value 'apiError' of 'Sendable'-conforming enum 'ResponseError' has non-sendable type 'Mercari_Gateway_V1_Error'
}

PlatformProto is an in-house library that depends on swift-protobuf.
The compiler emits a warning because Mercari_Gateway_V1_Error doesn’t support Sendable yet.

To silence the warning, we can add @preconcurrency to the import line.

@preconcurrency import PlatformProto

Modules imported with preconcurrency will not be checked for Sendable.
And when the module supports Sendable, the compiler emits a warning that @preconcurrency is not necessary.
In the proposal SE-0337 says like that.

If the @preconcurrency attribute is unused[3], a warning will be emitted recommending that it be removed.

So we can use @preconcurrency with confidence.

Conclusion

I showed you how we introduce Swift Concurrency in the Merpay codebase.
We set the compile option -warn-concurrency and fix the concurrency errors and silence warnings.
-warn-concurrency is a very strict option. The compiler will emit a lot of warnings you can’t silence.
If you use Xcode 14, we recommend you to use the SWIFT_STRICT_CONCURRENCY option as the targeted level.
As some Apple frameworks don’t support Swift Concurrency, it is hard to silence all of concurrency warnings.
As I showed you today, in some cases, annotating @MainActor causes a domino effect, and we might need to change the logic significantly.
Concurrency support may have been premature due to errors up to Swift 5.6. However, through concurrency adoption, we learned how to write code to prevent data races.
I hope this article helps you.

Reference

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加