Въведение в 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])
}
}
Най-добри практики и често срещани грешки
Практики, които да следвате
- Винаги съхранявайте абонаментите — забравянето да запазите
AnyCancellableе най-честата грешка, която виждам. Без.store(in: &cancellables)абонаментът се отменя веднага и ще се чудите защо кодът ви „не работи". - Използвайте
receive(on:)за UI обновления — винаги превключвайте наDispatchQueue.mainпреди да обновите UI елементи. - Предпочитайте
[weak self]в затваряния — предотвратявайте цикли на задържане чрез слаби референции. - Използвайте
eraseToAnyPublisher()— скривайте конкретния тип на издателя в публични интерфейси за по-чист API. - Пазете конвейерите прости — ако верижните оператори стават прекалено дълги, разбийте ги на по-малки, именувани издатели. Четимостта е по-важна от краткостта.
Грешки, които да избягвате
- Забравени абонаменти — водят до загуба на данни и изтичане на памет. Сериозно, това е грешка номер едно.
- Прекомерна употреба на
flatMap— безmaxPublishersможе да създадете неограничен брой паралелни потоци. - Блокиране на главната нишка — тежки операции трябва да се изпълняват на фонова опашка с
subscribe(on:). - Игнориране на
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 приложения, използващи най-доброто от двата свята.