全国のスーパーに置かれる storeTV 端末の情報の取得にかかる時間を15分から10秒にした話

こんにちは。
メディアプロダクト開発部の柴原です。
普段は CookpadTV のサービスである storeTV や storeLive の Android アプリを開発を担当しています。

storeTV では現在、サービスを高品質に継続開発・運用するための仕組みづくりをしており、この記事ではその中の1つである「端末が再生した動画をリアルタイムで把握する機能」について紹介します。
このリアルタイムログの導入でもともと実際の端末で動画再生がされているのかを確認するのに15分かかっていたものが10秒程度に短縮できました。

storeTV とは

storeTV は、スーパーで料理動画を流すサービスで、店頭に独自の Android 端末を設置し、その売り場に適したレシピ動画を再生するサービスです。 より詳しいサービス概要にについては、弊社メンバーの Cookpad TechConf 2018 における以下の発表スライドを御覧ください。

動画の再生順序の概要

storeTV では、「手順動画15秒 * 4回 + 広告動画30秒 * 1回」の90秒を1ロールとし動画をループ再生しています。

f:id:Nshiba:20210607140255j:plain
1ロールの動画再生順序

背景

storeTV では通常のアプリよりも端末の状態把握が重要です。 なぜなら「storeTV」は、買い物客の「今日何作ろう?」と、店舗の「この食材がおすすめ!」をマッチングさせるサービスなので、サービスとして動き続けることが重要だからです。 万が一停止していると問い合わせが発生した場合こちらで端末の状態を把握し回答をする必要があります。
そのため、いくつか端末の状態を把握するための仕組みがあり、主に端末から送信しているログと端末管理システムから取得できる情報があります。

端末から送信しているログについては、ログが送信されてから実際に見れるようになるまで約15分程度の時間がかかります。 また、端末管理システムから取得できる情報はアプリバージョンやOSバージョンといった基本的なものと、1台ずつですがその時のスクリーンショットを取得することが可能です。

よって、端末の状態を把握するにはログが来るまでの15分程度待つか、端末管理システムで1台ずつ能動的にスクリーンショットを確認していく必要がありました。

このような状況を改善するためにリアルタイムで各端末の動画再生ログを収集するリアルタイムログを導入しました。

構成


リアルタイムログを実現するために以下のような構成を採用しました。

f:id:Nshiba:20210607140435j:plain
構成図

IoT

まずログの受け取りは AWS IoT の Rule Action を用いています。
なぜ AWS IoT を用いているかというと、端末管理システムは内製したもので AWS IoT を用いて開発しており、今回のリアルタイムログも端末管理システムの一部機能として実装しました。
端末管理システムのより詳しい話は、以下のAWS導入事例の記事を御覧ください。

aws.amazon.com

Rule Action とは、特定の Topic に対してデータが送られてきた際に特定の動作を行わせることができる機能です。 例として、受け取ったデータを DynamoDB や S3 への書き込みや Lambda Function の起動といったことが可能になってます。

しかし今回のリアルタイムログでは、受け取ったデータを Kinesis に流す構成を採用しています。

Kinesis & Lambda

IoT Rule Action で受け取ったデータは一度 Kinesis に入れ Lambda から DynamoDB に書き込みます。 これは、大量のデータを DynamoDB に直接書き込んでしまうと大量の WCU が必要になってしまうため、書き込み量を制御するためです。
 構成図を見ると分かる通り、最終的に DynamoDB にログを書き込みます。
IoT の Rule Action ではそのまま DynamoDB に書き込むことも可能ですが、今回は一度 Kinesis を挟んでから DynamoDB に書き込むようにしています。
これは、そのまま DynamoDB に書き込んでしまうと秒間の書き込みの最大が端末数に依存してしまうためです。
現在でも約5000台程度の端末から約15秒に1件のログが送られてきます。さらに端末数は今後増えていく見込みであるためシステム側で DynamoDB への書き込み量を制御するために Kinesis を挟み、一度ハンドリングしてから DynamoDB に書き込むようにしています。

DynamoDB

データは storeTV 端末から以下のような JSON が送られてきます。
thing_name とは端末を識別するためにそれぞれ割り当てられている固有の値です。

{
    "thing_name": #{thingName}, 
    “creative_id”: 1, 
    “creative_type”: 1,
    "published_at": "2021-06-01 10:00:00"
    “project”: “store_tv”   
}

これを thing_name を Hash Key とし、受け取ったものから上書き保存という形で DynamoDB に書き込んで行きます。

name type schema example
thing_name String Hash Key thing_001
creative_type Int Sort Ket 1
creative_id Int 1
published_at String 2021-06-01 10:00:00
project String store_tv

大量のログをさばくためにしたこと

この機能を実現しようとしたときに、まずはじめに困ったことは大量のログが常に送信され続ける、という点です。
そのため、この機能を実装する際に大量のログをさばくためにいくつか対策をしました。

実際どの程度のログが送信されてくるかというと、現在 storeTV が稼働している端末で、端末管理システムが導入できている端末は当時5000台程度になります。
また storeTV は主に15秒の動画をループ再生しているため、1分間に送信されうるログの量は 5000 x 4 で20000件のログが送信されてくることになります。

IoT Rule Action では受け取ったデータをそのまま DynamoDB に書き込むことも可能ですが、この量のログをそのまま書き込んでしまうと大量の WCU が必要になってしまい運用にかかるコストが莫大になります。
そのため、まずは直接 DynamoDB に書き込むのではなく、 Kinesis でバッファし Lambda で DynamoDB に書き込むログの量を制御できるようにしています。

さらに DynamoDB のテーブル構成を見ると thing_name を Hash Key としています。
そのため、このテーブル仕様では1つの端末が sort key 毎に1行のログしか持てないことになります。
これは今回のリアルタイムログでは、端末が「最後に動画を再生したのはいつか」が把握できれば良いという方針で設計を行ったからです。
こうすることで Kinesis に溜まったデータを Lambda で DynamoDB に書き込む際に、同じ thing_name のログは最新のログだけ DynamoDB に書き込むようにすることで書き込む量を削減し大量のログをさばけるようにしています。

また、今回のログは端末側がオフラインのときなどにログが送信できなかった場合、再送などの処理は行っていません。
そのため、もともと正確性はそれほど高くならないような設計になっており、広告動画の再生回数の集計などには使えないものになっていますが、集計のためのログはすでに別の方法で取得し集計しているため今回は必要ありませんでした。

DynamoDB のコスト最適化

今回のリアルタイムログでは、 Scheduled Action を用いて 8:00~22:00 の間だけ DynamoDB の WCU を必要な分確保しています。 これは storeTV は 8:00~22:00 の営業時間というのが設定されており、営業時間外の 22:01~翌日7:59まではアプリは動作しておらず、ログが来るのは 8:00~22:00 の間のみに限定されるからです。

ちなみに、当初は Auto Scaling のみで DynamoDB の WCU を調整するようにしていましたが、これだと 8:00 の段階で急に書き込みが増えるため Auto Scaling が間に合わず数時間にわたりログの遅延が発生する状態に陥っており、これを解消するためにも Scheduled Action を用いるようにしました。
また、オンデマンドでも試しましたがサービスの稼働時間が固定されている状況もあり、 Auto Scaling のほうがコスト的にメリットが大きかったため Auto Scaling を採用しています。

まとめ

今回リアルタイムログを実装し端末ごとに今なんの動画が再生されているのかが迅速に把握できるようになりました。
ちなみにログの遅延具合は約5~10秒程度、遅くても1分以内に再生した動画は確認できるようになりました。
ただし、まだ端末ごとに再生した動画が確認できるだけなのでこのリアルタイムログを活用した新たな機能や、障害対応時を想定した端末内のより詳しいログを欲しいときだけ取得する機能など、端末の状態をより詳しく把握するための仕組みづくりを検討しています。

また、私はアプリエンジニアでしたが、サービスに何が必要なのかを自分で考えそれが自分の得意分野でなかったですが興味のある分野であったため、今回設計から実装までを任せてもらい、実際に Go でプログラム書いたり Lambda のデプロイ環境の準備や DynamoDB のテーブル設計を行いました。
そんなメディアプロダクト開発部では、一緒に働いてくれるメンバーを募集しています。少しでも興味を持っていただけたら、ぜひ採用情報を御覧ください。

info.cookpad.com

料理動画サービスに強く興味がある方は以下のリンクから「料理動画サービス」のついた項目を御覧ください。

info.cookpad.com

音声インターフェースに最適なビジュアルインタラクションを実現するための APL テクニック

こんにちは、 CTO 室の 山田 (@y_am_a_da) です。今回は、 Amazon Presentation Language (APL) という、Amazon Alexa 向けのアプリケーション (以下、スキルと記述します) 上で主にビジュアルの表現や、音声や液晶操作によるユーザーとのインタラクションを実現するために使われている言語についての簡単な紹介と、最近のクックパッドでの実装テクニックについてお話させていただきたいと思います。

APL とは

APL とは、先に述べた通り、 Amazon Alexa 向けのスキル上で主にビジュアルの表現などを実現するために使われている言語です。画面に表示するコンポーネントの定義やデザイン、 ユーザーとの簡単なインタラクションを実現するためのロジックを以下のような JSON 形式で記述することができます。

{
    "document": {
        "type": "APL",
        "version": "1.6",
        "theme": "dark",
        "mainTemplate": {
            "parameters": [
                "payload"
            ],
            "items": [{
                "type": "Container",
                "width": "100%",
                "height": "100%",
                "items": [{
                    "type": "Text",
                    "text": "こんにちは"
                }]
            }]
        }
    }
}

Amazon 社の販売する Amazon Echo は、数年前から液晶付きの端末を何種類か販売しており、もちろんそこで動くスキルも音声だけでなく画面に表示をすることでユーザーとやりとりをすることができます。例えば、クックパッドスキルでは 2021 年 5 月現在、ユーザーが指定したレシピの作り方をナビゲーションする機能を提供しており、その際には以下のような画面を Echo 上に表示しています。

f:id:yoshiaki-0614:20210527142123p:plain
クックパッドスキルの画面

APL 自体はただの言語とそれを評価する仕組みでしかなく、https://github.com/alexa/apl-core-library で OSS として公開されているため、 Web ブラウザやモバイルアプリケーション上でもレンダリングの仕組みさえ用意すれば APL を導入することが可能です。

また、 APL は Amazon Alexa 向けに提供していることもあり、 Amazon Alexa や、音声インターフェース特有の技術課題や要望を満たす上で役立つ特徴をいくつか備えています。

なお、今回の記事執筆時点での APL の最新バージョンは 1.6 です。可能な限り詳細に書きますが、スキルを開発したことが無いと理解が難しい内容も含んでいます。

APL の優れている点

データサイズを小さくするための仕組みが提供されている

Amazon Alexa 上で動くスキルは、予め用意したサーバーにリクエストを送信し、そのレスポンスをもとに発話や画面表示などのインタラクションをする。という仕組みで動いています。 サーバーから返すレスポンスにはセッション変数、 Alexa に読み上げさせるテキスト、 APL によるビジュアルの記述などを含めることができるのですが、合計で 24KB 以内に収める必要があります (つい先日、仕様の変更によりついに 120KB まで許容されるようになりましたが、スキルがクラッシュしなくなるだけで応答はレスポンスのデータサイズが大きくなるほど遅くなるため、対話的なインタラクションを実現したいならば今まで通りに抑えておいたほうが良いと思います)。

この限られた容量に収められるように、 APL では下の例の documentdatasources のように、画面に表示するコンポーネントとそこで利用したいデータを分離して記述できる仕組みがあります。これにより、コンポーネント内で何度か同じデータを参照する必要があったとしても、一度 datasources に定義するだけで使い回すことができます。

{
    "document": {
        "type": "APL",
        "version": "1.6",
        "theme": "dark",
        "mainTemplate": {
            "parameters": [
                "payload"
            ],
            "items": [{
                "type": "Container",
                "width": "100%",
                "height": "100%",
                "items": [{
                    "type": "Text",
                    "text": "${payload.helloWorldData.helloWorld}"
                }]
            }]
        }
    },
    "datasources": {
        "helloWorldData": {
            "helloWorld": "Hello World"
        }
    }
}

この他にも、とにかくデータサイズを小さくするための様々な仕組みが提供されています。

音声によるアウトプットに特化した仕組みが提供されている

Amazon Alexa において、視覚的なフィードバックはあくまでも音声でのフィードバックを補助する立場にあります。そのため、音声でのアウトプットと可能な限り同期的に視覚的なインタラクションを実行させる仕組みが提供されています。

APL で動的なインタラクションを実現する場合には、コマンドと呼ばれるものを使う必要があるのですが、例えば以下のように記述することで「DescriptionFrame というコンポーネントの背景色を黄色にし、 SpeakComponent の内容を読み上げた後、背景色を白色に戻す」ということができます。

{
    "type": "Sequential",
    "commands": [{
            "type": "SetValue",
            "componentId": "DescriptionFrame",
            "property": "backgroundColor",
            "value": "yellow"
        },
        {
            "type": "SpeakItem",
            "componentId": "SpeakComponent"
        },
        {
            "type": "SetValue",
            "componentId": "DescriptionFrame",
            "property": "backgroundColor",
            "value": "white"
        }
    ]
}

また、例えば以下のように記述をすることで「SpeakComponent の内容を読み上げるのと並行して DescriptionFrame の背景色を 500ms おきに黄色に 2 回点滅させる」ということもできます。このように、音声によるフィードバックに合わせたインタラクションを実現しやすくなっていることも APL の特徴といえるかと思います。

{
    "type": "Parallel",
    "commands": [{
            "type": "Sequential",
            "repeatCount": 2,
            "commands": [{
                    "type": "Idle",
                    "delay": 500
                },
                {
                    "type": "SetValue",
                    "componentId": "DescriptionFrame",
                    "property": "backgroundColor",
                    "value": "yellow"
                },
                {
                    "type": "Idle",
                    "delay": 500
                },
                {
                    "type": "SetValue",
                    "componentId": "DescriptionFrame",
                    "property": "backgroundColor",
                    "value": "white"
                }
            ]
        },
        {
            "type": "SpeakItem",
            "componentId": "SpeakComponent"
        }
    ]
}

このように、 APL にはいくつか強みといえる特徴がいくつかあるのですが、 Public Beta 版になったのが 2018 年後半とまだ産まれてそんなに経っていないこともあり、不便な点も多くあります。

APL の不便な点

画面を構成する要素の記述が宣言的であるものの、その状態の監視や更新の仕組みが貧弱

APL では、画面を構成する要素 (以下コンポーネントと記述します) をいわゆる宣言的に記述できます。 bind プロパティに定義した変数の値によりコンポーネントの内容を変化させることができます。 例えば、以下のように記述をすることで画面に「こんにちは」と描画をすることができます。 GreetingText の値を変更することで画面に表示するテキストも動的に変化させることができます。 ${} で囲むことでその内部の変数の展開や式の評価などができます。

 {
    "id": "MainContainer",
    "type": "Container",
    "width": "100%",
    "height": "100%",
    "bind": [{
        "name": "GreetingText",
        "value": "こんにちは",
        "type": "string"
    }],
    "items": [{
        "type": "Text",
        "text": "${GreetingText}"
    }]
 }

APL において bind に定義されている値を明示的に変化させる手段は SetValue コマンドただ 1 つです。例えば以下のように記述します。

{
    "type": "SetValue",
    "componentId": "MainContainer",
    "property": "GreetingText",
    "value": "Hello"
}

componentId に変更したい対象の id を設定し、 property に変更したい変数名 (正確にいうと widthheight などのプロパティも設定できます)、 value にその値をセットできます。 例示したような簡単なコンポーネントであれば問題は無いのですが、例えば、あるコンポーネントの bind に定義されている値を他のコンポーネントで監視したい場合には工夫が必要になります。

例えば、あるボタンをタップした時に別のコンポーネント内のテキストを表示させたい状況を考えてみます。

{
    "mainTemplate": {
        "parameters": [
            "payload"
        ],
        "items": [{
            "type": "Container",
            "items": [{
                    "id": "PressedStatusContainer",
                    "type": "Container",
                    "items": [{
                        "type": "Text",
                        "text": "ボタンがタップされました"
                    }],
                    "display": "${IsPressed ? 'normal' : 'none'}"
                },
                {
                    "type": "TouchWrapper",
                    "bind": [{
                        "name": "IsPressed",
                        "value": false
                    }],
                    "onPress": [{
                        "type": "SetValue",
                        "property": "IsPressed",
                        "value": "true"
                    }],
                    "items": [{
                        "type": "Text",
                        "text": "ボタン"
                    }]
                }
            ]
        }]
    }
}

上記の例では TouchWrapper にバインドされている IsPressed の値により display 値を変化させようとしていますが、これは想定通りの振る舞いをしません。なぜなら PressedStatusContainerIsPressed を参照できないからです。以下のように記述をする必要があります。

{
    "mainTemplate": {
        "parameters": [
            "payload"
        ],
        "items": [{
            "type": "Container",
            "bind": [{
                "name": "IsPressed",
                "value": false
            }],
            "items": [{
                    "id": "PressedStatusContainer",
                    "type": "Container",
                    "items": [{
                        "type": "Text",
                        "text": "ボタンがタップされました"
                    }],
                    "display": "${IsPressed ? 'normal' : 'none'}"
                },
                {
                    "type": "TouchWrapper",
                    "onPress": [{
                        "type": "SetValue",
                        "property": "IsPressed",
                        "value": "true"
                    }],
                    "items": [{
                        "type": "Text",
                        "text": "ボタン"
                    }]
                }
            ]
        }]
    }
}

IsPressedTouchWrapper ではなくその上位のコンポーネントにバインドする必要があります。 APL では、あるコンポーネントが別のコンポーネントに定義されている bind を参照したい場合、ただ一つのルールに則って可能となっています。「bind に定義された値は、後続のコンポーネントでも参照ができる」です。 これをコンポーネント視点で言い換えると「あるコンポーネントは、自分とその親の bind に定義されている値を参照できる」になります。そのため、複数のコンポーネントから監視したい値がある場合、それら全ての親コンポーネントとなるコンポーネントで bind で定義する必要があります。

これは非常に取り扱いの難しい仕組みです。例の IsPressedTouchWrapper が押されたかどうかを表現しているわけなので TouchWrapper に定義するほうが直感的です。しかし値の定義場所はそのコンポーネントだけの都合ではなく参照しうる全てのコンポーネント間の関係性によって決定する必要があります。 しばらくした後にコードを読み返した時、もしくは多くの開発者によって頻繁にコンポーネントの構造や挙動が修正される環境で、その修正に問題が無いかを判断するにはその画面の全体像やそのコンテキストを深く理解した人間による属人的な判断が頼りになるであろうことは想像に難くありません。

上記の例でしたら直接 PressedStatusContainerdisplay の値を変更する手段もとれますが、コンポーネントの数が増えてくるごとに難しくなっていくためおすすめできません。

また、これに近い話になりますが、 bind の値を監視する時だけでなく、値を動的に変更したい場合にも似たような困難にぶつかります。 例えばボタンがタップされた時に TextBox という id のテキストボックスに入力された値を MessageText というテキストコンポーネントで表示する例を考えてみましょう。

APL において値の更新に利用できるのは SetValue ただひとつです。そして、 SetValue では、コンポーネントを 1 つしか指定できません。 では、「TextBox の値を MessageText に表示する」をどうやって表現すれば良いのでしょうか。

このようにすることで実現できます。

{
    "mainTemplate": {
        "parameters": [
            "payload"
        ],
        "items": [{
            "type": "Container",
            "bind": [
                {
                    "name": "Message",
                    "value": ""
                }
            ],
            "items": [{
                    "type": "Container",
                    "items": [{
                        "id": "MessageText",
                        "type": "Text",
                        "text": "${Message}"
                    }]
                },
                {
                    "id": "TextBox",
                    "type": "EditText"
                },
                {
                    "type": "TouchWrapper",
                    "onPress": [{
                        "type": "SetValue",
                        "componentId": "TextBox",
                        "property": "Message",
                        "value": "${event.target.text}"
                    }],
                    "items": [{
                            "type": "Text",
                            "text": "ボタン"
                        }
                    ]
                }
            ]
        }]
    }
}

以下の部分が実際に値を変更しているコマンドになります。一言で書くと「設定したい値を持つコンポーネントを componentId に指定し、そのコンポーネントと、値を利用したいコンポーネントの両方が参照できる位置にある bind 値を property に指定して更新する」となります。

{
    "type": "SetValue",
    "componentId": "TextBox",
    "property": "Message",
    "value": "${event.target.text}"
}

このように、 bind を定義する位置はその値の監視、更新の都合に強く依存します。そのため、 bind の修正や、例えばある 1 つのコンポーネントの修正をするだけでも、その箇所だけではなくより広い範囲について深い理解を必要とします。

また、 APL のコンポーネントは layouts というプロパティ内にモジュールとして定義し、呼び出すことが可能なのですが、この制約がモジュールの作成を非常に難しくしています。例えば前の例に倣うと以下のような例が考えられます。前の例の TouchWrapperSendButton という名前で呼び出せるようになっています。

{
    "layouts": {
        "SendButton": {
            "items": [
                {
                    "type": "TouchWrapper",
                    "onPress": [{
                        "type": "SetValue",
                        "componentId": "TextBox",
                        "property": "Message",
                        "value": "${event.target.text}"
                    }],
                    "items": [{
                            "type": "Text",
                            "text": "ボタン"
                        }
                    ]
                }
            ]
        }
    },
    "mainTemplate": {
        "parameters": [
            "payload"
        ],
        "items": [{
            "type": "Container",
            "bind": [
                {
                    "name": "Message",
                    "value": ""
                }
            ],
            "items": [{
                    "type": "Container",
                    "items": [{
                        "id": "MessageText",
                        "type": "Text",
                        "text": "${Message}"
                    }]
                },
                {
                    "id": "TextBox",
                    "type": "EditText"
                },
                {
                    "type": "SendButton"
                }
            ]
        }]
    }
}

SendButton 内には Message プロパティの定義は無いものの、ボタンをタップした際には Message プロパティに値を設定しようとします。別の開発者がこのコンポーネントを再利用したい場合、 SendButton よりも上位のコンポーネントで Message を定義する必要があることに気づく必要があります。そもそも、そのモジュール内で挙動が完結しないモジュールはモジュールとしてどうなんだという話もあります。

モジュールを定義する場合には、この点を考慮してモジュールの粒度を考える必要が出てきます。

さて、便利な点も不便な点について理解したところで、次はクックパッドスキルで実際に利用しているテクニックについて紹介をしていきたいと思います。

クックパッドでの APL 実装テクニック

ここまで述べてきた通り、 APL には様々な特徴が備わっています。これを上手く使いこなし、 VUI (Voice User Interface) ならではの視覚体験を提供するために、クックパッドでは以下のようなテクニックを用いています。 今回主に以下の 2 つを達成するためのテクニックを紹介します。

  • GUI による操作でも VUI による操作でも一貫性のある体験を提供する
  • APL の bind の仕組みを上手く使いこなす

コマンドでコンポーネントのスタイルを直接変更しない

APL では「コンポーネントまでスクロールさせる (ScrollToComponent)」や「コンポーネントに紐づくテキストを読み上げる (SpeakItem)」など、直接コンポーネントを指定してコマンドを実行することがよくあります。

コマンド実行時にコンポーネントのスタイルを変更したい場合、ついコマンドで直接スタイルを書き換えたくなりますが、 APL では UI を宣言的に記述した方が良いので可能な限り避けるべきです。スタイルはあらかじめ bind に紐付けるようにしておき、コマンドでは bind の値を変更するに留めておきましょう。

これには以下のメリットがあります。

  • スタイルに関する記述がコンポーネント内に閉じ込められるため、見通しが良くなり、挙動を把握しやすくなる
  • 画面操作による要求でも発話による要求でも一貫した振る舞いをすることが容易になる

例えば、クックパッドスキルではこのテクニックを利用して以下のようなインタラクションを実現しています。

f:id:yoshiaki-0614:20210527142235g:plain
クックパッドスキルでのスクロール時のインタラクション

左下の「材料」ボタンをタップすると、材料の一覧までスクロールし、一部の文字がハイライトされたり、「上へ」のボタンが登場しています。 これらのスタイルは、全てスクロール位置に紐付いて変更させています。ボタンをタップした時に発火するコマンドは材料の一覧に対する ScrollToComponent のみです。 そのため、例えばユーザーが「材料を教えて」と声で尋ねてきたとしても同じく ScrollToComponent コマンドを発行するだけで同じ振る舞いをすることができます。

GUI と VUI が同居する世界観では、 GUI のみの場合と違いユーザーは同じ要求をボタンのタップだけでなく音声で求めてくることもあります。 コンポーネントのビジュアルを bind に依存させることで、要求のインターフェースが何であってもユーザーの意図していることが同じであれば同じ結果を返すことが容易になります。 これにより、振る舞いの一貫性を保ち、ユーザーに余計な認知負荷を与えないインタラクションが実現できます。

React や Flutter など、他のプラットフォームで似たフレームワークを利用した経験がある方からすれば当たり前のことのように聞こえるかもしれませんが、 APL では意外とやりがちなので意識しておくと良いと思います。

SendEvent によるリクエストの処理は Intent Request に合わせる

APL には SendEvent という、サーバーにリクエストを送信するコマンドが用意されています。例えば画面遷移をしたい場合にはサーバーにリクエストを送信して新しい画面を受け取る必要があるため、このコマンドを使うことになります。

SendEvent は、 arguments 内に好きな値を入れてサーバーに渡すことができます。

{
    "type": "SendEvent",
    "arguments": []
}

arguments は自由度が高すぎるため、そのまま使うとサーバー側で値を利用する際のロジックがとても複雑になり得ます。例えば SendEvent を送信するボタンが画面に複数個存在していた場合、 arguments だけ渡されてもそれが何のパラメータなのかを理解するだけで一苦労です。複数個のさまざまな型の値が含まれうる arguments を想定しなければならない状況は作りたくありません、

そのため、クックパッドスキルでは以下のように Intent 名とそのパラメータで統一しています。すなわち、サーバー側では発話による Intent Request と同じ扱いができるようなフォーマットにしています。 前の話と被りますが、ユーザーは GUI で実現できることは音声でも求めてくることがあり、また、スキルはそれに応えられるべきだと考えています。そしてその場合、インターフェースの種類に限らず意図が同じであれば同じような結果が期待されることがほとんどです。であれば、 GUI によるリクエストは発話によるリクエストと同じ扱いを受けても問題はないはずです。

{
    "type": "SendEvent",
    "arguments": ["SelectRecipeIntent", "${params}"]
}

クックパッドスキルでは APL が登場した当初からずっとこのルールで SendEvent を利用していますが、今の所困ったことはありません。細かい振る舞いの微調整が欲しいことはあるので、サーバー側で最低限リクエストが GUI 由来かどうかを区別できるようにはしていますが、その程度です。

カスタムコマンドを活用する

APL におけるコマンドは、 APL 内だけでなくサーバー側からも発行することができます。前に述べた「材料を教えて」というユーザーからの発話に対して画面に ScrollToComponent コマンドを送信するのはサーバー側からのコマンドで実現しています。これは、レスポンスに ExecuteCommand ディレクティブを挿入し、そこにコマンドを記述します。この場合、コマンドの記述はいわゆるサーバー側の特にリクエストを処理するロジックか、それに近いところで記述することになるかと思います。 一方で、先ほど登場した材料一覧のコンポーネントにスクロールするボタンは、そのコマンドを APL のコンポーネント内に記述することになります。この 2 つのコマンドは全く同じ振る舞いを期待しているため、可能な限り共通化をしたいです。

カスタムコマンドを利用して両者が実行するコマンドを共通化することには以下のメリットがあります。

  • コマンドの具体的な挙動が一箇所に集まるため、見通しがよくなり、修正をしやすくなる
  • APL のコンポーネントからでも、サーバー側の ExecuteCommand からでも同じコマンドを実行させることができるので、挙動の一貫性を保つことができる

特に APL のコマンドは、バグの検知が難しく、期待通りに動いているのかをテストするコストがなかなか大きいです。そのため、コード内や APL のコンポーネント内に色々なコマンドを直書きするのではなく、カスタムコマンドとして定義し抽象化することがとても重要だと考えています。

意味ある場所に bind を定義できない場合、いっそのことグローバル変数のようにしてしまう

APL の不便な点で述べたとおり、 bind に値を定義する場合、定義する場所はコンポーネント間の関係性に依存します。あまりにも多くの場所でその値を利用する場合、いっそのこと最上位に bind の値を定義するだけのコンポーネントを用意し、そこにまとめてしまっても良いかもしれません。

{
    "id": "GlobalVaribalesContainer",
    "type": "Container",
    "bind": [
        ...
    ],
    "items": [
        {
            "id": "Body",
            "type": "Container",
            "items": [
                ...
            ]
        }
    ]
}

一般的にグローバル変数を使う時のデメリットと同様に、気をつけるべきことはたくさんあるため慎重に採用するべきですが、コンポーネントの保守運用の点では他の開発者が bind の値を見つけやすくなったり、本質的でない場所に bind が定義されてしまうことによりモジュール化が難しくなることを避けることができます。

場合によっては意味が無くても引数を使う

layouts 以下に定義されるモジュールには parameters で引数を渡すことができます。これを利用することにより、例えば前で例に出した SendButton を利用したコンポーネントは以下のように書き換えることができます。

{
    "layouts": {
        "SendButton": {
            "parameters": ["Message"],
            "items": [
                {
                    "type": "TouchWrapper",
                    "onPress": [{
                        "type": "SetValue",
                        "componentId": "TextBox",
                        "property": "Message",
                        "value": "${event.target.text}"
                    }],
                    "items": [{
                            "type": "Text",
                            "text": "ボタン"
                        }
                    ]
                }
            ]
        }
    },
    "mainTemplate": {
        "parameters": [
            "payload"
        ],
        "items": [{
            "type": "Container",
            "bind": [
                {
                    "name": "Message",
                    "value": ""
                }
            ],
            "items": [{
                    "type": "Container",
                    "items": [{
                        "id": "MessageText",
                        "type": "Text",
                        "text": "${Message}"
                    }]
                },
                {
                    "id": "TextBox",
                    "type": "EditText"
                },
                {
                    "type": "SendButton",
                    "Message": "${Message}"
                }
            ]
        }]
    }
}

これで SendButton には Message という値を利用することが明文化できました。また、上位の Containerbind されている MessageSendButton で利用していることもひと目でわかるようになりました。

修正前と比べて挙動は変わらないのでシステム的に全く意味はありません。人間が挙動を理解するのに少し役立つ程度です。これが良い書き方かというと少し疑問は感じますが、コンポーネントが巨大になった時に bind の定義場所と実際に利用するコンポーネントが遠く離れることは珍しくなく、その時に挙動を理解するための手がかりとしてしばしば助けられたのでここに紹介しました。

まとめ

今回は APL という、 Amazon Alexa 向けのスキルでビジュアルインタラクションを実現するための仕組みについて紹介をさせていただきました。 APL はまだ産まれて間もないこともあり、実践的な内容を記している記事はまだそう多くないと感じております。 この記事が皆さまの日々の APL ライフに役立てばと思います。

弊社ではこのように色々な技術スタックを持ったエンジニアが数多く在籍しております。絶賛エンジニア募集しておりますのでご興味ありましたらぜひこちらのサイトをご覧ください。

https://info.cookpad.com/careers/

アプリリニューアルを楽しくやりきる話

こんにちは。レシピ事業開発部 クロスファンクショナルグループの @kaa です。

クックパッドAndroidアプリは昨年秋にフルリニューアルを実施しました。 リニューアル内容としては半年ほど先にiosでリニューアルを実施したものを導入になります。 弊社はこのリニューアルプロジェクト開始の2ヶ月ほど前からリモート主体の勤務体制になっており、期間中は週1で出社という状況でした。まだリモート勤務にもみんなが慣れているとは言えないタイミングでの実施となりました。

このプロジェクトのため3ヶ月程度の期間、部署横断でメンバーを集め進めました。 体制は以下の通り。

  • アプリエンジニア6名(1名はAPI兼任)+決済・基盤・テストのサポートに2名
  • デザイナー2名(デザインディレクション+1名)
  • ディレクター3名(PdM,PjM,開発ディレクション)

このプロジェクトについて、開発ディレクションの立場から楽しくやるために行っていたことをお話します。

モチベーションに関する課題

評価に対する不安

部署横断プロジェクトにありがちな課題ですが、メンバー本人の年初にたてた個人目標・チーム目標とは直接関係がない仕事をすることになります。 自分の仕事に対して評価が行われるのかわからない、最終評価者は詳しく見ていない、さらには自分がやりたいと考えていた今年の仕事とは異なる可能性もある。

この課題の対応のため、開発進行をしている人(自分ですが)が期末の追加の評価者として開発メンバー全員の評価にディレクションの立場から参加するということを事前に決めました。

主体性を持ってもらうための仕組み

人がパフォーマンスを出す条件として、次の3点を考えています。

  • やりたい仕事であること
  • 裁量があること
  • 仕事に集中できること

やりたい仕事であること

初耳の、3ヶ月拘束される本来の仕事と違うプロジェクトでいきなりパフォーマンスが出せる人はまずいません。 さらに今回は別プラットフォームでリリース済みのことを実装なので新規性もありません。 どうやって自分のやりたい仕事だと思ってもらうかが必要になります。

まず、参加する開発メンバーにいまのアプリ開発で改善したい点をだしてもらいました。

参考:大規模プロジェクトにおけるモバイル基盤の取り組み

長く運用しているアプリですので棚卸ししたい負債や制定したい実装ルールは溜まっています。 minSdkVersionあげたい、マルチモジュール化すすめたい、遷移ルールの統一、スタイルの再定義などなど。やりたいけど実施タイミングがとりにくいものはたくさんです。 これらの話題を事前に時間とって話し合い、共通の理想とする実装方法・改善をまとめます。今回は普段は基盤チーム(弊社ではサービス開発と開発基盤は部署として分かれています)に所属しているメンバーも多く参加しておりプロジェクトの事前準備の段階から関わっていました。

そしてこれらを実現できるプロジェクトとして進めました。他の部の仕事にお手伝い参加ではなくいま自分達の考える理想のアプリ開発に近づける仕事にしていきます。

裁量があること

各メンバーに対して、任せたタスクについてはほぼ一任しました。 仕様は事前に細かく詰める、でも仕様の実現方法については任せきります。 条件はコードレビューが通ること。 (クックパッドではPRのマージには一人以上のapprovedが必要です)

これだけでは実装方法にばらつきが発生しそうですが、事前に実装方法についての方針を開発陣で話し合って方針を決めているため、そこから外れることはありません。自分達の考えたルールに縛られるのはつらいことではなく自分達の理想を実行している状態ですので、やりたいようにやりつつ統一された実装方法で進めている、という状態になります。

また、仕様の実現が難しい場合はいつでも相談が可能としました。 実装して触ってみないとわからない問題はどうしても起きるものですしその画面について一番時間を使っているのは実装者本人なので気付ける改善も必ずあります。

ただし、相談には本人のどうしたらいいと思うかをあわせて伝える、というルールです。 どうすればよくなるか考えるきっかけにもなり、同時に仕様策定者に対する別の視点の提供になります。実際ほとんどの場合は開発者の意見をそのまま採用しました。 NGの場合は明確な理由があるものなので説明します。サービスの方向性の目線合わせもかねて。

集中できること

基本的に、人は考える必要のあることが少ない状態にあるほどパフォーマンスが上がると考えています。 考えてもすぐに結論のでないことはいくらでもありますし、違う話を理解するのも脳と時間のコストがかなり持っていかれます。スイッチングコストは本当にたいへん。

今回は2週間単位にフェーズを切り、タスクは画面単位に分けました。 その期間中、または完了するまでの間、他の人のコードレビュー以外で担当画面以外のことを考える必要がない状態を目指しています。

タスク単位を判りやすくしたのは、実装者の成果をわかりやすくする意味合いもあります。〜〜をやりました、とわかりやすく言えると評価に限らず仕事の実感としても感じやすいことを狙っています。 画面単位の実装段階は2つにわけ、機能実装と最終デザイン反映を別にしました。これは複数人でフェーズ単位の進め方をすると各実装のデザイン確認の時期がフェーズ終盤に集中し実装者に待ちが発生するのを避けるためです。機能実装が完了した段階ではデザイナーのチェックをなくし(スタイルは反映されているため大きく破綻はしません)、エンジニアは次のタスクに移ることが可能になります。

弊社では通常時はUI変更がある場合にデザイナー確認なしでのPRのマージは禁止ですがこのプロジェクト中は例外としました。

リモートにまつわる課題

リモートではただでさえ距離感が難しくなりますが、今回はそもそも所属している部も異なるメンバーが集まっています。エンジニア同士であればリポジトリ上、slack上でのやりとりはしていたとはいえ、職種が変わるとこれまであまり関わりがない人もいます。実際に開発ディレクションの自分とほとんど話したことのない開発メンバーもいました。

レスポンスを確約する

質問したいことがあった際にすぐ反応がもらえるか、も重要ですが今回は質問に対していつ答えを出すかを事前に決めました。 その日のうちに質問しておけば、翌日の昼会で決定した内容を伝えます。昼会で話題にする、相談するでなく決定状態にする、です。持ち越しダメゼッタイ。ディレクションのメンバーもきっちり仕事していると見せないといけません。すぐに反応するとか相談できるではなく確定させるタイミングをルール化します。 いつ決定した仕様がわかるのかが事前にわかっていれば実装の段取りもたてやすくなります。 どんなことでも知りたいことは翌日には確定したものがわかる、という状況を作ります。もちろん即答できるものは即答です。

情報の公開

実装に集中すればするほど、slack追ってらんないし関係ないかもしれないzoom色々はいっていられない。 しかしいまプロジェクトでなにが話題になっているか知りたい時に知れる状態を目指します。 これが所属している部での仕事であればミーティングのルール整備といったことになりますがこれは3ヶ月の短期プロジェクトですので整備している暇はありません。

ではどうするか。ほとんどすべてのミーティングに参加している人が1人はいますよね、その人が全部やりましょう。今回の場合自分です。 大丈夫数ヶ月の短期プロジェクトならこれでなんとかなる。意地で議事録取り続ける、ミーティング終わったら整理してslackで共有。すべてを明文化です。

楽しくやる

いいものができてきていると言い続ける

リモートによらないことでもありますが、リモート主体であるからこそいま自分達がいいものを作っているという実感を得ながらやっていきたいものです。チームの皆が頑張っている様子を可視化します。

基本姿勢はホメ&ポジティブです。なにかできていたらこれすげーいいじゃんとかばっかり言ってました。 ディレクションとしては内心スケジュール間に合うかとか考慮もれがないかとか不安が色々と発生し続けてますが、それらはあくまで自分の責任であって、それをうまくやるのが仕事なので特に周りに伝える必要はありません、不安なとこ見せない。

メンバーにこのプロジェクトうまくいくか不安なんて伝えても集中できなくなるだけですし、不安は伝搬してしまうし自分の多少のストレス軽減になるだけでなにもいい効果を生んでくれません。 であればぶっちゃけるタイミング以外では不安を周囲に伝える必要はありません。

裏目標を作る

最後に、自分自身のモチベーションの話。 以前に別プラットフォームで行ったリニューアルの導入プロジェクト、どうしても単なる移植になりがちでモチベーションの確立が難しい部分があります。 このプラットフォームにあわせたUIを設計し、仕様に落とし込むのですがその際、先にリリースした時よりもさらに考えることは難しい。OSにあわせて自然に、という意識がどうしても働きます。要は無難なことをしがち。

なので意識的にプラットフォームごとにUIを変更する部分において、いくつかの指標について先にリリースしたものの数字を超えることを目標としました、個人的に。 とはいっても特殊なことをするのはダメですし以前のプラットフォーム(今回でいうとiOS)のものをそのまま導入するよりandroidにマッチしたUIである必要があります。プラットフォームへの最適化と数値目標の両立を目指します。導入するのはリニューアルの体験・コンセプトであって焼き直しや単なるOS最適化ではありません。

これにより画面仕様策定の楽しさが格段に上がります。別プラットフォームですでにリリースしたアプリとそのアプリでの数字は既に知っています。これから作るプラットフォームでのアプリでは先にリリースした時よりも理解が進んでいてもっといいものができますよね?

最後に

今回は短期・社内横断プロジェクトでのモチベーションの観点で話しました。

クックパッドではチーム開発をやっていきたいメンバーを職種限らず募集しております。興味のある方はぜひご気軽にご連絡ください。