Combine Framework: Реактивне Програмування у Swift
Опануйте Combine для роботи з асинхронними потоками даних у Swift: Publishers, Subscribers, Operators та просунуті патерни для iOS-додатків.

Реактивне програмування трансформує підхід до асинхронних подій і потоків даних в iOS-додатках. Combine, нативний фреймворк Apple, пропонує декларативний і типобезпечний спосіб оркеструвати складні конвеєри даних. Цей посібник веде від базових понять до патернів, готових до продакшну.
Combine вбудований в iOS 13+, забезпечує кращу продуктивність завдяки оптимізаціям Apple і бездоганно інтегрується зі SwiftUI. Жодних зовнішніх залежностей для підтримки.
Основні поняття Combine
Combine побудовано на трьох ключових поняттях: Publishers, що випромінюють значення, Subscribers, що їх отримують, та Operators, що перетворюють дані між ними. Така архітектура дозволяє будувати реактивні та компонувані конвеєри даних.
Publisher: джерело даних
Publisher — це тип, здатний випромінювати послідовність значень у часі. Кожен Publisher оголошує два пов'язані типи: тип значення, що випромінюється (Output), і тип можливої помилки (Failure). Ось як створювати різні типи Publishers:
import Combine
// Just: emits a single value then completes
// Useful for converting a simple value to a Publisher
let singleValue = Just("Hello Combine")
// CurrentValueSubject: stores and emits the current value
// Perfect for representing state that changes over time
let counter = CurrentValueSubject<Int, Never>(0)
// PassthroughSubject: emits values without storing them
// Ideal for one-time events (taps, notifications)
let buttonTaps = PassthroughSubject<Void, Never>()
// Future: emits a single value asynchronously
// Wraps an async operation that returns a result
let asyncOperation = Future<String, Error> { promise in
// Simulate a network call
DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
promise(.success("Data loaded"))
}
}Тип Never для помилок означає, що Publisher ніколи не може зазнати невдачі. Це гарантія часу компіляції, яка спрощує код обробки помилок.
Subscriber: отримання значень
Subscriber підписується на Publisher, щоб отримувати його значення. Метод sink — найпоширеніший спосіб створити Subscriber. Він приймає дві замикання: одне для помилок або завершення і одне для кожного отриманого значення:
import Combine
// Variable to store subscriptions
// Without this reference, the subscription would be immediately cancelled
var cancellables = Set<AnyCancellable>()
let publisher = ["Swift", "Combine", "iOS"].publisher
// sink() creates a Subscriber that receives values
publisher
.sink(
// Called when the Publisher completes or fails
receiveCompletion: { completion in
switch completion {
case .finished:
print("✅ Completed successfully")
case .failure(let error):
print("❌ Error: \(error)")
}
},
// Called for each emitted value
receiveValue: { value in
print("Received: \(value)")
}
)
// store() keeps a reference to the subscription
.store(in: &cancellables)
// Output:
// Received: Swift
// Received: Combine
// Received: iOS
// ✅ Completed successfullyЗавжди зберігайте AnyCancellable, який повертає sink(). Без посилання підписку буде автоматично скасовано і жодне значення не буде отримано.
Перетворення даних за допомогою Operators
Operators — це серце Combine. Вони дозволяють декларативно перетворювати, фільтрувати та комбінувати потоки даних. Кожен Operator повертає новий Publisher, що дає змогу їх ланцюжкувати.
Основні перетворювальні Operators
Перетворювальні Operators змінюють кожне випромінене значення. map перетворює значення, flatMap сплющує вкладені Publishers, а compactMap відфільтровує nil:
import Combine
var cancellables = Set<AnyCancellable>()
// map: transforms each value
// Equivalent to map on arrays
[1, 2, 3, 4, 5].publisher
.map { $0 * 2 } // Multiply each number by 2
.sink { print("Doubled: \($0)") }
.store(in: &cancellables)
// Output: 2, 4, 6, 8, 10
// compactMap: transforms AND filters out nil
// Useful for optional conversions
["1", "two", "3", "four", "5"].publisher
.compactMap { Int($0) } // Convert to Int, ignore failures
.sink { print("Valid number: \($0)") }
.store(in: &cancellables)
// Output: 1, 3, 5
// flatMap: flattens nested Publishers
// Essential for chaining async operations
struct User { let id: Int; let name: String }
func fetchUser(id: Int) -> AnyPublisher<User, Never> {
// Simulate an API call
Just(User(id: id, name: "User \(id)"))
.delay(for: .milliseconds(100), scheduler: RunLoop.main)
.eraseToAnyPublisher()
}
[1, 2, 3].publisher
.flatMap { id in fetchUser(id: id) } // Each ID becomes an API call
.sink { user in print("User: \(user.name)") }
.store(in: &cancellables)Фільтрувальні Operators
Фільтрувальні Operators визначають, які значення проходять конвеєром. Вони необхідні, щоб уникати зайвої обробки і оптимізувати продуктивність:
import Combine
var cancellables = Set<AnyCancellable>()
let numbers = [1, 2, 2, 3, 3, 3, 4, 5, 5].publisher
// filter: keeps only values that satisfy the condition
numbers
.filter { $0 > 2 } // Keep only numbers > 2
.sink { print("Filtered: \($0)") }
.store(in: &cancellables)
// Output: 3, 3, 3, 4, 5, 5
// removeDuplicates: removes consecutive identical values
numbers
.removeDuplicates() // Eliminate consecutive duplicates
.sink { print("Without duplicates: \($0)") }
.store(in: &cancellables)
// Output: 1, 2, 3, 4, 5
// debounce: waits for a pause before emitting
// Perfect for real-time search
let searchText = PassthroughSubject<String, Never>()
searchText
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.removeDuplicates() // Ignore if text hasn't changed
.sink { query in
print("Search: \(query)")
// Launch API call here
}
.store(in: &cancellables)
// Simulate rapid typing
searchText.send("S")
searchText.send("Sw")
searchText.send("Swi")
searchText.send("Swift") // Only "Swift" is emitted after 300msГотовий до співбесід з iOS?
Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.
Комбінування кількох Publishers
Реальні додатки часто потребують поєднання кількох джерел даних. Combine пропонує кілька Operators для оркестрації цих множинних потоків.
CombineLatest та Zip
combineLatest випромінює щоразу, коли випромінює будь-який Publisher, поєднуючи з останніми значеннями інших. zip чекає, поки всі Publishers випромінять, перш ніж комбінувати:
import Combine
var cancellables = Set<AnyCancellable>()
// Simulate a form with validation
let email = CurrentValueSubject<String, Never>("")
let password = CurrentValueSubject<String, Never>("")
// combineLatest: combines the latest values from each Publisher
// Emits on every change from either source
Publishers.CombineLatest(email, password)
.map { email, password in
// Validate that email contains @ and password > 6 chars
let isEmailValid = email.contains("@")
let isPasswordValid = password.count >= 6
return isEmailValid && isPasswordValid
}
.sink { isFormValid in
print("Form valid: \(isFormValid)")
}
.store(in: &cancellables)
email.send("user@example.com") // false (password empty)
password.send("123456") // true (both are valid)
// zip: waits for one value from each Publisher before emitting
// Useful for synchronizing parallel operations
let firstAPI = PassthroughSubject<String, Never>()
let secondAPI = PassthroughSubject<Int, Never>()
Publishers.Zip(firstAPI, secondAPI)
.sink { stringValue, intValue in
print("Received pair: \(stringValue), \(intValue)")
}
.store(in: &cancellables)
firstAPI.send("Hello") // No emission, waiting for secondAPI
secondAPI.send(42) // Emits: ("Hello", 42)
firstAPI.send("World") // No emission, waiting for secondAPI
secondAPI.send(100) // Emits: ("World", 100)Merge для об'єднання потоків
merge об'єднує кілька Publishers одного типу в єдиний потік. Значення надходять у порядку випромінення, незалежно від того, який Publisher їх надіслав:
import Combine
var cancellables = Set<AnyCancellable>()
// Multiple user notification sources
let pushNotifications = PassthroughSubject<String, Never>()
let localNotifications = PassthroughSubject<String, Never>()
let inAppMessages = PassthroughSubject<String, Never>()
// Merge unifies all streams into one
Publishers.Merge3(pushNotifications, localNotifications, inAppMessages)
.sink { message in
// Handle all notifications the same way
print("📬 Notification: \(message)")
}
.store(in: &cancellables)
pushNotifications.send("New message") // 📬 Notification: New message
localNotifications.send("Reminder: meeting") // 📬 Notification: Reminder: meeting
inAppMessages.send("Welcome!") // 📬 Notification: Welcome!Обробка помилок у Combine
Обробка помилок вбудована у ядро Combine. Тип Failure у Publishers дозволяє компілятору перевіряти, що всі помилки оброблено.
Стратегії відновлення
Combine надає кілька Operators для роботи з помилками: catch, щоб замінити іншим Publisher, retry, щоб повторити спробу, та replaceError для значення за замовчуванням:
import Combine
var cancellables = Set<AnyCancellable>()
enum APIError: Error {
case networkError
case invalidResponse
case serverError(Int)
}
// Simulate an API call that can fail
func fetchData() -> AnyPublisher<String, APIError> {
Fail(error: APIError.networkError)
.eraseToAnyPublisher()
}
// retry: retries N times before propagating the error
fetchData()
.retry(3) // Try up to 3 times
.catch { error -> Just<String> in
// catch: replaces the error with a fallback Publisher
print("Error after 3 attempts: \(error)")
return Just("Cached data") // Fallback value
}
.sink(
receiveCompletion: { _ in },
receiveValue: { print("Result: \($0)") }
)
.store(in: &cancellables)
// replaceError: replaces any error with a fixed value
// Simpler than catch when only a default value is needed
fetchData()
.replaceError(with: "Error - default value")
.sink { print("With fallback: \($0)") }
.store(in: &cancellables)Використовуйте setFailureType(to:), щоб перетворити Publisher Never на такий, що може зазнати невдачі, та replaceError(with:) чи catch для зворотного шляху.
Патерн MVVM з Combine
Combine природно інтегрується з патерном MVVM (Model-View-ViewModel). ViewModel надає Publishers, які спостерігає View, створюючи реактивний зв'язок між даними і інтерфейсом.
Повний реактивний ViewModel
Ось приклад ViewModel для списку користувачів із пошуком, завантаженням і обробкою помилок:
import Combine
import Foundation
// Data model
struct User: Codable, Identifiable {
let id: Int
let name: String
let email: String
}
// ViewModel with reactive state
final class UserListViewModel: ObservableObject {
// MARK: - Published Properties (observed by SwiftUI)
@Published var users: [User] = [] // User list
@Published var searchQuery: String = "" // Search text
@Published var isLoading: Bool = false // Loading state
@Published var errorMessage: String? // Optional error message
// MARK: - Private Properties
private var cancellables = Set<AnyCancellable>()
private let userService: UserServiceProtocol
// MARK: - Computed Properties
// Filters users based on search query
var filteredUsers: [User] {
guard !searchQuery.isEmpty else { return users }
return users.filter {
$0.name.localizedCaseInsensitiveContains(searchQuery)
}
}
// MARK: - Initialization
init(userService: UserServiceProtocol = UserService()) {
self.userService = userService
setupBindings()
}
// MARK: - Private Methods
private func setupBindings() {
// Observe searchQuery changes
// debounce prevents too frequent calls
$searchQuery
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.removeDuplicates()
.sink { [weak self] query in
// Server-side search logic if needed
print("Search updated: \(query)")
}
.store(in: &cancellables)
}
// MARK: - Public Methods
func loadUsers() {
isLoading = true
errorMessage = nil
userService.fetchUsers()
.receive(on: DispatchQueue.main) // Ensure UI updates on main thread
.sink(
receiveCompletion: { [weak self] completion in
self?.isLoading = false
if case .failure(let error) = completion {
self?.errorMessage = error.localizedDescription
}
},
receiveValue: { [weak self] users in
self?.users = users
}
)
.store(in: &cancellables)
}
}Сервіс із Combine та URLSession
URLSession інтегрується з Combine нативно через dataTaskPublisher. Ось як створити перевикористовуваний мережевий сервіс:
import Combine
import Foundation
protocol UserServiceProtocol {
func fetchUsers() -> AnyPublisher<[User], Error>
}
final class UserService: UserServiceProtocol {
private let baseURL = URL(string: "https://api.example.com")!
private let session: URLSession
private let decoder: JSONDecoder
init(session: URLSession = .shared) {
self.session = session
self.decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
}
func fetchUsers() -> AnyPublisher<[User], Error> {
let url = baseURL.appendingPathComponent("users")
return session.dataTaskPublisher(for: url)
// Check HTTP status code
.tryMap { data, response in
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard 200..<300 ~= httpResponse.statusCode else {
throw URLError(.badServerResponse)
}
return data
}
// Decode JSON to Swift model
.decode(type: [User].self, decoder: decoder)
// Erase concrete type to return AnyPublisher
.eraseToAnyPublisher()
}
}Готовий до співбесід з iOS?
Практикуйся з нашими інтерактивними симуляторами, flashcards та технічними тестами.
Інтеграція зі SwiftUI
Combine і SwiftUI утворюють потужний дует. Властивості @Published об'єкта ObservableObject автоматично запускають оновлення view.
SwiftUI View з Combine ViewModel
Ось як підключити ViewModel до SwiftUI view:
import SwiftUI
struct UserListView: View {
// StateObject: creates and owns the ViewModel
@StateObject private var viewModel = UserListViewModel()
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading {
// Centered loading indicator
ProgressView("Loading...")
} else if let error = viewModel.errorMessage {
// Error view with retry button
VStack(spacing: 16) {
Text("Error: \(error)")
.foregroundStyle(.red)
Button("Retry") {
viewModel.loadUsers()
}
}
} else {
// User list
List(viewModel.filteredUsers) { user in
UserRowView(user: user)
}
}
}
.navigationTitle("Users")
.searchable(text: $viewModel.searchQuery) // Direct binding
.onAppear {
viewModel.loadUsers() // Load on first appearance
}
}
}
}
struct UserRowView: View {
let user: User
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(user.name)
.font(.headline)
Text(user.email)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}Просунуті патерни
Скасування і автоматичне очищення
Керування життєвим циклом підписок є критично важливим, щоб уникати витоків пам'яті. Патерн cancellables з AnyCancellable гарантує автоматичне очищення:
import Combine
final class DataManager {
// Set of cancellables: automatically cancelled on destruction
private var cancellables = Set<AnyCancellable>()
// Individual cancellable for fine-grained control
private var currentRequest: AnyCancellable?
func startPolling() {
// Timer that emits every 5 seconds
Timer.publish(every: 5, on: .main, in: .common)
.autoconnect() // Starts automatically
.sink { [weak self] _ in
self?.fetchLatestData()
}
.store(in: &cancellables)
}
func fetchLatestData() {
// Cancel the previous request if it exists
currentRequest?.cancel()
currentRequest = URLSession.shared
.dataTaskPublisher(for: URL(string: "https://api.example.com/data")!)
.map(\.data)
.decode(type: [String].self, decoder: JSONDecoder())
.replaceError(with: [])
.receive(on: DispatchQueue.main)
.sink { data in
print("Data received: \(data)")
}
}
deinit {
// All cancellables are automatically cancelled
print("DataManager destroyed, subscriptions cancelled")
}
}Schedulers для потоків виконання
Schedulers визначають, у якому потоці виконуються операції. Використовуйте subscribe(on:) для роботи у фоні і receive(on:) для оновлень UI:
import Combine
import Foundation
var cancellables = Set<AnyCancellable>()
func loadAndProcessData() -> AnyPublisher<ProcessedData, Error> {
URLSession.shared.dataTaskPublisher(for: apiURL)
// Perform parsing on a background thread
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.map(\.data)
.decode(type: RawData.self, decoder: JSONDecoder())
// Heavy processing on background thread
.map { rawData in
// This expensive operation runs in the background
processData(rawData)
}
// Return to main thread for UI
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}Висновок
Combine надає потужний і декларативний підхід до роботи з асинхронними потоками даних в iOS-додатках. Ключові тези:
✅ Publishers випромінюють значення в часі
✅ Subscribers отримують і обробляють ці значення
✅ Operators перетворюють і поєднують потоки
✅ AnyCancellable керує життєвим циклом підписок
✅ @Published зі SwiftUI створює автоматичні реактивні зв'язки
✅ Schedulers контролюють потоки виконання для оптимальної продуктивності
Володіння Combine дозволяє будувати надійні, підтримувані та реактивні iOS-додатки. Нативна інтеграція зі SwiftUI робить його незамінним інструментом сучасної iOS-розробки.
Починай практикувати!
Перевір свої знання з нашими симуляторами співбесід та технічними тестами.
Теги
Поділитися
Пов'язані статті

Combine vs async/await у Swift: Шаблони Прогресивної Міграції
Повний посібник з міграції з Combine на async/await у Swift: прогресивні стратегії, шаблони мостування та співіснування парадигм у iOS-кодових базах.

Питання співбесід з доступності iOS у 2026: VoiceOver і Dynamic Type
Підготовка до співбесід з iOS із ключовими питаннями про доступність: VoiceOver, Dynamic Type, семантичні traits та аудити.

Swift Macros: практичні приклади метапрограмування
Повний посібник зі Swift Macros: створення freestanding- та attached-макросів, маніпуляція AST за допомогою swift-syntax і практичні приклади для усунення повторюваного коду.