我们的第一个App

Memos, a minimalist notepad app
在本章中,我们将着手建立一个名为Memos的简易记事本应用。在开工之前,让我们先来回顾下该应用的运行方式。
该应用具有三个界面。主界面列出记事条目。当你点击一条记事(或者添加一条记事)的时候,就会跳转到可以对相关标题和内容进行编辑的详细界面。如下图所示:

Memos, editing screen
在上示界面中,我们可以通过点击垃圾桶图标来删除这项记事。点击之后会弹出一个请求确认的对话框。

Memos, note removal confirmation screen
该记事本的源码在这里:the Memos Github Repo(或zip格式:the Memos.zip)。推荐大家下载简单快捷的zip压缩文件。在本书的code文件夹里也有备份:github repository for this book
Memos采用IndexedDB作为数据库来保存条目;采用Gaia Building Blocks来建立交互界面。再版时我会和大家更加深入细致地探讨Gaia Building Blocks,现在我们只管去用它。读者可以通过上述链接了解交互界面开发工具等各类详细内容。
首先我们为要开发的应用建立一个名为 memos的文件夹。
创建应用的manifest文件
Memos的manifest文件相当简洁。先在memos文件下创建一个名为manifest.webapp 的文件。manifest是一种用来描述应用属性的JSON格式文件。该文件通常包含应用名称,应用图标源地址,从哪个文件开始加载应用,该应用会调用哪些用户级API函数等各项信息。
以下我们可以看到Memos应用中manifest文件的详细内容。在复制这些数据的时候要注意避免在文本中添加额外的逗号导致JSON格式出错。当前有很多帮助开发者合法化JSON文件格式的工具,不过这里推荐一款专门用来生成manifest文件的在线工具。详情参见http://appmanifest.org/(译注:这个链接有问题)。关于manifest的更多内容参见this page on MDN about them。
Memos manifest file (manifest.webapp)
1 {
2 "name": "Memos",
3 "version": "1.1",
4 "description": "A simple memo taking app",
5 "launch_path": "/index.html",
6 "permissions": {
7 "storage": {
8 "description": "Required for storing and retrieving notes."
9 }
10 },
11 "developer": {
12 "name": "Andre Garzia",
13 "url": "http://andregarzia.com"
14 },
15 "icons": {
16 "60": "/style/icons/icon_60.png",
17 "128": "/style/icons/icon_128.png"
18 }
19 }
我们来看下以上manifest文件中包含了哪些字段。
| Field | Description |
|---|---|
| name | App的名字. |
| version | App的当前版本. |
| launch_path | 你的App的启动文件. |
| permissions | 你的App请求的 API 权限. 下面是详细信息. |
| developer | 谁开发了这个App |
| icons | 这个App使用的图标的不同尺寸. |
这些字段中比较重要的是授权许可字段,通过在该字段中声明对存储设备的读写权限,应用才能运用IndexedDB无限制地存储数据。[^存储限制](由于权限许可应用软件才可以随心所欲地存储-不过要注意不要过多占用设备的存储空间)。
[^存储权限]:相关权限的更多内容参见the page on MDN about app permissions。
既然已经建好manifest文件,接下来我们开始着手创建HTML文件。
创建HTML文件
创建HTML文件之前,我们简略地探讨一些关于Gaia Building Blocks的内容。Gaia Building Blocks中包含了很多可以重用到开发者自己的应用上,Firefox OS风格的交互界面代码模板。
就像网页上那样,开发者并没有被强制要求采用以上Firefox OS风格的交互界面模块。是否采用Gaia模块完全取决于开发者自身抉择。并且,一个好的应用本来就应当具有别具一格的自身特点和用户体验。还要说明一点,开发者的应用并不会因为没有采用Gaia模板而遭受偏见或惩罚。在本书中采用Gaia界面模板是因为本人UI设计不行(也没钱另聘平面设计师)。
Gaia模块中的HTML布局将应用的每一屏幕界面定义为一个<section>,其他的元素也遵循一定的默认布局格式。可以从位于github上的memos仓库下载Memos应用源文件(包含Building Blocks)。要是对git和github不熟悉的话,可以下载压缩包:memos.zip。
注意:我使用的这一Gaia模块并不是Mozilla发布的最新版本。更新到最新版本会导致Memos应用崩溃。但是在建立你自己的应用时还是应该尽量使用最新版本的Gaia模块。
将Gaia模块包含进来
先将已下载的源码中的 shared 和 style 文件夹复制到之前新建的 memos 文件夹里。这样我们就可以在开发应用时使用Gaia模块了。
然后在 index.html 文件中声明要调用的各个css文件。
1 <!DOCTYPE html>
2 <html>
3 <head>
4 <meta charset="utf-8">
5 <link rel="stylesheet" type="text/css" href="style/base.css" />
6 <link rel="stylesheet" type="text/css" href="style/ui.css" />
7 <link rel="stylesheet" type="text/css" href="style/building_blocks.css" />
8 <link rel="stylesheet" type="text/css"
9 href="shared/style/headers.css" />
10 <link rel="stylesheet" type="text/css"
11 href="shared/style_unstable/lists.css" />
12 <link rel="stylesheet" type="text/css"
13 href="shared/style_unstable/toolbars.css" />
14 <link rel="stylesheet" type="text/css"
15 href="shared/style/input_areas.css" />
16 <link rel="stylesheet" type="text/css"
17 href="shared/style/confirm.css" />
18 <title>Memos</title>
19 </head>
在第1行,我们将文件的DOCTYPE声明为HTML5。第5~15行中,我们对在应用中用作标题,列表,输入框等各种布局的css文件进行调用声明。
创建主界面
现在我们可以开始着手创建各类界面了。像前文提到的那样,应用所用的每个界面被定义为包含在HTML <body> 主体中的一个 <section> 部件。在body标签之后需要将role 的属性设置成 application ,以便CSS选择器据此创建相应界面。所以我们这样定义body标签:<body role="application">。现在让创建第一个屏幕界面并定义相关的body标签。
1 <body role="application">
2
3 <section role="region" id="memo-list">
4 <header>
5 <menu type="toolbar">
6 <a id="new-memo" href="#"><span class="icon icon-add">add</span></a>
7 </menu>
8 <h1>Memos</h1>
9 </header>
10 <article id="memoList" data-type="list"></article>
11 </section>
以上屏幕界面代码中的 <header> 部件包含了该应用的名称和一个用来添加新记事及其的按钮。这个屏幕也包含一个<article>部件用来容纳记事列表。在后面的JavaScript应用环节中我们将利用按钮和记事ID来捕获事件(capture events)。
记住,每个屏幕界面都是直接由一大段HTML代码定义布局。用其他编程语言构建同样外观的界面的过程通常更加繁杂。在HTML中,我们只需声明各类容器,并赋以对应的ID值以便引用。
主屏幕已建好,接下来我们开始构建编辑界面。
构建编辑界面
因为在用户尝试删除一条记事时需要弹出一个确认删除的对话框,所以编辑界面的代码更加复杂。
1 <section role="region" id="memo-detail" class="skin-dark hidden">
2 <header>
3 <button id="back-to-list"><span class="icon icon-back">back</span>
4 </button>
5 <menu type="toolbar">
6 <a id="share-memo" href="#"><span class="icon icon-share">edit</span>
7 </a>
8 </menu>
9 <form action="#">
10 <input id="memo-title" placeholder="Memo Title" required="required"
11 type="text">
12 <button type="reset">Remove text</button>
13 </form>
14 </header>
15 <p id="memo-area">
16 <textarea placeholder="Memo content" id="memo-content"></textarea>
17 </p>
18 <div role="toolbar">
19 <ul>
20 <li>
21 <button id="delete-memo" class="icon-delete">Delete</button>
22 </li>
23 </ul>
24 </div>
25 <form id="delete-memo-dialog" role="dialog" data-type="confirm"
26 class="hidden">
27 <section>
28 <h1>Confirmation</h1>
29 <p>Are you sure you want to delete this memo?</p>
30 </section>
31 <menu>
32 <button id="cancel-delete-action">Cancel</button>
33 <button id="confirm-delete-action" class="danger">Delete</button>
34 </menu>
35 </form>
36 </section>
在编辑界面的顶端,即代码中的<header>部件中包含了:
* 一个可以返回主界面的按钮,
* 一个用来包含记事标题的文本框
* 一个可以通过邮件分享记事的按钮
在顶部菜单栏下方,我们用一个<textarea>部件来容纳记事主内容,用一个带垃圾桶图标的菜单栏来删除当前记事。
编辑界面由以上三个部件以及它们的子节点一起组成。此外我们采用一个<form>部件作为用户试图删除记事时的交互部件。这个简单的提示框仅包含提示文本和两个按钮:一个确认,一个删除。
完成这个<section>后,我们已经实现了所有的屏幕界面。剩下的任务就是声明JavaScript文件的引用位置,结尾添加</html>呼应前文。
1 <script src="/js/model.js"></script>
2 <script src="/js/app.js"></script>
3 </body>
4 </html>
编写Javascript脚本
现在我们开始着手编写可以使应用变得更加生动的Javascript脚本。我们一共创建了两个脚本文件,以便管理和组织代码。
- model.js:包含存储路径和记事检索路径,但是不含任何应用运行逻辑,用户界面,数据项等信息。理论上,我们可以在其他需要记事功能的应用中重用这个文件。
- app.js: 将HTML元素和其对应的事件句柄联系起来,并且包含应用的运行逻辑。
以上两个文件都应该放在一个名为js的文件夹中,该文件夹位于style和shared文件夹之后。
model.js
我们将采用IndexedDB存储记事条目。由于事先在应用的manifest文件中对存储权限进行声明,所以存储条目不会受到限制。但是,我们不能因此而滥用权限。Firefox OS设备的存储空间通常有限。所以开发者需要时刻注意你的应用存储了什么数据(如果你的应用占用了设备过多存储空间的话,用户就会卸载应用并给你的应用差评!)。并且存储过量数据的话会对应用的流畅运行产生影响,使应用在运行时有卡顿的感觉。注意,当你向Firefox OS应用市场提交应用,审查者会问你为什么要声明无限大的存储空间 – 如果你不能做出合理解释的话,你的应用将不能通过审核。
以下的这段js代码主要用来打开链接和创建存储空间。
注意:这部分代码在编写方面以通俗易懂为主,没有体现出JS脚本在性能方面的特点。一些全局变量在各种代码段中都有使用(我要开始抱怨了啊)。但是这些。本书的主要目的是为了教授开发Firefox OS应用的主要流程,而不是JS脚本的最佳结构。此外,我会根据读者的后续反馈,在不影响初学者理解的前提下优化代码。
1 var dbName = "memos";
2 var dbVersion = 1;
3
4 var db;
5 var request = indexedDB.open(dbName, dbVersion);
6
7 request.onerror = function (event) {
8 console.error("Can't open indexedDB!!!", event);
9 };
10 request.onsuccess = function (event) {
11 console.log("Database opened ok");
12 db = event.target.result;
13 };
14
15 request.onupgradeneeded = function (event) {
16
17 console.log("Running onUpgradeNeeded");
18
19 db = event.target.result;
20
21 if (!db.objectStoreNames.contains("memos")) {
22
23 console.log("Creating objectStore for memos");
24
25 var objectStore = db.createObjectStore("memos", {
26 keyPath: "id",
27 autoIncrement: true
28 });
29 objectStore.createIndex("title", "title", {
30 unique: false
31 });
32
33 console.log("Adding sample memo");
34 var sampleMemo1 = new Memo();
35 sampleMemo1.title = "Welcome Memo";
36 sampleMemo1.content = "This is a note taking app. Use the plus sign " +
37 "in the topleft corner of the main screen to " +
38 "add a new memo. Click a memo to edit it. All " +
39 "your changes are automatically saved.";
40
41 objectStore.add(sampleMemo1);
42 }
43 }
注:源码中的全局变量只是作为教学之用。原谅我在书本中为了节约排版空间而删除了全局变量的相关注释。Github上的源码中任然包含所有注释。以上代码创建了一个db对象和一个request对象。db对象会被源码中的其他方法调用以管理记事的存储。
在调用request.onupgradeneeded方法的同时应用会创建一个新的类似hello worid的记事。该方法会在第一次运行应用(或数据库更新版本)的时候调用。即当应用被首次加载时,数据会初始化并保存一条包含“欢迎使用”内容的记事。
在打开链接和初始化数据库之后,就可以开始编写管理记事内容的相关方法了。
1 function Memo() {
2 this.title = "Untitled Memo";
3 this.content = "";
4 this.created = Date.now();
5 this.modified = Date.now();
6 }
7
8 function listAllMemoTitles(inCallback) {
9 var objectStore = db.transaction("memos").objectStore("memos");
10 console.log("Listing memos...");
11
12 objectStore.openCursor().onsuccess = function (event) {
13 var cursor = event.target.result;
14 if (cursor) {
15 console.log("Found memo #" + cursor.value.id +
16 " - " + cursor.value.title);
17 inCallback(null, cursor.value);
18 cursor.continue();
19 }
20 };
21 }
22
23 function saveMemo(inMemo, inCallback) {
24 var transaction = db.transaction(["memos"], "readwrite");
25 console.log("Saving memo");
26
27 transaction.oncomplete = function (event) {
28 console.log("All done");
29 };
30
31 transaction.onerror = function (event) {
32 console.error("Error saving memo:", event);
33 inCallback({
34 error: event
35 }, null);
36
37 };
38
39 var objectStore = transaction.objectStore("memos");
40
41 inMemo.modified = Date.now();
42
43 var request = objectStore.put(inMemo);
44 request.onsuccess = function (event) {
45 console.log("Memo saved with id: " + request.result);
46 inCallback(null, request.result);
47
48 };
49 }
50
51 function deleteMemo(inId, inCallback) {
52 console.log("Deleting memo...");
53 var request = db.transaction(["memos"],
54 "readwrite").objectStore("memos").delete(inId);
55
56 request.onsuccess = function (event) {
57 console.log("Memo deleted!");
58 inCallback();
59 };
60 }
在以上代码中我们创建了一个构造器,在必须的字段已经初始化的情况下就可以创建新的应用实例。之后我们调用相关方法来列出记事列表,存储和删除记事条目。其中的部分函数接受一个称为inCallback的回调参数,这个参数会在方法运行完成后返回。之所以采用这种机制主要由IndexedDB的异步同步机制决定。所有的回调函数具备类似callback(error, value)的结构。有些方法的回调参数value值为零。
注:由于本书面向初学者,我不主张调用涉及Promises的API函数。推荐使用那些容易实现相同效果且易于阅读的方法。
既然条目存储和方法调用已经完备,接下来就可以将app.js中的运行逻辑应用到app中。
app.js
该文件包含应用的逻辑。在本书中由于源码冗杂限于篇幅以致不可能全部照搬。我会将其分为几个部分然后分别讲解。
1 var listView, detailView, currentMemo, deleteMemoDialog;
2
3 function showMemoDetail(inMemo) {
4 currentMemo = inMemo;
5 displayMemo();
6 listView.classList.add("hidden");
7 detailView.classList.remove("hidden");
8 }
9
10
11 function displayMemo() {
12 document.getElementById("memo-title").value = currentMemo.title;
13 document.getElementById("memo-content").value = currentMemo.content;
14 }
15
16 function shareMemo() {
17 var shareActivity = new MozActivity({
18 name: "new",
19 data: {
20 type: "mail",
21 body: currentMemo.content,
22 url: "mailto:?body=" + encodeURIComponent(currentMemo.content) +
23 "&subject=" + encodeURIComponent(currentMemo.title)
24
25 }
26 });
27 shareActivity.onerror = function (e) {
28 console.log("can't share memo", e);
29 };
30 }
31
32 function textChanged(e) {
33 currentMemo.title = document.getElementById("memo-title").value;
34 currentMemo.content = document.getElementById("memo-content").value;
35 saveMemo(currentMemo, function (err, succ) {
36 console.log("save memo callback ", err, succ);
37 if (!err) {
38 currentMemo.id = succ;
39 }
40 });
41 }
42
43 function newMemo() {
44 var theMemo = new Memo();
45 showMemoDetail(theMemo);
46 }
在程序开头,我们先声明几个全局变量(哇!!!)来存储后面会用到的指向DOM元素的指针(reference)。其中currentMemo全局变量存储用户当前阅读的记事对象。
showMemoDetail() 和 displayMemo()两个方法一起协同工作。前者将选中的记事加载到后一个方法中,并控制元素的CSS布局生成一个可编辑的记事界面。后者加载记事内容并将其显示在屏幕上。我们可以用一个方法同时实现这两个功能,但是分开的话在调用时更易于experiment。
shareMemo()方法调用调用WebActivity函数来打另一个邮件应用,并将当前的记事内容预先填写到邮件正文中。
textChanged()方法会将所有字段中的数据保存到currentMemo对象中然后保存当前记事。这样的话当记事中的内容发生变化的时候,该方法就会自动将变更的内容同步到IndexedDB数据库中。
newMemo()方法会创建一个新记事并打开一个相关的编辑界面。
1 function requestDeleteConfirmation() {
2 deleteMemoDialog.classList.remove("hidden");
3 }
4
5 function closeDeleteMemoDialog() {
6 deleteMemoDialog.classList.add("hidden");
7 }
8
9 function deleteCurrentMemo() {
10 closeDeleteMemoDialog();
11 deleteMemo(currentMemo.id, function (err, succ) {
12 console.log("callback from delete", err, succ);
13 if (!err) {
14 showMemoList();
15 }
16 });
17 }
18
19 function showMemoList() {
20 currentMemo = null;
21 refreshMemoList();
22 listView.classList.remove("hidden");
23 detailView.classList.add("hidden");
24 }
`requestDeleteConfirmation()方法负责弹出确认删除对话框。
closeDeleteMemoDialog() 和 deleteCurrentMemo()方法会在用户确认删除时触发。
showMemoList()方法在显示记事列表前将先执行一次清除。比如,会将currentMemo方法中的内容清除掉,因为在显示记事列表时我们还没有读取任何具体记事条目。
1 function refreshMemoList() {
2 if (!db) {
3 // HACK:
4 // this condition may happen upon first time use when the
5 // indexDB storage is under creation and refreshMemoList()
6 // is called. Simply waiting for a bit longer before trying again
7 // will make it work.
8 console.warn("Database is not ready yet");
9 setTimeout(refreshMemoList, 1000);
10 return;
11 }
12 console.log("Refreshing memo list");
13
14 var memoListContainer = document.getElementById("memoList");
15
16
17 while (memoListContainer.hasChildNodes()) {
18 memoListContainer.removeChild(memoListContainer.lastChild);
19 }
20
21 var memoList = document.createElement("ul");
22 memoListContainer.appendChild(memoList);
23
24 listAllMemoTitles(function (err, value) {
25 var memoItem = document.createElement("li");
26 var memoP = document.createElement("p");
27 var memoTitle = document.createTextNode(value.title);
28
29 memoItem.addEventListener("click", function (e) {
30 console.log("clicked memo #" + value.id);
31 showMemoDetail(value);
32
33 });
34
35 memoP.appendChild(memoTitle);
36 memoItem.appendChild(memoP);
37 memoList.appendChild(memoItem);
38
39
40 });
41 }
refreshMemoList()方法通过逐条建立记事列表来调整DOM。你可以使用诸如handlebars 或者 underscore之类的模板来简化编写过程。但是本文中的应用仅仅使用vanilla javascript手工编写完成。 该功能通过上文中的showMemoList()方法调用。
以上就是应用中用到的所有函数方法。现在唯独缺少事件句柄的初始化和refreshMemoList()方法的初始调用代码。
1 window.onload = function () {
2 // elements that we're going to reuse in the code
3 listView = document.getElementById("memo-list");
4 detailView = document.getElementById("memo-detail");
5 deleteMemoDialog = document.getElementById("delete-memo-dialog");
6
7 // All the listeners for the interface buttons and for the input changes
8 document.getElementById("back-to-list")
9 .addEventListener("click", showMemoList);
10 document.getElementById("new-memo")
11 .addEventListener("click", newMemo);
12 document.getElementById("share-memo")
13 .addEventListener("click", shareMemo);
14 document.getElementById("delete-memo")
15 .addEventListener("click", requestDeleteConfirmation);
16 document.getElementById("confirm-delete-action")
17 .addEventListener("click", deleteCurrentMemo);
18 document.getElementById("cancel-delete-action")
19 .addEventListener("click", closeDeleteMemoDialog);
20 document.getElementById("memo-content")
21 .addEventListener("input", textChanged);
22 document.getElementById("memo-title")
23 .addEventListener("input", textChanged);
24
25 // the entry point for the app is the following command
26 refreshMemoList();
27
28 };
所有代码准备好后,我们就可以开始在模拟器中测试应用。我们可以分别通过App Manager或者较旧的Firefox OS 1.1 simulator两种方法进行测试。接下来分别详细讲解这两种技术。
如果你的火狐浏览器版本在29及以上,那么你可以安装 App Manager插件。其他较低版本浏览器可以安装旧版本的模拟器。需要注意的是,App Manager只能连接Firefox OS 1.2以上版本的设备。
如果你有一个Firefox OS 1.1版本的设备,然后你的浏览版本是29,那么你可以根据需要安装对应平台的Firefox OS 1.1 simulator version 5.0 Mac OS X, Linux or Windows。安装完成后你就可以按照指导说明连接上你的设备。
如果你的设备系统是Firefox OS 1.1版本并且是无锁的,那么你还可以选择更新到1.2或更高的系统版本。选择更新会使你之后的工作变得更加顺手。 Geeksphone Keon,Geeksphone Peak和Geeksphone Revolution上面每天都会推出Firefox OS的改进版本,参见http://downloads.geeksphone.com/。 我国的ZTE Open详细更新步骤参见Upgrading your ZTE Open to Firefox 1.1 or 1.2 (fastboot enabled)。LG Fireweb暂时不能更新。如果你们觉得我人不错,然后又觉得LG不能更新很讨厌的话,那么快去他们的官方Twitter上提建议。Alcatel One Touch Fire可以更新,但是其详细步骤已经超出了本书的讨论范畴。
注:bugzilla上的这个bug request #1001590补丁可以修正当前Firefox 29不能运行Firefox OS 1.1模拟器的问题。
用App Manager测试应用
在测试之前,我们最好事先确认下所有的文件都在正确的位置上。你的memos应用的文件布局应当如下图所示:

List of files used by Memos
如果出现不同的文件层次的话,那么你肯定在某些地方写错了。出错的话请将你的源码和这份源码进行比对the memos github repository 。本书附录book repository中的code文件夹内也包含了一份相同的源码。
打开Simulator Dashboard,依次选择Tools -> Web Developer -> App Manager

Where you can find the App Manager
打开App Manager后,点击Apps tab中的Add Packaged App选项,然后浏览并选中你的应用所在的文件夹目录。

Adding a new app
一切顺利的话接下来你就可以在应用列表中看到你的应用。

Memos showing on the App Manager
添加完应用之后,点击Start Simulator按钮并启动一个已安装好的设备模拟器。如果你还没有安装任何模拟器的话,建议你按照屏幕上的说明步骤把它们全部安装上去。
当模拟器开始运行时,在App Manager的memos应用详细界面中点击Update按钮来将memos应用安装到模拟器中。安装完成后memos应用的图标就会出现在模拟器的主屏幕上。可以通过点击图标来运行该应用。

Memos installed on the Simulator
干得漂亮!现在你已经创建并测试完成了你的第一个应用。一个没有新意的简单应用–但是我希望通这个示例让你对Firefox OS应用的创建流程有更加深刻的了解。如你所见,这和基本的Web开发没有很大的区别。
注意,更新应用源文件中任何内容之后,都需要通过点击Update按钮来更新模拟器中存储的对应文件。
在模拟器上测试应用
在测试之前,我们最好事先确认下所有的文件都在正确的位置上。你的memos应用的文件布局应当如下图所示:

List of files used by Memos
如果出现不同的文件层次结构缩进的话那么你肯定在某些地方写错了。出错的话请将你的源码和这份源码进行比对the memos github repository 。本书附录book repository中的code文件夹内也包含了一份相同的源码。
打开Simulator Dashboard,依次选择Tools -> Web Developer -> Firefox OS Simulator。

How to open simulator dashboard
点击Add Directory按钮,然后浏览并选中你的应用所在的文件夹目录。

Adding a new app
一切顺利的话接下来你就可以在应用列表中看到你的应用

Memos showing on the dashboard
当你添加一个新的应用时,模拟器会加载并运行你的新应用。接下来你就可以测试应用的各项功能。
干得漂亮!现在你已经创建并测试完成了你的第一个应用。一个没有新意的简单应用–但是我希望通这个示例让你对Firefox OS应用的创建流程有更加深刻的了解。如你所见,这和基本的Web开发没有很大的区别。
注意,更新应用源文件中任何内容之后,都需要通过点击Refresh按钮来更新模拟器中存储的对应文件。
总结
在本章中我们建立了第一个自己的Firefox OS应用并在模拟器上测试运行。下一章我们将涉略开发者工具,那些套件将在开发中助你一臂之力。