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.

Migrasi dari Combine ke async/await di Swift dengan pola koeksistensi

Kedatangan Swift Concurrency dengan async/await telah mengubah praktik pemrograman asinkron di iOS. Bagi proyek yang menggunakan Combine, pertanyaan migrasi muncul secara alami. Apakah semuanya harus ditulis ulang? Apakah kedua pendekatan dapat hidup berdampingan? Pola apa yang memungkinkan transisi yang mulus? Panduan ini mengeksplorasi strategi migrasi progresif, memungkinkan adopsi async/await tanpa meninggalkan Combine secara mendadak.

Apa yang dibahas panduan ini

Panduan ini menyajikan pola konkret untuk migrasi progresif dari Combine ke async/await, dengan contoh jembatan dua arah dan strategi koeksistensi yang sesuai untuk basis kode yang sudah ada.

Memahami Perbedaan Mendasar

Sebelum memulai migrasi, penting untuk memahami apa yang membedakan Combine dari async/await. Kedua pendekatan ini menjawab kebutuhan yang berbeda, dan beberapa kasus penggunaan tetap lebih cocok dengan Combine.

Model Mental Combine

Combine didasarkan pada model aliran data. Sebuah Publisher memancarkan nilai dari waktu ke waktu, operator mentransformasikan nilai-nilai tersebut, dan Subscriber menerima hasil akhirnya. Model ini unggul untuk aliran berkelanjutan seperti event UI, notifikasi, atau WebSocket.

CombineExample.swiftswift
// Event stream with Combine - stream-based model
import Combine

class SearchViewModel {
    @Published var searchText = ""
    private var cancellables = Set<AnyCancellable>()

    // Combine excels for continuous streams with transformations
    func setupSearch() {
        $searchText
            // Wait 300ms pause in typing
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
            // Ignore consecutive duplicates
            .removeDuplicates()
            // Filter searches that are too short
            .filter { $0.count >= 3 }
            // Transform text into network request
            .flatMap { query in
                self.searchAPI(query: query)
                    // Local error handling
                    .catch { _ in Just([]) }
            }
            // Final subscription
            .sink { results in
                self.updateUI(with: results)
            }
            .store(in: &cancellables)
    }

    private func searchAPI(query: String) -> AnyPublisher<[SearchResult], Error> {
        // Network implementation
    }
}

Kode ini mengilustrasikan kekuatan Combine: merangkai operator deklaratif untuk memproses aliran event yang berkelanjutan.

Model Mental async/await

Async/await mengadopsi model sekuensial: sebuah operasi dimulai, kode menunggu hasilnya, lalu melanjutkan. Model ini lebih intuitif untuk operasi sekali pakai seperti permintaan jaringan terisolasi atau pembacaan file.

AsyncAwaitExample.swiftswift
// One-off operations with async/await - sequential model
import Foundation

actor SearchService {
    // async/await excels for sequential operations
    func performSearch(query: String) async throws -> [SearchResult] {
        // Pre-validation - clear sequential reading
        guard query.count >= 3 else {
            return []
        }

        // Network request with await
        let url = URL(string: "https://api.example.com/search?q=\(query)")!
        let (data, response) = try await URLSession.shared.data(from: url)

        // Response verification
        guard let httpResponse = response as? HTTPURLResponse,
              httpResponse.statusCode == 200 else {
            throw SearchError.invalidResponse
        }

        // Result decoding
        let results = try JSONDecoder().decode([SearchResult].self, from: data)
        return results
    }
}

Pembacaan bersifat linier, error merambat secara alami dengan try, dan alur eksekusi langsung dapat dipahami.

Kapan memilih setiap pendekatan

Combine tetap relevan untuk aliran berkelanjutan (event UI, timer, WebSocket). Async/await lebih cocok untuk operasi sekali pakai (permintaan API, pembacaan file, perhitungan terisolasi).

Menjembatani Combine ke async/await

Langkah pertama dari sebuah migrasi sering kali berupa mengonsumsi Publisher yang sudah ada dalam kode async/await. Swift menyediakan alat bawaan untuk jembatan ini.

Menggunakan AsyncSequence dengan Publisher.values

Sejak Swift 5.5, setiap Publisher memaparkan properti .values yang mengembalikan AsyncPublisher. Sekuens asinkron ini memungkinkan iterasi atas nilai yang dipancarkan dengan loop for await.

BridgingCombineToAsync.swiftswift
// Publisher → AsyncSequence conversion via .values
import Combine

class NotificationObserver {
    private let notificationPublisher: AnyPublisher<Notification, Never>

    init() {
        // Existing Combine Publisher
        notificationPublisher = NotificationCenter.default
            .publisher(for: UIApplication.didBecomeActiveNotification)
            .eraseToAnyPublisher()
    }

    // Consuming the Publisher with async/await
    func observeNotifications() async {
        // .values converts the Publisher to AsyncSequence
        for await notification in notificationPublisher.values {
            // Process each notification
            await handleAppBecameActive(notification)
        }
        // This line is never reached for an infinite Publisher
    }

    private func handleAppBecameActive(_ notification: Notification) async {
        // Async processing logic
    }
}

Pendekatan ini mempertahankan Publisher asli sambil tetap memungkinkan konsumsinya dalam konteks asinkron.

Mendapatkan Nilai Tunggal dengan firstValue

Untuk Publisher yang memancarkan satu nilai (seperti permintaan jaringan), properti .values.first(where:) atau ekstensi kustom menyederhanakan jembatan.

SingleValueBridging.swiftswift
// Extension to extract a single value from a Publisher
import Combine

extension Publisher where Failure == Never {
    // Awaits and returns the first emitted value
    var firstValue: Output {
        get async {
            await withCheckedContinuation { continuation in
                var cancellable: AnyCancellable?
                cancellable = self.first()
                    .sink { value in
                        continuation.resume(returning: value)
                        cancellable?.cancel()
                    }
            }
        }
    }
}

extension Publisher {
    // Throwing version for Publishers with errors
    var firstValueThrowing: Output {
        get async throws {
            try await withCheckedThrowingContinuation { continuation in
                var cancellable: AnyCancellable?
                cancellable = self.first()
                    .sink(
                        receiveCompletion: { completion in
                            if case .failure(let error) = completion {
                                continuation.resume(throwing: error)
                            }
                            cancellable?.cancel()
                        },
                        receiveValue: { value in
                            continuation.resume(returning: value)
                        }
                    )
            }
        }
    }
}

// Usage in async code
class UserRepository {
    private let apiClient: APIClient

    func fetchCurrentUser() async throws -> User {
        // Consume an existing Publisher asynchronously
        try await apiClient.userPublisher().firstValueThrowing
    }
}

Ekstensi ini mengenkapsulasi kompleksitas penjembatanan dan menawarkan API yang bersih.

Siap menguasai wawancara iOS Anda?

Berlatih dengan simulator interaktif, flashcards, dan tes teknis kami.

Menjembatani async/await ke Combine

Migrasi sebaliknya juga diperlukan: mengonsumsi kode async dalam pipeline Combine yang sudah ada.

Membuat Publisher dari Fungsi async

Pendekatan paling langsung menggunakan Future yang dikombinasikan dengan Task untuk mengenkapsulasi panggilan async.

BridgingAsyncToCombine.swiftswift
// async → Publisher conversion via Future
import Combine

extension Publisher {
    // async flatMap operator for Combine pipelines
    func asyncMap<T>(
        _ transform: @escaping (Output) async throws -> T
    ) -> AnyPublisher<T, Error> {
        flatMap { value in
            Future { promise in
                Task {
                    do {
                        // Execute the async transformation
                        let result = try await transform(value)
                        promise(.success(result))
                    } catch {
                        promise(.failure(error))
                    }
                }
            }
        }
        .eraseToAnyPublisher()
    }
}

// Usage in a Combine pipeline
class ImageProcessor {
    @Published var selectedImageURL: URL?
    private var cancellables = Set<AnyCancellable>()

    func setupProcessingPipeline() {
        $selectedImageURL
            .compactMap { $0 }
            // Use an async function in the Combine pipeline
            .asyncMap { url in
                // downloadImage is an async function
                try await self.downloadImage(from: url)
            }
            .asyncMap { imageData in
                // processImage is also async
                try await self.processImage(imageData)
            }
            .receive(on: DispatchQueue.main)
            .sink(
                receiveCompletion: { completion in
                    if case .failure(let error) = completion {
                        print("Error: \(error)")
                    }
                },
                receiveValue: { processedImage in
                    self.displayImage(processedImage)
                }
            )
            .store(in: &cancellables)
    }

    private func downloadImage(from url: URL) async throws -> Data {
        let (data, _) = try await URLSession.shared.data(from: url)
        return data
    }

    private func processImage(_ data: Data) async throws -> UIImage {
        // Async image processing
    }
}

Publisher Kustom untuk Aliran Async

Untuk kebutuhan yang lebih lanjut, Publisher kustom dapat mengenkapsulasi aliran AsyncSequence yang utuh.

AsyncSequencePublisher.swiftswift
// Publisher wrapper for AsyncSequence
import Combine

struct AsyncSequencePublisher<S: AsyncSequence>: Publisher {
    typealias Output = S.Element
    typealias Failure = Error

    private let sequence: S

    init(_ sequence: S) {
        self.sequence = sequence
    }

    func receive<Sub>(subscriber: Sub) where Sub: Subscriber,
                                              Failure == Sub.Failure,
                                              Output == Sub.Input {
        let subscription = AsyncSubscription(
            sequence: sequence,
            subscriber: subscriber
        )
        subscriber.receive(subscription: subscription)
    }
}

private final class AsyncSubscription<S: AsyncSequence, Sub: Subscriber>: Subscription
where Sub.Input == S.Element, Sub.Failure == Error {

    private var task: Task<Void, Never>?
    private var subscriber: Sub?
    private let sequence: S

    init(sequence: S, subscriber: Sub) {
        self.sequence = sequence
        self.subscriber = subscriber
    }

    func request(_ demand: Subscribers.Demand) {
        // Start asynchronous iteration
        task = Task {
            do {
                for try await element in sequence {
                    // Check subscription is still active
                    guard subscriber != nil else { break }
                    _ = subscriber?.receive(element)
                }
                subscriber?.receive(completion: .finished)
            } catch {
                subscriber?.receive(completion: .failure(error))
            }
        }
    }

    func cancel() {
        task?.cancel()
        subscriber = nil
    }
}

// Convenience extension for any AsyncSequence
extension AsyncSequence {
    var publisher: AsyncSequencePublisher<Self> {
        AsyncSequencePublisher(self)
    }
}

Strategi Koeksistensi dalam Basis Kode

Migrasi penuh dari basis kode besar membutuhkan waktu. Berikut adalah pola untuk membuat Combine dan async/await hidup berdampingan secara harmonis.

Arsitektur Berlapis dengan Abstraksi

Mendefinisikan protokol yang mengabstraksikan implementasi memungkinkan migrasi progresif tanpa memodifikasi kode pemanggil.

RepositoryAbstraction.swiftswift
// Abstraction enabling two implementations
import Combine

// Protocol defining the contract
protocol UserRepositoryProtocol {
    // Modern async interface
    func fetchUser(id: String) async throws -> User

    // Legacy Combine interface (optional with default implementation)
    func fetchUserPublisher(id: String) -> AnyPublisher<User, Error>
}

// Default Publisher implementation based on async
extension UserRepositoryProtocol {
    func fetchUserPublisher(id: String) -> AnyPublisher<User, Error> {
        Future { promise in
            Task {
                do {
                    let user = try await self.fetchUser(id: id)
                    promise(.success(user))
                } catch {
                    promise(.failure(error))
                }
            }
        }
        .eraseToAnyPublisher()
    }
}

// Modern implementation - async first
class UserRepository: UserRepositoryProtocol {
    private let apiClient: APIClient

    init(apiClient: APIClient) {
        self.apiClient = apiClient
    }

    func fetchUser(id: String) async throws -> User {
        // Native async implementation
        let url = URL(string: "https://api.example.com/users/\(id)")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode(User.self, from: data)
    }

    // fetchUserPublisher is provided by the default extension
}

Pendekatan ini memungkinkan pemanggil baru menggunakan async/await sementara kode lama terus menggunakan Publisher.

Perhatikan manajemen memori

Selama penjembatanan, Task yang dibuat dapat hidup lebih lama daripada objek yang membuatnya. Sebaiknya selalu gunakan [weak self] atau batalkan task secara eksplisit untuk menghindari kebocoran memori.

ViewModel Hibrida

Sebuah ViewModel dapat memaparkan kedua antarmuka selama periode transisi.

HybridViewModel.swiftswift
// ViewModel supporting both Combine and async/await
import Combine
import SwiftUI

@MainActor
class ProfileViewModel: ObservableObject {
    // Published state for SwiftUI (Combine)
    @Published private(set) var user: User?
    @Published private(set) var isLoading = false
    @Published private(set) var errorMessage: String?

    private let repository: UserRepositoryProtocol
    private var cancellables = Set<AnyCancellable>()
    private var loadTask: Task<Void, Never>?

    init(repository: UserRepositoryProtocol) {
        self.repository = repository
    }

    // Async interface for modern UIKit or SwiftUI with .task
    func loadUser(id: String) async {
        isLoading = true
        errorMessage = nil

        do {
            user = try await repository.fetchUser(id: id)
        } catch {
            errorMessage = error.localizedDescription
        }

        isLoading = false
    }

    // Combine interface for legacy code
    func loadUserPublisher(id: String) {
        isLoading = true
        errorMessage = nil

        repository.fetchUserPublisher(id: id)
            .receive(on: DispatchQueue.main)
            .sink(
                receiveCompletion: { [weak self] completion in
                    self?.isLoading = false
                    if case .failure(let error) = completion {
                        self?.errorMessage = error.localizedDescription
                    }
                },
                receiveValue: { [weak self] user in
                    self?.user = user
                }
            )
            .store(in: &cancellables)
    }

    // Clean cancellation
    func cancelLoading() {
        loadTask?.cancel()
        cancellables.removeAll()
        isLoading = false
    }
}

Migrasi Operator Combine yang Umum

Beberapa operator Combine tidak memiliki padanan langsung di async/await. Berikut cara mereproduksinya.

Padanan Debounce dengan async

DebounceAsync.swiftswift
// Debounce implementation with async/await
import Foundation

actor Debouncer {
    private var task: Task<Void, Never>?
    private let duration: Duration

    init(duration: Duration) {
        self.duration = duration
    }

    // Cancels previous execution and schedules a new one
    func debounce(_ operation: @escaping @Sendable () async -> Void) {
        task?.cancel()

        task = Task {
            do {
                // Wait for the specified duration
                try await Task.sleep(for: duration)
                // Execute operation if not cancelled
                await operation()
            } catch {
                // Task cancelled - expected behavior
            }
        }
    }
}

// Usage in a ViewModel
@MainActor
class SearchViewModel: ObservableObject {
    @Published var searchText = ""
    @Published private(set) var results: [SearchResult] = []

    private let debouncer = Debouncer(duration: .milliseconds(300))
    private let searchService: SearchService

    init(searchService: SearchService) {
        self.searchService = searchService
    }

    func onSearchTextChanged(_ text: String) {
        Task {
            await debouncer.debounce { [weak self] in
                guard let self else { return }
                await self.performSearch(text)
            }
        }
    }

    private func performSearch(_ query: String) async {
        guard query.count >= 3 else {
            results = []
            return
        }

        do {
            results = try await searchService.search(query: query)
        } catch {
            // Error handling
        }
    }
}

Padanan Merge dengan TaskGroup

MergeAsync.swiftswift
// Combining multiple async streams with TaskGroup
import Foundation

struct AsyncMerge {
    // Executes multiple async operations in parallel and returns all results
    static func merge<T>(
        _ operations: [@Sendable () async throws -> T]
    ) async throws -> [T] {
        try await withThrowingTaskGroup(of: T.self) { group in
            // Launch all operations in parallel
            for operation in operations {
                group.addTask {
                    try await operation()
                }
            }

            // Collect results
            var results: [T] = []
            for try await result in group {
                results.append(result)
            }
            return results
        }
    }

    // Streaming version that emits results as they arrive
    static func mergeStream<T: Sendable>(
        _ operations: [@Sendable () async throws -> T]
    ) -> AsyncThrowingStream<T, Error> {
        AsyncThrowingStream { continuation in
            Task {
                await withThrowingTaskGroup(of: T.self) { group in
                    for operation in operations {
                        group.addTask {
                            try await operation()
                        }
                    }

                    do {
                        for try await result in group {
                            continuation.yield(result)
                        }
                        continuation.finish()
                    } catch {
                        continuation.finish(throwing: error)
                    }
                }
            }
        }
    }
}

// Usage
class DataAggregator {
    func fetchAllData() async throws -> AggregatedData {
        // Execute three requests in parallel
        let results = try await AsyncMerge.merge([
            { try await self.fetchUsers() },
            { try await self.fetchPosts() },
            { try await self.fetchComments() }
        ])

        return AggregatedData(
            users: results[0] as! [User],
            posts: results[1] as! [Post],
            comments: results[2] as! [Comment]
        )
    }
}

Siap menguasai wawancara iOS Anda?

Berlatih dengan simulator interaktif, flashcards, dan tes teknis kami.

Kasus Penggunaan di Mana Combine Tetap Lebih Disukai

Meskipun ada keunggulan async/await, beberapa skenario tetap lebih cocok dilayani oleh Combine.

Aliran Event UI Reaktif

SwiftUI dan UIKit menghasilkan aliran event yang berkelanjutan di mana operator Combine (debounce, throttle, combineLatest) bersinar.

UIEventsCombine.swiftswift
// Combine remains optimal for reactive UI events
import Combine
import SwiftUI

class FormViewModel: ObservableObject {
    @Published var email = ""
    @Published var password = ""
    @Published var confirmPassword = ""

    // Derived states computed via Combine
    @Published private(set) var isEmailValid = false
    @Published private(set) var isPasswordStrong = false
    @Published private(set) var passwordsMatch = false
    @Published private(set) var canSubmit = false

    private var cancellables = Set<AnyCancellable>()

    init() {
        setupValidation()
    }

    private func setupValidation() {
        // Email validation with debounce
        $email
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
            .map { email in
                let regex = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/
                return email.wholeMatch(of: regex) != nil
            }
            .assign(to: &$isEmailValid)

        // Password strength validation
        $password
            .map { password in
                password.count >= 8 &&
                password.rangeOfCharacter(from: .uppercaseLetters) != nil &&
                password.rangeOfCharacter(from: .decimalDigits) != nil
            }
            .assign(to: &$isPasswordStrong)

        // Password matching
        Publishers.CombineLatest($password, $confirmPassword)
            .map { password, confirm in
                !password.isEmpty && password == confirm
            }
            .assign(to: &$passwordsMatch)

        // Final combination to enable submit button
        Publishers.CombineLatest3($isEmailValid, $isPasswordStrong, $passwordsMatch)
            .map { $0 && $1 && $2 }
            .assign(to: &$canSubmit)
    }
}

Pola deklaratif ini akan jauh lebih bertele-tele dengan async/await.

Manajemen Koneksi WebSocket

WebSocket memancarkan pesan secara berkelanjutan, sebuah kasus penggunaan yang alami untuk Combine.

WebSocketCombine.swiftswift
// WebSocket with Combine for continuous stream
import Combine
import Foundation

class WebSocketManager: ObservableObject {
    @Published private(set) var messages: [ChatMessage] = []
    @Published private(set) var connectionState: ConnectionState = .disconnected

    private var webSocketTask: URLSessionWebSocketTask?
    private let messageSubject = PassthroughSubject<ChatMessage, Never>()
    private var cancellables = Set<AnyCancellable>()

    // Exposed Publisher for consumers
    var messagePublisher: AnyPublisher<ChatMessage, Never> {
        messageSubject.eraseToAnyPublisher()
    }

    func connect(to url: URL) {
        webSocketTask = URLSession.shared.webSocketTask(with: url)
        webSocketTask?.resume()
        connectionState = .connected

        // Start reception loop
        receiveMessages()

        // Message processing pipeline
        messageSubject
            // Buffer messages to avoid too frequent UI updates
            .collect(.byTime(RunLoop.main, .milliseconds(100)))
            // Accumulate in history
            .scan([ChatMessage]()) { accumulated, new in
                accumulated + new
            }
            .assign(to: &$messages)
    }

    private func receiveMessages() {
        webSocketTask?.receive { [weak self] result in
            switch result {
            case .success(let message):
                if case .string(let text) = message,
                   let data = text.data(using: .utf8),
                   let chatMessage = try? JSONDecoder().decode(ChatMessage.self, from: data) {
                    self?.messageSubject.send(chatMessage)
                }
                // Continue reception
                self?.receiveMessages()

            case .failure(let error):
                self?.connectionState = .error(error.localizedDescription)
            }
        }
    }
}

Daftar Periksa Migrasi Progresif

Migrasi yang sukses mengikuti pendekatan yang metodis. Berikut adalah tahap-tahap yang direkomendasikan.

Tahap 1: Persiapan

  • ✅ Identifikasi Publisher yang digunakan dalam basis kode
  • ✅ Kategorikan: aliran berkelanjutan vs operasi sekali pakai
  • ✅ Buat ekstensi penjembatanan (firstValue, asyncMap)
  • ✅ Definisikan protokol abstrak untuk repositori

Tahap 2: Migrasi Operasi Sekali Pakai

  • ✅ Konversi permintaan jaringan sederhana ke async/await
  • ✅ Migrasikan pembacaan file
  • ✅ Transformasikan operasi basis data
  • ✅ Pertahankan Publisher melalui implementasi default

Tahap 3: Adaptasi ViewModel

  • ✅ Tambahkan metode async ke ViewModel yang sudah ada
  • ✅ Gunakan .task di SwiftUI untuk layar baru
  • ✅ Pertahankan binding @Published untuk kompatibilitas

Tahap 4: Pembersihan

  • ✅ Hapus metode Combine yang sudah tidak berguna
  • ✅ Hapus ekstensi penjembatanan yang tidak digunakan
  • ✅ Dokumentasikan pola Combine yang sengaja dipertahankan

Kesimpulan

Migrasi dari Combine ke async/await merupakan evolusi alami untuk proyek Swift modern. Pendekatan progresif yang menggunakan pola penjembatanan dua arah memungkinkan adopsi keunggulan async/await tanpa keretakan yang tiba-tiba.

Poin kunci yang perlu diingat:

  • ✅ Combine dan async/await menjawab kebutuhan yang berbeda
  • .values mengonversi Publisher menjadi AsyncSequence
  • Future + Task mengenkapsulasi kode async dalam Publisher
  • ✅ Protokol abstrak memudahkan koeksistensi
  • ✅ Combine tetap relevan untuk aliran UI reaktif
  • ✅ Operator seperti debounce dapat dibuat ulang dalam async
  • ✅ Migrasi progresif mengurangi risiko regresi

Tujuannya bukan untuk menghilangkan Combine, melainkan memilih alat yang tepat untuk setiap konteks: async/await untuk operasi sekali pakai, Combine untuk aliran event yang berkelanjutan.

Mulai berlatih!

Uji pengetahuan Anda dengan simulator wawancara dan tes teknis kami.

Tag

#swift
#ios
#combine
#async-await
#migration

Bagikan

Artikel terkait