[Swift] [Combine] 配列を引数にとる関数の戻り値が AnyPublisher であるときのインターフェースの検討
伝えたいこと
- 配列を引数にとる関数の戻り値が AnyPublisher の場合のインターフェースの場合は以下のことに注意する
- Input と Output の紐付けが必要な場合は、順序による暗黙的な紐付けを行わないようにすること
- Input と Output の型は統一性があったほうが自然だということ
- Output を 単体 or 配列 で迷った場合は、より汎用的な 単体 を用いて、 Output をまとめて処理したい場合は、subscribe 側で
collect()
すること
はじめに
Combine の特性上、配列処理との相性がとても良いです。
今回は、配列を引数にとる関数の戻り値が AnyPublisher の場合のインターフェースの検討をおこなっていきます。
やりたいこと
具体例があると想像しやすいので、以下の定義されているときに、『複数の写真のサムネイルに取得して、それを Realm などのデータストアに永続化する処理』をやりたいとして、それを例に検討します。
import Combine
import Foundation
private var cancellables: Set<AnyCancellable> = []
// PhotoEntity
struct PhotoEntity: Identifiable {
/// id
let id: String
/// サムネイルのURL(サムネイルの未取得の場合は nil)
var thumbnail: URL?
}
// サムネイルの未取得の PhotoEntity の配列
let photos: [PhotoEntity] = [.init(id: UUID().uuidString), .init(id: UUID().uuidString)]
// サムネイルのfetch処理
func fetchThumbnail(photoId: String) -> URL {
URL(string: photoId)!
}
// 保存処理(単体版)
func store(photo: PhotoEntity) {
// Realm などへのデータストアへの保存処理...
print("PhotoEntityの保存に成功しました! id: \(photo.id), thumbnail: \(String(describing: photo.thumbnail))")
}
// 保存処理(複数版)
func store(photos: [PhotoEntity]) {
photos.forEach { store(photo: $0) }
}
前提条件は以下になります。
- サムネイルの未取得の
PhotoEntity
の配列がある - サムネイルの URL の fetch 処理には
PhotoEntity
のid
が必要 - Realm などへのデータストアへの保存処理の
store()
は単体でも複数でも保存が可能である - サムネイル取得処理は AnyPublisher 型で返却され、それを subscribe した Output を
store()
していくという実装方針
インターフェースの案
AnyPublisher を返却する関数の案として、以下の A 〜 F までの 6 つの案を考えたとき、どれが良いかを検討します。
protocol PhotoDownloadDriverProtocol {
// 案 A
func fetchThumbnailA(photoIds: [String]) -> AnyPublisher<[URL], Never>
// 案 B
func fetchThumbnailB(photoIds: [String]) -> AnyPublisher<URL, Never>
// 案 C
func fetchThumbnailC(photoIds: [String]) -> AnyPublisher<(photoId: String, thumbnailURL: URL), Never>
// 案 D
func fetchThumbnailD(photoIds: [String]) -> AnyPublisher<PhotoEntity, Never>
// 案 E
func fetchThumbnailE(photos: [PhotoEntity]) -> AnyPublisher<PhotoEntity, Never>
// 案 F
func fetchThumbnailF(photos: [PhotoEntity]) -> AnyPublisher<[PhotoEntity], Never>
}
A 〜 F までの 6 つの案の違いを表でまとめると以下になります。
案 | Input | Output |
---|---|---|
A | photoIds: [String] |
[URL] |
B | photoIds: [String] |
URL |
C | photoIds: [String] |
(String, URL) |
D | photoIds: [String] |
PhotoEntity |
E | photos: [PhotoEntity] |
PhotoEntity |
F | photos: [PhotoEntity] |
[PhotoEntity] |
これらのインターフェースの案の中で、どれがよさそうか検討します。
先に結論
結論は以下の表の通りになります。
『評価』 の列を見ていただければと思います。
案 | Input | Output | 評価 |
id と URL の紐付けが 可能 or 不可能 |
In/Out の型の統一性 | Output の単数形 or 複数形 |
---|---|---|---|---|---|---|
A | [String] |
[URL] |
x | 不可能☔️ | - | 複数形🌥 |
B | [String] |
URL |
x | 不可能☔️ | - | 単数形 🌟 |
C | [String] |
(String, URL) |
◎ | 可能🌟 | あり🌟 | 単数形 🌟 |
D | [String] |
PhotoEntity |
◯ | 可能🌟 | なし🌥 | 単数形 🌟 |
E | [PhotoEntity] |
PhotoEntity |
◎ | 可能🌟 | あり🌟 | 単数形 🌟 |
F | [PhotoEntity] |
[PhotoEntity] |
△ | 可能🌟 | あり🌟 | 複数形🌥 |
評価が ◎ の案 C もしくは 案 E をお勧めします。
案 D や 案 F もそこまで問題ないと思います。
一方で、案 A と 案 B はお勧めできません。
結論に至った理由
結論に至った理由は AnyPublisher を返却を受け取る subscribe 側の実装をみてみるとわかるかと思います。
Driver の実装
Driver の実装はどのパターンでも、インターフェースに合わせて実装しているだけなので、良い/悪いの差は特にない認識です。
すでにサムネイル取得済みの写真のついて、サムネイルの取得をスキップしたい場合は、その制御の実装は Driver 内ではなく、Driver を使用する方の実装に寄せるべきかと思いますので、Driver 内では特に実装しません。
class PhotoDownloadDriver: PhotoDownloadDriverProtocol {
// 案 A
func fetchThumbnailA(photoIds: [String]) -> AnyPublisher<[URL], Never> {
Just(photoIds.map { fetchThumbnail(photoId: $0) })
.eraseToAnyPublisher()
}
// 案 B
func fetchThumbnailB(photoIds: [String]) -> AnyPublisher<URL, Never> {
photoIds.publisher
.map { fetchThumbnail(photoId: $0) }
.eraseToAnyPublisher()
}
// 案 C
func fetchThumbnailC(photoIds: [String]) -> AnyPublisher<(photoId: String, thumbnailURL: URL), Never> {
photoIds.publisher
.map { (photoId: $0, thumbnailURL: fetchThumbnail(photoId: $0)) }
.eraseToAnyPublisher()
}
// 案 D
func fetchThumbnailD(photoIds: [String]) -> AnyPublisher<PhotoEntity, Never> {
photoIds.publisher
.map { PhotoEntity(id: $0, thumbnail: fetchThumbnail(photoId: $0)) }
.eraseToAnyPublisher()
}
// 案 E
func fetchThumbnailE(photos: [PhotoEntity]) -> AnyPublisher<PhotoEntity, Never> {
photos.publisher
.map { PhotoEntity(id: $0.id, thumbnail: fetchThumbnail(photoId: $0.id)) }
.eraseToAnyPublisher()
}
// 案 F
func fetchThumbnailF(photos: [PhotoEntity]) -> AnyPublisher<[PhotoEntity], Never> {
Just(photos.map { PhotoEntity(id: $0.id, thumbnail: fetchThumbnail(photoId: $0.id)) })
.eraseToAnyPublisher()
}
}
let photoDownloadDriver: PhotoDownloadDriver = .init()
※ この記事の趣旨として Driver の実装は特に気にしなくても問題ありません。
subscribe 側の実装
ここの実装の差が本記事のポイントになります。
一旦、全てのパターンで実装してみたものを並べたいと思います。
// 案 A の場合
photoDownloadDriver.fetchThumbnailA(photoIds: photos.map { $0.id })
.sink { print("completion A: \($0)") } receiveValue: { urls in
// 入力数と出力数があっているかが暗黙的である
guard photos.count == urls.count else {
assertionFailure("photos.count is not equal urls.count.")
return
}
// 入力した photos の index と出力の urls の index が対応していることが暗黙的である
store(photos: photos.indices.map { PhotoEntity(id: photos[$0].id, thumbnail: urls[$0]) })
}
.store(in: &cancellables)
// 案 B の場合
photoDownloadDriver.fetchThumbnailB(photoIds: photos.map { $0.id })
.zip(photos.publisher) // zip() で出力を合わせる(入出力の組み合わせが一致していることは暗黙的である)
.sink { print("completion B: \($0)") } receiveValue: { url, photo in
// 入力した photos の id の順番と出力のサムネイルの URL の順番が対応していることが暗黙的である
store(photo: PhotoEntity(id: photo.id, thumbnail: url))
}
.store(in: &cancellables)
// 案 C の場合
photoDownloadDriver.fetchThumbnailC(photoIds: photos.map { $0.id })
.sink { print("completion C: \($0)") } receiveValue: { store(photo: PhotoEntity(id: $0, thumbnail: $1)) }
.store(in: &cancellables)
// 案 D の場合
photoDownloadDriver.fetchThumbnailD(photoIds: photos.map { $0.id })
.sink { print("completion D: \($0)") } receiveValue: { store(photo: $0) }
.store(in: &cancellables)
// 案 E の場合
photoDownloadDriver.fetchThumbnailE(photos: photos)
.sink { print("completion E: \($0)") } receiveValue: { store(photo: $0) }
.store(in: &cancellables)
// 案 F の場合
photoDownloadDriver.fetchThumbnailF(photos: photos)
.sink { print("completion F: \($0)") } receiveValue: { store(photos: $0) }
.store(in: &cancellables)
それぞれ個別に検討していきます。
案 A、案 B の場合
案 A と案 B については他の案と大きく異なります。
案 A と案 B は、どの id
の入力に対して、処理が行われて、サムネイルの URL が返却されたかの情報が消失してしまい、id
と URL
を再度紐付けを行おうとするときに、順序による暗黙的な紐付けしかできなくなってしまいます。
// 案 A の場合
photoDownloadDriver.fetchThumbnailA(photoIds: photos.map { $0.id })
.sink { print("completion A: \($0)") } receiveValue: { urls in
// 入力数と出力数があっているかが暗黙的である
guard photos.count == urls.count else {
assertionFailure("photos.count is not equal urls.count.")
return
}
// 入力した photos の index と出力の urls の index が対応していることが暗黙的である
store(photos: photos.indices.map { PhotoEntity(id: photos[$0].id, thumbnail: urls[$0]) })
}
.store(in: &cancellables)
// 案 B の場合
photoDownloadDriver.fetchThumbnailB(photoIds: photos.map { $0.id })
.zip(photos.publisher) // zip() で出力を合わせる(入出力の組み合わせが一致していることは暗黙的である)
.sink { print("completion B: \($0)") } receiveValue: { url, photo in
// 入力した photos の id の順番と出力のサムネイルの URL の順番が対応していることが暗黙的である
store(photo: PhotoEntity(id: photo.id, thumbnail: url))
}
.store(in: &cancellables)
Output の順番が必ず、Input の順番を維持しているのであれば、問題ないのですが、それを保証するように Driver の実装を行う必要がでてきてしまい、暗黙的な制約が生じてしまうので、そのような実装は避けた方がよいです。
案 C、案 D の場合
案 A と案 B と違い、案 C と案 D の Output は、(id, サムネイルのURL)
が組み合わせになったタプル、もしくは、id
と サムネイルのURL
がセットになった PhotoEntity
の struct で返却されるので、暗黙的な紐付けを行うことなく、store()
することができます。
// 案 C の場合
photoDownloadDriver.fetchThumbnailC(photoIds: photos.map { $0.id })
.sink { print("completion C: \($0)") } receiveValue: { store(photo: PhotoEntity(id: $0, thumbnail: $1)) }
.store(in: &cancellables)
// 案 D の場合
photoDownloadDriver.fetchThumbnailD(photoIds: photos.map { $0.id })
.sink { print("completion D: \($0)") } receiveValue: { store(photo: $0) }
.store(in: &cancellables)
そうなると案 C、案 D の (id, サムネイルのURL)
のタプルと、PhotoEntity
のどちらがよいかという議論になるのですが、Input が id
の単体であることを考えると、Input/Output の型の統一性がある、(id, サムネイルのURL)
のタプルを返却する案 C の方がわずかにですがよいと思われます。
また、案C であれば、『サムネイルのURL
が nil
かもしれない』という心配もする必要がないのも、メリットでもあります。
案 E、案 F の場合
案 D の Input が id
の単体であったものを、PhotoEntity
に変更したものが案 E、さらに、案 E の Output を配列にしたものが案 F となります。
// 案 E の場合
photoDownloadDriver.fetchThumbnailE(photos: photos)
.sink { print("completion E: \($0)") } receiveValue: { store(photo: $0) }
.store(in: &cancellables)
// 案 F の場合
photoDownloadDriver.fetchThumbnailF(photos: photos)
.sink { print("completion F: \($0)") } receiveValue: { store(photos: $0) }
.store(in: &cancellables)
案 E のメリットとして、案 C と同様に、Input/Output の型の統一性があるところが挙げられます。とても自然ですね。
ただ、案 C のように nil
の可能性を排除できないのは微妙なところですが、それを良しとするかどうかはケースバイケースだと思われます。
Output は 単体 or 配列 のどちらがよいのか?
次に、PhotoEntity
の単体を返却する案 E と、PhotoEntity
の配列を返却する案 F を比較します。
Output が来たものから順に、すぐに store()
の処理を行うことができるという点から、案 E のほうが良いと思われます。
案 F ではすべてのサムネイル所得処理が完了しない限り、store()
できないようなインターフェースとなっております。
ただし、今回はエラーの発生することのない Never
なので、全部出力が成功することしか期待しておりません。
しかしながら、エラーが発生する場合は、出力が途中で終了してしまうケースも考慮しなければならなく、出力が途中で終了してしまう場合は、store()
処理を行いたくないというケースも考えられます。
そのときは案 F のほうが優勢かと思われますが、collect()
を使用することによって、単体の Output でも出力を配列に貯めることができます。
そのため、案 E のインターフェースでも、全部出力が揃い切るまで待つこともできるので、やはり、案 F より汎用性の高い、案 E のほうがよいと思われます。
案 C のタプルも配列で返却することも可能なのですが、上記と同様の理由で単体で出力されたほうが、より汎用性がある形になります。
以下、collect()
で出力をまとめてみる実装例です。
// 案 C を collect() して store() 処理をまとめる場合
photoDownloadDriver.fetchThumbnailC(photoIds: photos.map { $0.id })
.map { PhotoEntity(id: $0, thumbnail: $1) } // ← 追加(先に PhotoEntity に変換)
.collect() // ← 追加
.sink { print("completion C collect(): \($0)") } receiveValue: { store(photos: $0) }
.store(in: &cancellables)
// 案 E を collect() して store() 処理をまとめる場合
photoDownloadDriver.fetchThumbnailE(photos: photos)
.collect() // 追加
.sink { print("completion E collect(): \($0)") } receiveValue: { store(photos: $0) }
.store(in: &cancellables)
良くも悪くも subscribe 側でどうにもなるので、Driver 側でのあるべき姿を追求する方針でインターフェースを検討して問題ないかと思われます。
まとめ
- 配列を引数にとる関数の戻り値が AnyPublisher の場合のインターフェースの場合は以下のことに注意する
- Input と Output の紐付けが必要な場合は、順序による暗黙的な紐付けを行わないようにすること
- Input と Output の型は統一性があったほうが自然だということ
- Output を 単体 or 配列 で迷った場合は、より汎用的な 単体 を用いて、 Output をまとめて処理したい場合は、subscribe 側で
collect()
すること
以上になります。
Discussion