2015年8月26日水曜日

これがAngularJSの$apply()や$digest()が低速な理由のひとつか?

昨日書いた記事 「琴線探査: Dr. GlebのAngularアプリ最適化TIPSまとめ」 に、try-catchがあるとV8では最適化されないため処理が遅くなると書いた。

今日は自分で書いたアプリをプロファイルしてみた。このように、$apply()や$digest()に相当な時間がかかっていることが分かった。


これは予想通りだったが、驚いたのは$apply()や$digest()でtry-catchを使っているらしいことだ。

「Not optimized: TryCatch Statement」とツールチップが出ている。ひょっとすると、これが$apply()や$digest()が低速な理由のひとつなのかもしれない。

だとすると、かなり根本的なレベルで高速化できていないことになる…内部的にtry-catchを使わないようにできないのかなぁ(´・ω・`)

2015年8月25日火曜日

Dr. GlebのAngularアプリ最適化TIPSまとめ

Dr. Glebのブログ記事 Improving Angular web app performance example. | Better world by better software を簡単にまとめておこうと思う。


try-catchはできるだけ使わない

V8ではtry-catchを使った処理は最適化されないので、特にループ処理ではできるだけ使わないようにする。Primesの例ではtry-catchを無くしただけで全体の処理は2倍以上、メソッド単体では200倍近く高速化している。

追記15.08.26:$apply()や$digest()でtry-catchが使われていることが判明(ノ∀`)
琴線探査: これがAngularJSの$apply()や$digest()が低速な理由のひとつか?


ダイジェストサイクルをできるだけ速くする

$scope.$apply()した時のあれ。ダイジェストサイクルはフレームレートと同じで、長くなるほどUIが固まってしまうことになる。短くする方法は色々ある。


バインドさせる変数をできるだけ少なくする

ng-modelとか$scope.valueとか。バインドさせる変数の数だけダイジェストサイクルは遅くなる。


filterはできるだけ使わない

filterはダイジェストサイクルを遅くさせる。「{{ "index" | lowercase }}」的な無駄なフィルターはもってのほか。


one-time-bindingを使う

サーバーから持ってくるデータは大抵の場合は大量だ。これをページにレンダリングするために2wayバインディングを使ってしまうとダイジェストサイクルが大幅に遅くなる。

そこでAngular1.3から導入されたone-time-bindingを使う。これを使えば、始めだけはバインディングするのでダイジェストサイクルに影響するが、その後は影響が無くなる。

例えば「{{name}}」としている所を「{{::name}}」とするだけでone-time-bindingになる。

AngularJS: Developer Guide: Expressions


React的なレンダリング方法に変更する

特にtableの中身などの反復部分で。要するに、こういった部分を

<tr ng-repeat="prime in primes | orderBy:$index " bindonce>
  <td>index</td>
  <td bo-text="$index + 1 | number:0" />
  <td>prime number</td>
  <td bo-text="prime | number:0" />
  <td>is prime? true</td>
</tr>

こうする。

// use AngularJs built-in filter
var number = $filter('number');
function generateTableRows() {
  var k;
  var str = '';
  for(k = 0; k < $scope.n; k += 1) {
    str += '<tr><td>index</td>';
    str += '<td>' + number(k + 1, 0) + '</td>';
    str += '<td>prime number</td>';
    str += '<td>' + number($scope.primes[k], 0) + '</td>';
    str += '<td>is prime? true</td></tr>';
  }
  document.getElementsByTagName('table')[0].innerHTML = str;
}
$scope.find = function () {
  // generate primes list as before
  generateTableRows();
}

これだけで10倍速くなる。

※後に出てくるバーチャルスクローリングの方が現実的と思われ


表示できるものはできるだけ早く表示する

素数の計算は分単位の時間がかかる場合があるが、始めの100個は非常に速く計算できる。まずこれを表示し、残りは後で表示するようにする。

例えば、setTimeout()や$timeoutを使って長い処理を分割する。

実際に処理が速くなるわけではないが、心理的な処理速度は大幅に速くなる。UX的な配慮と言えるだろう。


UIの更新サイクルを33msec以内に保つ

UIの更新サイクルには

・JSコード実行
・レイアウト計算
・各コンポーネントをバックバッファーにレンダリング
・各コンポーネントを全部合わせてペイント

の4つのステップがあるが、これを最低33msec以内に保つようにする。これで30fps。60fpsが必要なら16msec以内に保つ必要がある。

ブラウザの性能やハードウェアの性能に依存する部分も多いが、コーダーとして最もコントロールできる部分はJSコードの実行だろう。ここをできるだけ速くすることが大事。

33msec以上かかるような処理は$qや$timeoutを駆使して処理を分割し、計算>表示>計算>表示>…と少しずつ計算しては表示するというサイクルを作る。


テーブルに行を追加する時はテーブル自体を追加する

大量にデータを含むテーブルにこのような処理をすると、全ての行でレイアウトの再計算が行われてしまい非常に遅くなる。

function generateTableRows(first, last) {
  // generate new rows HTML markup into variable str
  document.getElementsByTagName('tbody')[0].innerHTML += str;
}

そこで、行を追加せずテーブル自体を追加する。

function generateTableRows(first, last) {
  var k, txt = angular.bind(document, document.createTextNode);
  var table = document.createElement('table');
  for(k = first; k < last; k += 1) {
    var row = table.insertRow();
    row.insertCell().appendChild(txt('index'));
    row.insertCell().appendChild(txt(k + 1));
    row.insertCell().appendChild(txt('prime number'));
    row.insertCell().appendChild(txt($scope.primes[k]));
    row.insertCell().appendChild(txt('is prime? true'));
  }
  // schedule DOM update by attaching new table element to the body
  document.body.appendChild(table);
}

※後に出てくるバーチャルスクローリングの方が現実的と思われ


時間のかかる処理をWebWorkerに投げてしまう

結構面倒だが、奥の手としては十分検討の価値がある。


Array.pushはできるだけ使わない

Array.pushを使うとメモリを効率的に使えないため頻繁にGCが発生し、パフォーマンスに影響する。

var k, n = numbers.length;
for(k = 0; k < n; k += 1) {
  $scope.primes.push(numbers[k]);
}

配列の大きさが決まっている場合は、先にその大きさで配列を確保して値をコピーするようにする。

// initialize the array length
$scope.primes = new Array($scope.n);
$scope.computedN = 0;
// copy numbers
var k, n = numbers.length;
for(k = 0; k < n; k += 1) {
  $scope.primes[$scope.computedN] = numbers[k];
  $scope.computedN += 1;
}


必要になった時に必要なだけ計算(表示)する

全てのデータが必要とは限らないので、「ngInfiniteScroll」などを使ってリストの最後まで行ったら素早く計算して表示するようにする。


$scope.$watch()をやたらと使わない

// instead of individual actions for same watcher
$scope.$watch(function () {
  return $scope.primes;
}, foo, true);
$scope.$watch(function () {
  return $scope.primes;
}, bar, true);
$scope.$watch(function () {
  return $scope.primes;
}, baz, true);
// use single watcher and fire off multiple actions
$scope.$watch(function () {
  return $scope.primes;
}, function () {
  foo();
  bar();
  baz();
}, true);


バーチャルスクローリングを使う

ngRepeatでバーチャルスクロール機能を実現するライブラリ 「kamilkp/angular-vs-repeat」。万単位のリストであっても、実際に見えるデータのDOMのみを保持するようにすることでメモリ面でも速度面でもパフォーマンスの向上が期待できる。Dr.Glebの実験でもかなりのパフォーマンスを叩き出しているようだ。


Angularのバージョンを上げる

1.2で850msかかっていた処理が1.3にしただけで250msに!


ng-classをやたらと使わない

ng-class内のexpressionを処理するのに時間がかかるのでやたらと使わない。例えばこのような処理は

<td>
  <input type="number"
         ng-model="prime" name="primeNumber" required=""
         ng-class="{ 'small': prime < 10, 'exact': prime == 10, 'large': prime > 10 }"
         />
</td>

このようにmaxを設定して

<td>
  <input type="number" max="10"
         ng-model="prime" name="primeNumber" required=""
         />
</td>

フォームのバリデーションでng-validやng-invalidのCSSクラスが付加されるようにして、そこで必要なスタイルをつける。


まとめ

なんだかAngular独特の機能をできるだけ使わない方がいいという感じを受けた(^^);

Angularが便利だからとあまり依存しすぎないようにして、必要最小限の利用にとどめた方が、コードのメンテナンス性も含めた全体のパフォーマンスが上がりそうだなと思った。

これらのTIPSの中では特にone-time-bindingとバーチャルスクローリングの効果が高そう。


こちらも良記事。

Ultimate AngularJS and Ionic performance cheat sheet | Julien Renaux Blog

やっぱりone-time-bindingは共通して言及されている。

2015年8月13日木曜日

AngularMaterial VS Polymer - UI部分だけそれぞれで実装・比較して分かったこと

あるWEBアプリをAngularMaterialを使って実装しているが、最近Polymerが1.0になったので、実験的にUI部分をPolymerで実装し直して比較してみることにした。

ここに分かったことをまとめておこうと思う。


UIコンポーネントの数

AngularMaterialの方がベーシックに使えるものが多い。最近ではFAB Speed Dialなどのコンポーネントが増え、益々充実してきている。Polymerにもgoogle-mapなどのコンポーネントが増えてはいる。


UIの動作速度

Polymerの方が速い。PolymerはアニメーションにWebAnimationを使用しているが、AngularMaterialはngAnimateに依存しているからだと思われ。


レイアウトのしやすさ

Polymerの方が分かりやすい。Polymerではvertical, horizontalとするところを、AngularMaterialではrow、columnとなっている。


レイアウト処理の軽さ

Polymerの方が軽いのではないかと思われる。Polymerではレイアウト関連の指定は「class="layout horizontal"」のように全てCSSのclassだが、AngularMaterialでは「layout="row"」のようにタグのアトリビュートで行われるので内部でカスタムタグの処理をしているだろうから。


ライブラリとしての扱いやすさ

AngularMaterialの方が扱いやすい。ngAnimate、ngAria、ngMaterialの各モジュールとCSSをひとつ読み込むだけでたくさんのUIコンポーネントを使うことができる。逆に言えばムダが多い。bowerのコンポーネントの数で言えば3つだ。

一方、PolymerはHTML importの仕組みによって必要なコンポーネントを必要なだけ組み込めるようになっているのでムダが少ないが、小さいファイルをたくさん読み込む必要がある。例えばFAB一つ使うだけでもbowerのコンポーネントは3つでは済まない。


起動速度

AngularMaterialの方が速い。参考までに、今回のアプリでは10回平均の起動時間(msec)はこのようになった。

AngularMaterial Polymer
PC 1132 1665
PHONE 4892 6435

このように、PolymerはAngularMaterialに比べて約1.3〜1.5倍の起動時間がかかった。

原因として考えられるのは、依存ファイル読み込みのオーバーヘッド。AngularMaterialは読み込むファイル数が少ないがPolymerはとても多い。

解決策として考えられるのは、ファイルのconcatだが、HTML importの仕組みだと怖くてヘタにconcatするわけにもいかないし、concatする勇気があっても慎重にファイルの依存関係を調べる必要があり面倒過ぎる。

特にモバイルのような遅いネットワークでは、読み込むファイルの数が多いということは相当なオーバーヘッドになり、悪くすると数倍の起動時間が必要になる可能性もある。


AngularJSとの統合のしやすさ

これはもちろんAngularMaterialの方が統合しやすい。Polymerのコンポーネントはそのままではng-changeなどのマッピングができない。マッピングするためにはこのライブラリを使う。

GabiAxel/ng-polymer-elements

ただ、このライブラリを実際に使ってみたところ、paper-toggle-buttonのng-changeにマッピングされたメソッドでそのpaper-toggle-buttonのON/OFF状態を調べたところ、完全に状態が逆になっていた。


結論

特にPolymerのUI動作速度は捨てがたいが、AngularMaterialも我慢できないほど遅いということは無い。どちらかというと起動速度の方が問題なので、現状のAngularMaterialを引き続き採用することにした。

2015年8月6日木曜日

CSSのtranslate3dをパースして数字の配列にするには?

CSSのtranslate3dは例えば translate3d(5, -10, 0) のような文字列になっているが、このままでは計算するのに使いにくい。

そこで、これをパースして数字の配列にするにはどうすればいいだろう。例えば、このような正規表現を使うというのはどうでしょう?

/**
 * CSSのtranslate3d文字列を数字の配列に変換して返す
 */
function parseTranslate3d(string) {
  var array = string.replace('translate3d', '').match(/-?[\d\.]+/g);
  for (var i = 0; i < array.length; i++) {
    array[i] = Number(array[i]);
  }
  return array;
}