Windows-商店应用开发入门指南-四-

61 阅读1小时+

Windows 商店应用开发入门指南(四)

原文:Beginning Windows Store Application Development – HTML and JavaScript Edition

协议:CC BY-NC-SA 4.0

十三、Web 工作器

在第十二章中,我介绍了承诺作为一种处理异步操作返回值的方法。虽然承诺在如何进行异步工作方面提供了很大的灵活性,但我指出异步并不意味着多线程。JavaScript 应用在单线程环境中执行。异步并不意味着“同时做两件事”相反,它的意思是“以后再做这件事,完成后让我知道发生了什么。”也就是说,在多线程上执行的应用有几个优点,例如利用多个处理器来更快地执行任务或在后台执行一些工作,同时用户可以继续使用应用的其他部分。

那么,在开发 Windows 应用商店应用时,我们有什么选择呢?嗯,我们可以用 C#或 C++编写部分应用作为 WinRT 组件,它们支持线程。事实上,这篇关于 MSDN 的文章讨论了这个选项。我不会在本书中涉及这些细节,尽管我会在第十八章中介绍用 C#构建 WinRT 组件。不是 C#或 C++开发人员?幸运的是,HTML5 提供了 Web Workers 作为一种新的选择。

Web 工作器

worker(或者更正式地说,Web Worker)是一个在后台运行的 JavaScript 脚本。Workers 是 HTML5 的一个特性,并不是专门用来用 HTML 和 JavaScript 构建 Windows Store 应用的。如果你曾经在一个 web 开发项目中与工人一起工作过,那么你可以跳过这一章。如果你还不熟悉 workers,那么本章将提供一个基本的介绍,从清单 13-1 开始,它展示了创建一个 worker 所需的代码。

清单 13-1。 创建一个 Web 工作器的实例

var myWorker = new Worker("/path/to/worker/script.js");

工作线程在单独的线程上运行。然而,JavaScript 是一个单线程环境,所以每个 worker 都在自己的环境中执行。结果,一个工作器不能访问主 UI 线程上的任何对象,比如document对象。它不能操纵 DOM。它不能改变页面上 JavaScript 变量的值。它完全孤立在自己的环境中。

这对线程安全来说非常好。你永远不必担心冲突,因为两个工人改变了同一个变量。您不需要处理竞争情况——两个或多个线程如果没有按照正确的顺序完成,将会导致意外的结果。你不用锁任何东西。您不必担心在其他编程环境中使多线程开发具有挑战性的许多概念。也就是说,一个不能改变主线程上任何东西的脚本有多大用处?当然,在某些情况下,当您想要“启动并忘记”时,它可能会有所帮助,但是要实际使用它们,如果有某种方法可以从工作线程影响您的主线程,那就更好了。

这就是信息的来源。虽然工作线程不能改变主线程上的任何东西,反之亦然,但消息可以从一个线程传递到另一个线程。清单 13-2 包含了一个如何配置主线程与工作线程通信的例子。

清单 13-2。 在主线程和工作线程之间传递消息

myWorker.onmessage = function(e) {
    var messageFromWorker  =  e.data;
    // do something with the message
};

// send a message to the worker thread
myWorker.postMessage(messageToWorker);

在这个进程的另一边,在 worker 线程中,有一个类似的onmessage事件和postMessage方法(参见清单 13-3 )。

清单 13-3。 处理和发送来自工作线程的消息

self.onmessage = function (e) {
    processTheData(e.data);
};

self.postMessage(responseMessage);

尽管我们现在有两个 JavaScript 环境,每个环境都执行自己的脚本,但是每个环境仍然是单线程的。迷茫?我用一个类比来解释这是怎么回事。

想象一下,你和我都在同一个团队工作,只不过我在不同的大楼工作,我们从来没有见过面,也没有当面说过话。在这个虚构的场景中,我们唯一的交流方式是通过办公室间的 messenger 互相发送消息。当你需要我的东西时,你给信差一个便条(叫worker.postMessage)。他或她把它放在我的桌子上,只要我能拿到它,我就拿着它(self.onmessage)开始做你在信息中要求的工作。当我完成后,我让信使把结果送还给你(self.postMessage)。当你看到结果(worker.onmessage)并决定你想用它们做什么时,你就完成了这个循环。只要我们能够就如何以对方能够理解的方式写笔记达成一致,我们就可以各自做好自己的工作(各自在单线程环境中),而不会妨碍到对方。

绘制时间条目

在第十二章中,我们给 Clok 增加了一些功能,允许用户输入他们在一个项目上工作的时间。如果用户可以看到一个图表,说明他们在每个客户端上花费了多少时间,这将是很有帮助的。在本章中,我将向您展示如何使用 Web Workers 实现这一点,使用一个名为 Flotr2 ( www.humblesoftware.com/flotr2/)的开源图形库。

image Web 超文本应用技术工作组(WHATWG),这个负责开发 HTML 规范的组织,包括 Web 工作器,已经提供了一些关于工作器的指导。它说,工人“体重相对较重”,而且“有望长寿”虽然我们的图形示例将说明开发人员如何利用工作人员,但它不是工作人员的理想用例,因为这个过程实际上非常快。在www.whatwg.org/specs/web-apps/current-work/multipage/workers.html在线阅读 WHATWG HTML 规范。

在我们开始之前,让我们确定时间入口图的一些要求:

  • 应该创建一个条形图来表示时间条目。它应该显示在自己的页面上,从时间表页面链接。
  • 应用于时间表页面的任何过滤器也应该应用于图表。
  • 图表的数据点应按客户分组。也就是说,如果用户在同一个客户的多个项目上工作,在这些项目上工作的时间将被合并到图上的单个条中。

没有 Web 工作器的情况下开始

timeEntries文件夹中,添加一个名为graph.html的新页面控件。顾名思义,这个页面将显示您将要添加的图表。开发这个特性的第一次迭代将集中在获得一个正确格式化的测试图,以显示在页面上。我们将从直接硬编码在页面控件的 JavaScript 文件中的图形数据开始。

Flotr2 图形库将在它动态创建的canvas元素上生成图形。我们只需要在页面上提供一个包含这个新的canvas元素的占位符。用清单 13-4 中高亮显示的代码修改graph.html的 body 元素。

清单 13-4。 为图形添加容器

<body>
    <div class = "graph fragment">
        <header aria-label = "Header content" role = "banner">
            <button class = "win-backbutton" aria-label = "Back" disabled type = "button" > </button>
            <h1 class = "titlearea win-type-ellipsis">
                <span class = "pagetitle" > Time Entries</span>
            </h1>
        </header>
        <section aria-label = "Main content" role = "main">
            <div id = "graphcontainer" > </div>
        </section>
    </div>
</body>

接下来,对graph.css 进行修改,如清单 13-5 中突出显示的。这些规则将导致 Flotr2 渲染的图形显示在页面中央。

清单 13-5。 CSS 变化

.graph section[role = main] {
    /* remove the CSS properties that were added to this rule by default */
}

#graphcontainer {
    width: 70vw;
    height: 70vh;
    margin: 8px auto;
}

Flotr2 项目相当广泛,但我将只涉及完成本章目标所需的部分。请访问该项目的网站(www.humblesoftware.com/flotr2/)以熟悉它所提供的功能。在浏览了文档和一些例子之后,下载名为flotr2.js的 JavaScript 文件。或者,因为 Flotr2 的创建者已经发布了许可的开源许可证,你可以在本书的源代码中找到一个副本。(见该书的 Apress 产品页面的源代码/下载标签[ www.apress.com/9781430257790 ]。)一旦你有了文件的副本,将flotr2.js放在清单 13-6 中指定的路径中,然后添加对graph.html的 JavaScript 引用。

清单 13-6。 引用图形库

<head>
    <!-- SNIPPED -->
    <script type = "text/javascript" src = "/js/lib/flotr2/flotr2.js" > </script>
</head>

我之前提到过,我们将使用硬编码数据构建我们的图形功能的第一次迭代。事实上,我们的测试图基于 Flotr2 堆叠条形图示例。将清单 13-7 中的代码添加到graph.jsready函数中。

清单 13-7。 生成测试数据并显示图形

var d1 = [], d2 = [], d3 = [], graph, i;

for (i = -10; i < 10; i++) {
    d1.push([i, Math.random()]);
    d2.push([i, Math.random()]);
    d3.push([i, Math.random()]);
}

var graphdata = [
    { data: d1, label: 'Series 1' },
    { data: d2, label: 'Series 2' },
    { data: d3, label: 'Series 3' }
];

var graphoptions = {
    bars: {
        show: true,
        stacked: true,
        horizontal: false,
        barWidth: 0.6,
        lineWidth: 1,
        shadowSize: 0
    },
    legend: {
        position: "ne",
        backgroundColor: "#fff",
        labelBoxMargin: 10,
    },
    grid: {
        color: "#000",
        tickColor: "#eee",
        backgroundColor: {
            colors: [[0, "#ddf"], [1, "#cce"]],
            start: "top",
            end: "bottom"
        },
        verticalLines: true,
        minorVerticalLines: true,
        horizontalLines: true,
        minorHorizontalLines: true,
    },
    xaxis: {
        color: "#fff",
    },
    yaxis: {
        color: "#fff",
    },
    HtmlText: true
};

graph = Flotr.draw(graphcontainer, graphdata, graphoptions);

我不会详细讨论所有代码,因为 Flotr2 文档涵盖了大部分内容。我确实想快速介绍一下突出显示的代码语句。代码创建了三个数据序列:d1d2d3。它向其中的每一个添加随机数据点。但是,请注意,这不是传递给draw方法的数据对象。相反,这些序列中的每一个都被包装成一个包含label属性的对象的data属性,该属性将显示在图表的图例中。Flotr2 支持在这个对象上设置额外的属性,但是我们只利用了label属性。一个名为graphdata的包装对象数组被传递给draw方法,同时传递的还有我们在清单 13-4 中添加到graph.htmlgraphcontainer div的引用。graphoptions对象为我们的图表设置了许多显示属性,这些都记录在 Flotr2 网站上。

将导航选项添加到时间表页面是加载图表页面之前需要做的最后一件事。我已经用应用栏上的一个新按钮做到了这一点。遵循清单 12-31 和图 12-11 中使用的相同模式,在项目细节屏幕的应用栏中添加一个时间表按钮,在时间表页面上创建一个新的基于 sprite 的AppBarCommand (参见图 13-1 )。

9781430257790_Fig13-01.jpg

图 13-1 。时间表应用栏上的新图表按钮

image 注意如果你不想创建自己的图像,你可以使用我创建的版本。您可以在本书附带的源代码中找到它。(见该书的 Apress 产品页[ www.apress.com/9781430257790 ]的源代码/下载标签)。)

当然,这个按钮需要一个为其click事件定义的处理程序。将清单 13-8 中的代码添加到list.js中。一定要将这个函数连接到ready函数中的click事件。

清单 13-8。 点击处理程序,将当前过滤器传递到图形页面

graphTimeEntriesCommand_click: function (e) {
    WinJS.Navigation.navigate("/pages/timeEntries/graph.html", {
        filter: this.filter,
    });
},

image 注意如果你忘记了如何将click事件连接到这个处理程序,参考清单 12-32 中的例子。

虽然我们不会在本节中使用它,但是我们会将用户当前的时间表过滤器传递给graph.html。这将确保用户在时间表页面上查看的任何时间条目都包含在图表中。在本章的后面,我将向您展示如何实现这一点。同时,你应该有一个工作测试图。运行 Clok 并点击时间表应用栏上的图表按钮,看看它看起来如何(参见图 13-2 )。

9781430257790_Fig13-02.jpg

图 13-2 。我们的第一张图表

多亏了一些开源开发者的辛勤工作,我们有了一个看起来非常漂亮的图表,没有很多代码。接下来,让我们看看如何在这个特性中引入一个 Web Worker。

从 Web Worker 返回图形数据

我们的最终目标是基于在时间表页面上选择的过滤器,使用一个工人来计算图表应该显示的数据点。在这一节中,我们将继续使用硬编码的数据,但是我们将把生成数据的逻辑转移到一个 worker。

在 Visual Studio 项目的js文件夹中创建一个名为workers的文件夹。选择专用工人文件类型,并将名为timeGraphWorker.js 的工人添加到workers文件夹中(参见图 13-3 )。

9781430257790_Fig13-03.jpg

图 13-3 。添加工人

image 值得注意的是,HTML5 规范定义了专用 Web Worker 和共享 Web Worker。共享工作线程为多个脚本连接到同一个工作线程提供了一种方式。但是,Windows 应用商店应用不支持共享工作线程。在这里,我只讨论敬业的员工。如果你正在做 web 开发,目标是支持共享工作器的有限的浏览器集,你会在网上找到更多的信息。

删除 worker 中的默认代码,并用清单 13-9 中的代码替换它。

清单 13-9。 我们工人的初稿

/// <reference group = "Dedicated Worker" />

importScripts(
    "//Microsoft.WinJS.1.0/js/base.js",
    "/js/extensions.js",
    "/js/utilities.js",
    "/data/project.js",
    "/data/timeEntry.js",
    "/data/storage.js"
    );

(function () {
    "use strict";

    var data = Clok.Data;
    var storage = data.Storage;

    self.onmessage = function (e) {
        getData(e.data);
    };

    function getData(messageData) {
        var d1 = [], d2 = [], d3 = [], i;

        for (i = -10; i < 10; i++) {
            d1.push([i, Math.random()]);
            d2.push([i, Math.random()]);
            d3.push([i, Math.random()]);
        }

        var graphdata = [
            { data: d1, label: 'Series 1' },
            { data: d2, label: 'Series 2' },
            { data: d3, label: 'Series 3' }
        ];

        self.postMessage({
            type: "graphdata",
            data: graphdata
        });
    }
})();

这个脚本做的第一件事是导入许多其他脚本。因为 worker 在他们自己的环境中运行,所以包含在graph.html页面或default.html页面的head元素中的脚本在 worker 中是不可用的。您可以使用importScripts功能来引用我们需要的文件。当这个工人在onmessage处理器中收到一条消息时,调用getData函数。最终,messageData参数将是来自时间表页面的filter对象,但是我将在下一节中介绍它。在getData中,我在清单 13-7 中引入的相同代码用于创建一个graphdata对象,然后使用postMessage将该对象传递回主线程。

但是这些都不会发生,直到工作线程从主线程收到一条消息。清单 13-10 包含了ready函数的新定义,以及一个格式化日期的帮助函数。将这两个功能都添加到graph.js中。

清单 13-10。 创建一个工作线程并与之通信

ready: function (element, options) {
    var timeGraphWorker = new Worker("/js/workers/timeGraphWorker.js");

    timeGraphWorker.onmessage = function getGraphData(e) {
        var message = e.data;

        if (message && message.type === "graphdata") {
            var graphoptions = {
                bars: {
                    show: true,
                    stacked: true,
                    horizontal: false,
                    barWidth: 0.6,
                    lineWidth: 1,
                    shadowSize: 0
                },
                legend: {
                    position: "ne",
                    backgroundColor: "#fff",
                    labelBoxMargin: 10,
                },
                grid: {
                    color: "#000",
                    tickColor: "#eee",
                    backgroundColor: {
                        colors: [[0, "#ddf"], [1, "#cce"]],
                        start: "top",
                        end: "bottom"
                    },
                    verticalLines: true,
                    minorVerticalLines: true,
                    horizontalLines: true,
                    minorHorizontalLines: true,
                },
                xaxis: {
                    color: "#fff",
                },
                yaxis: {
                    color: "#fff",
                },
                title: this.formatDate(options.filter.startDate)
                    + " - " + this.formatDate(options.filter.endDate),
                HtmlText: true
            };

            var graph = Flotr.draw(graphcontainer, message.data, graphoptions);

            timeGraphWorker.terminate();
            graph.destroy();
        } else if (message && message.type === "noresults") {
            graphcontainer.innerHTML = "No data found.  Try adjusting the filters.";
        }
    }.bind(this);

    timeGraphWorker.postMessage({
        startDate: options.filter.startDate,
        endDate: options.filter.endDate,
        projectId: options.filter.projectId
    });
},

formatDate: function (dt) {
    var formatting = Windows.Globalization.DateTimeFormatting;
    var formatter = new formatting.DateTimeFormatter("day month.abbreviated");
    return formatter.format(dt);
},

代码中突出显示的部分与我们在清单 13-7 中添加的部分相同。然而,我没有在页面加载时直接在ready函数中执行代码,而是将它移到了名为timeGraphWorker的新Worker对象的onmessage事件处理程序中。这是在清单 13-9 中postMessage被调用时接收从工作线程发送的消息的函数。当收到消息时,检查消息的type属性。如果消息包含数据,主线程使用该数据来呈现图形。如果typenoresults,则向用户显示一条消息。因为我们的数据仍然是硬编码的,所以我们还看不到那个消息。在连接了onmessage事件处理程序之后,对timeGraphWorker.postMessage的调用将把来自时间表页面的filter对象发送给工人。在这个调用发生之前,这个工人实际上并没有做任何事情。

image 注意在清单 13-10 的末尾,您会发现对timeGraphWorker.terminate的调用。有两种方法可以终止工作线程。首先,从主线程调用terminate方法。另一种是从工作线程本身调用close方法。它们是等价的。

如果您现在运行 Clok 并导航到图表页面,您将看到类似于图 13-4 中的图表。除了数据是随机的这一事实之外,它与图 13-2 中的数据没有任何不同,但是这个数据是在一个工作线程中计算出来的。好吧,它实际上是硬编码在一个 worker 线程中的,但你仍然创建了一些多线程 JavaScript,这令人印象深刻。

9781430257790_Fig13-04.jpg

图 13-4 。我们图表的第二次迭代与第一次非常相似

将时间表过滤器 作为消息传递

到目前为止,我们已经得到了一个漂亮的图表,其中填充了在工作线程中硬编码的数据。难题的最后一部分是用来自我们的Storage类的真实数据替换硬编码的数据。用清单 13-11 中的版本替换timeGraphWorker.jsgetData函数的当前版本。

清单 13-11。 从存储类中获取实时录入数据

function getData(messageData) {
    storage.timeEntries.getSortedFilteredTimeEntriesAsync(
            messageData.startDate,
            messageData.endDate,
            messageData.projectId)
        .then(
            function complete(results) {
                if (results.length < = 0) {
                    self.postMessage({
                        type: "noresults"
                    });
                } else {

                    // TODO: transform the data into format Flotr2 understands

                    // TODO: generate friendly labels for the graph axis

                    // TODO: post data back to the main thread

                }
            }.bind(this)
        );
}

现在我们有进展了。正如我在第十二章的中所讨论的,调用getSortedFilteredTimeEntriesAsync会返回一个Promise,当它被满足时,会将图表所需的时间输入数据传递给then函数的complete参数。如果没有结果,只需将消息发送回主线程。正如您在TODO评论中看到的,当结果被发现时,有三个任务需要完成,以使图形显示出来。

  • 将从getSortedFilteredTimeEntriesAsync接收的数据转换成 Flotr2 图形库可以处理的格式。
  • 为图表轴生成友好标签。
  • 将数据发送回主线程,供 Flotr2 渲染。

我将把第一个和第三个任务放在一起讨论,然后以第二个任务结束。

映射、减少并再次映射

调用getSortedFilteredTimeEntriesAsync的结果包括创建图表所需的所有数据。实际上,我们只需要来自results中每个timeEntry对象的三个值。不幸的是,timeEntry对象的格式与 Flotr2 需要的数据点不匹配。

将数据转换成正确的格式有多种方法。您可以创建许多for循环来迭代结果,并为图形库逐步构建数据对象。我选择使用mapreduce函数来处理这个问题。用清单 13-12 中的代码替换第一个TODO注释。

清单 13-12。 转换数据

var msInDay = 86400000; // to normalize dates

var graphdata = results.map(function (item) {
    // First, map to an array containing only the raw data needed
    return {
        clientName: item.project.clientName
            + ((messageData.projectId > 0)
                ? ": " + item.project.name
                : ""),
        dateWorked: item.dateWorked.removeTimePart(),
        timeWorked: Clok.Utilities.SecondsToHours(item.elapsedSeconds, false)
    };
}).reduce(function (accumulated, current) {
    // Second, reduce all hours worked on each day for the same client into a single value

    var found = false;
    for (var i = 0; i < accumulated.length; i++) {
        if (accumulated[i][0] === current.clientName) {
            found = true;
            continue;
        }
    }

    if (!found) {
        var worked = [];
        var dt = messageData.startDate;
        while (dt < = messageData.endDate) {
            worked[worked.length] = [dt / msInDay, 0];
            dt = dt.addDays(1);
        }
        accumulated[accumulated.length] = [current.clientName, worked];
    }

    for (var i = 0; i < accumulated.length; i++) {
        if (accumulated[i][0] === current.clientName) {
            for (var j = 0; j < accumulated[i][1].length; j++) {
                if (accumulated[i][1][j][0] === current.dateWorked.getTime() / msInDay) {
                    accumulated[i][1][j][1] + = current.timeWorked;
                    continue;
                }
            }
        }
    }

    return accumulated;
}, []).map(function (item) {
    // Finally, map the reduced values into the format Flotr2 requires
    return { label: item[0], data: item[1] };
});

代码并不漂亮,但是它做了它需要做的事情。我在这个任务的各个步骤中强调了一些注释,以说明正在发生的事情。对map的第一次调用将每个timeEntry对象的层次结构简化为一个扁平的对象,只包含客户、日期和工作时间。reduce函数的前两个块确保每个客户端在每个日期都有一个图形值,默认情况下是0。在初始化过程之后,reduce函数会遍历这些展平对象的数组,按照客户和日期对它们进行分组,并对每个组的工作时间进行求和。如果你熟悉 SQL,类似的任务可以用类似于清单 13-13 中的查询来完成。

清单 13-13。 这个我看清楚多了

select clientName, dateWorked, sum(timeWorked) as timeWorked
from timeEntries
group by clientName, dateWorked

不幸的是,您不能在 JavaScript 中嵌入 SQL。也就是说,本章的重点不是编写理想的 MapReduce 代码。其他人已经详细介绍了这一点,并且可以在网上找到大量的算法和技术。

graphdata对象包含了你所有贴图和缩小的结果。你现在需要做的就是用清单 13-14 中的代码替换清单 13-11 中的第三个TODO注释,将数据发送回主线程。

清单 13-14。 将我们紧张计算的结果发布回主线程

self.postMessage({
    type: "graphdata",
    data: graphdata
});

当你运行 Clok 并导航到图表时,你会看到一个漂亮、精确的图表,如图 13-5 所示。

9781430257790_Fig13-05.jpg

图 13-5 。图表上的条形看起来不错,但轴标签却不怎么样

虽然条形看起来很准确,但水平轴上的标签没有任何意义。让我们解决这个问题。

为图表创建轴标签

事实证明,默认情况下,Flotr2 将日期视为非常大的数字——自 1970 年 1 月 1 日以来的毫秒数,由一个Date对象的getTime函数返回。这意味着我们图中的每根棒线距离第二天的棒线有 86,400,000(每天的毫秒数)个单位。这使得图中的条形很难看到,所以我将清单 13-12 中的日期除以那个数字进行了归一化。因此,横轴上的值代表自 1970 年以来的天数,而不是自 1970 年以来的毫秒数。

虽然 15,835 比 1,368,144,000,000 要好,但是对于试图计算出他或她哪一天工作了 10 小时的用户来说,这个标签并不是特别有用。幸运的是,Flotr2 允许您将标签与横轴上的每个值相关联。用清单 13-15 中的代码替换清单 13-11 中的第二个TODO注释。

清单 13-15。 制作人性化的轴标签

var tickDays = [], otherDays = [];

var dateFormatter = function (dt) {
    var formatting = Windows.Globalization.DateTimeFormatting;
    var formatter = new formatting.DateTimeFormatter("day month.abbreviated");
    return formatter.format(dt);
};

var dt = messageData.startDate;
while (dt < = messageData.endDate) {
    if ((dt.getDay() === 0)
            || (messageData.startDate.addDays(7) > = messageData.endDate)
            || (messageData.startDate.getTime() === dt.getTime())
            || (messageData.endDate.getTime() === dt.getTime())
        ) {
        tickDays.push([dt / msInDay, dateFormatter(dt)]);
    } else {
        otherDays.push([dt / msInDay, dateFormatter(dt)]);
    }

    dt = dt.addDays(1);
}

在这段代码中,tickDays存储了一个友好版本的要在图表上显示的日期。如果请求的图表是七天或更短时间的数据,那么范围内的每个日期都会添加到tickDays中。否则,tickDays包含用户友好的日期标签,这些日期要么是星期天,要么与过滤器的startDateendDate属性相匹配。任何不符合这些标准的日期都会添加到otherDays中。除了在图表的水平轴上显示友好的标签之外,这两个数组还将决定竖线在图表上出现的位置。在此之前,我们必须将这些值返回给主线程。将清单 13-16 中突出显示的代码添加到来自清单 13-14 的postMessage调用中。

清单 13-16。 给我们的信息增加更多的价值

self.postMessage({
    type: "graphdata",
    ticks: tickDays,
    minorTicks: otherDays,
    data: graphdata
});

为了正确显示图形标签,主线程中还需要做最后一项更改。通过将清单 13-17 中高亮显示的代码添加到xaxis属性来修改graphoptions对象。

清单 13-17。 配置轴标签

xaxis: {
    color: "#fff",
    showLabels: true,
    ticks: message.ticks,
    minorTicks: message.minorTicks,
},

现在 Flotr2 可以按预期渲染图形和标签了(见图 13-6 )。

9781430257790_Fig13-06.jpg

图 13-6 。这些标签现在更有意义了

简单说说承诺

图表看起来很棒。图形的数据点在工作线程中计算,并在消息中传递回主线程。轴标签美观易读。你现在可以停下来,对你的工作感到高兴。事实上,这是我能做的最大限度的练习。

也就是说,如果你想找一些家庭作业,你可以多做一步。如果您喜欢通用语法,您可以将这个 Web Worker 封装在一个WinJS.Promise中。这将允许您在一个thendone方法中处理来自工人的消息,而不是在一个onmessage事件处理器中。功能没有变,只是语法变了。如果这是你感兴趣的事情,那么我推荐你阅读这个论坛的信息,作为你如何实现这个目标的例子:http://social.msdn.microsoft.com/Forums/en-US/winappswithhtml5/thread/9722d406-6de2-4705-9f00-4fdd7c2ad6b3

image 注意所列论坛帖子中描述的技术已经被 Kraig Brockschmidt 记录在他的书用 HTML、CSS 和 JavaScript 编程 Windows 8 应用(微软出版社,2012)中。

虽然不是在 Web Workers 的上下文中,但我在第十四章中使用了类似的技术,当我将数据库连接的创建包装在一个Promise中时,当连接成功打开时就完成了。你会在清单 14-1 和清单 14-2 中看到这段代码。

结论

虽然承诺允许您以方便的方式处理异步操作,但是 Web Workers 提供了让代码在不同的线程上执行的机会。将工作线程与主线程隔离开来意味着两个线程不能引用相同的变量。虽然这一开始似乎会适得其反,但它实际上会导致一个更稳定、解耦的系统,在这个系统中,作为开发人员,您不必担心多线程开发中的常见问题,如竞争条件和锁定。配置您的主线程和辅助线程通过传递和接收消息进行通信是一项简单的任务,可以实现多线程应用开发,而没有其他语言中可能遇到的潜在脆弱性。

十四、数据源选项

在第十一章中,我介绍了数据绑定,并介绍了一些可以用来在屏幕上显示应用数据的不同技术。然而,到目前为止,Clok 中的所有数据都存储在内存中。当应用启动时加载测试数据,当应用关闭时,对该数据的任何修改都将被丢弃。虽然这使我们能够构建一个看起来不错的应用,并制定出允许用户与数据交互的细节,但这还不足以成为一个可用的应用。

然而,到本章结束时,Clok 将处于一种状态,人们实际上可以开始每天使用它。这并不是说它是一个完整的应用——在本书的其余部分,我们仍然会添加一些功能来改善用户体验。也就是说,在完成本章的练习后,我开始自己使用 Clok。

那么,怎样才能让 Clok 从一个看起来功能正常的应用变成一个实际可用的应用呢?Clok 缺少的最大特性是以持久格式保存数据的能力。在本章中,我将讨论使用 IndexedDB 处理本地数据,以及使用WinJS.xhr函数将远程数据集成到 Clok 中。

本地数据源

本地数据的情况很明显。访问本地数据源中的数据比访问远程数据源中的相同数据更快。无论用户是否连接到互联网或内部网络,它始终可用。在用户没有连接的情况下,一个只依赖远程数据的应用是没有用的。在本节的大部分时间里,我将介绍一种被称为索引数据库 API 的技术,或简称为 IndexedDB 。

索引 b

IndexedDB 是一个数据库引擎,内置于现代 web 浏览器中,如 Internet Explorer、Firefox 和 Chrome web 浏览器。因为使用 HTML 和 JavaScript 构建的 Windows Store 应用利用了 Internet Explorer 的渲染和脚本支持,所以 IndexedDB 也可以在 Clok 等应用中使用。使用 IndexedDB,您可以将用户的数据存储在本地,就在您的应用中,使这些数据随时可用,无论是否连接。

与关系数据库管理系统(RDBMS)不同,如 Microsoft SQL Server 或 Oracle 数据库,IndexedDB 将数据存储为对象。一个对象可以有一个很深的层次结构,比如一个客户对象包含一个订单集合,每个订单都有一个产品集合。或者它可以是一个简单的对象,比如一个Project对象或TimeEntry对象。

image 注意在开始使用 IndexedDB 之前,一定要注释storage.js中用于用临时数据填充内存列表的代码。在本章的后面,您将把这段代码的修改版本移动到一个新文件中。

从 IndexedDB 填充内存列表

在第七章的中,我提到了一个事实,我更喜欢使用对象和对象集合,而不是构建我的用户界面来直接耦合到数据本身。这为我选择如何读写数据提供了一定的灵活性。考虑到这一点,我将说明如何使用 IndexedDB 作为数据存储来支持我们现有的内存中数据对象。

使用 IndexedDB 时,您需要做的第一件事是创建一个数据库。在storage类定义之前,添加清单 14-1 到storage.js中突出显示的代码。

清单 14-1。 创建索引数据库

"use strict";

var data = Clok.Data;

var _openDb = new WinJS.Promise(function (comp, err) {
    var db;

    var request = indexedDB.open("Clok", 1);

    request.onerror = err;

    request.onupgradeneeded = function (e) {
        var upgradedDb = e.target.result;
        upgradedDb.createObjectStore("projects", { keyPath: "id", autoIncrement: false });
        upgradedDb.createObjectStore("timeEntries", { keyPath: "id", autoIncrement: false });
    };

    request.onsuccess = function () {
        db = request.result;

        // Do something with the database here
    };
});

var storage = WinJS.Class.define(
    // SNIPPED

因为所有 IndexedDB 操作都是异步发生的,所以我将_openDb定义为代表数据库的Promise。由于异步的特性,使用 IndexedDB 的一个常见模式是发出某种类型的请求,并在处理程序中检查该请求的响应。这正是清单 14-1 中发生的事情。通过调用indexedDB.open,我请求打开名为 Clok 的数据库的版本 1。该请求的任何错误都由Promise对象的err函数处理。一旦成功连接到数据库,onsuccess事件处理程序将做一些有趣的事情,稍后我将展示这一点。

然而,目前 Clok 数据库的版本 1 并不存在。当请求一个新版本的数据库时,调用onupgradeneeded事件处理程序,在这里您可以创建集合,或者对象存储,在这里维护数据。每个对象都需要一个键,而ProjectTimeEntry类上的id属性是一个理想的键。因为我们已经有了逻辑来设置这些id属性的值,所以我指定了键不会自动递增。

image 注意除了用于创建初始数据库,onupgradeneeded处理程序还用于将现有数据库升级到新版本。在这种情况下,您将在处理程序中添加代码来检查e.oldVersion,以确定用户的数据库有多过时,以及成功升级它需要哪些步骤。你可以在www.w3.org/TR/IndexedDB/看到这样的例子。

一旦创建并打开了数据库,就会引发success事件,调用onsuccess处理程序。到目前为止,那里没有什么有趣的事情发生。我只在一个名为db的变量中设置了对数据库的引用。因为我计划在整个 Clok 中继续使用内存中的数据列表,所以当数据库打开时,应该用数据库中当前的数据填充这些列表,以便现有的屏幕继续像以前一样工作。将清单 14-2 中高亮显示的代码添加到onsuccess处理程序中。

清单 14-2。 此代码在数据库成功打开时执行

request.onsuccess = function () {
    db = request.result;

    _refreshFromDb(db).done(function () {
        comp(db);
    }, function (errorEvent) {
        err(errorEvent);
    });
};

通过在_openDb声明后添加清单 14-3 中的代码来定义_refreshFromDb

清单 14-3。 用数据库中的数据填充内存列表

var _refreshFromDb = function (db) {
    return new WinJS.Promise(function (comp, err) {
        while (storage.projects.pop()) { }
        while (storage.timeEntries.pop()) { }
        var transaction = db.transaction(["projects", "timeEntries"]);

        transaction.objectStore("projects").openCursor().onsuccess = function (event) {
            var cursor = event.target.result;
            if (cursor) {
                var project = data.Project.createFromDeserialized(cursor.value);
                storage.projects.push(project);
                cursor.continue();
            };
        };

        transaction.objectStore("timeEntries").openCursor().onsuccess = function (event) {
            var cursor = event.target.result;
            if (cursor) {
                var timeEntry = data.TimeEntry.createFromDeserialized(cursor.value);
                storage.timeEntries.push(timeEntry);
                cursor.continue();
            };
        };

        transaction.oncomplete = comp;
        transaction.onerror = err;
    });
}

_refreshFromDb函数返回一个Promise,它首先清空内存列表。与所有 IndexedDB 操作一样,在事务中,游标对数据库中的每个对象存储打开。只要游标有值,该值就会被添加到适当的内存列表中。事务的oncomplete处理程序被设置为Promisecomp处理程序,这将触发清单 14-2 中的done函数。此时,调用_openDb Promisecomp处理程序来提供对数据库的引用,该引用可用于引用_openDb的任何代码。

image 注意将 WinJS 对象保存到 IndexedDB 数据库时,只保存构造函数中定义的属性。当从数据库中检索对象时,任何定义为实例成员的属性,如TimeEntry类中的project属性,都没有值。为了缓解这个问题,在ProjectTimeEntry类中,我都添加了一个新的静态函数,名为createFromDeserialized。该函数获取从游标返回的匿名对象,并基于这些值创建一个完全水合的对象。这些函数的定义可以在本书附带的源代码中找到。你可以在这本书的产品页面的源代码/下载标签中找到本章的代码示例(www.apress.com/9781430257790)。

如果你现在运行 Clok,你会很快发现没有数据(见图 14-1 )。我们删除了测试数据,现在正在用数据库中的数据填充这些列表。但是,数据库中还没有数据。在这一章的后面,我将展示一些对我们开发人员有帮助的功能,通过提供一种方法来重置我们的测试数据和探索数据库中的数据。在此之前,让我们对 Clok 进行修改,这样当用户保存或删除数据时,我们的数据库就会更新。

9781430257790_Fig14-01.jpg

图 14-1 。Clok 中没有项目

image 注意在 Clok 中,我已经使用 IndexedDB 来填充在前面章节中添加的数据的内存列表。这提供了不需要对用于显示和管理项目和时间输入数据的现有屏幕进行任何改变的好处。如果您愿意,您可以实现IListDataSource接口来创建自己的数据源,直接使用 IndexedDB。这将允许你绑定ListView控件,例如,直接绑定到你的数据,而不必像我在 Clok 中做的那样,先把它加载到一个WinJS.Binding.List对象中。关于如何做到这一点的示例,请参见下面的博客条目:http://stephenwalther.com/archive/2012/07/10/creating-an-indexeddbdatasource-for-winjs

当数据改变时更新索引数据库

通过运行应用,您可能不知道这一点,但我们已经做了所有必要的更改,将数据从 IndexedDB 数据库加载到内存中,以便在屏幕上显示。在这一节中,我将展示从数据库中保存和删除数据所需的更改。幸运的是,所有需要的更改都局限于storage.js,这是将数据加载到内存列表并在整个应用中使用这些列表的另一个好处。将清单 14-4 中的代码添加到storage.js_refreshFromDb 的定义之后。

清单 14-4。 方法来处理 IndexedDB 数据库中的数据

var _getObjectStore = function (db, objectStoreName, mode) {
    mode = mode || "readonly";

    return new WinJS.Promise(function (comp, err) {
        var transaction = db.transaction(objectStoreName, mode);
        comp(transaction.objectStore(objectStoreName));
        transaction.onerror = err;
    });
};

var _saveObject = function (objectStore, object) {
    return new WinJS.Promise(function (comp, err) {
        var request = objectStore.put(object);
        request.onsuccess = comp;
        request.onerror = err;
    });
};

var _deleteObject = function (objectStore, id) {
    return new WinJS.Promise(function (comp, err) {
        var request = objectStore.delete(id);
        request.onsuccess = comp;
        request.onerror = err;
    });
};

image 注意所有这些函数都返回Promise对象,你也可以修改savedelete函数来返回Promise对象。虽然我不会在本章中讨论它,但是您可以对调用这些函数的各个地方进行更新,以利用 async 提供的好处。例如,您可以在用户点击保存按钮时提供一个进度指示器,然后在完成Promise时移除它。

定义了这些函数后,我们现在可以对savedelete函数进行修改。用清单 14-5 中指定的新版本替换保存和删除项目的功能。

清单 14-5。 新版本功能保存和删除项目

storage.projects.save = function (p) {
    if (p && p.id) {
        var existing = storage.projects.getById(p.id);
        if (!existing) {
            storage.projects.push(p);
        }

        return _openDb.then(function (db) {
            return _getObjectStore(db, "projects", "readwrite");
        }).then(function (store) {
            return _saveObject(store, p);
        });
    }

    return WinJS.Promise.as();
};

storage.projects.delete = function (p, permanent) {
    permanent = permanent || false;

    if (p && p.id) {
        if (!permanent) {
            var existing = storage.projects.getById(p.id);
            if (existing) {
                // soft delete = default
                existing.status = data.ProjectStatuses.Deleted;
                return storage.projects.save(existing);
            }
        } else {
            var index = this.indexOf(p);
            if (index >= 0) {
                this.splice(index, 1);

                return _openDb.then(function (db) {
                    return _getObjectStore(db, "projects", "readwrite");
                }).then(function (store) {
                    return _deleteObject(store, p.id);
                });
            }
        }
    }
    return WinJS.Promise.as();
};

save函数的修改非常简单。我只是添加了代码来连接到数据库,选择正确的对象存储,并将项目对象保存到该存储中。然而,我对delete函数做了一些修改。以前,没有办法从 Clok 中永久删除一个项目。项目只是被分配了一个Deleted状态,然后被保存。在典型的 Clok 使用过程中,情况依然如此。然而,当我们在下一节中添加重置临时数据的功能时,我们将需要一种永久删除数据的方法。

在这两个函数中,现在都返回了一个Promise。如果操作成功,在清单 14-4 中相应的_saveObject_deleteObject函数中定义的Promise将返回给调用代码。否则,使用WinJS.Promise.as函数返回一个空的Promise,不向其提供任何参数。这最后一步不是必需的,但是它确保了通过调用savedelete返回的对象类型的一致性——它总是一个Promise

保存和删除时间条目功能的更新版本可以在清单 14-6 中看到。这些函数的变化与清单 14-5 中的非常相似,尽管delete函数更简单,因为它总是永久删除时间条目。

清单 14-6。 新版本功能保存和删除时间条目

storage.timeEntries.save = function (te) {
    if (te && te.id) {
        var existing = storage.timeEntries.getById(te.id);
        if (!existing) {
            storage.timeEntries.push(te);
        }

        return _openDb.then(function (db) {
            return _getObjectStore(db, "timeEntries", "readwrite");
        }).then(function (store) {
            return _saveObject(store, te);
        });
    }

    return WinJS.Promise.as();
};

storage.timeEntries.delete = function (te) {
    if (te && te.id) {
        var index = this.indexOf(te);
        if (index >= 0) {
            this.splice(index, 1);

            return _openDb.then(function (db) {
                return _getObjectStore(db, "timeEntries", "readwrite");
            }).then(function (store) {
                return _deleteObject(store, te.id);
            });
        }
    }

    return WinJS.Promise.as();
};

image 注意清单 14-5 和清单 14-6 中的,你会注意到当我更新数据时,不管是保存还是删除,我都在更新内存列表和 IndexedDB 数据库。另一种方法是只更新数据库,然后从数据库中重新填充列表,类似于第一次建立数据库连接时加载列表的方式。这种方法没有错,但是我选择这个方向是为了最小化对应用的更改。每次重新加载列表都需要更改各种屏幕,以便在列表重新填充后重新加载数据。

现在让我们将一些临时数据放回到 Clok 中,以便于测试。

IndexedDB 浏览器

出于开发的目的,在 Clok 中拥有临时测试数据还是不错的。事实上,因为我们刚刚实现了持久存储所有 Clok 数据的功能,所以如果能够清除任何数据并将数据库重置为默认测试状态就好了。在这一节中,我将向您展示如何添加一个 settings 弹出按钮,它不仅允许您重置测试数据,还提供了一个小的数据库浏览器,您可以使用它来查看存储在 IndexedDB 数据库中的各种对象。

创建“设置”弹出按钮

settings文件夹中添加一个名为idbhelper.html的 HTML 文件。用清单 14-7 中的代码替换idbhelper.html的默认内容。

清单 14-7。 新设置弹出的外壳

<!DOCTYPE html>
<html>
<head>
    <title>IndexedDB Helper</title>
</head>
<body>
    <div id="settingsDiv" data-win-control="WinJS.UI.SettingsFlyout"
        aria-label="IndexedDB Helper"
        data-win-options="{settingsCommandId:'idbhelper',width:'wide'}">

        <div class="win-ui-dark win-header" style="background-color: #000046;">
            <button type="button" class="win-backbutton"
                onclick="WinJS.UI.SettingsFlyout.show()">
            </button>
            <div class="win-label clok-logo">IndexedDB Helper</div>
        </div>
        <div class="win-content">
            <div class="win-settings-section">

            </div>
        </div>
    </div>
</body>
</html>

接下来,通过添加清单 14-8 中突出显示的代码来修改default.js。不要像添加optionsabout设置弹出按钮那样将新的命令定义添加到e.detail.applicationcommands中,而是单独添加,这样更容易根据用户的偏好显示或隐藏它,这一功能将在第十五章的中添加。

清单 14-8。 布线设置弹出按钮

WinJS.Application.onsettings = function (e) {
    e.detail.applicationcommands = {
        "options": {
            title: "Clok Options",
            href: "/settings/options.html"
        },
        "about": {
            title: "About Clok",
            href: "/settings/about.html"
        }
    };

    e.detail.applicationcommands.idbhelper = {
        title: "IndexedDB Helper",
        href: "/settings/idbhelper.html"
    };

    WinJS.UI.SettingsFlyout.populateSettings(e);
};

现在你有了一个新的空的设置弹出按钮,让我们添加一些功能来帮助开发人员构建和测试 Clok。

从微软下载并配置 IDBExplorer

微软的 Internet Explorer 团队开发了一个名为 IDBExplorer 的工具。最初在内部使用,微软向开发人员开放,以探索他们的 IndexedDB 数据库,包括结构和数据。你可以从下面的博客文章中阅读和下载这个工具:http://blogs.msdn.com/b/ie/archive/2012/01/25/debugging-indexeddb-applications.aspx。下载包含该工具的 ZIP 文件,并将名为IDBExplorer的文件夹从该包复制到 Visual Studio 项目的settings文件夹中。图 14-2 显示了完成后你应该有的正确的文件夹层次结构。您可以在本书附带的源代码中参考这个过程的完整版本。

9781430257790_Fig14-02.jpg

图 14-2 。将 IDBExplorer 工具添加到设置文件夹中

如果我们建立的是一个网站,而不是一个 Windows 应用商店,我们早就完成了。然而,为了让这个有用的工具在我们的应用中工作,我们还需要完成几个步骤。IDBExplorer 包含一个旧版本的 jQuery。然而,jQuery 的最新版本在 Windows Store 应用中工作得更好,所以我建议从www.jquery.com下载 jQuery 2.0 版或更高版本,并将其添加到您刚刚添加到项目的IDBExplorer中。我选择下载缩小版,如图图 14-3 所示,但是未压缩版也可以。

9781430257790_Fig14-03.jpg

图 14-3 。更新到新版本的 jQuery

image 注意虽然 jQuery 版与 Windows Store 应用配合良好,但在调试模式下运行 Clok(按 F5 而不是 Ctrl+F5)时,您可能偶尔会看到错误。跳过调试器中可能出现的任何错误是安全的。正常运行 Clok 时,如果没有附加调试器,这些错误不会有任何负面影响。我怀疑 jQuery 的某个未来版本会消除这个问题。

在将 IDBExplorer 工具添加到设置弹出按钮之前,您必须对其本身进行的最后一项更改是用来自清单 14-9 的代码更新IDBExplorer.html的内容。

清单 14-9。 更新页面以更好地与 Windows 应用商店应用配合使用 s

<!DOCTYPE html>
<html FontName2">http://www.w3.org/1999/xhtml ">
<head>
    <!-- IDBExplorer references -->
    <script src="jquery-2.0.2.min.js"></script>
    <script src="jquery.jstree.js"></script>
    <script src="IDBExplorer.js"></script>
    <link rel="stylesheet" type="text/css" href="IDBExplorer.css" />
    <link rel="stylesheet" type="text/css" href="style.css" />
</head>
<body onload="setDBName();initIDBExplorer();">
</body>
</html>

这样,IDBExplorer 就不再需要修改了,您只需通过添加一个iframe来托管它,从而在 settings 弹出菜单中显示这个工具。将清单 14-10 中突出显示的代码添加到idbhelper.html

清单 14-10。 在 iframe 中托管 IDBExplorer

<div class="win-settings-section">
    <iframe style="width: 550px; height: 600px"
        src="/settings/IDBExplorer/IDBExplorer.html?name=Clok"></iframe>
</div>

立即运行 Clok 并打开 IndexedDB 辅助程序设置弹出按钮。您应该看到 IDBExplorer 显示了关于我们的空数据库的信息(见图 14-4 )。一旦我们将测试数据添加回 Clok,这个工具将会更有帮助。我将在下一节向您展示如何做到这一点。

9781430257790_Fig14-04.jpg

图 14-4 。IDBExplorer 显示一个空数据库

添加按钮以重置和加载测试数据

在本章的前面,我让你删除了storage.js中加载临时数据到 Clok 的代码。在这一节中,我将向您展示如何将临时数据放回到 Clok 中。首先,在 IndexedDB 辅助设置弹出按钮的顶部添加几个按钮。将清单 14-11 中突出显示的代码添加到idbhelper.html中。

清单 14-11。 添加按钮来重置我们的测试数据

<div class="win-settings-section">
    <button onclick="deleteAllData();" style="background-color:red;">Delete All Data</button>
    <button onclick="addTestData();">Add Test Data</button>
    <iframe style="width: 550px; height: 600px"
        src="/settings/IDBExplorer/IDBExplorer.html?name=Clok"></iframe>
</div>

因为 IndexedDB Helper settings 弹出按钮仅供开发人员使用,并且应该在部署之前从项目中删除,所以我决定直接在idbhelper.html中添加 JavaScript 代码。将来自清单 14-12 的脚本引用添加到idbhelper.htmlhead元素中。

清单 14-12。 JavaScript 引用和按钮点击处理程序

<script src="//Microsoft.WinJS.1.0/js/base.js"></script>
<script src="//Microsoft.WinJS.1.0/js/ui.js"></script>

<script src="/js/extensions.js"></script>
<script src="/js/utilities.js"></script>

<script src="/data/project.js"></script>
<script src="/data/timeEntry.js"></script>
<script src="/data/storage.js"></script>

<script>

    function deleteAllData() {
        // SNIPPED
    }

    function addTestData() {
        // SNIPPED
    }

</script>

重置我们的临时数据是一个两步过程。首先,我们必须删除当前数据库中的所有数据,然后我们必须将临时数据添加到数据库中。用清单 14-13 中的代码替换idbhelper.html中的deleteAllData函数。

清单 14-13。 删除所有数据按钮处理程序

function deleteAllData() {
    var msg = new Windows.UI.Popups.MessageDialog(
        "This cannot be undone.  Do you wish to continue?",
        "You're about to remove all data from Clok.");

    msg.commands.append(new Windows.UI.Popups.UICommand(
        "Yes, Delete It", function (command) {

            var storage = Clok.Data.Storage;
            storage.projects.forEach(function (p) {
                storage.projects.delete(p, true);
            });
            storage.timeEntries.forEach(function (te) {
                storage.timeEntries.delete(te);
            });
        }));

    msg.commands.append(new Windows.UI.Popups.UICommand(
        "No, Don't Delete It", function (command) { }));

    msg.defaultCommandIndex = 0;
    msg.cancelCommandIndex = 1;

    msg.showAsync();
}

单击“删除所有数据”按钮时,会出现一个消息对话框,要求在继续之前进行确认。如果确认,所有项目和时间条目将从内存列表中永久删除,进而从 IndexedDB 数据库中删除数据。

添加临时数据的代码与我们在第十一章中添加的代码几乎相同,有三个显著的不同:

  • 如果任何数据已经存在,则不能添加测试数据。
  • 我现在调用projects.savetime.save,而不是调用projects.pushtime.push将对象添加到列表中,这将把项目添加到WinJS.Binding.List对象和 IndexedDB 数据库中。
  • 其中一个项目指定了地址细节,这在本章后面讨论远程数据时会有帮助。

用清单 14-14 中的代码替换idbhelper.html中的addTestData函数。

清单 14-14。 添加测试数据按钮处理程序

function addTestData() {

    var projects = Clok.Data.Storage.projects;
    var time = Clok.Data.Storage.timeEntries;

    if (projects.length > 0 || time.length > 0) {
        var msg = new Windows.UI.Popups.MessageDialog(
            "You cannot add test data since Clok already contains data.",
            "Cannot add test data.");
        msg.showAsync();
        return;
    }

    var createProject = function (name, projectNumber, clientName, id, status) {
        // SNIPPED
    }

    // SNIPPED

    // one needs an address for map example
    var project = createProject(name1, "2012-0017", client3, 1368296808748);
    project.address1 = "1 Microsoft Way";
    project.city = "Redmond";
    project.region = "WA";
    project.postalCode = "98052";
    projects.save(project);

    // SNIPPED

    var createTime = function (id, projectId, dateWorked, elapsedSeconds, notes) {
        // SNIPPED
    }

    // SNIPPED

    time.save(createTime(timeId++, 1368296808757, date1, 10800, "Lorem ipsum dolor sit."));

    // SNIPPED
}

image 你可以在本书附带的源代码中找到更详细的addTestData版本。您可以在该书的 press product 页面的 Source Code/Downloads 选项卡(www.apress.com/9781430257790)上找到本章的代码示例。

运行 Clok 并打开 IndexedDB 助手设置弹出按钮。点击按钮添加测试数据,然后查看项目节点(见图 14-5 )和时间条目节点。您应该会看到所有的测试数据都被列出来了,并且您可以点击顶部窗格中的不同项目,以查看下部窗格中的详细信息。当您对项目时间条目进行更改时,这些更改将反映在 IDBExplorer 的设置弹出菜单中。每当您需要新的测试数据时,只需返回到此设置弹出按钮来删除当前数据并添加新的测试数据。

9781430257790_Fig14-05.jpg

图 14-5 。已完成的索引数据库帮助程序

image 注意您可能会注意到,首次启动 Clok 时,仪表板屏幕上的项目下拉列表最初并不总是包含活动项目的完整列表。但是,如果您导航到不同的页面,然后返回,列表是完整的。这是因为数据从 IndexedDB 数据库异步加载到填充该控件的WinJS.Binding.List中。我们可以通过使用承诺在数据加载后填充控件来解决这个问题。取而代之的是,我们将暂时保留这个问题,当我在第十七章的中讲述处理应用状态变化时,我们将解决这个问题。

SQLite〔??〕

对于本地数据存储来说,IndexedDB 是一个非常方便的选项,但它不是唯一的选项。如果你的背景和我的相似,你可能有相当多的使用关系数据库系统的经验,比如 Microsoft SQL Server。遗憾的是,您无法从 Windows 应用商店应用直接访问存储在基于服务器的数据库中的数据。您可以编写一个服务层来支持您的关系数据库,并将其作为远程数据源进行访问。

您还可以使用 SQLite 之类的工具在 Windows 应用商店应用中构建本地数据库。虽然这不是现成的直接支持,我也不会在本书中讨论,但是您可以使用第三方库来为您的应用添加对 SQLite 的支持。其中一个名为 SQLite3-WinRT 的库可以在这里找到:https://github.com/doo/SQLite3-WinRT

文件存储

除了 IndexedDB 和 SQLite,还可以使用文件在本地存储数据。您可以将 JavaScript 对象保存到文件中。就此而言,在文件中,你可以将文本保存为任何你想要的格式。我不会在这里讨论如何处理文件,但是我会在第十六章中讨论这个话题。

远程数据源

虽然选择在本地存储数据很重要,但这并不能降低支持远程数据源的需求。使用远程数据的场景数不胜数,例如:

  • 获取到客户办公室的路线—我们接下来将研究这个场景
  • 阅读和发送电子邮件
  • 订阅新闻或博客订阅源
  • 计算各航运公司的运费
  • 从公司 CRM 中查看和编辑客户数据
  • 从同一台机器上运行的另一个进程获取数据

image 注意需要注意的是,术语本地数据不仅仅指与应用在同一台机器上的数据。它指的是存储在应用本身中的数据。因为 Windows 应用商店应用彼此独立,所以一个应用不能直接访问另一个应用包含的数据,即使这两个应用是由同一开发人员创建的。尽管这两个应用在同一台机器上,但它们之间有一道墙,数据共享必须通过某种类型的服务进行。

通常,来自远程服务的数据通过 HTTP 公开,要么使用 REST API,要么使用 RPC API。如果您不熟悉这些术语,网上有大量关于 REST 和 RPC 的详细信息,但是它们之间的最大区别是 REST 强调查找和使用一些数据(资源),而 RPC(如 SOAP)强调执行一些远程操作,本质上是调用在不同进程中运行的函数。完全简化一下,REST 侧重于名词,RPC 侧重于动词。我将要介绍的例子从 REST API 获取数据。

WinJS.xhr

如果您开发 web 应用已经有一段时间了,那么您应该熟悉XMLHttpRequest (XHR)、,它用于向某处的 web 服务器提交请求并评估响应。web 服务器可以位于公共互联网、私有网络上,甚至与发出请求的应用位于同一台机器上。此外,尽管 XML 是其名称的一部分,XMLHttpRequest也可以从服务器接收 JSON 数据。因为对远程服务器的请求不会立即响应,所以使用XMLHttpRequest发出的请求将指定一个处理函数,在响应可用时执行。清单 14-15 显示了一个非常简单的例子,使用XMLHttpRequest请求someUrl并对来自服务器的响应做一些事情。

清单 14-15。 用 XMLHttpRequest 从远程服务器请求东西

var request = new XMLHttpRequest();

request.onreadystatechange = function() {
    if (request.readyState === 4 && request.status === 200) {
        // do something with the response
    } else {
        // something bad happened
    }
}

var asyncRequest = true;
request.open("GET", someUrl, asyncRequest);
request.send();

这个例子没有完全实现。它只检查readyState4(请求已经完成)和 HTTP 状态代码是200的情况。它认为任何其他情况都是错误的。WinJS.xhr函数将XMLHttpRequest的功能封装在WinJS.Promise中。响应在Promisethendone函数中可用,而不是指定处理函数。清单 14-16 执行与清单 14-15 相同的任务,使用WinJS.xhr和承诺代替。

清单 14-16。 用 WinJS.xhr 向远程服务器请求东西

WinJS.xhr({ url: someUrl, type: "GET" })
    .done(function success(completeEvent) {
        // do something with the response
    }, function err(errorEvent) {
        // something bad happened
    });

可以说它读起来更简单一些,但是因为这个函数返回了一个Promise,所以它非常适合 WinJS 中流行的异步编程风格。除了用GET方法请求数据,还可以用WinJS.xhr提交数据。如果您使用WinJS.xhr提交数据,您可能会使用POST方法而不是GET,并且还必须为 options 参数指定一个data属性。例如,您可以使用类似于清单 14-17 的代码将一个新用户保存到远程数据源中。

清单 14-17。 用 WinJS.xhr 发布数据

WinJS.xhr({ url: someUrl, type: "POST", data: { name: "Scott", dob: "Dec 1" } })

Clok 的一个新需求是为用户提供到客户所在地的驾驶方向。我将带你通过配置和使用 Bing Maps API 和WinJS.xhr来添加这个功能。

必应地图设置

你可能熟悉微软的必应地图产品。与其他提供地图和方向的公司一样,微软也为开发者提供了一个 API,将必应地图集成到他们自己的软件中。在撰写本文时,Bing 地图可以添加到 Windows Store 应用中,对于每天使用少于 50,000 笔交易的应用(www.microsoft.com/maps)不收取许可费。尽管我预计 Clok 会在 Windows Store 取得巨大成功,但我不认为使用率会很快接近这个数字。

尽管这项服务是免费的,但至少在最初,为了使用 Bing Maps API,需要一个密钥。创建一个帐户并登录到 Bing 地图门户(www.bingmapsportal.com)。从这里开始,为 Windows 应用商店应用创建一个新的基本密钥(见图 14-6 )。

9781430257790_Fig14-06.jpg

图 14-6 。请求阿炳地图 API 密钥

一旦你完成了表格,你的钥匙就可以用了。在图 14-7 中,你可以看到我的键列表,键本身被模糊掉了。这是一长串字母和数字。一会儿我会告诉您在哪里添加 Clok 的键。如果你把它放错了地方,你可以在任何时候从 Bing 地图门户检索到它。

9781430257790_Fig14-07.jpg

图 14-7 。我当前的 Bing 地图 API 密钥列表

现在你有了钥匙,你需要一个地方来放它。在 Visual Studio 项目的data文件夹中创建一个名为bingMapsWrapper.js 的新 JavaScript 文件。将清单 14-18 中的代码添加到bingMapsWrapper.js中。确保在apikey变量中添加 Bing Maps API 密钥。

清单 14-18。 定义 BingMaps 类

(function () {
    "use strict";

    var apikey = "PUT_YOUR_KEY_HERE";
    var apiEndpoint = " http://dev.virtualearth.net/REST/v1/ ";
    var xhrTimeout = 2000;

    var mapsClass = WinJS.Class.define(
        function constructor() { /* empty constructor */ },
        { /* static class, no instance members */ },
        {
            credentials: {
                get: function () { return apikey; }
            },

            getDirections: function (start, end) {
                // TODO: get the directions here
            }
        }
    );

    WinJS.Namespace.define("Clok.Data", {
        BingMaps: mapsClass,
    });
})();

到目前为止,这个类还很简单,只在credentials属性中公开了您的键。getDirections函数接受起始地址(start)和目的地地址(end),并将使用这些值向 Bing 地图服务请求驾驶路线。用清单 14-19 中的代码替换getDirections的定义。

image 注意一定要给default.js中的bingMapsWrapper.js文件添加一个脚本引用。

清单 14-19。 向必应地图服务请求路线

getDirections: function (start, end) {
    var distanceUnit = "mi";

    var routeRequest = apiEndpoint + "Routes?"
        + "wp.0=" + start
        + "&wp.1=" + end
        + "&du=" + distanceUnit
        + "&routePathOutput=Points&output=json"
        + "&key=" + apikey;

    return WinJS.Promise.timeout(xhrTimeout, WinJS.xhr({ url: routeRequest }))
        .then(function (response) {
            var resp = JSON.parse(response.responseText);

            if (resp
                    && resp.resourceSets
                    && resp.resourceSets[0]
                    && resp.resourceSets[0].resources
                    && resp.resourceSets[0].resources[0]
                    && resp.resourceSets[0].resources[0].routeLegs
                    && resp.resourceSets[0].resources[0].routeLegs[0]
                    && resp.resourceSets[0].resources[0].routeLegs[0].itineraryItems
                    && resp.resourceSets[0].resources[0].routeLegs[0].itineraryItems.length > 0
            ) {
                var directions = {
                    copyright: resp.copyright,
                    distanceUnit: resp.resourceSets[0].resources[0].distanceUnit,
                    durationUnit: resp.resourceSets[0].resources[0].durationUnit,
                    travelDistance: resp.resourceSets[0].resources[0].travelDistance,
                    travelDuration: resp.resourceSets[0].resources[0].travelDuration,
                    bbox: resp.resourceSets[0].resources[0].bbox
                }

                var itineraryItems =
                    resp.resourceSets[0].resources[0].routeLegs[0].itineraryItems.map(
                        function (item) {
                            return {
                                compassDirection: item.compassDirection,
                                instructionText: item.instruction.text,
                                maneuverType: item.instruction.maneuverType,
                                travelDistance: item.travelDistance,
                                travelDuration: item.travelDuration,
                                warnings: item.warnings || []
                            };
                        });

                directions.itineraryItems = new WinJS.Binding.List(itineraryItems);

                return directions;
            }

            return null;
        });
}

image 注意目前,Clok 将只提供以英里为单位的驾驶方向。在第十五章中,你将添加一个特性,允许用户指定他或她喜欢英里还是公里。

在构建 Bing 地图服务的 URL 之后,该值被传递给WinJS.xhr函数,我们已经将它封装在对WinJS.Promise.timeout的调用中。这种技术通常用于限制应用尝试连接到指定 URL 的时间。在本例中,xhrTimeout被设置为 2000 毫秒,因此如果任何从服务获取方向的尝试花费的时间超过 2 秒,用户将被视为离线,请求将被取消。在本章的后面,我将处理取消操作产生的错误,向用户显示适当的消息。

另一方面,如果请求成功,响应(JSON 格式的文本)将被解析成一个名为resp的 JavaScript 对象。来自 Bing 地图服务的有效响应具有非常深的层次结构。如果resp已经定义了这个层次,那么就构建了一个directions对象。该对象是收到的实际响应的简化版本。为了在为数据构建 UI 时简化数据绑定,我删除了层次结构中许多不必要的字段和层。getDirections函数返回一个Promise,而directions对象将通过thendone函数用于新方向页面。我稍后将对此进行说明,但首先我们需要对项目详细信息页面进行一些更改,以允许用户请求驾驶方向。

向项目详细信息添加按钮

用户将需要一种方法来导航到我们将在下一节创建的新方向页面。您必须在项目详细信息屏幕上的应用栏中添加一个按钮,该按钮将导航到新页面。至此,您已经能够在应用栏中添加按钮并处理click事件了,所以我只总结一下要点。向项目详细信息屏幕上的应用栏添加方向按钮。将icon属性设置为directions,将disabled属性设置为true(参见清单 14-20 )。

清单 14-20。 在项目详情屏幕上添加应用栏按钮

<button
    data-win-control="WinJS.UI.AppBarCommand"
    data-win-options="{
        id:'goToDirectionsCommand',
        label:'Directions',
        icon:'directions',
        section:'selection',
        tooltip:'Directions',
        disabled: true}">
</button>

接下来,将清单 14-21 中的代码添加到detail.js中。不要忘记在detail.js的就绪函数中连接这个click事件处理程序。

清单 14-21。 导航至方向屏幕

goToDirectionsCommand_click: function (e) {
    if (this.currProject
            && this.currProject.id
            && this.currProject.isAddressSpecified()) {
        WinJS.Navigation.navigate("/pages/projects/directions.html", {
            project: this.currProject
        });
    }
},

通过添加来自清单 14-22 的高亮代码来修改detail.js中的configureAppBar函数。这将允许为有地址的项目启用方向按钮。如果正在查看的项目尚未保存地址,或者项目尚未保存,按钮将保持禁用状态。

清单 14-22。 启用指路按钮,如果当前项目有地址

configureAppBar: function (existingId) {
    var fields = WinJS.Utilities.query("#projectDetailForm input, "
        + "#projectDetailForm textarea, "
        + "#projectDetailForm select");

    fields.listen("focus", function (e) {
        projectDetailAppBar.winControl.show();
    }, false);

    if (existingId) {
        deleteProjectCommand.winControl.disabled = false;
        goToTimeEntriesCommand.winControl.disabled = false;

        if (this.currProject.isAddressSpecified()) {
            goToDirectionsCommand.winControl.disabled = false;
        }
    }
},

清单 14-21 中的和清单 14-22 中的都引用了一个名为isAddressSpecified的新函数。要定义这个函数,添加清单 14-23 中的代码,作为data\project.jsProject类的实例成员。

清单 14-23。 一个确定项目是否有指定地址的函数

isAddressSpecified: function () {
    return (!!this.address1
            || !!this.city
            || !!this.region
            || !!this.postalCode);
}

运行 Clok 并导航到几个不同的项目,一个有客户端地址,一个没有。如果您使用了本书附带的源代码中指定的测试数据,那么一个项目将会有一个地址。如果您的测试数据中没有一个项目有客户端地址,那么添加一个地址。图 14-8 显示了没有客户地址的项目的项目详细信息屏幕,而图 14-9 显示了有客户地址的项目的相同屏幕。

9781430257790_Fig14-08.jpg

图 14-8 。此项目没有客户地址

9781430257790_Fig14-09.jpg

图 14-9 。这个项目有一个客户地址,用户可以请求方向

这样,用户现在可以导航到新方向页面。如果我们已经创造了它,他们就可以。现在,如果你点击方向按钮,应用只会崩溃。

显示驾驶方向

完成此功能的最后一步是向用户实际显示驾驶方向。让我们为新的方向页面定义一些简单的要求。

  • 用户可以输入他或她的起始地址。
  • 如果由于某种原因无法检索到方向,用户将会看到一条简单的错误消息。
  • 如果从 Bing 地图服务中成功检索到方向,它们将显示在列表中。

pages\projects文件夹中创建一个名为directions.html的新页面控件。将页面标题设置为 Directions,并用来自清单 14-24 的代码替换主要部分元素的内容。

清单 14-24。 指点页面

<div id="directionsTemplate" data-win-control="WinJS.Binding.Template" style="display: none">
    <div class="directionsItem">
        <div class="directionsItem-instruction">
            <h3 class="directionsItem-instructionText"
                data-win-bind="textContent: instructionText"></h3>
        </div>
        <div class="directionsItem-distance">
            <h2 class="directionsItem-formattedDistance"
                data-win-bind="textContent: travelDistance
                    Clok.Data.TravelDistanceConverter"></h2>
        </div>
    </div>
</div>

<div id="directionsContainer">
    <div id="locationsPane">
        <h2>Get Directions</h2>
        <div class="formField">
            <label for="fromLocation">From</label><br />
            <input id="fromLocation">
        </div>
        <div class="formField">
            <label for="toLocation">To</label><br />
            <span id="toLocation"></span>
        </div>
        <button id="getDirectionsButton">Get Directions</button>
    </div>
    <div id="directionsPane">
        <div id="directionsSuccess" class="hidden">

            <div id="totalDistance">
                Total distance:
                <span
                    data-win-bind="textContent: travelDistance
                        Clok.Data.TravelDistanceConverter"></span>
            </div>
            <div id="totalTime">
                Est. travel time:
                <span
                    data-win-bind="textContent: travelDuration
                        Clok.Data.TravelTimeConverter"></span>
            </div>

            <div
                id="directionsListView"
                class="itemlist win-selectionstylefilled"
                data-win-control="WinJS.UI.ListView"
                data-win-options="{
                    layout: {type: WinJS.UI.ListLayout},
                    itemTemplate: select('#directionsTemplate'),
                    selectionMode: 'none',
                    swipeBehavior: 'none',
                    tapBehavior: 'none'
                }">
            </div>
            <div data-win-bind="textContent: copyright"></div>
        </div>
        <div id="directionsError" class="hidden">
            Could not get directions. Please check your
            addresses and internet connection.
        </div>
    </div>
</div>

页面的布局类似于时间表屏幕,所以我不会详细解释。一边是一个表单,另一边是一个包含方向列表的 ListView。flexbox CSS 布局用于在它们各自的侧面显示它们。

我也不会在这里涵盖完整的 CSS 文件,因为其中没有什么是你没有看过的,在本书附带的源代码中有完整版本的directions.css。这里我要指出的一点是,我已经添加了 CSS 来突出显示方向列表中的最后一步,使用了清单 14-25 中指定的 CSS。

清单 14-25。 突出指示最后一步

#directionsPane #directionsListView .win-container:last-of-type {
    background-color: limegreen;
}

我这样做是为了改善用户体验,因为它给出了一个非常明确的指示,即没有更多的步骤,这在滚动一长串看起来都一样的方向时就不太清楚了。除了last-of-type伪元素,你还可以使用nth-of-type(odd)或者nth-of-type(even)在两种不同的风格之间切换。

用清单 14-26 中的代码替换directions.js的内容。

清单 14-26。 页面定义为指路屏幕

(function () {
    "use strict";

    var maps = Clok.Data.BingMaps;

    WinJS.UI.Pages.define("/pages/projects/directions.html", {
        // This function is called whenever a user navigates to this page. It
        // populates the page elements with the app's data.
        ready: function (element, options) {
            this.populateDestination(options);
            getDirectionsButton.onclick = this.getDirectionsButton_click.bind(this);
        },

        populateDestination: function (options) {
            if (options && options.project) {
                var proj = options.project;

                var addressParts = [
                    proj.address1,
                    proj.city,
                    ((proj.region || "") + " " + (proj.postalCode || "")).trim()];

                this.dest = addressParts.filter(function (part) {
                    return !!part;
                }).join(", ");
                toLocation.textContent = this.dest;
            }
        },

        showDirectionResults: function (hasDirections) {
            if (hasDirections) {
                WinJS.Utilities.removeClass(directionsSuccess, "hidden");
                WinJS.Utilities.addClass(directionsError, "hidden");
            } else {
                WinJS.Utilities.addClass(directionsSuccess, "hidden");
                WinJS.Utilities.removeClass(directionsError, "hidden");
            }
        },

        getDirectionsButton_click: function (e) {

            if (fromLocation.value) {

                maps.getDirections(fromLocation.value, this.dest)
                    .then(function (directions) {

                        if (directions
                                && directions.itineraryItems
                                && directions.itineraryItems.length > 0) {

                            WinJS.Binding.processAll(
                                document.getElementById("directionsContainer"), directions);

                            this.showDirectionResults(true);

                            directionsListView.winControl.itemDataSource
                                = directions.itineraryItems.dataSource;

                            directionsListView.winControl.forceLayout();

                        } else {
                            this.showDirectionResults(false);
                        }
                    }.bind(this), function (errorEvent) {
                        this.showDirectionResults(false);
                    }.bind(this));

            } else {
                this.showDirectionResults(false);
            }
        },
    });
})();

当从项目细节屏幕导航到该屏幕时,当前项目作为options参数的属性被传递到方向页面。populateDestination函数提取目的地地址并将其转换成标准格式,该格式将在获取方向的呼叫中使用。然后,它在屏幕上显示目的地地址。showDirectionResults功能用于在检索到方向时切换方向列表,在检索不到方向时切换错误消息。

对 Bing 地图服务的调用发生在getDirectionsButton_click。如果用户指定了起始地址,则调用BingMaps类中的getDirections。如果有成功的响应,页面的数据绑定将通过调用WinJS.Binding.processAll来连接。否则,将显示错误消息。

最后一步是添加清单 14-24 中引用的绑定转换器。将清单 14-27 中突出显示的代码添加到bingMapsWrapper.js中。

清单 14-27。 为列表方向绑定转换器

var secondsToTravelTimeConverter = WinJS.Binding.converter(function (s) {
    if (s > 3600) {
        return Clok.Utilities.SecondsToHours(s, true) + " hr";
    } else if (s > 60) {
        return (s / 60).toFixed(0) + " min";
    } else {
        return "< 1 min"
    }
});

var travelDistanceConverter = WinJS.Binding.converter(function (distance) {
    if (distance >= 5) {
        return distance.toFixed(0) + " mi";
    } else if (distance >= 0.2) {
        return distance.toFixed(2) + " mi";
    } else {
        return (distance * 5280).toFixed(0) + " ft";
    }
});

WinJS.Namespace.define("Clok.Data", {
    BingMaps: mapsClass,
    TravelTimeConverter: secondsToTravelTimeConverter,
    TravelDistanceConverter: travelDistanceConverter
});

Bing 地图服务以英里为单位返回距离,以秒为单位返回持续时间。我使用了一些简单的公式,根据英里数或秒数转换成一个更加用户友好的值。立即运行 Clok,获取从您所在位置到您的某个客户的路线。图 14-10 应该类似于你的屏幕,在右边的列表中有一个方向列表,包括你的最终目的地用绿色突出显示(或者如果你正在阅读这本书的黑白版本,用一种较浅的灰色)。

9781430257790_Fig14-10.jpg

图 14-10 。从西雅图到雷德蒙的方向

image 注意directions.itineraryItems中的每个值都包含一个maneuverType属性和一个warnings属性。我们不会在本书中使用它们,但是雄心勃勃的开发者可以在ListView中使用它们为用户提供额外的信息。例如,当maneuverType为“右转”时,您可能希望显示一个指向右边的箭头,当出现“收费站”警告时,您可能会显示一个货币符号。Bing 地图服务可以返回 60 多种策略类型和 30 多种警告类型。关于机动类型和警告的更多信息可分别在http://msdn.microsoft.com/en-us/library/gg650392.aspxhttp://msdn.microsoft.com/en-us/library/hh441731.aspx的 MSDN 上获得。

外部库

虽然用 HTML 和 JavaScript 构建的 Windows 应用商店应用中对远程数据的大多数访问将使用WinJS.xhr来检索或提交数据,但还有其他选择。使用外部库,无论是 JavaScript 库还是其他 WinRT 库,都可以实现自己处理远程数据的方法。例如,如果您在应用中使用 jQuery,您可以使用$.get$.post来处理远程 HTTP 服务。在幕后,jQuery 仍然在使用XMLHttpRequest,但是它把它抽象出来了。同样,如果您正在构建或引用一个 C# WinRT 组件,它可能会使用HttpClient类访问远程数据。虽然我将在第十八章的中介绍一个非常简单的 C# WinRT 组件,但我不会用这两种技术来介绍远程数据源。

Azure 移动服务

如果你正在寻找一个完整的远程数据解决方案,我会鼓励你看看 Windows Azure 移动服务。除了数据存储和检索之外,移动服务还提供了许多出色的功能,例如数据验证、单点登录用户身份验证、推送通知等等。所有这些功能在多个平台上都受支持,包括 Windows 应用商店应用、Windows Phone、iOS、Android 和 HTML 应用。移动服务是一个巨大的话题,可以用一整本书来专门讨论它。与其在这里尝试,我建议去 Windows Azure 移动服务在线开发中心获取文档和教程。

结论

外面有很多数据。有时候,你需要它在你的应用中本地可用,供离线使用,IndexedDB 是一个很好的选择。其他时候,您需要从第三方服务访问数据,或者将数据保存到您公司开发的自定义 HTTP 服务中。在这些情况下,WinJS.xhr是一个很好的起点。然而,还存在其他选择,包括 SQLite,用于本地数据存储,以及使用外部库或 Windows Azure Mobile 服务来提供对远程数据源的访问。

Clok 现在是一个人们可以实际使用的应用。在本章之前,没有保存任何数据,每次启动应用时,任何更改都会丢失。随着本章中 IndexedDB 的引入,现在可以保存对项目和时间条目的所有更改。在接下来的几章中,我们还将继续进行一些改进,以改善用户体验,包括允许用户保存一些应用偏好,我将在第十五章中介绍。

十五、会话状态和设置

在第十四章中,我介绍了一些使用本地和远程数据源的技术。除了用户在应用中创建并与之交互的数据之外,通常还需要保存和加载其他值。例如,您可能希望保存用户正在处理的表单的内容,以便您可以在应用终止后重新填充表单。或者用户可能希望在每次运行应用时指定某些首选项。

尽管可能,但将这些类型的值存储在 IndexedDB 数据库中并不理想。实际上,虽然 IndexedDB 可以高效地存储大量信息,但有更好的方法来存储这些类型的值。如果您的应用没有其他理由使用 IndexedDB,尤其如此,因为创建和连接 IndexedDB 数据库会产生开销。

我所指的值的类型被认为是会话状态或设置。会话状态和设置是类似的概念,因为它们允许您使用简单的语法存储简单的值,通常很小。Windows 应用商店应用可以利用会话状态和两种类型的设置—本地和漫游。在本章中,我将介绍以下主题:

  • 会话状态:如果应用被挂起和终止,存储维护和恢复应用状态所需的值
  • 本地设置:存储在应用启动之间和重启之后必须保持的值。用户当前使用的单台计算机的本地
  • 漫游设置:功能上与本地设置相同,只是它们在多台机器之间同步

image 微软在 MSDN 发表了一篇题为《高效存储和检索状态》的文章它比较了您可能考虑用于存储数据、会话状态和设置的各种选项。可以在http://msdn.microsoft.com/en-us/library/windows/apps/hh781225.aspx找到。

会话状态

使用 Windows 8 时,您可以从开始屏幕启动任意数量的应用。您可以让一个应用以全屏模式运行,也可以让两个应用并排显示。您可以随意在打开的应用之间切换,当您返回到之前使用的应用时,您可以从您离开的地方继续。标准的东西,对吧?实际上,所有这些应用可能不会一直运行。事实上,当你从一个应用切换出来时,Windows 会挂起它。该应用将保留在内存中,直到您切换回来,此时 Windows 将恢复该应用。因为应用在内存中,所以这是一个非常无缝的体验,而且应用似乎从未停止运行。

那么,为什么要提起呢?有时,当您从某个应用切换出来后,您的电脑将没有足够的资源将该应用保存在内存中。此时,Windows 将终止该应用,释放它正在使用的资源。例如,假设您正在使用应用来完成一个很长的表单,然后切换到其他地方查找完成表单所需的信息。找到所需信息后,切换回应用并完成表单。如果在您查找信息时,Windows 终止了应用,当您切换回来时会发生什么?解决这个问题是会话状态的目的。会话状态可用于在您使用应用时捕获它的当前状态,然后在终止的应用重新启动时还原它。

幸运的是,WinJS 使得从会话状态中保存和检索项目变得很容易。我将向您展示如何在项目详细信息页面上将会话状态合并到 Clok 中。如果 Clok 在此屏幕上工作时被终止,当它恢复时,会话状态将用于使屏幕看起来就像 Clok 被终止前一样。

保存会话状态

在本节中,您将添加代码以将项目详细信息表单的当前状态保存到会话状态中。当用户更改字段中的值时,您将更新会话状态。这是一个非常简单的要求,实现也非常简单。首先,打开detail.js,在文件顶部附近添加清单 15-1 中突出显示的类别名。

清单 15-1。 添加一些别名来方便自己

var app = WinJS.Application;
var data = Clok.Data;
var storage = Clok.Data.Storage;

WinJS.Application类有一个名为sessionState的对象,这是我们处理会话状态的访问点。因为 JavaScript 是一种动态语言,你可以简单地给sessionState附加新的属性,它们就会被保存。通过添加清单 15-2 中突出显示的代码来更新ready函数。

清单 15-2。 字段改变时更新会话状态

ready: function (element, options) {

    // SNIPPED

    WinJS.Utilities.query("input, textarea, select")
        .listen("change", function (e) {
            this.populateProjectFromForm();
            app.sessionState.currProject = this.currProject;
        }.bind(this));

    projectStatus.addEventListener("change", function (e) {
            this.populateProjectFromForm();
            app.sessionState.currProject = this.currProject;
        }.bind(this));
},

这段代码为任何输入字段、文本区域或下拉列表上的change事件添加一个事件处理程序,并为projectStatus ToggleSwitch添加一个事件处理程序。两个处理程序的代码是相同的。首先,调用您在第十一章中添加的populateProjectFromForm函数。在此之前,该函数仅在保存currProject变量之前使用(见清单 15-3 )。

清单 15-3。 之前使用的 populateProjectFromForm 函数

this.populateProjectFromForm();
storage.projects.save(this.currProject);

在清单 15-3 中,populateProjectFromForm函数根据表单字段中的值更新currProject的值。然后currProject被保存到app.sessionState中,顾名思义,这是 WinJS 应用中存储会话状态的地方。

到目前为止非常简单,当用户对项目细节表单进行更改时,这将非常有助于保持会话状态最新。但是,当用户键入时,不会引发 change 事件。对于文本输入控件,只有在用户更改控件的值,然后将焦点从输入字段移开后,才会引发该事件。在许多情况下,这不会有太大的不同。但是,假设在应用终止之前,您已经在描述字段中键入了几个段落。如果您一直在输入,但从未通过(例如)移动到下一个字段来触发更改事件,则您的更改不会添加到会话状态中。

为了处理这个场景,您必须处理WinJS.Applicationcheckpoint事件,该事件在应用即将被挂起时被触发。将清单 15-4 中高亮显示的代码添加到detail.js中。

***清单 15-4。***处理检查点事件

ready: function (element, options) {

    // SNIPPED

    this.app_checkpoint_boundThis = this.checkpoint.bind(this);
    app.addEventListener("checkpoint", this.app_checkpoint_boundThis);

    WinJS.Utilities.query("input, textarea, select")
        .listen("change", function (e) {
            this.populateProjectFromForm();
            app.sessionState.currProject = this.currProject;
        }.bind(this));

    projectStatus.addEventListener("change", function (e) {
            this.populateProjectFromForm();
            app.sessionState.currProject = this.currProject;
        }.bind(this));
},

checkpoint: function () {
    this.populateProjectFromForm();
    app.sessionState.currProject = this.currProject;
},

尽管对于当前的任务来说可能会更简单,但是添加事件监听器时不寻常的语法很快就会派上用场。简而言之,我将app_checkpoint_boundThis定义为一个函数,它是checkpoint函数,并且this变量的作用域与ready函数中的相同。在checkpoint函数中,使用了与清单 15-2 中定义的另外两个事件处理程序相同的代码。

现在,用户已经做出或正在做出的任何更改都将保存到会话状态中。如果他或她对字段进行了更改,会话状态将立即更新。如果当应用终止时,用户正在进行更改,当应用的checkpoint事件被触发时,会话状态将被更新。

image 注意 Windows 8 在终止你的应用时不会引发事件。当它被挂起时,它会通过checkpoint事件通知应用。当对保存的值进行更改时,以及当应用被挂起时,都应该保存会话状态。

checkpoint事件是WinJS.Application类的一部分。这意味着应用本身,而不仅仅是这个屏幕,正在引发事件。因此,我们希望确保当这个页面不活动时,比如当用户已经导航离开时,我们不必费心处理这个事件。同样,如果用户已经明确地离开了这个页面,我们可以丢弃存储在会话状态中的值。记住:会话状态的目的是让你的应用看起来好像从未被挂起或终止过。在detail.js中的ready函数后添加来自清单 15-5 的代码。

清单 15-5。 当用户导航离开页面时重置内容

unload: function () {
    app.sessionState.currProject = null;
    app.removeEventListener("checkpoint", this.app_checkpoint_boundThis);
},

正在读取会话状态

将对象保存到会话状态非常容易。现在,我将向您展示如何在恢复一个终止的应用时使用它。当前,当该屏幕加载时,执行清单 15-6 中的代码来初始化currProject属性。如果我们正在编辑一个现有的项目,我们将currProject设置为该值;否则,我们将它设置为一个新的、空的Project

***清单 15-6。***curr project 的当前初始化

this.currProject = storage.projects.getById(options && options.id)
    || new Clok.Data.Project();

现在我们添加了会话状态作为初始化currProject的另一个因素,逻辑会变得稍微复杂一些。让我们把这个逻辑移到一个新的函数中,试图让ready函数更容易理解。用清单 15-7 中的代码替换清单 15-6 中的代码,即detail.jsready函数的第一行。

清单 15-7。 替换代码

this.setCurrentProject(options);

你必须添加更新的初始化逻辑,所以通过添加清单 15-8 中的代码来定义detail.js中的setCurrentProject函数。

清单 15-8。 初始化 currProject 的新逻辑

setCurrentProject: function (options) {
    var sessionProject = (app.sessionState.currProject)
        ? data.Project.createFromDeserialized(app.sessionState.currProject)
        : null;

    if (options && options.id && sessionProject && options.id !== sessionProject.id) {
        sessionProject = null;
    }

    this.currProject = sessionProject
        || storage.projects.getById(options && options.id)
        || new Clok.Data.Project();

    app.sessionState.currProject = this.currProject;
},

该函数做的第一件事是确定项目当前是否保存在会话状态中。如果是,但由于某种原因,它不是当前正在查看的同一项目,则会话状态中的值将被忽略。此时,currProject被设置为来自会话状态的Project对象,如果它存在的话。如果不是,但是我们正在编辑一个项目,currProject被设置为那个Project对象。否则,当我们添加一个新项目时,currProject被设置为一个新的空的Project对象。然后,在结束时,currProject被保存到会话状态。

因为项目细节屏幕已经将其表单绑定到了currProject属性,这就是我们所要做的。项目详细信息屏幕现在将在会话状态中保存其状态,并在应用终止后恢复时恢复。让我们看看如何测试这个。

测试暂停和终止

要从 Visual Studio 测试这段代码,您必须调试应用(F5),而不是不调试就运行应用(Ctrl+F5)。您也可以单击 Visual Studio 工具栏上的“调试”按钮。图 15-1 显示了我的调试按钮,调试目标设置为模拟器。这将启动 Clok 并附加 Visual Studio 调试器。

9781430257790_Fig15-01.jpg

图 15-1 。在 Windows 模拟器中调试

立即调试 Clok。我更喜欢使用模拟器,但是如果你愿意,你也可以选择本地机器或者远程机器。导航至现有项目的项目详细信息屏幕,并对一个或多个字段进行更改(参见图 15-2 )。

9781430257790_Fig15-02.jpg

图 15-2 。项目详细信息,在一个字段中进行了更改,在另一个字段中进行了更改

当您仍在项目详细信息屏幕上时,在保存项目之前,切换回 Visual Studio。当 Clok 连接调试器运行时,你会在工具栏上看到一个类似于图 15-3 所示的菜单。如果你只点击按钮,它将暂停应用。相反,如果您展开菜单,您可以选择挂起、恢复或挂起并关闭。

9781430257790_Fig15-03.jpg

图 15-3 。模拟应用终止

由于 Windows 处理应用暂停和恢复的方式,这些场景将“正常工作”,Clok 将表现得好像它从未被中断过一样。然而,挂起和关闭选项模拟当资源太低而无法在不使用时将应用保留在内存中时,Windows 终止应用。如果您现在选择该选项,Clok 将关闭。

但是,如果您再次启动它,它应该会返回到同一个屏幕,您正在进行的更改仍然会显示在表单中。看起来应该还是像图 15-2 。

关于会话状态的快速注释

会话状态是 WinJS 的一个便利特性,它让你的应用看起来好像从未停止运行。但是,有时会丢弃会话状态。

  • 我们添加了代码,以便在用户导航到另一个屏幕时丢弃它。
  • 如果用户手动关闭应用,例如通过按 Alt+F4,或者从触摸屏的顶部向下滑动并将应用拖到屏幕的底部,则它会被丢弃。
  • 当用户重新启动计算机时,它将被丢弃。

在本书附带的代码中(参见本书的 Apress 产品页面[ www.apress.com/9781430257790 ]的源代码/下载选项卡),您可以找到一个版本的方向屏幕,它也利用了会话状态。我没有将它添加到时间输入屏幕。如果您想在屏幕上添加会话状态,这可能是一个很好的练习。这比我们在这里看到的要复杂一些,因为除了跟踪时间条目添加/编辑表单的当前状态之外,它还必须考虑在时间条目列表中选择了哪些项目(如果有的话)。

本地设置

会话状态存储值,使应用看起来好像从未停止运行,即使它可能已被挂起或终止,然后又被恢复。另一方面,本地设置有不同的目的。虽然会话状态最终会被丢弃,但在应用启动、应用关闭和计算机重新启动之间,本地设置会得到维护。本地设置是一个很好的工具,可以在这台计算机上存储与该应用相关的值,并以一种持久的方式长期存储这些值。

保存本地设置

虽然现在可以使用 Clok,但是有许多不便之处。一个突出的问题是,如果您在仪表板上启动计时器,然后关闭应用或导航到另一个屏幕,当您返回时,计时器已经停止并重置。在这一节中,我将向您展示如何使用本地设置来保持应用启动和导航之间的计时器状态。

为此,我们将保存计时器的当前状态(它的startStops属性)、当前选择的项目以及已经输入到本地设置中的任何注释。有了这些值,当启动 Clok 或从应用的另一个屏幕导航回仪表板时,就有可能以正确的状态显示计时器。打开home.js并将来自清单 15-9 的代码添加到文件顶部附近。

清单 15-9。 添加更多别名

var appData = Windows.Storage.ApplicationData.current;
var localSettings = appData.localSettings;

接下来,将清单 15-10 中的代码添加到PageControl定义中的home.js中。

清单 15-10。 功能更新本地设置

saveDashboardStateToSettings: function () {
    var state = JSON.stringify({
        startStops: elapsedTimeClock.winControl.startStops,
        projectId: Number(project.options[project.selectedIndex].value),
        timeNotes: timeNotes.value,
    });

    localSettings.values["dashboardState"] = state;
},

removeDashboardStateFromSettings: function () {
    localSettings.values.remove("dashboardState");
},

许多类型的值可以保存到本地设置中,但不幸的是,表示计时器启动和停止时间的对象数组不能。为了解决这个问题,我使用了JSON类中的stringify函数将 JavaScript 对象转换成 JSON 格式的字符串。虽然没有简单地将state变量保存到本地设置中那么方便,但这是一个简单的步骤。

在清单 15-10 中定义的函数将在home.js中的不同地方被调用。每当用户启动或停止计时器、选择项目或输入注释时,都会调用saveDashboardStateToSettings函数。将清单 15-11 中高亮显示的代码添加到home.js中。

清单 15-11。 调用该功能保存本地设置

project_change: function (e) {
    this.enableOrDisableButtons();
    this.saveDashboardStateToSettings();
},

timeNotes_change: function (e) {
    this.saveDashboardStateToSettings();
},

toggleTimer: function () {
    this.timerIsRunning = !this.timerIsRunning;
    this.setupTimerRelatedControls();
    this.saveDashboardStateToSettings();
},

类似地,removeDashboardStateFromSettings函数将在两个不同的时间被调用:当一个时间条目被保存或丢弃时。在savediscard功能的最后是一个done功能,它目前只重置计时器。将清单 15-12 中突出显示的代码添加到这两个done函数中,以便在不再需要该值时清理本地设置。

清单 15-12。 清除本地设置中的保存和丢弃功能

.done(function () {
    self.resetTimer();
    self.removeDashboardStateFromSettings();
});

正在读取本地设置

与会话状态一样,保存本地设置是一项非常简单的任务。你不会惊讶地发现,阅读它们也一样简单。在这一节中,我将向您展示如何读取之前保存的本地设置,以使 Clok 仪表板按照用户的期望工作。即使应用可能没有运行,计时器也会显示为连续运行。这也是我在《??》第十二章中重构Clock控件的原因之一。因为我们可以根据计时器的开始和停止来计算经过的时间,所以我们可以让计时器看起来好像它一直在运行,而实际上并没有。

你要做的第一件事是添加代码来读取我们在清单 15-10 中创建的本地设置。因为我们必须将其保存为 JSON 格式的字符串,所以我们必须使用JSON.parse函数将其转换回对象。将清单 15-13 中的代码添加到home.js

清单 15-13。 功能读取本地设置并初始化控件

setDashboardStateFromSettings: function () {
    var state = localSettings.values["dashboardState"];

    if (state) {
        state = JSON.parse(state);

        elapsedTimeClock.winControl.startStops = state.startStops;
        project.selectedIndex = this.getIndexOfProjectId(state.projectId);
        timeNotes.value = state.timeNotes;

        if (elapsedTimeClock.winControl.isRunning) {
            this.startTimer();
        }
    }
},

getIndexOfProjectId: function (projectId) {
    var index = 0;

    for (var i = 0; i < project.options.length; i++) {
        if (!isNaN(project.options[i].value)
                && Number(project.options[i].value) === projectId) {

            index = i;
            break;
        }
    }

    return index;
}

从本地设置中获取状态后,它用于为计时器、所选项目和 notes 字段设置正确的值。然后,如果定时器控件应该运行,我们通过调用startTimer函数来启动它。startTimer函数是新的,但其中的代码不是。我只是重构了setupTimerRelatedControls函数,拉出了启动和停止的逻辑。更新setupTimerRelatedControls函数,并在清单 15-14 中添加两个新函数。

清单 15-14。 重构函数

setupTimerRelatedControls: function () {
    if (this.timerIsRunning) {
        this.startTimer();
    } else {
        this.stopTimer();
    }

    this.enableOrDisableButtons();
},

startTimer: function () {
    elapsedTimeClock.winControl.start();
    timerImage.src = "/img/Clock-Running.png";
    timerTitle.innerText = "Stop Clok";
    this.timerIsRunning = true;
},

stopTimer: function () {
    elapsedTimeClock.winControl.stop();
    timerImage.src = "/img/Clock-Stopped.png";
    timerTitle.innerText = "Start Clok";
    this.timerIsRunning = false;
},

需要对Timer控件做一点小小的改动。目前,让计时器开始计数的唯一方法是调用start函数。然而,该功能仅在定时器尚未运行时有效。在我们返回计时器应该运行的仪表板屏幕的情况下,我们必须能够启动更新运行时间的间隔。将清单 15-15 中高亮显示的代码添加到timerControl.js中的start函数中。

清单 15-15。 添加条件允许定时器从停止的地方重新开始

start: function () {
    if (!this.isRunning) {
        this._intervalId = setInterval(this._updateTimer.bind(this), 250);
        this.startStops[this.startStops.length] = { startTime: (new Date()).getTime() };
        this.dispatchEvent("start", {});
    } else if (this._intervalId <= 0) {
        // timer is running, but not updating yet
        this._intervalId = setInterval(this._updateTimer.bind(this), 250);
    }
},

startStops中有一个项目有一个没有stopTimestartTime时,Timer控件的isRunning属性为true。只要我们停留在仪表板上,就会根据isRunning属性开始或停止更新 UI 的时间间隔。然而,如果我们启动计时器,然后重新启动 Clok,或者简单地从应用的另一个屏幕返回到仪表板屏幕,那么startStops数组将导致isRunning成为true,即使间隔时间没有更新 UI。有了这个改变,调用start将开始间隔。

现在用来自清单 15-16 的代码更新home.js中的ready函数。

清单 15-16。 修改就绪功能

ready: function (element, options) {

    this.initializeMenuPointerAnimations();
    this.bindListOfProjects();
    this.setDashboardStateFromSettings();
    this.setupTimerRelatedControls();

    toggleTimerMenuItem.onclick = this.toggleTimerMenuItem_click.bind(this);
    project.onchange = this.project_change.bind(this);
    timeNotes.onchange = this.timeNotes_change.bind(this);
    editProjectButton.onclick = this.editProjectButton_click.bind(this);
    saveTimeButton.onclick = this.saveTimeButton_click.bind(this);
    discardTimeButton.onclick = this.discardTimeButton_click.bind(this);

    projectsMenuItem.onclick = this.projectsMenuItem_click.bind(this);
    timesheetMenuItem.onclick = this.timesheetMenuItem_click.bind(this);

},

除了将一些语句以不同的顺序组合成类似的代码,主要的区别是我调用了我们刚刚在清单 15-13 的中添加的新的setDashboardStateFromSettings函数。现在,在推出 Clok 并进行测试之前,还有一个问题需要解决。

您可能还记得上一章中的一个注释,描述了由于从 IndexedDB 数据库异步加载数据而导致的在仪表板上加载项目列表的延迟。如果您现在运行此代码,应用可能仍然会启动,但没有完全填充的项目列表。不用担心;数据就在那里,如果您导航到(例如)项目屏幕,然后再回到控制面板,就会显示出来。然而,我们只是添加了代码,根据保存到本地设置的状态,在这个列表中选择适当的项目。当我讨论闪屏和应用状态时,我提到我会在第十七章中讨论减轻这种情况的方法。也就是说,如果没有项目列表,很难展示这个例子的预期效果。我将向您展示如何用承诺暂时解决这个问题。将清单 15-17 中的代码作为静态成员添加到storage.js中。

清单 15-17。 创建函数初始化 IndexedDB

initialize: function () {
    return _openDb;
},

home.jsready函数的内容包装起来(参见清单 15-16 ),调用initialize函数,返回一个Promise对象(参见清单 15-18 )。

清单 15-18。 将 Ready 函数的内容包装在一个承诺中

ready: function (element, options) {
    storage.initialize().done(function () {
        // SNIPPED
    }.bind(this));
},

image 注意这实际上是处理这个问题的一个非常有效的方法,但是我们将在第十七章中用不同的方式来解决这个问题。这修复了当仪表板是第一个加载的屏幕时的数据加载竞争情况,但是它没有解决用户以某种方式打开 Clok 到不同屏幕的问题。如果用户在不同的屏幕上恢复之前执行的已终止的应用,这可能会发生,或者它可能会通过点击通知或从 Windows 搜索界面激活 Clok 来发生,这是我将在第十九章中讨论的主题。

测试本地设置

在一个看起来不像仪表板的静态截图中,我没有太多可以演示的内容。但是,如果你现在跑 Clok,你可以通过一个小测试看到你的劳动成果。

  1. 发射 Clok。
  2. 启动计时器并选择一个项目。
  3. 关闭 Clok 一会儿。
  4. 重新推出 Clok。

当 Clok 重新启动时,仪表板将显示自您第一次启动计时器以来经过的总时间。在关闭应用之前,让计时器保持运行状态,并在关闭应用之前让计时器停止运行,以此来测试它。在这个过程中,你甚至可以重启电脑。

如果 Clok 在第十四章的结尾是一个可用的应用,那么它现在就更可用了。仍然有许多特性需要添加,但这是最明显的缺点。另一个可以改善整体用户体验的特性是允许用户为应用中的不同选项指定一些偏好。我将在下一步介绍漫游设置时讨论这个问题。

漫游设置

本地设置和漫游设置非常相似。在应用启动甚至计算机重启之间,它们都永久地存储值。因为它们都是ApplicationDataContainer的实例,所以它们的 API 是相同的。两者都是存储用户偏好的好选择。不同之处在于,存储在本地设置中的任何内容都只能在存储它的计算机上使用。另一方面,存储在漫游设置中的任何内容都将与同一用户安装了您的软件的任何其他计算机同步。

在这一节中,我将向您展示如何使用漫游设置来存储用户对 Clok 的偏好。建议将影响用户与应用交互方式的任何此类设置存储在漫游设置中,而不是本地设置中。如果他们在另一台电脑上使用该应用,这些设置将在电脑之间同步。

起初,在计算机之间漫游设置对我来说似乎是违反直觉的,但我当时的问题是,我考虑的每个例子都是只对单台计算机有意义的设置。我正在编写一个应用,从用户的硬盘上加载图像文件,我想在设置中存储最近使用的路径。这在多台电脑上实际上没有意义,因为一台电脑上包含图像的目录可能在另一台电脑上不存在。然而,当我开始考虑其他类型的设置时,我开始明白漫游设置应该是我的首选,在适当的时候恢复到本地设置。如果一个设置影响用户与应用的交互方式,那么这个设置应该漫游。例如,在 Clok 中,我们将允许用户指定他们喜欢 12 小时制还是 24 小时制。如果他们更喜欢一台计算机上的 12 小时时钟,他们很可能更喜欢每台计算机上的 12 小时时钟。

关于漫游设置,需要记住的一点是,只有当用户使用 Microsoft 帐户登录他们的计算机时,这些设置才会漫游。如果他们没有使用 Microsoft 帐户登录计算机,漫游设置就像本地设置一样。微软于http://msdn.microsoft.com/en-us/library/windows/apps/hh465094.aspx在 MSDN 上发布了“漫游应用数据指南”。如果您想测试漫游设置的同步功能,您可以切换到 Microsoft 帐户登录,方法是转到开始屏幕,键入“用户”,将搜索上下文切换到设置,然后单击用户搜索结果。从那里,你可以配置你的电脑,这样你就可以用微软账户登录(见图 15-4 )。

9781430257790_Fig15-04.jpg

图 15-4 。切换到 Microsoft 帐户

保存漫游设置

Clok 有一些特性,我们的用户可能想要为这些特性指定偏好。在这一节中,我将向您介绍如何实现 UI 来允许他们指定自己的首选项,以及如何将这些首选项保存到漫游设置中。具体来说,我们将允许用户更改以下内容:

  • 当前时间是以 12 小时制还是 24 小时制显示
  • 秒是否将显示为当前时间的一部分
  • 发出 Bing 地图请求的连接超时
  • 在方向屏幕上是以英里还是公里显示距离
  • 是否启用或禁用我们在第十四章的中添加的 IndexedDB 助手设置弹出按钮

你要做的第一件事是向 Clok 选项设置弹出按钮添加控件,允许用户表明他或她的偏好。用清单 15-19 中的代码替换settings\options.html中的win-content``div

清单 15-19。 构建 Clok 选项 UI

<div class="win-content">
    <div class="win-settings-section">
        <h3>Current Time</h3>
        <div id="clockModeToggle"
            data-win-control="WinJS.UI.ToggleSwitch"
            data-win-options="{
                title:'12-hour format or 24-hour format',
                labelOn: '15:30',
                labelOff: '3:30 PM'
            }"></div>
        <div id="clockSecondsToggle"
            data-win-control="WinJS.UI.ToggleSwitch"
            data-win-options="{
                title:'Show or hide seconds',
                labelOn: 'Show',
                labelOff: 'Hide'
            }"></div>
    </div>

    <div class="win-settings-section">
        <h3>Bing Maps API</h3>
        <label>Connection Speed (timeout)</label>
        <label><input type="radio"
            name="bingMapsTimeout"
            id="bingMapsTimeout_2000"
            value="2000" />Fast connection (2 sec)</label>
        <label><input type="radio"
            name="bingMapsTimeout"
            id="bingMapsTimeout_5000"
            value="5000" />Normal connection (5 sec)</label>
        <label><input type="radio"
            name="bingMapsTimeout"
            id="bingMapsTimeout_10000"
            value="10000" />Slow connection (10 sec)</label>

        <div id="bingMapsDistanceUnitToggle"
            data-win-control="WinJS.UI.ToggleSwitch"
            data-win-options="{
                title:'Metric or Imperial System',
                labelOn: '6.4 km',
                labelOff: '4 mi'
            }"></div>
    </div>

    <div class="win-settings-section">
        <h3>Debugging</h3>
        <div id="indexedDbHelperToggle"
            data-win-control="WinJS.UI.ToggleSwitch"
            data-win-options="{
                title:'Enable IndexedDB Helper',
                labelOn: 'Enabled',
                labelOff: 'Disabled'
            }"></div>
    </div>
</div>

我添加了几个ToggleSwitch控件来配置当前时间。单选按钮用于指定 Bing 地图连接超时。另外两个ToggleSwitch控件用于指定英里或公里以及启用 IndexedDB 助手。当然,简单地向用户显示这些控件并不能更新漫游设置。在settings文件夹中新建一个名为options.js的 JavaScript 文件,并在options.htmlhead元素中引用它(参见清单 15-20 )。

清单 15-20。 引用

<head>
    <title>Options</title>
    <script src="options.js"></script>
</head>

接下来,将清单 15-21 中的代码添加到新的options.js文件中。

清单 15-21。 保存漫游设置

(function () {
    "use strict";

    var appData = Windows.Storage.ApplicationData.current;
    var roamingSettings = appData.roamingSettings;

    var page = WinJS.UI.Pages.define("/settings/options.html", {

        ready: function (element, options) {
            clockSecondsToggle.onchange = this.clockSecondsToggle_change;
            clockModeToggle.onchange = this.clockModeToggle_change;

            bingMapsTimeout_2000.onchange = this.bingMapsTimeout_change;
            bingMapsTimeout_5000.onchange = this.bingMapsTimeout_change;
            bingMapsTimeout_10000.onchange = this.bingMapsTimeout_change;

            bingMapsDistanceUnitToggle.onchange = this.bingMapsDistanceUnitToggle_change;
            indexedDbHelperToggle.onchange = this.indexedDbHelperToggle_change;
        },

        clockSecondsToggle_change: function (e) {
            roamingSettings.values["clockSeconds"] =
                clockSecondsToggle.winControl.checked;
        },

        clockModeToggle_change: function (e) {
            roamingSettings.values["clockMode"] =
                (clockModeToggle.winControl.checked)
                ? Clok.UI.ClockModes.CurrentTime24
                : Clok.UI.ClockModes.CurrentTime12;
        },

        bingMapsTimeout_change: function (e) {
            roamingSettings.values["bingMapsTimeout"] = Number(e.currentTarget.value);
        },

        bingMapsDistanceUnitToggle_change: function (e) {
            roamingSettings.values["bingDistanceUnit"] =
                (bingMapsDistanceUnitToggle.winControl.checked) ? "km" : "mi";
        },

        indexedDbHelperToggle_change: function (e) {
            roamingSettings.values["enableIndexedDbHelper"] =
                indexedDbHelperToggle.winControl.checked;
        },
    });
})();

在“设置”弹出按钮中使用用户首选项时,最佳做法是在做出任何更改后立即应用这些更改。例如,当用户改变一个ToggleSwitch的值时,这个改变应该立即生效。因此,“设置”弹出按钮上没有“保存”或“提交”按钮。相反,我用一个函数处理了每个控件的 change 事件,该函数在用户指示更改时立即将首选项保存到漫游设置中。

正如您在这段代码中看到的,任何当前存储在本地设置中的设置都可以通过简单地将localSettings.values["someKey"]更改为roamingSettings.values["someKey"]而移动到漫游设置中。了解了这一点,有灵感的开发人员可以构建一个更复杂的偏好系统,允许用户指定他或她想要漫游的设置(如果有的话)。根据用户的偏好,您可以决定是使用localSettings容器还是roamingSettings容器。

保存漫游设置非常容易。读他们呢?

读取漫游设置

当用户没有指定某个设置的首选项时,您的应用应该使用合理的默认值。这实际上适用于任何类型的设置:本地设置、漫游设置,甚至会话状态。有几种方法可以解决这个问题。一种选择是在检查设置时使用默认值,以获得用户的偏好并发现他或她没有指定。在某些情况下,这没问题,这就是我对会话状态采取的方法。但是,使用这种方法,每次需要用户的首选项时,您都必须检查一个值并设置一个默认值。如果在应用的多个地方使用了某个特定的设置,那么在每个地方都会有重复的代码。

另一个选择是确保设置总是有一个值。这是我在 Clok 中采用的方法。将清单 15-22 中定义的函数添加到default.js。该函数查看每个漫游设置,如果它们还没有值,则给它们分配一个默认值。

清单 15-22。 确保漫游设置有合适的默认值

var initializeRoamingSettings = function () {
    roamingSettings.values["clockSeconds"] =
        roamingSettings.values["clockSeconds"] || false;

    roamingSettings.values["clockMode"] =
        roamingSettings.values["clockMode"] || Clok.UI.ClockModes.CurrentTime12;

    roamingSettings.values["bingMapsTimeout"] =
        roamingSettings.values["bingMapsTimeout"] || 5000;

    roamingSettings.values["bingDistanceUnit"] =
        roamingSettings.values["bingDistanceUnit"] || "mi";

    roamingSettings.values["enableIndexedDbHelper"] =
        roamingSettings.values["enableIndexedDbHelper"] || false;
};

确保将清单 15-23 中的别名添加到default.js的顶部,靠近其他别名。

清单 15-23。 别名用于漫游设置

var appData = Windows.Storage.ApplicationData.current;
var roamingSettings = appData.roamingSettings;

因为清单 15-22 中的代码将在应用启动后很快执行,在我们有机会尝试读取漫游设置之前,这些设置将总是有一个指定的值。这使得我们可以在需要时简单地检查设置,而不必担心如果用户从未保存某个特定设置的值该怎么办。

当我们仍然在default.js中工作时,让我们根据用户的偏好添加代码来显示或隐藏 IndexedDB 助手设置弹出按钮。用清单 15-24 中突出显示的代码更新default.js。这既包括对initializeRoamingSettings的调用,也包括决定 IndexedDB 助手设置弹出按钮是否可用的逻辑。

清单 15-24。 初始化漫游设置并决定添加设置弹出按钮

initializeRoamingSettings();

// add our SettingsFlyout to the list when the Settings charm is shown
WinJS.Application.onsettings = function (e) {
    e.detail.applicationcommands = {
        "options": {
            title: "Clok Options",
            href: "/settings/options.html"
        },
        "about": {
            title: "About Clok",
            href: "/settings/about.html"
        }
    };

    if (roamingSettings.values["enableIndexedDbHelper"]) {
        e.detail.applicationcommands.idbhelper = {
            title: "IndexedDB Helper",
            href: "/settings/idbhelper.html"
        };
    }

    WinJS.UI.SettingsFlyout.populateSettings(e);
};

现在,让我们重新访问“块选项设置”弹出按钮。现在,用户可以打开设置弹出按钮并保存他或她的设置。但是,加载“设定”弹出按钮时,“设定”弹出按钮上的控件不会反映每个设定的当前值。将清单 15-25 中的代码添加到options.js。另外,一定要从options.js中的ready函数调用initializeSettingsControls函数。

清单 15-25。 为 Clok 选项设置弹出按钮上的控件设置初始状态

initializeSettingsControls: function() {
    clockSecondsToggle.winControl.checked =
        roamingSettings.values["clockSeconds"];

    clockModeToggle.winControl.checked =
        roamingSettings.values["clockMode"] === Clok.UI.ClockModes.CurrentTime24;

    switch (roamingSettings.values["bingMapsTimeout"]) {
        case 5000:
            bingMapsTimeout_5000.checked = true;
            break;
        case 10000:
            bingMapsTimeout_10000.checked = true;
            break;
        default:
            bingMapsTimeout_2000.checked = true;
    }

    bingMapsDistanceUnitToggle.winControl.checked =
        roamingSettings.values["bingDistanceUnit"] === "km";

    indexedDbHelperToggle.winControl.checked =
        roamingSettings.values["enableIndexedDbHelper"];
},

正如我所展示的,根据设置更新 UI 或设置对象属性很简单。虽然我不会在bingMapsWrapper.js中展示合并漫游设置所需的更新,但我会简单地提醒你在自己做这些更改时去哪里查看。应根据设置来设置xhrTimeout变量,以及getDirections函数中的distanceUnit变量。此外,记得更新travelDistanceConverter函数,以指示距离使用公制,如果这是用户指定的。如果你卡住了,你可以在本书附带的源代码中看到一个完整版本的BingMaps类。(见该书的 Apress 产品页[ www.apress.com/9781430257790 ]的源代码/下载标签)。)

数据更改事件

在上述情况下,每次需要时都会检查漫游设置。然而,如果一个设置已经被应用,然后它改变了,会发生什么呢?例如,如果用户指定他或她想要为您的应用使用一个新的主题,但是该主题在应用启动时已经被应用了,该怎么办?或者,如果用户更改了另一台机器上的漫游设置,该怎么办?当该设置与当前机器同步时,您的应用应该做什么?

WinRT 定义了一个您可以在这些情况下处理的事件。每当漫游设置同步时,datachanged事件就会自动触发。此外,您可以在自己的代码中触发它。我将带您浏览这个场景,我们添加代码来将用户的首选项应用于当前时间的格式。在您的代码中手动触发datachanged事件需要一行代码。用清单 15-26 中突出显示的代码更新options.js

清单 15-26。 表示漫游设置已经改变

clockSecondsToggle_change: function (e) {
    roamingSettings.values["clockSeconds"] =
        (clockSecondsToggle.winControl.checked);

    appData.signalDataChanged();
},

clockModeToggle_change: function (e) {
    roamingSettings.values["clockMode"] =
        (clockModeToggle.winControl.checked)
        ? Clok.UI.ClockModes.CurrentTime24
        : Clok.UI.ClockModes.CurrentTime12;

    appData.signalDataChanged();
},

您还必须添加来自清单 15-27 到default.js的代码,以处理datachanged事件。

清单 15-27。 改变漫游设置改变时当前时间的显示

appData.addEventListener("datachanged", function (args) {
    configureClock();
});

var configureClock = function () {
    currentTime.winControl.showClockSeconds = roamingSettings.values["clockSeconds"];
    currentTime.winControl.mode = roamingSettings.values["clockMode"];
};

除了在设置更改时将用户的首选项应用于当前时间格式之外,您还应该在 Clok 启动时应用它们。用清单 15-28 中的高亮代码修改default.js

清单 15-28。 配置 Clok 启动的当前时间

args.setPromise(WinJS.UI.processAll().then(function () {
    configureClock();

    if (nav.location) {
        nav.history.current.initialPlaceholder = true;
        return nav.navigate(nav.location, nav.state);
    } else {
        return nav.navigate(Application.navigator.home);
    }
}));

在本书附带的源代码中,我也在方向屏幕上使用了类似的技术。如果用户在将他们的偏好从英里改为公里时没有看到方向屏幕,或者相反,我们没有什么可担心的。下次他们得到指示时,将使用正确的单位。但是,如果他们只是得到了以公里为单位指定距离的方向,并且他们将首选项切换到了英里,那么方向列表应该用新的单位刷新。我通过监听directions.js中的datachanged事件并适当地刷新指令来完成这个任务。

尺寸限制和复合设置

对于可以与漫游设置同步的内容有一些限制。每个设置的名称最长可达 255 个字符。此外,每个设置的最大大小为 8KB,复合设置除外,它的最大大小为 64KB。复合设置可用于将许多相关设置组合在一起,并将其作为一个单元进行同步。我们不会给 Clok 添加任何复合设置,但是清单 15-29 中的代码展示了如何在应用中使用它们。

清单 15-29。 复合设置

var compositeSetting = new Windows.Storage.ApplicationDataCompositeValue();
compositeSetting["first"] = "Scott";
compositeSetting["last"] = "Isaacs";
compositeSetting["dob"] = "Dec 1";
roamingSettings.values["profile"] = compositeSetting;

高优先级漫游设置

您的用户可以通过使用同一个 Microsoft 帐户登录多台机器来利用漫游功能。也就是说,虽然漫游设置会在设置它的计算机上立即生效,但它不会立即与任何其他计算机同步。您可以通过将单个设置命名为“高优先级”来指定要尽快同步的设置如上所述,它可以是一个复合设置,但大小限制为 8KB。如果您有关键设置要同步,这可能是一个有用的功能,但如果超过此限制,优先级将被移除,它将像正常的优先级设置一样同步。

结论

有句话叫魔鬼在细节中。在这一章中,我讨论了一些技术来确保你的应用中的小东西按预期工作。当应用终止后恢复时,使用会话状态来设置应用的正确状态可能会涉及大量繁琐的工作,但是如果您不这样做,用户会认为您的应用有问题。让你的用户有机会保存设置可以让你的应用运行得更流畅,也可以让用户灵活地让应用以他们认为最有帮助的方式运行。

尽管处理会话状态和设置可能很乏味,但编写或理解代码并不困难。随着越来越多的应用发布到 Windows Store 中,你会希望你的应用因其质量和对细节的关注而脱颖而出,而不是因其看似不完整而脱颖而出。我鼓励你花时间评估一下你可以实现哪些小细节来改善用户体验。