LINE株式会社は、2023年10月1日にLINEヤフー株式会社になりました。LINEヤフー株式会社の新しいブログはこちらです。 LINEヤフー Tech Blog

Blog


【インターンレポート】AndroidアプリにおけるLINE公式アカウント関連の改善

こんにちは。8/15から6週間の就業型インターンに参加しました、山本崚太と申します。
私は神戸大学に所属する3年生で、普段はweb開発のバイトをしたり、趣味でAndroidアプリの開発などをしたことがあります。
今回はLINE公式アカウント関連のアプリ開発をしている LINE B2Bアプリ開発チームにお世話になりました。

目次

・LINE公式アカウントアプリにおける並行ダウンロードの実装

・LINEアプリにおけるLINE公式アカウントのリッチメニューのTalkback対応

・感想

LINE公式アカウントアプリにおける並行ダウンロードの実装

LINE公式アカウントアプリとは?

ユーザーはLINE公式アカウントと"友だち"になることで、メッセージを交わしたり、さまざまな情報を受け取ったりするなど、あたかも実際の友人と同じようにコミュニケーションをとることができるようになります。

https://play.google.com/store/apps/details?id=com.linecorp.lineoa&hl=ja&gl=US

LINEを使っているなら下の画像のような公式マークの付いたアカウントを友だち追加したことがある方は多いのではないでしょうか?
LINE公式アカウントアプリは友だち追加時の挨拶メッセージ、メニュー機能のようなLINE公式アカウント特有の機能を扱うために作成されたLINE公式アカウントの運営用アプリです。

背景

LINE公式アカウントアプリで個人ユーザーとのチャット画面はLINEアプリと同様にメッセージだけでなく画像や動画の送受信ができます。

LINE公式アカウントアプリを利用するオーナー側がアップロードされた複数写真・動画を一括でダウンロードができるよう複数ダウンロードの機能が実装されています。

私は複数ダウンロードのパフォーマンス向上を目的として複数ダウンロードの並行化を担当しました。

まず以前の複数ダウンロードのシーケンス図については以下のようになります。

3.プログレスバーの表示のためトータルダウンロード件数の値をViewModelに設定します。
4.ダウンロードJobを作成します。
8. LineOaFileDownloaderは渡されたURLを基にDownload Requestを作成してDownload Managerに渡すことでダウンロード要求を行います。
17. ViewModelやダウンロードJobを初期状態に戻します。

以上からloop部分の処理を並行化を行えばよいことがわかりました。

実装

アプローチ

シーケンス図で示していたloop内の処理をasync function として抽出してJobの同時実行を行います。
同時待ちにはawaitAllを利用しました。

実際のダウンロード処理はDownloadManagerというAndroidのシステムサービスでよしなに行われるため、抽出した関数は関数の処理としては軽いという点を踏まえて、メインスレッド上で実行しています。
そのためスレッドセーフの考慮を必要とせずシンプルに実装できました。

また、ViewModelにはDownloadManagerにenqueue中のダウンロードアイテムがsetされていましたが、平行ダウンロードにあたってダウンロードアイテムは同時に複数存在しうるのでリストの形で持つように変更しました。

パフォーマンス

LINEアプリの本番環境において実際の端末でダウンロード速度の計測を行いました。
端末情報としてはPixel3a、Android 10の端末で4G回線を利用しました。
6kbほどの画像と100mbほどの大きなファイルのケースでそれぞれ複数回試しました。

シーケンシャル パラレル ダウンロード速度向上
( シーケンシャル時間 / パラレル時間 - 1 ) × 100%
6kb × 2

1.43s

1.14s

25%

6kb × 3

1.77s

1.20s

47%

6kb × 4

 1.80s

1.32s

50%

6kb × 5

 2.31s

1.39s

67%

100mb × 2

253s

136s

87%

100mb × 3

276s

186s

49%

実際のダウンロード速度はサーバーや自分自身のインターネットの回線速度に左右されるので一概には言えませんが少なくとも25%程度の高速化は図れたと言えるのではないでしょうか

追加の改善事項

ダウンロード中のプログレスバーはjetpack composeで構成されておりdownloadedCount と downloadTotalの2つの変数を持ちます。

以前はloopの内部でダウンロード毎にdownloadedCountをインクリメントしていましたが、ViewModelが持っているDownloadManagerにenqueue中のダウンロードアイテムのリスト(downloadEnqueueInfoList)をmutableStateListOf()によって監視可能にして

val downloadedCount: Int
    @MainThread
    get() = downloadTotal - downloadEnqueueInfoList.size

と計算することでダウンロードカウントの変更を反映する形にしました。

この変更によってViewModelでDownloadManagerにenqueue中のダウンロードアイテムリストを持っているに関わらず、downloadedCountも別でカウントしているという冗長なパラメータ保持を削減することができました。

LINEアプリにおけるLINE公式アカウントのリッチメニューのTalkback対応

リッチメニューとは?

「リッチメニュー」は、トーク画面下部(キーボードエリア)に固定で表示されるメニュー機能です。リッチメニューには、クーポンやショップカードなどのLINE公式アカウントの機能のほか、ECサイトや予約サイトなど、外部サイトへのリンクを設定できます。

https://www.linebiz.com/jp/column/technique/20180731-01/

なお以下クーポンやショップカード、URLなどを呼び出すことをアクション、それに対応する各領域のことをアクション領域と呼称することにします。

Talkbackとは?

TalkBack は Android デバイスに組み込まれている Google 製のスクリーン リーダーです。

TalkBack を利用すると、画面を見ずにデバイスを操作できます。

https://support.google.com/accessibility/android/answer/6283677?hl=ja

Talkbackオンの場合一回のタップでフォーカスという操作を行うようになり、この操作で選択されたViewを読み上げます。
その次にダブルタップすることでフォーカスの当たっているViewへクリックにあたる操作が行われるという仕組みになっています。

背景

TalkbackはViewへのフォーカス時にそのViewに設定されているcontentDescriptionというパラメータを読み上げます。
https://support.google.com/accessibility/android/answer/7158690?hl=ja

Android版でのリッチメニューには読み上げ用のテキストが用意されていないのでアクセシビリティ向上のためiOS版と同様にリッチメニューの各アクションに対して適切なテキストが読み上げられるようにしたいです。

また、各アクションに対応する読み上げテキストは各LINE公式アカウントでそれぞれ任意に決められるようになっていて、サーバーから送られてくる設定したリッチメニューのデータの中のlabelという項目が各アクションの読み上げテキストとなります。

Messaging API経由だとLINE公式アカウントのオーナーは下記のようなjsonでリッチメニューを指定することができます。

https://developers.line.biz/ja/docs/messaging-api/using-rich-menus/#create-a-rich-menu

{
 "size":{
   "width":1200,
   "height":405
 },
 "selected": false,
 "name": "LINE Developers Info",
 "chatBarText": "Tap to open",
 "areas": [
   {
     "bounds": {
       "x": 0,
       "y": 0,
       "width": 600,
       "height": 405
     },
     "action": {
       "type": "uri",
       "uri": "https://developers.line.biz/en/news/",
       "label": "大文字のAです"
     }
   },
   {
     "bounds": {
       "x": 600,
       "y": 0,
       "width": 600,
       "height": 405
     },
     "action": {
       "type": "uri",
       "uri": "https://www.line-community.me/ja/",
       "label": "大文字のBです"
     }
   }
 ]
}

ここで各アクションに対して適切な読み上げを行う手段として直感的にはactionが持つlabel情報を各アクションのViewが持つcontentDescriptionに代入すれば解決するかのように見えますが一つ問題点があります。

それはリッチメニューの実装についてです。
リッチメニューは主に背景画像を表示するView一枚で構成されており、どのアクション領域がタッチされたかはタッチイベントから取得した座標を基に決める形となっています。
そのためリッチメニューの各アクション領域に対応したViewは存在しておらず、直感的な解決策は上手くいかないようです。

その対応策としてアクションの判定にタッチ座標を用いたように読み上げテキストについても座標で決められばよいのではという考えが浮ぶかもしれませんが、フォーカス操作ではClickやTouch系イベントが取ることができず、読み上げテキストをタッチされた座標に従って変えるということは単純にはできません。

実装

リッチメニューのTalkback対応には二つの実装案が考えられました。
以下3項目で比較を行います。

タッチ座標を基に対応アクションのテキストを読み上げる案 contentDescriptionを設定した透明なViewをメニューの各アクションの上に被せるというグリッド生成案
フォーカスされる領域 リッチメニュー全体がフォーカスされる。

各アクションに対応する領域だけがフォーカスされる。

既に実装済みのiOS仕様に準拠している。

実装上の懸念

実装としてはフォーカス時にはタッチイベントが取れないが、ViewのdispatchHoverEventが呼ばれることが発見された。このメソッドをoverrideすることでTalkback時でも座標を取得することができる。
しかしながら、ホバーイベントを基に処理することはAndroid側では必ずしも想定されていないと考えられる。

グリッドが複数個生成されないようにケアする必要がある。
新たにViewを追加するので、パフォーマンスの低下に気をつける必要がある。
テストケース対応 従来実装の座標を基にアクション取得する形での実装なので既存のテストを拡張する形で対応できると考えられる。 透明なグリッドを上に被せることは将来的に追加したViewのタッチイベントを意図せずブロックしてしまう可能性がある。そのため、そのケースをテストする必要が生まれる。

各アクション領域を覆う形で生成するcontentDescriptionを設定した透明なViewの集まりをグリッドと呼称することにします。
フォーカスされる領域についてiOS仕様に準拠している点と、ホバーイベントを基に処理することへ将来的な懸念からリッチメニューの上に透明なグリッドを生成する実装案を採用することにしました。

グリッド生成案におけるテスト方法の懸念事項については後ほど詳しく説明することとして、次に具体的な実装について触れていきます。
大きなPRを避けるため、小さいタスクに分けて段階を踏んだ実装を行いました。

ActionModelクラスにラベルフィールドを追加する

背景にて説明したようにサーバーからリッチメニューのデータが送られてきて、これらのデータを基にアクションのModelクラスが作成されます。
まず第一段階としてこのアクションのModelクラスに読み上げテキストに当たるlabelフィールドを追加します。

グリッドの生成

2段階目では実際にActionModelクラスに追加された情報を基にTalkbackオンの時透明なグリッドを生成するようにします。
ActionModelクラスにはbounds情報が入っており、それらを利用して各Viewの位置と大きさを設定すると共にcontentDescriptionにlabel情報を代入します。

注意した点としてはグリッドが複数回存在しないようにグリッド生成する関数の最初に現存するグリッドを消去するようにしました。

また、グリッドの生成はViewを追加するためUIスレッドへの負荷がかかります。
よってパフォーマンス向上のため
・グリッドの追加はTalkbackオン時に限定する
・読み上げテキストが設定されていないアクションに対してはView生成の処理をスキップする
・バックグラウンド画像のロード失敗時はリッチメニューが表示されていないと扱ってよいため、その際にはグリッドを生成しない
などの処置を行いました。

テスト

最後にグリッド生成にあたって追加するテスト項目について考えます。

Talkback用に生成されたグリッドは透明でTalkbackオンの時のみ表示されるので将来的に他のViewを追加した際Talkbackがオンになるケースをケアせず、知らず知らずにグリッドが上に重なって追加したViewのタッチイベントをブロックしてしまう可能性があります。

また、開発時にTalkbackがオンにすることは稀で、QAチームもTalkbackオンのケースを検証する機会は少ないのではないかという懸念があり、その上グリッドは透明であるということからViewのタッチイベントのブロックをテストで検知したいとの提案がありました。

加えて条件として、instrumented test(実機orエミュレータでのテスト)は現状のLINEアプリのPull Request時のCIでは利用されていなかったため、unit testの範疇で実行したいです。
(FYI  instrumented testを利用する場合ではInstrumentation.sendPointerSync()というメソッドによってクリックイベントを動的に起こすという案がありました。)

以上を踏まえて私はViewの配置の前後関係からチェックを行うことを考えました。unit testの範疇で有効なテスト方法を考えるというのが私の中では一番苦労した点です。
以下具体的にどのケースでViewの配置の前後関係を見るべきかを検証していきます。

ケース1 将来的にViewの変更がリッチメニューの外で起こる場合

生成されたグリッドによってViewのブロックが起こる場合それは想定できるはずです。
なぜなら、グリッドが表示されるときリッチメニューのbackground画像も表示されていて、それも同様にViewをブロックすることになります。
よって、Talkbackオフの時でもViewがブロックされることに気づけるため、ケアする必要はないと判断しました。

ケース2 将来的にviewの変更がリッチメニューの内で起こる場合

グリッド生成の後にaddViewが起きた際はグリッドの上に生成されるので問題ないですが、リッチメニューのxmlへのView追加 または グリッド生成の前にaddView が起きるとグリッドはそのViewをブロックすることになってしまいます。

勿論これらの追加されたViewがグリッドにブロックされることを想定していれば大丈夫なのですが、最初述べた通りグリッドは透明でTalkbackオンの時のみ表示されるため、そのケースを考慮せず実装した可能性があります。

よって、
・リッチメニューのxmlへのView追加
・グリッド生成の前にaddView
の2つのケースに注目してテストを考えます。

ケース2-a リッチメニューのxmlへのView追加される場合

Viewの前後関係にはViewのz値を利用することを考えました。

実際のテスト方法としてはグリッドレイアウトのz値を上げてリッチメニュー内でz値がそれ以下のviewを列挙することにしました。

まずリッチメニュー内でグリッドレイアウトと同じ階層にあるViewのうちブロックされることを許容するもののリスト(ブロック許容Viewリスト)を作成します。
その次に先ほどブロックされることを許容したViewとそのViewの全ての子Viewのペアのマップ(ブロック許容Viewマップ)を作成します。

そして、実際のテスト処理としては

・ブロック許容Viewリストとリッチメニュー内でz値がグリッドレイアウト以下のViewを比較して一致していることを確認する。

・ブロック許容Viewマップとリッチメニュー内でz値が規定以下のViewに対してallViewsを呼んで得られる全てのViewのペアを列挙したものが一致していることを確認する。

これらをテストすることで、リッチメニューのxmlにViewを追加した際には自動的にこのテストに引っかかるため

・あらかじめ作成したブロック許容Viewリスト、ブロック許容Viewマップに対象Viewを追加する ( グリッドにブロックされることを許容する)

・対象Viewのz値をグリッドレイアウトより高くする(グリッドにブロックされないようにする)

のいずれかの処置を選ぶよう促すことができます。

ケース2-b グリッド生成の前にリッチメニューへaddViewされる場合

richmenuへのaddViewに対しては将来どのタイミングでどのメソッドが呼ばれるかは不明なのでaddViewが呼ばれるタイミングをunit testでは把握できません。よって、このケースのケアは残念ながら諦めることとしました。

結果

修正前

修正後

動画ではTalkbackの代わりに「選択して読み上げ」機能で代用して試していますが、Talkback対応後は各アクション領域をタッチすると確かにそれに対応したテキストを読み上げるようになりました。

感想

今回のインターンは基本オンラインでの参加となりましたが、毎日朝会で疑問点を解消する機会があり、メンターの方にはなにかと気を遣ってサポート頂けたこと、Slack上で質問すればチームメンバーの方々からすぐに返事を頂ける体制であったことなどあって、6週間の間それなりに順調に進めることができたのではないかなと思います。この場をお借りしてLINE B2Bアプリ開発チームの皆様方にはお礼申し上げます。
また、社員の方々にもリモートワークが浸透していて、さらに裁量労働制に移行している方々も多いと聞いて労働環境の柔軟さに驚きました。

コードレビューを通じてKotlinにおける簡潔な処理の書き方やコーディングスタイルを学べただけでなく、コメントの書き方や個人開発では中々ケアを忘れがちな例外処理について学ぶ知見も多くあり、大変よい経験になったと考えています。
また、Androidにおけるコルーチンとスレッドの関係と扱いについて曖昧だった理解を深めることもできました。
週一回のAndroid Study SessionではLINEで働くAndroidエンジニアの方々による色々な興味深いトピックの紹介もあり、インターンを通して知らなかったKotlinの様々な魅力を知ることができました。

今回LINE公式アカウントアプリとLINEアプリという二つの大規模なアプリの開発に関わることになりましたが、LINE公式アカウントアプリはまだコードの全体像がぼんやりと掴める規模での比較的スピード感のある開発LINEアプリは超大規模でTwo-Phase Reviewによるじっくりとしたレビューとそれぞれ違った雰囲気があって一粒で二度おいしい経験ができました。
そして、大規模なアプリケーションを扱う上でのテストやコードのコメント、ドキュメントの重要性を強く実感して、だからこそLINEのwikiの充実度があるのだなと何か納得した気持ちになりました。
特にリッチメニューのTalkback対応のタスクではテストの意義や作り方といったことについて深く考えさせられて勉強になりました。
大規模アプリケーションの開発に携わることができるのは中々得難いもので、この経験から得た学びを個人の開発にも生かしていければと思います。