音声インターフェースに最適なビジュアルインタラクションを実現するための 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最適化ではありません。

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

最後に

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

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

emruby: ブラウザで動くMRI

こんにちは、フルタイムRubyコミッタの遠藤です。

Ruby 3.0が出てもう4ヶ月経ってしまいました。最近のTypeProfの開発ですが、vscode拡張として使えるようにするために、一生懸命Language Server Protocolをいじって遊んでるところです。

こっちのほうはまだ実験段階なので、まとまったころに説明するとして、今回は、Ruby 3.0リリース後にほそぼそとやっていたemrubyをご紹介してみます。

emrubyとは

ブラウザの上で動くMRI(Matz Ruby Interpreter)です。

「エムルビー」だと組み込み向けRuby実装の mruby と紛らわしいので、「イーエムルビー」と読んでください(とmatzに言われています)。

デモ

このページを開いてみてください。

mame.github.io

"Code"の下のエディタ部分にRubyコードを書き、Runのボタンを押すと、実行結果が"Result"の下に出てきます。

f:id:ku-ma-me:20210430180447p:plain

たとえば

p 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10

と書いて実行すると 55 と出てきます。

require "stringio"
p StringIO.new("foobar").read(3)

のように(一部の)拡張ライブラリも使えます。

JavaScriptでは考えにくいですが、同期的なウェイトも可能です。

puts "waiting..."
sleep 1
puts "done"

とても実験的ですが、JavaScriptコードを呼び出すことも可能です。

p emscripten_run_script_int(<<JAVASCRIPT)
(function() {
  var sum = 0;
  for (var i = 1; i <= 100; i++) sum += i;
  return sum;
})();
JAVASCRIPT

JavaScriptで1から100まで足して、その結果をRubyのKernel#pメソッドで出力します(いまのところ、intの返り値しか対応してません)。

技術的な話

原理的には、C言語ソースコードをWebAssemblyにコンパイルしてくれるEmscriptenを使ってMRIをコンパイルしただけです。

しかし現実的には、コンパイルだけでも細々とした修正や対応が必要でした。

  • Emscriptenでは使えないC APIをいろいろケアした(たとえばIO.popenはNotImplementedErrorにしたとか)
  • 保守的GCがそのままでは動かないので、Emscriptenが提供している保守的GC用のAPIを使うようにした *1
  • 動的リンクは実験的サポートらしかったので、必要な拡張ライブラリを静的リンクするようにした
  • その他こまごまとコンパイルオプションを調整した

これらの変更はRubyソースコードへのパッチにする必要がありますが、幸いコミット権限を持っているので、すでにmasterを更新してあります。パッチを管理しなくてすんでよかった。

コンパイルしてみたい人は、emscriptenを使えるようにして(Emscriptenのドキュメント参照)、Rubyのmasterブランチを次のようにコンパイルすると ruby.wasm などができるはずです。

$ ./configure \
  --build x86_64-pc-linux-gnu \
  --host wasm32-unknown-emscripten \
  --with-static-linked-ext \
  --with-ext=ripper,date,strscan,io/console,…,psych \
  optflags=-Os debugflags=-g0 \
  CC=emcc LD=emcc AR=emar RANLIB=emranlib

$ make

フロントエンド、といってもemrubyは全部フロントエンドですが、特にユーザインターフェイスの部分はNext.jsxterm.jscodemirrorで自作しています。↓のHackarade(社内ハッカソン)に乗っかってエイヤと作りました。

techlife.cookpad.com

想定問答

なんで作ったの?

RubyがWebフロントエンド市場に本格的に進出する足がかり!という意気込みが当初は少しだけありました。実際、WASMが話題になった2017年ごろは、ブラウザでも適材適所で言語を選べるのでは、という期待感があったと思います。が、最近のTypeScriptの隆盛を見ると、なかなかそういう感じにはなってないですね。やっぱりJavaScriptは強い。

しかしそれでも、RustやGoやKotlinなど、最近の言語はWASM対応をうたっていることが多いです。どの言語がどのマーケットをとるかは運ですが、動いてない言語は候補にもならないので、動くようにしておくことは大切かなと思っています。宝くじを買う気分。*2

などと適当なことをいいましたが、正直に言えばJust for funなところが大きかったです。ブラウザの上でRubyが動くのはそれだけで楽しい。

完成度は?

rubygemsやirbも含めて一応動いています。意外と動くなあ、という感じです。

https://mame.github.io/emruby/irb/

とはいえ、やはりEmscriptenは普通の環境ではないので、普通の環境のMRIに比べるといろいろ問題があります。たとえば、Fiberが動かない*3、Threadも動かない、など。もし興味のある人がいたら一緒に改良しましょう。

(ちなみにirbのデモではSharedArrayBufferを使っているのですが、これはChrome 91から制限が強化されるらしいので、Chrome 90以前でしか動かない見込みです。Chrome 91は5月にリリースされるらしいので、つまり、今しか動きません……)

なお、もし今すぐブラウザの上で動くRubyを書きたいなら、Opalを検討するのがよいと思います。他には、次の記事も参考になると思います。

blog.unasuke.com

まとめ

ブラウザの上で動くMRI、emrubyを紹介しました。遊んでもらえるとうれしいです。

先日銀座Rails#32でもemrubyについて話したので、発表資料を貼っておきます。Rubyのビルドプロセスや、Emscripten自体に興味がある人は面白いと思います。

www.slideshare.net

*1:マシンスタックやレジスタを走査してオブジェクトの参照を探すということをするのですが、そのためにスタックの先頭と終端を得るAPI emscripten_scan_stack やレジスタをスキャンするためのAPI emscripten_scan_registers がEmscriptenによって提供されていたので、それらを使うようにしました。

*2:似たような気持ちで、AndroidエミュレータでもRubyをCIテストしてたりします。こっちは意外と全テストが完走するのですごい。だれかiOSもやらないかな。

*3:emscripten_fiber_init や emscripten_fiber_swap などのAPIを使っているので、動くはずなのですが、minirubyでは動くけどrubyでは動かない状態です。原因もよくわからないので、デバッグが必要。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/