今回はこちらのページに沿って進めていきます。バックエンドの知識があまりないので、解釈間違ってるかもしれませんので、なにかあればマサカリ投げていただければ受け取ります!
###イントロ
前回は、Navigator
とRouter
を使い、実際に複数のビュー間の移動とデータの受け渡しを実装しました。しかし、この時点では編集ビューで行った変更を維持することができません。ハイキングを選択して編集ビューで変更をしたのはいいものの、前の画面に戻るとその変更は破棄されてしまいます。これは表示しているデータの元となるモデルまでは変更していないからです。
これまでアプリのアーキテクチャの多くを理解できたと思うので、そろそろこの問題に取り組みたいと思います。実際のバックエンドのように機能するモックバックエンドをつくります。本来はサーバーに保存するデータを、今回は実行中のアプリケーションにローカルで保存します。
もちろん、このような擬似バックエンドをつくることは、Fuseでアプリをつくることに必須ではありません。既存のバックエンドソリューションを使っても問題はありません。ただ、この章で行うチュートリアルはより一般的なもので、特定のバックエンドに依存しないように進める予定です。なので、バックエンドの詳細についてではなく、コンセプトの核となる部分焦点を当てていきます。ともあれ、Fuseがバックエンドと連携するときにどのような実装が必要なのかを理解しておくために、一度は通るべき道です。
さらに、モックバックエンドを直接操作する代わりに、モックバックエンドの上に抽象化レイヤーをしいて、相互作用できるようにしたいと思います。そうすることで、モックバックエンドの機能を変更したり、実際のバックエンドに置き換えたりするときに、ビューモデルに手をつけず、同じインターフェースで提供することができます。また、オブジェクトキャッシュなど、通常のバックエンドにはない機能を付け足すこともできます。
いろいろ言ってきましたが、実装を始める前に、まずは典型的なバックエンドのインターフェースがどんなものかをみてみましょう。
###典型的なバックエンドのインターフェース
※今回はFuseというよりも、JSの書き方がメインのところもあるので、「そんなん知ってるよ・・」という人はとばしとばしでみてください。
バックエンドは複雑で、見た目や振る舞いはそれぞれ異なる可能性がありますが、最も基本的な部分は共通する部分があります。また、初期化やサインアップ、認証などはバックエンド固有のものであり、一般的なアプリでは考慮していないので、無視することができます。今回に必要なのは、データの保存・取得とそれを更新する方法です。これらの機能を念頭におくと、インターフェースは下記のような形が一般的です。
// アイテムの配列を返す
function getItems() { ... }
// アイテムを更新する
function updateItem(...) { ... }
このインターフェースを使うときは下記のように書きます。
// バックエンドから項目を取得する
var someItems = getItems();
// バックエンドの項目を更新する
updateItem(...);
直感的で簡単だと思います。しかし、たいていのバックエンドが対処するべき部分を無視しています。現状はすでにローカルにデータがあるのでいいですが、データが別の場所にあるサーバにある場合はどうでしょう。このままだとサーバのレスポンスを待つために実行が止まってしまいます。さらに、アプリ側からの要求がサーバに届き、サーバからのデータが返却されるまでにかかる時間が不確定になる場合があります。なので、データの取得と更新は非同期で行われる必要があります。そして、JSで非同期計算が行われる場合、大抵はPromise
をつかうことになるとおもいます。MDNのPromiseについての記事を要約するとPromiseは「今使える値、今後使えるようになる値、ずっと使えない値」を表しています。Promiseは私たちのユースケースに向いているので、今回はPromiseを採用します。先ほどのコードの意味は次のように変わります。
// アイテムの配列を表すPromiseオブジェクトを返す
function getItems() { ... }
// バックエンドでアイテムが更新されると実行されるPromiseを返す
function updateItem(...) { ... }
関数の定義は変わりませんが、実行側は少し変わります。
// バックエンドから非同期でアイテムを取得する
var someItems = [];
getItems().then(
function(items) {
someItems = items;
}).catch(
function(error) {
console.log("アイテム取得に失敗しました : " + error);
});
// バックエンドのアイテムを非同期で更新する
updateItem(...).catch(
function(error) {
console.log("アイテムの更新に失敗しました : " + error);
});
Promiseを使用するには独自の非同期コードを導入する必要があります。一番シンプルな方法はthen
とcatch
を使う方法です。
then
を呼び出すことで、Promiseが解決された時に実行する関数を定義することができます。この関数には、Promiseから引数が渡されます。この場合はバックエンドからのデータです。しかし、この引数はオプションです。例えば、先ほど定義したupdateItem
関数は単にデータを更新するものであり、返却されたPromiseオブジェクトは実行時に引数を渡すことを想定していません。実際、Promiseオブジェクトが完了したかどうかが大事なので、引数は使われません。
catch
は、何かしらの理由で失敗した場合に、Promiseにそれが伝えられ実行されます。引数にエラーオブジェクトが渡されて、なんで失敗したかを追求することができます。たとえば、バックエンドサーバーに接続できなかった場合や認証に失敗した場合にエラーを発行します。エラー検出/デバッグは語り始めるととても複雑なので、簡単なエラーハンドラーをいくつかPromiseに実装したいと思います。
Promiseはそのほかにも色々な機能を提供してくれます。今回モックバックエンドを構築するのに必要な部分や、後で実際のバックエンドのインターフェースの役割をとらえるために知るべきことは今書いたとおりです。
さて、このPromise
がFuseのObservable
に似ていることに気がついた人もいるかもしれません。Observable
は変更可能で、その変更をすることができる値です。非同期インターフェースにも適合します。実際、モックバックエンドのインターフェースとしてPromise
の代わりに使うこともできます。Fuseに特化したバックエンドを作るなら、統合が容易になるのでObservable
を推奨します。しかし、多くのバックエンドはFuse以外にも多くの環境をサポートするように作られているので、今回のモックバックエンドに関してはPromise
を使っていきます。後ほど、Promise
とObservable
のギャップの埋めかたを説明しますが、とても簡単なので、安心してください。
Promise
に関しての詳しい説明はMDNやPromises/A+から参照できます。
それでは、Promise
を使用して、モックバックエンドを作っていきます。
###モックバックエンドを実装しよう
※ここからは修正が主になり、その間コンパイルやプレビューができません。コードだけ見たい方は最後にすべてまとめるので、そちらをご覧ください。
これからいろいろとJSモジュールを追加していくので、ディレクトリなどをまとめたいと思います。現状、独立したJSモジュールはhikes.js
のみです。プロジェクトのルートにModules
フォルダを作って、それをこちらに移動しましょう。
.
|- MainView.ux
|- Modules
|- Pages
| |- EditHikePage.js
...
つぎにこのフォルダごとアプリにバンドルさせるよう、プロジェクトファイルを修正します。
...
"Includes": [
"*",
"Modules/*.js:Bundle"
]
}
これで準備は整いました。今回使用するハイキングのデータはhikes.js
に含まれているので、これをスタートポイントとして利用します。ファイル名をbackend.js
に変更しておきます。
現状はハイキング情報を単に公開しているだけなので、アプリとやりとりするインターフェースの部分を実装します。まずは配列を取得する関数です。
// ハイキングの配列を表すPromiseオブジェクトを返す
function getHikes() {
return new Promise(function(resolve, reject) {
resolve(hikes);
});
}
先に説明した通り、単に配列を返すのではなく、Promise
オブジェクトを生成して返却しています。Promise
オブジェクトはインスタンス化する際に、解決されたときに実行する関数と、拒否されたときに実行するエラーハンドラーを受け取ります。ここではresolve
とreject
がそれにあたります。分かりづらいですが、この二つは関数です。resolveを実行すると、getHikes
を呼び出したときにthen
の第一引数に設定した関数が実行されます。エラーが発生したときには、reject
を実行して、呼び出し時にcatch
で指定した関数を実行することができます。
JSに用意されているsetTimeout
関数を利用して、特定の時間遅らせることもできます。この関数は2つの引数を受け取ります。一つ目はあとで実行される関数。二番目はその関数を難病後に実行するかを指定する時間をミリ秒で指定します。例えば次のコードは0.5秒後に実行されます。
function getHikes() {
return new Promise(function(resolve, reject) {
setTimeout(function() {
resolve(hikes);
}, 500);
});
}
このコードはバックエンドからのレスポンスが0.5秒かかることをシミュレートするのに役に立ちます。しかしテスト中はシンプルにするために、遅延時間は0を指定しておきましょう。
function getHikes() {
return new Promise(function(resolve, reject) {
setTimeout(function() {
resolve(hikes);
}, 0);
});
}
これでgetHikes
関数ができました。今度はupdateHike
を作っていきます。こちらもPromise
を使っていきます。ハイキングを特定するためIDや、実際に更新するデータを引数として渡します。
function updateHike(id, name, location, distance, rating, comments) {
}
基本的に、渡された値ですべての情報を上書きする形で進めます。getHikes
同様、setTimeout
で遅延実行されるPromise
を返すように変更します。
function updateHike(id, name, location, distance, rating, comments) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
}, 0);
});
}
これで枠組みはできました。実際に更新する部分を作ります。シンプルにするために、for文を用いて単純な検索を行います。合致するハイキングを見つけたら、引数のデータで情報を上書きして、ループを抜けます。返却するPromise
オブジェクトの引数に渡されるresolve
を実行します。
function updateHike(id, name, location, distance, rating, comments) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
for (var i = 0; i < hikes.length; i++) {
var hike = hikes[i];
if (hike.id == id) {
hike.name = name;
hike.location = location;
hike.distance = distance;
hike.rating = rating;
hike.comments = comments;
break;
}
}
resolve();
}, 0);
});
}
これで、モックバックエンドのインターフェースができました。あとはこれをexport
してあげましょう。
module.exports = {
getHikes: getHikes,
updateHike: updateHike
};
###コンテキストアブストラクタ
前述したように、このモックバックエンドは直接ビューとやりとりすることができます。技術的には問題ありませんが、ふたつの間に抽象化レイヤーを挟むことによって、より一貫性のあるインターフェースを提供することができ、キャッシングのようなものを実装して、アプリが消費するバッテリーを節約することができます。
次はこの抽象化レイヤーを実装します。今回はこれをコンテキストと呼ぶことにします。まずは新しいcontext.js
ファイルを作成します。プロジェクトの構造は下記のようになります。
.
|- MainView.ux
|- Modules
| |- Backend.js
| |- Context.js
|- Pages
| |- EditHikePage.js
...
まずはObservable
モジュールと先ほど作ったbackend.js
をインポートします。
var Observable = require("FuseJS/Observable");
var Backend = require("./Backend");
通常のインポートはrequire
にルート相対で指定しますが、相対パスで指定することもできます。context.js
とbackend.js
は同じmodules
フォルダにあるので、相対で良いでしょう。
コンテキストはビューにアプリのデータを使ったり、変更したりできるシンプルなインターフェースを提供する必要があります。今まで作ってきたように、ビューは最終的にデータバインディングを通して、モデルのデータを表示します。なので、それに合わせてコンテキストは1つ以上のObservable
を使ってデータを渡すのが理想的でしょう。
var Observable = require("FuseJS/Observable");
var backend = require("./backend");
var hikes = Observable();
定義したObservable
はハイキングを表示するために、モデルで使用されます。アプリが起動したら、バックエンドからデータを取得して、Observable
を生成します。アプリを実行している間、このObservable
はバックエンドが持っているデータのコピーとなります。あとはObservable
が更新されればビューも更新されます。また先ほどのインターフェースを使うことで非同期での更新が可能となりました。
それではアプリ起動時の実装を行います。
var hikes = Observable();
backend.getHikes()
.then(function(newHikes) {
hikes.replaceAll(newHikes);
})
.catch(function(error) {
console.log("Couldn't get hikes: " + error);
});
reolaceAll
はgetHikes
から返却されるPromise
が解決されたときに、取得した内容をすべて上書きするのに最適です。また、デバッグ用に、エラーハンドラーも用意しています。これで、アプリを立ち上げた時に、モデルからデータを取得する部分はできました。
今度は、モデルのデータを更新する方法を見ていきます。backend.js
のupdateHike
を実行するupdateHike
関数を定義します。この関数はコンテキストが持っているObservable
のハイキング情報を更新し、バックエンドにデータの更新を指示します。
function updateHike(id, name, location, distance, rating, comments) {
for (var i = 0; i < hikes.length; i++) {
var hike = hikes.getAt(i);
if (hike.id == id) {
hike.name = name;
hike.location = location;
hike.distance = distance;
hike.rating = rating;
hike.comments = comments;
hikes.replaceAt(i, hike);
break;
}
}
backend.updateHike(id, name, location, distance, rating, comments)
.catch(function(error) {
console.log("Couldn't update hike: " + id);
});
}
最後にexport
で公開します。
module.exports = {
hikes: hikes,
updateHike: updateHike
}
###連結させよう
バックエンドのモジュールと、バックエンドとビューをつなぐコンテキストができたので、これらを使用してみましょう。HomePage
の移行から始めます。古いハイキングモジュールをインポートしているので、代わりにContext.js
を読み込み、参照を変更します。
var Context = require("Modules/Context");
module.exports = {
hikes: Context.hikes,
goToHike: goToHike
};
次はEditHikePage
です。このページは前回行ったルータから、ハイキングデータを受信するので、データ表示に関する修正はいりません。前回戻るボタンだったところをキャンセルに変更し、変更を最終的に確定する保存ボタンも作りたいと思います。
まずは保存ボタンを追加します。編集ビューで行った変更をモデルにコミットすることを除けば、戻るとだいたい同じです。まずはeditHikePage.ux
にボタンを追加します。
<Text>Comments:</Text>
<TextView Value="{comments}" TextWrapping="Wrap" />
<Button Text="Save" Clicked="{save}" />
</StackPanel>
今度はJSの方にクリック時に実行する関数を追加し、exports
します。
function save() {
router.goBack();
}
...
rating: rating,
comments: comments,
save: save
};
最後に編集をコミットする部分です。これは先ほど作ったコンテキストのupdateHike
関数を実行すればよいです。渡す値はObservable
から取得できます。
function save() {
Context.updateHike(hike.value.id, name.value, location.value,distance.value, rating.value, comments.value);
router.goBack();
}
これで保存ボタンはOKです。続いてキャンセルの方も作ります。キャンセルは、変更があった場合はコミットせず、元に戻す必要があります。まずは取り消しボタンと、クリックハンドラを準備しましょう。
<Text>Comments:</Text>
<TextView Value="{comments}" TextWrapping="Wrap" />
<Button Text="Save" Clicked="{save}" />
<Button Text="Cancel" Clicked="{cancel}" />
</StackPanel>
function cancel() {
router.goBack();
}
...
module.exports = {
...
cancel: cancel,
save: save
};
最後に、router.goBack
を実行する前に、行った変更を取り消す処理です。モデルのObservable
が、editHike
クラスで、map
メソッドで作られていることを利用しましょう。Observable
の値をリフレッシュすると、エディタの編集がすべてリセットされます。
何も知らない人がこのコードを読んだ時もわかるようにコメントを追加しておきしょう。
function cancel() {
//hike(Observable)の値を、リセットします。
hike.value = hike.value;
router.goBack();
}
これですべての連結が完了しました。ここまでやればやっとプレビューを確認することができます。ファイルを保存して実行してみてください。
###今回の成果
アプリケーションの主要な機能は完成しました。さまざまなページとモジュールが完璧に調和しており、スケーラビリティにすぐれたアーキテクチャを備えています。
コードを書きに載せておきます。
var hikes = [
{
id: 0,
name: "Tricky Trails",
location: "Lakebed, Utah",
distance: 10.4,
rating: 4,
comments: "This hike was nice and hike-like. Glad I didn't bring a bike."
},
{
id: 1,
name: "Mondo Mountains",
location: "Black Hills, South Dakota",
distance: 20.86,
rating: 3,
comments: "Not the best, but would probably do again. Note to self: don't forget the sandwiches next time."
},
{
id: 2,
name: "Pesky Peaks",
location: "Bergenhagen, Norway",
distance: 8.2,
rating: 5,
comments: "Short but SO sweet!!"
},
{
id: 3,
name: "Rad Rivers",
location: "Moriyama, Japan",
distance: 12.3,
rating: 4,
comments: "Took my time with this one. Great view!"
},
{
id: 4,
name: "Dangerous Dirt",
location: "Cactus, Arizona",
distance: 19.34,
rating: 2,
comments: "Too long, too hot. Also that snakebite wasn't very fun."
}
];
function getHikes() {
return new Promise(function(resolve, reject) {
setTimeout(function() {
resolve(hikes);
}, 0);
});
}
function updateHike(id, name, location, distance, rating, comments) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
for (var i = 0; i < hikes.length; i++) {
var hike = hikes[i];
if (hike.id == id) {
hike.name = name;
hike.location = location;
hike.distance = distance;
hike.rating = rating;
hike.comments = comments;
break;
}
}
resolve();
}, 0);
});
}
module.exports = {
getHikes: getHikes,
updateHike: updateHike
};
var Observable = require("FuseJS/Observable");
var Backend = require("./Backend");
var hikes = Observable();
Backend.getHikes()
.then(function(newHikes) {
hikes.replaceAll(newHikes);
})
.catch(function(error) {
console.log("Couldn't get hikes: " + error);
});
function updateHike(id, name, location, distance, rating, comments) {
for (var i = 0; i < hikes.length; i++) {
var hike = hikes.getAt(i);
if (hike.id == id) {
hike.name = name;
hike.location = location;
hike.distance = distance;
hike.rating = rating;
hike.comments = comments;
hikes.replaceAt(i, hike);
break;
}
}
Backend.updateHike(id, name, location, distance, rating, comments)
.catch(function(error) {
console.log("Couldn't update hike: " + id);
});
}
module.exports = {
hikes: hikes,
updateHike: updateHike
};
var Context = require("Modules/Context");
function goToHike(arg) {
var hike = arg.data;
router.push("editHike", hike);
}
module.exports = {
hikes: Context.hikes,
goToHike: goToHike
};
<Page ux:Class="EditHikePage">
<Router ux:Dependency="router" />
<JavaScript File="EditHikePage.js" />
<ScrollView>
<StackPanel>
<Text Value="{name}" />
<Text>Name:</Text>
<TextBox Value="{name}" />
<Text>Location:</Text>
<TextBox Value="{location}" />
<Text>Distance (km):</Text>
<TextBox Value="{distance}" InputHint="Decimal" />
<Text>Rating:</Text>
<TextBox Value="{rating}" InputHint="Integer" />
<Text>Comments:</Text>
<TextView Value="{comments}" TextWrapping="Wrap" />
<Button Text="Save" Clicked="{save}" />
<Button Text="Cancel" Clicked="{cancel}" />
</StackPanel>
</ScrollView>
</Page>
var name = hike.map(function(x) { return x.name; });
var location = hike.map(function(x) { return x.location; });
var distance = hike.map(function(x) { return x.distance; });
var rating = hike.map(function(x) { return x.rating; });
var comments = hike.map(function(x) { return x.comments; });
function cancel() {
// Refresh hike value to reset dependent Observables' values
hike.value = hike.value;
router.goBack();
}
function save() {
Context.updateHike(hike.value.id, name.value, location.value, distance.value, rating.value, comments.value);
router.goBack();
}
module.exports = {
name: name,
location: location,
distance: distance,
rating: rating,
comments: comments,
cancel: cancel,
save: save
};
###次回は
さて、機能ができあがったので、今度は見た目を作っていきます。コンポーネントの見た目をカスタマイズしていく予定です。