2017 年 4 月

第 32 卷,第 4 期

UWP 应用 - 开发适用于 UWP 的托管 Web 应用

作者 Sagar Bhanudas Bhanudas

许多开发者和公司都为其产品和服务创建了 Web 接口,以便用户可以轻松发现和访问它们。另一平台是应用平台。相比 Web 应用,原生应用的用户体验和功能更加丰富。现在,借助 Project Westminster,开发者可以将新式网站转换成原生应用。

通过 Project Westminster 桥,Web 开发者可以利用现有代码生成适用于通用 Windows 平台 (UWP) 的响应式 Web 应用(请参阅 Windows 开发者博文“Project Westminster 简述”,网址为 bit.ly/2jyhVQo)。这一“桥”概念的用途是,帮助开发者重用现有的网站代码,并添加 UWP 专用代码层,以形成 Web 应用与基础 Windows 平台的集成点。这篇博文介绍了一些旨在确保用户体验一致的编码实践。

Web 开发者如何才能在新式 Web 应用中集成 UWP 功能(如 Cortana)? 答案就是通过它们的共同特性,即 JavaScript。Windows 通过 JavaScript 和 Windows 命名空间公开应用框架功能(Windows 运行时 API)。生成的应用称为“托管 Web 应用”。

在本文中,我将分享自己在与独立软件供应商 (ISV) 和合作伙伴合作,共同通过 Project Westminster 工具将应用移植到 UWP 的过程中所学到的知识。本文的重点是详细介绍如何最好地调整作为平台应用运行的 Web 应用,从而提供最出色的用户体验。

开始使用

若要开始使用 Project Westminster 工具,首先需要将网站转换成 UWP 应用(请观看 Channel 9 上的视频“使用 Project Westminster 创建托管 Web 应用”,网址为 bit.ly/2jp4srs)。

为了确保网站能够按预期呈现且外观一致,作为初步测试,应在 Microsoft Edge 浏览器中进行测试。这将有助于在开始进行 Windows 功能集成编码之前发现和修复任何呈现问题。

大多数网站都有一些共同功能,即允许用户完成特定任务(如填写表单)或获取参考信息(如下载手册)。在这种情形下,需要保持这些功能不变,并确保新生成的托管 Web 应用的用户体验与原始网站一致,这一点非常重要。为了达到用户预期,可能需要通过代码对其中一些方案进行测试、评审和重构。接下来的部分将介绍几个重要方案,以阐明开发者在使用 Project Westminster 工具和相关代码工作流时可以使用的不同类或对象。将应用移植到 UWP 时,最好考虑集成平台的一些原生功能,以提供更丰富的用户体验和经过改进的应用体验。下面介绍了应用开发者通常会实现的一些功能:

  • 联系人
  • Cortana 集成
  • 动态磁贴
  • Toast 通知
  • 照相机和麦克风
  • 照片库
  • 丰富图形和媒体 Windows 运行时堆栈
  • 与其他应用共享内容

本文将重点介绍如何集成 UWP 功能。例如,Cortana 和动态磁贴,这两种功能有助于通过语音命令激活应用和向用户传递信息。这样一来,便可以借助 UWP 应用基础结构的支持来改进整体用户体验。若要快速概览更多可集成的功能,请访问网页 bit.ly/2iKufs6,其中介绍了适用于开发者的最新 Windows 10 功能。此网页也正在转换成应用,介绍了以下功能:

  1. Web 应用功能
    1. 文件下载
    2. 会话管理或单一登录
    3. 可以通过“后退”按钮轻松转到上一页
  2. UWP 集成功能
    1. 动态磁贴
    2. Cortana

在移植和重构应用以提供可预测的体验时,还需要考虑集成这些功能。

文件下载方案

目前,大多数 Web 应用都支持通过文件下载来下载各种内容。如图 1 所示,浏览器默认提供的文件下载体验是让用户单击按钮或超链接,然后浏览器开始下载文件并将其保存到以下根目录(大多数情况下):\users\username\Downloads。

默认提供的下载体验

图 1:默认提供的下载体验

提供这种用户体验的部分原因是,浏览器是使用完全信任权限运行的原生 Win32 应用,将文件直接写入“下载”文件夹。

现在,在同一情形下,此时网站是在应用(具体而言,是 WWAHost.exe)上下文中运行,并且用户单击了下载按钮。接下来会发生什么? 最有可能是什么动静也没有,就像是代码根本没有运行一样。有可能是按钮没有响应,也有可能是文件下载已启动,但文件会保存到哪里呢?

WWAHost.exe 是网站的应用容器,只具有浏览器的一部分功能。这部分功能包括呈现功能(主要通过 HTML 呈现)和标准脚本执行功能。

现在要开发的是应用。对于开发者来说,应在应用中对文件下载进行显式编码。否则,远程文件只是一个 URL,应用/容器不知道要用远程文件 URL 来做什么(我可以通过专门的调试工具来阐明内在原理,但暂不这么做)。

若要处理此类方案,需要调用相应的 UWP API。这些 API 可以与在使用浏览器运行网站时实现些功能的代码共存。图 2 展示了所涉及的代码。

图 2:文件下载 JavaScript 代码(大型文件)

(function() {
  if (typeof Windows !== 'undefined' &&
    typeof Windows.UI !== 'undefined' &&
    typeof Windows.ApplicationModel !== 'undefined') {
  function WinAppSaveFileBGDownloader() {
    // This condition is only true when running inside an app.
    // The else condition is effective when running inside browsers.
    // This function uses the Background Downloader class to download a file.
    // This is useful when downloading a file more than 50MB. 
    // Downloads continue even after the app is suspended/closed.
    if (typeof Windows !== 'undefined' &&
      typeof Windows.UI !== 'undefined' &&
      typeof Windows.ApplicationModel !== 'undefined') {
        var fileSavePicker = new Windows.Storage.Pickers.FileSavePicker();
        // Example: You can replace the words EXTENSION and ext with the word PNG. 
        fileSavePicker.fileTypeChoices.insert(
          "EXTENSION file", [".ext"]);
            // Insert appropriate file format through code.
        fileSavePicker.defaultFileExtension = ".ext";
          // Extension of the file being saved.
        fileSavePicker.suggestedFileName = "file.ext";
          // Name of the file to be downloaded.
        fileSavePicker.settingsIdentifier = "fileSavePicker1";
        var fileUri =
          new Windows.Foundation.Uri("<URL of the file being downloaded>");
        fileSavePicker.pickSaveFileAsync().then(function (fileToSave) {
          var downloader =
            new Windows.Networking.BackgroundTransfer.BackgroundDownloader();
          var download = downloader.createDownload(
            fileUri,
            fileToSave);
          download.startAsync().then(function (download) {
            // Any post processing.
            console.log("Done");
          });
        });
      }
      else {
        // Use the normal download functionality already implemented for browsers,
        // something like <a href="<URL>" />.
      }
    }
}
})();

可以在实现按钮单击或应实现文件下载功能的位置编写图 2 中的代码。此代码片段

使用 BckgroundDownloader 类 (bit.ly/2jQeuBw),带来了诸多好处,如可以在应用暂停后在后台持续下载,以及可以下载大型文件。

图 2 中的代码实际上实现了与浏览器一样的体验,如提示用户选择文件位置(fileSavePicker 变量)、启动下载(downloader 变量)以及将文件写入选定位置。

也可以为小型文件下载编写图 3 中的代码,只要应用在运行,就可以运行此代码:

图 3:文件下载 JavaScript 代码(小型文件)

// Use the Windows.Web.Http.HttpClient to download smaller files and
// files are needed to download when the app is running in foreground.
(function() {
  if (typeof Windows !== 'undefined' &&
    typeof Windows.UI !== 'undefined' &&
    typeof Windows.ApplicationModel !== 'undefined') {
  function WinAppSaveFileWinHTTP() {
    var uri = new Windows.Foundation.Uri("<URL of the file being downloaded>");
    var fileSavePicker = new Windows.Storage.Pickers.FileSavePicker();
    fileSavePicker.fileTypeChoices.insert("EXT file",
      [".ext"]); //insert appropriate file format through code.
    fileSavePicker.defaultFileExtension = ".ext";
      // Extension of the file being saved.
    fileSavePicker.suggestedFileName = "file.ext";
      // Name of the file to be downloaded. Needs to be replaced programmatically.
    fileSavePicker.settingsIdentifier = "fileSavePicker1";
    fileSavePicker.pickSaveFileAsync().then(function (fileToSave) {
      console.log(fileToSave);
      var httpClient = new Windows.Web.Http.HttpClient();
      httpClient.getAsync(uri).then(function (remoteFile) {
        remoteFile.content.readAsInputStreamAsync().then(
           function (stream) {
          fileToSave.openAsync(
            Windows.Storage.FileAccessMode.readWrite).then(
            function (outputStream) {
            Windows.Storage.Streams.RandomAccessStream.copyAndCloseAsync(
              stream, outputStream).then(function (progress, progress2) {
          // Monitor file download progress.
            console.log(progress);
            var temp = progress2;
          });
        });
        });
      });
    });
  }
}
})();

图 3 中的代码使用 Windows.Web.Http.HttpClient 来处理文件下载操作 (bit.ly/2k5iX2E)。如果比较图 2图 3 中的代码片段,你将会发现,后者需要进行一些高级编码,以便更好地控制文件下载。这样一来,还可以探索多种使用 UWP 应用来保存文件流的方式。

此外,如果应用还处理已下载的文件,最好使用 FutureAccessList 属性来缓存并访问文件的保存位置 (bit.ly/2k5fXn6)。这一点非常重要,因为用户可以选择在系统上的任意位置(如 D:\files\ 或 C:\users\myuser\myfiles\)保存已下载的文件,而如果没有用户启动的操作,作为沙盒进程的应用容器则可能无法直接访问文件系统。使用此类,无需为了再次打开同一文件而让用户执行额外操作。必须使用 FileOpenPicker 打开文件至少一次,才能使用此类。

这应该有助于简化托管 Web 应用中的文件下载功能,并提供直观的用户体验。(提示: 请务必将文件 URL 域添加到 ApplicationContentURI (bit.ly/2jsN1WS),以免出现运行时异常。)

会话管理

如今,大多数新式应用通常都会保留用户的多个会话数据。举例来说,假设在每次启动常用应用时,都需要进行登录。如果在一天内多次启动应用,将会非常耗时。Windows 运行时框架内的类适用于转换成 UWP 应用的 Web 应用。

设想一下,需要使用参数才能保留同一用户的多个会话。例如,假设需要使用可标识用户和其他信息(如上次会话时间和其他缓存项)的字符串。

在此示例中,要使用的正确类是 Windows.Storage.ApplicationData 类。此类有许多属性和方法,但在此示例中,需要使用的是 localSettings 属性。

设置存储在键值对系统中。示例实现代码如下:

function getSessionData()
{
var applicationData = Windows.Storage.ApplicationData.current;
var localSettings = applicationData.localSettings;
// Create a simple setting.
localSettings.values["userID"] = "user998-i889-27";
// Read data from a simple setting.
var value = localSettings.values["userID"];
}

理想情况下,可以在需要捕获特定信息并将其发送到远程 Web API 或服务的应用检查点,编写用于将数据写入设置的代码。通常可以在 App_activated 事件处理程序中编写用于读取设置的代码。在此处理程序中,可以启动应用,并能从中访问保留的上一应用会话信息。请注意,每个设置名称的长度上限为 255 个字符,每个设置的大小上限为 8 KB。

语音 (Cortana) 集成方案

网站(现在是应用)在移植到 UWP 后可以实现的一个方案是集成 Cortana,这是 Windows 设备上的语音交互式数字助理。

假设网站是一个旅游门户,现在移植到 Windows 10 应用后,可以集成 Cortana 的用例之一是显示用户的旅行详细信息。然后,用户可以发出命令,如“显示伦敦之行”(或其他任何目的地),这需要由应用开发者进行配置,如图 4 所示。

图 4:Cortana 命令 XML

<?xml version="1.0" encoding="utf-8"?>
<VoiceCommands xmlns="https://schemas.microsoft.com/voicecommands/1.1">
  <CommandSet xml:lang="en-us" Name="AdventureWorksCommandSet_en-us">
    <CommandPrefix> Adventure Works, </CommandPrefix>
    <Example> Show trip to London </Example>
    <Command Name="showTripToDestination">
      <Example> Show trip to London </Example>
      <ListenFor RequireAppName=
        "BeforeOrAfterPhrase"> show trip to {destination} </ListenFor>
      <Feedback> Showing trip to {destination} </Feedback>
      <Navigate/>
    </Command>
    <PhraseList Label="destination">
      <Item> London </Item>
      <Item> Dallas </Item>
    </PhraseList>
  </CommandSet>
<!-- Other CommandSets for other languages -->
</VoiceCommands>

Cortana 可使用许多第一方应用(如“日历”)帮助完成任务,并向包含自定义应用的接口公开 API。下面介绍了 Cortana 在应用中的基本工作原理:

  1. 侦听 voicecommands.xml 文件中注册的命令。
  2. 激活相关应用,同时匹配用户 Windows 搜索栏中通过语音输入/手动键入的命令。
  3. 将语音转文本命令作为变量传递给应用,以便应用可以处理用户输入。

注意: 文件名具有指示性。可以根据需要进行命名(采用 XML 扩展名)。Windows 开发者文章“使用 Cortana 与客户进行交互 (10x10)”(bit.ly/2iGymdE) 很好地概述了如何在 XML 文件及其架构中配置命令。

图 4 中的 XML 代码有助于 Cortana 确定在以下命令发出时需要启动 Adventure Works 应用:

'Adventure Works, Show trip to London'
'Adventure Works, Show trip to Dallas'

现在,我们来看看 Web 代码是如何根据用户输入来处理应用(或网站)激活和相关页导航的。为了编码实现这种方案,可以单独创建 JavaScript 文件。

应先在调用方页/引用页的 <body> 部分中引用图 5 中的代码,然后紧接着再触发 DOMContentLoaded 事件。因此,最好在引用页中紧靠着结束 <body> 元素前面添加 <script src>。

图 5:语音命令处理程序代码

<!-- In HTML page :
<meta name="msapplication-cortanavcd" content="http://<URL>/vcd.xml" />
-->
(function () {
  if (typeof Windows !== 'undefined' &&
    typeof Windows.UI !== 'undefined' &&
    typeof Windows.ApplicationModel !== 'undefined') {
    Windows.UI.WebUI.WebUIApplication.addEventListener("activated", activatedEvent);
  }
  function activatedEvent (args) {
    var activation = Windows.ApplicationModel.Activation;
    // Check to see if the app was activated by a voice command.
    if (args.kind === activation.ActivationKind.voiceCommand) {
      // Get the speech recognition.
      var speechRecognitionResult = args.result;
      var textSpoken = speechRecognitionResult.text;
      // Determine the command type {search} defined in vcd.
      switch (textSpoken) {
         case "London":
           window.location.href =
             'https://<mywebsite.webapp.net>/Pages/Cities.html?value=London';
        break;
      case "Dallas":
        window.location.href =
          'https://<mywebsite.webapp.net>/Pages/Cities.html?value=Dallas';
        break;
                              ...                                   
        <other cases>
                              ...               
      }
    }
  }
})();

 (注意: 因为 Cortana 是通过将“activationKind”用作“voiceCommand”来“激活”应用,所以请务必在事件处理程序中注册此激活事件。若要在应用不是原生 WinJS 或 C# 程序的情况下注册应用生命周期事件,可以使用提供的命名空间 Windows.UI.WebUI.WebUIApplication 来订阅和处理特定事件。)

图 5 的代码中,Cortana API 将接收用户语音输入,并填充激活类的 SpeechRecognition 属性。这应该有助于在代码中检索转换后的文本,并帮助应用执行相关操作。此代码片段使用 switch-case 语句来计算 textSpoken 变量,然后将 city 值追加为查询字符串,将用户路由到 Cities.html 页。

显然,这只是各种情形之一,具体用例也会根据每个网站的路由配置(MVC、REST 等)相应地发生变化。应用现在可以与 Cortana 进行通信了。

(缺少)“后退”按钮

在介绍了一些最先进的功能后,我们来看看应用体验一个非常基本但又很重要的方面,即导航。轻松的应用浏览体验和直观的导航层次结构有助于为用户提供可预测的应用体验。

这种方案很特别,因为在将响应式网站移植到 UWP 应用时,UI 可以按预期正常运行。不过,需要为应用容器提供更多指针来处理应用导航。默认用户体验如下:

  1. 用户启动应用。
  2. 用户通过单击页面上的超链接或菜单来浏览应用的各个部分。
  3. 在某一时刻,用户想要转到上一页,并单击 Windows OS 提供的硬件或软件“后退”按钮(应用中尚无“后退”按钮)。
  4. 用户退出应用。

第 4 点不符合预期,需要进行修复。解决方案很简单,就是通过常规 JavaScript API 指示应用容器窗口后退。图 6 展示了此方案的代码。

图 6:“后退”按钮代码

(function() {
  if (typeof Windows !== 'undefined' &&
    typeof Windows.UI !== 'undefined' &&
    typeof Windows.ApplicationModel !== 'undefined') {
   Windows.UI.Core.SystemNavigationManager.getForCurrentView().
     appViewBackButtonVisibility =
     Windows.UI.Core.AppViewBackButtonVisibility.visible;
        Windows.UI.Core.SystemNavigationManager.getForCurrentView().
        addEventListener("backrequested", onBackRequested);
function onBackRequested(eventArgs) {
      window.history.back();
      eventArgs.handled = true;
    }
  }
  })();

前几行代码实现了框架提供的“后退”按钮,此按钮在应用最上面显示,可以在事件处理程序中注册点击/单击事件。在代码中,你只需访问“窗口”DOM 对象,并指示它返回到上一页。请注意一点: 当应用位于导航堆栈的底部时,历史记录中没有其他任何页面,此时将退出应用。如果需要在应用中内嵌自定义体验,还需要编写其他代码。

动态磁贴

动态磁贴是一项 UWP 应用功能,用户无需启动应用,即可查看有关应用更新或可能感兴趣的任何内容的简明信息。点击 Windows 设备上的开始菜单即可快速看到示例。对于“新闻”、“财经”和“体育”等应用,一些动态磁贴将变得显而易见。

下面介绍了几个用例:

  • 在电子商务应用中,磁贴可以显示商品推荐或订单状态。
  • 在业务线应用中,磁贴可以显示组织报表的迷你图像。
  • 在游戏应用中,动态磁贴可以显示优惠、成就、新挑战等。

图 7 展示了一些 Microsoft 第一方应用的两个磁贴示例。

动态磁贴示例
图 7:动态磁贴示例

若要将动态磁贴集成到托管 Web 应用,最简单的方法之一是创建 Web API,并编写应用代码(如图 8 所示),以便每隔几分钟轮询一次。Web API 的任务是发回 XML。此 XML 将用于创建 Windows 应用的磁贴内容。(注意: 请务必让最终用户将应用固定到开始菜单,从而体验动态磁贴。)

图 8:简易动态磁贴代码

function enableLiveTile()
  {
    if (typeof Windows !== 'undefined' &&
      typeof Windows.UI !== 'undefined' &&
      typeof Windows.ApplicationModel !== 'undefined') {
      {
        var notification = Windows.UI.Notifications;
        var tileUpdater =
          notification.TileUpdateManager.createTileUpdaterForApplication();
        var recurrence = notification.PeriodicUpdateRecurrence.halfHour;
        var url = new Windows.Foundation.Uri("<URL to receieve the XML for tile>");
        tileUpdater.startPeriodicUpdate(url, recurrence);
      }
      }       
  }

其中最重要的类是 Windows.UI.Notifications 命名空间的 TileUpdateManager。此类不仅在内部创建模板以发送给磁贴,还通过 startPeriodicUpdate 方法来轮询磁贴 XML 内容的指定 URL。可以使用 Periodic­UpdateRecurrence 枚举来设置轮询的持续时间,以便定期拉取磁贴的 XML 内容。这更是一种服务器驱动方法,因为 Web API 将 XML 代码和磁贴模板发送到客户端。如果开发者可以控制应用和服务层,那么这样做可行。

现在,假设应用从第三方 Web API 接收信息(如天气或市场研究数据)。在此示例中,大多数 Web API 会以正文、标头等形式发送标准 HTTP 响应。此时,可以分析 Web API 响应,然后用客户端 UWP 应用 JavaScript 代码生成 XML 磁贴内容。这样一来,应用开发者可以更好地控制显示数据的模板的类型。还可以通过 TileNotification 类提及磁贴的到期时间。图 9 中展示了此示例的代码。

图 9: 另一种动态磁贴创建方法

function createLiveTile() /* can contain parameters */
{
  var notifications = Windows.UI.Notifications,
  tile = notifications.TileTemplateType.tileSquare310x310ImageAndText01,
  tileContent = notifications.TileUpdateManager.getTemplateContent(tile),
  tileText = tileContent.getElementsByTagName('text'),
  tileImage = tileContent.getElementsByTagName('image');
  // Get the text for live tile here [possibly] from a remote service through xhr.
  tileText[0].appendChild(tileContent.createTextNode('Demo Message')); // Text here.
  tileImage[0].setAttribute('src','<URL of image>');
  var tileNotification = new notifications.TileNotification(tileContent);
  var currentTime = new Date();
  tileNotification.expirationTime = new Date(currentTime.getTime() + 600 * 1000);
    notifications.TileUpdateManager.createTileUpdaterForApplication().
    update(tileNotification);
}

请注意,使用 TileTemplateType 类,可以创建包含图像和文本的 310 x 310 像素方形磁贴模板。此外,还可以通过代码将磁贴的到期时间设置为 10 分钟。也就是说,在 10 分钟过后,动态磁贴会还原成应用包中提供的默认应用磁贴,除非应用中有新的推送通知。若要详细了解可用磁贴模板,请访问 bit.ly/2k5PDJj

总结

规划从 Web 应用迁移到 UWP 应用时,需要注意以下几点:

  1. 使用新式浏览器(例如 Microsoft Edge)测试应用的布局和呈现效果。
  2. 如果 Web 应用依赖于 ActiveX 控件或插件,请确保当应用在新式浏览器中或作为 UWP 应用运行时,可以通过备用方法实现功能。
  3. 使用 bit.ly/1PJBcpi 中的 SiteScan 工具获取与库和功能相关的建议。
  4. 标识网站引用的外部资源的 URL。必须将这些 URL 添加到 Appx.manifest 文件的 ApplicationContentUriRules 部分。

此外,还可以通过 JavaScript Windows 对象进一步集成其他更多功能,这些集成将有助于丰富用户体验,让应用功能大放异彩。联系人、照相机、麦克风、Toast 通知和其他许多功能为向网站赋予应用角色提供了契机。本文中的代码已转换成项目模板,开发者可通过 GitHub (bit.ly/2k5FlJh) 获取代码。


Sagar Bhanudas Joshi已经与开发者和 ISV 就通用 Windows 平台和 Microsoft Azure 合作了六年多。他负责与创业公司合作,帮助它们构造、设计并生成 Azure、Windows 和 Office 365 平台的入门级解决方案和应用。Joshi 在印度孟买生活和工作。可以通过 Twitter (@sagarjms) 与他联系。

衷心感谢以下 Microsoft 技术专家对本文的审阅: Sandeep Alur 和 Ashish Sahu