この記事は
MV*というかModelの話がメインです
結論
- Modelに 状態 を持たせ、 状態遷移 を行うようにする。(= Modelをステートマシンとして使う)
話すこと
- ステートマシンとは
- 状態とは
- 状態遷移とは
前提
- Model
- ユーザーに提示する状態・値を持っている
- 内部状態の変更を通知(Observerパターン)で知らせる
参考?
この記事のみでも成り立つように作成していますが、上記の前提を詳しく話しているのは別の記事になります。
ステートマシンの話に入る前に
なぜステートマシンが必要になるのか。
アプリ開発のよくある要件を例に話を始めます。
よくある要件
- 画面上にボタンがある
- ボタンを押したらサーバーと通信して情報を取得する
- 通信中 -> インジケータを表示したい
- 通信中は、再度ボタンをタップされても何もしないようにしたい
- 通信失敗 -> アラートを表示したい
- 通信成功 -> 結果を画面へ反映したい
それぞれをフラグ管理した場合
要件を実現するために、Modelにフラグを用意してみます。
class ProcessModel {
// 通信中フラグ
private(set) var isDoing: Bool
// 成功時は結果のStringが入る
private(set) var result: String?
// 失敗時はErrorが入る
private(set) var error: Error?
func execute() {
// 通信中の場合は何もしない
guard !self.isDoing else {
return
}
self.isDoing = true
/**
なんらかの非同期な通信処理
コールバックで通信結果を受け取るとする
*/
API.someProcess { processResult in
// 通信成功の場合
if processResult.isSuccess {
self.result = self.createResult(from: processResult)
// 通信失敗の場合
} else {
self.error = self.createError(from: processResult)
}
}
}
}
Modelの取りうる状態
フラグが3つありますが、この場合Modelの取りうる状態は全部で何パターンあるでしょうか?
No. | isDoing | result | error |
---|---|---|---|
1 | true | nil | nil |
2 | true | nil | Error |
3 | true | String | nil |
4 | true | String | Error |
5 | false | nil | nil |
6 | false | nil | Error |
7 | false | String | nil |
8 | false | String | Error |
全8パターン。
実はこの時点で既に問題が発生しています。
個別のフラグ管理 = 「このModelはどういう状態をとるのか」の全パターンが分かりづらい という問題です。
そして良く見ると、どういう状態なのかよく分からないパターンが...。
No.2: 通信中かつエラーがある場合?
No.3: 通信中かつ結果がある場合?
No.4,8: 結果もエラーもある場合?
既によくわからないパターンが含まれていますが、ここからさらに仕様を追加されたらどうなるでしょうか?
さらにフラグが増えた場合
仕様追加「処理Aが実行中は処理Bを行わないようにしたい」
-> isProcessADoing, isProcessBDoing の発生
フラグが増えたので、パターン数も増えました。
ついでに、あり得なさそうな(あり得てはいけないはずの)状態も増えました。
(isProcessADoing = true かつ isProcessBDoing = true とか)
つまり、 フラグが増える = 無駄なパターンも増える
きつい💀
問題点と要望
-
問題点
- 現状では「このModelはどういう状態をとるのか」の全パターンが分かりづらい
- フラグが増える = 無駄なパターンも増える
-
要望
- 取りうる状態の一覧が欲しい
- 無駄なパターンは省きたい
Enumが便利ですよ
要望を叶えるためにはどう変更したらいいか考えてみます。
「取りうる状態の一覧が欲しい」
-> Enumが良さそう。
ということで、状態を表すのにEnumが便利なので使います。
しかし今度は、どういうEnumを定義すればいいのかという疑問が出てきます。
そこで、状態(Enum)を持ってるModelのことを、ステートマシンとして使うと良いよという話をします。
ステートマシンとは
- 状態を持ち、 状態遷移を行うクラス
- 簡単に言うと、状態遷移図を実装したクラス
状態とは
通信中, 通信失敗, 通信成功 などを指しています。
状態遷移とは
文字通り状態が移り変わることです。
Modelをステートマシンとして使う = 上記の状態遷移図を満たすようにModelを実装する
ということになります。
具体的なコードを書いてみる
状態の一覧をEnumで定義する
状態遷移図を元に、取りうる状態をEnumで定義します。
enum State {
// 何もしていない
case sleeping
// 通信中
case doing
// 通信成功
case success(result: String)
// 通信失敗
case failure(error: Error)
}
状態の定義はこれでOK...?🤔
状態遷移図を見直してみる
通信中のあと
-> 通信成功 or 通信失敗
-> 通信成功 と 通信失敗は同列で扱うべきっぽい
下記のように変更
enum State {
case sleeping
case doing
case done(Result)
enum Result {
case success(result: String)
case failure(error: Error)
}
}
状態の定義はこれでOK🎉
Modelに状態、状態遷移を持たせる
定義した状態を使うようにModelを書き換えます。
class ProcessModel {
// 状態をModelに持たせる
// 状態: 何もしていない
private(set) var currentState: State = .sleeping
func execute() {
// 状態: 通信中
self.currentState = .doing
/**
なんらかの非同期な通信処理
コールバックで通信結果を受け取るとする
*/
API.someProcess { processResult in
// 通信結果: 成功
if processResult.isSuccess {
let result = self.createResult(from: processResult)
// 状態: 通信成功
self.currentState = .done(.success(result: result))
// 通信結果: 失敗
} else {
let error = self.createError(from: processResult)
// 状態: 通信失敗
self.currentState = .done(.failure(error: error))
}
}
}
}
これで状態遷移図を満たせているでしょうか。
連打対策
ぱっと見は大丈夫そう。
ですが、実は状態遷移図にない 余計な遷移 をしてしまっている箇所があります。
通信中 -> 通信中
の矢印です。
この矢印があると、 要件の「通信中は、再度ボタンをタップされても何もしないようにしたい」を満たさないので、Modelを修正します。
class ProcessModel {
// ... 中略 ...
func execute() {
switch self.currentState {
case .doing:
// 何もしない
return
case .sleeping, .done:
// 状態: 通信中
self.currentState = .doing
}
// ... 以下同じ...
}
}
要望は満たせたか?
- 取りうる状態の一覧が欲しい
- ⭕️ -> Enumが一覧になる
- 無駄なパターンは省きたい
- ⭕️ -> 状態遷移図を書いた時点で省かれている
yeei😎
処理が複数ある場合は状態が複数必要なのでは?
仕様追加「処理Aが実行中は処理Bを行わないようにしたい」に対応するためには?
処理ごとにModelをわけ、2つのModelを取り持つModelを作る
処理A, 処理BごとにModel作り、Model同士を取り持つクラスをつくります。
「取り持つクラス」も立ち位置はModelなのですが、ただのModelと区別するためにModelMediator(Modelの仲介者)と名付けています。
(名前はModelでもいいです(宗教感ある))
class ModelMediator {
private let processAModel: ProcessAModel
private let processBModel: ProcessBModel
// 要件: 処理Aが実行中は処理Bを行わないようにしたい
func executeProcessB() {
switch self.processAModel.currentState {
case .doing:
// 処理Aが実行中なので何もしない
return
case .sleeping, .done:
self.processBModel.execute()
}
}
}
Modelへ指示する側では、直接Modelを持たずにModelMediatorを持ちます
class Controller {
// ModelMediatorを持つ
private let modelMediator: ModelMediator
init(modelMediator: ModelMediator) {
self.modelMediator = modelMediator
}
func executeProcessB() {
// 各Modelが今どういう状態か、はControllerで考慮しない -> ModelMediatorにやらせる
self.modelMediator.executeProcessB()
}
}
Modelを分ける意味はあるのか
Q. Model同士を取り持つクラスがいるなら、初めから1つのModelにしてしまった方が楽なのでは?
A. 処理Aと処理Bは別のものなので、別Modelにできるはず。別Modelのままにすることで、それぞれのModelの単純さを保ちたい。
処理Aは実行中に処理Bのことは考えません。逆も同じです。
処理Bは開始時こそ処理Aのことを考えますが、実行を開始した以降は処理Aのことを考えません。
処理Aと処理Bが関係するのは、「処理Bの開始時に処理Aが実行中だった場合」だけです。
ある一点でしか関係しないのでしたら、「その一点を表すクラス(Model)」を外部に用意する方が、各Modelは単純で済みます。
まとめ
- Modelをステートマシンとして使うと便利
- 状態の一覧があるので、 Modelの取りうる状態がわかりやすい
- 無駄なパターンを省ける
- Modelが複数必要になった場合は、取り持つクラスをModelの外部につくる。そうすることで、それぞれのModelは単純さを保てる