この記事では、Fuseを使用してHacker Newsリーダーアプリを作成します。Fuseとは、AndroidデバイスとiOSデバイスの両方で動作するクロスプラットホームアプリを開発するためのツールです。プロジェクトのソースコードはGithubにあります。最終的なアウトプットは次のようになります。
Fuseとは?
一言で説明すると、FuseはJavaScriptを使ったクロスプラットホームアプリを作成するツールです。Fuseを支える基本的なアイデアは、React NativeやNativeScriptとよく似ています。FuseはJavaScriptを実行するためにJavaScript VMを使用します。また、UIコンポーネントをネイディブUIにコンバートし、異なるプラットホームのAPIにアクセスできるようにします。
Fuseは、開発者とデザイナーの両方にとって使いやすいツールを作成したいという目的から生まれたものです。これまでの状況を見るかぎり、とても順調に進んでいると言ってよいでしょう。Fuseの特徴としては、ネイティブUI動作、効果的で表現豊かなアニメーション、複数のデバイスに対して瞬時に実行されるライブリローディングなどがあられます。
Fuseのインストール
Fuseを使用する前に、Android開発用にAndroid SDKを、iOS開発用にXCodeをインストールする必要があります。以下の2つのリンクをインストールの参考にしてください。
AndroidまたはiOSの開発用にマシンをセットアップ済みの場合は、この手順はスキップします。
Android SDKまたはXCodeのインストールが完了したら、Fuseのインストーラーをダウンロードします。メールアドレスを入力し、使用許諾契約(license agreement)に同意して、Let’s Fuseをクリックします。そのあと、各開発プラットホーム用のインストーラーをダウンロードするボタンが表示されるので、開発環境に合わせていずれかをダウンロードします。記事では、Windows版を使用します。
Fuseインストーラーのダウンロードが完了したら、クリックしてインストールを開始します。インストーラーは依存オブジェクトもインストールするため、インターネット接続状況によっては時間がかかることがあります。
新たなプロジェクトを作成する
Fuseダッシュボードを起動し、New Projectをクリックすると新たなプロジェクトを作成できます。プロジェクト名とプロジェクトを保存する場所を指定します。
作成したプロジェクトは、ダッシュボードの最近使用したプロジェクト(Recent Projects)の下にリスト表示されます。プロジェクトをクリックし、さらにOpen in Sublime Text 3をクリックします。FuseとSublime Textはうまく統合されているので、Fuseのプロジェクトに取り組むときはSublime Textをテキストエディターとしてすすめます。
ただし、この統合環境を利用するにはパッケージコントロールを使ってFuseプラグインをインストールする必要があります。インストールするとコード補完機能や、ビルド結果を表示して具体的にコードのどこが間違っているのか知らせる機能などが追加されます。Sublime Textを使ったことがなければ、ここからダウンロードできます。パッケージコントロールのインストール手順はこちらです。
Sublime Textのダウンロードおよびパッケージコントロールのインストールが完了したら、Fuseをインストールするためにキーボードのctrl + shift + Pを押してPackage Control: Install Packageを選択します。ここで「Fuse」を検索し、検索結果の中から最初の項目を選択します。
開発ツール
アプリを作成する前に、Fuseが備えている開発ツールの概要を説明しておきます。ツールの使い方を知っておけば、あとでなにかあっても悩まずに済みます。
MainView.uxファイルを開きます。デフォルトのコードが含まれており、実行したり試しに触れてみたりできます。Fuseのダッシュボードでプロジェクトを選択したあと、PreviewをクリックしLocalを選択します。新たにコマンドライン画面が開き、プレビューツール(Preview Tool)が起動します。
プレビューツールを使えばアプリがどのように表示されるか確認できます。コードに変更を加えると、プレビューも更新されます。動作はとても高速で、これこそがFuseを使っていて楽しい理由です。ファイルを保存するとすぐに変更が反映され、その場で確認できるのです。
プレビューが起動したら、デフォルトアプリが表示されます。変更をするにはFuse -> Design Modeをクリックし、続いてFuse -> Open Inspectorをクリックします。新たな画面が開き、プレビューで選択したエレメントのスタイルを変更できます。たとえば、幅、高さ、背景色、フォントサイズを変更できます。
デザインモードを使用している間はアプリをインタラクティブに操作できないので注意してください。デザインモードでは、プレビューで選択したあらゆるエレメントのスタイルを変更できるため、インタラクティブな操作をロックしユーザーインターフェイスが変わらないようにしています。
また、モニターツール(Monitor Tool)というツールも使用できます。Fuse -> Open Monitorをクリックして開きます。新たに画面が表示され、アプリ内で発生したすべてのエラーを記録します。さらに、コード内でdebug_log()関数を呼び出して独自にログの記録もできます。この関数はブラウザーでよく目にするconsole.log()に似ています。ただし、文字列しか出力できません。オブジェクトの内容を調べたい場合は、JSON.stringify()を使う必要があります。
ビルド結果(Build Result)というツールにも詳しくなっておくとよいでしょう。このツールはSublime Textと統合されており、画面左下にある箱型のアイコンをクリックすると選択できます。コンテキストメニューが開くので[Output: FuseBuildResults]を選択するとログコンソールが起動し、ファイルを保存するたびにビルド結果を表示します。
コードにエラーがあれば、コンソールに表示します。コードのなにが間違っているのか、または具体的にどの行が問題なのかが分かるので、エラーを解決できます。直前の変更によってエラーが発生したかどうかを確かめる手っ取り早い方法は、新たなエレメントを追加してユーザーインターフェイスが更新されるか確認することです。
よくあるエラーには、コンポーネントの属性名のスペルが間違っている、無効なパッケージをインクルードしようとしている、JavaScriptの構文エラーなどがあります。
アプリの作成
Fuse開発ツールの概要が分かったので、アプリの作成を始めます。最初に書いたように、作成するのはHacker Newsリーダーアプリです。Hacker News APIから最新ニュースを10個取得してリスト表示し、各ニュース項目のリンク先のWebページを閲覧できるようにします。
MainView.uxを開き、デフォルトのコードをすべて削除します。<app>エレメントの追加から始めます。Fuseではどのページもこのエレメントから始まります。
<javascript File="js/App.js"></javascript>
アプリ全体で使用されるJavaScriptファイルへのリンクを作成します。
<javascript File="js/App.js"></javascript>
このファイルはプロジェクトのルート下のjs/ディレクトリ内にあり、ファイルには以下のコードが含まれています。
const app_title = 'HN Reader';
const Observable = require("FuseJS/Observable");
var current_page = Observable("news_items");
var current_url = Observable("");
var title = Observable(app_title);
function navigatePage(context) {
if(context.data.url){
current_url.value = context.data.url;
title.value = context.data.title.substring(0, 20) + '...';
current_page.value = 'web_page';
}else{
title.value = app_title;
current_page.value = 'news_items';
}
}
module.exports = {
title: title,
navigatePage: navigatePage,
current_page: current_page,
current_url: current_url
};
上のコードを分解すると、アプリで使用されるデフォルト値を定義しているのが分かります。ヘッダーに表示されるタイトルやユーザーが現在閲覧しているページ、WebViewに用いられるWebページのURLが含まれています。これらの値はすべてFuseJS/Observableを用いてオブザーバブルな値にセットされますが、Fuseが内蔵しているライブラリーで使用すると双方向データバインディングが可能になります。つまり、コード内のこうしたオブザーバブルな変数の値を変更するとユーザーインターフェイスに反映され、その逆も同様になります。
// js/App.js
const app_title = 'HN Reader';
const Observable = require("FuseJS/Observable");
var current_page = Observable("news_items");
var current_url = Observable("");
var title = Observable(app_title);
ニュース一覧ページと実際のニュースページ(WebView)の間を行き来するための関数を定義します。関数に渡された引数の中にcontext.data.urlが存在すれば、current_urlの値を更新します。また、ニュースのタイトルから最初の20文字を取り出し、さらにcurrent_pageにweb_pageをセットすることで、WebViewを表示するページへ移動できるようになります。条件が成立しなければcurrent_pageにnews_itemsをセットし、タイトルをアプリのタイトルに戻します。
// js/App.js
function navigatePage(context) {
if(context.data.url){
current_url.value = context.data.url;
title.value = context.data.title.substring(0, 20) + '...';
current_page.value = 'web_page';
}else{
title.value = app_title;
current_page.value = 'news_items';
}
}
ユーザーインターフェイスから変数を参照するためには、module.exportsを使用して変数を利用可能にする必要があります。
// js/App.js
module.exports = {
title: title,
navigatePage: navigatePage,
current_page: current_page,
current_url: current_url
};
MainView.uxファイルに戻り、以下をjs/App.jsファイルへのリンクのすぐ下に追加します。このコンポーネントは、ページ間を移動する際の切り替え効果を自動的に処理します。
<pagecontrol Active="{current_page}">
</pagecontrol>
内側にアプリで使用するすべてのページを定義します。今回はnews_itemsとweb_pageの2つだけです。最初の<pagecontrol>コンポーネントで、current_page変数の現在値を値として持つActive属性を追加します。js/App.jsファイルでは、この変数にnews_itemsをセットしました。つまり、ニュース一覧ページをデフォルトのページとして使用します。web_pageのページに移動したいときは、単純にcurrent_pageの値をweb_pageに更新します。Fuseは切り替え効果を自動的に処理します。デフォルトではスライドアニメーションを使用します。
<page Name="news_items">
</page>
<page Name="web_page">
</page>
ニュース一覧ページ
最初にニュース一覧ページのコンテンツです。ここではすべて<dockpanel>にラップします。これによりエレメントを特定の位置に配置できます。デフォルトではFillにセットされており、画面全体を使用します。
</dockpanel><dockpanel>
</dockpanel>
次にjs/NewsItems.jsファイルにリンクします。これはニュース一覧ページにのみ使用されるJavaScriptです。
<javascript File="js/NewsItems.js"></javascript>
以下がファイルの内容です。
const Observable = require("FuseJS/Observable");
var loader_opacity = Observable('1');
var news_items = Observable();
const TOP_STORIES_URL = 'https://hacker-news.firebaseio.com/v0/topstories.json';
var story_promises = [];
fetch(TOP_STORIES_URL)
.then(function(response) { return response.json(); })
.then(function(top_stories) {
for(var x = 0; x < = 10; x++){
const story_url = "https://hacker-news.firebaseio.com/v0/item/" + top_stories[x] + ".json";
const p = fetch(story_url)
.then(function(response) { return response.json(); })
.then(function(news) {
news_items.add({
title: news.title,
url: news.url,
time: news.time
});
});
story_promises.push(p);
}
Promise.all(story_promises).then(function(){
loader_opacity.value = 0;
});
})
.catch(function(error) {
console.log('There has been a problem with your fetch operation: ' + error.message);
});
module.exports = {
news_items: news_items,
loader_opacity: loader_opacity
};
上のコードを分解すると、再びObservableライブラリーを使用していることがわかります。Fuseでは、<JavaScript>コンポーネントのそれぞれのインスタンスは自分自身のコンテキストを持っているので注意してください。つまり、Observableを前に使っていたとしても、ここでは無効だということです。前に定義したどのObservable変数についても同様です。
// js/NewsItems.js
const Observable = require("FuseJS/Observable");
ローダーの透過度に値をセットします。ユーザーがアプリを開く際に表示される読み込みアニメーションの透過度です。最初にAPIからニュース項目を読み込まなければならないため、デフォルト値は1にセットする必要があります。アプリがデータを読み込んでいる間は、ユーザーにアニメーションを表示します。あとで透過度の値を0に更新し、ユーザーから見えないようにします。
// js/NewsItems.js
var loader_opacity = Observable('1');
オブザーバブルなリストを初期化します。
// js/NewsItems.js
var news_items = Observable();
Hacker New APIのトップニュースのエンドポイントであるURLを追加します。さらに、各fetch()コールにより返されるプロミスを格納する配列を追加します。
// js/NewsItems.js
const TOP_STORIES_URL = 'https://hacker-news.firebaseio.com/v0/topstories.json';
var story_promises = [];
fetch APIを使用してトップニュースのエンドポイントへリクエストします。
// js/NewsItems.js
fetch(TOP_STORIES_URL)
.then(function(response) { return response.json(); })
.then(function(top_stories) {
})
.catch(function(error) {
console.log('There has been a problem with your fetch operation: ' + error.message);
});
これにより、Hacker NewsからトップニュースのIDを格納した配列を受け取ります。最初の10個だけが必要なので、forループを使って最初の10項目分だけ繰り返します。各繰り返しで、それぞれのニュース項目の詳細を返すエンドポイントのURLを作成します。URLを作成後、各ニュース項目を個別にリクエストし、結果をあらかじめ作成しておいたオブザーバブルなリストに追加します。また、各プロミスをstory_promises配列にプッシュします。
// js/NewsItems.js
for(var x = 0; x < = 10; x++){
const story_url = "https://hacker-news.firebaseio.com/v0/item/" + top_stories[x] + ".json";
const p = fetch(story_url)
.then(function(response) { return response.json(); })
.then(function(news) {
news_items.add({
title: news.title,
url: news.url
});
})
.catch(function(error) {
console.log('There has been a problem with your fetch operation: ' + error.message);
});
story_promises.push(p);
}
ここで、読み込みアニメーションを隠すべきタイミングが決定します。Promise.allはプロミスの配列を受け取ります。そして、すべてのプロミスが成功していればthen()に渡した関数が実行されます。透過度を0にセットしてローダーを非表示にする場所は以下のようになります。
// js/NewsItems.js
Promise.all(story_promises).then(function(){
loader_opacity.value = 0;
});
ユーザーインターフェイスで必要になる値を忘れずにエクスポートしてください。
// js/NewsItems.js
module.exports = {
news_items: news_items,
loader_opacity: loader_opacity
};
MainView.uxファイルに戻り、ステータスバーのプレースホルダーを追加します。Fuseは自動的に画面全体を占有するため、プレースホルダ―を追加しないとステータスバーが見えなくなります。
<StatusBarBackground Dock="Top" />
<stackpanel>を使用してヘッダーを追加します。Fuseでは、子要素を積み重ねたい(縦方向に)ときは</stackpanel><stackpanel>をコンテナとして使用します。ステータスバーの直下に配置されるように、Dock属性にTopをセットします。
</stackpanel><stackpanel Dock="Top" Color="#FF6600">
<text FontSize="18" Margin="0,10,0,10" Alignment="VerticalCenter" TextAlignment="Center" TextColor="#FFF" Value="{title}"></text>
</stackpanel>
<scrollview>を使用し、ニュース項目が利用可能なスペースに収まらないときに縦スクロールバーが自動的に生成されるようにします。Fuseでは、<each>コンポーネントを使用してリストを生成します。リストのそれぞれの項目について<panel>を作成します。これをクリックすると、前に定義しておいたnavigatePage関数を実行します。関数に呼びだされるコンテキストは自動的に第1引数として渡されるため、この関数に渡すものはなにもありません。上記のjs/App.jsファイル内でてcontext.data.urlの値をチェックしていた理由はここにあります。
<scrollview>
<stackpanel Alignment="Top">
<each Items="{news_items}">
<panel Clicked="{navigatePage}" Alignment="VerticalCenter">
<text Margin="10,20,10,20" TextWrapping="Wrap" FontSize="20" Value="{title}"></text>
</panel>
<rectangle Height="1" Fill="#dcdee3"></rectangle>
</each>
</stackpanel>
</scrollview>
Webページ
Webページのコンテンツに移ります。ニュース一覧ページのように、すべてを<dockpanel>にラップし<statusbarbackground>を追加します。ヘッダーは少し異なります。今回はClicked、つまり押されたときにnavigatePageボタンを実行する<text>エレメントがあります。これはユーザーインターフェイスから実行されるため、context.dataを引数として渡します。唯一の違いはurlが存在しないことで、そのためnavigatePage関数のelse条件が実行されます。つまり、ニュース一覧ページに戻ります。
<dockpanel>
<statusbarbackground Dock="Top"></statusbarbackground>
<wrappanel Dock="Top" Color="#FF6600">
<text FontSize="18" Margin="10,10,0,10" Alignment="Left" TextAlignment="Left" TextColor="#FFF" Value="Back" Width="100" Clicked="{navigatePage}"></text>
<text FontSize="18" Alignment="VerticalCenter" TextAlignment="Center" TextColor="#FFF" Value="{title}"></text>
</wrappanel>
</dockpanel>
ux:Class属性を追加し再利用可能なコンポーネントを定義します。この値が、コンポーネントを継承するコンポーネントの名前になります。
<panel ux:Class="LoadingBar" Width="5%" Height="10" Color="#fc0" Alignment="Left"></panel>
作成したばかりのコンポーネントを使用します。定義したすべてのスタイルがコンポーネントに継承されます。コンポーネントはWebページのプログレスローダーとして動作します。ux:Nameを追加しあとでWidthを変更できるようにします。
<loadingbar Dock="Top" ux:Name="_loadingBar"></loadingbar>
最後に、WebViewコンポーネントを使用してユーザーが選択したニュース項目を表示します。コンポーネントは<nativeviewhost>の内側にラップする必要があるので注意してください。コンポーネントがデバイスのネイティブWebViewを使用するためです。つまり、プレビューツールを用いたテストもできません。<progressanimation>コンポーネントを使用することにも留意してください。ページを読み込む際に読み込みバーの値を100%に変更できるようになります。ユーザーは、ページのロードが完了するまであとどれくらい待てば良いか分かります。
<panel>
<nativeviewhost>
<webview Dock="Fill" Url="{current_url}" ux:Name="webpage">
<progressanimation>
<change _loadingBar.Width="100%"></change>
</progressanimation>
</webview>
</nativeviewhost>
</panel>
同時アニメーション
これまで説明してきたように、Fuseのすばらしさはアニメーションにあります。おまけとして、2種類のアニメーションを一気に実行してみます。この項目はオプションなので飛ばして、「アプリの実行」へ進んでも構いません。
<rectangle>コンポーネントを使用してローダーボックスを作成します。rectangle(長方形)という名称に惑わされないようにしてください。WidthとHeightを同じ値にセットすれば正方形も作れます。デフォルトの縮尺(scaling)と回転(rotation)を定義します。縮尺のFactorは1にセットします。これは縮尺のデフォルト値が、定義したWidthおよびHeightと同じ値であるということです。回転のDegreesは0にセットします。これは、回転していないことです。それぞれにux:Name属性を追加し、アニメーションタイムラインで値をコントロールできるようにします。
</rectangle><rectangle Width="50" Height="50" Fill="#FA983C" Opacity="{loader_opacity}">
<scaling ux:Name="loaderScale" Factor="1"></scaling>
<rotation ux:Name="loaderRotate" Degrees="0"></rotation>
</rectangle>
次に、ローダーボックスのアニメーションタイムラインを定義します。PlayMode="Wrap"はアニメーションが無限に実行されることです。デフォルトではOnceにセットされており、アニメーションは1度だけ実行されます。縮尺と回転の値を変更するには、Changeオペレーターを使用します。そして、ローダーボックスのアニメーションプロパティに追加しておいたux:Nameを使用します。例では縮尺の係数を1.5にセットし、ボックスのサイズを元のサイズの半分だけ大きくします。さらに360度回転させます。2つのアニメーションは1秒間に同時に実行されます。Easingの指定もできます。しかし、みなさんがQuadraticInOutの動作を探求できるように、これについては触れないことにします。
<timeline PlayMode="Wrap">
<change loaderScale.Factor="1.5" Duration="1" Easing="QuadraticInOut"></change>
<change loaderRotate.Degrees="360" Duration="1" Easing="QuadraticInOut"></change>
</timeline>
アプリの実行
デバッグバージョンをコンパイルして、アプリをデバイスで実行します。
fuse build --target=Android
コンパイルが完了すると、build/Android/Debugディレクトリ内に.apkファイルができます。デバイスにコピーしインストール後、実行します。
最後に
以上です。記事では、ネイティブモバイルアプリ開発の世界を刺激するFuseという新たなツールを説明してきました。Fuseでモバイルアプリを開発するのは本当に楽しいことです。こうした楽しさは次の特徴のおかげです。
- 超高速に自動で再読み込みする機能性。デザイナーおよび開発者が生産的に作業できる
- iOSデバイスおよびAndroidデバイスの両方で動作するアプリを作成できる
- GUIを通じてデザインを容易に変更できるデザインモード
- 開発者がアプリをデバッグするのをサポートするモニターツール
- 宣言型マークアップで実行されるアニメーション。デザイナーはアニメーションをとても簡単に作成できる
Fuseは現時点ではまだベータ版なので、複雑なプロジェクトには対応できないかもしれません。しかし、今回作成したようなシンプルなアプリなら、なにも問題はありません。
(原文:Getting Started with Fuse)
[翻訳:薮田佳佑/編集:Livit]