Combine Framework: Lập Trình Phản Ứng Trong Swift

Làm chủ Combine để xử lý các luồng dữ liệu bất đồng bộ trong Swift: Publishers, Subscribers, Operators và các mẫu nâng cao cho ứng dụng iOS.

Hướng dẫn framework Combine cho lập trình phản ứng trong Swift iOS

Lập trình phản ứng thay đổi cách các sự kiện bất đồng bộ và luồng dữ liệu được xử lý trong ứng dụng iOS. Combine, framework gốc của Apple, mang đến cách tiếp cận khai báo và an toàn về kiểu để điều phối các pipeline dữ liệu phức tạp. Hướng dẫn này dẫn dắt từ các khái niệm nền tảng đến những mẫu sẵn sàng cho production.

Tại sao chọn Combine thay vì RxSwift?

Combine được tích hợp sẵn trong iOS 13+, cho hiệu năng tốt hơn nhờ tối ưu của Apple và tích hợp mượt mà với SwiftUI. Không phải quản lý phụ thuộc bên ngoài.

Khái niệm cốt lõi của Combine

Combine được xây dựng trên ba khái niệm chính: Publishers phát ra giá trị, Subscribers nhận chúng và Operators biến đổi dữ liệu giữa hai bên. Kiến trúc này cho phép xây dựng các pipeline dữ liệu phản ứng và có khả năng kết hợp.

Publisher: nguồn dữ liệu

Publisher là một kiểu có thể phát ra một chuỗi giá trị theo thời gian. Mỗi Publisher khai báo hai kiểu liên kết: kiểu giá trị phát ra (Output) và kiểu lỗi có thể xảy ra (Failure). Đây là cách tạo các loại Publisher khác nhau:

PublisherBasics.swiftswift
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"))
    }
}

Kiểu Never cho lỗi nghĩa là Publisher không bao giờ có thể thất bại. Đây là sự đảm bảo tại thời điểm biên dịch giúp đơn giản hóa mã xử lý lỗi.

Subscriber: nhận giá trị

Một Subscriber đăng ký vào Publisher để nhận giá trị của nó. Phương thức sink là cách phổ biến nhất để tạo Subscriber. Nó nhận hai closure: một cho lỗi hoặc khi hoàn tất và một cho mỗi giá trị nhận được:

SubscriberBasics.swiftswift
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
Cẩn trọng với rò rỉ bộ nhớ

Luôn lưu giữ AnyCancellablesink() trả về. Không có tham chiếu, đăng ký sẽ bị hủy tự động và không nhận được giá trị nào.

Biến đổi dữ liệu với Operators

Operators là trái tim của Combine. Chúng cho phép biến đổi, lọc và kết hợp các luồng dữ liệu một cách khai báo. Mỗi Operator trả về một Publisher mới, cho phép xâu chuỗi chúng.

Các Operators biến đổi thiết yếu

Operators biến đổi điều chỉnh từng giá trị phát ra. map biến đổi giá trị, flatMap làm phẳng các Publisher lồng nhau, và compactMap lọc ra giá trị nil:

TransformOperators.swiftswift
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)

Các Operators lọc

Operators lọc kiểm soát những giá trị nào đi qua pipeline. Chúng quan trọng để tránh xử lý không cần thiết và tối ưu hiệu năng:

FilterOperators.swiftswift
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

Sẵn sàng chinh phục phỏng vấn iOS?

Luyện tập với mô phỏng tương tác, flashcards và bài kiểm tra kỹ thuật.

Kết hợp nhiều Publisher

Ứng dụng thực tế thường cần kết hợp nhiều nguồn dữ liệu. Combine cung cấp nhiều Operators để điều phối các luồng đa nguồn này.

CombineLatest và Zip

combineLatest phát ra mỗi khi bất kỳ Publisher nào phát ra, kết hợp với giá trị mới nhất từ các Publisher khác. zip đợi tất cả các Publisher phát ra trước khi kết hợp:

CombiningPublishers.swiftswift
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 để hợp nhất các luồng

merge gộp nhiều Publisher cùng kiểu thành một luồng duy nhất. Các giá trị đến theo thứ tự phát ra, bất kể Publisher nào đã gửi:

MergePublishers.swiftswift
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!

Xử lý lỗi trong Combine

Xử lý lỗi được tích hợp vào lõi của Combine. Kiểu Failure của Publisher cho phép trình biên dịch xác minh rằng mọi lỗi đều được xử lý.

Chiến lược phục hồi

Combine cung cấp nhiều Operators để xử lý lỗi: catch để thay thế bằng Publisher khác, retry để thử lại, và replaceError để dùng giá trị mặc định:

ErrorHandling.swiftswift
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)
Never so với Error

Dùng setFailureType(to:) để chuyển một Publisher Never thành Publisher có thể thất bại, và replaceError(with:) hoặc catch để làm chiều ngược lại.

Mẫu MVVM với Combine

Combine tích hợp tự nhiên với mẫu MVVM (Model-View-ViewModel). ViewModel cung cấp các Publisher để View quan sát, tạo binding phản ứng giữa dữ liệu và giao diện.

ViewModel phản ứng đầy đủ

Đây là ví dụ ViewModel cho một danh sách người dùng có tìm kiếm, tải dữ liệu và xử lý lỗi:

UserListViewModel.swiftswift
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 với Combine và URLSession

URLSession tích hợp Combine một cách tự nhiên qua dataTaskPublisher. Đây là cách tạo một service mạng có thể tái sử dụng:

UserService.swiftswift
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()
    }
}

Sẵn sàng chinh phục phỏng vấn iOS?

Luyện tập với mô phỏng tương tác, flashcards và bài kiểm tra kỹ thuật.

Tích hợp với SwiftUI

Combine và SwiftUI tạo nên một bộ đôi mạnh mẽ. Các thuộc tính @Published của một ObservableObject tự động kích hoạt cập nhật view.

View SwiftUI với ViewModel Combine

Đây là cách kết nối ViewModel với một view SwiftUI:

UserListView.swiftswift
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)
    }
}

Mẫu nâng cao

Hủy bỏ và dọn dẹp tự động

Quản lý vòng đời đăng ký rất quan trọng để tránh rò rỉ bộ nhớ. Mẫu cancellables với AnyCancellable đảm bảo dọn dẹp tự động:

CancellationPatterns.swiftswift
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 cho threading

Schedulers kiểm soát thread mà các thao tác chạy trên đó. Dùng subscribe(on:) cho công việc nền và receive(on:) cho cập nhật UI:

SchedulerPatterns.swiftswift
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()
}

Kết luận

Combine cung cấp cách tiếp cận mạnh mẽ và khai báo để xử lý các luồng dữ liệu bất đồng bộ trong ứng dụng iOS. Các điểm chính:

Publishers phát ra giá trị theo thời gian ✅ Subscribers nhận và xử lý các giá trị đó ✅ Operators biến đổi và kết hợp các luồng ✅ AnyCancellable quản lý vòng đời đăng ký ✅ @Published với SwiftUI tạo binding phản ứng tự động ✅ Schedulers kiểm soát threading để có hiệu năng tối ưu

Làm chủ Combine giúp xây dựng các ứng dụng iOS bền vững, dễ bảo trì và phản ứng. Sự tích hợp tự nhiên với SwiftUI biến nó thành công cụ thiết yếu cho phát triển iOS hiện đại.

Bắt đầu luyện tập!

Kiểm tra kiến thức với mô phỏng phỏng vấn và bài kiểm tra kỹ thuật.

Thẻ

#combine
#swift
#ios
#reactive
#async

Chia sẻ

Bài viết liên quan