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.
- If Swift 5 code calls a non-
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
- This effect is equivalent to
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.
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.
- A report for the bug
- A fixed PR for the bug
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 {}
inTask {}
’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.
- The above code, you keep the caller method of
- Changes will be partial. You don’t need to change the caller method.
- 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 }
- Nothing so far compared to
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
}
}
note:
UIApplication.shared.window
s 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
- Implement -strict-concurrency control and default to "minimal" #42523
- The PR for Swift 5.7 of SWIFT_STRICT_CONCURRENCY
- Eliminate data races using Swift Concurrency: WWDC 2022
- WWDC 2022 video
- Incremental migration to concurrency checking
- the proposal for
SWIFT_STRICT_CONCURRENCY
- the proposal for
- @MainActor Doesn’t work or misunderstood?
- The behavior of MainActor