Swift で実現した絞り込みシステムの話
前書き
こんにちは、Lumi です。日本語も記事書きも初心者です。
この記事は関数型プログラミングを中心とした、 iOS アプリ内アイテムの絞り込みシステムの作り方を紹介します。
課題
こういった場面を考えましょう。
あるソシャゲの中に、カードというものがあります。カードには名前、レア度、特技などの様々な属性がついています:
class Card {
let name: String
let rarity: Rarity // レア度
let skill: Skill // 特技
// ...
}
レア度が enum
、特技が別のクラス:
enum Rarity: CaseIterable {
case n
case r
case sr
case ssr
}
class Skill {
let name: String
let interval: Int // 発動間隔
// ...
}
カードを特定な条件で絞り込みたい時に、filter(isIncluded:)
関数に (Element) -> Bool
型のクロージャを渡します:
let cards: [Card] = //...
// レア度が SSR のカード
let ssrCards = cards.filter { card in
card.rarity == .ssr
}
簡単でしょう?
次、複数の条件で絞りたい時にはどう?
// レア度が SR 或いは SSR、かつ特技がスコアボーナス
let ssrCards = cards.filter { card in
(card.rarity == .sr || card.rarity == .ssr)
&& card.skill === scoreBonusSkill
}
…まだ納得できます!
では、あらゆる条件を用意し、ユーザーに好きな条件を複数選んでもらう場合は?
// すべての条件
let allRarity = Rarity.allCases
let allSkills = //...
let allSkillInterval = Set(allSkills.map(\.interval))
// ユーザーが選ぶ
let searchName: String = "..."
let selectedRarity: [Rarity] = [.sr, .ssr]
let selectedSkill: [Skill] = //...
let selectedSkillInterval: [Int] = [9, 13]
// ...
let filteredCards = cards.filter { card in
card.name.contains(searchName))
&& selectedRarity.contains(card.rarity)
&& selectedSkill.contains(where: { card.skill === $0 })
&& selectedSkillInterval.contains(card.skill.interval}
// ...
}
めちゃくちゃになりましたね。
これもまだ選んだ条件に何にもない状況を考慮してない結果、そして、もし必要な条件が10組あれば、コードはもう見てられないものになるでしょう。
こんな臭いコードを避けて、絞り込み機能を優雅に実現するのが今回の課題です。
改善方法
考えとして、条件という概念を構造化し、一箇所に管理するのが望ましいです。最終的に、予め作った条件を一つのフィルター変数に凝縮し、 filter(isIncluded:)
に渡す形にしたいです。
let finalFilter: (Card) -> Bool = { card in ... }
let filteredCard = cards.filter(finalFilter)
基本定義
まず、このフィルタータイプのクロージャを Filter
にリネームします:
typealias Filter<T> = (T) -> Bool
ここから、この Filter
をめぐって様々な機能を拡張していきます。
次に、カードのそれぞれの属性が各自のフィルターを作れるようにします。例えばレア度:
extension Rarity {
var filter: Filter<Card> {
return { card in
return card.rarity == self
}
}
}
意味としては、一つのレア度 case
が、「レア度がこの case
と一致するカードを選ぶ」という Filter
を提供します。使い方は:
let ssrCards = cards.filter(Rarity.ssr.filter)
実際に複数の Filter
を組み合わせて使いたいので、Filter
を結合する方法を用意します:
func && <T> (f1: @escaping Filter<T>, f2: @escaping Filter<T>) -> Filter<T> {
return { t in
return f1(t) && f2(t)
}
}
func || <T> (f1: @escaping Filter<T>, f2: @escaping Filter<T>) -> Filter<T> {
return { t in
return f1(t) || f2(t)
}
}
関数型プログラミングに詳しくない方には少し分かりづらいかもしれませんが、ここでは、Filter
に対して &&
AND演算子を定義して、「渡された二つの Filter
を同時に満足する」という新しい Filter
を作ることができます。
||
OR 演算子も同じく、「渡された二つの Filter
を少なくとも一つを満足する」という新しい Filter
が作れます。
こうすれば、あらゆる Filter
をいとも簡単に書けます:
// レア度が SR 或いは SSR、かつ特技がスコアボーナス
// という条件で絞り込むフィルター
let filter =
Rarity.sr.filter || Rarity.ssr.filter
&& scoreBonusSkill.filter
let result = cards.filter(filter)
整合
ここまで、基盤的なものを準備出来ました。条件という概念に戻ります。
FilterToggle
条件をユーザーに見せるには、条件の名前と、有効状態の管理が必要です。Filter
を加えて、FilterToggle
(以下 Toggle という)になります:
struct FilterToggle<Category> {
let name: String
let filter: Filter<Category>
var isOn = false
init(name: String, filter: @escaping Filter<Category>) { /*...*/ }
}
Toggle を使う時に、ユーザー操作を通って isOn
の開閉を弄りながら、isOn
が true
であればこの条件が有効だと見なします。
使い方は:
// SR の Toggle
let srToggle = FilterToggle(name: sr.name, filter: sr.filter)
// 条件の有効状態を切り替える
srToggle.isOn.toggle()
let ssrCards = cards.filter(srToggle.filter)
FilterSet
ここで条件たちの互いの関係を振り返りましょう。ユーザーがいくつの条件から複数選んで、その条件が同じ種類でしたら OR 関係(例えばレア度が SR 或いは SSR)で、違う種類でしたら AND 関係(レア度が SR かつ特技がスコアボーナス)で組み合わせます。
同じ種類の条件を一箇所に管理する FilterSet
(以下 Set という)を用意します。
struct FilterSet<Category> {
var toggles: [FilterToggle <Category>]
}
Set の中の Toggle が全部選ばれている、或いは全部選ばれていない、という場合、この Set が無効だと見なします:
extension FilterSet {
var isAffectable: Bool {
! toggles.allSatisfy({$0.isOn}) &&
! toggles.allSatisfy({!$0.isOn})
}
}
そして Set 段階の Filter
を作ります
extension FilterSet {
var filter: Filter<Category> {
var result: Filter<Category> = { _ in false } // 1
for toggle in toggles where toggle.isOn { // 2
result = result || toggle.filter // 3
}
return result
}
}
- OR 関係で
Filter
を畳み込みたいので、初期値がfalse
。 - 有効である Toggle を取り出して
- 取った Toggle の
filter
を OR 関係で畳み込んで、Set 段階のFilter
に合成します。
Filter Requirement
そして同じロジックで、複数の Set を FilterRequirement
(以下 Requirement という)に格納します。
struct FilterRequirement<Category> {
var sets: [FilterSet<Category>]
var finalFilter: Filter<Category> {
sets
.filter(\.isAffectable) // 1
.map(\.filter) // 2
.reduce({ _ in true }, &&) // 3
}
}
- 有効である Set を取り出して
- Set の
filter
に転換して - AND 関係で初期値の
true
に畳み込みます。
ちなみに、Requirement の filter
が上の Set の filter
とほぼ同じロジックなのに、Swift の中で関数型プログラミングに関する最も使われている filter
map
reduce
を使えば、コードがこのように簡潔に変わるのが Swift の醍醐味ではないかと。
こうやって、下図が示した関係のように、複数の Toggle を組み合わせて Set になり、そして複数の Set が Requirement になります。Toggle の isON
を弄りながら、有効した Toggle から取ったすべての filter
を Set と Requirement を通して一つの filter
にまとめます。
これで準備万端。
使い方
// 1
let rarityToggles: [FilterToggle<Card>] = Rarity.allCases.map { rarity in
FilterToggle(name: rarity.name) { card in
card.rarity == rarity
}
}
let skillToggles: [FilterToggle<Card>] = skills.map { ... }
// ...
// 2
let sets: [FilterSet<Card>] =
[rarityToggles, skillToggles...].map(FilterSet.init)
// 3
let cardFilterRequirement = FilterRequirement<Card>(sets: sets)
// 4
let filteredCards = cards.filter(cardFilterRequirement.finalFilter)
- カードに関する属性から、それぞれの Toggle を作って
- 同じ種類の Toggle を一つの Set にして
- 多数の Set でカードの絞り込み用の Requirement を作ります
- ユーザー操作から Requirement の中の Toggle たちの有効状態を調整してから、最終的に生成された
Filter
でカードを絞り込みます。
このやり方のメリット
新しい属性が追加された時、既存コードを変える必要がない
新しい属性の追加が容易にできます。
すべての Toggle、Set と Requirement が様々な属性変数から作られたので、enum
の属性が追加された時、case
一行を加えるだけで;サーバーから生成された動的属性でしたら、コードを変える必要さえなく自動的に対応してくれます;たとえ新しい属性の種類が現れた時でも、新しいFilterSet
を書けば良い、既存コードを変える必要が全くありません。
柔軟性が高い、バグが生じ難い
Toggle が name
と filter
で生成されたものなので、このように:
var customToggle = FilterToggle<Card>(
name: "HP over 5000",
filter: { $0.hp >= 5000 }
)
具体的な変数がなくても、判断ロジックのフィルターを用意すれば、様々な Toggle が自由に作れます。
またすべての型がジェネリックになったので、カードじゃなくてほかのアイテムにしても通用します。コンパイラーに通されたらバグる可能性が低く、テストもしやすいです。
コード量が少ない
実際に、カード・曲・アイドル、三つの実体に、Filter と Sorter を同じパターンで実装しています。使う時に違う属性と型を入れ替われば良いです。下記の PropertyForFiltering
のプロトコルを導入したらコード量が更に減り、簡潔に書くことができます。
補足
プロトコルの導入
条件として name
と Filter
だけに関心あるので、プロトコルを作ってコード量を減らしましょう。
protocol PropertyForFiltering {
associatedtype Category
var name: String { get }
var filter: Filter<Category> { get }
}
extension Rarity: PropertyForFiltering {...}
extension FilterToggle: PropertyForFiltering {
init<Property: PropertyForFiltering>(property: Property)
where Property.Category == Category {
self.name = property.name
self.filter = property.filter
}
}
extension FilterSet {
init?<Property: PropertyForFiltering>(properties: [Property])
where Property.Category == Category
{
guard !properties.isEmpty else { return nil }
toggles = properties.map(AnyFilterToggle.init)
}
}
let cardFilterRequirement = FilterRequirement<Card>(propertySets: [
FilterSet(properties: Rarity.allCases),
FilterSet(properties: skills),
// ...
])
let filteredCards = allCards.filter(cardFilterRequirement.finalFilter)
類推する
カード名前の検索も:
let text = textField.text!
let searchTextFilter: Filter<Card> = { card in
card.name.contains(text)
}
アイテムの並び替えも:
typealias Sorter<T> = (T, T) -> Bool
protocol PropertyForSorting: Localizable {
associatedtype Category
var name: String { get }
var sorter: Sorter<Category> { get }
}
class AnyPropertyForSorting<Category> {...}
class SorterRequirement<Category> {...}
let sortedCard = cards.sorted(cardSorterRequirement.finalSorter)
同じパターンで実装しています。
こういったアーキテクチャーで、すべてのカードを絞り込んで、並べ替えてから、展示しています:
実装効果
実はこの記事が自作アプリのデレガイド2の話です。
アプリ内で、カードというアイテムを収録した図鑑機能があって、今2000枚以上のカードがアプリ内で保存されています。
下図のよう、ユーザーがカードの属性に関する様々な条件を選んで、その条件に満ちたカードを見ることができます。実際にこのようなシステムが実装しています。
おわりに
この記事はアイテムの絞り込むシステムを紹介しました。書けば書くほど思った以上の量になりました。Swift の型システムが強いなので、それを頼って、関数型プログラミングの利点をどんどん活かしたいですね。
説明の不十分や、質問があれば気軽にコメントしてください!
Reference
- 関数クロージャを結合するというデザインパターン
- Swift の関数型プログランミングに興味があればこの本を強くおすすめします
Discussion