ジンジャー研究室

長めのつぶやき。難しいことは書きません。

GUIをツリーにする話(新篇)

English

前置き

この記事は以下の記事からの続きである。

GUIをツリーにする話(前篇) - ジンジャー研究室
GUIをツリーにする話(後篇) - ジンジャー研究室

前回の話をざっくりまとめると、

の2つがあった場合、モデルの受け渡しを不要にするために共有したい。
また、片方のViewによってModelが更新されたとき、もう片方のViewにもそれが反映されて欲しい。

f:id:jinjor:20131210030223p:plain

「モデルを監視する」を定義通りに、Observerを用いて実装するのにBackbone.jsが適している。しかし、記述方法としてはAngularJSのdirectiveがおいしい。

<div ng-app="app">
    <div ng-controller="Ctrl">
         <list values="members"></list>
         <sum values="members"></sum>
    </div>
</div>

これをベースとして、今回の記事では実装・設計上必要となる更にいくつかの概念について触れることにする。

プロパティのメモ化

ツリーの話からはいったん離れて、もう少し深く掘り下げると見えてくることがある。
ModelからViewへの距離が遠くなるほど再計算が多く走るという点である。

これだけでは何を言っているか分からない。
そこで、お題をもう少し複雑にするために仕様を追加してみよう。

  • それぞれの行について、全体に対する割合を併せて表示する。

すると<list>directiveの詳細は次のようになる。

var add = function(a, b) {return a + b};
var sumOf = function(values){
    return values.reduce(add, 0);
}

angular.module('app', [])
.directive('list', function() {
    return {
        restrict: 'E',
        template: '<div><button ng-click="add()">add</button>\
<ul><li ng-repeat="v in values">{{v}} ({{ratioOf(v)}}%)</li></ul></div>',
        scope: { values: '=' },
        link: function($scope) {
            $scope.add = function(){
                var value = $scope.values.length + 1;
                $scope.values.push(value);
            },
            $scope.ratioOf = function(value){
                // sumOfが何度も呼ばれてしまう
                return (value / sumOf($scope.values) * 100).toFixed(1);
            }
        }
    };
})
.directive('sum', function() { ... })
.controller('Ctrl', function($scope) {
    $scope.members = [1, 2, 3];
});

ここで顕在化するのは、関数ratioOfはループ中(ng-repeat)で何度も呼ばれているので、1回で済むはずの関数sumOfがn回呼ばれているという問題である。
宣言的に書けたのは良いが、これはHaskellでもなんでもないので前の計算を覚えてくれているはずもなく、愚直に再計算してしまう。

この問題は、次のように少々ぎこちない方法を使って解決することが出来る。

var sum;
$scope.$watch('values.length', function() {
    sum = sumOf($scope.values);
});
$scope.ratioOf = function(value){
    return (value / sum * 100).toFixed(1);
}

デモ(JSFiddle)

やっていることとしては、「values.lengthという値が変わったとき*1、sumの値を更新しなさい」とAngularに教えている。
似たような仕組みはEmber.jsにもあって、Modelに対してpropertyを設定することが出来る。Backbone.jsの場合でもModel(Collection)の変更時に同じ計算を走らせることが出来る。

ぎこちないと言ったのは宣言的に書けないという点で、さらに一般化して、Model <- Model <- Model ... <- Model <- View のように連なった時、常に依存関係と計算量を考慮してプログラミングするのはきつくなってくる。


このように、「ある値を変更した時にそれに依存した値を自動的に再計算する」ことを目的とした、Reactive Programmingというパラダイムがある。*2
MVC」は問題をModelとViewという2つの領域に分ける概念だが、実際にはViewのための中間Modelが居たり、Viewの後ろに更にスタイル記述が居たりするので、実際にはもっと沢山の階層が存在する。そう考えた時、Reactive Programmingの概念を導入すると、もっとすっきり解決することが多いのではないかと思われる。*3

f:id:jinjor:20131212200810p:plain

Reactive Programmingは一般的な概念だが、GUIやゲームへの適用を試みている言語のひとつにElmがある。サンプルを見れば、どれだけ簡潔に書けるのかが一目で分かるはずだ。

アプリケーション設計

コンポーネントツリーの話から大分離れていたので、ここで話を戻すことにする。
ひとつのModelを子供のView達が共有する話だった。これを更に組み上げていくと全体としては次のような図になることが想像できる。

f:id:jinjor:20131212193037p:plain

ここで、Modelとなるオブジェクトは常にグローバルから渡されているわけではなく、コンポーネントにとっての関心を扱う固有のModelが子供に共有されているというケースが考えられる。つまり、親からは見えず、子供には共有されている。恐らくこのようなアプリケーションの組み方が洗練されてくる頃には、このスコープの概念に何か名前が付くのではないだろうか。

グローバル変数が欲しくなったら

さて、この調子でどんどんアプリケーションが巨大化していくことを妄想すると、次のようなことが発生するのではないだろうか。

十分にあり得る。状態を共有しないのであれば単にユーティリティを使えばいいのだが、例えばメニューバーやステータスバーの類、それから画面全体の状態を扱うものに関して上記のケースが当てはまる。その場合、ツリーのルートから引数に渡していくのは面倒になる。面倒でも渡せばいいのだが、しっかりしたインターフェイスを持たないと仕様変更の際に打撃を受ける(動的型付けでは特に)。そのノードに至る全ての経路を洗い出す必要が出てくるからだ。
そういうわけで、「このオブジェクトだけは特別にグローバルに置いておくから好きにしていいよ」という設計的な判断があったとする。図にすると次のようになる。

f:id:jinjor:20131212195418p:plain

判断の是非はさておき、このようにする場合は、アプリケーションレイヤーとユーティリティレイヤーをしっかり意識する必要がある。アプリケーションレイヤーでは疎結合を犠牲にして実装の簡便さを提供する。一方で、ユーティリティレイヤーに属するコンポーネントは外界に依存しないことを心に誓う。

ただし、一度グローバルへの直接参照を許可する設計にすると後戻りが出来ないのが注意点としてある。
例えば、「ロガーは統一して使えばいいだろう」と思っていても、後になって「このコンポーネント以下のログは出さない」のような変更があると対処できなくなる。

Web ComponentsとModel Driven Views

さて、概念図ばかりなので「じゃあどういう風に書けるのか?」が気になる。

AngularJSでは、上記の例のようにdirectiveを登録する必要があった。しかしそもそもAngularJSというフレームワークにガッツリ依存しているし、こういうことをしたい場合に標準の仕組みが欲しい。ということで、Web Componentsの導入を検討したい。
詳細な解説は他の記事に譲るとして、要は「カスタムタグ(コンポーネント)が書ける!」という事実だけで十分である。例はリンク先を見て欲しい。テンプレートと対応するスクリプトをペアで記述する。

ところで、Web ComponentsはMVCフレームワークではなく、単にDOMの延長である。だからAngularJSのような(双方向)データバインディング機構を期待することはできない。

ちなみに、そのような機能を備えたテンプレートは、MDV(Model Driven Views)と呼ばれている。
Web Componentsを実装していると謡うライブラリ(PolymerやDart Web UIなど)が増えてきているが、必ずしもこの機能を実装しているとは限らない点には注意が必要である。(逆に言うとその辺に注意しながら動向を追っていると面白い!)

最後に

3回に亘って「GUIをツリーにする」ことに関しての私見をグダグダと語ってきた。現時点で持っている理想とそれに至る経緯や観点、それから潜在する問題点と現時点での動向を一通り嘗めたつもりである。

仮説として理想は持ったので、あとは誰かが実装するのを待つか自分で実装するかという話になるのだが、これがなかなか難しくて既に挫折気味である。
仮に完成したとして、それをプロダクトに導入できるかと言ったら(当然ながら)別問題で、特に「モデルを共有するコンポーネント」というシロモノは馴染みがないためにメンタルモデルを共有しにくい。だから何とか伝えようと頑張ってみたのだが、果たしてどうだろう。

また、最後に少しWeb Componentsについて触れたが、MDVが無くても十分面白い技術なので積極的に使っていきたい。


以上。長文お疲れ様でした。

*1:valueでは何故か感知しなかった

*2:特にデータの流れを抽象化して関数的に表現するものをFRP(Functional Reactive Programming)と呼ぶ。JavaScriptライブラリではBacon.jsが有名。

*3:それでも計算量を最適にするのは簡単ではない。例えば評価のタイミングにはpull型とpush型があり、状況に応じて使い分ける必要がある。