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 full-size slide

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

    View full-size slide

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

    View full-size slide

  4. ViewController ʹϕλॻ͖

    ViewModel Λಋೖ

    DI ͱςετ

    View full-size slide

  5. ViewController ʹϕλॻ͖

    View full-size slide

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

    View full-size slide

  7. 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  14. 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 full-size slide

  15. 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 full-size slide

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

    View full-size slide

  17. 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 full-size slide

  18. 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 full-size slide

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

    View full-size slide

  20. 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  26. 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 full-size slide

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

    View full-size slide

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

    View full-size slide

  29. ͢ͰʹίʔϧόοΫ൛͕͋Δ৔߹
    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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  33. 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 full-size slide

  34. 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 full-size slide

  35. 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 full-size slide

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

    View full-size 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  48. 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  62. 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 full-size slide

  63. 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 full-size slide

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

    View full-size slide

  65. 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 full-size slide

  66. 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 full-size slide

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

    View full-size slide

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

    View full-size slide

  69. 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 full-size slide

  70. 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  75. 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 full-size slide

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

    View full-size slide

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

    View full-size slide

  78. 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 full-size slide

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

    View full-size slide

  80. 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 full-size slide

  81. 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 full-size slide

  82. 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  87. 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 full-size slide

  88. 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 full-size slide

  89. ViewModel Λಋೖ

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  94. !
    ViewModel Λ MainActor Ͱอޢ͢Δ

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  104. 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  115. 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  123. AuthService
    actor AuthService: AuthServiceProtocol {
    ...
    }

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  147. 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 full-size slide

  148. 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 full-size slide

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

    View full-size slide

  150. ςετ༻ͷ 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide

  154. 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 full-size slide

  155. 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 full-size slide

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

    View full-size slide

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

    View full-size slide

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

    View full-size slide