logo_small

Chrome packaged apps

Codelab at DevFest 2013 Season

介绍

欢迎来到 chrome 打包应用 的开发练习!这是一个自学课程(self-paced codelab),在这里你将完成 chrome 打包应用 开发的基本练习并学习 API 中的一部分。 你可以在 官方文档 中找到本教程中大部分概念的详细描述。之后的每一个练习环节都会包含该练习所用到的 API 文档链接。

在第一章节中你将学习开发 chrome 打包应用 所需的基本构建部分,以及如何运行和调试你的 chrome 打包应用。

第二章节中我们引入一个众所周知的开源 web 应用,ToDoMVC,通过移除一些不支持的特性(比如 localStorage)并遵从 CSP(Content Security Policy - 内容安全政策),来学习如何让该 web 应用成为 chrome 应用。

之后的第三章节,我们会使用 chrome 应用独有的 API 为 ToDoMVC 添加一些功能。

最后,你能够开发出一个具有离线能力、可安装的并功能完善的 ToDoMVC 应用。通过整个学习过程,你会学到:

  • chrome 应用(chrome platform)的基础构件以及开发流程
  • 如何将已有的 web 应用打包成可在 chrome 平台上运行的应用
  • chrome.storage.local storage,一个异步的本地存储
  • 提醒和通知的 API
  • 如何通过 webview 标签来显示外部的、无限制的网页内容
  • 如何从外部来源加载资源文件(如图片)
  • 使用扩展的文件系统 API 向本地的文件系统写入文件

准备工作

本课程假定你已具备 web 编程的基础知识。你应该已经知道基本 HTML 和 CSS,并且你应该熟悉 javascript。

你应该安装了 Chrome Dev。在 chrome 的地址栏中输入 chrome://version 来查看你的 “Google Chrome” 浏览器的版本为 28 或者更高。如果不是,请根据此链接的引导操作:https://www.google.com/intl/en/chrome/browser/index.html?extra=devchannel#eula

PIXEL 用户:如果你想使用你那闪亮的 Pixel Chromebook(你应该使用它!),请查看第 1 步中的指导。(just follow the instructions at the beginning of Step 1.)

如果你真的很想使用 Chrome 27,请注意第 3 步中的通知是无法使用的。

如何使用本资料

新建一个文件夹。每一步的练习都建立在之前的练习成果之上。每个练习都有一个参考时间,如果你的练习时间超过了参考时间并想进入下一阶段的练习,每一步都会有 “cheat” 链接,通过这个链接你可以获取从上一步相关代码。(from the previous step.)

下载所有的练习代码:http://goo.gl/KJIihd
安装并运行本课程的应用: http://goo.gl/9zCZQM

第 1 步 - 创建并运行一个最小的应用

目标:掌握开发流程
完成本练习的建议时间:10 分钟

除了你的应用本身所需要的代码,一个 chrome 应用还需要两个文件:

  • 一个 manifest,用来指定你的应用的元信息(meta information)
  • 一个 event page,该文件也叫 background page,通常在此文件中注册指定事件的监听器,如打开应用窗口的启动监听器

闪亮的 chromebook 使用指导部分,这部分翻译就交给有 chromebook 的高富帅吧。

打开你最喜爱的 代码/文本 编辑器并在一个空的目录下创建以下文件:

manifest.json:

  1. {
  2. "manifest_version": 2,
  3. "name": "I/O Codelab",
  4. "version": "1",
  5. "permissions": [],
  6. "app": {
  7. "background": {
  8. "scripts": ["background.js"]
  9. }
  10. },
  11. "minimum_chrome_version": "28"
  12. }

background.js:

  1. chrome.app.runtime.onLaunched.addListener(function() {
  2. chrome.app.window.create('index.html', {
  3. id: 'main',
  4. bounds: { width: 620, height: 500 }
  5. });
  6. });

index.html:

  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <meta charset="utf-8">
  5. </head>
  6. <body>
  7. <h1>Hello, let's code!</h1>
  8. </body>
  9. </html>

恭喜,你刚刚已经创建了一个新的 chrome 应用。如果你复制了 cheats 里的代码,确保你也复制了 icon_128.png,因为在 manifest.json 中关联了这个文件。

现在你可以运行它了:
step_1_1

如何调试 Chrome 应用

如果你熟悉 Chrome 开发者工具,你就知道你可以使用开发者工具来查看、调试、审计以及测试你的应用,就像你在普通的 web 页面上所做的一样。

通常的开发方式是:

  • 写一段代码
  • 重新加载应用(右击鼠标,重新加载应用)
  • 测试
  • 查看开发者工具控制台中是否有错误
  • 重复以上部分
    step_1_2

开发者工具的控制台具有与你的应用相同的权限来使用你应用中的 API。这样,你就可以在加入一个 API 到你的代码中之前方便的测试它:
step_1_3

第 2 步 - 引入 ToDoMVC 应用并使之适应 Chrome(Import and adapt ToDoMVC app)

想从这一步重新开始?可以在 solution_for_step1 子目录下找到之前练习的代码!

目标:

  • 学习如何将一个现有的应用适用于 Chrome 应用平台
  • 学习 chrome.storage.local API
  • 为下一个练习做准备
    完成本练习的建议时间:10 分钟

我们引入一个已有的常见 web 应用到我们的项目,作为练习的开始,我们会使用一个非常主流(benchmark)的应用,ToDoMVC:

  1. 我们会引入 ToDoMVC 应用的一个版本(a version of the ToDoMVC app)(相关代码的压缩包)。请复制所有的内容,包括其中的子目录,从压缩包里的 todomvc 文件夹到你的应用所在的文件夹。
    完成后你的应用所在的文件夹里应该有一下文件结构:

    1. manifest.json(来自第 1 步)
    2. background.js(来自第 1 步)
    3. icon_128.png(可选,来自第 1 步)
    4. index.html(来自 todomvc
    5. bower.json(来自 todomvc
    6. bower_components/(来自 todomvc
    7. js/(来自 todomvc
  2. 现在重新加载你的应用。你应该可以看到基本的界面,但没有什么东西可以工作(but nothing else will work)。如果你打开控制台(右击,审查元素,控制台),你会发现这样的错误:

    Refused to execute inline script because it violates the following Content Security Policy directive: “default-src ‘self’ chrome-extension-resource:”. Note that ‘script-src’ was not explicitly set, so ‘default-src’ is used as a fallback. index.html:42

遵从 CSP

  1. 让我们通过让应用 遵从 CSP 来修复这个错误。造成不符合 CSP 的一个常见原因是内联的 Javascript,比如 DOM 属性上的事件处理(<button onclick=''>)以及 HTML 中的 <script> 标签。解决此问题的方法很简单:只要把那些内联的内容移动到新的文件中:

    a. 编辑 index.html 并把内联的 Javascript 移动到一个新文件 js/bootstrap.js:

    1. <!--
    2. <script>
    3. // Bootstrap app data
    4. window.app = {};
    5. </script>
    6. -->
    7. <script src="js/bootstrap.js"></script>

    b. 创建 js/bootstrap.js,内容为:

    1. // Bootstrap app data
    2. window.app = {};
  2. 重新加载你的应用,是不是仍然报错?只是之前的错误已经不见了,但有了另一个错误:

    Uncaught window.localStorage is not available in packaged apps. Use chrome.storage.local instead. platformApp:16

  3. 这个错误需要更多的步骤来修复

把 localStorage 变为 chrome.storage.local

Chrome 应用不支持 LocalStorage。为什么呢?因为 LocalStorage 是同步的,而同步获取块资源(I/O)的方式在单线程的运行环境中通常不是一个好主意。如果你的存储延迟很高,那你的应用可能变得毫无响应。

Chrome 应用拥有一个等价的异步存储来直接存放对象,避免了某些时候 对象 -> 字符串 -> 对象 的序列化过程所造成的代价。

为了修复刚才的问题,我们需要将 localStorage 转换为 chrome.storage.local。这需要许多步骤,不过这些步骤对于 API 调用和修复 ToDoMVC 异步支持的改动都很小(but they are all small changes to the API calls or fixes on the ToDoMVC async support)。

注意:修改 ToDoMVC 中所有用到 localStorage 的地方是很耗时的而且容易出错,尽管这对于你的学习很必要。为了最大化你学习的乐趣,我们非常建议你:

  • a. 看一下下方代码的改动。确认你是否能够理解它们,如果有问题请询问助教。
  • b. 复制 cheat_code/solution_for_step_2 下的文件并进入下一步练习。我们已经搞定了下面的步骤,以便你继续学习。( We’ve kept all the steps below for learning purposes)
  1. manifest.json 中,加入 “storage” 权限:

    1. ···
    2. "permissions": ["storage"],
    3. ···
  2. store.js 中,修复构造器:

    1. function Store(name, callback) {
    2. var data;
    3. var dbName;
    4. callback = callback || function() {};
    5. dbName = this._dbName = name;
    6. /* if (!localStorage[dbName]) {
    7. data = {
    8. todos: []
    9. };
    10. localStorage[dbName] = JSON.stringify(data);
    11. }
    12. callback.call(this, JSON.parse(localStorage[dbName])); */
    13. chrome.storage.local.get(dbName, function(storage) {
    14. if ( dbName in storage ) {
    15. callback.call(this, storage[dbName].todos);
    16. } else {
    17. storage = {};
    18. storage[dbName] = { todos: [] };
    19. chrome.storage.local.set( storage, function() {
    20. callback.call(this, storage[dbName].todos);
    21. }.bind(this));
    22. }
    23. }.bind(this));
    24. }
  3. 修复 find 方法:

    1. Store.prototype.find = function (query, callback) {
    2. if (!callback) {
    3. return;
    4. }
    5. /* var todos = JSON.parse(localStorage[this._dbName]).todos;
    6. callback.call(this, todos.filter(function (todo) {
    7. for (var q in query) {
    8. return query[q] === todo[q];
    9. }
    10. })); */
    11. chrome.storage.local.get(this._dbName, function(storage) {
    12. var todos = storage[this._dbName].todos.filter(
    13. function (todo) {
    14. for (var q in query) {
    15. return query[q] === todo[q];
    16. }
    17. });
    18. callback.call(this, todos);
    19. }.bind(this));
    20. };
  4. 修复 findAll 方法:

    1. Store.prototype.findAll = function (callback) {
    2. callback = callback || function () {};
    3. /* callback.call(this, JSON.parse(localStorage[this._dbName]).todos); */
    4. chrome.storage.local.get(this._dbName, function(storage) {
    5. callback.call(this, storage[this._dbName].todos);
    6. }.bind(this));
    7. };
  5. save 方法提出了新的挑战:因为它依赖了两个异步操作(get 和 set),这两个操作每次都会操作整个 JSON 存储,在对一个以上的 ToDos 进行任何一种批量操作都会造成称之为 Read-After-Write 的数据冒险(数据冲突)。有一些办法可以解决这个问题,但是我们会利用之后的机会来稍稍重构它(代码),通过使用一个含有 ToDo id 的数组来进行单次更新。请注意如果我们使用像索引数据库这样的更加合适的数据存储,就不会发生这样的问题了。不过我们正在努力减少转换工作(minimize the conversion effort)(TODO: 这段语句不通):

    1. Store.prototype.save = function (id, updateData, callback) {
    2. chrome.storage.local.get(this._dbName, function(storage) {
    3. /* var data = JSON.parse(localStorage[this._dbName]); */
    4. var data = storage[this._dbName];
    5. var todos = data.todos;
    6. callback = callback || function () {};
    7. // If an ID was actually given, find the item and update each property
    8. if ( typeof id !== 'object' || Array.isArray(id) ) {
    9. var ids = [].concat( id );
    10. ids.forEach(function(id) {
    11. for (var i = 0; i < todos.length; i++) {
    12. if (todos[i].id == id) {
    13. for (var x in updateData) {
    14. todos[i][x] = updateData[x];
    15. }
    16. }
    17. }
    18. });
    19. /* localStorage[this._dbName] = JSON.stringify(data);
    20. callback.call(this, JSON.parse(localStorage[this._dbName]).todos); */
    21. chrome.storage.local.set(storage, function() {
    22. chrome.storage.local.get(this._dbName, function(storage) {
    23. callback.call(this, storage[this._dbName].todos);
    24. }.bind(this));
    25. }.bind(this));
    26. } else {
    27. callback = updateData;
    28. updateData = id;
    29. // Generate an ID
    30. updateData.id = new Date().getTime();
    31. todos.push(updateData);
    32. /* localStorage[this._dbName] = JSON.stringify(data);
    33. callback.call(this, [updateData]); */
    34. chrome.storage.local.set(storage, function() {
    35. callback.call(this, [updateData]);
    36. }.bind(this));
    37. }
    38. }.bind(this));
    39. };
  6. 我们也需要改写客户端的 save 方法,使它能够在一次调用中包含所有的 ID。(it can include all IDs in one call)

    a. controller.js 中 toggleComplete 的 update 方法:

    1. Controller.prototype.toggleComplete = function (ids, checkbox, silent) {
    2. var completed = checkbox.checked ? 1 : 0;
    3. this.model.update(ids, { completed: completed }, function () {
    4. if ( ids.constructor != Array ) {
    5. ids = [ ids ];
    6. }
    7. ids.forEach( function(id) {
    8. var listItem = $$('[data-id="' + id + '"]');
    9. if (!listItem) {
    10. return;
    11. }
    12. listItem.className = completed ? 'completed' : '';
    13. // In case it was toggled from an event and not by clicking the checkbox
    14. listItem.querySelector('input').checked = completed;
    15. });
    16. if (!silent) {
    17. this._filter();
    18. }
    19. }.bind(this));
    20. };

    b. controller.js 中 toggleAll 的 update 方法:

    1. Controller.prototype.toggleAll = function (e) {
    2. var completed = e.target.checked ? 1 : 0;
    3. var query = 0;
    4. if (completed === 0) {
    5. query = 1;
    6. }
    7. this.model.read({ completed: query }, function (data) {
    8. var ids = [];
    9. data.forEach(function (item) {
    10. ids.push(item.id);
    11. /* this.toggleComplete(item.id, e.target, true); */
    12. }.bind(this));
    13. this.toggleComplete(ids, e.target, false);
    14. }.bind(this));
    15. this._filter();
    16. };
  7. 现在让我们来修复 ToDoMVC 代码中的两个小 bug,当使用异步存储时它们就会出现:

    c. 在 controller.js 中的 removeItem 方法,把调用 _filter 的语句移动到回调函数里:

    1. Controller.prototype.removeItem = function (id) {
    2. this.model.remove(id, function () {
    3. this.$todoList.removeChild($$('[data-id="' + id + '"]'));
    4. this._filter();
    5. }.bind(this));
    6. /* this._filter(); */
    7. };

    d. 还是在 controller.js 中,使 _updateCount 变为异步执行:

    1. Controller.prototype._updateCount = function () {
    2. /* var todos = this.model.getCount(); */
    3. this.model.getCount(function(todos) {
    4. this.$todoItemCounter.innerHTML = this.view.itemCounter(todos.active);
    5. this.$clearCompleted.innerHTML = this.view.clearCompletedButton(todos.completed);
    6. this.$clearCompleted.style.display = todos.completed > 0 ? 'block' : 'none';
    7. this.$toggleAll.checked = todos.completed === todos.total;
    8. this._toggleFrame(todos);
    9. }.bind(this));
    10. };

    e. 现在 model.js 中对应的 getCount 方法需要接受回调函数:

    1. Model.prototype.getCount = function (callback) {
    2. var todos = {
    3. active: 0,
    4. completed: 0,
    5. total: 0
    6. };
    7. this.storage.findAll(function (data) {
    8. data.each(function (todo) {
    9. if (todo.completed === 1) {
    10. todos.completed++;
    11. } else {
    12. todos.active++;
    13. }
    14. todos.total++;
    15. });
    16. if (callback) callback(todos);
    17. });
    18. /* return todos; */
    19. };
  8. 我们快要完成了。如果现在重新加载应用,你能够插入新的 Todo 了,但是你还不能移除它们。现在让我们来修复 store.js 中的 remove 方法。同样,之前在 save 方法中提到的数据冒险(数据冲突)问题也存在于这里,因此我们需要改写 remove 方法使其允许批量操作:

    f. 修复 store.js 中的 remove 方法:

    1. Store.prototype.remove = function (id, callback) {
    2. chrome.storage.local.get(this._dbName, function(storage) {
    3. /* var data = JSON.parse(localStorage[this._dbName]); */
    4. var data = storage[this._dbName];
    5. var todos = data.todos;
    6. var ids = [].concat(id);
    7. ids.forEach( function(id) {
    8. for (var i = 0; i < todos.length; i++) {
    9. if (todos[i].id == id) {
    10. todos.splice(i, 1);
    11. break;
    12. }
    13. }
    14. });
    15. /* localStorage[this._dbName] = JSON.stringify(data);
    16. callback.call(this, JSON.parse(localStorage[this._dbName]).todos); */
    17. chrome.storage.local.set(storage, function() {
    18. callback.call(this, todos);
    19. }.bind(this));
    20. }.bind(this));
    21. };

    g. 现在改写 controller.js 中的 removeCompletedItems 使其对所有的 id 调用一次 removeItem:

    1. Controller.prototype.removeCompletedItems = function () {
    2. this.model.read({ completed: 1 }, function (data) {
    3. var ids = [];
    4. data.forEach(function (item) {
    5. ids.push(item.id);
    6. /* this.removeItem(item.id); */
    7. }.bind(this));
    8. this.removeItem(ids);
    9. }.bind(this));
    10. this._filter();
    11. };

    h. 最后改写 controller.js 中的 removeItem 以支持一次性从 DOM 移除多个条目:

    1. Controller.prototype.removeItem = function (id) {
    2. this.model.remove(id, function () {
    3. var ids = [].concat(id);
    4. ids.forEach( function(id) {
    5. this.$todoList.removeChild($$('[data-id="' + id + '"]'));
    6. }.bind(this));
    7. this._filter();
    8. }.bind(this));
    9. };
  9. 完成了!重新加载你的应用(右击,重新加载应用),好好享受吧!

其实 store.js 中还有另一个方法使用了 localStorage:drop。但因为整个项目都没有用到它,所以我们决定让你之后修复它来作为练习。

现在你应该拥有了如下面截图一样的一个酷炫的 Chrome 打包版 ToDoMVC:
step_2_1

遇到问题了?

记住保持开发者工具的控制台处于打开状态,观察 Javascript 的错误信息:

  • 打开开发者工具(应用窗口内右击,点击查看元素)
  • 选择 Console 标签
  • 你可以在其中看到运行时的错误信息

第 3 步 - 提醒和通知:提醒你打开 To Do’s

想从这一步重新开始?可以在 solution_for_step2 子目录下找到之前练习的代码!

目标:

  • 学习如何用提醒功能指定一个间隔时间来唤醒你的应用
  • 学习如何使用通知功能来提示用户一些重要的信息(something important)
    完成本练习的建议时间:20 分钟

现在我们要改写这个应用,让它能够在需要你打开 To Do’s 的时候提醒你,即使在应用关闭的时候。应用将会使用 Alarm API 来设置一个唤醒应用的间隔时间,只要 Chrome 一直在运行,提醒监听器就会在接近于所设置的间隔时间被调用。

Part 1 - 提醒

  1. manifest.json 文件中,加入 “alarms” 权限:

    1. ···
    2. "permissions": ["storage", "alarms"],
    3. ···
  2. background.js 中,加入 onAlarm 监听器,目前,每当 storage 中有一个 To Do 的项目时向控制台发送一个 log 消息即可:

    1. ···
    2. chrome.app.runtime.onLaunched.addListener(function() {
    3. chrome.app.window.create('index.html', {
    4. id: 'main',
    5. bounds: { width: 620, height: 500 }
    6. });
    7. });
    8. chrome.alarms.onAlarm.addListener(function( alarm ) {
    9. console.log("Got an alarm!", alarm);
    10. });
  3. index.html 中,添加一个 “Activate alarm” 按钮并引入我们之后要创建的脚本:

    1. ···
    2. <footer id="info">
    3. <button id="toggleAlarm">Activate alarm</button>
    4. <p>Double-click to edit a todo</p>
    5. ···
    6. <script src="js/store.js"></script>
    7. <script src="js/model.js"></script>
    8. <script src="js/view.js"></script>
    9. <script src="js/controller.js"></script>
    10. <script src="js/app.js"></script>
    11. <script src="js/alarms.js"></script>
    12. </body>
    13. </html>
  4. 创建一个新的脚本 js/alarms.js:

    • 添加 checkAlarm, createAlarm, cancelAlarm 和 toggleAlarm 方法:

      1. (function () {
      2. 'use strict';
      3. var alarmName = 'remindme';
      4. function checkAlarm(callback) {
      5. chrome.alarms.getAll(function(alarms) {
      6. var hasAlarm = alarms.some(function(a) {
      7. return a.name == alarmName;
      8. });
      9. var newLabel;
      10. if (hasAlarm) {
      11. newLabel = 'Cancel alarm';
      12. } else {
      13. newLabel = 'Activate alarm';
      14. }
      15. document.getElementById('toggleAlarm').innerText = newLabel;
      16. if (callback) callback(hasAlarm);
      17. })
      18. }
      19. function createAlarm() {
      20. chrome.alarms.create(alarmName, {
      21. delayInMinutes: 0.1, periodInMinutes: 0.1});
      22. }
      23. function cancelAlarm() {
      24. chrome.alarms.clear(alarmName);
      25. }
      26. function doToggleAlarm() {
      27. checkAlarm( function(hasAlarm) {
      28. if (hasAlarm) {
      29. cancelAlarm();
      30. } else {
      31. createAlarm();
      32. }
      33. checkAlarm();
      34. });
      35. }
      36. $$('#toggleAlarm').addEventListener('click', doToggleAlarm);
      37. checkAlarm();
      38. })();

    注意:

    • 观察调用 chrome.alarms.create 时的参数,这些非常小的值(0.1 of a minute) 只是为了用来测试。在发布的应用中,这些值不会被接受,它们会被四舍五入到接近 1 分钟。在你的测试环境中,一个简单的警告被发送到了控制台 - 你可以忽略它。
    • 当日志信息被发送到事件(背景)页的控制台中(参见上面的第 2 步),你需要检查背景页来查看那个日志信息: 打开开发者工具(在应用窗口上右击,点击检查背景页,选择控制台标签)。每当你激活了一个提醒,你应该会在控制台里看到日志信息 “rings” 被显示出来。

part 2 - 通知

现在让我们把提醒通知变成能让用户更容易注意到的东西。为了达到这个目的,我们要使用 chrome notifications API。我们将显示一个像下面这样桌面通知,并且当用户点击通知后,弹出 To Do 窗口到最上层。(to the top)

step_3_1

  1. manifest.json 中,加入 “notifications” 权限:

    1. ···
    2. "permissions": ["storage", "alarms", "notifications"],
    3. ···
  2. background.js 中:

    • 把 chrome.app.window.create 的调用移动到一个方法中,为了让我们可以重用它:

      1. ...
      2. function launch() {
      3. chrome.app.window.create('index.html', {
      4. id: 'main',
      5. bounds: { width: 620, height: 500 }
      6. });
      7. }
      8. /*
      9. chrome.app.runtime.onLaunched.addListener(function() {
      10. chrome.app.window.create('index.html', {
      11. id: 'main',
      12. bounds: { width: 620, height: 500 }
      13. });
      14. });
      15. */
      16. chrome.app.runtime.onLaunched.addListener(launch);
      17. ...
    • 创建一个 showNotification 方法(注意下面的样例代码关联了 icon_128.png。如果你希望你的通知拥有一个图标,记得也把图标从作弊代码(TODO: cheat code)复制过来或创建一个你自己的图标):

      1. ...
      2. var dbName = 'todos-vanillajs';
      3. function showNotification(storedData) {
      4. var openTodos = 0;
      5. if ( storedData[dbName].todos ) {
      6. storedData[dbName].todos.forEach(function(todo) {
      7. if ( !todo.completed ) {
      8. openTodos++;
      9. }
      10. });
      11. }
      12. if (openTodos>0) {
      13. // Now create the notification
      14. chrome.notifications.create('reminder', {
      15. type: 'basic',
      16. iconUrl: 'icon_128.png',
      17. title: 'Don\'t forget!',
      18. message: 'You have '+openTodos+
      19. ' things to do. Wake up, dude!'
      20. }, function(notificationId) {})
      21. }
      22. }
      23. // When the user clicks on the notification,
      24. // we will open the To Do list and dismiss the notification
      25. chrome.notifications.onClicked.addListener(
      26. function( notificationId ) {
      27. launch();
      28. chrome.notifications.clear(notificationId, function() {});
      29. }
      30. );
      31. ...
    • 改写 onAlarm 监听器,把向控制台输出简单的新提醒信息变成存储数据并调用 showNotification:

      1. ...
      2. chrome.alarms.onAlarm.addListener(function( alarm ) {
      3. /* console.log("Got an alarm!", alarm); */
      4. chrome.storage.local.get(dbName, showNotification);
      5. });
      6. ...

现在重启你的应用并花一些使用它。你会注意到:

  • 即使你关闭了应用的窗口,提醒还是会出现
  • 如果你关闭了所有 Chrome 窗口,提醒就不会出现(非 ChromeOS 平台)
  • 当你的应用关闭后,你点击通知会打开应用窗口

遇到问题了?

如果通知没有显示,检查一下你的 Chrome 版本是不是 28 或更高。chrome 通知是 Chrome 28 引进的,所以你可能想要使用标准的 web 通知 API,我们把改写代码的挑战留个你。W3C 的规范在这里

如果你的 Chrome 是 28 但是通知还是没有显示,检查控制台中主窗口(右击 -> 审查元素)和背景页(右击 -> 审查背景页)的错误信息

第 4 步 - 解析 URL 并在 Webview 中打开

想从这一步重新开始?可以在 solution_for_step3 子目录下找到之前练习的代码!

目标:

  • 学习在安全的沙箱模式下通过 webview 标签 在你的应用中加载并显示大部分的外部内容。 // TODO
    完成本练习的建议时间:10 分钟

一些应用需要直接向用户展示一些外部的 web 内容,同时保持用户在应用的体验内(TODO: but keep him inside the application experience)。举例来说,一个新闻汇集应用(TODO: a news aggregator)可能想要从外部站点嵌入各种新闻且希望与原网站的格式、图片和行为保持一致。为完成这一点或其他用途,Chrome 打包应用拥有一个叫做 webview 的自定义的标签。它是一个非常强大的组建,但其最基础的用法事实上是非常简单的,你接下来将会学习它。

我们现在要改写我们的例子让其能够在 ToDo 内容中搜索网址,当找到搜索结果时添加一个链接。当点击该链接时将用一个 webview 打开一个新的应用窗口(不是浏览器标签)来展示内容。

  1. manifest.json 中,加入 “webview” 权限:

    1. ···
    2. "permissions": ["storage", "alarms", "notifications", "webview"],
    3. ···
  2. 使用一个简单的 <webview> 标签创建一个新文件 webview.html

    1. <!DOCTYPE html>
    2. <html>
    3. <head>
    4. <meta charset="utf-8">
    5. </head>
    6. <body>
    7. <webview style="width: 100%; height: 100%;"></webview>
    8. </body>
    9. </html>
  3. controller.js 中:

    • 添加一个方法用来解析 To Do 内容中的链接地址。一旦找到地址,用一个锚点来替换它:

      1. Controller.prototype._parseForURLs = function (text) {
      2. var re = /(https?:\/\/[^\s"<>,]+)/g;
      3. return text.replace(re, '<a href="$1" data-src="$1">$1</a>');
      4. };
    • 添加一个方法来打开一个带有 webview 的新窗口并给 webview.src 设置一个地址:

      1. Controller.prototype._doShowUrl = function(e) {
      2. // only applies to elements with data-src attributes
      3. if (!e.target.hasAttribute('data-src')) {
      4. return;
      5. }
      6. e.preventDefault();
      7. var url = e.target.getAttribute('data-src');
      8. chrome.app.window.create(
      9. 'webview.html',
      10. {hidden: true}, // only show window when webview is configured
      11. function(appWin) {
      12. appWin.contentWindow.addEventListener('DOMContentLoaded',
      13. function(e) {
      14. // when window is loaded, set webview source
      15. var webview = appWin.contentWindow.
      16. document.querySelector('webview');
      17. webview.src = url;
      18. // now we can show it:
      19. appWin.show();
      20. }
      21. );
      22. });
      23. };
    • 每当显示代办项(TODO: items)的时候进行链接解析:

      1. /**
      2. * An event to fire on load. Will get all items and display them in the
      3. * todo-list
      4. */
      5. Controller.prototype.showAll = function () {
      6. this.model.read(function (data) {
      7. this.$todoList.innerHTML =
      8. this._parseForURLs(this.view.show(data));
      9. }.bind(this));
      10. };
      11. /**
      12. * Renders all active tasks
      13. */
      14. Controller.prototype.showActive = function () {
      15. this.model.read({ completed: 0 }, function (data) {
      16. this.$todoList.innerHTML =
      17. this._parseForURLs(this.view.show(data));
      18. }.bind(this));
      19. };
      20. /**
      21. * Renders all completed tasks
      22. */
      23. Controller.prototype.showCompleted = function () {
      24. this.model.read({ completed: 1 }, function (data) {
      25. this.$todoList.innerHTML =
      26. this._parseForURLs(this.view.show(data));
      27. }.bind(this));
      28. };
    • 解析编辑项中的链接。同样的,修复代码让其使用 input 元素的 innerText 来代替它的 innerHTML:

      1. Controller.prototype.editItem = function (id, label) {
      2. ...
      3. var onSaveHandler = function () {
      4. ...
      5. // Instead of re-rendering the whole view just update
      6. // this piece of it
      7. /* label.innerHTML = value; */
      8. label.innerHTML = this._parseForURLs(value);
      9. ...
      10. // Get the innerHTML of the label instead of requesting the data from the
      11. // ORM. If this were a real DB this would save a lot of time and would avoid
      12. // a spinner gif.
      13. /* input.value = label.innerHTML; */
      14. input.value = label.innerText;
      15. ...
    • 最后,在 Controller 构造器中添加一个点击事件监听器,当用户点击链接时调用 doShowUrl 方法:

      1. function Controller(model, view) {
      2. this.model = model;
      3. this.view = view;
      4. this.ENTER_KEY = 13;
      5. this.ESCAPE_KEY = 27;
      6. this.$main = $$('#main');
      7. this.$toggleAll = $$('#toggle-all');
      8. this.$todoList = $$('#todo-list');
      9. this.$todoItemCounter = $$('#todo-count');
      10. this.$clearCompleted = $$('#clear-completed');
      11. this.$footer = $$('#footer');
      12. this.router = new Router();
      13. this.router.init();
      14. this.$todoList.addEventListener('click', this._doShowUrl);
      15. ...

现在,如果你重载你的应用,你看到的应该如下图:
step_4_1

点击链接后:
step_4_2

注意:一个 webview 就是一个沙箱进程。你只能通过使用其 API 与其交互。嵌入的应用(你的应用)无法简单的获取直接访问 webview 的权限,如示例(TODO: for example)。

高级:如果你提前完成了,浏览一下 webview 的文档并运行其中一些方法,让 webview 在加载时显示一个小的状态信息或状态指示器。

第 5 步 - 从 web 添加图片

想从这一步重新开始?可以在 solution_for_step4 子目录下找到之前练习的代码!

目标:

  • 学习如何通过 XHR 和 ObjectURLs 加载你的应用的外部资源并添加到 DOM 中。
    完成本练习的建议时间:20 分钟

Chrome 打包应用平台要求你的应用必须完全遵从内容安全政策。其中包括不能够直接加载 DOM 资源,如那些在你应用外部的图片、字体和 CSS。如果你想要在你的应用中显示一张外部图片,你需要通过 XHR 来请求,将它放进一个 Blob(TODO: Blob 怎么翻译 = =)并创建一个 ObjectURL。该 ObjectURL 之后能够被添加进 DOM,因为它与应用的上下文中的一个内存项相关(TODO: it refers to an in-memory item in the context of the app)。

让我们改写我们的应用来查找 To Do 内容中的图片链接。如果地址看上去像一个图片(以 .png, .jpg, .svg 或 .gif 结尾),我们会下载该图片并将其作为缩略图放在锚点中显示在一侧。

  1. manifest.json 中,加入 “<all_url>“ 权限。在一个 Chrome 打包应用中你可以让 XMLRequest 请求发向任意地址,只要你在 manifest 中把该域名设为白名单。我们不设置指定的域名,而是要求开启访问 ““(所有地址)的权限,是因为我们无法提前知道使用我们应用的用户将会在 To Do 的内容中输入什么图片地址:

    1. ···
    2. "permissions": ["storage", "alarms",
    3. "notifications", "webview", "<all_urls>"],
    4. ···
  2. js/controller.js 中:

    a. 添加一个方法来从 Blob(TODO: 怎么翻译?) 创建 ObjectURLs。ObjectURLs 会保持在内存中,在你不需要它们时需释放它们,所以也要增加一个 clear 方法:

    1. Controller.prototype._clearObjectURL = function() {
    2. if (this.objectURLs) {
    3. this.objectURLs.forEach(function(objURL) {
    4. URL.revokeObjectURL(objURL);
    5. });
    6. this.objectURLs = null;
    7. }
    8. };
    9. Controller.prototype._createObjectURL = function(blob) {
    10. var objURL = URL.createObjectURL(blob);
    11. this.objectURLs = this.objectURLs || [];
    12. this.objectURLs.push(objURL);
    13. return objURL;
    14. };

    b. 添加一个方法来执行 XMLRequest 的 URL(TODO: execute a XMLHttpRequest on a URL),根据 XHR 的 response 创建一个 ObjectURLs 并用该 ObjectURLs 创建一个 Chrome packaged apps - 图9 元素添加到 DOM 中:

    1. Controller.prototype._requestRemoteImageAndAppend =
    2. function(imageUrl, element) {
    3. var xhr = new XMLHttpRequest();
    4. xhr.open('GET', imageUrl);
    5. xhr.responseType = 'blob';
    6. xhr.onload = function() {
    7. var img = document.createElement('img');
    8. img.setAttribute('data-src', imageUrl);
    9. img.className = 'icon';
    10. var objURL = this._createObjectURL(xhr.response);
    11. img.setAttribute('src', objURL);
    12. element.appendChild(img);
    13. }.bind(this);
    14. xhr.send();
    15. };

    c. 现在添加一个方法,用来找到所有未处理的链接并检查它们。对于每个看上去像图片的链接地址,执行 _requestRemoteImageAndAppend:

    1. Controller.prototype._parseForImageURLs = function () {
    2. // remove old blobs to avoid memory leak:
    3. this._clearObjectURL();
    4. var links = this.$todoList.
    5. querySelectorAll('a[data-src]:not(.thumbnail)');
    6. var re = /\.(png|jpg|jpeg|svg|gif)$/;
    7. for (var i = 0; i<links.length; i++) {
    8. var url = links[i].getAttribute('data-src');
    9. if (re.test(url)) {
    10. links[i].classList.add('thumbnail');
    11. this._requestRemoteImageAndAppend(url, links[i]);
    12. }
    13. }
    14. };

    d. 在 showAll、showActive、showCompleted 和 eidtItem 中合适(TODO: appropriately)的地方调用它:

    1. Controller.prototype.showAll = function () {
    2. this.model.read(function (data) {
    3. this.$todoList.innerHTML =
    4. this._parseForURLs(this.view.show(data));
    5. this._parseForImageURLs();
    6. }.bind(this));
    7. };
    8. /**
    9. * Renders all active tasks
    10. */
    11. Controller.prototype.showActive = function () {
    12. this.model.read({ completed: 0 }, function (data) {
    13. this.$todoList.innerHTML =
    14. this._parseForURLs(this.view.show(data));
    15. this._parseForImageURLs();
    16. }.bind(this));
    17. };
    18. /**
    19. * Renders all completed tasks
    20. */
    21. Controller.prototype.showCompleted = function () {
    22. this.model.read({ completed: 1 }, function (data) {
    23. this.$todoList.innerHTML =
    24. this._parseForURLs(this.view.show(data));
    25. this._parseForImageURLs();
    26. }.bind(this));
    27. };
    28. ...
    29. Controller.prototype.editItem = function (id, label) {
    30. var li = label;
    31. // This finds the <label>'s parent <li>
    32. while (li.nodeName !== 'LI') {
    33. li = li.parentNode;
    34. }
    35. var onSaveHandler = function () {
    36. var value = input.value.trim();
    37. var discarding = input.dataset.discard;
    38. if (value.length && !discarding) {
    39. this.model.update(id, { title: input.value });
    40. // Instead of re-rendering the whole view just update
    41. // this piece of it
    42. label.innerHTML = this._parseForURLs(value);
    43. this._parseForImageURLs();
    44. ...
  3. 最后,在 bower_components/todomvc-common/base.css 中,添加一条 CSS 规则来限制图片的尺寸:

    1. .thumbnail img[data-src] {
    2. max-width: 100px;
    3. max-height: 28px;
    4. }

现在重新加载你的应用,打开 Google 图片搜索,找一些图片链接地址并把它们添加到 ToDo 的内容中。一些例子:

http://goo.gl/lftY4r#.jpg
http://goo.gl/YCBJz1#.png

然后看起来会是这样:
step_5_1

提示:在真实环境的情况下,当你需要控制离线缓存并同时下载多个资源时,我们创建了一个 帮助库 来处理一些常见用例。

第 6 步 - 导出 ToDo 到文件系统

想从这一步重新开始?可以在 solution_for_step5 子目录下找到之前练习的代码!

目标:

  • 学习如何获取外部文件系统中的文件的引用,并使用 FileSystem API 在应用的生命周期中写入该文件。
    完成本练习的建议时间:20 分钟

在这一步中,我们将在应用里添加一个导出按钮。点击后,当前的 To Do 项会保存到一个用户选择的文本文件中。如果文件存在,则会替换此文件。否则,会创建一个新文件。值得注意的是在表示文件实体的对象的生命周期内,用户仅需要选择文件一次。在我们的例子中,我们把它绑定到应用窗口上 - 因此只要用户保持窗口打开,Javascript 代码就能无需任何用户交互来写入选择的文件。

  1. manifest.json 中,添加 {fileSystem: [ "write" ] } 授权。注意该授权的语法要比其他的更复杂,我们不仅需要获取外部文件系统,也需要向其写入:

    1. ...
    2. "permissions": ["storage", "alarms", "notifications", "webview",
    3. "<all_urls>", { "fileSystem": ["write"] } ],
    4. ...
  2. index.html 中,添加一个 “导出到磁盘” 按钮以及一个 div,用来让我们显示状态信息。另外,加载我们之后会创建的脚本:

    1. ...
    2. <footer id="info">
    3. <button id="toggleAlarm">Activate alarm</button>
    4. <button id="exportToDisk">Export to disk</button>
    5. <div id="status"></div>
    6. <p>Double-click to edit a todo</p>
    7. <p>Created by <a href="http://twitter.com/oscargodson">Oscar Godson</a></p>
    8. </footer>
    9. <script src="bower_components/todomvc-common/base.js"></script>
    10. <script src="bower_components/director/build/director.js"></script>
    11. <script src="js/bootstrap.js"></script>
    12. <script src="js/helpers.js"></script>
    13. <script src="js/store.js"></script>
    14. <script src="js/model.js"></script>
    15. <script src="js/view.js"></script>
    16. <script src="js/controller.js"></script>
    17. <script src="js/app.js"></script>
    18. <script src="js/alarms.js"></script>
    19. <script src="js/export.js"></script>
    20. </body>
    21. </html>
  3. 按照以下步骤创建一个新 Javascript 脚本,js/export.js

    • 一个 getTodosAsText 方法用来从 chrome.storage.local 读取 ToDos 并生成相应的文本内容;
    • 一个 exportToFileEntry 方法,给定一个文件实体,会保存 To Do 的文本到那个文件;
    • 一个 doExportToDisk 方法,在我们已有一个保存过的文件实体时,执行上面添加的 exportToFileEntry 方法,在没有的时候让用户选择一个;
    • 监听 “导出到磁盘” 按钮的点击事件
    1. (function() {
    2. var dbName = 'todos-vanillajs';
    3. var savedFileEntry, fileDisplayPath;
    4. function getTodosAsText(callback) {
    5. chrome.storage.local.get(dbName, function(storedData) {
    6. var text = '';
    7. if ( storedData[dbName].todos ) {
    8. storedData[dbName].todos.forEach(function(todo) {
    9. text += '- ';
    10. if ( todo.completed ) {
    11. text += '[DONE] ';
    12. }
    13. text += todo.title;
    14. text += '\n';
    15. }, '');
    16. }
    17. callback(text);
    18. }.bind(this));
    19. }
    20. // Given a FileEntry,
    21. function exportToFileEntry(fileEntry) {
    22. savedFileEntry = fileEntry;
    23. var status = document.getElementById('status');
    24. // Use this to get a pretty name appropriate for displaying
    25. chrome.fileSystem.getDisplayPath(fileEntry, function(path) {
    26. fileDisplayPath = path;
    27. status.innerText = 'Exporting to '+path;
    28. });
    29. getTodosAsText( function(contents) {
    30. fileEntry.createWriter(function(fileWriter) {
    31. fileWriter.onwriteend = function(e) {
    32. status.innerText = 'Export to '+
    33. fileDisplayPath+' completed';
    34. };
    35. fileWriter.onerror = function(e) {
    36. status.innerText = 'Export failed: '+e.toString();
    37. };
    38. var blob = new Blob([contents]);
    39. fileWriter.write(blob);
    40. // You need to explicitly set the file size to truncate
    41. // any content that might was there before
    42. fileWriter.truncate(blob.size);
    43. });
    44. });
    45. }
    46. function doExportToDisk() {
    47. if (savedFileEntry) {
    48. exportToFileEntry(savedFileEntry);
    49. } else {
    50. chrome.fileSystem.chooseEntry( {
    51. type: 'saveFile',
    52. suggestedName: 'todos.txt',
    53. accepts: [ { description: 'Text files (*.txt)',
    54. extensions: ['txt']} ],
    55. acceptsAllTypes: true
    56. }, exportToFileEntry);
    57. }
    58. }
    59. document.getElementById('exportToDisk').
    60. addEventListener('click', doExportToDisk);
    61. })()

高级:文件实体无法持久化,这意味着每次启动你的应用都需要让用户选择文件。但是,如果你的应用被强制重启(比如运行环境崩溃、应用升级或运行环境升级),那么 这里有一个选项 来还原文件实体。如果你提前完成,尝试一下保存 retainEntry 返回的 ID 并且在应用重启时恢复它(提示:为 背景页的 onRestarted 事件添加一个监听器)

恭喜你!如果你完成了所有的步骤,你应该有了一个如下图所示的完整的 ToDoMVC 打包应用:

sterp_6_1

sterp_6_2

进阶的奖励挑战:添加语音指令

想从这一步重新开始?可以在 solution_for_step6 子目录下找到之前练习的代码!

如果你已经进行到这一步并且还有多余的时间,你可能会想要尝试一下非常高级的挑战:让你的应用能够通过说话来添加 ToDos,这个怎么样?使用 HTML 5 WebSpeech API 并按照以下高阶步骤(TODO: high-level step)来做:

  • manifest.json 中添加 “audioCapture” 授权
  • 当用户点击激活按钮时开始语音识别
  • 在 “add note” 命令之后、”end note” 之前,获取任意文本并保存它们到 To Do 项中

这部分挑战没有作弊用的代码,自己来 hack 吧!


[1] http://developer.chrome.com/trunk/apps/manifest.html
[2] http://developer.chrome.com/trunk/apps/app_lifecycle.html#eventpage
[3] http://goo.gl/u9sRAL
[4] http://developer.chrome.com/trunk/apps/storage.html
[5] If you know the ToDoMVC web app (http://todomvc.com), we have copied its vanilla JavaScript version to be used as a starting point.
[6] http://developer.chrome.com/trunk/apps/app_csp.html
[7] http://en.wikipedia.org/wiki/Hazard_(computer_architecture)#Read_After_Write_.28RAW.29
[8] http://www.w3.org/TR/notifications/
[9] http://developer.chrome.com/trunk/apps/webview_tag.html
[10] https://github.com/GoogleChrome/apps-resource-loader#readme
[11] http://developer.chrome.com/trunk/apps/fileSystem.html#method-restoreEntry
[12] http://www.google.com/intl/en/chrome/demos/speech.html