Swift・iOS

Swiftを中心に学んだことを記録に残すブログです。技術に関係ない記事もたまに書いています。

【Combine】Futureの使い所

 

はじめに

Appleのドキュメント(https://developer.apple.com/documentation/combine/future)に、Futureとは「最終的に単一の値を生成し、その後終了または失敗するPublisher」と記載されていますが、これだけだと使い所がわかりません・・・。使い所に関しては以下のドキュメント(https://developer.apple.com/documentation/combine/using-combine-for-your-app-s-asynchronous-code)に記載があります。ドキュメント内の"Replace Completion-Handler Closures with Futures"を確認しながら、自分でもサンプルを作って挙動を確かめてみました。

 

開発環境

 

本題 

Futureの使い所

Futureの使い所としては、「Aの処理が完了したらBの処理を実行する」という、これまで通信処理などで利用していたコールバック処理をCombineで実装する(Combineに置き換える)場合に使います。

 

具体例

「20までカウントアップしたら完了メッセージを表示する」という仕様のサンプルを実装してみます。動きのイメージは以下。

f:id:hfoasi8fje3:20210825203041g:plain

 

コールバック処理にCombineを使わない場合は以下のようになります。

// カウントアップ処理
func startCounting(completionHandler: @escaping () -> Void) {
    Timer.publish(every: 0.1, on: .main, in: .common)
        .autoconnect()
        .receive(on: RunLoop.main)
        .sink { [weak self] _ in
            guard let self = self else { return }
            
            if self.count < self.endCount {
                self.count += 1
            } else {
                // 処理が終了した時点でcompletionHandler()を呼ぶ
                completionHandler()
            }
        }
        .store(in: &self.cancellables)
}
// カウントアップ処理の呼び出し元
startCounting() { [weak self] in
    // startCounting()の処理内でcompletionHandler()が呼ばれると以下の処理を実行する
    guard let self = self else { return }
    
    withAnimation(.easeOut(duration: 0.8)) {
        self.isCountingCompleted = true
    }
    self.cancellables.removeAll()
}

 

CombineのFutureでコールバック処理を実装すると以下のようになります。

// カウントアップ処理
func startCounting() -> Future<Void, Never> {
    return Future() { promise in
        Timer.publish(every: 0.1, on: .main, in: .common)
            .autoconnect()
            .receive(on: RunLoop.main)
            .sink { [weak self] _ in
                guard let self = self else { return }
                
                if self.count < self.endCount {
                    self.count += 1
                } else {
                    // カウントアップが完了した時点でpromiseを実行する
                    // promiseを実行するとFutureは値を発行(公開)する
                    promise(Result.success(()))
                }
            }
            .store(in: &self.cancellables)
    }
}
// カウントアップ処理の呼び出し元
// startCounting()の戻り値であるFutureはPublishersの一種であるため、
// Operatorsで値を変換したり、Subscribersで値を受け取ることができる
startCounting()
    // startCounting()の処理内でpromiseが呼ばれると以下の処理を実行する
    .sink { _ in
        withAnimation(.easeOut(duration: 0.8)) {
            self.isCountingCompleted = true
        }
        self.cancellables.removeAll()
    }
    .store(in: &cancellables)

 

具体例で使用したサンプルの全体の実装

ContentView.swift
import SwiftUI

struct ContentView: View {
    @ObservedObject private var viewModel: ContentViewModel
    
    init(viewModel: ContentViewModel) {
        self.viewModel = viewModel
    }
    
    var body: some View {
        GeometryReader { geometry in
            ZStack {
                Text("\(viewModel.count)")
                    .font(.title)
                    .fontWeight(.bold)
                    .padding()
                
                if viewModel.isCountingCompleted {
                    Text("Completed!")
                        .frame(width: 220, height: 50)
                        .background(Color(red: 0.8, green: 0.8, blue: 0.8))
                        .cornerRadius(110)
                        .shadow(color: Color(red: 0.85, green: 0.85, blue: 0.85), radius: 20)
                        .position(x: geometry.size.width / 2, y: 30)
                        .transition(.move(edge: .top))
                }
            }
            .frame(width: geometry.size.width, height: geometry.size.height)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(viewModel: ContentViewModel())
    }
}

 

ContentViewModel.swift
import SwiftUI
import Combine

final class ContentViewModel: ObservableObject {
    @Published var count = 0
    private let endCount: Int = 20
    @Published var isCountingCompleted = false
    
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        // カウントアップ処理の呼び出し元
        // startCounting()の戻り値であるFutureはPublishersの一種であるため、
        // Operatorsで値を変換したり、Subscribersで値を受け取ることができる
        startCounting()
            // startCounting()の処理内でpromiseが呼ばれると以下の処理を実行する
            .sink { _ in
                withAnimation(.easeOut(duration: 0.8)) {
                    self.isCountingCompleted = true
                }
                self.cancellables.removeAll()
            }
            .store(in: &cancellables)
    }
    
    // カウントアップ処理
    func startCounting() -> Future<Void, Never> {
        return Future() { promise in
            Timer.publish(every: 0.1, on: .main, in: .common)
                .autoconnect()
                .receive(on: RunLoop.main)
                .sink { [weak self] _ in
                    guard let self = self else { return }
                    
                    if self.count < self.endCount {
                        self.count += 1
                    } else {
                        // カウントアップが完了した時点でpromiseを実行する
                        // promiseを実行するとFutureは値を発行(公開)する
                        promise(Result.success(()))
                    }
                }
                .store(in: &self.cancellables)
        }
    }
}

 

おわりに

今回読んだドキュメントに記載のある"Replace Repeatedly Invoked Closures with Subjects"の内容については別途記事にしようと思います。

 

参考