Skip to content

koher/login-challenge-slides

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

9 Commits
 
 
 
 
 
 

Repository files navigation

Login Challenge

Yuta Koshizawa @koher


目標

  • コードの見通しを良くする
    • View からロジックを分離
    • 状態遷移をわかりやすく
    • 依存関係をわかりやすく
  • コードの品質を担保する
    • 単体テストの導入

Login ではなく Home について説明します🙏


inline


View からロジックを分離


View からロジックを分離 (Before)

struct HomeView: View {
    @State private var user: User?
    ...
    var body: some View {
        ...
        Text(user?.name ?? "User Name")
        ...
    }
    ...
}

View の中に状態が記述されている。


View からロジックを分離 (Before)

do {
    // API を叩いて User を取得。
    let user = try await UserService.currentUser()

    // 取得した情報を View に反映。
    self.user = user
} catch ... {
    ...
}

画面表示時に View から API を叩いて user を更新している。


View からロジックを分離 (Before)

struct HomeView: View {
    @State private var user: User?
    ...
    var body: some View {
        ...
        Text(user?.name ?? "User Name")
        ...
    }
    ...
}

View からロジックを分離 (After)

struct HomeView: View {
    @StateObject private var state: HomeViewState
    ...
    var body: some View {
        ...
        Text(state.user?.name ?? "User Name")
        ...
    }
    ...
}

ViewModel 的なクラスに状態を保持させる。


View からロジックを分離 (After)

@MainActor
public final class HomeViewState: ObservableObject {
    @Published public var user: User?
    ...
}

ViewModel 的なクラスに状態を保持させる。


View からロジックを分離 (After)

@MainActor
public final class HomeViewState: ObservableObject {
    @Published public private(set) var user: User?
    ...
}

user は内部からしか更新しないので private(set) に。


View からロジックを分離 (After)

public func loadUser() async {
    ...
    do {
        // API を叩いて User を取得。
        let user = try await UserService.currentUser()

        // 取得した情報を View に反映。
        self.user = user
    } catch ... { ... }
}

user をロードするロジックも state 側に記述する。


View からロジックを分離 (After)

@MainActor
public final class HomeViewState: ObservableObject {
    @Published public private(set) var user: User?
    ...
}

user@Published なので、 user が更新されると objectWillChange が発火して View に反映される。


View からロジックを分離 (After)

struct HomeView: View {
    ...
    var body: some View {
        ZStack {
            ...
        }
        .task {
            await state.loadUser()
        }
    }
}

View から stateloadUser を呼び出す。


View からロジックを分離 (After)

// リロードボタン
Button {
    Task {
        await state.loadUser()
    }
} label: { ... }
.disabled(state.isLoadingUser)

View から stateloadUser を呼び出す。


View からロジックを分離 (After)

@MainActor
public final class HomeViewState: ObservableObject {
    ...
    @Published public private(set)
        var isLoadingUser: Bool = false
    ...
}

ロード中かどうかも state 側で制御する。


View からロジックを分離 (After)

public func loadUser() async {
    // 処理が二重に実行されるのを防ぐ。
    if isLoadingUser { return }
    // 処理中はリロードボタン押下を受け付けない。
    isLoadingUser = true
    defer {
        // リロードボタン押下を再度受け付けるように。
        isLoadingUser = false
    }
    ...
}

ロード中かどうかも state 側で制御する。


View からロジックを分離 (After)

@MainActor
public final class HomeViewState: ObservableObject {
    ...
    @Published public
        var presentsNetworkErrorAlert: Bool = false
    ...
}

loadUser のエラーハンドリングでネットワークエラーのアラートを表示する。


View からロジックを分離 (After)

public func loadUser() async {
    ...
    do {
        ...
    } catch let error as NetworkError {
        ...
        presentsNetworkErrorAlert = true
    } ...
}

loadUser のエラーハンドリングでネットワークエラーのアラートを表示する。


状態遷移をわかりやすく


fit


状態遷移をわかりやすく

private enum LoadingUserState {
    case waiting
    case loading
    case failure(Error)
}

各状態を enumcase で表現する。


状態遷移をわかりやすく

private enum LoadingUserState {
    ...
    mutating func startLoading() {
        guard case .waiting = self else {
            assertionFailure()
            return
        }
        self = .loading
    }
}

状態遷移を enum のメソッドで表現する。


状態遷移をわかりやすく

private enum LoadingUserState {
    ...
    mutating func startLoading() { ... }
    mutating func finishLoading() { ... }
    mutating func failToLoad(with error: Error) { ... }
    mutating func clearError() { ... }
}

状態遷移の矢印それぞれに対応したメソッドを実装する。


fit


状態遷移をわかりやすく (Before)

public final class HomeViewState: ObservableObject {
    @Published public private(set)
        var isLoadingUser: Bool = false
    ...
    @Published public
        var presentsNetworkErrorAlert: Bool = false
    @Published public
        var presentsServerErrorAlert: Bool = false
    ...
}

ロードの状態が様々なプロパティに分散して扱われている。


状態遷移をわかりやすく (After)

public final class HomeViewState: ObservableObject {
    @Published private var loadingUserState:
        LoadingUserState = .waiting
    ...
}

loadingUserState で一元的に状態を管理する。内部的な状態なので private にする。


状態遷移をわかりやすく (After)

public final class HomeViewState: ObservableObject {
    ...
    public var isLoadingUser: Bool {
        guard case .loading = loadingUserState else {
            return false
        }
        return true
    }
    ...
}

各プロパティは loadingUserState を使って実装する。


状態遷移をわかりやすく (After)

public final class HomeViewState: ObservableObject {
    ...
    public var presentsNetworkErrorAlert: Bool {
        get {
            guard case .failure(is NetworkError)
                = loadingUserState else { return false }
            return true
        }
        set { loadingUserState.clearError() }
    }
    ...
}

状態遷移をわかりやすく (Before)

public func loadUser() async {
    // 処理が二重に実行されるのを防ぐ。
    if isLoadingUser { return }
    // 処理中はリロードボタン押下を受け付けない。
    isLoadingUser = true
    ...
}

以前は各プロパティを直接変更していた。


状態遷移をわかりやすく (After)

public func loadUser() async {
    // 処理が二重に実行されるのを防ぐ。
    if isLoadingUser { return }
    // 処理中はリロードボタン押下を受け付けない。
    loadingUserState.startLoading()
    ...
}

loadingUserState で一元的に状態遷移を管理する。メソッド経由で状態遷移させるので不正な遷移が起こらない。


依存関係をわかりやすく


fit


依存関係をわかりやすく

ViewModel 的なクラス( HomeViewState など)は View とは別モジュールに切り出したい。

  • 依存関係が最小限に整理されていると、登場人物が少なくコードの見通しが良くなる
  • ロジックを扱うモジュールを分離しておくと、そこだけビルドして高速にテストを実行可能

fit


inline 200%


依存関係をわかりやすく

let package = Package(
    name: "UseCases",
    ...
    dependencies: [
        .package(path: "../Entities"),
        ...
    ],
    ...
)

依存関係を Package.swift に記述する。


fit


依存関係をわかりやすく

HomeViewStateUserServiceAuthService を利用しており、それらは APIServices モジュールが提供している。


fit


依存関係をわかりやすく

UseCases モジュールが APIServices モジュールに依存するのは望ましくない。

  • UseCases が純粋なロジックの世界でなくなる
    • テストしづらい

fit


fit


fit


依存関係逆転の原則

Dependency Inversion Principle (DIP)


fit


fit


依存性の注入

Dependency Injection (DI)


依存関係をわかりやすく (After)

public protocol UserServiceProtocol {
    static func currentUser() async throws -> User
}

UseCases モジュールで UserServiceProtocol を宣言する。


依存関係をわかりやすく (Before)

import APIServices

public final class HomeViewState: ObservableObject {
    public func loadUser() async {
        ...
        let user = try await UserService.currentUser()
        ...
    }
}

元々は APIServices モジュールに依存していた。


依存関係をわかりやすく (After)

public final class HomeViewState<UserService>:
        ObservableObject
        where UserService: UserServiceProtocol {
    public func loadUser() async {
        ...
        let user = try await UserService.currentUser()
        ...
    }
}

HomeViewState は注入された UserService を利用する形に。


依存関係をわかりやすく (Before)

let package = Package(
    name: "UseCases",
    ...
    dependencies: [
        .package(path: "../Entities"),
        .package(path: "../APIServices"),
        ...
    ],
    ...
)

UseCases は元々 APIServices に依存していた。


依存関係をわかりやすく (After)

let package = Package(
    name: "UseCases",
    ...
    dependencies: [
        .package(path: "../Entities"),
        ...
    ],
    ...
)

UseCases の Package.swift から APIServices への依存を除去。


依存関係をわかりやすく (Before)

let package = Package(
    name: "APIServices",
    ...
    dependencies: [
        .package(path: "../Entities"),
    ],
    ...
)

APIServices は元々 Entities だけに依存していた。


依存関係をわかりやすく (After)

let package = Package(
    name: "APIServices",
    ...
    dependencies: [
        .package(path: "../Entities"),
        .package(path: "../UseCases"),
    ],
    ...
)

APIServicesUseCases にも依存させる。


依存関係をわかりやすく (Before)

public enum UserService {
    public static func currentUser()
            async throws -> User {
        ...
    }
}

依存関係をわかりやすく (After)

public enum UserService: UserServiceProtocol {
    public static func currentUser()
            async throws -> User {
        ...
    }
}

UserServiceUserServiceProtocol に適合させる。


fit


fit


単体テストの導入


単体テストの導入

DIP によって UseCases モジュールが純粋なロジックの世界になったので、単体テストを簡単に記述できる。


単体テストの導入

import XCTest
import UseCases
import Entities

@MainActor
final class HomeViewStateTests: XCTestCase {
    ...
}

UseCases のテストモジュールに HomeViewStateTests を追加する。


単体テストの導入

import XCTest
import UseCases
import Entities

@MainActor
final class HomeViewStateTests: XCTestCase {
    func testLoadUser() async { ... }
    ...
}

ここでは loadUser メソッドのテストを取り上げる。


XCTest の構造化

final class FooTests: XCTestCase {
    func testFooSuccess() { ... }
    func testFooFailure() { ... }
}

昔( Xcode 8 まで)はテストケースをフラットに記述していた。


XCTest の構造化

final class FooTests: XCTestCase {
    func testFoo() {
        XCTContext.runActivity(named: "成功") { _ in
            ...
        }
        XCTContext.runActivity(named: "失敗") { _ in
            ...
        }
    }
}

XCTContext.runActivity でテストケースを構造化可に。


XCTest の構造化

final class FooTests: XCTestCase {
    func testFoo() async { // OK
        await bar() // OK
        XCTContext.runActivity(named: "成功") { _ in
            await baz() // NG 😭
        }
    }
}

しかし、 async/await に対応していない。


XCTest の構造化

extension XCTContext {
    @MainActor
    static func runActivityAsync<Result>(named name: String,
            block: @escaping (XCTActivity) async -> Result) async -> Result {
        await withCheckedContinuation { continuation in
            let _: Void = runActivity(named: name, block: { activity in
                Task {
                    let result = await block(activity)
                    continuation.resume(returning: result)
                }
            })
        }
    }
}

仕方がないので自作する。


単体テストの導入

func testLoadUser() async {
    await XCTContext.runActivityAsync(named: "成功") { _ in
        ...
    }

    await XCTContext.runActivityAsync(named: "失敗") { _ in
        ...
    }
}

成功と失敗のケースに分けて testLoadUser を実装していく。


単体テストの導入

func testLoadUser() async {
    await XCTContext.runActivityAsync(named: "成功") { _ in
        let state: HomeViewState<UserService>
            = .init(dismiss: {})
        ...
    }
    ...
}

まずは state を作る。が、この UserService は何者?


単体テストの導入

private enum UserService: UserServiceProtocol {
    static private(set) var currentUserContinuation:
        CheckedContinuation<User, Error>?
    
    static func currentUser() async throws -> User {
        try await withCheckedThrowingContinuation { continuation in
            currentUserContinuation = continuation
        }
    }
}

テスト用の UserService を実装。外から Continuation に任意の結果を与えられる。


単体テストの導入

func testLoadUser() async {
    await XCTContext.runActivityAsync(named: "成功") { _ in
        let state: HomeViewState<UserService> = ...
        
        XCTAssertNil(state.user)
        ...
    }
}

loadUser を呼び出す前は usernil であることを確認する。


単体テストの導入

func testLoadUser() async {
    await XCTContext.runActivityAsync(named: "成功") { _ in
        ...
        async let x: Void = state.loadUser()
        UserService.currentUserContinuation!
            .resume(returning: user)
        await x
        ...
    }
}

loadUser を呼び出し、 Continuation に結果を与える。


単体テストの導入

func testLoadUser() async {
    await XCTContext.runActivityAsync(named: "成功") { _ in
        ...
        async let x: Void = state.loadUser()
        await Task.sleep()
        UserService.currentUserContinuation!
            .resume(returning: user)
        await x
        ...
    }

loadUser 経由で Continuation がセットされるまで要待機。


ワンサイクルだけ待つ Task.sleep

extension Task where Success == Never, Failure == Never {
    static func sleep() async {
        await withCheckedContinuation { continuation in
            Task<Void, Never> {
                continuation.resume()
            }
        }
    }
}

標準では提供されていないので実装する。


単体テストの導入

func testLoadUser() async {
    await XCTContext.runActivityAsync(named: "成功") { _ in
        ...
        await x
        ...
    }
}

単体テストの導入

func testLoadUser() async {
    await XCTContext.runActivityAsync(named: "成功") { _ in
        ...
        await x
        
        XCTAssertEqual(state.user, user)
    }
}

state.userUserService が返した User になっていることを確認する。


単体テストの導入

func testLoadUser() async {
    await XCTContext.runActivityAsync(named: "成功") { _ in
        await XCTContext.runActivityAsync(named:
                "userが更新される") { _ in
            ...
        }
        await XCTContext.runActivityAsync(named:
                "ロード中はisLoadingUserがtrueになる") { _ in
            ...
        }

user だけでなく、 isLoadingUser もテストしたい。


単体テストの導入

await XCTContext.runActivityAsync(
        named: "ロード中はisLoadingUserがtrueになる") { _ in
    let state: HomeViewState<AuthService, UserService>
        = ...
    
    XCTAssertFalse(state.isLoadingUser)
    ...    
}

loadUser を呼び出す前は isLoadingUserfalse であることを確認する。


単体テストの導入

await XCTContext.runActivityAsync(
        named: "ロード中はisLoadingUserがtrueになる") { _ in
    ...
    async let x: Void = state.loadUser()
    await Task.sleep()
    XCTAssertTrue(state.isLoadingUser)
    UserService.currentUserContinuation!
        .resume(returning: user)
    await x
    ...

ロード中は isLoadingUsertrue であることを確認する。


単体テストの導入

await XCTContext.runActivityAsync(
        named: "ロード中はisLoadingUserがtrueになる") { _ in
    ...
    UserService.currentUserContinuation!
        .resume(returning: user)
    await x
    
    XCTAssertFalse(state.isLoadingUser)
}

ロードが完了すると isLoadingUserfalse に戻る。


目標

  • コードの見通しを良くする
    • View からロジックを分離
    • 状態遷移をわかりやすく
    • 依存関係をわかりやすく
  • コードの品質を担保する
    • 単体テストの導入

まとめ

Swift Concurrency & SwiftUI で、標準的な道具をだけを使って次の目標を実現した。

  • コードの見通しを良くする
  • コードの品質を担保する

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published