Combine Framework: Pemrograman Reaktif di Swift
Kuasai Combine untuk menangani aliran data asinkron di Swift: Publishers, Subscribers, Operators, dan pola lanjutan untuk aplikasi iOS.

Pemrograman reaktif mengubah cara peristiwa asinkron dan aliran data ditangani di aplikasi iOS. Combine, framework bawaan Apple, menawarkan pendekatan deklaratif dan type-safe untuk mengorkestrasi pipeline data yang kompleks. Panduan ini membahas konsep dasar hingga pola yang siap produksi.
Combine sudah terintegrasi di iOS 13+, memberikan performa lebih baik berkat optimasi Apple, dan menyatu sempurna dengan SwiftUI. Tidak ada dependensi eksternal yang perlu dikelola.
Konsep Inti Combine
Combine dibangun di atas tiga konsep kunci: Publishers yang memancarkan nilai, Subscribers yang menerimanya, dan Operators yang mengubah data di antara keduanya. Arsitektur ini memungkinkan pembuatan pipeline data yang reaktif dan dapat dikomposisi.
Publisher: sumber data
Publisher adalah tipe yang dapat memancarkan urutan nilai sepanjang waktu. Setiap Publisher mendeklarasikan dua tipe terkait: tipe nilai yang dipancarkan (Output) dan tipe error yang mungkin (Failure). Berikut cara membuat berbagai jenis Publisher:
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"))
}
}Tipe Never untuk error berarti Publisher tidak pernah bisa gagal. Ini adalah jaminan saat compile-time yang menyederhanakan kode penanganan error.
Subscriber: menerima nilai
Subscriber berlangganan ke Publisher untuk menerima nilainya. Metode sink adalah cara paling umum untuk membuat Subscriber. Ia menerima dua closure: satu untuk error atau penyelesaian dan satu untuk setiap nilai yang diterima:
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 successfullySelalu simpan AnyCancellable yang dikembalikan oleh sink(). Tanpa referensi, langganan otomatis dibatalkan dan tidak ada nilai yang diterima.
Mengubah data dengan Operators
Operators adalah inti dari Combine. Mereka memungkinkan transformasi, pemfilteran, dan penggabungan aliran data secara deklaratif. Setiap Operator mengembalikan Publisher baru, sehingga dapat dirantai.
Operators transformasi penting
Operators transformasi memodifikasi setiap nilai yang dipancarkan. map mengubah nilai, flatMap meratakan Publisher bersarang, dan compactMap menyaring nilai 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 pemfilteran
Operators pemfilteran mengontrol nilai mana yang melewati pipeline. Ini penting untuk menghindari pemrosesan yang tidak perlu dan mengoptimalkan performa:
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 300msSiap menguasai wawancara iOS Anda?
Berlatih dengan simulator interaktif, flashcards, dan tes teknis kami.
Menggabungkan beberapa Publisher
Aplikasi nyata sering perlu menggabungkan beberapa sumber data. Combine menawarkan beberapa Operators untuk mengorkestrasi aliran ganda ini.
CombineLatest dan Zip
combineLatest memancarkan setiap kali Publisher mana pun memancarkan, menggabungkan dengan nilai terbaru dari yang lain. zip menunggu semua Publisher memancarkan sebelum menggabungkan:
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 untuk menyatukan aliran
merge menggabungkan beberapa Publisher dengan tipe yang sama menjadi satu aliran. Nilai tiba sesuai urutan emisi, terlepas dari Publisher mana yang mengirimnya:
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!Penanganan error di Combine
Penanganan error tertanam di inti Combine. Tipe Failure pada Publisher memungkinkan kompiler memverifikasi bahwa semua error ditangani.
Strategi pemulihan
Combine menyediakan beberapa Operators untuk menangani error: catch untuk mengganti dengan Publisher lain, retry untuk mencoba lagi, dan replaceError untuk nilai default:
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)Gunakan setFailureType(to:) untuk mengubah Publisher Never menjadi yang bisa gagal, dan replaceError(with:) atau catch untuk arah sebaliknya.
Pola MVVM dengan Combine
Combine terintegrasi secara alami dengan pola MVVM (Model-View-ViewModel). ViewModel mengekspos Publisher yang diamati View, menciptakan binding reaktif antara data dan antarmuka.
ViewModel reaktif lengkap
Berikut contoh ViewModel untuk daftar pengguna dengan pencarian, pemuatan, dan penanganan error:
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)
}
}Service dengan Combine dan URLSession
URLSession terintegrasi dengan Combine secara native melalui dataTaskPublisher. Berikut cara membuat service jaringan yang dapat digunakan ulang:
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()
}
}Siap menguasai wawancara iOS Anda?
Berlatih dengan simulator interaktif, flashcards, dan tes teknis kami.
Integrasi SwiftUI
Combine dan SwiftUI membentuk duo yang kuat. Properti @Published dari ObservableObject secara otomatis memicu pembaruan view.
View SwiftUI dengan ViewModel Combine
Berikut cara menghubungkan ViewModel ke view SwiftUI:
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)
}
}Pola lanjutan
Pembatalan dan pembersihan otomatis
Mengelola siklus hidup langganan sangat penting untuk menghindari memory leaks. Pola cancellables dengan AnyCancellable menjamin pembersihan otomatis:
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 untuk threading
Schedulers mengontrol di thread mana operasi dijalankan. Gunakan subscribe(on:) untuk pekerjaan latar belakang dan receive(on:) untuk pembaruan 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()
}Kesimpulan
Combine menawarkan pendekatan yang kuat dan deklaratif untuk menangani aliran data asinkron di aplikasi iOS. Poin-poin utama:
✅ Publishers memancarkan nilai sepanjang waktu
✅ Subscribers menerima dan memproses nilai-nilai tersebut
✅ Operators mengubah dan menggabungkan aliran
✅ AnyCancellable mengelola siklus hidup langganan
✅ @Published dengan SwiftUI menciptakan binding reaktif otomatis
✅ Schedulers mengontrol threading untuk performa optimal
Menguasai Combine memungkinkan pembuatan aplikasi iOS yang tangguh, mudah dipelihara, dan reaktif. Integrasinya yang native dengan SwiftUI menjadikannya alat penting untuk pengembangan iOS modern.
Mulai berlatih!
Uji pengetahuan Anda dengan simulator wawancara dan tes teknis kami.
Tag
Bagikan
Artikel terkait

Combine vs async/await di Swift: Pola Migrasi Progresif
Panduan lengkap migrasi dari Combine ke async/await di Swift: strategi progresif, pola jembatan, dan koeksistensi paradigma di basis kode iOS.

Pertanyaan Wawancara Aksesibilitas iOS di 2026: VoiceOver dan Dynamic Type
Persiapkan wawancara iOS dengan pertanyaan kunci aksesibilitas: VoiceOver, Dynamic Type, trait semantik, dan audit.

Swift Macros: contoh praktis metaprogramming
Panduan lengkap Swift Macros: pembuatan macro freestanding dan attached, manipulasi AST dengan swift-syntax, serta contoh praktis untuk menghilangkan kode berulang.