Master Combine framework for reactive programming - publishers, subscribers, operators, schedulers
Reactive programming with Apple's Combine framework for handling asynchronous events and data streams.
parameters:
use_async_await:
type: boolean
default: true
description: Prefer async/await where possible (iOS 15+)
scheduler:
type: string
enum: [main, background, immediate]
default: main
error_handling:
type: string
enum: [catch, retry, replace]
default: catch
| Concept | Description | |---------|-------------| | Publisher | Emits values over time | | Subscriber | Receives values from publisher | | Operator | Transforms/filters values | | Subject | Publisher + manual value injection | | Cancellable | Subscription lifecycle |
| Publisher | Purpose |
|-----------|---------|
| Just | Single value, then complete |
| Future | Single async result |
| PassthroughSubject | Manual value broadcast |
| CurrentValueSubject | Manual + current value |
| @Published | Property wrapper publisher |
| Category | Operators | |----------|-----------| | Transform | map, flatMap, scan | | Filter | filter, removeDuplicates, compactMap | | Combine | merge, combineLatest, zip | | Timing | debounce, throttle, delay | | Error | catch, retry, mapError |
import Combine
final class SearchViewModel: ObservableObject {
@Published var searchText = ""
@Published private(set) var results: [SearchResult] = []
@Published private(set) var isLoading = false
@Published private(set) var error: Error?
private var cancellables = Set<AnyCancellable>()
private let searchService: SearchService
init(searchService: SearchService) {
self.searchService = searchService
setupSearch()
}
private func setupSearch() {
$searchText
.debounce(for: .milliseconds(300), scheduler: RunLoop.main) // Wait for typing pause
.removeDuplicates() // Don't search same query twice
.filter { $0.count >= 2 } // Minimum characters
.handleEvents(receiveOutput: { [weak self] _ in
self?.isLoading = true
self?.error = nil
})
.flatMap { [searchService] query -> AnyPublisher<[SearchResult], Never> in
searchService.search(query: query)
.catch { error -> Just<[SearchResult]> in
// Handle error, return empty
return Just([])
}
.eraseToAnyPublisher()
}
.receive(on: DispatchQueue.main)
.sink { [weak self] results in
self?.isLoading = false
self?.results = results
}
.store(in: &cancellables)
}
}
final class EventBus {
static let shared = EventBus()
// PassthroughSubject - no current value
let userActions = PassthroughSubject<UserAction, Never>()
// CurrentValueSubject - maintains current value
let authState = CurrentValueSubject<AuthState, Never>(.loggedOut)
private init() {}
func send(_ action: UserAction) {
userActions.send(action)
}
func login(user: User) {
authState.send(.loggedIn(user))
}
func logout() {
authState.send(.loggedOut)
}
}
enum UserAction {
case tappedButton(String)
case viewedScreen(String)
case completedPurchase(orderId: String)
}
enum AuthState {
case loggedOut
case loggedIn(User)
}
// Subscription
class AnalyticsService {
private var cancellables = Set<AnyCancellable>()
init() {
EventBus.shared.userActions
.sink { [weak self] action in
self?.track(action)
}
.store(in: &cancellables)
}
private func track(_ action: UserAction) {
// Send to analytics
}
}
final class CheckoutViewModel: ObservableObject {
@Published var cartItems: [CartItem] = []
@Published var shippingAddress: Address?
@Published var paymentMethod: PaymentMethod?
@Published var promoCode: String = ""
@Published private(set) var canCheckout = false
@Published private(set) var total: Decimal = 0
private var cancellables = Set<AnyCancellable>()
init() {
setupValidation()
setupTotalCalculation()
}
private func setupValidation() {
Publishers.CombineLatest3($cartItems, $shippingAddress, $paymentMethod)
.map { items, address, payment in
!items.isEmpty && address != nil && payment != nil
}
.assign(to: &$canCheckout)
}
private func setupTotalCalculation() {
Publishers.CombineLatest($cartItems, $promoCode)
.map { items, promo -> Decimal in
let subtotal = items.reduce(0) { $0 + $1.price * Decimal($1.quantity) }
let discount = self.calculateDiscount(promo: promo, subtotal: subtotal)
return subtotal - discount
}
.assign(to: &$total)
}
private func calculateDiscount(promo: String, subtotal: Decimal) -> Decimal {
// Promo code logic
return 0
}
}
extension Publisher {
func retryWithBackoff(
maxRetries: Int = 3,
initialDelay: TimeInterval = 1,
maxDelay: TimeInterval = 30
) -> AnyPublisher<Output, Failure> {
self.catch { error -> AnyPublisher<Output, Failure> in
guard maxRetries > 0 else {
return Fail(error: error).eraseToAnyPublisher()
}
let delay = min(initialDelay * pow(2, Double(3 - maxRetries)), maxDelay)
return Just(())
.delay(for: .seconds(delay), scheduler: DispatchQueue.global())
.flatMap { _ in
self.retryWithBackoff(
maxRetries: maxRetries - 1,
initialDelay: initialDelay,
maxDelay: maxDelay
)
}
.eraseToAnyPublisher()
}
.eraseToAnyPublisher()
}
}
// Usage
apiClient.fetchData()
.retryWithBackoff(maxRetries: 3)
.catch { error -> Just<Data> in
Logger.network.error("Final failure: \(error)")
return Just(Data())
}
.sink { data in
// Process data
}
.store(in: &cancellables)
extension Publisher {
func firstValue() async throws -> Output {
try await withCheckedThrowingContinuation { continuation in
var cancellable: AnyCancellable?
cancellable = self.first()
.sink(
receiveCompletion: { completion in
switch completion {
case .finished:
break
case .failure(let error):
continuation.resume(throwing: error)
}
cancellable?.cancel()
},
receiveValue: { value in
continuation.resume(returning: value)
}
)
}
}
}
// Usage
let result = try await somePublisher.firstValue()
| Issue | Cause | Solution | |-------|-------|----------| | Sink never called | No strong reference | Store in cancellables Set | | UI not updating | Wrong scheduler | Use .receive(on: DispatchQueue.main) | | Memory leak | Strong reference in closure | Use [weak self] | | Duplicate events | Missing removeDuplicates | Add .removeDuplicates() | | Publisher completes early | Using first() or prefix() | Check operator semantics |
// Print debug info for each event
publisher
.print("Debug")
.sink { ... }
// Handle events at each stage
publisher
.handleEvents(
receiveSubscription: { _ in print("Subscribed") },
receiveOutput: { print("Output: \($0)") },
receiveCompletion: { print("Completed: \($0)") },
receiveCancel: { print("Cancelled") }
)
.sink { ... }
// Breakpoint on specific conditions
publisher
.breakpoint(receiveOutput: { $0 > 100 })
.sink { ... }
validation:
- rule: store_cancellables
severity: error
check: All subscriptions must be stored in cancellables
- rule: weak_self_in_closures
severity: warning
check: Use [weak self] in sink closures
- rule: receive_on_main
severity: warning
check: UI updates must receive on main queue
Skill("swift-combine")
swift-swiftui - @Published integrationswift-concurrency - async/await alternativeswift-networking - Network publishersnpx skills add pluginagentmarketplace/swift-combine下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer