はじめに
GMapBFQuizというSwiftUIで作成したアプリを作っています。
→訳あってストアから削除しました。
AppleのTutorialをみよう見真似でアプリを作りましたが、アプリを作り上げて気づきました。
自分のアプリに Fat ViewController ならぬ Fat View が存在していることを・・・。
※Fat ViewControllerとは俗にいう、ViewControllerにUI更新処理以外の多くの処理が入ってしまい責務が肥大化している状態を指します
具体的には、4択問題を出題する画面のView(QuizView)が
- 4択問題のロード
- タイムリミットのカウント
- ユーザー回答のチェック
- 回答履歴の保存
などの処理を実装しているうちに、気が付くとUI更新以外の処理がてんこ盛りに詰まってしまっていました。
これでは将来の自分が保守に困ると思い、自分に合ったSwiftUIで保守性の高い実装方法を探すことにしました。
Storeパターンを実現するVerge
私はWebUIを少しかじったことがあり、Vue.jsのComponentとSwiftUIのViewはUIを表現しつつ、UI部品に渡すデータはデータバインディングできるという点が似てると思っていました。
このVue.jsでUIとビジネスロジックを分離する方法として、VuexやStoreパターンがあります。SwiftUIでも同じ方法を適用したら保守性の高いコードができると考えました。
探してみると、SwiftUIでStoreパターンを実現するVergeというライブラリを見つけました。早速、Vergeを使い問題のコードを書き直してみました。
Storeパターンとは
詳しい説明は割愛しますが、次のような考えに基づく設計方法です。
- データの流れが単一方向
- ViewはStoreに保存された値を監視するが、直接値の書き換えはしない
- Viewがユーザー操作を受け取り、Storeの値を書き換える必要がある場合はCommitとという関数を経由して行う
- Storeはデータ保存だけでなく、値の計算やデータの保存などを行う
- ViewはあくまでStoreのデータをどうやってユーザーに見せるかだけの責務を負う
Vergeの基本的な使い方
使い方は超簡単です。
データを保持する独自のStructを作り、Genericsでそいつを指定したStoreを継承した独自Storeを作るだけです。
独自Storeと一緒にActivityというデータ更新を伴わないイベントを定義することができますが、必要ない場合はNeverでOKです。
Activityが気になる方はVergeの公式ドキュメントをご参照ください。
struct QuizState {
var category = QuizCategory.all
var answer = Answer.none
var sentence = ""
}
class QuizStore: Store<QuizState, Never> {
func checkAnswer() {
commit {
xxx
}
}
}
View側ではStoreのインスタンスを保持しておき、必要に応じてCommitを行います。
あとは、Storeの変更を検知するためにStateReaderを使います。
Storeの中身が書き換えられるとそれをトリガにStateReaderがViewを作り直してくれます。
struct QuizView: View {
private let quizStore: QuizStore = RootStore.instance.quizStore
var body: some View {
StateReader(quizStore).content { state in
VStack {
Text(state.sentence)
Button("Check Answer") {
quizStore.checkAnswer()
}
}
}
}
}
この例だと、QuizStateのsentenceが書き変わるとそれが即時にUIに反映されます。
Verge導入による before/after
1. Stateプロパティが減った
beforeでは画面表示のために下記の大量のStateプロパティを保持していましたが、それらを全てStoreに持たせることでQuizViewから全て消し去ることができました。
Stateプロパティの代わりにプライベートにStoreのインスタンスを2つだけ保持する形になりました。
Before
@State var category : QuizCategory
@State private var elapsedTime = 0.0
@State private var answer = Answer.none
@State private var qId : Int64 = 0
@State private var sentence = ""
@State private var choice1 = ""
@State private var choice2 = ""
@State private var choice3 = ""
@State private var choice4 = ""
@State private var correct = 0
@State private var source = ""
@State private var qRecords : [QuestionRecord]? = nil
@State private var results = [ResultItem]()
@State private var timeLimit = 60.0
@State private var numOfQuiz = 999999
After
private let quizStore: QuizStore = RootStore.instance.quizStore
private let settingStore: SettingStore = RootStore.instance.settingStore
2. ビジネスロジックを排除できた
beforeにはUI表示には直接関係のない、下記の3つの処理をprivateメソッドとしてQuizView内に持たせていました。
- 4択問題のロード
- タイムリミットのカウント
- ユーザー回答のチェック
これらをStoreに持たせることでQuizViewから完全に削除することができました。
3. QuizViewを表示のコードに集中させることができた
下記が、Vergeで書き直したQuizViewのコードです。Beforeが無いので、何処が変わったのか分かりづらいと思いますが、
- QuizViewはbody以外にビジネスロジックを含む処理を持っていない
- Stateプロパティの代わりにStoreのstateを参照していること
- ボタンがやViewが押されたときに処理をダラダラと書くのではなく、Storeのメソッドを呼んでいる
という点に注目をして頂ければと思います。
おそらく何も考えずにSwiftUIのコードを組むと、
- StateやBindingプロパティの乱立
- ボタンのクロージャにデータ処理コードがてんこ盛り
になるかと思いますが、Storeパターンに従うことで、すっきりと書くことができます。
After
struct QuizView: View {
private let quizStore: QuizStore = RootStore.instance.quizStore
private let settingStore: SettingStore = RootStore.instance.settingStore
var body: some View {
StateReader(quizStore).content { state in
VStack {
if state.answer != .nomore {
ProgressView(NSLocalizedString("Elapsed Time : ", comment: "") + "\(state.elapsedTime)" + NSLocalizedString("sec", comment: ""),
value: state.elapsedTime,
total: settingStore.state.isOnTimeLimit ? settingStore.state.timeLimit : 60)
}
if state.answer == .correct {
Spacer()
VStack {
Text("Correct").foregroundColor(.green).font(.title)
Image(systemName: "checkmark.circle")
.resizable()
.frame(width: 64.0, height: 64.0)
.foregroundColor(.green)
Button("Next") {
quizStore.fetchNextQuiz()
}
}
Spacer()
} else if state.answer == .incorrect {
Spacer()
VStack {
Text("Incorrect").foregroundColor(.red).font(.title)
Image(systemName: "multiply.circle")
.resizable()
.frame(width: 64.0, height: 64.0)
.foregroundColor(.red)
Text(NSLocalizedString("Source : ", comment: "") + state.source)
Button("Next") {
quizStore.fetchNextQuiz()
}
}
Spacer()
} else if state.answer == .timeup {
Spacer()
VStack {
Text("Time up").foregroundColor(.orange).font(.title)
Image(systemName: "hand.raised")
.resizable()
.frame(width: 64.0, height: 64.0)
.foregroundColor(.orange)
Button("Next") {
quizStore.fetchNextQuiz()
}
}
Spacer()
} else if state.answer == .nomore {
ResultView()
} else {
Text(state.sentence)
.font(.title)
List() {
AnswerRow(text: Text(state.choice1), image: Image(systemName: "1.circle"), color: Color.red)
.onTapGesture(count: 1, perform: {
quizStore.checkAnswer(1)
})
AnswerRow(text: Text(state.choice2), image: Image(systemName: "2.circle"), color: Color.blue)
.onTapGesture(count: 1, perform: {
quizStore.checkAnswer(2)
})
AnswerRow(text: Text(state.choice3), image: Image(systemName: "3.circle"), color: Color.green)
.onTapGesture(count: 1, perform: {
quizStore.checkAnswer(3)
})
AnswerRow(text: Text(state.choice4), image: Image(systemName: "4.circle"), color: Color.orange)
.onTapGesture(count: 1, perform: {
quizStore.checkAnswer(4)
})
}
Spacer()
}
}
.padding()
.navigationBarTitle(Text(NSLocalizedString(quizStore.state.category.rawValue, comment: "")), displayMode: .inline)
.onAppear() {
quizStore.loadQuiz()
}
}
}
}
Vergeで気になったこと
Vergeは素晴らしいライブラリですが、使ってみていくつか気になる点もありました。
そもそも私の使い方が誤っているのかもしれませんが、ひとまず次の様に対処しました。
- Storeをアプリで一つに限定する場合は問題ありませんが、コンテキストでStoreを複数に分けたい場合、各Storeの参照を持たないので、工夫が必要
→ 各Storeをシングルトンにして、Store内で参照できるようにする必要がある - UI部品側で操作イベントハンドラがない場合がある
→ なければonTapGestureなどのハンドラを仕込む必要がある
→ NavigationLinkはかなりトリッキーな方法が必要 - ToggleのようなBinding引数を要求しているUI部品に直接Storeの値を渡すことはできない
→ @State変数をかまさないといけない
まとめ
before/afterの節で紹介した通り、Vergeを導入することで綺麗にUIとビジネスロジックを分けることができ、当初の目的である保守性の高いコードに変えることができたと思います。
また、Vergeの開発者であるmuukiさんもとても親切で分からない点を質問したら丁寧に回答をして貰えました。
コードも日々アップデートされているようで、しっかりと保守されている印象でこれからも安心して使えるライブラリだと思います。
同じ様なSwiftUIの悩みを持つ方がいたらぜひ試してみて頂きたいライブラリです。