I have multiple services implemented through protocols to be able to inject a mock service on initialization of ViewModels, and after enabling strict concurrency check I have many warnings "Capture of 'self' with non-sendable type "ViewModelType" in 'async let' binding"
Here's a minimal reproducible example:
class ViewController: UIViewController {
let viewModel: UserProfileViewModel
init(service: UserProfileServiceProtocol) {
self.viewModel = UserProfileViewModel(service: service)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
getUserProfileData()
// Do any additional setup after loading the view.
}
func getUserProfileData() {
Task {
do {
try await viewModel.getProfileData()
// update UI
} catch {
print(error)
}
}
}
}
struct UserModel {
var userID: String
var username: String
var profilePictureURL: URL?
var profilePhoto: UIImage?
}
class UserProfileViewModel {
private let service: any UserProfileServiceProtocol
var user: UserModel?
var postsCount: Int?
var followersCount: Int?
var followedUsersCount: Int?
init(service: UserProfileServiceProtocol) {
self.service = service
}
func getProfileData() async throws {
async let user = service.getUserData()
async let followersCount = service.getFollowersCount()
async let followedUsersCount = service.getFollowingCount()
async let postsCount = service.getPostsCount()
self.user = try await user
self.followersCount = try await followersCount
self.followedUsersCount = try await followedUsersCount
self.postsCount = try await postsCount
}
}
protocol UserProfileServiceProtocol {
var followService: FollowSystemProtocol { get }
var userPostsService: UserPostsServiceProtocol { get }
var userDataService: UserDataServiceProtocol { get }
func getFollowersCount() async throws -> Int
func getFollowingCount() async throws -> Int
func getPostsCount() async throws -> Int
func getUserData() async throws -> UserModel
}
protocol FollowSystemProtocol {
func getFollowersNumber(for uid: String) async throws -> Int
func getFollowingNumber(for uid: String) async throws -> Int
}
protocol UserPostsServiceProtocol {
func getPostCount(for userID: String) async throws -> Int
}
protocol UserDataServiceProtocol {
func getUser(for userID: String) async throws -> UserModel
}
class UserService: UserProfileServiceProtocol {
let userID: String
let followService: FollowSystemProtocol
let userPostsService: UserPostsServiceProtocol
let userDataService: UserDataServiceProtocol
init(userID: String, followService: FollowSystemProtocol, userPostsService: UserPostsServiceProtocol, userDataService: UserDataServiceProtocol) {
self.userID = userID
self.followService = followService
self.userPostsService = userPostsService
self.userDataService = userDataService
}
func getFollowersCount() async throws -> Int {
let followersCount = try await followService.getFollowersNumber(for: userID)
return followersCount
}
func getFollowingCount() async throws -> Int {
let followersCount = try await followService.getFollowingNumber(for: userID)
return followersCount
}
func getPostsCount() async throws -> Int {
let postsCount = try await userPostsService.getPostCount(for: userID)
return postsCount
}
func getUserData() async throws -> UserModel {
let user = try await userDataService.getUser(for: userID)
return user
}
}
class FollowSystemService: FollowSystemProtocol {
func getFollowersNumber(for uid: String) async throws -> Int {
try await Task.sleep(for: .seconds(1))
return 5
}
func getFollowingNumber(for uid: String) async throws -> Int {
try await Task.sleep(for: .seconds(1))
return 19
}
}
class UserPostsService: UserPostsServiceProtocol {
func getPostCount(for userID: String) async throws -> Int {
try await Task.sleep(for: .seconds(1))
return 27
}
}
class UserProfileService: UserDataServiceProtocol {
func getUser(for userID: String) async throws -> UserModel {
try await Task.sleep(for: .seconds(1))
return UserModel(userID: "testUser_01", username: "testUser", profilePictureURL: nil)
}
}
I don't have enough experience to judge what would be the correct way to tackle this problem so I'm just nervously trying to dig any information on this with no luck.
Should I make service protocols conform to sendable? Is it even a common practice to do so? Or should I do something entirely different to fix this?
Currently, your view model does not conform to Sendable
. The deeper problem is that it is not thread-safe. Because the view model is not isolated to any particular actor, all of its async
methods (by virtue of SE-0338) run on a “generic executor” (i.e., not the main thread). So you have a background thread updating properties that the view controller accesses from the main thread. If you set the “Strict Concurrency Checking” build setting to “Complete”, you will see more warnings about the lack of thread-safety.
The view model should, at the very least, isolate properties accessed by the view to the main actor. Simpler, we would often isolate the whole view model to the main actor. The entire job of a view model is to support the view (which is on the main actor), so it makes sense to isolate the whole view model to the main actor as well:
@MainActor
class UserProfileViewModel {…}
Regarding the services, yes, you will want to make those protocols Sendable
, too. The compiler (especially with a “Strict Concurrency Checking” build setting of “Complete”) will warn you that a Sendable
object cannot have properties that are not Sendable
. I.e., an object is not thread-safe if its properties are not thread-safe. So, make the protocols Sendable
:
protocol UserPostsServiceProtocol: Sendable {
func getPostCount(for userID: String) async throws -> Int
}
And then, of course, make your implementations Sendable
, too. E.g., if the service has no mutable properties, you could just declare it as final
. (It will inherit the Sendable
from the protocol). Perhaps:
final class UserPostsService: UserPostsServiceProtocol {
func getPostCount(for userID: String) async throws -> Int {
try await Task.sleep(for: .seconds(1))
return 27
}
}
But if the service has some internal mutable state, it will require some synchronization to avoid data races. Or, rather than adding your own manual synchronization, it is easier to actor isolate the entire service. You can either isolate that to the main actor (which is less compelling for a service than it was for the view model), or just make it its own actor:
actor UserPostsService: UserPostsServiceProtocol {
private var value = 0
func getPostCount(for userID: String) async throws -> Int {
try await Task.sleep(for: .seconds(1))
value = 27
return value
}
}
So pulling this all together, you might end up with:
class ViewController: UIViewController {
let viewModel: UserProfileViewModel
private var task: Task<Void, Error>?
init(service: UserProfileServiceProtocol) {
self.viewModel = UserProfileViewModel(service: service)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
getUserProfileData() // you might consider doing this in `viewDidAppear` … it depends upon whether this view presents other view controllers and whether you want it to re-fetch user profile data when it re-appears
}
// If you use unstructured concurrency, you are responsible for
// canceling the task whenever its results are no longer needed.
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
task?.cancel()
}
// So, make sure to save this unstructured concurrency `Task` in a property so you can cancel it when no longer needed
func getUserProfileData() {
task = Task {
do {
try await viewModel.getProfileData()
// update UI
} catch {
print(error)
}
}
}
}
struct UserModel {
let userID: String // this probably should be immutable
let username: String // this probably should be immutable
let profilePictureURL: URL? = nil // this probably should be immutable
var profilePhoto: UIImage? = nil
}
extension UserModel: Identifiable { // you might want to make this `Identifiable`
var id: String { userID }
}
@MainActor
class UserProfileViewModel {
private let service: any UserProfileServiceProtocol
var user: UserModel?
var postsCount: Int?
var followersCount: Int?
var followedUsersCount: Int?
init(service: UserProfileServiceProtocol) {
self.service = service
}
func getProfileData() async throws {
async let user = service.getUserData()
async let followersCount = service.getFollowersCount()
async let followedUsersCount = service.getFollowingCount()
async let postsCount = service.getPostsCount()
self.user = try await user
self.followersCount = try await followersCount
self.followedUsersCount = try await followedUsersCount
self.postsCount = try await postsCount
}
}
protocol UserProfileServiceProtocol: Sendable {
var followService: FollowSystemProtocol { get }
var userPostsService: UserPostsServiceProtocol { get }
var userDataService: UserDataServiceProtocol { get }
func getFollowersCount() async throws -> Int
func getFollowingCount() async throws -> Int
func getPostsCount() async throws -> Int
func getUserData() async throws -> UserModel
}
protocol FollowSystemProtocol: Sendable {
func getFollowersNumber(for uid: String) async throws -> Int
func getFollowingNumber(for uid: String) async throws -> Int
}
protocol UserPostsServiceProtocol: Sendable {
func getPostCount(for userID: String) async throws -> Int
}
protocol UserDataServiceProtocol: Sendable {
func getUser(for userID: String) async throws -> UserModel
}
final class UserService: UserProfileServiceProtocol {
let userID: String
let followService: FollowSystemProtocol
let userPostsService: UserPostsServiceProtocol
let userDataService: UserDataServiceProtocol
init(userID: String, followService: FollowSystemProtocol, userPostsService: UserPostsServiceProtocol, userDataService: UserDataServiceProtocol) {
self.userID = userID
self.followService = followService
self.userPostsService = userPostsService
self.userDataService = userDataService
}
func getFollowersCount() async throws -> Int {
let followersCount = try await followService.getFollowersNumber(for: userID)
return followersCount
}
func getFollowingCount() async throws -> Int {
let followersCount = try await followService.getFollowingNumber(for: userID)
return followersCount
}
func getPostsCount() async throws -> Int {
let postsCount = try await userPostsService.getPostCount(for: userID)
return postsCount
}
func getUserData() async throws -> UserModel {
let user = try await userDataService.getUser(for: userID)
return user
}
}
final class FollowSystemService: FollowSystemProtocol {
func getFollowersNumber(for uid: String) async throws -> Int {
try await Task.sleep(for: .seconds(1))
return 5
}
func getFollowingNumber(for uid: String) async throws -> Int {
try await Task.sleep(for: .seconds(1))
return 19
}
}
actor UserPostsService: UserPostsServiceProtocol {
var value = 0
func getPostCount(for userID: String) async throws -> Int {
try await Task.sleep(for: .seconds(1))
value = 27
return value
}
}
final class UserProfileService: UserDataServiceProtocol {
func getUser(for userID: String) async throws -> UserModel {
try await Task.sleep(for: .seconds(1))
return UserModel(userID: "testUser_01", username: "testUser")
}
}