Представьте, что вы строите дом. У вас есть электрик, сантехник, маляр — каждый специалист отвечает за свою часть работы. В программировании похожая ситуация: у нас есть разные сервисы (службы), каждый из которых выполняет свою задачу. Сегодня расскажу, как удобно организовать работу с такими сервисами в iOS-приложении на SwiftUI.

Проблема: запутанные связи

Когда приложение растёт, в нём появляется всё больше компонентов, которые должны друг с другом взаимодействовать. Например:

  • Сервис для работы с сетью
  • Сервис для сохранения данных
  • Сервис аналитики
  • И так далее…

Без правильной организации код быстро превращается в спагетти, где всё зависит от всего.

Решение: ServiceLocator

ServiceLocator — это как справочная служба в большом офисном центре. Вместо того чтобы бегать по этажам в поисках нужного специалиста, вы просто обращаетесь в справочную: «Мне нужен бухгалтер» — и вам сразу говорят, где его найти.

Вот как это выглядит в коде:

final class ServiceLocator {
    // Singleton instance for the entire app
    static let shared = ServiceLocator()
    
    // Dictionary to store all services
    private lazy var services = [String: Any]()
    
    // Register a new service
    func addService<T>(service: T) {
        let key = String(describing: T.self)
        services[key] = service
    }
    
    // Get a service
    func getService<T>() -> T {
        let key = String(describing: T.self)
        guard let service = services[key] as? T else {
            fatalError("Service of type \(T.self) is not registered!")
        }
        return service
    }
}

Магия @propertyWrapper

Но постоянно писать ServiceLocator.shared.getService() — утомительно. Здесь на помощь приходит возможность Swift создавать собственные «обёртки» для свойств. Это как автоматический дозатор мыла — вы просто подносите руки, а он сам выдаёт нужное количество.

@propertyWrapper
struct Injected<Service> {
    private lazy var service: Service = ServiceLocator.shared.getService()
    
    var wrappedValue: Service {
        mutating get { service }
    }

    public var projectedValue: Injected<Service> {
        get { self }
        set { self = newValue }
    }
}

Важность протоколов: контракт вместо реализации

Представьте, что вы нанимаете водителя. Вам важно, что он умеет водить машину, а не то, какой марки у него права. В программировании протоколы работают так же — они описывают, ЧТО должен уметь делать сервис, а не КАК он это делает.

Почему это важно?

Допустим, вы создали приложение, которое сохраняет данные в iCloud. Но потом решили, что хотите сохранять их локально. Если вы использовали протоколы, то замена будет простой как щелчок выключателя.

Вот пример:

// Protocol describes WHAT the storage service should do
protocol StorageServiceProtocol {
    func save(data: String, key: String)
    func load(key: String) -> String?
    func delete(key: String)
}

// Implementation for iCloud
class CloudStorageService: StorageServiceProtocol {
    func save(data: String, key: String) {
        print("Saving to iCloud: \(data)")
        // Code to work with iCloud
    }
    
    func load(key: String) -> String? {
        print("Loading from iCloud")
        return "data from cloud"
    }
    
    func delete(key: String) {
        print("Deleting from iCloud")
    }
}

// Implementation for local storage
class LocalStorageService: StorageServiceProtocol {
    func save(data: String, key: String) {
        print("Saving locally: \(data)")
        // Code to work with UserDefaults
    }
    
    func load(key: String) -> String? {
        print("Loading locally")
        return "local data"
    }
    
    func delete(key: String) {
        print("Deleting locally")
    }
}

Магия замены

Теперь смотрите, как просто поменять реализацию:

// Choose implementation when app starts
if userHasInternet {
    ServiceLocator.shared.addService(service: CloudStorageService() as StorageServiceProtocol)
} else {
    ServiceLocator.shared.addService(service: LocalStorageService() as StorageServiceProtocol)
}

// No need to change anything in the app code!
struct SettingsView: View {
    @Injected private var storage: StorageServiceProtocol
    @State private var userName = ""
    
    var body: some View {
        VStack {
            TextField("Your name", text: $userName)
            
            Button("Save") {
                storage.save(data: userName, key: "user_name")
            }
        }
        .onAppear {
            userName = storage.load(key: "user_name") ?? ""
        }
    }
}

Как это работает на практике в SwiftUI

Шаг 1: Создаём протокол и реализацию

// Protocol for network operations
protocol NetworkServiceProtocol {
    func loadUserData() async throws -> UserData
    func sendAnalytics(event: String)
}

// Real implementation
class NetworkService: NetworkServiceProtocol {
    func loadUserData() async throws -> UserData {
        // API call code here
        return UserData(name: "Artem", email: "ar@bolotov.dev")
    }
    
    func sendAnalytics(event: String) {
        print("Sending analytics: \(event)")
    }
}

// Mock implementation for development
class MockNetworkService: NetworkServiceProtocol {
    func loadUserData() async throws -> UserData {
        // Return test data without server call
        return UserData(name: "Test User", email: "test@example.com")
    }
    
    func sendAnalytics(event: String) {
        print("TEST: \(event)")
    }
}

// Data model
struct UserData {
    let name: String
    let email: String
}

Шаг 2: Регистрируем сервисы в точке входа

@main
struct MyApp: App {
    init() {
        setupServices()
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
    
    private func setupServices() {
        #if DEBUG
        // Use mock services in development
        ServiceLocator.shared.addService(service: MockNetworkService() as NetworkServiceProtocol)
        #else
        // Use real services in production
        ServiceLocator.shared.addService(service: NetworkService() as NetworkServiceProtocol)
        #endif
        
        // Register other services
        ServiceLocator.shared.addService(service: AnalyticsService() as AnalyticsServiceProtocol)
        ServiceLocator.shared.addService(service: KeychainService() as KeychainServiceProtocol)
    }
}

Шаг 3: Используем в SwiftUI Views

struct ProfileView: View {
    @Injected private var network: NetworkServiceProtocol
    @Injected private var analytics: AnalyticsServiceProtocol
    
    @State private var userData: UserData?
    @State private var isLoading = false
    @State private var errorMessage: String?
    
    var body: some View {
        NavigationView {
            VStack {
                if isLoading {
                    ProgressView()
                        .padding()
                } else if let userData = userData {
                    VStack(spacing: 20) {
                        Text("Hello, \(userData.name)!")
                            .font(.title)
                        
                        Text(userData.email)
                            .foregroundColor(.secondary)
                    }
                    .padding()
                } else if let error = errorMessage {
                    Text(error)
                        .foregroundColor(.red)
                        .padding()
                }
                
                Button("Reload") {
                    Task {
                        await loadUserData()
                    }
                }
                .buttonStyle(.borderedProminent)
            }
            .navigationTitle("Profile")
            .onAppear {
                analytics.trackEvent("ProfileView opened")
                Task {
                    await loadUserData()
                }
            }
        }
    }
    
    private func loadUserData() async {
        isLoading = true
        errorMessage = nil
        
        do {
            userData = try await network.loadUserData()
            analytics.trackEvent("User data loaded")
        } catch {
            errorMessage = "Failed to load data: \(error.localizedDescription)"
            analytics.trackEvent("User data load failed")
        }
        
        isLoading = false
    }
}

Продвинутый пример: ObservableObject с сервисами

Часто в SwiftUI нужно использовать сервисы внутри ObservableObject:

// Protocol for authentication
protocol AuthServiceProtocol {
    func login(email: String, password: String) async throws -> User
    func logout()
    var isAuthenticated: Bool { get }
}

// ViewModel using services
class AuthViewModel: ObservableObject {
    @Injected private var authService: AuthServiceProtocol
    @Injected private var analytics: AnalyticsServiceProtocol
    
    @Published var isLoading = false
    @Published var errorMessage: String?
    @Published var isAuthenticated = false
    
    func login(email: String, password: String) async {
        await MainActor.run {
            isLoading = true
            errorMessage = nil
        }
        
        do {
            let user = try await authService.login(email: email, password: password)
            analytics.trackEvent("Login successful", parameters: ["user_id": user.id])
            
            await MainActor.run {
                isAuthenticated = true
                isLoading = false
            }
        } catch {
            analytics.trackEvent("Login failed")
            
            await MainActor.run {
                errorMessage = error.localizedDescription
                isLoading = false
            }
        }
    }
    
    func logout() {
        authService.logout()
        analytics.trackEvent("User logged out")
        isAuthenticated = false
    }
}

// SwiftUI View using ViewModel
struct LoginView: View {
    @StateObject private var viewModel = AuthViewModel()
    @State private var email = ""
    @State private var password = ""
    
    var body: some View {
        Form {
            TextField("Email", text: $email)
                .textContentType(.emailAddress)
                .autocapitalization(.none)
            
            SecureField("Password", text: $password)
                .textContentType(.password)
            
            if let error = viewModel.errorMessage {
                Text(error)
                    .foregroundColor(.red)
                    .font(.caption)
            }
            
            Button(action: {
                Task {
                    await viewModel.login(email: email, password: password)
                }
            }) {
                if viewModel.isLoading {
                    ProgressView()
                } else {
                    Text("Login")
                }
            }
            .disabled(viewModel.isLoading || email.isEmpty || password.isEmpty)
        }
        .navigationTitle("Login")
    }
}

Практический пример: переключение окружений

// Protocol for configuration
protocol ConfigServiceProtocol {
    var apiBaseURL: String { get }
    var environment: Environment { get }
}

enum Environment {
    case development
    case staging
    case production
}

// Different implementations
class DevConfigService: ConfigServiceProtocol {
    var apiBaseURL = "https://dev-api.example.com"
    var environment = Environment.development
}

class ProdConfigService: ConfigServiceProtocol {
    var apiBaseURL = "https://api.example.com"
    var environment = Environment.production
}

// Settings view to switch environments
struct SettingsView: View {
    @Injected private var config: ConfigServiceProtocol
    
    var body: some View {
        List {
            Section("Environment") {
                Label(config.environment.rawValue, systemImage: "server.rack")
                Text(config.apiBaseURL)
                    .font(.caption)
                    .foregroundColor(.secondary)
            }
        }
        .navigationTitle("Settings")
    }
}

Советы для SwiftUI разработчиков

1. Используйте @Injected в View напрямую

struct ContentView: View {
    @Injected private var network: NetworkServiceProtocol
    
    var body: some View {
        // Use service directly in view
    }
}

2. Для StateObject создайте фабричный метод

extension ServiceLocator {
    func makeAuthViewModel() -> AuthViewModel {
        return AuthViewModel()
    }
}

struct RootView: View {
    @StateObject private var authViewModel = ServiceLocator.shared.makeAuthViewModel()
    
    var body: some View {
        // Your view code
    }
}

3. Группируйте регистрацию по смыслу

extension ServiceLocator {
    func registerNetworkServices() {
        addService(service: APIService() as APIServiceProtocol)
        addService(service: ImageLoader() as ImageLoaderProtocol)
    }
    
    func registerStorageServices() {
        addService(service: KeychainService() as KeychainServiceProtocol)
        addService(service: UserDefaultsService() as UserDefaultsServiceProtocol)
    }
}

Заключение

ServiceLocator с @Injected и протоколами отлично работает в SwiftUI приложениях. Вы получаете простоту использования, чистый код и возможность легко менять реализацию сервисов.

Помните: протокол — это контракт между частями вашего приложения. Он говорит «что делать», а реализация решает «как делать». Это даёт свободу менять «как» без изменения «что».

Начните использовать этот подход в своих SwiftUI проектах — и вы удивитесь, насколько проще станет организовывать код и добавлять новые функции!