Edited at

Angular/Ionicアプリケーションのチューニング〜スクロールによるデータの差分描画〜

More than 1 year has passed since last update.

BRIGHT VIE Advent Calendar 2017 - Qiita の13日目です!

今日は社内で開発しているサービスのチューニング系のお話を少しだけしようと思います。


はじめに

今回チューニング対象となるのは、Angular/Ionicで構築したアプリケーションになります。

(Angular/Ionicと記載していますが、やっていることは普通のWebアプリケーションのチューニングとさほど変わりないと思います)

Chrome Developer Toolsを用いて実際にボトルネックを解消していく流れを実戦形式で共有することで、

チューニングに困っている方々の何かのキッカケになれば嬉しいです。


チューニング対象のアプリケーションについて

対象のアプリケーションをざっくり説明すると、日々の生活の状態を記録をすることが出来る、

記録アプリのようなイメージしてもらえればよいかと思います。

チューニング対象の画面は、各ユーザの1週間分の記録データを閲覧する画面ですが、

このページではユーザ選択後画面が表示されるまで4秒程度かかっている状態でした。


チューニング実施前の状況

なんとなく遅い原因を予測はしながらも、「推測するな、計測せよ」という格言を元に、

まずはChromeのDeveloper Toolsを用いてどこが遅いのかを調べてみました。

プロファイリングの状況は下記のような感じでした。

Qiita_1_チューニング前_記録を見る画面.png


  • ネットワークから1週間分の履歴データを取得する部分はそこまで時間はかかっていない

  • データ取得後画面に描画されるまでが2~3秒近くかかっているのでここがボトルネック

  • 「Rendering」にも0.5秒近くかかっている

  • メモリの使用量も51~80MBと比較的多い

という状況でした。

1週間分のデータ量といっても、200件程度しかなく、各記録データをカテゴリ別に表示を切り替えたりしているだけのため、

無駄な処理が走っている可能性が高いとまずは感じました。

また、メモリ量も51MB~80MBと比較的多く、画面遷移やターゲットを繰り返すとさらにメモリも肥大化しており、

iPadなどタブレットで表示すると、徐々にもっさりしていくような動きをしている状況でした。


チューニングの実施


1つ目: 描画処理あたりで無駄な処理がないかチェック

まずは、データ取得後の処理及び「Rendering」に時間がかかっていることから、

描画処理あたりで無駄な処理がないかチェックしてみました。

すると下記のような箇所が見つかりました。(諸事情により少しプログラムの表記は変更しております)


xxxx.html


<ion-list>
<ion-item *ngFor="let d of data" no-lines>
<ion-card class="carenote_card" data-id="{{d?.id}}">
<ion-my-content>
<!-- カテゴリAの記録データを表示 -->
<page-mycategory-a [hidden]="(d.type !== 'a')" [data]="d.data"></page-mycategory-a>
<!-- カテゴリBの記録データを表示 -->
<page-mycategory-b [hidden]="(d.type !== 'b')" [data]="d.data"></page-mycategory-b>
<!-- カテゴリCの記録データを表示 -->
<page-mycategory-c [hidden]="(d.type !== 'c')" [data]="d.data"></page-mycategory-c>
<!-- カテゴリDの記録データを表示 -->
<page-mycategory-d [hidden]="(d.type !== 'd')" [data]="d.data"></page-mycategory-d>
</ion-my-content>
</ion-card>
</ion-item>
</ion-list>

表示する1週間分の記録データをngForで回していくなかで、カテゴリ別に独自コンポーネントを内部的に呼び出し、

カテゴリ毎の表示方法に対応させる方法で実装されていたのですが、

このときに「[hidden]」を利用していたがために、画面上には表示されていないが、

裏側では各コンポーネントのインスタンス化やDOMの生成が行われている状況でした。

単純に200件のデータであっても、上記の場合1件につき4種類の定義がされているため、

200件*4種類=800回処理が行われいたことになります。

これは無駄な処理であるため、hiddenではなくngIfで判定処理を行ういました。


xxxx.html

<ion-content>

<ion-list>
<ion-item *ngFor="let d of data" no-lines>
<ion-card class="carenote_card" data-id="{{d?.id}}">
<ion-my-content>
<!-- カテゴリAの記録データを表示 -->
<page-mycategory-a *ngIf="(d.type === 'a')" [data]="d.data"></page-mycategory-a>
<!-- カテゴリBの記録データを表示 -->
<page-mycategory-b *ngIf="(d.type === 'b')" [data]="d.data"></page-mycategory-b>
<!-- カテゴリCの記録データを表示 -->
<page-mycategory-c *ngIf="(d.type === 'c')" [data]="d.data"></page-mycategory-c>
<!-- カテゴリDの記録データを表示 -->
<page-mycategory-d *ngIf="(d.type === 'd')" [data]="d.data"></page-mycategory-d>
</ion-my-content>
</ion-card>
</ion-item>
</ion-list>
</ion-content>

プロファイリングを行ってみた結果は下記です。

Qiita_2_無駄なインスタンス生成の排除_記録を見る画面.png


  • 2~3秒かかっていたAPIでデータ取得後の処理が、1秒近くにまで半減した

  • 0.5秒かかっていた「Rendering」も、0.2~0.3秒と半減した

  • 51~80MBだったメモリの使用量も、29.1~49.6MBとほぼ半分になった

全体の画面遷移としては、まだ2秒近くかかっている状況ですが、

最初の状態と比べると無駄な処理を排除することで半分の時間やメモリ使用量まで下げることが出来ました。


2つめ: リスト表示は最低限の初期描画にとどめる

色々と遅い箇所を調査してみましたが、200件ほどのデータをリスト表示画面に一気に描画している状況だったため、

細かい処理を改善するよりは画面遷移後の初期描画数を減らすことが一番改善につながると感じました。

画面上では、スクロールすることでデータを閲覧することが可能なため、

画面遷移後すぐに全てのデータを描画する必要はなく、

最初に10件程度描画しておいてスクロールに合わせて差分データを描画すれば良い状況でした。

Angular/Ionicだとスクロール画面での差分描画ライブラリなどありそうでしたが、

自前で作るのもさほど難しそうではなかったので、自前で実装してみることにしました。


リスト表示に少しずつデータを追加した際に差分のみ描画されるのかを検証

リスト表示箇所にデータを10件ずつ追加した際に、追加する度にリストが全て再描画されるようであれば

データ追加時に画面が固まってしまいチューニングの効果が薄れる懸念があったので、

Angular/Ionicだとどのような動きになるのか軽く検証してみました。

まず、下記のようなリスト形式の画面を用意します。

<ion-list>

<ion-item *ngFor="let d of data" no-lines>
</ion-item>
</ion-list>

次にthis.dataがテンプレート側にバインディングされているとした場合、

0~9までの配列データを初期描画で0~4までの5つを描画。

その後、5秒後に残りの5件を追加したときに、どのような変化が起きるのかを目視で確認してみました。

let data = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];

this.data = data.slice(0, 4);

// 差分で描画する
setTimeout(() => {
this.data = data;
}, 5000); // 5秒後に残りの5件もセットする

結果的には、既に描画されているDOMは変化せず(直接初期描画されたDOMのプロパティを変化させても5秒後の再描画時に上書きされることはなかったため)

新しく追加された5件分のみが描画される状態でした。

Angularの内部構造をよく理解していないのですが、

内部的にDOMの状態を管理して(VirtualDOMとか使われているのかな?)差分がある部分のみ書き換えるという処理が行われているように見受けられました。

もしかしたら、下記の記事あたりが参考になるのかなと...

日本語訳:Angular 2 Change Detection Explained

とりあえず上記検証より、テンプレートにバインディングしている変数に、

描画したいデータリストをsliceで少しずつ追加することで、最終的なDOMの量は多くなってしまいますが、

スクロールに合わせて差分データのみを追加していくことが可能だと感じ、突き進むことにしてみました。


スクロールの終了を検知する

Ionicでは、スクロール対象のDOM上でスクロールが開始したときや完了したときのイベントを取得することが可能です。

今回は、「ionScrollEnd」を利用して、スクロールが終了したときのイベントを検知し、

今回は「infiniteScroll」を利用して、表示データの最下部近くまでスクロールすれば、

次の10件データをリストに追加して表示するというページング機能を組み込むことにしました。

先程のHTMLのion-contentにionScrollEndイベントを発火できるように追加します。

実機やシミュレータで確認するとスクロールを行い画面下まで到達すると跳ね返るような動作になります。

ionScrollEndイベントは完全にスクロールが止まったタイミングでイベントが発火されるのですが、

跳ね返り動作がある場合スクロール完了からデータ読み込みまで少し間隔が空いてしまい挙動として不自然な状況になりました。

そのため、ionScrollEndだけでなくionScrollStartでも同様に検知することで違和感をなるべくなくすような工夫をしてみました。

2017/12/15修正)

ionScrollStartやionScrollEndだと位置情報の計算式などを行う必要があり、良い感じのプログラムではないので、

コメントに記載の通り、もっとシンプルに実装可能な「infiniteScroll」を利用するように修正しました。


xxxx.html

<ion-content>

<ion-list>
<ion-item *ngFor="let d of data" no-lines>
...省略...
</ion-item>

<!-- infiniteScroll -->
<ion-infinite-scroll (ionInfinite)="doInfinite($event)">
<ion-infinite-scroll-content></ion-infinite-scroll-content>
</ion-infinite-scroll>
</ion-list>
</ion-content>



JS側でスクロールイベントを検知する

やることとしては、スクロールエンドイベントを検知した際に、

1. 最下部まで残り300pxまでスクロールしたらデータ読み込み処理を実施する

2. 読み込むデータがある場合はローディング画面を表示してデータを追加する

3. 既に全てのデータを描画している場合は、何もしない

という感じで組み込んでみました。

2017/12/15修正)

はい、こちらもスクロールエンドイベントではなく、ionInfiniteを利用することで上記でやっていたことをプラグインとして提供してくれています。

infiniteScroll部分が画面上に描画されるとローディングが表示され、doInfiniteメソッドを呼ぶような処理になっています。

以下プログラム側を一部のみ記載してみます。


xxxx.ts

// APIのレスポンス結果を保持する
private apiResponse:any = {};

// テンプレートに描画するデータ
public data:any = [];

/**
* 履歴データの読み込み処理
*/

init() {

...省略...

// APIの実行後の処理のみ記載 「res」にAPIのレスポンスデータが入っている場合
this.apiResponse = res["data"];
this.data = this.apiResponse.slice(0, 10);
}

/**
      * スクロール領域の最下部に来たときに実行される処理
      */

doInfinite(infiniteScroll:any) {

// 差分データを再描画する必要があるかをチェックする
let data = this.getListData();
if (data) {
this.data = data;
} else {
// 表示するデータがなくなった場合はこれ以上 infiniteScroll を実行しない
infiniteScroll.enable(false);
}
// infiniteScroll上でのローディングも非表示にする
infiniteScroll.complete();
}


上記プログラムでプロファイリングを行ってみた結果は下記です。

Qiita_3_スクロールによる差分データ表示_記録を見る画面.png


  • 2~3秒かかっていたAPIからのデータ取得後の処理が、0.3秒近くにまで減少!

  • 0.5秒かかっていた「Rendering」も、0.1秒程度にまで減少!!

  • 51~80MBだったメモリの使用量も、31~46MBと初回より減ったが1回目のチューニングとさほど変わりなし

と改善することに成功し、全体的な画面遷移も0.5秒程度で切り替わるようになり快適に利用できるレベルにまで改善することが出来ました!

infiniteScroll まじで便利だ!!


その他やってみたことや気になったこと


Ionic 「virtualScroll」の利用

Ionicの公式にも記載されているAPIにVirtualScrollがあります。

こちらは内部的には10件程度のDOMを最初に構築し、ユーザのスクロールの状況に合わせてデータを入れ替え

CSSのpositionで位置を調整することでスクロールによる動的コンテンツの入れ替えを実現していました。

DOMの数も多くならないため、かなり多いデータ量をスクロール画面で描画する際もパフォーマンス的に問題なく動作するように考慮されているようですが、

弊社社長が導入した際には、CSSのpositionの影響かスクロールを続けると組み込んだレイアウトが一部崩れるなどの問題が発生したので、一旦こちらの採用を見送りました。

(各リストで定義しているDOMの構成や定義したCSSなど何が悪いかまでしっかり調査できていませんが、自前で組んだほうが修正も早かったので今回は採用を見送りました)


Angular/Ionicのメモリ使用量ってどの程度なのだろうか

前職では、スマホ向けのソーシャルゲームの開発に携わっており、そのときはBackbone.jsをメインに利用しておりました。

処理としてCanvasで画像の合成やjsonで管理さているマスターデータの解析なども行っていましたが、チューニングを行うことで30~40MB程度で動かすことが出来ていました。

(チューニング実施前は80MB~100MBほどの時期もあり、Android端末で動かないなど悩まされた記憶があります)

Angular/Ionicがある程度メモリを使うんだろうなぁという想定はありましたが、

基本データ量がそんなに無いテキストデータ扱っているだけなのに、比較的多めのメモリを使っている印象があったので、

普通がどの程度なのかなという疑問は少し生まれました。(まぁ調べればいいだけなのですが...)


まとめ

今回は、「なんだかもっさりする」「反応が悪い」といった課題から、

Chrome Developer Toolsを利用してボトルネックを見つけ、

チューニングを行い改善するといったフローを私自身が実際に行ったことをそのまま記載してみました。

チューニングにおいては実際に計測してみることで本当にボトルネックになっているのはどこなのかを見つけることが出来るため、

まずは遅いと感じたらプロファイラーで計測してみることをおすすめします。

また、ここ2~3ヶ月でAngularをよく触るようになったのですが、まだまだ上辺だけのことしか理解できておらず、

内部的な動きの部分は全然分かっていないと痛感したため、

Backbone.jsを触っていたときのように内部のコードを読んだりして理解を深めていかなければなと感じました。

でもやっぱりチューニングって楽しいですね!!