Combine Framework: Swiftにおけるリアクティブプログラミング

Swiftで非同期データストリームを扱うためのCombineを習得します。Publishers、Subscribers、Operators、そしてiOSアプリ向けの高度なパターンを解説します。

Swift iOS のリアクティブプログラミングのための Combine フレームワークガイド

リアクティブプログラミングは、iOSアプリケーションにおける非同期イベントとデータストリームの扱い方を一変させます。Appleのネイティブフレームワークである Combine は、複雑なデータパイプラインを宣言的かつ型安全に組み立てるためのアプローチを提供します。本ガイドは基礎概念から本番運用に耐えるパターンまで網羅します。

なぜ RxSwift ではなく Combine なのか?

Combine は iOS 13+ に組み込まれており、Apple による最適化のおかげで高いパフォーマンスを発揮し、SwiftUI ともシームレスに連携します。外部の依存関係を管理する必要はありません。

Combine の中核概念

Combine は3つの主要な概念で構成されています。値を発行する Publishers、それを受け取る Subscribers、そしてその間でデータを変換する Operators です。このアーキテクチャによって、リアクティブで合成可能なデータパイプラインを構築できます。

Publisher:データソース

Publisher は、時間とともに値の列を発行できる型です。各 Publisher は2つの関連型を宣言します。発行される値の型 (Output) と、発生し得るエラーの型 (Failure) です。さまざまな種類の Publisher を作成する方法を示します。

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"))
    }
}

エラー型に Never を指定すると、その Publisher は決して失敗しないことを意味します。これはコンパイル時に保証されるため、エラーハンドリングのコードを簡潔にできます。

Subscriber:値を受け取る

Subscriber は Publisher を購読して値を受け取ります。sink メソッドは Subscriber を作る最も一般的な方法です。エラーまたは完了用と、受信した各値用の2つのクロージャを受け取ります。

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
メモリリークに注意

sink() が返す AnyCancellable は必ず保持してください。参照がないと購読は即座にキャンセルされ、値を受け取ることはできません。

Operators によるデータ変換

Operators は Combine の中核です。データストリームを宣言的に変換、フィルタリング、結合できます。各 Operator は新しい Publisher を返すため、連結して使うことができます。

基本的な変換 Operators

変換 Operators は発行された各値を加工します。map は値を変換し、flatMap はネストされた Publisher を平坦化し、compactMap は 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)

フィルタリング Operators

フィルタリング Operators はパイプラインを通過する値を制御します。不要な処理を避け、パフォーマンスを最適化するために欠かせません。

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

iOSの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

複数の Publisher を組み合わせる

実際のアプリケーションでは、複数のデータソースを組み合わせる必要が頻繁にあります。Combine は、こうした複数ストリームを取り回すための Operators を複数提供しています。

CombineLatest と Zip

combineLatest は、いずれかの Publisher が値を発行するたびに、ほかの Publisher の最新値と組み合わせて発行します。zip はすべての Publisher が値を発行するのを待ってから組み合わせます。

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

merge は、同じ型の複数の Publisher を1つのストリームに統合します。値はどの Publisher が送ったかに関係なく、発行された順に到着します。

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!

Combine におけるエラーハンドリング

エラーハンドリングは Combine の中核に組み込まれています。Publisher の Failure 型のおかげで、すべてのエラーが扱われていることをコンパイラが検証できます。

リカバリ戦略

Combine にはエラー処理のための Operators が複数あります。別の Publisher に置き換える catch、再試行する retry、デフォルト値に置き換える replaceError などです。

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 と Error の使い分け

setFailureType(to:) を使うと Never の Publisher を失敗し得るものに変換でき、replaceError(with:)catch を使えば逆方向に変換できます。

Combine による MVVM パターン

Combine は MVVM (Model-View-ViewModel) パターンと自然に統合されます。ViewModel は Publisher を公開し、View がそれを観測することで、データと UI の間にリアクティブな結合が生まれます。

完全なリアクティブ ViewModel

検索、ローディング、エラーハンドリングを備えたユーザーリスト用 ViewModel の例です。

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)
    }
}

Combine と URLSession を使ったサービス

URLSession は dataTaskPublisher を通じて Combine をネイティブに統合しています。再利用可能なネットワークサービスの作り方は次のとおりです。

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()
    }
}

iOSの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

SwiftUI との統合

Combine と SwiftUI は強力な組み合わせです。ObservableObject@Published プロパティはビューの更新を自動的にトリガします。

Combine ViewModel を使った SwiftUI ビュー

ViewModel を 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)
    }
}

高度なパターン

キャンセルと自動クリーンアップ

購読のライフサイクル管理はメモリリークを避けるうえで非常に重要です。AnyCancellable と組み合わせる cancellables パターンによって、自動的なクリーンアップが保証されます。

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

Schedulers は処理がどのスレッドで実行されるかを制御します。バックグラウンド処理には subscribe(on:)、UI 更新には receive(on:) を使用します。

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()
}

まとめ

Combine は iOS アプリケーションで非同期データストリームを扱うための強力で宣言的なアプローチを提供します。重要なポイントは次のとおりです。

Publishers が時間とともに値を発行する ✅ Subscribers がそれらの値を受け取り処理する ✅ Operators がストリームを変換し結合する ✅ AnyCancellable が購読のライフサイクルを管理する ✅ SwiftUI と組み合わせた @Published が自動的なリアクティブバインディングを生む ✅ Schedulers がスレッディングを制御し最適なパフォーマンスを引き出す

Combine を習得することで、堅牢で保守しやすく、リアクティブな iOS アプリを構築できます。SwiftUI とのネイティブ統合により、現代の iOS 開発には欠かせないツールとなっています。

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

タグ

#combine
#swift
#ios
#reactive
#async

共有

関連記事