Upgrade to PRO for Only $50/Year—Limited-Time Offer! 🔥

Swift Concurrency時代のiOSアプリの作り方

Yuta Koshizawa
September 12, 2022

Swift Concurrency時代のiOSアプリの作り方

昨年、Swift Concurrencyが導入されました。当初はiOS 15のみでサポートされていましたが、Concurrencyのback deploymentが実現されたため、iOS 13以降であれば今すぐにでもConcurrencyを取り入れることができます。

しかし、実際にConcurencyを取り入れようとすると、参考となる情報はまだまだ少ないのではないでしょうか。Conccurency自体の情報は豊富でも、iOSアプリ開発での活用、特にactorや単体テストなどについてはほとんど語られていないように思います。

本トークでは、iOSアプリ開発におけるConcurrency活用の一つのベースラインとなることを目指して、async/awaitやTask、actor、MainActorなどを、アプリやテストのコードにどのように取り入れるか、具体例を用いて紹介します。

Yuta Koshizawa

September 12, 2022
Tweet

More Decks by Yuta Koshizawa

Other Decks in Programming

Transcript

  1. LoginViewController final class LoginViewController: UIViewController { @IBOutlet private var idField:

    UITextField! @IBOutlet private var passwordField: UITextField! @IBOutlet private var loginButton: UIButton! ...
  2. LoginViewController final class LoginViewController: UIViewController { @IBOutlet private var idField:

    UITextField! @IBOutlet private var passwordField: UITextField! @IBOutlet private var loginButton: UIButton! @IBAction private func loginButtonPressed( sender: UIButton) { ... } ...
  3. AuthAPI.logIn enum AuthAPI { static func logIn( id: String, password:

    String ) async throws -> String { // ID token ... } }
  4. AuthAPI.logIn enum AuthAPI { static func logIn( for id: String,

    with password: String ) async throws -> String { // ID token ... } }
  5. AuthAPI.logIn enum AuthAPI { static func logIn( for id: User.ID,

    with password: String ) async throws -> IDToken { ... } }
  6. User.ID struct User: Identifiable { let id: ID var nickname:

    String var birthday: Date struct ID: Hashable { let rawValue: String init(rawValue: String) { self.rawValue = rawValue } } }
  7. User.ID struct User: Identifiable, Sendable { let id: ID var

    nickname: String var birthday: Date struct ID: Hashable { let rawValue: String init(rawValue: String) { self.rawValue = rawValue } } }
  8. User.ID struct User: Identifiable, Sendable { let id: ID var

    nickname: String var birthday: Date struct ID: Hashable { let rawValue: String init(rawValue: String) { self.rawValue = rawValue } } }
  9. User.ID struct User: Identifiable, Sendable { let id: ID var

    nickname: String var birthday: Date struct ID: Hashable, Sendable { let rawValue: String init(rawValue: String) { self.rawValue = rawValue } } }
  10. User.ID struct User: Identifiable, Sendable { let id: ID var

    nickname: String var birthday: Date struct ID: Hashable, Sendable { let rawValue: String init(rawValue: String) { self.rawValue = rawValue } } }
  11. AuthAPI.logIn enum AuthAPI { static func logIn( for id: User.ID,

    with password: String ) async throws -> IDToken { ... } }
  12. AuthAPI.logIn static func logIn(...) async throws -> IDToken { let

    url: URL = ... let request: URLRequest = ... // Concurrency Ҏલ let dataTask = URLSession.shared .dataTask(with: request) { // ίʔϧόοΫͰ݁ՌΛड͚औΔ } ... }
  13. AuthAPI.logIn static func logIn(...) async throws -> IDToken { let

    url: URL = ... let request: URLRequest = ... // Concurrency Ҏ߱ let (data, response) = try await URLSession.shared .data(for: request) // ໭Γ஋Ͱ݁ՌΛड͚औΔ ... }
  14. AuthAPI.logIn static func logIn(...) async throws -> IDToken { let

    (data, response) = ... let idToken: IDToken = ... return idToken }
  15. ͢ͰʹίʔϧόοΫ൛͕͋Δ৔߹ enum AuthAPI { static func logIn( for id: User.ID,

    with password: String, completion: @escaping (Result<IDToken, Error>) -> Void ) { ... } }
  16. ͢ͰʹίʔϧόοΫ൛͕͋Δ৔߹ extension AuthAPI { static func logIn( for id: User.ID,

    with password: String ) async throws -> IDToken { try await withCheckedThrowingContinuation { continuation in logIn(for: id, with: password) { result in continuation.resume(with: result) } } }
  17. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { let idToken

    = try await AuthAPI.logIn( // for: .init(rawValue: idField.text ?? ""), with: passwordField.text ?? "" ) ... }
  18. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {

    // async throws let idToken = try await AuthAPI.logIn( // for: .init(rawValue: idField.text ?? ""), with: passwordField.text ?? "" ) ... } }
  19. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task.init {

    // async throws let idToken = try await AuthAPI.logIn( // for: .init(rawValue: idField.text ?? ""), with: passwordField.text ?? "" ) ... } }
  20. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {

    // async throws let idToken = try await AuthAPI.logIn( // for: .init(rawValue: idField.text ?? ""), with: passwordField.text ?? "" ) ... } }
  21. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {

    // async throws let idToken = try await AuthAPI.logIn( // for: .init(rawValue: idField.text ?? ""), with: passwordField.text ?? "" ) ... } }
  22. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {

    // async throws do { let idToken = try await AuthAPI.logIn(...) ... } catch { // ΤϥʔϋϯυϦϯά } } }
  23. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {

    loginButton.isEnabled = false defer { loginButton.isEnabled = true } ... } }
  24. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { // main

    εϨου Task { // ??? loginButton.isEnabled = false defer { loginButton.isEnabled = true } ... } }
  25. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { // main

    εϨου Task { // main εϨου loginButton.isEnabled = false defer { loginButton.isEnabled = true } ... } }
  26. loginButtonPressed @IBAction private func loginButtonPressed(...) { // main εϨου Task

    { // main εϨου loginButton.isEnabled = false defer { loginButton.isEnabled = true } do { let idToken = try await AuthAPI.logIn(...) // main εϨου
  27. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { // main

    εϨου Task { // main εϨου loginButton.isEnabled = false defer { loginButton.isEnabled = true } ... } }
  28. Swi$ ͷόά @MainActor ͳϝιουͰɺ Task.init ʹ౉͢Ϋϩʔδϟͷத Ͱ defer ͢Δͱɺ MainActor

    Ͱอޢ͞Ε͍ͯͳ͍ͱ൑அ͞Εͯ ͠·͏ɻ 2021/08/01 ʹϑΥʔϥϜͰใࠂ Swi* Forums #50796 ͞Ε௕Β͘ղܾ͞ Ε͍ͯͳ͔͕ͬͨɺ 2022/08/22 ʹ PR apple/swi* #60688 ͕࡞ΒΕɺ 8/31 ʹϚʔδɻ apple/swi) #60688 h0ps:/ /github.com/apple/swi)/pull/60688 Swi$ Forums #50796 h3ps:/ /forums.swi$.org/t/a-bug-cant-defer-actor-isolated-variable-access/50796
  29. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { // main

    εϨου Task { // main εϨου loginButton.isEnabled = false defer { loginButton.isEnabled = true } ... } }
  30. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { // main

    εϨου Task { // main εϨου loginButton.isEnabled = false defer { loginButton.isEnabled = true } ... } }
  31. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { // main

    εϨου Task { // main εϨου loginButton.isEnabled = false defer { Task { await MainActor.run { loginButton.isEnabled = true } }
  32. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { // main

    εϨου Task { // main εϨου loginButton.isEnabled = false defer { Task { @MainActor in loginButton.isEnabled = true } }
  33. MainActor.run ͱ Task { @MainActor in } func foo() async

    { await MainActor.run { // ϝΠϯεϨουͰ࣮ߦ͍ͨ͠ॲཧ } } func bar() { Task { @MainActor in // ϝΠϯεϨουͰ࣮ߦ͍ͨ͠ॲཧ } }
  34. DispatchQueue.main.async ͸ʁ @Sendable ͕෇͍͍ͯͳ͍ͷͰආ͚ͨํ͕ྑ͍ɻ func async( group: DispatchGroup? = nil,

    qos: DispatchQoS = .unspecified, flags: DispatchWorkItemFlags = [], execute work: @escaping () -> Void )
  35. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {

    loginButton.isEnabled = false defer { Task { @MainActor in loginButton.isEnabled = true } } ... }
  36. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {

    loginButton.isEnabled = false defer { loginButton.isEnabled = true } ... } }
  37. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {

    loginButton.isEnabled = false ... loginButton.isEnabled = true }
  38. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {

    ... do { let idToken = try await AuthAPI.logIn(...) ... } catch { // ΤϥʔϋϯυϦϯά } } }
  39. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {

    ... do { let idToken = try await AuthAPI.logIn(...) let data: Data = idToken.rawValue .data(using: .utf8)! let url: URL = .libraryDirectory .appendingPathComponent("IDToken") try data.write(to: url, options: .atomic) ...
  40. Task.detached Task.detached { let data: Data = idToken.rawValue .data(using: .utf8)!

    let url: URL = .libraryDirectory .appendingPathComponent("IDToken") try data.write(to: url, options: .atomic) }
  41. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {

    ... do { let idToken = try await AuthAPI.logIn(...) Task.detached { ... try data.write(to: url, options: .atomic) } ...
  42. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {

    ... do { let idToken = try await AuthAPI.logIn(...) _ = try await Task.detached { ... try data.write(to: url, options: .atomic) }.value ...
  43. actor actor IDTokenStore { func update(_ value: IDToken) throws {

    let data: Data = value.rawValue .data(using: .utf8)! let url: URL = .libraryDirectory .appendingPathComponent("IDToken") try data.write(to: url, options: .atomic) } }
  44. actor actor IDTokenStore { var value: IDToken { get throws

    { let url: URL = .libraryDirectory .appendingPathComponent("IDToken") let data: Data = try .init(contentsOf: url) let rawValue: String = .init(data: data, encoding: .utf8)! return IDToken(rawValue: rawValue) } } ...
  45. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {

    ... do { let idToken = try await AuthAPI.logIn(...) try await IDTokenStore.shared .update(idToken) ...
  46. actor ͷϝιου͸֎͔ΒݟΔͱ async ʹͳΔ actor IDTokenStore { func update(_ value:

    IDToken) throws { let data: Data = value.rawValue .data(using: .utf8)! let url: URL = .libraryDirectory .appendingPathComponent("IDToken") try data.write(to: url, options: .atomic) } }
  47. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {

    ... do { let idToken = try await AuthAPI.logIn(...) try await IDTokenStore.shared .update(idToken) ...
  48. AuthService actor AuthService { func logIn( for id: User.ID, with

    password: String ) async throws { ... } }
  49. AuthService actor AuthService { func logIn( for id: User.ID, with

    password: String ) async throws { let idToken = try await AuthAPI .logIn(for: id, with: password) try await IDTokenStore.shared .update(idToken) } }
  50. AuthService actor AuthService { func logIn( for id: User.ID, with

    password: String ) async throws { let idToken = try await AuthAPI .logIn(for: id, with: password) try await IDTokenStore.shared .update(idToken) } }
  51. AuthService actor AuthService { private var isLoggingIn: Bool = false

    func logIn( for id: User.ID, with password: String ) async throws { let idToken = try await AuthAPI .logIn(for: id, with: password) try await IDTokenStore.shared .update(idToken) }
  52. AuthService actor AuthService { private var isLoggingIn: Bool = false

    func logIn( for id: User.ID, with password: String ) async throws { if isLoggingIn { ... } isLoggingIn = true defer { isLoggingIn = false} ... }
  53. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {

    ... do { let idToken = try await AuthAPI.logIn(...) try await IDTokenStore.shared .update(idToken) ...
  54. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {

    ... do { try await AuthService.shared.logIn(...) parent?.dismiss(animated: true) } catch { ...
  55. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {

    ... do { ... } catch { ... let title: String let message: String if error is LoginError { title = "ϩάΠϯΤϥʔ" message = "ID·ͨ͸ύεϫʔυ͕ਖ਼͋͘͠Γ·ͤΜɻ" } else ...
  56. loginButtonPressed @IBAction private func loginButtonPressed( sender: UIButton) { Task {

    ... do { ... } catch { ... let alertController: UIAlertController = .init(title: title, message: message, preferredStyle: .alert) alertController .addAction(.init(title: "ด͡Δ", style: .default, handler: nil)) present(alertController, animated: true) }
  57. LoginViewModel import Combine final class LoginViewModel { @Published var id:

    String = "" @Published var password: String = "" @Published private(set) var isLoginButtonEnabled: Bool = true }
  58. LoginViewModel import Combine final class LoginViewModel: ObservableObject { @Published var

    id: String = "" @Published var password: String = "" @Published private(set) var isLoginButtonEnabled: Bool = true }
  59. LoginViewModel import Combine @MainActor final class LoginViewModel: ObservableObject { @Published

    var id: String = "" @Published var password: String = "" @Published private(set) var isLoginButtonEnabled: Bool = true }
  60. LoginViewController.loginButtonPressed // LoginViewController @IBAction private func loginButtonPressed(...) { Task {

    loginButton.isEnabled = false defer { loginButton.isEnabled = true } do { ... } catch { ... } } }
  61. isLoginButtonEnabled ͷ View ΁ͷ൓ө // LoginViewController override func viewDidLoad() {

    super.viewDidLoad() ... viewModel.$isLoginButtonEnabled .sink { [weak self] isEnabled in guard let self else { return } self.loginButton.isEnabled = isEnabled } .store(in: &cancellables)
  62. LoginViewModel.loginButtonPressed // LoginViewModel func loginButtonPressed() async { isLoginButtonEnabled = false

    defer { isLoginButtonEnabled = true } do { try await AuthService.shared.logIn(...) dismiss.send() } catch { ... } }
  63. dismiss ͷ View Ͱͷ࣮ߦ // LoginViewController override func viewDidLoad() {

    super.viewDidLoad() ... viewModel.dismiss .sink { [weak self] _ in self?.parent?.dismiss(animated: true) } .store(in: &cancellables)
  64. LoginViewModel.loginButtonPressed // LoginViewModel func loginButtonPressed() async { ... do {

    ... } catch { logger.warning("\(error)") if error is LoginError { ... } else ... } }
  65. LoginViewModel.loginButtonPressed // LoginViewModel func loginButtonPressed() async { ... do {

    ... } catch { logger.warning("\(error)") if error is LoginError { presentLoginErrorAlert.send(...) } else ... } }
  66. LoginViewModel.loginButtonPressed // LoginViewModel func loginButtonPressed() async { ... do {

    ... } catch { logger.warning("\(error)") if error is LoginError { loginErrorMessage = .login } else ... } }
  67. Swi$UI struct LoginView: View { ... var body: some View

    { VStack(alignment: .center) { Spacer() TextField("ID", text: $viewModel.id) .textInputAutocapitalization(.never) .textFieldStyle(.roundedBorder) .frame(width: 200) ...
  68. Swi$UI struct LoginView: View { ... var body: some View

    { VStack(alignment: .center) { ... Button("Log in") { Task { await viewModel .loginButtonPressed() } } ...
  69. Swi$UI struct LoginView: View { ... var body: some View

    { VStack(alignment: .center) { ... } .onReceive(viewModel.dismiss) { _ in dismiss() } ... } }
  70. Swi$UI struct LoginView: View { ... @Environment(\.dismiss) private var dismiss

    var body: some View { VStack(alignment: .center) { ... } .onReceive(viewModel.dismiss) { _ in dismiss() } ... } }
  71. LoginViewModel ͷςετ͕ॻ͖ͮΒ͍ func loginButtonPressed() async { isLoginButtonEnabled = false defer

    { isLoginButtonEnabled = true } do { // αʔόʔʹΞΫηεɺϑΝΠϧ I/O try await AuthService.shared.logIn(...) dismiss.send() } catch { ... } }
  72. AuthServiceProtocol protocol AuthServiceProtocol { static var shared: Self { get

    } func logIn( for id: User.ID, with password: String ) async throws ... }
  73. AuthService ͷར༻Օॴͷίʔυ͸มΘΒͳ͍ func loginButtonPressed() async { isLoginButtonEnabled = false defer

    { isLoginButtonEnabled = true } do { try await AuthService.shared.logIn(...) dismiss.send() } catch { ... } }
  74. loginErrorMessage ͷςετ func testLoginErrorMessageByLoginError() async { let viewModel: LoginViewModel<AuthService> =

    .init() await viewModel.loginButtonPressed() // LoginError XCTAssertEqual(viewModel.loginErrorMessage, .login) }
  75. loginErrorMessage ͷςετ func testLoginErrorMessageByLoginError() async { let viewModel: LoginViewModel<AuthService> =

    .init() await viewModel.loginButtonPressed() // LoginError XCTAssertEqual(viewModel.loginErrorMessage, .login) }
  76. ςετ༻ͷ AuthService private final class AuthService: AuthServiceProtocol { ... func

    logIn(for id: User.ID, with password: String) async throws { throw LoginError() } }
  77. ςετ༻ͷ AuthService private final class AuthService: AuthServiceProtocol { ... var

    logInResult: Result<Void, Error>? func logIn(for id: User.ID, with password: String) async throws { try logInResult!.get() } }
  78. loginErrorMessage ͷςετ func testLoginErrorMessageByLoginError() async { let viewModel: LoginViewModel<AuthService> =

    .init() await viewModel.loginButtonPressed() XCTAssertEqual(viewModel.loginErrorMessage, .login) }
  79. loginErrorMessage ͷςετ func testLoginErrorMessageByLoginError() async { let viewModel: LoginViewModel<AuthService> =

    .init() AuthService.shared.logInResult = .failure(LoginError()) await viewModel.loginButtonPressed() XCTAssertEqual(viewModel.loginErrorMessage, .login) }
  80. loginErrorMessage ͷςετ func testLoginErrorMessageByServerError() async { let viewModel: LoginViewModel<AuthService> =

    .init() AuthService.shared.logInResult = .failure(ServerError.response(...)) await viewModel.loginButtonPressed() XCTAssertEqual(viewModel.loginErrorMessage, .server) }
  81. isLoginButtonEnabled ͷςετ func testLoginButtonEnabled() async { let viewModel: LoginViewModel<AuthService> =

    .init() XCTAssertTrue(viewModel.isLoginButtonEnabled) viewModel.loginButtonPressed() ... }
  82. isLoginButtonEnabled ͷςετ func testLoginButtonEnabled() async { let viewModel: LoginViewModel<AuthService> =

    .init() XCTAssertTrue(viewModel.isLoginButtonEnabled) viewModel.loginButtonPressed() XCTAssertFalse(viewModel.isLoginButtonEnabled) ... }
  83. isLoginButtonEnabled ͷςετ func testLoginButtonEnabled() async { let viewModel: LoginViewModel<AuthService> =

    .init() XCTAssertTrue(viewModel.isLoginButtonEnabled) viewModel.loginButtonPressed() XCTAssertFalse(viewModel.isLoginButtonEnabled) ... XCTAssertTrue(viewModel.isLoginButtonEnabled) }
  84. isLoginButtonEnabled ͷςετ func testLoginButtonEnabled() async { let viewModel: LoginViewModel<AuthService> =

    .init() XCTAssertTrue(viewModel.isLoginButtonEnabled) await viewModel.loginButtonPressed() XCTAssertFalse(viewModel.isLoginButtonEnabled) ... XCTAssertTrue(viewModel.isLoginButtonEnabled) }
  85. isLoginButtonEnabled ͷςετ func testLoginButtonEnabled() async { let viewModel: LoginViewModel<AuthService> =

    .init() XCTAssertTrue(viewModel.isLoginButtonEnabled) async let logIn: Void = viewModel.loginButtonPressed() XCTAssertFalse(viewModel.isLoginButtonEnabled) await logIn XCTAssertTrue(viewModel.isLoginButtonEnabled) }
  86. isLoginButtonEnabled ͷςετ func testLoginButtonEnabled() async { let viewModel: LoginViewModel<AuthService> =

    .init() XCTAssertTrue(viewModel.isLoginButtonEnabled) async let logIn: Void = viewModel.loginButtonPressed() XCTAssertFalse(viewModel.isLoginButtonEnabled) AuthService.shared.??? await logIn XCTAssertTrue(viewModel.isLoginButtonEnabled)
  87. ςετ༻ͷ AuthService private final class AuthService: AuthServiceProtocol { ... var

    logInResult: Result<Void, Error>? func logIn(for id: User.ID, with password: String) async throws { try logInResult!.get() } }
  88. ςετ༻ͷ AuthService private final class AuthService: AuthServiceProtocol { ... var

    logInContinuation: CheckedContinuation<Void, Error>? func logIn(for id: User.ID, with password: String) async throws { try await withCheckedThrowingContinuation { continuation in logInContinuation = continuation } } }
  89. isLoginButtonEnabled ͷςετ func testLoginButtonEnabled() async { ... async let logIn:

    Void = viewModel.loginButtonPressed() XCTAssertFalse(viewModel.isLoginButtonEnabled) AuthService.shared.??? await logIn
  90. isLoginButtonEnabled ͷςετ func testLoginButtonEnabled() async { ... async let logIn:

    Void = viewModel.loginButtonPressed() XCTAssertFalse(viewModel.isLoginButtonEnabled) AuthService.shared.logInContinuation? .resume(returning: ()) await logIn
  91. isLoginButtonEnabled ͷςετ func testLoginButtonEnabled() async { ... async let logIn:

    Void = viewModel.loginButtonPressed() await Task.yield() XCTAssertFalse(viewModel.isLoginButtonEnabled) AuthService.shared.logInContinuation? .resume(returning: ()) await logIn
  92. isLoginButtonEnabled ͷςετ func testLoginButtonEnabled() async { ... async let logIn:

    Void = viewModel.loginButtonPressed() while AuthService.shared.logInContinuation == nil { await Task.yield() } XCTAssertFalse(viewModel.isLoginButtonEnabled) AuthService.shared.logInContinuation? .resume(returning: ()) await logIn
  93. isLoginButtonEnabled ͷςετ func testLoginButtonEnabled() async { ... async let logIn:

    Void = viewModel.loginButtonPressed() while AuthService.shared.logInContinuation == nil { await Task.yield() } XCTAssertFalse(viewModel.isLoginButtonEnabled) AuthService.shared.logInContinuation? .resume(returning: ()) AuthService.shared.logInContinuation = nil await logIn