POSレジグループで STORES レジ という製品の開発をしている片桐といいます。今年の2月から、POSレジグループにサーバーサイドエンジニアとして参加したのですが、現在はサーバーサイドの開発と並行してアプリの開発にも参加しています1。
STORES レジ は、iPad で利用できるPOSレジアプリです。STORES の中では比較的あたらしいプロダクトですが、人員や時間の限られる中生き残ってきたプロダクトの1つではあるので、相応に負債となってしまっている部分もあります。その1つとして「カスタマーディスプレイ」という機能がありました。今回、機能開発と並行してカスタマーディスプレイの負債を解消できたので、その取り組みについて紹介させていただきます。
カスタマーディスプレイとは
スーパーなどにある昔ながらの一般的なレジの場合、従業員が見ている画面に加えて、お客さま向けに金額の表示される画面もついています。
STORES レジ では、スマートフォンを STORES レジ アプリと接続することで、このようなお客さま向けの画面として利用できる「カスタマーディスプレイ」という機能を搭載しています。
カスタマーディスプレイは、実はWebSocketを利用した単なるウェブページです。STORES レジ アプリがHTTPサーバーとなってウェブページをWi-Fiネットワーク内に配信し、スマートフォンは単にウェブブラウザでそれを表示している、といった構造になっています。画面に表示すべき情報が変わるたびに、新しく表示したいHTMLをWebSocketのconnectionに対してpushすることで、画面をリアルタイムに更新しています。
課題
カスタマーディスプレイはウェブページであり、iOSアプリの画面ではないので、SwiftUIの範囲外になります。そのためなのか、カスタマーディスプレイの画面を更新する処理は宣言的な実装になっておらず、全体が命令的な実装となっていました。そのため、「お会計中に〇〇という操作をしてから商品を追加すると表示がおかしくなる」といった、操作手順によって不具合が発生する事象が頻発していました。さらに、それを回避するためにアドホックな修正が積み重なっていった結果「お会計にかかわる部分に手を入れるたびに、毎回カスタマーディスプレイの表示が問題ないかを入念に確認する」という作業が欠かせない状態になってしまっていました。
私がここの改善を決意するきっかけになった、具体的な問題を紹介します。
レジアプリのお会計フローは、大きく「商品をカートに入れるフェーズ」と「お支払いをするフェーズ」に分かれます。「お支払いをするフェーズ」では、STORES ロイヤリティ という機能との連携でポイントによる割引も設定できます。
画面 | 商品をカートに入れるフェーズ | お支払いをするフェーズ | お支払いをするフェーズでポイント割引を設定 |
---|---|---|---|
iPad | |||
カスタマーディスプレイ |
このとき、ポイントを設定した後に「内容を変更」ボタンから再度「商品をカートに入れるフェーズ」に戻ることもできます。ただ、その後「お支払いをするフェーズ」に戻ってきたときに再度割引ポイントを入力するのは手間なので、「商品をカートに入れるフェーズ」に戻った場合もポイント割引金額は保持されていると嬉しいです。
しかし、「ポイント割引」はあくまで「お支払いをするフェーズ」で設定するものであり、「商品をカートに入れるフェーズ」では設定されていない値です。「商品をカートに入れるフェーズ」に戻ってきたときはカスタマーディスプレイには表示させたくありません。
それを実現するためのカスタマーディスプレイの更新処理がこちらです。
// 全体割引をセットしたときはポイントによる割引を合計金額に反映させない return customerDisplaySocket.send(Process.edit(state.cart.activeCart).contents)
customerDisplaySocket.send
に表示したいデータを渡すことで、カスタマーディスプレイの表示を更新できます。ここで、customerDisplaySocket.send
に渡すデータを生成するのに使用しているProcess
の定義がこちらです。
public enum Process: Equatable { // カートで全体割引をセットした時と、お支払い方法選択からホームに戻った時に呼ばれる // 合計金額の表示にロイヤリティポイントを含めない case edit(Cart) // 合計金額からロイヤリティポイント分を引く case prepare(Cart) case inComplete(remainingPrice: Int) case complete(change: Int) case refund(Order) // カスタマーディスプレイに表示するHTMLを構築する処理 public var contents: CustomerDisplayMessage { switch self { // (以下略) }
これはやばいな、と思いました。「カートで全体割引をセットした時と、お支払い方法選択からホームに戻った時」とありますが、上に書いた期待仕様とこの条件を結びつけるのはかなり難しいです。また、edit
/ prepare
という命名も何を表しているか不明瞭です。
同時に、ちょうどそのとき進めていた機能開発でカスタマーディスプレイの処理にも手を加える必要があったので、このまま実装したくない!という強い気持ちに駆られました。
前提
ここから、実際の改善について紹介していきたいのですが、その前にいくつか前提となる情報を共有させてください。
お会計に関する情報がどのようにアプリ内で扱われているか
STORES レジでは、お会計する商品を入れる「カート」の情報やお会計時に設定したポイント割引の情報は、特定のViewに紐づいたstateではなく、globalなstateとして管理しています。これは、複数の画面でカートやお会計に関する情報を表示する必要があり、特定のViewに紐づく情報ではないためです。
先に紹介したコードでstate.cart.activeCart
というコードが出てきましたが、このstate
がglobalなstateです。
改善の方針
現代ではモバイルアプリ・ウェブフロントエンド共に、宣言的UIでUIを実装するのが一般的です。
かつて宣言的UIが導入される以前は、ドメインデータをもとに、そのデータに沿うようにUIの必要な箇所を書き換える指示を出していました。しかしUI上のあるパーツがどう表示されるべきかには複数のデータの状態が関わっていることがあり、この関係しているデータが更新されるたびに、他の関係するデータの状態を踏まえてUIを更新する必要があります。このアプローチはUIの更新が漏れてしまうリスクがありますし、そのときに更新されたデータとは別のデータがどうなっているかの考慮も必要です。UIが複雑になるにつれて更新処理の内容も複雑になってきてしまいます。
宣言的UIでは、UIは「stateを引数に受け取って、それに対応するUIを返す関数」という形(render function)で実装されます2。stateが変化するたびに、render functionが再実行され、その返り値のUIと現在のUIを比較し、差分を解消するようにUIを変更する仕組みになっています。この「もとになる情報の変更に対して、自動的にUIも変化する」という性質を「リアクティブ」と呼びます。この仕組みであれば、きちんとstateを更新さえできていれば、UIがそこからずれてしまうことはありません。
私はカスタマーディスプレイの機能も、宣言的UIのエッセンスを取り入れたリアクティブな実装にできるのではないかと考えました。
目の前の問題を解決する
最終的にはリアクティブな実装に切り替えたいところですが、そこまでやり切るとなると、カスタマーディスプレイ機能全体に手を入れることになります。機能開発のスケジュールがある中、機能開発より先にそれだけの開発工数・QA工数を割くのは困難です。一方で今のまま会計処理周りの実装を進めたくはありません。そこで、私は今の実装の中で特に問題になっているところにターゲットを絞って先行して対応することにしました。
私が特に問題だと思ったのは、下記の仕様に対応する実装です
しかし、「ポイント割引」はあくまで「お支払いをするフェーズ」で設定するものであり、「商品をカートに入れるフェーズ」では設定されていない値です。「商品をカートに入れるフェーズ」に戻ってきたときはカスタマーディスプレイには表示させたくありません。
既存の実装では、カスタマーディスプレイを更新するすべての場所を精査し、「このときはポイント割引を計算に入れるべきか」を確認し、それに応じた処理が実装されていました。(「課題」のセクションで共有させていただいたコードがまさにそれです。) ここを改善できれば、既存のツラみを大きく軽減できると考えました。
その対応が以下になります。
CustomerDisplay.swift
public enum Process: Equatable { - // カートで全体割引をセットした時と、お支払い方法選択からホームに戻った時に呼ばれる - // 合計金額の表示にロイヤリティポイントを含めない - case edit(Cart) - // 合計金額からロイヤリティポイント分を引く case prepare(Cart) case inComplete(remainingPrice: Int) case complete(change: Int) case refund(Order) // カスタマーディスプレイに表示するHTMLを構築する処理 public var contents: CustomerDisplayMessage { switch self { - case let .edit(cart): - // ロイヤリティポイントを含めない合計金額文字列を生成する処理 case let .prepare(cart): - // ロイヤリティポイント分を引いた合計金額文字列を生成する処理 + if cart.shouldApplyPointsToCustomerDisplay { + // ロイヤリティポイント分を引いた合計金額文字列を生成する処理 + } else { + // ロイヤリティポイントを含めない合計金額文字列を生成する処理 + } }
Cart.swift
public struct Cart: Equatable, Hashable { // (中略) + /// ポイントをカスタマーディスプレイの表示に反映するか + /// NOTICE: 本来であれば「会計フローのどこにいるか」のような情報からポイントを反映すべきか判断するのが妥当なはずなので、ここにフラグは要らないはず + /// しかし、現状ではどのように保持して参照すべきかが決めきれないので「会計フローのどこにいるか」相当のstateを導入するのは断念した + /// ただ、直近「ポイントを反映すべきか」の判断がかなり非直感的なロジックになっていて課題感が強いので + /// 暫定的なものになることは許容しつつフラグを導入した + public var shouldApplyPointsToCustomerDisplay: Bool
更新を指示する際に、edit
/ prepare
を使い分けていたのをやめて、Cart
というデータ(state)に「ポイントをカスタマーディスプレイに表示すべきか」というフラグを追加し、それで分岐するように変更しました。そして、会計フローのフェーズが切り替わる箇所にこのフラグを切り替える処理を追加しました。
これもこれでカスタマーディスプレイの広範囲に影響のある変更ではあるのですが、前述したとおり、この時点でのカスタマーディスプレイは「機能開発をするたびに壊れる」と認識されている場所でした。そのため、今回の機能開発のスケジュールにもカスタマーディスプレイの動作検証が含まれていました。今回「目の前の問題」の解消に注力したこともあり、この修正による影響範囲は元々予定されていた検証でカバーできる範囲に収まっていました。
結果として、この修正はスケジュールに大きく影響を与えることなくリリースまで進められました。
リアクティブな実装へ
ここまでの実装を通して、自分の中で「頑張れば完全にリアクティブにするのも割と現実的にいけるんじゃないか?」という気持ちが高まってきました。そこで、既存の負債で起きている問題とその対応にかかる工数の見込みをチームで共有し、カスタマーディスプレイの改善をスケジュールに乗せて進めることにしました。
リアクティブな実装を目指すにあたって、既存実装で課題となっているのは以下の2点です。
- カスタマーディスプレイのUIは、更新時の指示に依存している(命令的な)部分があり、がglobalなstateから一意に導くことができない。
- お会計関連の金額が変わるたびに、手動でカスタマーディスプレイを更新する処理を実行しなければならない。
UIをglobalなstateから一意に導けるようにする
最初の改善で、Process
enumのcase数、つまり命令のパターン数は4つに減らすことができました。
public enum Process: Equatable { // 元々は`case edit(Cart)` というcaseがあった case prepare(Cart) case inComplete(remainingPrice: Int) case complete(change: Int) case refund(Order)
あとは、この4つを呼び出し元が判断して呼ぶのではなく、globalなstateに含まれている情報から判断できるようになれば、命令的な処理は一掃できます。そこで、まずはProcess
に定義されている命令のパターンがそれぞれどのような状態にときに呼び出されているのか整理してみました。
public enum Process: Equatable { // 「商品をカートに入れるフェーズ」or「お支払いをするフェーズ」 // ここは最初の改善で`Cart.shouldApplyPointsToCustomerDisplay`という globalなstateで判定できるようになっている case prepare(Cart) // 複数の決済手段で支払いをするパターンで、部分的に支払いが行われた状態 case inComplete(remainingPrice: Int) // 全ての支払いが完了した状態 case complete(change: Int) // 返品処理を行っている状態 case refund(Order)
これを元に、現時点でglobalなstateの情報でどこまで分岐をカバーできるのかを考えてみました。
- 支払いの進捗状態はすでにglobalなstateで管理されている。なので、
prepare
/inComplete
/complete
はすでにglobalなstateにある情報で判定できる。 - 返品の際の返金額などの情報は、会計金額と同じglobalなstateで管理されている3。しかし、それが返金なのか通常の会計なのかを判断できる情報はglobalなstateに無い。
これによって、残る問題は「今返金中なのかどうかの情報がglobalなstateに存在しない」であることがわかりました。なので、この情報を表すフラグをglobalなstateに導入すれば対応できそうです。
しかし、すでに最初の対応で、Globalに管理されているCart
というstateにshouldApplyPointsToCustomerDisplay
フラグを導入しています。そこで、既存でglobalなstateに持っている情報を踏まえて、もっと整理された情報の持ち方ができないかを考えてみました。
そこで思いついたのが、「会計フローのフェーズ」という概念です。
最初の対応ではshouldApplyPointsToCustomerDisplay
という「カスタマーディスプレイがどのような挙動をすべきか」という観点のフラグを追加しました。これを「商品をカートに入れるフェーズなのか、お支払いをするフェーズなのか」という観点のフラグにもできるなと思いました。
さらに、今回区別したい「返金のフェーズなのか」という情報は、上記の2つの状態とは重複することのない状態です。なので、この3つの状態を「会計フローのフェーズ」と定義し、globalなstateに持たせるのが、適切な状態の持ち方ではないかなと考えました。
具体的な変更がこちらです。
CustomerDisplayState.swift
public struct CustomerDisplayState: Equatable { + /// 会計フローのフェーズの定義 + /// + /// NOTICE: 本来であればこのStateはCustomerDisplayに閉じたものではなく、Globalに管理されるべきもののはず + /// ただ、現状適切な置き場所がなく設計から検討が必要なので、一旦CustomerDisplayに閉じたStateとして導入する + public enum OrderPhase: Equatable { + /// 他のいずれにも該当しない、初期状態のフェーズ。 + case notEntered + /// お会計を行うフェーズ。ロイヤリティポイントによる割引を設定したり、お支払い方法を選択したりする画面以降のフェーズを指す。 + case ordering + /// オーダーの取り消しを行うフェーズ。 + case refunding(Order) + } public var url: URL? // (中略) + /// 現在会計フローのどのフェーズにいるか + public var currentOrderPhase: OrderPhase
Cart.swift
public struct Cart: Equatable, Hashable { // (中略) - /// ポイントをカスタマーディスプレイの表示に反映するか - /// NOTICE: 本来であれば「会計フローのどこにいるか」のような情報からポイントを反映すべきか判断するのが妥当なはずなので、ここにフラグは要らないはず - /// しかし、現状ではどのように保持して参照すべきかが決めきれないので「会計フローのどこにいるか」相当のstateを導入するのは断念した - /// ただ、直近「ポイントを反映すべきか」の判断がかなり非直感的なロジックになっていて課題感が強いので - /// 暫定的なものになることは許容しつつフラグを導入した - public var shouldApplyPointsToCustomerDisplay: Bool
図らずも、shouldApplyPointsToCustomerDisplay
のNOTICEコメントに書いていた「『会計フローのどこにいるか』相当のstateを導入する」という目標に一歩近づきました。まだCustomerDisplay専用のstateにはなってしまっていますが、変更の方向としても良さそうです。
更新処理を個別に書くのをやめる
globalなstateはRootState
というstructで管理されています。このstateは、SwiftUIで変更を検知できるように、ObservableObject
を実装したStore
というclassのpropertyになっています。
public final class Store: ObservableObject { @Published public private(set) var state: RootState // (以下略)
先の修正で、カスタマーディスプレイでの表示に必要な情報はすべてこのglobalなstateの中に格納されている状態になったので、このstate
をいう値の変更を監視することで個別の更新処理を無くせそうです。
その実装がこちらです
CustomerDisplayMessageBuilder.swift
※ 元々Process
というenumに実装されていた処理を移植してきました
public struct CustomerDisplayMessageBuilder { // 元になるデータ public static var initial: Self { .init( aaaa: .init(), bbbb: nil, // ... ) } public static func from(rootState: RootState) -> Self { .init( aaaa: rootState.cart.aaaa, bbbb: rootState.order.bbbb, // ... ) } public init(...) { // (略) } public var builtMessage: CustomerDisplayMessage { // (略) } }
ConnectRootStateToCustomerDisplay.swift
public func connectRootStateToCustomerDisplay( rootStatePublisher: Published<RootState>.Publisher, messageBuilder: @escaping (RootState) -> CustomerDisplayMessage, socket: CurrentValueSubject<CustomerDisplayMessage?, Never>, initialMessage: CustomerDisplayMessage ) -> AnyCancellable { socket.send(initialMessage) return rootStatePublisher // stateの変更を確実に順番通りに反映したいので、変更の反映処理はSerial Queueで実行する .receive(on: DispatchQueue(label: "*****")) .map(messageBuilder) .removeDuplicates() .sink(receiveValue: socket.send) }
AppDelegate.swift
customerDisplayCancellable = connectRootStateToCustomerDisplay( rootStatePublisher: store.$state, messageBuilder: { CustomerDisplayMessageBuilder.from(rootState: $0).builtMessage }, socket: customerDisplaySocket, initialMessage: CustomerDisplayMessageBuilder.initial.builtMessage )
リリースと成果
以上の2つの対応で、コードの複雑性を大きく減らしつつ、今までどおりにカスタマーディスプレイを動作させることができるようになりました。その後のQAでも大きな問題は発生せず、無事にリリースまでこぎつけることができました。
今回の修正により、以下のような改善が達成できました。
- カスタマーディスプレイのUIがglobalなstateから導けるようになり、誤った表示になるリスクが軽減された。
- globalなstate自体の更新が漏れない限り、カスタマーディスプレイが誤った表示になることは無くなった。
- ※ globalなstateの更新漏れは基本的にカスタマーディスプレイに限らない不具合なので、カスタマーディスプレイだけがバグるというケースはかなり減った。
- コードベースの各所に散りばめられていた更新処理が一掃され、カスタマーディスプレイの更新漏れのリスクが無くなった。
- これによりコードも削減できた。
結果として、「お会計にかかわる部分に手を入れるたびに、毎回カスタマーディスプレイの表示が問題ないかを入念に確認する」という手順を廃止でき、長年レジの生産性を悪化させていた問題の1つを葬り去ることができました。
まとめ
プログラムには負債はつきものです。メンバーのスキル、システムへの理解度、事業を継続していくための期日・どうしても入れなければならない機能、当時思い描いていた方針からの急な方針転換、etc.。業務としてプログラムを書く以上、負債のないプログラムを作ることは困難です。
負債をすべて解消しようとするといくら時間があっても足りないので、対応すべき内容の取捨選択は欠かせません。しかしながら取捨選択に意識が向き過ぎて尻込みしてしまうのも問題です。今回のように「このまま実装したくない!という強い気持ち」で一定勢いに任せて進むことも大事なのかなと思いました。
最後になりますが、 STORES では事業をしっかりと進めながら、着実に内部的な課題も潰していけるエンジニアを募集しています。興味を持って頂けた方はぜひご連絡ください。
- 前職時代にもAndroid専任で開発していた時期があり、今までやったことがないiOSの開発をやる機会が欲しい!と言いまくってこの体制にしていただきました。↩
-
概念的にこうなっているというだけで、具体的な実装が必ずしもそうなっているわけではありません。例えばAndroidのJetpack Composeでは戻り値を返さない関数として実装されていますし、iOSのSwift UIでは関数ではなくstructで実装されています。また、stateも引数として渡ってくるのではなく、それぞれの実装が提供する仕組み(
remember
や@State
)で管理されています。↩ - これもこれで適切な持ち方ではない気がするのですが、今回はここには手を入れていません。↩