Trang chủ

Thay các singleton bằng environment

Một ứng dụng tôi từng làm có mười hai singleton. AnalyticsManager.shared, NetworkClient.shared, UserDefaults.standard được bọc trong SettingsManager.shared, và cứ thế xuống hết danh sách.

Mỗi cái đều là lựa chọn hiển nhiên vào lúc đó, và gộp lại chúng khiến khoảng một nửa codebase không thể kiểm thử được nếu không khởi chạy cả ứng dụng.

Thật ra chúng sai ở đâu

Không phải chuyện trạng thái toàn cục như một tội lỗi trừu tượng. Ba vấn đề cụ thể:

Các phụ thuộc là vô hình. Chữ ký của một view chẳng nói gì về những thứ nó động tới. Bạn không thể biết từ ProfileView(user: user) rằng nó còn gọi cả analytics, network client và kho cài đặt — bạn phát hiện ra khi bài test sập.

Bạn không thay thế chúng được. Kiểm thử một view gọi AnalyticsManager.shared.track() nghĩa là hoặc gửi dữ liệu phân tích thật từ một bài test, hoặc thò tay vào singleton để tráo thứ gì đó ra, mà đó lại là trạng thái khả biến dùng chung giữa các bài test.

Thứ tự khởi tạo là ngầm định. Mười hai singleton khởi tạo lười, vài cái trong đó động tới nhau, sinh ra một thứ tự chạy được nhờ tình cờ và vỡ khi có ai đó thêm cái thứ mười ba.

Thứ thay thế

Một khóa environment cho mỗi phụ thuộc, với một giá trị mặc định vô hại:

extension EnvironmentValues {
    @Entry var analytics: Analytics = .noOp
    @Entry var api: APIClient = .live
}

Tiêm một lần ở gốc:

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.analytics, LiveAnalytics())
                .environment(\.api, APIClient.live)
        }
    }
}

Đọc ở nơi cần:

struct ProfileView: View {
    @Environment(\.analytics) private var analytics

    var body: some View {
        ProfileContent()
            .onAppear { analytics.track(.profileViewed) }
    }
}

Và trong một bài test hay một preview, thay thế nó:

#Preview {
    ProfileView()
        .environment(\.analytics, .noOp)
        .environment(\.api, .stub(profile: .sample))
}

Mẹo

Hãy làm cho giá trị mặc định không làm gì cả, đừng bao giờ để nó là fatalError. Đó là thứ preview nhận được, thứ test nhận được, và thứ chạy nếu ai đó quên tiêm. Một mặc định gây sập biến mọi preview mới thành một câu đố, mà mục đích của tất cả chuyện này là làm cho trường hợp thay thế trở nên dễ dàng.

Protocol hay struct chứa closure

Hai cách làm cho một phụ thuộc thay thế được. Tôi đã dùng cả hai và giờ thích cái thứ hai hơn.

Một protocol là hình dạng quen thuộc:

protocol Analytics {
    func track(_ event: Event)
}

Nó cần một existential (any Analytics) trong environment, và một kiểu tuân thủ đầy đủ cho mỗi bản giả lập.

Một struct chứa closure thì nhẹ hơn:

struct Analytics {
    var track: (Event) -> Void

    static let noOp = Analytics(track: { _ in })
    static let live = Analytics(track: { LiveClient.send($0) })
}

// trong một bài test
var recorded: [Event] = []
let spy = Analytics(track: { recorded.append($0) })

Không protocol, không kiểu tuân thủ, không existential — và một bản do thám viết gọn một dòng ngay tại chỗ dùng. Với những phụ thuộc chỉ có dăm ba thao tác, cách này ít thủ tục hơn hẳn, và đó là hình dạng tôi lấy làm mặc định bây giờ.

Hai cái tôi giữ lại

Tôi muốn thành thật rằng câu trả lời không phải là “không singleton nào cả”.

UserDefaults.standard ở lại. Nó là một singleton của hệ thống, nó vốn đã thay thế được qua UserDefaults(suiteName:), và bọc nó lại chỉ thêm một tầng mà không thêm một năng lực nào.

Cái cache ảnh ở lại, vì theo thiết kế thì nó thật sự là một cache dùng chung — hai thực thể sẽ phá hỏng chính mục đích của nó — và nó chẳng có hành vi nào đáng phải giả lập trong một bài test.

Sự phân biệt còn trụ lại: một singleton thì ổn khi thật sự chỉ có thể có một cái, và nó là vấn đề khi nó chỉ là một biến toàn cục tình cờ tiện tay. Analytics, mạng và cài đặt đều là những thứ bạn muốn có một phiên bản thứ hai, giả. Một cái cache thì không.

Nó tốn gì

Gõ nhiều hơn ở gốc. Bốn dòng .environment trong điểm vào ứng dụng thay vì không dòng nào.

Preview cần được tiêm. Mọi preview của một view nằm dưới điểm tiêm đều cần cả các phụ thuộc. Đây là cái giá thật, và các giá trị mặc định không làm gì cả thì phần lớn đã lo được.

Một cú sập lúc chạy thay vì một lỗi biên dịch nếu một đối tượng @Environment(SomeType.self) không được tiêm. Các giá trị environment theo key path có giá trị mặc định thì tránh được điều này, và đó là thêm một lý do để ưu tiên chúng.

Nó đem lại gì

Thứ tôi không ngờ tới: các phụ thuộc của một view trở nên nhìn thấy được ngay trong file. Ba dòng @Environment ở đầu một view cho bạn biết chính xác nó động tới những gì, thứ mà một lời gọi .shared chôn ở dòng hai trăm chẳng bao giờ làm được.

Điều đó hóa ra còn giá trị hơn cả khả năng kiểm thử, vì nó khiến cả một lớp vấn đề thiết kế trở nên lộ liễu. Một view đọc năm phụ thuộc là một view làm quá nhiều việc, và điều đó vốn vô hình khi mọi phụ thuộc đều là biến toàn cục.