LoginSignup
3
2

More than 3 years have passed since last update.

[Swift5][Combine] collect()を使ってPublisherの配列から出力をまとめた配列を返すPublisherを作成する

Last updated at Posted at 2021-05-09

TL;DR

  • Publisherの配列があり、全てのPublisherの出力をまとめた配列を返すPublisherが欲しかったらcollect()を使おう。
  • 結果が2次元配列になってしまう場合はflatMapで1次元化しよう。

モチベーション

Publisherの配列から結果をまとめて返してくれるPublisherを作ろうと思いました。

例えばFirebaseのStorageからReferenceを取得する場面があるとします。
Referenceは以下のパスで画像を保存しています。

root
└── Store // お店の画像を保存するパス
    ├── Header // ヘッダー画像のパス
    │    └──{ID} // 画像
    └── Image // その他の画像のパス
         ├── UserA // Userごとのパス
         │   └──{ID} // 画像
         ├── UserB
         │   └──{ID}
         └── UserC
             └──{ID}

お店というエンティティに対し、ヘッダー画像とその他の画像があります。
その他の画像はいろんなユーザーが投稿できるためユーザーごとにディレクトリを分けています。
(この設計が正しいかどうかは分かりません。。。)

Headerのようにreferenceから直下の画像を取得する時はこんな感じでReferenceを取得するFutureを作ってやれば可能です。

extension StorageReference {

    func getReferences() -> AnyPublisher<[StorageReference], Error> {

        Deferred {
            Future<[StorageReference], Error> { [weak self] promise in

                guard let self = self else { return }

                self.listAll { result, error in

                    if let error = error {
                        promise(.failure(error))
                        return
                    }

                    promise(.success(result.items))
                }
            }

        }.eraseToAnyPublisher()
    }
}

// Usage
Storage.storage().reference(withPath: "パス")
    .getReferences()
    .sink(
        receiveCompletion: { result in
            // なんかする
        }, receiveValue: { references in
            // なんかする
        }
    )
    .store(in: &cancellables)

けどImageのreferenceを取得してくるのはちょっとめんどくさいです。
単純にImage直下のReferenceを取得してきても、UserA,B,CのReferenceがPrefixとして取得されるだけなので、画像のReferenceは取得できません。
画像を取得するには取得したUserA、B、CのReferenceそれぞれに対して、listAllを呼ぶ必要があります。

forEachで愚直にやってみた場合

まずはImage配下から取得できるのがitemではなくprefixなので、prefixが取得できた時はPrefixを返すように先程のFutureを変更します。

    func getReferences() -> AnyPublisher<[StorageReference], Error> {

        Deferred {
            Future<[StorageReference], Error> { [weak self] promise in

                guard let self = self else { return }

                self.listAll { result, error in

                    if let error = error {
                        promise(.failure(error))
                        return
                    }

                    if result.prefixes.isEmpty {
                        promise(.success(result.items))
                    } else {
                        promise(.success(result.prefixes)) // UserA, B, Cのreferenceが返ってくる。
                    }
                }
            }

        }.eraseToAnyPublisher()
    }

こうして返されるのがRefenrenceの配列で、これらをまた画像の取得用のPublisherに変換するとPublisherの配列ができます。
そのPublisherの配列をforEachでそれぞれsubscribeしていくと以下のようになります。

Storage.storage().reference(withPath: "パス")
    .getReferences()
    .catch { _ -> Just<[StorageManager.Reference]> in
        return .init([])
    }
    .sink { prefixes in
        prefixes
            .map { $0.getReferences() }
            .forEach {
                $0.catch { _ -> Just<[StorageManager.Reference]> in
                    return .init([])
                }
                .sink { [weak self] references in

                    guard let self = self else { return }

                    self.imageReferences.append(contentsOf: references)
                }
                .store(in: &self.cancellables)
           }
    }
    .store(in: &cancellables)

上記の書き方でもUserA、B、Cのパス全ての画像を取得してくることは可能です。
でもちょっと嫌ですよね。。。
ネスト深いし、.store(in: )を何度も呼んでいてどこで何をsubscribeしてるのか読みにくいです。

collectというOperatorを使おう

上記のコードはcollect()というOperatorを使うと解決できます。

名称未設定.jpg

collect()は複数の出力を配列にして出力してくれるOperatorです。

※0~10を出力するPublisherをIntの配列を出力するPublisherに変換する例

import Combine

var cancellables = Set<AnyCancellable>()

(0...10).publisher
    .handleEvents(receiveOutput: {
        print($0)
    })
    .collect()
    .sink { print("\($0)") }
    .store(in: &cancellables)

結果

0
1
2
3
4
5
6
7
8
9
10
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

途中までは数字を個別に出力していますが、collectを噛ませると全てまとめて配列にして出力してくれることが分かります。

これを先程のStorageReferenceを使うケースで使ってみました。

結論以下の形になります。

Storage.storage().reference(withPath: "パス")
    .getReferences()
    .catch { _ -> Just<[StorageManager.Reference]> in
        return .init([])
    }
    .flatMap {
        $0.publisher // [StorageReference]の要素を出力するpublisherを作成
            .flatMap { $0.getReferences() } // 配下の画像のreferenceを取得するAnyPublisherに変換
            .collect() // collectで全ての出力をまとめて配列にするPublisherに変換
        }
    .map { $0.flatMap { $0 } } // [[StorageReference]]として返ってくるのでflatMapで1次元配列にする。
    .sink(
        receiveCompletion: { result in
            // なんかする
        }, receiveValue: { references in
            // なんかする
        }
    )
    .store(in: &cancellables)

コメントで説明しているように、StorageReferenceを個別にSinkするのではなく、Collectを使って出力を配列化して返すPublisherを作成し、flatMapで変換しています。
今回はcollectでまとめた出力が元々配列だったことで2次元配列になってしまったため、出力結果をflatMapにかけるmapを噛ませて(ややこしい。。。)1次元配列に直しています。

これで個別にPublisherをSinkしなくても結果をまとめてSubscribeできるようになりました。

まとめ

  • Publisherの配列の出力をまとめるときはcollectが使える。
  • 配列が多次元になる時はflatMapで1次元になおそう。

おまけ

毎回flatMapとcollectで変換するのも大変なのでArrayのextensionで変換したPublisherを作成できるようにしてみました。

extension Array where Element: StorageReference {

    func getReferences() -> AnyPublisher<[StorageReference], Error> {
        publisher
            .flatMap { $0.getReferences() }
            .collect()
            .map { $0.flatMap { $0 } }
            .eraseToAnyPublisher()
    }

}

Storage.storage().reference(withPath: "パス")
    .getReferences()
    .catch { _ -> Just<[StorageManager.Reference]> in
        return .init([])
    }
    .flatMap { $0.getReferences() }
    .sink(
        receiveCompletion: { result in
            // なんかする
        }, receiveValue: { references in
            // なんかする
        }
    )
    .store(in: &cancellables)

だいぶスッキリしたと思います。

欲を言えばstorageを取得した時にprefixがあれば再帰的に画像を取得するようにしたかったんですが、難しくて諦めました。。。
アルゴリズムに強くなりたい。
元気がある時に再チャレンジしてみようと思います。

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2