The Composable Architecture 入門録
The Composable Architecture (以下 TCA) は、Swift UIを使ったアプリを作る上で知っておきたいアーキテクチャのひとつだったので、休暇を利用して入門してみました。そのときの、自分がつまったところを記録します。
ドキュメントを読む
まずは、ライブラリのドキュメントを読みます。
- 振る舞いの定義とViewを分けて定義できる
- APIを呼ぶなどのロジックの依存注入がしやすい作り
- 振る舞いのテストを状態の監視として書ける
といった特徴があることがわかります。
SwiftUIのアプリを作る
実際にTCAを使ってアプリを構築していきます。
今回は、加賀ゆびぬき刺し模様シミュレータのアプリ内から送信できるご意見を読むためのアプリを構築しました。
アプリの画面構成は以下のとおりです。
一覧画面を表示する
はじめに一覧画面から実装をはじめました。
この画面は、以下のように振る舞います。
- 表示されたら一覧をAPIから取得する
- 一覧を取得できたら、リスト表示する
- 一覧の取得に失敗したら、エラー画面を表示する
TCAのリポジトリにある CaseStudies を参考にしながら、実装していきます。
状態の表現を何回か更新したので、その変遷をみてみましょう。
最初はサンプル通りに実装してみます。
struct State: Equatable { var rows: IdentifiedArrayOf<Row>? var isLoading: Bool = false var failedToFetch: Bool = false struct Row: Equatable, Identifiable { var opinion: Opinion let id: UUID } }
更新しなければならないフラグの種類が多く、機能が増えると間違えそうですね。
ここで、ScreenState
を導入してみます。しかし、これは冗長ですね。
struct State: Equatable { var alert: AlertState<OpinionList.Action>? var screenState: ScreenState<IdentifiedArrayOf<Row>> = .initial var isLoading: Bool { screenState.isLoading } var hasError: Bool { screenState.hasError } var data: IdentifiedArrayOf<Row>? { screenState.data } struct Row: Equatable, Identifiable { var opinion: Opinion let id: UUID } }
なにかやりようはあるだろうと思っていたところ、より最適な方法がサンプルにもあると教えてもらいました。
↓ みたいな感じでいけます🙆♂️https://t.co/jEftnLZWZA
— アイカワ (@kalupas226) January 10, 2023
それに従って書きかえたのがこちら。
enum State: Equatable { struct Row: Equatable, Identifiable { var opinion: Opinion let id: UUID } case initial case loading case loaded(IdentifiedArrayOf<Row>) case error(AlertState<OpinionList.Action>) // ... 略 ... }
このときのViewは、if 文でStateをみながら分岐しています。(まだ CaseLet
を知らなかった)
struct OpinionListView: View { let store: StoreOf<OpinionList> var body: some View { WithViewStore(self.store, observe: { $0 }) { viewStore in VStack { if viewStore.isLoading { ProgressView() } else if viewStore.hasError { OpinionListErrorView( onTapRetry: { viewStore.send(.requestFetch) } ) } else if let rows = viewStore.data, !rows.isEmpty { List(rows) { row in OpinionListRowView(opinion: row.opinion) } } else { OpinionListEmptyView() } } .alert(self.store.scope(state: \.alert), dismiss: .alertDismissed) .onAppear { viewStore.send(.didAppear) } } } }
ログイン画面を作る
これはサンプルコードhttps://github.com/pointfreeco/swift-composable-architecture/blob/main/Examples/TicTacToe/tic-tac-toe/Sources/AppCore/AppCore.swiftがあり、それに習うのですが、ここでSwitchStore/CaseLet
が登場します。
対応するViewがわかりやすい上、sub-state に切り出すことで考えることを減らせるようになります。これは便利。
一覧画面も SwitchStore/CaseLet
を使おう
一覧画面にも適用していこうとするのですが、ここで少し詰まります。
State を以下のように定義していたので、一部にだけ sub-state があります。
enum State { case initial case loading case loaded(OpinionListSort.State) case error(OpinionListError.State) }
CaseLet
では、対象の CaseState と、そこから発生するactionを指定する必要があるため、sub-state がないときの指定に困りました。
サンプルからは以下のような書き方は見つかりますが、sub-state なしの場合のサンプルがなく CaseLet
の実装をみながら考えることになりました。
CaseLet(state: /OpinionList.State.loaded, action: OpinionList.Action.opinionListSort) { store in OpinionListSortView(store: store) }
最終的に、action は引数を取る関数であるため、以下のようにダミーアクションを用意しました。sub-state の実態がないため、action が実行中に呼ばれることはありません。
CaseLet(state: /OpinionList.State.loading, action: { OpinionList.Action.opinionListLoading }) { store in ProgressView() }
今回は1ケースだけなので、ダミーアクションらしい名前ではなく、対応する状態の名前をつけています。他にもダミーケースが必要な場合は、まとめてしまう名前でもよいかもしれません。
Sub-State をさらにわける
一覧が表示できるようになったので、そこに機能を追加していきます。 データに応じてフィルタし、並び順を変更できるようにします。
前節の State
のとおり、APIから一覧を取得できたときの状態をSub-Stateに分離しました。
Stateをわける前は、loaded になった時点からフィルタ条件を持ちまわす実装を書いていて、なかなか大変でした。State を分離することで、持ちまわす部分はライブラリがやってくれます。
画面遷移をSwiftUIで書く
一覧画面ができたので、リストの各セルをタップして詳細を見れるようにします。
最初の実装は NavigationView
を使ったので、大きな問題なく組むことができました。
var body: some View { WithViewStore(store, observe: ViewState.init, send: OpinionListSort.Action.init) { viewState in Form { OpinionListFilterView(...) if viewState.rows.isEmpty { Text("no opinions") } else { Section { ForEach(viewState.rows) { row in NavigationLink( destination: IfLetStore( self.store.scope( state: \.selection?.value, action: OpinionListSort.Action.opinionDetail ) ) { OpinionDetailView(store: $0) }, tag: row.id, selection: viewState.binding( get: \.selection?.id, send: ViewAction.setNavigation(selection:) ) ) { OpinionListRowView(opinion: row.opinion) } } } } } .onAppear { viewState.send(.didAppear) } } }
iOS 16からは、NavigationLink
は NavigationStack
の中で使うことが推奨されるため、この実装にはwarningがありました。(自分しか使わないアプリのため、development target は 16.0 です。)
どうせならと書き換えていくのですが、ここは詰まりました。
NavigationStack/List/NavigationLink で、navigationDestination があれば動く。
— ichiko/Morichk (@ichiko_revjune) January 19, 2023
NavigationStack/Form/List/NavigationLink では動かない。
入れ子の構造しだいで動かないことに気づくのは大変でした。
フィルタのために、リストの上部に別のViewを表示しており、Form
に入れることで見た目を揃えていたのです。
しかし、Form
が邪魔なことはわかったので、UIKit でリストを実装するときのように cell type を定義していきましょう。
enum Row: Equatable, Identifiable, Hashable { case filter case opinion(Opinion) var id: String { switch self { case .filter: return "filter" case let .opinion(opinion): return opinion.id } } }
var body: some View { WithViewStore(store, observe: ViewState.init, send: OpinionListSort.Action.init) { viewState in NavigationStack { List( viewState.rows, id: \.id, selection: viewState.binding( get: \.selection?.id, send: ViewAction.setNavigation(selection:) ) ) { row in switch row { case .filter: OpinionListFilterView( sortMode: viewState.binding(get: \.sortMode, send: ViewAction.sortModeChanged), includeChecked: viewState.binding(get: \.includeChecked, send: ViewAction.includeCheckedChanged), includeTestData: viewState.binding(get: \.includeTestData, send: ViewAction.includeTestDataChanged) ) case let .opinion(opinion): NavigationLink( value: opinion ) { OpinionListRowView(opinion: opinion) } } } .navigationDestination(for: Opinion.self) { _ in IfLetStore( self.store.scope( state: \.selection?.value, action: OpinionListSort.Action.opinionDetail ) ) { OpinionDetailView(store: $0) .onDisappear { viewState.send(.opinionDetailDismissed) } } } } .onAppear { viewState.send(.didAppear) } } }
全体のState
Stateをどんどん分割することで、その画面の責務をはっきりさせることができました。ほとんどの画面で、親画面へイベントを投げることは発生していません。
例外は2つです。
- OpinionListからのログアウトイベント
- OpinionListErrorからのリトライイベント
Stateを分割することで、FatViewControllerのような問題が起きにくくなっているのはこのアーキテクチャの特徴といえそうです。
今回は対応する機能がありませんでしたが、ひとつの画面の中に複数のSub Stateと対応するViewがあるような場合は、ここまでシンプルにできるのか気になるところです。
Sub State に関わるテスト
State 分割によって責務を小さくし、各Stateごとにテストを書けるので、テストを作るのはかなり快適でした。
Sub Stateのイベントに依存するテストもScopeを使って、Sub StateのTestStoreを作ることで書くことができます。
一覧と詳細の行き来をするときに、詳細で変更された値を一覧に反映する処理が発生します。今回は、サーバーから再取得ではなく、画面間で値を伝える方法をとっています。そのため、一覧のテストコードで、詳細画面で発生するイベントを発生させてSub Stateの書き換えをしたくなりました。
Sub State で発生するイベントを send(_)
することはできますが、Sub State が受けとるイベントの検証はできませんでした。(実は書き方がある?)
Sub State が受けとるイベントに関わる検証は、Sub Stateのテストとして書く方が適切なので、悪くない使いわけと思います。
// Sub State のスコープを指定して、新たに TestStore を得る let detailState = store.scope(state: \.selection, action: OpinionListSort.Action.opinionDetail) // Sub State で起きるイベントは指定できる (ここでSub Stateの検証が可能。今回は変化しないケースだった。) await detailState.send(OpinionDetail.Action.isCheckedChanged(isOn: true)) // Sub State で上記イベントから連鎖して起きるイベントの `receive` を書きたくなるが、元 State の `Action` として書く必要がある await detailState.receive(OpinionListSort.Action.opinionDetail(OpinionDetail.Action.responseUpdate(.success(.init(isChecked: true, isTestData: false))))) { $0?.opinion.isChecked = true }
以上
Flutter化の振り返り
加賀ゆびぬきシミュレータアプリをFlutter 化したときの内部に関わる記録を残したかったので、あらためて記憶を掘り起こしました。
理由
Android、iOSの2環境に、同じロジックで提供していたため、不具合修正も2箇所に実装していて手間がかかっていました。ほぼ1人プロジェクトなので、メンテにかかる時間を減らす価値があります。 宣言的UIに慣れるにも良い機会です。
方針
- UI、ロジック共に、OSによる分岐は極力しない
- データ管理もプラットフォーム間で共通化する
- 保存単位とファイル単位が一対一なのは共通だが、保存ファイル名は違うルールだった
実装の記録
中心機能である、刺し図の描画から着手しました。夫が手伝ってくれたので、一番複雑な手順の並び替えを実装してもらいました。 画面パーツを作っていき、データを指定すれば、各画面のプレビューができる状態にしました。
その後、Providerを使ったデータ管理を導入していきます。ゆびぬきの編集は3つの画面で構成されており、ひとつのオブジェクトを更新していくため、Providerで状態を同期できるようにしました。Provider経由で取得したデータを、各画面でViewModelに変換して表示します。
画面遷移は Navigator 2.0 を使いました。パッケージは使わずに、画面Stackを自前で構成しています。当時は、解説記事も少なく、Flutter の Navigator 2.0 の解説 前編とコードを読みながら進めていました。現在だと、便利なパッケージもあり、ある程度隠蔽して使うこともできるようです。
過去のバージョンのアプリデータをマイグレーションする機能は、アプリ内の深い階層にインポートログも見れるようにしました。
開発終盤に手順管理のUIに手を入れて、初心者にわかりづらい「グループ」を消す大きな変更をしました。「グループ」はデータ都合の集合なので、実際にゆびぬきを作る上では対応する集合を表す名前がありません。それ故に、よくわからない概念が登場し、「初心者には意味がわからないもの」になっていました。
アーキテクチャ
最終的に、MVVM風 + Navigator 2.0 でできています。(図は拡大推奨)
データからModel上で、Viewで表示する項目をViewModelに変換し、Viewで表示しています。「風」をつけたのは、ViewModelには振舞いを持たせず、画面特化のEntityとして扱っているためです。
このあたりは、SwiftのThe Component Architecture を学ぶとよりよい方法が見えそうな気がしています。
リリースから1年
状態管理の失敗
Navigation Stackをアプリで厳密に管理しているため、たまにStack操作の異常を検知しています。StateErrorを仕込んであるので、Crashlyticsに届きます。ドキュメントを二重にひらこうとしたり、手順の詳細を二重に開こうとするなどのケースで起きています。連打が可能になっていそうで、対策を入れる予定です。Flutter故に、描画が遅いことで起きている可能性もあります。単純に連打だとすると、操作画面側でローディングを入れるなども有効かもしれません。
assertionに変更して、検知はさせつつ、本番ではクラッシュしないようにする必要もあります。
エラー監視
実装方法にもよりますが、エラーをCrashlyticsに送るようにしていても、non fatal error なので監視に注意が必要です。 Document に従って、推奨されている記法を使いましたが、見直します。
runZonedGuarded<Future<void>>(() async { runApp(const YubinukiApp()); }, (error, stack) => FirebaseCrashlytics.instance.recordError(error, stack));
Warningとして記録する実装を書いたのと区別できないのが困っています。
これから
- 一部のパッケージ対応の都合で nullable のままのコードを読みやすく変えられそうです。パッケージの Non-null 対応も進んでいることでしょう。
- 前述のエラーまわりは改善して監視を楽にしたいです。
- 半年に1回くらいはFlutter の更新もしたいものです。
ゆびぬきシミュレータFlutter化計画
先日、加賀ゆびぬき刺し模様シミュレータ v2 を Android/iOS でリリースしました。
何回目かのリニューアルですが、その舞台裏と意図を記録しておこうと思います。
アプリを乗り換えよう
遡ること1年半。年を越えたら産前休暇が確定的になってきた頃、Android版にiOS版の機能を取り込むべく開発をすすめていました。
機能取込みのつらいところは、同じことを別の言語で実装して、大量にあるテストも再実装するところです。(アプリの核であるシミュレートエンジンは複雑なので、その分テストが増えます。)
そんなわけで筆(?)も乗らず、ゆっくりペースで開発はすすんでいました。
「今後のメンテを考えると、ソースコードをひとつにした方がいいんじゃない」という夫の助言から、Flutter に乗り換えることにしました。
これまでもマルチプラットフォームの開発ツールはあったものの、安定性の面で躊躇して取り組んではきませんでした。Flutter は Google が本腰を入れているので、使い続けることもできると期待しています。
シミュレート結果の描画を試して、ほぼいけると確認したので開発をはじめました。宣言的UIで開発していくのも新鮮でした。
iOS にのみ提供していた、3D プレビューも夫が頑張ってくれたおかげで、Flutter版アプリでも機能提供できました。
Flutter Web
Android/iOSにFlutterで実装したアプリを公開できました。
Flutter には、Webブラウザで動くアプリケーションも作ることができます。加賀ゆびぬきシミュレータもWeb版を置き換えられると、すべてのコードがひとつになる! と期待していました。
Flutter Web の課題を調べると、まだいくつか実用段階になさそうな issue が登場します。
少し古いですが、わかりやすかった課題解説がこちら。
アプリの性質上、UIは別に作りなおす必要があると踏んでいました。モバイルアプリの動作をWebで再現されても使いやすくはありません。
これらを想定しつつも、自分のアプリだとどうなるかな、という興味からひとまず最低限の変更でWebブラウザで動かしてみることにしました。
やってみると結構大変でした。4日ほどかけて重要な画面を開ける状態にしました。(日に1〜2時間)
- platform の書き分けがめんどい
- UIパーツの書き分けも必要 (数字のドラムロールが操作不能、Webで動かしたときだけ表示される要素がある)
- 複数画面で構成されるの操作がしんどい。画面遷移いらない。 (想定通り)
- ロードがめちゃくちゃ遅い。さっと始められない。 (known issue)
- 日本語が豆腐になる
- クリックへの反応がちょっと遅い
これは実用段階ぎりぎりかな、という感想でした。
ゆびぬきシミュレータにおいては、さっと始められないのは許容しがたいので、Web版は別の実装ですすめることにしました。
Android/iOSと同等のシミュレートができるように更新予定です。
Web版に適用できないのは残念でしたが、Android/iOSをひとつのコードで管理できるだけでもだいぶ助かっています。Flutter ありがとう。
iPadに最適化したビューにトライした
こんにちは。 加賀ゆびぬきシミュレータアプリのiOS版を、iPadで実行するときにより見やすいように改良しました。v1.3.4 大きな変更になったので、いくつかの気づきを記録します。
表示する情報量を増やしたい
加賀ゆびぬきシミュレータのiOS版は、およそ8割がiPhone、2割がiPadで利用されています。(2018年7月実績) 特別な対応をしていないiOSアプリは、iPadで表示したとき、iPhoneで表示しているものをそのまま引き伸ばしたように表示されます。
せっかく画面が大きいのにそれを活かせていないし、拡大されることで間抜けに見えてしまう画面もありました。
ではどうするか。 単純に情報量を増やすためには、画面を分割して表示する項目を増やしていくことになります。 iPhoneで表示するときとまったく別の実装にはしたくなかったので、UISplitViewControllerを使って両環境での表示をひとつの実装で切り替えることを試しました。
画面分割による複雑さの上昇
UISplitViewControllerを使うことで、ゆびぬき作成手順の一覧と管理をmasterView、各手順の詳細の表示と編集をdetailViewに表示できるようになりました。UISplitViewControllerの機能に任せることで、iPhoneでは1カラム、iPadでは2カラムの表示切り替えをやってくれます。
表示を切り替えて、中身をそろえていく過程では特に問題に気づきませんでした。しかし、編集機能を追加していったところ問題が見えてきました。
iPadで表示しているときには2カラム表示なので、手順の数を増減しつつ、同時に手順の中身を変更することが可能になります。これらが同時に操作可能であると、手順の中身を編集している最中にその手順を一覧から削除可能です。そうした操作によって不具合が生じやすくなりました。
階層構造を持つデータの親と子を同時に編集できることで、データの整合性を維持するためにロジックの工夫が必要になってしまったのです。
だいたい出来上がってからテストをする段階で、2つのデータ(親と子)の状況の組み合わせで、アプリの状態が決まることに気づき、想定するべき状況の多さに頭を抱えました。
最終的に、親データ(手順の一覧)の編集中には、子データ(手順の中身)の編集をできないようにすることで、アプリの状態を減らして対処しました。
UISplitViewControllerの代表的な使い方は、iOSの設定アプリでしょう。このアプリでは、データの親にあたる一覧(iPadで開いたときに左側に表示される部分)は追加は削除をユーザーが行うことはありません。 もともと想定されている使い方が設定アプリのようなものであれば、今回の加賀ゆびぬきシミュレータでの実装は、想定と違う使い方をしたことで複雑さが増してしまったのかと思います。
そもそも画面分割することで、ViewControllerが増えていくと、各ViewControllerごとにとりうる状態が増え、かつそれらの連携の組み合わせの考慮が必要になるようです。UISplitViewControllerでなかったとしても、画面分割することで、状態の複雑化は発生するので、複雑なアプリになっていくことは避けられないのでしょうか。
画面サイズへ対応して変化するレイアウト
iPhone/iPadの両方で使いやすい画面にするために、それぞれの状態でレイアウトが切り替わるようにしました。
iPhoneでは上部に製図を固定し、スライダーと手順の一覧がスクロール可能になっています。 iPadでは上部に製図とスライダーを固定で表示し、手順の一覧とその詳細がそれぞれ別にスクロール可能にしています。
レイアウトの切り替えは、一部はStoryboardで設定するのみで済ましました。SizeClassによって自動的にHideの有無を切り替えるよう指定しています。
当たり前なのかもしれませんが、初めは気づかなかったのが、各ViewControllerが受け取るSizeClassがそのVewControllerが扱うViewのみを対象に計測されているということです。アプリの実行されている画面サイズによるものと考えていたため、UISplitViewControllerの中に分割して表示されるViewControllerが受け取るサイズがRegularではなく、compactである理由がわかりませんでした。
製図とスライダーの表示の仕方を決める条件が、アプリが実行されている画面サイズであったため、ここは実装するしかありませんでした。ViewControllerに用意されている traitChange ??? をRootViewControllerに実装し、そこで受け取ったSizeClassを子ViewControllerへ伝播して、レイアウトを切り替えるようにしました。子ViewControllerが受け取るTraitCollectionとは別の値を使用するために、切り替えも自前で実装することにしました。
まとめ
これまでまともに扱ってこなかった、サイズクラスと画面分割をフル活用してアプリを改善してみました。
目的通りに表示する情報量を増やすことはできましたが、構造的に高い複雑性を抱え込んでしまうという結果になりました。 実際に、使い勝手が向上したかどうかはユーザーのみなさんの反応を待ちたいと思います。
ReadableContentGuide を試してみた
ReadableContentGuide
iOS 9 から導入された、コンテンツ幅を端末や文字サイズに対応した、読み易い幅に設定するものだ。
Safe area とともに、WWDC 2018 のセッションでも触れられていた。
Xcode 9.4 を使った場合の動作を試したので記録する。
加賀ゆびぬき刺し模様シミュレータを題材にしたので、キャプチャにあるのは開発中の画像です。Readable Widthの適用がわかりやすいため、iPad 横向きを使用しています。
なお、ここで扱うTableViewでは、UITableViewCellとContentView の "Preserve Superview Margins" をONにしてある。
基本
Xib や Storyboard で、ViewのSize Inspector上で、”Follow Readable Width” のチェックを入れることで有効にできる。 有効にしたViewのMargin(内側)が、Readable width に則って設定されるようになる。
これを有効に使うには、Viewの子要素は、margin に対してconstraints を設定する必要がある。 constraints を作成するときに、“constrain to margins” にチェックを入れた上で親Viewとの位置関係を指定する。
UITableView
TableViewにReadable Widthを適用するとき、”Follow Readable Width” のチェックボックをONにするViewによって、レイアウトが変化する。
UITableViewの基本的な構造の内、ここでは上層の3つを扱う。コンテンツ依存になるContentViewの中身については、基本のルールに従うので触れない。
- UITableView
- UITableViewCell
- ContentView
- Label などコンテンツの中身
- ContentView
- UITableViewCell
Case 1
Follow Readable Width の設定
- UITableView: OFF
- UITableViewCell: OFF
- ContentView: ON
まず、Readable widthを設定しようとするのは、ContentViewになるだろう。 CellのContentViewにだけ、ContentViewにFollow Readable Widthを設定した。
コンテンツ幅は、Readable Widthになり、それらしくなる。しかし、accessary を使用している場合には、accessaryが右端に表示されるため、コンテンツと離れてしまう。 また、2行目以降と見比べると、accessaryの有無で、コンテンツ幅が揃わないという問題もある。
Case 2
Follow Readable Width の設定
- UITableView: OFF
- UITableViewCell: ON
- ContentView: ON
次に、UITableViewCell にも設定する。 これはひとつ前の状態と変化しなかった。
Case 3
Follow Readable Width の設定
- UITableView: ON
- UITableViewCell: ON
- ContentView: ON
次は、UITableViewにも設定しよう。
UITableViewのSize Inspectorには項目がないのだが、UITableViewとUITableViewCellを同時に選択すると、”Follow Readable Width” のチェックボックスが出現する。
チェックボックスをONにする。
accessaryの位置がReadable Widthの中に収まった。 しかし、今度は、accessaryの左側の余白が気になる。
Case 4
Follow Readable Width の設定
- UITableView: ON
- UITableViewCell: ON
- ContentView: OFF
ContentViewの"Follow Readable Width" を外した。
accessary の左側の余白がなくなり、きれいにそろった。 いまのところ、これがベストの設定のようだ。
テストへの視野狭窄に気づいた話をしてきた
とちぎテストの会議 05 で、「テストへの視野狭窄に気づいた話」をしてきました。
発表では省略したこととか、自分用の追記情報を書いておこう。
不具合報告
すごく丁寧な不具合報告をいただきました。 この手順ではできるけれど、少し変えると製図がおかしくなる。という分析までしていただいて、画像つきで報告をいただきました。
画像つきで、報告いただけると、問題の把握が簡単で非常に助かります。
もちろん、画像なしでも、不具合報告いただけると嬉しいです。
アプリの課題としては、ご意見導線を用意しているけれど、活用しきれてないかもしれないです。 画像添付できる方がよいとか、あるかもしれません。
過剰なテストを書かないためには
どうしたら有効なテストだけにできるかな、と考えていたのだけれど、基本的に未来のことはわからないという前提にたつと、はじめから完璧にするのは無理とあきらめたほうがよさそう。 気づいたときに、整理したり消したりできるだけの、柔軟性をもっていられる環境を作る方が有効に思います。
今回話したのは、ひとつの実話でした。 言語化できないけれど、似たようなことにひっかかっていた人たちが、言語化して発信して、行動する助けになればうれしいです。
1年近くブログ書いてなかった。かなしみ。
iOS版の加賀ゆびぬきシミュレータを書き換えているはなし (自分メモ)
(今日は自分メモなので、文章は素です。)
いろいろ集ってきた要望を取り入れて、機能を増やすために、ひさしぶりに加賀ゆびぬきシミュレータをがりがりと書いている。 今回、大きく書き換えをしているので、その経過を残したい。
課題
このアプリのアーキテクチャはほぼMVCである。 そして一番重いのが、いい感じにシミュレート結果を描画する機能である。
もともとEntityとModelがべったりくっついていて、配列の二重管理が発生していたために問題が多かった。 まずは、そこを解消した。
次に、Modelに描画のためのプロパティと処理がかなり含まれていて見通しが悪いのが問題になっていた。 ここはテストが書きにくく、複数の責任を持っていると感じていたところである。
はやりのクリーンアーキテクチャの勉強をしていたのもあり、全面的に書き換えるという妄想も持ったが、問題になっているところだけエッセンスをもらうことにした。 クリーンアーキテクチャでは、ModelをViewのためのViewModelへ変換する層がある。 描画のために必要な情報だけを含むViewModelを作れば、Modelのごちゃごちゃを減らせそうであると考えた。
したがって、今回は、Modelの見通しの悪さを解消するために、描画のためのModelを新たに作ることにした。 データ編集のためのModelと、描画のためのModelを分けるのである。 描画のためのModelはViewModelと呼ぶことにした。
実装
これまでの構造では、データの流れは以下のようになっていて、ViewControllerはModelを書き換えながら描画をしていた。 描画のためのプロパティを Model が持っていたのである。
ValueObject -> Model <--> ViewController
新しい構造では、データの流れが一方通行になって、ViewControllerはViewModelを参照するが、書き換えをしないで描画できるようになった。 描画のための一時的な状態は、ViewController内で完結した。
ValueObject -> Model -> (translater) -> ViewModel -> ViewController
ModelからViewModelへの変換は、translaterで行うことにした。 ViewModelは、状態が変わらないオブジェクトになり、ViewControlelr内の描画処理も複雑さが減った。 ViewController内の処理が簡潔になったことで、描画の不具合は、ほぼModelからViewModelへの変換部分の問題であると限定できるようになった。 ModelからViewModelへの変換のテストを書けるようになったので、問題の検証が容易になった。
その後
現在は、既存機能の書き換えが終わって、予定していた機能追加ができる状態に辿りついたところである。 リリースまではまだ少し時間がかかる。
手続き構造が変わったこと
(ここはコードなしで伝えるのがむつかしかったので、自分向けです。)
今回の書き換えで、手作業をそのままコードにしたような手続きそのままの実装が消えた。
これまでは、手作業に準じていたので、割と条件判断が複雑だった。 人間の作業は、意外に複雑なようだ。
// 描画の疑似コード - 手順があるだけ繰り返す - 糸の色を選択 - 一段終わるまで繰り返す - 一針分すすめる(描画) → 段を変えるべきか?
描画の部分だけで言えば、条件判断が減った。
// 描画の疑似コード - 描画設定があるだけ繰り返す - 一段分の設定を取り出す (色、方向、位置を持つ) - 設定に従って描画する
複雑な分岐は、ModelからViewModelへの変換部分へ移動したが、考え方自体が大きくかわった。 行ってみないとわからない、という状態から、未来が予測可能になったような大きな変化だと思った。
// 変換の疑似コード - 手順があるだけ繰り返す - その手順で刺す回数を計算する - 糸の色の段数を決める - 刺す回数分くりかえす - 描画設定をつくる (決めた段数に応じて色を変える)