优化 ListView 条目呈现
对于许多使用 JavaScript 编写的 Windows 应用商店应用(可以处理数据集合),与 WinJS ListView 控件很好地协同工作是获得出色应用性能的基础。这并不奇怪:当处理数以千计的条目的管理和显示时,您对这些条目进行的每个数位的优化可能都发挥着重要的作用。最重要的是每个条目的呈现方式,也就是说,如何呈现以及何时呈现,ListView 控件中的每个条目都会构建于 DOM 中,并成为应用的可见部分。实际上,当用户在列表内快速平移时并且希望列表能够跟进平移的速度时,何时呈现就成为一个重要的要素。
通过 HTML 中定义的声明性模板或通过一个自定义的 JavaScript 呈现函数(将会为列表中的每个条目调用该函数),就可以在 ListView 中呈现条目。尽管使用声明性模板是最简单的,但是它不能对整个过程提供太多具体的控制。另一方面,呈现函数允许逐条目自定义呈现,并实现一定量的优化,HTML ListView 优化性能示例中演示了其中的一些示例。优化包括:
- 允许异步传输条目数据和已呈现的元素(基本呈现函数支持该功能)。
- 将条目形状的创建与内部元素的创建分开,条目形状对于 ListView 的整体数据布局是必不可少的要素。这是通过一个占位符呈现器提供对该功能的支持。
- 重用以前创建的条目元素(及其子集),方法是替换数据、避免大部分元素创建步骤,此功能是通过一个循环占位符呈现器提供支持。
- 延迟耗费资源的可视性操作(如图片加载和动画显示)直到条目可见,并且 ListView 没有快速平移,此功能是通过一个多级呈现器来完成。
- 将这些相同的可视性操作同时进行批处理操作,以便利用多级批处理呈现器将重新呈现 DOM 的工作量最小化。
在本篇博文中,我们将逐一介绍所有这些阶段,并了解它们是如何与 ListView 的条目呈现过程相互配合。您可以想象得到,围绕着何时呈现条目所进行的优化涉及大量的异步操作,因此会有大量的承诺。因此在这个过程中,基于先前发表的关于承诺博文,我们还会对承诺本身有更深入的理解。
作为一条适用于所有呈现器的一般原则,令核心条目呈现时间(不包括延迟的操作)尽量最短非常重要。因为 ListView 的最终性能表现很大程度上取决于它的更新与屏幕刷新间隔的吻合程度,在条目呈现器上花费的额外的几个毫秒可能会使 ListView 的整体呈现时间推迟到下一个刷新间隔,导致丢失帧和视觉效果波动。也就是说,条目呈现器就是一个用于优化您的 JavaScript 代码的位置。
基本呈现器
我们先快速了解一下条目呈现函数(我简单地称为呈现器)是什么样的。呈现器是您分配给 ListView 的 itemTemplate 属性而不是模板名称的一个函数,将会在必要时为 ListView 希望包括在 DOM 中的条目调用该函数。(另外,您可以在 itemTemplate 页查找到呈现器的基础说明文档,不过它是切实显示优化的示例。)
您可能希望条目呈现函数仅仅获得一个来自 ListView 数据源的条目。然后它将会创建该特定条目所需的 HTML 元素,然后返回根路径元素,ListView 可以将该根路径元素添加到 DOM。这是确凿出现的情况,但是还有两个额外的注意事项。首先,条目数据本身可以异步加载,因此,将元素创建与该数据的可用性相关联起来就很有必要。另外,呈现条目本身的过程可能还涉及其他异步操作,例如从远程 URI 加载图片,或者在条目数据中标识的其他文件中读取数据。事实上,我们将看到不同的优化级别,在条目元素请求和这些元素的实际传输之间允许任意数量的异步操作。
因此,重申一下,您可以看到呈现器会涉及到承诺。举例来说,ListView 并不直接为呈现器提供条目数据,它为该数据提供承诺。并非函数直接返回该条目的根路径元素,而是返回该元素的承诺。这允许 ListView 同时加入许多条目呈现承诺,并且等待(异步)直到整个页面得以呈现。事实上,它执行此操作以智能地管理构建不同页面的方式,首先构建可视条目的页面,然后构建用户最有可能下次向前平移和向后平移到的两个屏幕外页面。此外,具有所有这些承诺意味着,如果用户平移走,则 ListView 可以轻松地取消未完成条目的呈现,这样可以避免不必要的元素创建。
我们可以查看在示例的 simpleRenderer 函数中是如何使用这些承诺的:
function simpleRenderer(itemPromise) { return itemPromise.then(function (item) { var element = document.createElement("div"); element.className = "itemTempl"; element.innerHTML = "<img src='" + item.data.thumbnail + "' alt='Databound image' /><div class='content'>" + item.data.title + "</div>"; return element; }); }
此代码首先会将已完成处理程序附加到 itemPromise。当条目数据可用并且有效地创建元素时,将调用该处理程序。不过,再次强调,我们实际上并没有直接返回元素 — 而是返回了利用该元素履行的承诺。也就是说,从 itemPromise.then() 的返回值是利用该元素履行的承诺(ListView 是否需要以及何时需要)。
返回承诺允许我们在必要时执行其他异步操作。在这种情况下,呈现器可以仅将这些中间承诺链接起来,从链中的最后一个 then 中返回承诺。例如:
function someRenderer(itemPromise) { return itemPromise.then(function (item) { return doSomeWorkAsync(item.data); }).then(function (results) { return doMoreWorkAsync(results1); }).then(function (results2) { var element = document.createElement("div"); // use results2 to configure the element return element; }); }
请注意,这是一种我们在链的末尾不使用 done 的情况,因为我们从最后一个 then 调用中返回了承诺。ListView 负责处理这个过程中可能引发的任何错误。
占位符呈现器
ListView 优化的下一个阶段使用占位符呈现器,可以将元素构建分到两个不同阶段进行。这样将允许 ListView 只要求对于定义列表的整体布局必要的元素,而无需在每个条目内构建所有元素。因此,ListView 可以快速完成其布局过程,并且对后续的输入继续保持高度及时响应的状态。然后它可以在以后要求提供元素的其余部分。
占位符呈现器会返回一个具有两个属性的对象,而不是承诺:
- element 条目结构中的顶级元素,足以定义条目的大小和形状,它不依赖于条目数据。
- renderComplete 当构建其余的元素内容时要履行的承诺,即,依旧会从 itemPromise.then 开始从您拥有的任意链处返回承诺。
ListView 足够智能,可以检查您的呈现器是返回一个承诺(如上基本情况),还是返回具有 element 和 renderComplete 属性的对象(更为高级的情况)。因此先前 simpleRenderer 的同等占位符呈现器(在示例中)如下所示:
function placeholderRenderer(itemPromise) { // create a basic template for the item that doesn't depend on the data var element = document.createElement("div"); element.className = "itemTempl"; element.innerHTML = "<div class='content'>...</div>"; // return the element as the placeholder, and a callback to update it when data is available return { element: element, // specifies a promise that will be completed when rendering is complete // itemPromise will complete when the data is available renderComplete: itemPromise.then(function (item) { // mutate the element to include the data element.querySelector(".content").innerText = item.data.title; element.insertAdjacentHTML("afterBegin", "<img src='" + item.data.thumbnail + "' alt='Databound image' />"); }) }; }
请注意,element.innerHTML 分配可以移到 renderComplete 内,因为示例的 css/scenario1.css 文件中的itemTempl 类会直接指定条目的宽度和高度。在 element 属性中包括该类的原因是它在占位符中提供默认的 “…” 文本。您可以就像轻松地使用一个引用了在所有条目间共享的小型包内资源的 img 元素那样(因此可以快速呈现)。
循环占位符呈现器
下一个优化是循环占位符呈现器,不会在涉及承诺的位置添加任何新的内容。它会将第二个参数的感知度添加到名为 recycled 的呈现器,它是以前呈现过但现在不再可见的条目的根路径元素。即,循环的元素已经有自己的子元素,因为您可以简单地替换该数据,可能需要对一些元素进行微调。对于全新条目所需的调用,可以避免大部分成本高昂的其他方式的元素创建调用,这样在呈现的过程中可以节省不少时间。
当它的 loadingBehavior 设置为“randomaccess”时,ListView 可能会提供一个循环的元素。如果指定循环,则您可以将数据清除出元素(及其子元素),将其返回为占位符,然后在 renderComplete 内填充数据并创建所有其他子元素(如果需要)。如果未提供循环的元素(当 ListView 首次创建或者当 loadingBehavior 为“incremental”时),您将再次创建该元素。下面是针对此种变化情况的示例代码:
function recyclingPlaceholderRenderer(itemPromise, recycled) { var element, img, label; if (!recycled) { // create a basic template for the item that doesn't depend on the data element = document.createElement("div"); element.className = "itemTempl"; element.innerHTML = "<img alt='Databound image' style='visibility:hidden;'/>" + "<div class='content'>...</div>"; } else { // clean up the recycled element so that we can reuse it element = recycled; label = element.querySelector(".content"); label.innerHTML = "..."; img = element.querySelector("img"); img.style.visibility = "hidden"; } return { element: element, renderComplete: itemPromise.then(function (item) { // mutate the element to include the data if (!label) { label = element.querySelector(".content"); img = element.querySelector("img"); } label.innerText = item.data.title; img.src = item.data.thumbnail; img.style.visibility = "visible"; }) }; }
在 renderComplete 中,请确保检查存在着您不是为新占位符创建的元素,例如 label,如果需要请在此处创建他们。
如果您希望以更常规地方式清除循环的条目,请注意,您可以为 ListView 的 resetItem 属性提供一个函数。此函数将包含与上面所示类似的代码。对于 resetGroupHeader 属性也是同样,因为您可以对组标头以及条目使用模板功能。我们尚未涉及太多此话题,是因为组标头非常少,并且通常没有相同的性能影响。不过,此功能仍然强大。
多级呈现器
现在我们来看倒数第二级的优化,多级呈现器。这会令循环占位符呈现器延迟加载图片和其他媒体,直到条目的其他部分已经在 DOM 中完全呈现。它还会延迟诸如动画显示之类的效果,直到条目确实完全显示在屏幕上。这是意识到用户会经常在 ListView 内快速平移,所以异步延迟很多耗费资源的操作直到 ListView 达到一种稳定的状态和位置,这是非常有意义的。
ListView 提供必要的挂钩作为来自 itemPromise 的 item 结果的成员:名为 ready 的属性(承诺)和两个方法 loadImage 和 isOnScreen,都会返回(更多!)承诺。即:
renderComplete: itemPromise.then(function (item) { // item.ready, item.loadImage, and item.isOnScreen available })
下面是它们的使用方式:
- ready 从链内第一个已完成处理程序中返回此承诺。当元素的整体结构已经被呈现并且可见时,就履行了此承诺。这意味着您可以利用一个已完成处理程序来链接另一个 then,在该处理程序中您可以执行其他可视化后操作,诸如加载图片。
- loadImage 从 URI 中下载图片,并在指定的 img 元素中显示该图片,然后返回一个利用同一元素履行的承诺。您可以将已完成处理程序附加到此承诺,它本身会从 isOnScreen 返回承诺。请注意,loadImage 将创建一个 img 元素(如果未提供该元素的话),并且将其提供给您的已完成处理程序。
- isOnScreen 返回一个承诺,其履行值为一个布尔值,指示该条目是否可见。在当前的实施中,这是一个已知值,因此将同步履行承诺。尽管是打包在一个承诺中,但是该值可以用在一个较长的链中。
我们会在示例的 multistageRenderer 函数中查看到所有情形,其中图片加载的完成用于启动一个淡出动画。下面的内容是从 renderComplete 承诺中返回的结果:
renderComplete: itemPromise.then(function (item) { // mutate the element to update only the title if (!label) { label = element.querySelector(".content"); } label.innerText = item.data.title; // use the item.ready promise to delay the more expensive work return item.ready; // use the ability to chain promises, to enable work to be cancelled }).then(function (item) { // use the image loader to queue the loading of the image if (!img) { img = element.querySelector("img"); } return item.loadImage(item.data.thumbnail, img).then(function () { // once loaded check if the item is visible return item.isOnScreen(); }); }).then(function (onscreen) { if (!onscreen) { // if the item is not visible, don't animate its opacity img.style.opacity = 1; } else { // if the item is visible, animate the opacity of the image WinJS.UI.Animation.fadeIn(img); } })
尽管有很多内容,我们仍然还有一个基本承诺链在这里。呈现器中的第一个异步操作更新了条目元素结构的简单部分,比如文本。然后它会在 item.ready 中返回承诺。当履行该承诺时,或者更准确地说,如果该承诺被履行后,我们使用条目的异步 loadImage 方法开始图片的加载,从已完成处理程序中返回 item.isOnScreen 承诺。这意味着 onscreen 可见性标志会在链中传递到最终的已完成处理程序。是否履行以及何时履行了 isOnScreen 承诺,意味着该条目确实可见,我们可以执行类似动画等相关操作。
我强调是否履行了承诺,是因为当所有这一切正发生的时候,很可能又是用户在 ListView 内平移。将所有这些承诺链在一起,令 ListView 可能在这些条目平移出视图和/或任何缓存页面时取消异步操作。只要说 ListView 控件已经经历了很多性能测试,这就足矣!
另外一点也很重要,就是要再次提醒我们自己在所有这些链中使用 then,因为我们仍会在 renderComplete 属性内的呈现函数中返回了承诺。我们永远不会是这些呈现器中链的末尾,因此我们永远不会在末尾使用 done。
缩略图批处理
最后的优化确实是 ListView 控件的杀手锏。在名为 batchRenderer 的函数中,我们发现了 renderComplete 的此结构(已省略了大部分代码):
renderComplete: itemPromise.then(function (item) { // mutate the element to update only the title if (!label) { label = element.querySelector(".content"); } label.innerText = item.data.title; // use the item.ready promise to delay the more expensive work return item.ready; // use the ability to chain promises, to enable work to be cancelled }).then(function (item) { // use the image loader to queue the loading of the image if (!img) { img = element.querySelector("img"); } return item.loadImage(item.data.thumbnail, img).then(function () { // once loaded check if the item is visible return item.isOnScreen(); }); }).then(function (onscreen) { if (!onscreen) { // if the item is not visible, don't animate its opacity img.style.opacity = 1; } else { // if the item is visible, animate the opacity of the image WinJS.UI.Animation.fadeIn(img); } })
这与 multistageRenderer 基本相同,除了在 item.loadImage 调用和 item.isOnScreen 检查之间对名为 thumbnailBatch 函数的神秘调用以外。在链中放置 thumbnailBatch 指示其返回值必须是已完成处理程序,其本身会返回另一个承诺。
有点糊涂了?好,我们现在就来探个究竟!不过首先需要一些有关我们将要完成内容的更多背景内容。
如果我们的 ListView 只具有一个条目,则各种不同的加载优化的效果可能不会那么显著。但 ListViews 通常具有很多条目,并且将会为每个条目调用呈现函数。在前面的 multistageRenderer 部分中,每个条目的呈现都会启动异步 item.loadImage 操作,从任意 URI 下载其缩略图,每个操作所需花费的时间都不固定。因此对于整个表,我们可能会同时调用一批 loadImage,等待其特定缩略图完成时会呈现每个条目。到目前为止一切顺利。
在 multistageRenderer 中并不是全部可见的一个重要特征是缩略图的 img 元素已经在 DOM 中,下载一经完成,loadImage 函数会设置该图片的 src 属性。当我们一经从承诺链的其余部分返回时,这反过来也会触发呈现引擎的更新,会在该点后同步。
然后,在可能的情况下,一批缩略图也会在很短的时间内返回到 UI 线程。这会导致呈现引擎的过度繁忙,以及低劣的视觉效果。为了避免这种混乱,我们希望在这些 img 元素位于 DOM 之前完全创建它们,然后成批添加它们,以便可以在单一的呈现过程中处理这些元素。
该示例利用名为 createBatch 的函数通过一些承诺魔力代码来完成上述操作。仅会为整个应用调用一次 createBatch,其结果(另一个函数)会存储在名为 thumbnailBatch 的变量中:
var thumbnailBatch; thumbnailBatch = createBatch();
对此 thumbnailBatch 函数的调用(就像我在这里引用它一样)会再次插入到呈现器的承诺链中。考虑到我们马上要看到的批处理代码的性质,此插入的目的就是将一组已加载的 img 元素分组到一起,然后在适合的间隔释放它们以便进一步处理。只需要在呈现器中查看承诺链,对 thumbnailBatch() 的调用就必须返回已完成处理程序函数,该函数会返回一个承诺,该承诺的履行值(查看链中的下一步)必须是 img 元素,可以添加到 DOM。在进行批处理后将这些图片添加到 DOM,我们会将整个组添合并到同一呈现过程。
这是 batchRenderer 和上述 multistageRenderer 之间的重要区别:后来,缩略图的 img 元素已经存在于 DOM 中,会作为第二个参数传递给 loadImage。因此当 loadImage 设置了图片的 src 属性,将会触发呈现更新。不过,在 batchRenderer 内,该 img 元素是在 loadImage 内单独创建的(其中 src 也得到了设置),但是 img 尚未在 DOM 中设置。它只是在 thumbnailBatch 步骤完成后添加到 DOM,令其成为单一布局过程内的一个组。
因此现在让我们来看一下批处理是如何工作的。下面是整个 createBatch 函数:
function createBatch(waitPeriod) { var batchTimeout = WinJS.Promise.as(); var batchedItems = []; function completeBatch() { var callbacks = batchedItems; batchedItems = []; for (var i = 0; i < callbacks.length; i++) { callbacks[i](); } } return function () { batchTimeout.cancel(); batchTimeout = WinJS.Promise.timeout(waitPeriod || 64).then(completeBatch); var delayedPromise = new WinJS.Promise(function (c) { batchedItems.push(c); }); return function (v) { return delayedPromise.then(function () { return v; }); }; }; }
再强调一次,createBatch 仅调用一次,将会为列表中的每个已呈现条目调用一次其 thumbnailBatch 结果。然后将会调用 thumbnailBatch 生成的已完成处理程序,不论何时 loadImage 操作完成。
就好像已完成处理程序已经直接轻松地插入呈现函数一样,但是我们在这里要执行的是跨多个条目之间的协调活动,而不是按条目执行。通过在 createBatch 开始处创建和初始化的两个变量来实现协调:batchedTimeout,(初始化为一个空承诺)和 batchedItems(初始化一个最初为空的函数数组)。createBatch 还会声明一个函数,completeBatch 只是清空 batchedItems 在数组中调用每个函数:
function createBatch(waitPeriod) { var batchTimeout = WinJS.Promise.as(); var batchedItems = []; function completeBatch() { var callbacks = batchedItems; batchedItems = []; for (var i = 0; i < callbacks.length; i++) { callbacks[i](); } } return function () { batchTimeout.cancel(); batchTimeout = WinJS.Promise.timeout(waitPeriod || 64).then(completeBatch); var delayedPromise = new WinJS.Promise(function (c) { batchedItems.push(c); }); return function (v) { return delayedPromise.then(function () { return v; }); }; }; }
现在让我们看 thumbnailBatch(从 createBatch 返回的函数)内发生的情况,将会为每个要呈现的条目再次调用该函数。首先,我们取消所有现有 batchedTimeout 并立即重新创建它:
batchTimeout.cancel(); batchTimeout = WinJS.Promise.timeout(waitPeriod || 64).then(completeBatch);
第二行显示在“关于承诺”博文 <TODO:link>中介绍的未来的传输/履行模式:它会在延迟 waitPeriod 毫秒后(默认值为 64ms)调用 completeBatch。这意味着只要在前一次调用的 waitPeriod 内再次调用 thumbnailBatch,batchTimeout 将被重设为另一个 waitPeriod。因为仅在 item.loadImage 调用完成之后才会调用 thumbnailBatch,我们可以有把握地说,在上次调用的 waitPeriod 内完成的任何 loadImage 操作都将包括在同一批处理中。当时间超过 waitPeriod 时,就会处理该批处理 — 图片会添加到 DOM — 下一批处理开始。
在处理此超时业务后,thumbnailBatch 创建了一个新的承诺,只是将完成的调度程序函数推送到 batchedItems 数组:
var delayedPromise = new WinJS.Promise(function (c) { batchedItems.push(c); });
请注意,在“关于承诺”<TODO:link>中承诺只是一个代码构造,如此而已。新创建的承诺内及其自身没有异步行为:我们只是将完成的调度程序函数 c 添加到 batchedItems。不过,当然,我们在 batchedTimeout 异步完成前不会利用该调度程序执行任何操作,因此事实上这里存在着异步关系。当发生超时时,我们清除了批处理(在 completeBatch 内),我们会将在别处指定的已完成处理程序调用到 delayedPromise.then。
这将会为我们带来 createBatch 中的最后一行代码,是 thumbnailBatch 实际返回的函数。此函数正是已完成处理程序,将会插入到呈现器的整个承诺链:
return function (v) { return delayedPromise.then(function () { return v; }); };
事实上,让我们把这段代码直接放到承诺链中,因为我们可以看到产生的关系:
return item.loadImage(item.data.thumbnail); }).then(function (v) { return delayedPromise.then(function () { return v; }); ).then(function (newimg) {
现在,我们可以看到参数 v 是 item.loadImage 的结果,是 img 元素为我们创建的。如果我们不想执行批处理,我们可以就说 return WinJS.Promise.as(v) ,整个链将仍可以工作:然后将同步传递 v,并且在下一步骤中显示为 newimg。
尽管如此,我们将会从 delayedPromise.then 返回承诺,将不会利用 v 履行该承诺,直到履行了当前的 batchedTimeout。这时 — 当 loadImage 完成之间存在 waitPeriod 的间隙时 — 那些 img 元素将会传输到链中的下一步,在那里它们会被添加到 DOM。
就先介绍这么多内容!
结语
HTML ListView 优化性能示例中演示的五个不同的呈现函数都有一个共同的通性:他们显示 ListView 和呈现器之间的异步关系 — 通过承诺表示 — 为呈现器在如何以及何时为列表中的条目生成元素方面提供了极大的灵活性。在编写自己的应用时,您为 ListView 优化使用的策略很大程度上取决于您数据源的大小、条目本身的复杂性,以及您为这些条目异步获取的数据量(例如下载远程图片)。很明显,您希望在达到性能目标的前提下将条目呈现器尽量保持简单。但是,无论如何,您现在具有帮助 ListView — 和您的应用 — 达到性能最佳所需的所有工具。
Kraig Brockschmidt
Windows 生态系统团队项目经理