Combine Framework: Пълно ръководство за реактивно програмиране в Swift

Пълно ръководство за Combine в Swift — Publishers, Subscribers, Operators, мрежови заявки с URLSession, интеграция със SwiftUI и сравнение с async/await. С практически примери за реални проекти.

Въведение в Combine Framework

Combine е рамката на Apple за функционално реактивно програмиране (FRP), представена за първи път на WWDC 2019. Честно казано, когато за първи път видях какво може да прави, бях впечатлен — декларативен подход за обработка на асинхронни събития, който ви позволява да създадете единна верига за обработка на данни вместо куп делегатни методи, затваряния (closures) и обратни извиквания (callbacks).

И в ерата на Swift 6 и iOS 26, Combine си остава неразделна част от екосистемата на Apple, работейки ръка за ръка с async/await и SwiftUI.

В това ръководство ще разгледаме всичко — от основните градивни блокове като Publishers, Subscribers и Operators, през практическо приложение за мрежови заявки, интеграция със SwiftUI, до напреднали шаблони и сравнение с модерната Swift Concurrency. Ако вече сте прочели нашето ръководство за Swift Concurrency, тази статия ще ви покаже кога Combine е по-подходящият избор и как двата подхода могат да работят заедно.

Основни градивни блокове

Publishers — Издатели на данни

Publisher е протокол, който описва тип, способен да излъчва поредица от стойности във времето. Всеки Publisher дефинира два асоциирани типа: Output (типът на излъчваните стойности) и Failure (типът на грешката, или Never ако издателят не може да се провали). Звучи формално, но в практиката е доста интуитивно.

// Основен Publisher протокол
protocol Publisher {
    associatedtype Output
    associatedtype Failure: Error
    
    func receive<S: Subscriber>(subscriber: S) 
        where S.Input == Output, S.Failure == Failure
}

// Примери за вградени Publishers
let justPublisher = Just(42)  // Излъчва единична стойност
let arrayPublisher = [1, 2, 3, 4, 5].publisher  // Излъчва всеки елемент
let notificationPublisher = NotificationCenter.default
    .publisher(for: UIApplication.didBecomeActiveNotification)

Apple предоставя доста вградени издатели. Just излъчва единична стойност и веднага завършва. Future създава издател, който ще излъчи точно една стойност в бъдещето. Empty завършва веднага без да излъчва нищо. А Fail — ами, незабавно завършва с грешка (полезен е при тестване, повярвайте ми).

Колекциите в Swift също имат свойството .publisher, което ги превръща в издатели.

Subscribers — Абонати за данни

Subscriber е протоколът, който получава стойностите от Publisher. Combine предоставя два основни вградени абоната: sink и assign.

import Combine

var cancellables = Set<AnyCancellable>()

// sink — получаване на стойности чрез затваряне
[1, 2, 3, 4, 5].publisher
    .sink(
        receiveCompletion: { completion in
            switch completion {
            case .finished:
                print("Завършено успешно")
            case .failure(let error):
                print("Грешка: \(error)")
            }
        },
        receiveValue: { value in
            print("Получена стойност: \(value)")
        }
    )
    .store(in: &cancellables)

// assign — директно присвояване на стойност към свойство
class UserProfile {
    var displayName: String = ""
}

let profile = UserProfile()
Just("Иван Петров")
    .assign(to: \.displayName, on: profile)
    .store(in: &cancellables)
// profile.displayName е вече "Иван Петров"

Управление на паметта с AnyCancellable

Ето нещо, което трябва да запомните добре — правилното управление на абонаментите е критично. Когато sink или assign създадат абонамент, те връщат обект от тип AnyCancellable. Ако този обект бъде деалокиран, абонаментът се отменя автоматично.

Затова е толкова важно да съхранявате абонаментите в Set<AnyCancellable>. Пропуснете ли го — ще се чудите защо нищо не работи.

class DataManager {
    private var cancellables = Set<AnyCancellable>()
    
    func startListening() {
        NotificationCenter.default
            .publisher(for: .NSCalendarDayChanged)
            .sink { _ in
                print("Нов ден!")
            }
            .store(in: &cancellables)
        // Абонаментът живее докато DataManager съществува
    }
    
    deinit {
        // cancellables се освобождават автоматично
        // и всички абонаменти се отменят
    }
}

Оператори — Трансформация на потоци от данни

И тук идва интересното. Операторите са сърцето на Combine — методи, които приемат Publisher като вход и връщат нов Publisher с трансформирани данни. Могат да се свързват във вериги и точно това ги прави толкова мощни.

Оператори за трансформация

// map — трансформиране на стойности
[1, 2, 3, 4, 5].publisher
    .map { $0 * 2 }
    .sink { print($0) }  // 2, 4, 6, 8, 10
    .store(in: &cancellables)

// compactMap — трансформация с филтриране на nil
["1", "две", "3", "четири", "5"].publisher
    .compactMap { Int($0) }
    .sink { print($0) }  // 1, 3, 5
    .store(in: &cancellables)

// flatMap — трансформация в нов Publisher
func fetchUser(id: Int) -> AnyPublisher<String, Never> {
    Just("Потребител_\(id)")
        .delay(for: .seconds(1), scheduler: RunLoop.main)
        .eraseToAnyPublisher()
}

[1, 2, 3].publisher
    .flatMap { id in
        fetchUser(id: id)
    }
    .sink { print($0) }
    .store(in: &cancellables)

// scan — акумулиране на стойности (подобно на reduce)
[1, 2, 3, 4, 5].publisher
    .scan(0) { accumulator, value in
        accumulator + value
    }
    .sink { print($0) }  // 1, 3, 6, 10, 15
    .store(in: &cancellables)

Оператори за филтриране

Филтриращите оператори са точно това, което звучат — помагат ви да контролирате кои стойности да преминат и кога.

// filter — пропускане само на стойности, отговарящи на условие
(1...20).publisher
    .filter { $0.isMultiple(of: 3) }
    .sink { print($0) }  // 3, 6, 9, 12, 15, 18
    .store(in: &cancellables)

// removeDuplicates — премахване на последователни дубликати
[1, 1, 2, 2, 3, 1, 1].publisher
    .removeDuplicates()
    .sink { print($0) }  // 1, 2, 3, 1
    .store(in: &cancellables)

// debounce — изчакване на пауза преди излъчване
let searchSubject = PassthroughSubject<String, Never>()

searchSubject
    .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
    .removeDuplicates()
    .sink { query in
        print("Търсене за: \(query)")
    }
    .store(in: &cancellables)

// throttle — ограничаване на честотата на излъчване
searchSubject
    .throttle(for: .seconds(1), scheduler: RunLoop.main, latest: true)
    .sink { print("Throttled: \($0)") }
    .store(in: &cancellables)

Оператори за комбиниране

Тези оператори са вероятно любимата ми част от Combine. Позволяват ви да съберете няколко потока в един — нещо, което е изненадващо трудно с обикновени callbacks.

// combineLatest — комбиниране на последните стойности от два издателя
let temperature = PassthroughSubject<Double, Never>()
let humidity = PassthroughSubject<Double, Never>()

temperature.combineLatest(humidity)
    .map { temp, hum in
        "Температура: \(temp)°C, Влажност: \(hum)%"
    }
    .sink { print($0) }
    .store(in: &cancellables)

temperature.send(22.5)
humidity.send(65.0)
// "Температура: 22.5°C, Влажност: 65.0%"

// zip — синхронно комбиниране (изчаква и двете стойности)
let names = PassthroughSubject<String, Never>()
let ages = PassthroughSubject<Int, Never>()

names.zip(ages)
    .map { "\($0) е на \($1) години" }
    .sink { print($0) }
    .store(in: &cancellables)

names.send("Мария")
ages.send(28)
// "Мария е на 28 години"

// merge — сливане на издатели от един и същи тип
let localNotifications = PassthroughSubject<String, Never>()
let pushNotifications = PassthroughSubject<String, Never>()

localNotifications.merge(with: pushNotifications)
    .sink { print("Известие: \($0)") }
    .store(in: &cancellables)

Subjects — Мостът между императивен и реактивен код

Subjects са специални типове, които са едновременно Publisher и Subscriber. Накратко, те ви позволяват да „инжектирате" стойности в Combine поток от обикновен императивен код. Има два основни вида и всеки си има своето предназначение.

PassthroughSubject

PassthroughSubject не съхранява последната си стойност — просто предава каквото получи на абонатите си в момента. Ако няма абонати когато изпратите стойност, тя просто се губи.

let eventBus = PassthroughSubject<AppEvent, Never>()

enum AppEvent {
    case userLoggedIn(userId: String)
    case userLoggedOut
    case dataRefreshed
    case errorOccurred(Error)
}

// Абониране за събития
eventBus
    .filter { event in
        if case .userLoggedIn = event { return true }
        return false
    }
    .sink { event in
        print("Потребител влезе: \(event)")
    }
    .store(in: &cancellables)

// Изпращане на събития от произволно място в кода
eventBus.send(.userLoggedIn(userId: "user_123"))
eventBus.send(.dataRefreshed)

CurrentValueSubject

CurrentValueSubject пък е различна история — той съхранява последната си стойност и я предоставя на нови абонати веднага при абониране. Много удобно за неща като статус на връзка или текущо състояние.

let connectionStatus = CurrentValueSubject<ConnectionState, Never>(.disconnected)

enum ConnectionState: String {
    case connected = "Свързан"
    case connecting = "Свързване..."
    case disconnected = "Изключен"
}

// Нов абонат веднага получава текущата стойност
connectionStatus
    .sink { state in
        print("Състояние: \(state.rawValue)")
    }
    .store(in: &cancellables)
// Веднага принтира: "Състояние: Изключен"

// Промяна на състоянието
connectionStatus.send(.connecting)
// Принтира: "Състояние: Свързване..."
connectionStatus.send(.connected)
// Принтира: "Състояние: Свързан"

// Достъп до текущата стойност по всяко време
print(connectionStatus.value)  // .connected

Мрежови заявки с Combine и URLSession

Едно от най-честите (и, по мое мнение, най-елегантните) приложения на Combine е за мрежови заявки. URLSession предоставя вграден dataTaskPublisher, който се интегрира безпроблемно с Combine конвейерите. Нека разгледаме как.

Основна GET заявка

struct Article: Codable, Identifiable {
    let id: Int
    let title: String
    let body: String
}

class ArticleService {
    private var cancellables = Set<AnyCancellable>()
    
    func fetchArticles() -> AnyPublisher<[Article], Error> {
        let url = URL(string: "https://api.example.com/articles")!
        
        return URLSession.shared.dataTaskPublisher(for: url)
            .map(\.data)                          // Извличаме само данните
            .decode(type: [Article].self, 
                    decoder: JSONDecoder())       // Декодираме JSON
            .receive(on: DispatchQueue.main)       // Превключваме на главната нишка
            .eraseToAnyPublisher()                 // Скриваме конкретния тип
    }
}

Обработка на грешки при мрежови заявки

В реалния свят нещата не вървят винаги по план. Ето как да се справите с грешките по елегантен начин:

enum NetworkError: LocalizedError {
    case invalidResponse
    case serverError(statusCode: Int)
    case decodingFailed
    
    var errorDescription: String? {
        switch self {
        case .invalidResponse:
            return "Невалиден отговор от сървъра"
        case .serverError(let code):
            return "Сървърна грешка: \(code)"
        case .decodingFailed:
            return "Грешка при декодиране на данните"
        }
    }
}

func fetchArticles() -> AnyPublisher<[Article], NetworkError> {
    let url = URL(string: "https://api.example.com/articles")!
    
    return URLSession.shared.dataTaskPublisher(for: url)
        .tryMap { data, response in
            guard let httpResponse = response as? HTTPURLResponse else {
                throw NetworkError.invalidResponse
            }
            guard (200...299).contains(httpResponse.statusCode) else {
                throw NetworkError.serverError(
                    statusCode: httpResponse.statusCode
                )
            }
            return data
        }
        .decode(type: [Article].self, decoder: JSONDecoder())
        .mapError { error in
            if error is DecodingError {
                return NetworkError.decodingFailed
            }
            return error as? NetworkError ?? NetworkError.invalidResponse
        }
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
}

Верижни заявки и паралелно изпълнение

Ето нещо наистина готино — с Combine можете лесно да правите паралелни заявки и да комбинирате резултатите. Опитайте да го направите толкова чисто с обикновени callbacks!

struct User: Codable {
    let id: Int
    let name: String
}

struct Post: Codable {
    let id: Int
    let userId: Int
    let title: String
}

class ProfileService {
    private var cancellables = Set<AnyCancellable>()
    
    // Верижни заявки с flatMap
    func fetchUserPosts(userId: Int) -> AnyPublisher<(User, [Post]), Error> {
        let userURL = URL(string: "https://api.example.com/users/\(userId)")!
        let postsURL = URL(string: "https://api.example.com/users/\(userId)/posts")!
        
        let userPublisher = URLSession.shared.dataTaskPublisher(for: userURL)
            .map(\.data)
            .decode(type: User.self, decoder: JSONDecoder())
        
        let postsPublisher = URLSession.shared.dataTaskPublisher(for: postsURL)
            .map(\.data)
            .decode(type: [Post].self, decoder: JSONDecoder())
        
        // Паралелно изпълнение с zip
        return userPublisher.zip(postsPublisher)
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
    
    // Retry при неуспех
    func fetchWithRetry(url: URL) -> AnyPublisher<Data, Error> {
        URLSession.shared.dataTaskPublisher(for: url)
            .map(\.data)
            .retry(3)                    // Опитваме до 3 пъти
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
}

Интеграция на Combine със SwiftUI

Combine и SwiftUI буквално са създадени да работят заедно. Макар че с iOS 17 Apple представи @Observable макроса като по-опростена алтернатива, ObservableObject с @Published остава широко използван — особено когато имате нужда от пълната мощ на Combine операторите.

ViewModel с @Published и Combine

Ето един реалистичен пример за ViewModel за търсене. Обърнете внимание как debounce и removeDuplicates се вписват естествено:

import SwiftUI
import Combine

class SearchViewModel: ObservableObject {
    @Published var searchText = ""
    @Published var results: [Article] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    
    private var cancellables = Set<AnyCancellable>()
    private let articleService = ArticleService()
    
    init() {
        setupSearchPipeline()
    }
    
    private func setupSearchPipeline() {
        $searchText                           // Publisher от @Published
            .debounce(for: .milliseconds(300), 
                      scheduler: RunLoop.main)  // Изчакваме пауза
            .removeDuplicates()                 // Игнорираме повторения
            .filter { !$0.isEmpty }             // Игнорираме празен текст
            .handleEvents(receiveOutput: { [weak self] _ in
                self?.isLoading = true
                self?.errorMessage = nil
            })
            .flatMap { [weak self] query -> AnyPublisher<[Article], Never> in
                guard let self = self else {
                    return Just([]).eraseToAnyPublisher()
                }
                return self.articleService
                    .search(query: query)
                    .catch { error -> Just<[Article]> in
                        DispatchQueue.main.async {
                            self.errorMessage = error.localizedDescription
                        }
                        return Just([])
                    }
                    .eraseToAnyPublisher()
            }
            .receive(on: DispatchQueue.main)
            .sink { [weak self] articles in
                self?.results = articles
                self?.isLoading = false
            }
            .store(in: &cancellables)
    }
}

SwiftUI изглед, свързан с ViewModel

struct SearchView: View {
    @StateObject private var viewModel = SearchViewModel()
    
    var body: some View {
        NavigationStack {
            VStack {
                TextField("Търсене на статии...", text: $viewModel.searchText)
                    .textFieldStyle(.roundedBorder)
                    .padding()
                
                if viewModel.isLoading {
                    ProgressView("Зареждане...")
                } else if let error = viewModel.errorMessage {
                    ContentUnavailableView(
                        "Грешка",
                        systemImage: "exclamationmark.triangle",
                        description: Text(error)
                    )
                } else if viewModel.results.isEmpty 
                            && !viewModel.searchText.isEmpty {
                    ContentUnavailableView.search(text: viewModel.searchText)
                } else {
                    List(viewModel.results) { article in
                        VStack(alignment: .leading) {
                            Text(article.title)
                                .font(.headline)
                            Text(article.body)
                                .font(.subheadline)
                                .lineLimit(2)
                                .foregroundStyle(.secondary)
                        }
                    }
                }
            }
            .navigationTitle("Статии")
        }
    }
}

Комбиниране на множество @Published свойства

Ето един шаблон, който използвам постоянно — валидация на форма за регистрация. Combine прави подобна реактивна валидация невероятно чиста:

class RegistrationViewModel: ObservableObject {
    @Published var username = ""
    @Published var email = ""
    @Published var password = ""
    @Published var confirmPassword = ""
    @Published var isFormValid = false
    
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        // Валидация на потребителско име
        let usernameValid = $username
            .map { $0.count >= 3 }
        
        // Валидация на имейл
        let emailValid = $email
            .map { email in
                let regex = /^[\w.-]+@[\w.-]+\.[a-zA-Z]{2,}$/
                return email.contains(regex)
            }
        
        // Валидация на парола
        let passwordValid = Publishers.CombineLatest($password, $confirmPassword)
            .map { password, confirm in
                password.count >= 8 && password == confirm
            }
        
        // Комбиниране на всички валидации
        Publishers.CombineLatest3(usernameValid, emailValid, passwordValid)
            .map { $0 && $1 && $2 }
            .assign(to: &$isFormValid)
    }
}

Combine срещу async/await: Кога да използваме кое?

С въвеждането на Swift Concurrency (async/await) в Swift 5.5, много разработчици се питат дали Combine все още е необходим. Краткият отговор? Да, и двата подхода имат своето място и се допълват взаимно. Дългият отговор — зависи от контекста.

Кога да изберете async/await

  • Еднократни асинхронни операции — изтегляне на данни от API, четене на файл, еднократна задача
  • Последователно изпълнение — когато стъпките следват една след друга
  • Прост код — когато не се нуждаете от сложна трансформация на потоци
  • Обработка на грешки с try/catch — познатата императивна обработка

Кога да изберете Combine

  • Непрекъснати потоци от данни — наблюдение на промени, реактивни UI обновления
  • Debounce и throttle — контрол върху честотата на събития (търсачки, скролване)
  • Комбиниране на множество източнициcombineLatest, zip, merge
  • Сложна трансформация на данни — верижни оператори за обработка на потоци
  • SwiftUI с ObservableObject@Published свойства с автоматично обновяване на UI

Практическо сравнение

Нека го видим в действие. Ето един и същ проблем, решен по два различни начина:

// С async/await — прост и четим за еднократни заявки
func fetchUser() async throws -> User {
    let (data, _) = try await URLSession.shared.data(
        from: URL(string: "https://api.example.com/user")!
    )
    return try JSONDecoder().decode(User.self, from: data)
}

// С Combine — мощен за реактивни потоци
class SettingsManager: ObservableObject {
    @Published var fontSize: CGFloat = 14
    @Published var isDarkMode = false
    @Published var previewText = ""
    
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        // Автоматично обновяване на визуализация
        // при промяна на който и да е параметър
        Publishers.CombineLatest3($fontSize, $isDarkMode, $previewText)
            .debounce(for: .milliseconds(100), scheduler: RunLoop.main)
            .sink { [weak self] fontSize, isDark, text in
                self?.updatePreview(
                    fontSize: fontSize,
                    isDark: isDark,
                    text: text
                )
            }
            .store(in: &cancellables)
    }
    
    private func updatePreview(
        fontSize: CGFloat, isDark: Bool, text: String
    ) {
        // Обновяване на визуализацията
    }
}

Мост между Combine и async/await

И тук е хубавата новина — не е нужно да избирате само едното. Swift предоставя начини за преминаване между двата подхода.

// От Combine към async/await
func getLatestArticle() async throws -> Article {
    try await articleService.fetchArticles()
        .first()                    // Вземаме първата стойност
        .values                     // AsyncSequence от стойности
        .first(where: { _ in true })! // Първата стойност
}

// От async/await към Combine
extension ArticleService {
    func fetchArticlesPublisher() -> AnyPublisher<[Article], Error> {
        Future { promise in
            Task {
                do {
                    let articles = try await self.fetchArticlesAsync()
                    promise(.success(articles))
                } catch {
                    promise(.failure(error))
                }
            }
        }
        .eraseToAnyPublisher()
    }
}

Напреднали шаблони и практики

Създаване на собствен Operator

Когато се окажете, че пишете едно и също нещо отново и отново, може би е време за собствен оператор. Ето един полезен пример — retry с експоненциално забавяне:

extension Publisher {
    /// Автоматично retry с експоненциално забавяне
    func retryWithBackoff(
        retries: Int,
        initialDelay: TimeInterval = 1,
        scheduler: some Scheduler
    ) -> AnyPublisher<Output, Failure> {
        self.catch { error -> AnyPublisher<Output, Failure> in
            guard retries > 0 else {
                return Fail(error: error).eraseToAnyPublisher()
            }
            
            let delay = initialDelay * pow(2, Double(retries - 1))
            
            return Just(())
                .delay(for: .seconds(delay), scheduler: scheduler)
                .flatMap { _ in
                    self.retryWithBackoff(
                        retries: retries - 1,
                        initialDelay: initialDelay,
                        scheduler: scheduler
                    )
                }
                .eraseToAnyPublisher()
        }
        .eraseToAnyPublisher()
    }
}

// Използване
articleService.fetchArticles()
    .retryWithBackoff(retries: 3, scheduler: DispatchQueue.main)
    .sink(
        receiveCompletion: { _ in },
        receiveValue: { articles in
            print("Получени \(articles.count) статии")
        }
    )
    .store(in: &cancellables)

Шаблон за кеширане с Combine

Ето един практичен шаблон, който можете да използвате направо в проектите си — кеширане на мрежови заявки с автоматично изтичане:

class CachingArticleService {
    private let cache = NSCache<NSString, CacheEntry>()
    private let networkService = ArticleService()
    
    class CacheEntry {
        let articles: [Article]
        let timestamp: Date
        
        init(articles: [Article]) {
            self.articles = articles
            self.timestamp = Date()
        }
        
        var isExpired: Bool {
            Date().timeIntervalSince(timestamp) > 300 // 5 минути
        }
    }
    
    func fetchArticles(
        category: String
    ) -> AnyPublisher<[Article], Error> {
        let cacheKey = NSString(string: category)
        
        // Проверка на кеша
        if let entry = cache.object(forKey: cacheKey), 
           !entry.isExpired {
            return Just(entry.articles)
                .setFailureType(to: Error.self)
                .eraseToAnyPublisher()
        }
        
        // Мрежова заявка с кеширане на резултата
        return networkService.fetchArticles()
            .handleEvents(receiveOutput: { [weak self] articles in
                let entry = CacheEntry(articles: articles)
                self?.cache.setObject(entry, forKey: cacheKey)
            })
            .eraseToAnyPublisher()
    }
}

Координиране на множество заявки

В реалните приложения рядко имате само една заявка. Ето как да заредите цял dashboard с паралелни заявки:

class DashboardViewModel: ObservableObject {
    @Published var user: User?
    @Published var articles: [Article] = []
    @Published var notifications: [AppNotification] = []
    @Published var isLoading = true
    
    private var cancellables = Set<AnyCancellable>()
    
    func loadDashboard() {
        isLoading = true
        
        let userPub = userService.fetchCurrentUser()
            .catch { _ in Empty<User, Never>() }
        
        let articlesPub = articleService.fetchLatest()
            .catch { _ in Just<[Article]>([]) }
        
        let notifPub = notificationService.fetchUnread()
            .catch { _ in Just<[AppNotification]>([]) }
        
        // Изчакваме всички заявки да завършат
        Publishers.Zip3(userPub, articlesPub, notifPub)
            .receive(on: DispatchQueue.main)
            .sink { [weak self] user, articles, notifications in
                self?.user = user
                self?.articles = articles
                self?.notifications = notifications
                self?.isLoading = false
            }
            .store(in: &cancellables)
    }
}

Тестване на Combine код

Тестването на Combine конвейери изисква малко по-различен подход заради асинхронната им природа. Но не се притеснявайте, не е толкова сложно, колкото звучи. Ето основните техники:

import XCTest
import Combine

class ArticleServiceTests: XCTestCase {
    var cancellables = Set<AnyCancellable>()
    
    func testSearchDebounce() {
        let expectation = XCTestExpectation(
            description: "Търсенето трябва да излъчи след debounce"
        )
        
        let viewModel = SearchViewModel()
        var receivedResults: [[Article]] = []
        
        viewModel.$results
            .dropFirst()  // Игнорираме началната стойност
            .sink { articles in
                receivedResults.append(articles)
                expectation.fulfill()
            }
            .store(in: &cancellables)
        
        // Симулираме бързо писане
        viewModel.searchText = "S"
        viewModel.searchText = "Sw"
        viewModel.searchText = "Swi"
        viewModel.searchText = "Swift"
        
        wait(for: [expectation], timeout: 2.0)
        
        // Благодарение на debounce, очакваме само 1 заявка
        XCTAssertEqual(receivedResults.count, 1)
    }
    
    func testPublisherOutput() {
        let expectation = XCTestExpectation(
            description: "Издателят трябва да излъчи трансформирани стойности"
        )
        
        var received: [Int] = []
        
        [1, 2, 3, 4, 5].publisher
            .filter { $0 > 2 }
            .map { $0 * 10 }
            .collect()
            .sink { values in
                received = values
                expectation.fulfill()
            }
            .store(in: &cancellables)
        
        wait(for: [expectation], timeout: 1.0)
        XCTAssertEqual(received, [30, 40, 50])
    }
}

Най-добри практики и често срещани грешки

Практики, които да следвате

  1. Винаги съхранявайте абонаментите — забравянето да запазите AnyCancellable е най-честата грешка, която виждам. Без .store(in: &cancellables) абонаментът се отменя веднага и ще се чудите защо кодът ви „не работи".
  2. Използвайте receive(on:) за UI обновления — винаги превключвайте на DispatchQueue.main преди да обновите UI елементи.
  3. Предпочитайте [weak self] в затваряния — предотвратявайте цикли на задържане чрез слаби референции.
  4. Използвайте eraseToAnyPublisher() — скривайте конкретния тип на издателя в публични интерфейси за по-чист API.
  5. Пазете конвейерите прости — ако верижните оператори стават прекалено дълги, разбийте ги на по-малки, именувани издатели. Четимостта е по-важна от краткостта.

Грешки, които да избягвате

  1. Забравени абонаменти — водят до загуба на данни и изтичане на памет. Сериозно, това е грешка номер едно.
  2. Прекомерна употреба на flatMap — без maxPublishers може да създадете неограничен брой паралелни потоци.
  3. Блокиране на главната нишка — тежки операции трябва да се изпълняват на фонова опашка с subscribe(on:).
  4. Игнориране на Completion — в sink обработвайте както .finished, така и .failure.
// ❌ Грешно — абонаментът се губи веднага
URLSession.shared.dataTaskPublisher(for: url)
    .sink(receiveCompletion: { _ in }, receiveValue: { _ in })
    // Липсва .store(in:) — заявката се отменя!

// ✅ Правилно — абонаментът се съхранява
URLSession.shared.dataTaskPublisher(for: url)
    .sink(receiveCompletion: { _ in }, receiveValue: { _ in })
    .store(in: &cancellables)

// ❌ Грешно — UI обновление от фонова нишка
URLSession.shared.dataTaskPublisher(for: url)
    .map(\.data)
    .decode(type: [Article].self, decoder: JSONDecoder())
    .sink(receiveCompletion: { _ in }, 
          receiveValue: { self.articles = $0 })
    .store(in: &cancellables)

// ✅ Правилно — превключваме на главната нишка
URLSession.shared.dataTaskPublisher(for: url)
    .map(\.data)
    .decode(type: [Article].self, decoder: JSONDecoder())
    .receive(on: DispatchQueue.main)  // Важно!
    .sink(receiveCompletion: { _ in }, 
          receiveValue: { [weak self] in self?.articles = $0 })
    .store(in: &cancellables)

Заключение

Combine framework си остава мощен и (според мен) незаменим инструмент в арсенала на всеки Swift разработчик. Да, async/await опрости много асинхронни задачи, но Combine продължава да блести в области като реактивно управление на UI състоянието, обработка на непрекъснати потоци от данни и debounce/throttle на потребителски входове.

Ключът е прост — познавайте и двата подхода и ги прилагайте там, където наистина добавят стойност. Не се опитвайте да решите всичко с един инструмент.

Започнете с простите оператори като map, filter и sink, и постепенно навлизайте в по-сложните шаблони. С времето flatMap, combineLatest и персонализираните оператори ще станат втора природа. А с познанията от това ръководство, вие сте повече от подготвени да изградите реактивни и добре структурирани iOS приложения, използващи най-доброто от двата свята.

За Автора Editorial Team

Our team of expert writers and editors.