音声インターフェースに最適なビジュアルインタラクションを実現するための 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/