APL（Alexa Presentation Language）の画面タッチ処理について

Echo Showが発売されスマートディスプレイが盛り上がってまいりました。
先日発表されたAPL(Alexa Presentation Language)を使えば音声と視覚の両方を抑えたスキルの開発ができます。
APLでのタッチ処理について解説します。

2019/01/17 APL有効化に関する記述を追記(@zono_0 さんご指摘ありがとうございました！)

# 完成動画
<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">APL（Alexa Presentation Language）のタッチ処理を実装してみた<a href="https://twitter.com/hashtag/alexa?src=hash&amp;ref_src=twsrc%5Etfw">#alexa</a> <a href="https://twitter.com/hashtag/apl?src=hash&amp;ref_src=twsrc%5Etfw">#apl</a> <a href="https://t.co/M3rewzxi2w">pic.twitter.com/M3rewzxi2w</a></p>&mdash; がおまる@スマートスピーカーアプリ開発入門発売中！ (@gaomar) <a href="https://twitter.com/gaomar/status/1074519132932952065?ref_src=twsrc%5Etfw">2018年12月17日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

# 環境
* ask-sdk-core v2.3.0
* ask-sdk-model v1.9.0

# 1. APLを有効化する
alexa developer consoleにあるインターフェースのDisplayインターフェースとAlexa Presentation Languageをそれぞれ有効にしておきます。そうしないとうまく反応が返ってきませんので注意しましょう。

![apl_on.png](https://qiita-image-store.s3.amazonaws.com/0/154355/f0591cbe-275e-278e-37c2-83a715ac1fbd.png)

# 2. タッチ処理実装
Echo ShowやEcho Spotで画面タッチ処理をAPLで行う場合は`TouchWrapper`というものを使って実装します。
TouchWrapperに関する詳しいドキュメントはコチラをご確認ください。

https://developer.amazon.com/ja/docs/alexa-presentation-language/apl-touchwrapper.html

### 2-1. documentの記述
以下はAPLのdocumentの記述例です。これはデザインの側の実装です。
Sequenceでリストの表示をしています。TouchWarapperの子アイテムでタッチする対象のものを記述する形です。
argumentsでどのボタンが押されたか分かるようになっています。

```javascript:document/apl_doc_top.json
{
    "type": "APL",
    "version": "1.0",
    "theme": "dark",
    "import": [
        {
            "name": "alexa-layouts",
            "version": "1.0.0"
        }
    ],
    "resources": [
        {
            "strings": {
                "skillName": "アイエンター受付システム",
                "menuText": "該当するメニューをタップしてください。"
            }
        }
    ],
    "styles": {},
    "layouts": {},
    "mainTemplate": {
        "parameters": [
            "payload"
        ],
        "items": [
            {
                "type": "Container",
                "direction": "column",
                "items": [
                    {
                        "type": "Frame",
                        "width": "100vw",
                        "height": "100vh",
                        "backgroundColor": "#999999"
                    },
                    {
                        "type": "Frame",
                        "width": "100vw",
                        "height": "15vh",
                        "backgroundColor": "#b52e29",
                        "position": "absolute"
                    },
                    {
                        "type": "Container",
                        "position": "absolute",
                        "items": [
                            {
                                "type": "Text",
                                "text": "@skillName",
                                "height": "15vh",
                                "paddingLeft": "4vw",
                                "textAlignVertical": "center"
                            },
                            {
                                "type": "Container",
                                "alignItems": "center",
                                "justifyContent": "center",
                                "height": "70vh",
                                "items": [
                                    {
                                        "type": "Sequence",
                                        "scrollDirection": "horizontal",
                                        "data": "${payload.skilldata.image}",
                                        "width": "100vw",
                                        "item": {
                                            "type": "TouchWrapper",
                                            "onPress": {
                                                "type": "SendEvent",
                                                "arguments": [
                                                    "${data.name}"
                                                ]
                                            },
                                            "item": {
                                                "type": "Image",
                                                "source": "${data.icon}",
                                                "width": "34vw",
                                                "height": "34vh"
                                            }
                                        }
                                    }
                                ]                                
                            },
                            {
                                "type": "Text",
                                "id": "SpeechComponent_0",
                                "speech": "${payload.skilldata.properties.Speech_0}",
                                "text": "@menuText",
                                "textAlign": "center"
                            }
                        ]
                    }
                ]
            }
        ]
    }
}
```

### 2-2. dataの記述
dataの記述でdocumentにデータを渡す。

```javascript:data/apl_data_top.json
{
    "skilldata": {
        "type": "object",
        "image": [
            {
                "icon": "https://お好きな画像のURL.png",
                "name": "ボタン1"
            },
            {
                "icon": "https://お好きな画像のURL.png",
                "name": "ボタン2"
            },
            {
                "icon": "https://お好きな画像のURL.png",
                "name": "ボタン3"
            }
        ],
        "properties": {
          "Ssml_0": "<speak>タップしてください。</speak>"
        },
        "transformers": [
            {
                "inputPath": "Ssml_0",
                "outputName": "Speech_0",
                "transformer": "ssmlToSpeech"
            }
        ]
    }
}
```

# 3. 呼び出すプログラムを実装する
AWS Lambdaで記述するプログラム例です。`.addDirective`でdocumentとdataをそれぞれ指定します。

```javascript:index.js
'use strict';

const Alexa = require('ask-sdk-core');

// 起動時に呼ばれる
const LaunchRequestHandler = {
    canHandle(handlerInput) {
      return handlerInput.requestEnvelope.request.type === 'LaunchRequest';
    },
    handle(handlerInput) {
        const speechText = '該当するメニューをタップしてください。';

        // documentとdataをそれぞれ指定する
        return handlerInput.responseBuilder
            .speak(speechText)
            .addDirective({
                type : 'Alexa.Presentation.APL.RenderDocument',
                version: '1.0',
                token: "token",
                document: require('./document/apl_doc_top.json'),
                datasources: require('./data/apl_data_top.json')
            })	          
            .getResponse();

	}
};

// 画面タッチ処理
// シミュレーターではonPressが反応し、実機ではPressが反応するため2つ書いておく
const TouchEventHandler = {
    canHandle(handlerInput) {
    return ((handlerInput.requestEnvelope.request.type === 'Alexa.Presentation.APL.UserEvent' &&
        (handlerInput.requestEnvelope.request.source.handler === 'Press' || 
        handlerInput.requestEnvelope.request.source.handler === 'onPress')));
    },
    handle(handlerInput) {
        // TcouhWrapperのargumentsで指定したパラメータを取得する
        const choice = handlerInput.requestEnvelope.request.arguments[0];

        const speechText = `メニュー${choice}をタップしました。`

        return handlerInput.responseBuilder
            .speak(speechText)
            .getResponse();            
    }
};

// 終了時に呼ばれる
const SessionEndedRequestHandler = {
    canHandle(handlerInput) {
      return handlerInput.requestEnvelope.request.type === 'SessionEndedRequest';
    },
    handle(handlerInput) {
      return handlerInput.responseBuilder
            .withShouldEndSession(true)
            .getResponse();
    }
};

// EndIntent
const EndHandler = {
    canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
            && (handlerInput.requestEnvelope.request.intent.name === 'AMAZON.StopIntent' ||
                handlerInput.requestEnvelope.request.intent.name === 'AMAZON.CancelIntent' ||
                handlerInput.requestEnvelope.request.intent.name === 'AMAZON.NoIntent');
    },
    handle(handlerInput) {
        const speechText = 'バイバイ！またね！';

        return handlerInput.responseBuilder
            .speak(speechText)
            .withShouldEndSession(true)
            .getResponse();
    }
};

const ErrorHandler = {
    canHandle(handlerInput, error) {
        return true;
    },
    handle(handlerInput, error) {
        const speechText = 'すみません、よく分かりませんでした。';
        
        return handlerInput.responseBuilder
            .speak(speechText)
            .reprompt(speechText)
            .getResponse();
    }
};

let skill;
exports.handler = async (event, context) =>{
  if(!skill){
    skill = Alexa.SkillBuilders.custom()
        .addRequestHandlers(
            LaunchRequestHandler,
            TouchEventHandler,
            EndHandler,
            SessionEndedRequestHandler)
        .addErrorHandlers(ErrorHandler)
        .create();
  }
  return skill.invoke(event);
}
```

これを実行するとこのようなAPLになります。
2018年12月12日ぐらいからシミュレーターでもタッチ反応できるようになったので、
テストがかなり楽になりました！！

<img width="1657" alt="apl100.png" src="https://qiita-image-store.s3.amazonaws.com/0/154355/e181e960-af6c-8e32-9dc0-631a2971f497.png">


# 繰り返し発話する処理
APLにはコマンド実行することができ、特殊な動きはこのコマンドを使って実装していきます。
詳細はこちらに明記されています。

https://developer.amazon.com/ja/docs/alexa-presentation-language/apl-standard-commands.html

今回の例では、タッチされずにある一定時間放置されると「タップしてください」と促すようにしたいと思います。
完成動画のように文字がハイライト表示されて水色の文字に一瞬変えることができます。
index.jsの一部を変更します。

`.addDirective`に`Alexa.Presentation.APL.ExecuteCommands`を追加します。
5秒待ってから、「タップしてください」と言わせています。repeatCountで2度繰り返すようにしています。
公式ドキュメントにハイライトの説明でkaraoke状態という記載があったので、そこは面白いですね！

```javascript:index.js
// 起動時に呼ばれる
const LaunchRequestHandler = {
    canHandle(handlerInput) {
      return handlerInput.requestEnvelope.request.type === 'LaunchRequest';
    },
    handle(handlerInput) {
        const speechText = '該当するメニューをタップしてください。';

        return handlerInput.responseBuilder
            .speak(speechText)
            .addDirective({
                type : 'Alexa.Presentation.APL.RenderDocument',
                version: '1.0',
                token: "token",
                document: require('./document/apl_doc_top.json'),
                datasources: require('./data/apl_data_top.json')
            })	  
            .addDirective({
                type : 'Alexa.Presentation.APL.ExecuteCommands',
                token: "token",
                commands: [
                    {
                        type: "Sequential",
                        commands: [
                            {
                                type: "Idle",
                                delay: 5000
                            },
                            {
                                type: "SpeakItem",
                                componentId: "SpeechComponent_0",
                                highlightMode: "line",
                            }
                        ],
                        repeatCount: 2
                    }
                ]
            })	
            .getResponse();

	}
};
```

# 今回使用したAPL
今回使用したAPLです。APL画面のコードアップロードからこのjsonファイルを指定すると確認することができます。
https://developer.amazon.com/alexa/console/ask/displays

イメージ画像はお好きな画像URLを指定してください。

```javascript:apl.json
{
    "document": {
        "type": "APL",
        "version": "1.0",
        "theme": "dark",
        "import": [
            {
                "name": "alexa-layouts",
                "version": "1.0.0"
            }
        ],
        "resources": [
            {
                "strings": {
                    "skillName": "アイエンター受付システム",
                    "menuText": "該当するメニューをタップしてください。"
                }
            }
        ],
        "styles": {},
        "layouts": {},
        "mainTemplate": {
            "parameters": [
                "payload"
            ],
            "items": [
                {
                    "type": "Container",
                    "direction": "column",
                    "items": [
                        {
                            "type": "Text",
                            "id": "SpeechComponent_0",
                            "speech": "タップしてください。",
                            "position": "absolute",
                            "width": 0,
                            "height": 0
                        },
                        {
                            "type": "Frame",
                            "width": "100vw",
                            "height": "100vh",
                            "backgroundColor": "#999999"
                        },
                        {
                            "type": "Frame",
                            "width": "100vw",
                            "height": "15vh",
                            "backgroundColor": "#b52e29",
                            "position": "absolute"
                        },
                        {
                            "type": "Container",
                            "position": "absolute",
                            "items": [
                                {
                                    "type": "Text",
                                    "text": "@skillName",
                                    "height": "15vh",
                                    "paddingLeft": "4vw",
                                    "textAlignVertical": "center"
                                },
                                {
                                    "type": "Container",
                                    "alignItems": "center",
                                    "justifyContent": "center",
                                    "height": "70vh",
                                    "items": [
                                        {
                                            "type": "Sequence",
                                            "scrollDirection": "horizontal",
                                            "data": "${payload.skilldata.image}",
                                            "width": "100vw",
                                            "item": {
                                                "type": "TouchWrapper",
                                                "onPress": {
                                                    "type": "SendEvent",
                                                    "arguments": [
                                                        "${data.name}"
                                                    ]
                                                },
                                                "item": {
                                                    "type": "Image",
                                                    "source": "${data.icon}",
                                                    "width": "34vw",
                                                    "height": "34vh"
                                                }
                                            }
                                        }
                                    ]
                                },
                                {
                                    "type": "Text",
                                    "text": "@menuText",
                                    "textAlign": "center"
                                }
                            ]
                        }
                    ]
                }
            ]
        }
    },
    "dataSources": {
        "skilldata": {
            "image": [
                {
                    "icon": "https://おすきな画像.png",
                    "name": "btn1"
                },
                {
                    "icon": "https://お好きな画像.png",
                    "name": "btn2"
                },
                {
                    "icon": "https://お好きな画像.png",
                    "name": "btn3"
                }
            ],
            "properties": {
                "Ssml_0": "<speak>タップしてください。</speak>"
              },
              "transformers": [
                  {
                      "inputPath": "Ssml_0",
                      "outputName": "Speech_0",
                      "transformer": "ssmlToSpeech"
                  }
              ]            
        }
    }
}
```

# repromptは記述しない方がいい？
Echo DotやEchoなどのスマートスピーカーはrepromptの記載がないとスキルが終了してしまいますが、
Echo Show、Spotではスキルは終了されません。
タッチを要求するシーンでは無いほうが良いかと思います。repromptの記述があるとユーザーの音声を待つ状態になり、
画面も暗くなってしまうからです。
ただこの場合に再び音声反応させる場合は「アレクサ」とウェイクワードを言わないと駄目ですが。。。

Echo ShowやSpotでは明示的にスキルを終了させる場合は`withShouldEndSession`をtrueにすることでスキルを終了させることができます。

```javascript:index.js
// EndIntent
const EndHandler = {
    canHandle(handlerInput) {
        return handlerInput.requestEnvelope.request.type === 'IntentRequest'
            && (handlerInput.requestEnvelope.request.intent.name === 'EndIntent' ||
                handlerInput.requestEnvelope.request.intent.name === 'AMAZON.StopIntent' ||
                handlerInput.requestEnvelope.request.intent.name === 'AMAZON.CancelIntent' ||
                handlerInput.requestEnvelope.request.intent.name === 'AMAZON.NoIntent');
    },
    handle(handlerInput) {
        const speechText = 'バイバイ！またね！';

        return handlerInput.responseBuilder
            .speak(speechText)
            .withShouldEndSession(true) /* 明示的にスキルを終了させる*/
            .getResponse();
    }
};
```

# まとめ
画面付きになり、リッチな表現をすることができるようになりました。
あくまでもメインはVUI（音声操作）なので、声で操作する方をメインにおきつつ、せっかくの画面付きなのでうまく使い分けましょう。

システム化のご検討やご相談は弊社までお問い合わせください。
https://i-enter.co.jp/contact/