トレタ開発者ブログ

飲食店向け予約/顧客台帳サービス「トレタ」、モバイルオーダー「トレタO/X」などを運営するトレタの開発メンバーによるブログです。

SwiftUIとCombineを使ったMVVMの実装

この記事は トレタ Advent Calendar 2019 の23日目の記事です。

こんにちは。iOS & Androidエンジニアの山口です。 トレタでは、主にトレタnowの開発を行っています。

これまで、UIKit、RxSwift(RxCocoa)を使ったMVVMアーキテクチャで実装することが多かったこともあり、 SwiftUI、Combineを使った場合にはどう書けばよいか考えてみました。

なお、この記事はSwiftUIを使う際に重要なポイントとなる PropertyWrappersやDynamicMemberLookup、それらを利用したObservedObjectやBindingについて の知識がある前提で記述しています。

ViewModel Protocol の定義

早速ですが、コードから説明していきます。 まず、すべてのViewModelが準拠すべきシンプルなProtocolを定義しました。 意図としては下記2点が挙げられます。

  • チーム開発する上でのViewModelの書き方をある程度統一したい
  • SwiftUI側で利用しやすい形にしたい
/// ViewModelが準拠するプロトコル
protocol ViewModelObject: ObservableObject {

    associatedtype Input: InputObject
    associatedtype Binding: BindingObject
    associatedtype Output: OutputObject

    var input: Input { get }
    var binding: Binding { get }
    var output: Output { get }

}

extension ViewModelObject where Binding.ObjectWillChangePublisher == ObservableObjectPublisher, Output.ObjectWillChangePublisher == ObservableObjectPublisher {

    var objectWillChange: AnyPublisher<Void, Never> {
        return Publishers.Merge(binding.objectWillChange, output.objectWillChange).eraseToAnyPublisher()
    }

}

protocol InputObject: AnyObject {
}

protocol BindingObject: ObservableObject {
}

protocol OutputObject: ObservableObject {
}

@propertyWrapper struct BindableObject<T: BindingObject> {

    @dynamicMemberLookup struct Wrapper {
        fileprivate let binding: T
        subscript<Value>(dynamicMember keyPath: ReferenceWritableKeyPath<T, Value>) -> Binding<Value> {
            return .init(
                get: { self.binding[keyPath: keyPath] },
                set: { self.binding[keyPath: keyPath] = $0 }
            )
        }
    }

    var wrappedValue: T

    var projectedValue: Wrapper {
        return Wrapper(binding: wrappedValue)
    }

}
ViewModelObject
  • 後述するInputObject、BindingObject、OutputObjectをプロパティとして持つ
  • 値の変更をSwiftUIに通知するためにObservableObjectに準拠
InputObject
  • ユーザからの入力であるタップなどのイベント一覧をPassthroughSubjectとして定義
BindingObject
  • TextFieldの文字列やToggleのオン・オフなどのような双方向バインディングする値の一覧を@Publishedとして定義
  • 値の変更をSwiftUIに通知するためにObservableObjectに準拠
OutputObject
  • Textに表示する文字列やListに表示する配列などUIに出力する値の一覧を@Publishedとして定義
  • 値の変更をSwiftUIに通知するためにObservableObjectに準拠

SwiftUIからViewModelを使うための工夫

SwiftUI側はViewModelインスタンスを@ObservedObjectとして保持します。 ViewModelが持つBindingObjectOutputObjectの値が更新された際に再描画させたいので、 その2つのobjectWillChangeをマージした結果をViewModelObjectobjectWillChangeとしています。

また、BindingObjectから双方向バインディングするためのBindingを取り出すために、 @propertyWrapper@dynamicMemberLookupを利用しています。

MVVMの実装

では実際にViewModelObjectを利用して、サインアップ画面ぽいものを実装してみます。 UIの構成要素は下記の通りです。

  • ID入力欄(6文字以上)
  • パスワード入力欄(8文字以上)
  • 確認用パスワード入力欄
  • 規約同意のトグルスイッチ
  • サインアップボタン

f:id:kyo__hei:20191224104341p:plain

ViewModelの実装

final class SignUpViewModel: ViewModelObject {

    final class Input: InputObject {
        /// サインインボタンのタップイベント
        let signUpTapped = PassthroughSubject<Void, Never>()
    }
    
    final class Binding: BindingObject {
        /// ユーザID
        @Published var id = ""
        
        /// パスワード
        @Published var password = ""
        
        /// 確認用パスワード
        @Published var confirmPassword = ""
        
        /// 利用規約同意フラグ
        @Published var agreed = false
        
        /// サインアップ完了アラート表示フラグ
        @Published var isCompletionAlertPresented = false
    }
    
    final class Output: OutputObject {
        /// サインアップボタンの有効フラグ
        @Published fileprivate(set) var isSignUpEnabled = false
    }
    
    let input: Input
    
    @BindableObject private(set) var binding: Binding
    
    let output: Output
    
    private var cancellables = Set<AnyCancellable>()
    

    init() {
        let input = Input()
        let binding = Binding()
        let output = Output()
        
        /// ユーザIDのバリデーション(6文字以上)
        let isIdValid = binding.$id
            .map { $0.count >= 6 }

        /// パスワードのバリデーション(6文字以上)
        let isPasswordValid = binding.$password
            .map { $0.count >= 8 }

        /// 確認用パスワードのバリデーション(`password`と一致)
        let isConfirmPasswordValid = Publishers
            .CombineLatest(
                binding.$password,
                binding.$confirmPassword
            )
            .map { $0.0 == $0.1 }
        
        /// サインアップボタン有効フラグ
        ///   - すべての入力内容が有効
        ///   - 利用規約に同意
        let isSignUpEnabled = Publishers
            .CombineLatest4(
                isIdValid,
                isPasswordValid,
                isConfirmPasswordValid,
                binding.$agreed
            )
            .map { $0.0 && $0.1 && $0.2 && $0.3 }
        
        /// サインアップ完了アラートの表示
        let isCompletionAlertPresented = input.signUpTapped
            .flatMap {
                // 実際にはModel層のサインアップ処理を呼び出す
                Just(true)
            }
        
        
        // 組み立てたストリームをbinding, outputに反映
        cancellables.formUnion([
            isCompletionAlertPresented.assign(to: \.isCompletionAlertPresented, on: binding),
            isSignUpEnabled.assign(to: \.isSignUpEnabled, on: output)
        ])
        
        self.input = input
        self.binding = binding
        self.output = output
    }

}

ViewModelの実装は基本的にイニシャライザのみで、InputObjectBindingObjectから流れてくるストリームの合成を行い、 その結果をBindingObjectOutputObjectに反映させています。BindingObjectに定義されている双方向バインディング用の値は@Publishedとして定義されているため、 $をつけることでPublisherを取り出すことができます。

Viewの実装

import Combine
import SwiftUI

struct SignUpView: View {
    
    @ObservedObject var viewModel: SignUpViewModel
    
    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            TextField("ユーザID", text: viewModel.$binding.id)
                .textFieldStyle(RoundedBorderTextFieldStyle())
            
            TextField("パスワード", text: viewModel.$binding.password)
                .textFieldStyle(RoundedBorderTextFieldStyle())
            
            TextField("パスワード(確認用)", text: viewModel.$binding.confirmPassword)
                .textFieldStyle(RoundedBorderTextFieldStyle())
            
            Toggle(isOn: viewModel.$binding.agreed) {
                Text("利用規約に同意する")
            }
            
            Color.clear.frame(height: 16)
            
            Button(action: { self.viewModel.input.signUpTapped.send(()) }) {
                HStack {
                    Spacer()
                    Text("サインアップ")
                        .padding(.vertical)
                    Spacer()
                }
            }
            .disabled(!viewModel.output.isSignUpEnabled)
            
            Spacer()
        }
        .padding(.horizontal)
        .padding([.top], 64)
        .alert(isPresented: viewModel.$binding.isCompletionAlertPresented) {
            Alert(title: Text("サインアップ完了"), dismissButton: .cancel(Text("OK")))
        }
    }
    
}

@ObservedObjectとしてViewModelのプロパティを定義します。 ボタンタップなどのアクションを受け取るクロージャで、InputObjectSubjectに対してイベントを送信しています。 TextFieldなどの双方向バインディングする値については、viewModel.$bindingと書くことでBindableObject.Wrapperを取り出すことができ、 DynamicMemberLookupによりBindingプロパティを取得しています。

おわりに

SwiftUIにはバインディングの仕組みが標準的に実装されており、UIKitとRxSwift(RxCocoa)での実装にくらべ、かなりすっきりと書くことができました。 また、今回実装したViewModelはView側をUIKitに差し替えても使うことができるため(バインディングの仕組みが無いので少しつらいですが)、 SwiftUIではまだ実装が厳しい部分については、一部UIKitで実装するなどの選択肢も取ることが可能かと思います。

Combineについては、2019年12月時点ではWithLatestFromオペレータが無いなどの問題はありますが、 RxSwiftを触ったことがあればすんなり実装できるかと思います。 SwiftUIとCombineのこれからに期待しています!

なぜQAエンジニアが仕様を書いたのか

この記事はトレタ Advent Calendar 2019 21日目の記事です。

こんにちは。QAエンジニアの坂田です。

トレタに入社して1年が経ち、既存サービスのエンハンス開発やいくつかのリニューアルプロジェクトに携わってきました。プロダクト品質向上やメンバーへの貢献を模索するなかでQAエンジニアが仕様を書くという試みをテーマの1つとして行ってきましたので、今回はその話をしたいと思います。

なお、本記事では「プロダクトの外部的な振る舞いを定義したもの(UI仕様)」のことを「仕様」と呼びます。テストの仕様ではありませんのでご注意ください!

そもそもなぜ仕様を書くのか

仕様を書く目的を私なりにざっくり表現すると、以下のような感じになります。

  • リリース前:プロダクト設計の不明瞭さや矛盾を早期検知・解決するため
  • リリース後:プロダクトの振る舞いが本来どうなっている(べき)か確認するため

リリース前は開発フェーズ、リリース後は運用フェーズと言い換えると分かりやすいかもしれません。

仕様はメンテが大変、すぐ陳腐化する、などの理由でそもそも書かれないことも多いと思いますが、それはリリース前ではなく後者(リリース後)のドキュメント保守の不毛さが原因だと考えています。

最近ではソースコードやテストケースなどの最終成果物で仕様を分かりやすく表現し、中間的な仕様書の保守をなくしてしまおう、という考えも主流になっています。 リリースを経て最終成果物が揃っている段階であれば、個人的にはこの考え方に大賛成です。

ここで問題なのは、前者と後者が混同され、保守が大変だからという理由で前者(リリース前)の目的での仕様作成も行わない判断が下されがちなことです。

リリース前は、要件検討〜UI設計〜実装〜テストの全てのフェーズで設計を反復的に更新する必要が生じます。 また、後ろのフェーズで不明瞭さや矛盾が見つかれば、都度前のフェーズへ戻って設計を見直すことになります。

そのため開発フェーズにおいては、可能な限り早い段階から設計の問題点を解消しつつ、プロジェクト全体に及ぶ頻繁な設計変更に追随し、メンバー間で常に認識を揃えるための何か(つまり仕様)が必要です。

個人的な考えですが、開発フェーズでは「プロダクト設計の不明瞭さや矛盾を早期検知・解決する」ために仕様を書くのは意義があるし、むしろそれに特化させてしまって良いのではないか、と考えています。

なぜQAエンジニアが書くのか

通常、QAが仕様をレビューすることはあっても、書くことはあまりないかもしれません。 にも関わらず私が仕様を書こうと思ったのは、以下のように着手しやすい状況だった、というのが大きいです。

  • ゼロからではなく既存プロダクトの動作をベースに仕様を書き起こすことができた(リニューアルプロジェクトの場合)
  • プロジェクト参入時点で画面デザインや遷移図などのドキュメントが揃っていた
  • キャッチアップのための時間的余裕があった

実際に仕様を書いてみて、QAが仕様を書くのは自分のためにもチームのためにも利点が多いと実感しました。 具体的にいくつか挙げてみます。

仕様作成へのモチベーションがある

困ったことに、そもそも仕様(あるべき振る舞い)が分からないとQAはテストを設計できません。 そうした状況を日頃から経験しているQAは、テストに足りない情報を集めて仕様を整理する、という行動を元々取ってきており、それをプロジェクト早期から行っておくことへの強いモチベーションがあります。

早期に第三者視点で仕様をチェックすることで品質が上がる

個人的に一番重要と考えているのがこれです。 実はテストで見つかるバグのほとんどは実装の不手際ではなく、重要な要件に誰も気づいていなかったり、仕様に綻びがあったり、仕様の認識がずれていたことが原因だったりします。

ソフトウェアテストには「初期テスト」という原則があり、プロジェクト終盤のテストでバグを見つけるよりも序盤で設計の問題点を早く潰した方が何倍何十倍も少ないコストで対処できます。

QAは通常テストを行うことで品質をチェックしますが、むしろより重要なのは、どれだけ早期に仕様の不明瞭さや矛盾を沢山発見し解決に導いていけるかだと考えています。

その意味で、QAがテストに使える粒度でちゃんと仕様を書き、同時に仕様作成のベースとなるドキュメントを第三者視点でチェックする役割を持つ意義は大きいです。

実装フェーズやテストフェーズで本来のタスクに集中できる

仕様が書かれない場合、実装フェーズでデベロッパが細かい仕様を検討しながらコードを書いていくことになります。 その結果、デベロッパはシステムの内部構造やアルゴリズムの検討など本来のタスクに集中できなくなり、外部の振る舞いまで扱う必要が生まれ、複雑度は格段に上がってしまいます。

仕様策定作業を後工程に丸投げせず先に行っておくことで、各フェーズで本来のタスクに集中できるようになり、開発スピードも向上します。

デザインから開発への橋渡しができる

QAは元々テスト設計などで「ユーザに対して果たすべき機能」を「システムが行うべき振る舞い」として表現する能力を身につけています。

これはまさに仕様を書く際にも発揮される能力で、システム構造や内部処理を念頭に置きながらデザインで意図された機能や振る舞いを書いていくことで、必要な情報を開発に必要な粒度で漏れなく記載することができます。

デベロッパにちゃんとテストを書いてもらえる

期待する動作が仕様で整理されているとデベロッパがユニットテストや結合テストを書きやすくなり、実際に書いてもらえる、という利点もあります。

仕様を書いてみて学んだこと

最後に、QAエンジニアが仕様を書いてみて学んだことと反省点を整理します。

  • 良かった点
    • 仕様を細部まで書こうとすることで初めてデザインや仕様の不明瞭さや矛盾点を発見できることが多かった
    • 仕様を書いている途中で色々とテスト観点が浮かび、それをテストフェーズに活かせた
    • プロジェクトのスムーズな進行や品質向上に多少なり貢献でき、PMやデベロッパから感謝された
  • 反省点
    • 仕様を詳細に書いたつもりでも、仕様漏れによる手戻りがいくつか発生してしまった
    • QAのプロジェクト参画時期が、仕様を書くには少し遅いタイミングだった
    • プロジェクトメンバーに仕様を読んでもらう努力が足りず、仕様が認識されていないことがあった
    • プロジェクト完了まで仕様書をちゃんと更新し続ける意識と覚悟が必要

次にやりたいこと

テーマは変わりますが、E2EテストレベルでBDD(ATDD)を実践していきたいです!

一緒に働く仲間を探しています

トレタのQAエンジニアはお互いの自主性を尊重しつつ、プロダクト品質向上・生産性向上のために積極的に幅広い役割を担っていくことが可能です。

SET(Software Engineer in Test)も求めていますので、ご興味ある方はぜひ下記からご応募ください!

トレタCCにAmazonConnectを導入した話

本記事はトレタアドベントカレンダー19日目の記事です。

こんにちは。リングフィットアドベンチャーを2週に1回のペースでプレイしているフロントエンドエンジニアの北川です。

私はトレタのコンタクトセンター事業(以下、トレタCC)のシステム開発全般を担っており、今年は基幹のCTI(電話機)をAmazonConnectへ入れ替えたので、その辺の話を本記事で紹介します。

トレタCCとは

トレタCCは、契約している飲食店の電話業務を代行するサービスです。

トレタって予約台帳なのになんでコンタクトセンターやってるの?と聞かれることがありますが、トレタは飲食店を支援することをミッションとしています。店舗にとっての電話業務とは、予約の電話に限らず当日の道案内や落し物の問い合わせなど、予約以外の多種多様な電話も時間問わずにかかってくるとても業務負荷の高いものです。トレタCCではそれらすべての電話を代行し、飲食店に電話がかかってこない環境をつくることで本来の業務にフォーカスしていただくためのサービスです。

トレタCCのシステムアーキテクチャ

トレタCCで使用するアプリケーションは、代行した店舗の電話をうけるCA(コールエージェント)が利用するシステムです。 トレタの予約台帳への予約の書き込みや、顧客台帳から電話口の顧客情報を表示したりします。また、前述の通り予約以外の電話もあるため、専用のDBヘ電話の対応履歴を登録したりします。

利用技術

  • AmazonConnect
  • Angular
  • Lambda(Node.js)
  • DynamoDB

f:id:mkitagawa-312:20191216132949p:plain

クライアントはAngularで作られたWebアプリケーションになっており、AmazonConnectが提供しているCCP(ContactControlPanel)を埋め込み電話操作を行います。 サーバーサイドはAmazonConnectとの連携を考慮し、Lambdaを主軸にしたサーバーレス構成にしています。AngularへのAPIを提供や、AmazonConnectの問い合わせフロー(IVR)との連携を行います。DBはLambdaとの相性を考慮してDynamoDBを利用しています(先日LambdaにRDS Proxy機能が発表されたので、今ならRDBを使う選択肢もあると思います)。 また、serverlessフレームワークを利用しており、Lambdaのデプロイや、APIGatewayとDynamoDBをオフライン化した状態(serverless-offline, serverless-dynamodb-local)の開発環境を構築しています。

データ分析

AmazonConnectでは、履歴メトリクスとして通話履歴やエージェントの稼働状況の統計画面が標準で提供されています。データ出力も提供されており、トレタCCでの通話履歴とトレタ台帳の予約データを突合させるため、BigQueryでデータの一限管理を行なっています。

  • AmazonKinesis
  • GoogleBigQuery

f:id:mkitagawa-312:20191216133023p:plain

AmazonConnectはAPIを提供していますが、通話履歴のAPIは提供されていないため、AmazonKinesisを設定することで、リアルタイムの通話履歴が取得できます。AmazonKinesisに設定されたLambdaが随時トリガーされ、渡される通話履歴データをBigQueryへ集約しています。また、音声ファイルは自動でS3へアップロードされ、通話履歴内にも音声ファイルのURLが含まれています。

f:id:mkitagawa-312:20191216133038p:plain

AngularとAmazonConnectの連携

電話のインターフェースはAmazonConnectが提供しているCCPを使用しています。また、jsライブラリであるamazon-conect-streamsを使うことで、CCP上での受電などのイベント取得や発信を行うことが出来るようになります。

f:id:mkitagawa-312:20191216133054p:plain (AmazonConnectのCCP)

amazon-conect-streamsでは、エージェントのステータス変更イベントや通話のステータス変更イベントにコールバックが設定できるので、RxJsと組み合わてストリームにしてAngularで扱っています。

export class AmazonConnectAdapter {
  callStatus$ = new BehaviorSubject<CallStatus>(CallStatus.disconnect);
  agentStatus$ = new BehaviorSubject<AgentStatus>(AgentStatus.unavailable);
  availableQueues$ = new BehaviorSubject<Queue[]>([]);
  agentEndpoints$ = new BehaviorSubject<AgentEndpoint[]>([]);
  onInboundCallIncoming: EventEmitter<CallNumbers> = new EventEmitter();
  onInboundCallAccepted: EventEmitter<CallNumbers> = new EventEmitter();
  onOutboundCallConnected: EventEmitter<CallNumbers> = new EventEmitter();

  initialize(ctiElement: ElementRef) {
    this.initCCP(ctiElement);
    this.openLogin();

    connect.agent(agent => {
      this.onAgentCallback(agent);

      agent.onRoutable(() => {
        this.agentStatus$.next(AgentStatus.available);
      });

      agent.onOffline(() => {
        this.agentStatus$.next(AgentStatus.unavailable);
      });

      agent.onAfterCallWork(() => {
        this.agentStatus$.next(AgentStatus.unavailable);
      });
    });
// ...

CCP内で行える機能はほぼamazon-conect-streamsから可能になっているため、今後は自前のUIに切り替える予定です。 逆にCCPではできないこともあり、発信時には発信元が設定できなかったりします。

f:id:mkitagawa-312:20191216133113p:plain (発信用インターフェースの例)

LambdaとAmazonConnectの連携

AmazonConnectはIVR機能である問い合わせフローを管理画面から設定することができ、フロー内でLambda関数を呼び出し、取得した値に応じたフローを書くことが可能です。

f:id:mkitagawa-312:20191216133135p:plain たとえば、コールセンターの業務時間の設定は標準機能である「オペレーション時間」の管理画面から設定することができますが、飲食店では祝日や祝前日は特別なオペレーションを行うことがあるため、自前のオペレーション時間設定をLambda関数から呼び出すことで柔軟に対応を行なっています。

今後の取り組み

AmazonConnectへの切り替えが終わり、現在はAmazonConnectを利用した自動音声応答(VoiceChatBot)に力を入れています。 AmazonConnectの問い合わせフローでの標準機能として、

  • VideoStreamによるリアルタイムな音声データの抽出
  • Lambda呼び出しによる動的なフロー作成
  • AmazonPollyによる動的な文章読み上げ

が可能です。 これらを組み合わせることで、店舗にあった動的な自動応答が可能になります。 また、先日Amazon Transcribeの日本語対応が発表され、文字起こしがさらに容易に行えるようになりました。 テンプレ的な予約の受け答えはBot化し、人にしか対応できない部分はオペレーターが対応するハイブリッドな形を目指していこうと思っています。

おわりに

トレタCCでは、今までにない新しいコンタクトセンターを作っていく仲間を募集しています。 フロントエンドでAngularをゴリゴリやりたい方、Node.jsでサーバーレスをやりたい方、通話内容を分析して機械学習をやりたい方、などなど幅広く募集しております!

www.wantedly.com

お次はインフラエンジニアのなぎらさんです。お楽しみに!

© Toreta, Inc.

Powered by Hatena Blog