Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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. Swi$ Concurrency ࣌୅ͷ
    iOS ΞϓϦͷ࡞Γํ
    Yuta Koshizawa @koher

    View Slide

  2. ͜ͷ਺೥ͷ iOS ΞϓϦ։ൃͷมԽ
    • Swi%UI (2019)
    • Combine (2019)
    • Swi$ Concurrency (2021)

    View Slide

  3. Swi$ Concurrency ࣌୅ͷ iOS ΞϓϦ։ൃͷ
    ϕʔεϥΠϯΛߟ͑Δࢀߟʹ

    View Slide

  4. View Slide

  5. View Slide

  6. View Slide

  7. View Slide

  8. ViewController ʹϕλॻ͖

    ViewModel Λಋೖ

    DI ͱςετ

    View Slide

  9. ViewController ʹϕλॻ͖

    View Slide

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

    View Slide

  11. 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) {
    ...
    }
    ...

    View Slide

  12. loginButtonPressed
    @IBAction private func loginButtonPressed(
    sender: UIButton) {
    // ͜͜ͰϩάΠϯͷ API Λୟ͘
    }

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  16. User.ID
    struct User: Identifiable {
    let id: String
    var nickname: String
    var birthday: Date
    }

    View Slide

  17. User.ID
    struct User: Identifiable {
    let id: ID
    var nickname: String
    var birthday: Date
    }

    View Slide

  18. 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
    }
    }
    }

    View Slide

  19. 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
    }
    }
    }

    View Slide

  20. Sendable
    ฒߦʹѻͬͯ΋҆શͳܕͰ͋Δ͜ͱΛࣔ͢
    • actor ڥքΛ௒͑Δͷʹඞཁ
    • Task.init ͳͲ @Sendable ΫϩʔδϟʹΩϟϓνϟ͢Δͷ
    ʹඞཁ

    View Slide

  21. 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
    }
    }
    }

    View Slide

  22. 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
    }
    }
    }

    View Slide

  23. !
    ฒߦʹѻ͏ܕ͸ Sendable ʹ४ڌͤ͞Δ

    View Slide

  24. 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
    }
    }
    }

    View Slide

  25. @unchecked Sendable
    // Xcode 13 ·Ͱͷ৔߹
    extension Date: @unchecked Sendable {}

    View Slide

  26. !
    Sendable ରԠ͍ͯ͠ͳ͍ܕ͸
    @unchecked Sendable Ͱ
    ແཧ΍Γ४ڌͤ͞ΒΕΔ
    ※ Sendable ʹͯ͠΋҆શͳ৔߹͚ͩ

    View Slide

  27. Sendable ʹͯ͠΋҆શͳܕͷྫ
    • ७ਮͳ struct
    • ΠϛϡʔλϒϧΫϥε
    • ϛϡʔλϒϧ͚ͩͲϩοΫ౳Ͱอޢ͞ΕͨΫϥε
    • actor
    • ɾɾɾ

    View Slide

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

    View Slide

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

    View Slide

  30. 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) // ໭Γ஋Ͱ݁ՌΛड͚औΔ
    ...
    }

    View Slide

  31. AuthAPI.logIn
    static func logIn(...) async throws -> IDToken {
    let (data, response) = ...
    let idToken: IDToken = ...
    return idToken
    }

    View Slide

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

    View Slide

  33. ͢ͰʹίʔϧόοΫ൛͕͋Δ৔߹
    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)
    }
    }
    }

    View Slide

  34. !
    Con%nua%on Λ࢖͑͹ίʔϧόοΫΛ
    async ʹม׵Ͱ͖Δ

    View Slide

  35. loginButtonPressed
    @IBAction private func loginButtonPressed(
    sender: UIButton) {
    // ͜͜ͰϩάΠϯͷ API Λୟ͘
    }

    View Slide

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

    View Slide

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

    View Slide

  38. 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 ?? ""
    )
    ...
    }
    }

    View Slide

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

    View Slide

  40. !
    ಉظϝιου͔ΒඇಉظॲཧΛ
    ࢝ΊΔʹ͸ Task.init Λ࢖͏

    View Slide

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

    View Slide

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

    View Slide

  43. !
    Task.init ʹ౉͢ΫϩʔδϟͰ͸
    do/catch Λ๨Εͳ͍Α͏ʹ͢Δ

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  47. UIViewController ͱ @MainActor
    @MainActor class UIViewController : UIResponder

    View Slide

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

    View Slide

  49. !
    Task.init ͸ actor context ΛҾ͖ܧ͙

    View Slide

  50. !
    UIViewController ͸ MainActor Ͱ
    อޢ͞Ε͍ͯΔ

    View Slide

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

    View Slide

  52. 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

    View Slide

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

    View Slide

  54. MainActor Ͱ࣮ߦ͢Δํ๏
    • await MainActor.run { }
    • Task { @MainActor in ... }

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  58. MainActor.run ͱ Task { @MainActor in }
    func foo() async {
    await MainActor.run {
    // ϝΠϯεϨουͰ࣮ߦ͍ͨ͠ॲཧ
    }
    }
    func bar() {
    Task { @MainActor in
    // ϝΠϯεϨουͰ࣮ߦ͍ͨ͠ॲཧ
    }
    }

    View Slide

  59. !
    async ϝιου͔ΒҰ෦ͷॲཧΛ
    ϝΠϯεϨουͰ࣮ߦ͍ͨ͠৔߹͸
    await MainActor.run { }

    View Slide

  60. !
    ಉظϝιου͔ΒϝΠϯεϨουʹ
    ॲཧ౤͍͛ͨ৔߹ʹ͸
    Task { @MainActor in }

    View Slide

  61. DispatchQueue.main.async ͸ʁ
    @Sendable ͕෇͍͍ͯͳ͍ͷͰආ͚ͨํ͕ྑ͍ɻ
    func async(
    group: DispatchGroup? = nil,
    qos: DispatchQoS = .unspecified,
    flags: DispatchWorkItemFlags = [],
    execute work: @escaping () -> Void
    )

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  66. 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)
    ...

    View Slide

  67. 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)
    }

    View Slide

  68. !
    ॏΊͷॲཧ͸ Task.detached Ͱ
    ผεϨουʹಀ͢

    View Slide

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

    View Slide

  70. 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
    ...

    View Slide

  71. !
    ޙଓॲཧ͕͋Δ৔߹͸
    Task.detached ͔Β
    value Λऔಘͯ͠
    ॲཧ͕ऴΘΔͷΛ଴ͭ

    View Slide

  72. Task.detached ΑΓ
    ΋ͬͱྑ͍ํ๏͕͋Δ

    View Slide

  73. 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)
    }
    }

    View Slide

  74. 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)
    }
    }
    ...

    View Slide

  75. !
    ഉଞతʹ࣮ߦ͍ͨ͠ॲཧ͸ actor Ͱอޢ

    View Slide

  76. actor
    actor IDTokenStore {
    static let shared: IDTokenStore = .init()
    private init() {}
    ...
    }

    View Slide

  77. !
    actor ͸Πϯελϯε͝ͱʹ
    Serial Executor Λ࣋ͪอޢ͞ΕΔ
    ※ άϩʔόϧʹอޢ͍ͨ͠ͳΒγϯάϧτϯʹ͢Δ

    View Slide

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

    View Slide

  79. 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)
    }
    }

    View Slide

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

    View Slide

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

    View Slide

  82. 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)
    }
    }

    View Slide

  83. A B
    AuthAPI.login (1) (2)
    IDTokenStore.sh
    ared.update
    (4) (3)

    View Slide

  84. 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)
    }
    }

    View Slide

  85. 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)
    }

    View Slide

  86. 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}
    ...
    }

    View Slide

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

    View Slide

  88. loginButtonPressed
    @IBAction private func loginButtonPressed(
    sender: UIButton) {
    Task { ...
    do {
    try await AuthService.shared.logIn(...)
    ...

    View Slide

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

    View Slide

  90. loginButtonPressed
    @IBAction private func loginButtonPressed(
    sender: UIButton) {
    Task { ...
    do { ... } catch {
    logger.warning("\(error)")
    ...

    View Slide

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

    View Slide

  92. 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)
    }

    View Slide

  93. ViewModel Λಋೖ

    View Slide

  94. UIKit Λར༻͢ΔίʔυͱͦΕҎ֎Λ෼཭

    View Slide

  95. View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  99. !
    ViewModel Λ MainActor Ͱอޢ͢Δ

    View Slide

  100. LoginViewModel
    import Combine
    @MainActor
    final class LoginViewModel: ObservableObject {
    ...
    func loginButtonPressed() async {
    ...
    }
    }

    View Slide

  101. LoginViewController.loginButtonPressed
    // LoginViewController
    @IBAction private func loginButtonPressed(...) {
    Task {
    loginButton.isEnabled = false
    defer { loginButton.isEnabled = true }
    do {
    ...
    } catch {
    ...
    }
    }
    }

    View Slide

  102. LoginViewController.loginButtonPressed
    // LoginViewController
    @IBAction private func loginButtonPressed(...) {
    Task {
    }
    }

    View Slide

  103. LoginViewController.loginButtonPressed
    // LoginViewController
    @IBAction private func loginButtonPressed(...) {
    Task {
    await viewModel.loginButtonPressed()
    }
    }

    View Slide

  104. LoginViewModel.loginButtonPressed
    // LoginViewModel
    func loginButtonPressed() async {
    }

    View Slide

  105. LoginViewModel.loginButtonPressed
    // LoginViewModel
    func loginButtonPressed() async {
    isLoginButtonEnabled = false
    defer { isLoginButtonEnabled = true }
    do {
    ...
    } catch {
    ...
    }
    }

    View Slide

  106. !
    Task.init Λ ViewModel Ͱ͸ͳ͘
    View ଆʹॻ͘
    ※ ViewModel ͷςετ͕ॻ͖΍͘͢ͳΔ
    ※ ͨͩ͠ɺ ViewModel ͰΩϟϯηϧΛѻ͍ͮΒ͘ͳΔ

    View Slide

  107. WWDC2021 10019 h*ps:/
    /developer.apple.com/videos/play/wwdc2021/10019/

    View Slide

  108. LoginViewModel.loginButtonPressed
    // LoginViewModel
    func loginButtonPressed() async {
    isLoginButtonEnabled = false
    defer { isLoginButtonEnabled = true }
    ...
    }

    View Slide

  109. 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)

    View Slide

  110. LoginViewModel.loginButtonPressed
    // LoginViewModel
    func loginButtonPressed() async {
    isLoginButtonEnabled = false
    defer { isLoginButtonEnabled = true }
    ...
    }

    View Slide

  111. LoginViewModel.loginButtonPressed
    // LoginViewModel
    func loginButtonPressed() async {
    isLoginButtonEnabled = false
    defer { isLoginButtonEnabled = true }
    do {
    try await AuthService.shared.logIn(...)
    dismiss.send()
    } catch {
    ...
    }
    }

    View Slide

  112. LoginViewModel.dismiss
    @MainActor
    final class LoginViewModel: ObservableObject {
    ...
    let dismiss: PassthroughSubject = .init()
    ...
    }

    View Slide

  113. dismiss ͷ View Ͱͷ࣮ߦ
    // LoginViewController
    override func viewDidLoad() {
    super.viewDidLoad()
    ...
    viewModel.dismiss
    .sink { [weak self] _ in
    self?.parent?.dismiss(animated: true)
    }
    .store(in: &cancellables)

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  117. LoginViewModel.loginErrorMessage
    @MainActor
    final class LoginViewModel: ObservableObject {
    ...
    @Published var loginErrorMessage: LoginErrorMessage?
    ...
    }

    View Slide

  118. LoginErrorMessage
    enum LoginErrorMessage: Hashable {
    case login
    case network
    case server
    case system
    }

    View Slide

  119. Swi$UI
    struct LoginView: View {
    @StateObject private var viewModel:
    LoginViewModel = .init()
    ...
    }

    View Slide

  120. 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)
    ...

    View Slide

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

    View Slide

  122. Swi$UI
    struct LoginView: View {
    ...
    var body: some View {
    VStack(alignment: .center) { ... }
    .onReceive(viewModel.dismiss) { _ in
    dismiss()
    }
    ...
    }
    }

    View Slide

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

    View Slide

  124. DI ͱςετ

    View Slide

  125. LoginViewModel ͷςετ͕ॻ͖ͮΒ͍
    func loginButtonPressed() async {
    isLoginButtonEnabled = false
    defer { isLoginButtonEnabled = true }
    do {
    // αʔόʔʹΞΫηεɺϑΝΠϧ I/O
    try await AuthService.shared.logIn(...)
    dismiss.send()
    } catch {
    ...
    }
    }

    View Slide

  126. ςετ͔Βαʔόʔ΍ϑΝΠϧʹΞΫηεͨ͘͠ͳ͍
    • ωοτϫʔΫ΍αʔόʔͷঢ়ଶͰ݁Ռ͕ෆఆ
    • ੒ޭɾࣦഊέʔεͳͲΛ੍ޚͰ͖ͳ͍
    • ςετͷ౓ʹσʔλΛॻ͖ࠐΈͨ͘ͳ͍

    View Slide

  127. ಠཱͨ͠ίΞϨΠϠʔύλʔϯ
    Core Layer Pa*ern h*ps:/
    /blog.shin1x1.com/entry/independent-core-layer-pa*ern

    View Slide

  128. View Slide

  129. View Slide

  130. View Slide

  131. AuthServiceProtocol
    protocol AuthServiceProtocol {
    static var shared: Self { get }
    func logIn(
    for id: User.ID,
    with password: String
    ) async throws
    ...
    }

    View Slide

  132. AuthService
    actor AuthService: AuthServiceProtocol {
    ...
    }

    View Slide

  133. LoginViewModel
    @MainActor
    final class LoginViewModel:
    ObservableObject {
    ...
    }

    View Slide

  134. LoginViewModel
    @MainActor
    final class LoginViewModel:
    ObservableObject
    where AuthService: AuthServiceProtocol {
    ...
    }

    View Slide

  135. ܕύϥϝʔλΠϯδΣΫγϣϯ

    View Slide

  136. AuthService ͷར༻Օॴͷίʔυ͸มΘΒͳ͍
    func loginButtonPressed() async {
    isLoginButtonEnabled = false
    defer { isLoginButtonEnabled = true }
    do {
    try await AuthService.shared.logIn(...)
    dismiss.send()
    } catch {
    ...
    }
    }

    View Slide

  137. !
    DI Λ׆༻ͯ͠ ViewModel Λ
    UI ΍ωοτϫʔΫ౳͔Β
    ಠཱͤ͞Δ

    View Slide

  138. LoginViewModelTests
    @MainActor
    final class LoginViewModelTests: XCTestCase {
    ...
    }

    View Slide

  139. loginErrorMessage ͷςετ
    func testLoginErrorMessageByLoginError() async {
    let viewModel: LoginViewModel = .init()
    ...
    }

    View Slide

  140. loginErrorMessage ͷςετ
    func testLoginErrorMessageByLoginError() async {
    let viewModel: LoginViewModel = .init()
    await viewModel.loginButtonPressed() // LoginError
    ...
    }

    View Slide

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

    View Slide

  142. !
    async/await Λ࢖͑͹
    ඇಉظϝιουͷςετΛ
    γϯϓϧʹॻ͚Δ
    ※ Task.init Λ ViewModel ଆʹॻ͔ͳ͔͔ͬͨΒͦ͜

    View Slide

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

    View Slide

  144. ςετ༻ͷ AuthService
    private final class AuthService: AuthServiceProtocol {
    static var shared: AuthService = .init()
    private init() {}
    ...
    }

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  150. isLoginButtonEnabled ͷςετ
    func testLoginButtonEnabled() async {
    let viewModel: LoginViewModel = .init()
    ...
    }

    View Slide

  151. isLoginButtonEnabled ͷςετ
    func testLoginButtonEnabled() async {
    let viewModel: LoginViewModel = .init()
    XCTAssertTrue(viewModel.isLoginButtonEnabled)
    ...
    }

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  159. ςετ༻ͷ AuthService
    private final class AuthService: AuthServiceProtocol {
    ...
    var logInContinuation: CheckedContinuationError>?
    func logIn(for id: User.ID, with password: String)
    async throws {
    try await withCheckedThrowingContinuation
    { continuation in
    logInContinuation = continuation
    }
    }
    }

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  163. 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

    View Slide

  164. 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

    View Slide

  165. isLoginButtonEnabled ͷςετ
    func testLoginButtonEnabled() async {
    ...
    await logIn
    XCTAssertTrue(viewModel.isLoginButtonEnabled)
    }

    View Slide

  166. ·ͱΊ
    • async/await, Sendable, Con'nua'on, Task, actor,
    MainActor
    • ViewModel ͷಋೖͱඇಉظॲཧͷςετ

    View Slide

  167. ͝ਗ਼ௌ͋Γ͕ͱ͏͍͟͝·ͨ͠ʂ

    View Slide