2018 年 4 月

第 33 卷,第 4 期

Visual Studio for Mac - 使用 Xamarin 和 Visual Studio for Mac 为 watchOS 编程

作者 Dawid Borycki

个人活动跟踪器、智能手表(如 Microsoft Band、Android Wear 或 Apple Watch)等小型可穿戴设备越来越受欢迎。这些可穿戴设备配备了各种传感器,可实时监视穿戴者的健康参数。许多可穿戴设备还有通信接口,这样就可以将传感器数据轻松传输到自定义或专用云服务(如 Microsoft Health),以供存储或高级处理。因此,可穿戴设备可用作物联网 (IoT) 生态系统中的附加终结点。这进而有助于将个人保健提升到一个全新高度,即 IoT 预测算法可以提前通知用户新出现的健康问题。

可穿戴设备还可以运行自定义应用。开发人员可以使用所提供的专用 SDK。不过,与许多移动设备一样,每个平台都有自己的专用 API,可以通过平台专用编程语言和工具进行访问。为方便起见,Xamarin 分别在 Xamarin.Android 和 Xamarin.iOS 库中提供对 Android Wear 和 watchOS 的支持。可穿戴设备应用的开发方式与移动应用类似,都是通过利用平台专用项目中引用的通用 .NET 基准代码。

本文将介绍如何利用此类方法生成图 1 中的 watchOS 应用。运行时,此应用先从 REST Web 服务中检索对象集合。此集合由虚构照片组成,每张照片都有一个标题和位图(单色图像)。在这个阶段中,此应用只显示一个描述文字为“获取列表”的按钮。此按钮在数据下载完成前一直处于禁用状态,如图 1**** 中的第一行所示。

watchOS 应用预览
图 1:watchOS 应用预览

点击按钮后便会看到操作工作表(如图 1 中的第二行所示),其中显示的警报由几个定义为操作或操作按钮的按钮组成 (bit.ly/2EEUZpL)。在此示例中,操作工作表提供的是操作按钮,这些按钮的描述文字包含要显示的一系列照片。如果点击某个操作,就会在“获取列表”按钮正下方的“表”控件 (bit.ly/2Caq0nM) 中看到选定照片,如图 1**** 中的最后一行所示。因为此表可滚动,所以能够向下滚动列表,以查看组中的所有照片。

我将在单独的 .NET Standard 类库 (bit.ly/2HArfMq) 中实现与 Web 服务的通信。根据参考文档,.NET Standard 是正式 .NET API 规范,旨在完成一致性访问对所有 .NET 实现可用的编程接口。这种方法的主要优点之一是,可以在 .NET 项目中轻松引用共用库,以减少或消除共用代码的条件编译。因此,只需实现一次 .NET Standard 类库,即可在各种定目标到通用 Windows 平台 (UWP)、.NET Core、ASP.NET Core、Xamarin.iOS、Xamarin.Android、Xamarin.Forms 等的 .NET 项目中引用它,而无需为每个平台重新编译。

接下来,我将介绍如何在 watchOS 应用中重用通用代码。对于 Web 服务,我将使用虚构的 REST API 服务器 JSONPlaceholder (jsonplaceholder.typicode.com),以提供多个资源,包括示例应用中使用的照片资源。此类资源存储了一系列虚构照片,同时将每张照片都表示为以下 JSON 对象:

{
  "albumId": 1,
  "id": 1,
  "title": "accusamus beatae ad facilis cum similique qui sunt",
  "url": "http://placehold.it/600/92c952",
  "thumbnailUrl": "http://placehold.it/150/92c952"
}

每张照片都包含关联的 ID、相册、标题,以及两个指向位图及其缩略图的 URL。在本练习中,位图只是包含位图尺寸标签的单色图像。

本文中的所有内容都是使用 Visual Studio for Mac 创建而成。有关此项目的完整示例代码,可以访问 GitHub (github.com/dawidborycki/Photos)。

父应用和共用代码

典型 watchOS 解决方案的结构由三个项目组成(请访问 apple.co/2GwXhrnbit.ly/2EI2dNO)。第一个项目是父 iOS 应用。另外两个项目专用于手表应用,分别是手表应用包和 WatchKit 扩展包。父 iOS 应用可用作将手表应用包提供给可穿戴设备的代理。手表应用包内含界面情节提要。与 iOS 一样,开发人员使用界面情节提要来定义它们之间的场景和转入(转换)。最后一个项目 WatchKit 扩展包内含资源和应用代码。

首先,使用全新的单一视图 iOS 项目(可从 Visual Studio for Mac 的新项目创建器中获取)创建父 iOS 应用。我将项目和解决方案分别命名为 Photos.iOS 和 Photos。创建此项目后,我添加另一个使用 .NET Standard 库项目模板创建的项目 Photos.Common,从而补充 Photos 解决方案。此模板位于新项目创建器的“多平台 | 库组”下。

创建 .NET Standard 库时,可以根据需要选择 .NET Standard 版本。此版本决定了可用的 API(版本越高,可使用的功能就越多)和受支持的平台(版本越高,受支持的平台就越少)。接下来,我将把 .NET Standard 版本设置为 2.0,其中已包含通过 HTTP 与 Web 服务进行通信的 HttpClient 类。可以使用 .NET API 浏览器 (bit.ly/2Fl44Fa) 检索每个 .NET Standard 版本的 API 列表。

设置通用项目后,我将安装一个 NuGet 包(即 Newtonsoft.JSON),用于反序列化 HTTP 响应。在 Visual Studio for Mac 中安装 NuGet 包的步骤与在 Visual Studio for Windows 中一样。在解决方案资源管理器中,右键单击“依赖项 | NuGet”节点,并选择关联菜单中的“添加包”。此时,Visual Studio 会显示可供搜索包的窗口。也可以使用包控制台,并通过命令行安装 NuGet 包。

REST 客户端

现在,我已准备就绪,可以为 Web 服务 Photos 实现客户端类。为了简化反序列化,我将上述 JSON 对象映射到 C# 类。为此,可以手动完成,也可以使用专用工具。接下来,我将使用 JSONUtils (jsonutils.com)。此网站有一个由以下元素组成的直观界面:

  • 用于输入类名的“类名”文本框
  • 将 JSON 代码或其 URL 放置到的“JSON 文本或 URL”文本框
  • 多个可供选择语言(C#、VB.NET 等)的单选按钮
  • 两个复选框:“添加命名空间”和“帕斯卡命名法大小写”

为了生成 C# 类,我将“类名”设置为“Photo”,并在“JSON 文本或 URL”文本框中粘贴以下 URL:jsonplaceholder.typicode.com/photos/1。最后,我选中“帕斯卡命名法大小写”复选框,并单击“提交”按钮。此时,生成的 Photo 类显示在页面底部。Photo 类(请参阅随附代码:Photos.Common/Model/Photo.cs)无需额外注释,它仅包含自动实现的属性,同时表示上述 JSON 对象。

为了实现 REST 客户端,我创建了静态 PhotoClient 类(Photos.Common 项目)。此类包含一个类型为 HttpClient 的字段。此字段在静态构造函数中进行实例化,以将 BaseAddress 属性设置为指向 JSONPlaceholder URL,如下所示:

private static HttpClient httpClient;
static PhotosClient()
{
  httpClient = new HttpClient()
  {
    BaseAddress = new Uri("https://jsonplaceholder.typicode.com/")
  };
}

如图 2 所示,PhotosClient 包含以下两个公共方法:

  • GetByAlbumId:使用 HttpClient 类的 GetAsync 方法,实现收集指定相册中的照片。检查 HTTP 响应状态代码 (CheckStatusCode) 后,生成的响应通过通用帮助程序方法 DeserializeResponse(稍后将介绍帮助程序方法)反序列化为 C# Photo 对象集合。
  • GetImageData:检索表示来自所提供 URL 的照片的字节数组。我使用 HttpClient 类实例的 GetByteArrayAsync 方法,以获取图像数据。

图 2:PhotoClient 类的公共方法

public static async Task<IEnumerable<Photo>> GetByAlbumId(int albumId)
{
  var response = await httpClient.GetAsync($"photos?albumId={albumId}");
  var photoCollection = new List<Photo>();
  try
  {
    CheckStatusCode(response);
    photoCollection = await DeserializeResponse<List<Photo>>(response);
  }
  catch(Exception ex)
  {
    Console.WriteLine(ex.Message);
  }
  return photoCollection;
}
public static async Task<byte[]> GetImageData(Photo photo)
{
  var imageData = new byte[0];
  try
  {
    Check.IsNull(photo);
    imageData = await httpClient.GetByteArrayAsync(photo.ThumbnailUrl);
  }
  catch(Exception ex)
  {
    Console.WriteLine(ex.Message);
  }
  return imageData;
}

PhotosClient 还实现了以下两个私有方法:Check­StatusCode 和 DeserializeResponse。第一个方法接受 HttpResponseMessage 类的实例,并检查它的 IsSuccessStatusCode 属性值,以在状态代码不是 200(成功代码)的情况下抛出异常。

private static void CheckStatusCode(HttpResponseMessage response)
{
  if (!response.IsSuccessStatusCode)
  {
    throw new Exception($"Unexpected status code: {response.StatusCode}");
  }
}

虽然第二个方法 DeserializeReponse 也接受 HttpResponseMessage 类的实例,但它使用 HttpResponseMessage.Content.ReadAsStringAsync 方法将消息正文读取为字符串。然后,生成的值传递给 Newtonsoft.Json.JsonConvert 类的静态 DeserializeObject 方法。后者返回给定类型的 C# 对象,如下所示:

private static async Task<T> DeserializeResponse<T>(HttpResponseMessage response)
{
  var jsonString = await response.Content.ReadAsStringAsync();
  return JsonConvert.DeserializeObject<T>(jsonString);
}

在图 2 中,我还使用了自定义 Check 类的 IsNull 方法。IsNull 方法执行简单的参数验证,以检查参数是否为 NULL。如果是,将会抛出 ArgumentNullException 类型的异常(请参阅随附代码:Photos.Common/Helpers/Check.cs)。

共用功能现已准备就绪,这样我就可以继续实现 watchOS 应用了。

watchOS 应用及其结构

为了创建实际 watchOS 应用,我先右键单击解决方案资源管理器下的解决方案名称(在此示例中为 Photos),再选择上下文菜单中的“添加 | 添加新项目”。此时,“新建项目”对话框显示。我在其中单击了以下选项卡:“watchOS | 应用”(确保使用的不是“扩展”或“库”选项卡)。此时,项目模板列表显示,我从中选择了“WatchKit 应用 C#”项目模板。接下来,就会看到可配置选项列表,如图 3**** 所示。

配置 watchOS 应用
图 3:配置 watchOS 应用

如前所述,每个 watchOS 应用都有关联的父 iOS 应用。在此示例中,它就是 Photos.iOS 应用。然后,我在“应用名称”文本框中键入“WatchKit”,将“目标”设置为“watchOS 4.2”(请注意,此列表中的项具体视安装的 SDK 版本而定),并取消选中“场景”组下的所有复选框。

我单击“下一步”按钮后,Visual Studio 便会创建以下两个附加项目:Photos.iOS.WatchKit 和 Photos.iOS.WatchKitExtension。

第一个项目 (WatchKit) 包含情节提要,可用于定义它们之间的场景和转入(转换)。第二个项目 (WatchKitExtension) 包含关联逻辑,包括将 watchOS 中的视图控制器称为界面控制器。因此,通常使用情节提要设计器(如图 4 所示)修改手表应用的 UI。若要激活情节提要设计器,请双击 WatchKit 应用的 Interface.storyboard 文件。

设计 watchOS 应用的 UI
图 4:设计 watchOS 应用的 UI

使用情节提要设计器,可以将控件从“工具箱”拖放到场景中。在图 4 中,我只有一个场景,它与 Photos.iOS.WatchKit­Extension 项目下定义的 InterfaceController 类相关联。此类就像是 iOS 应用的 UIViewController。也就是说,它在屏幕上呈现和管理内容,并实现用户交互处理方法。请注意,将控件添加到场景中后,可以使用“属性”面板修改其属性,然后通过 InterfaceController 类使用代码访问它们。

编辑 UI 前,先来简单研究一下此类(派生自所有界面控制器的基类 WatchKit.WKInterfaceController)的结构。InterfaceController 的默认实现重写与视图生命周期相关的三个方法(请访问 apple.co/2GwXhrn):

  • Awake:系统在 InterfaceController 初始化后立即调用。此方法通常用于加载数据并准备 UI。
  • WillActivate:当关联视图即将变为活动状态时调用。此方法用于在界面刚好显示之前准备最终更新。
  • DidDeactivate:当视图变为非活动状态时调用。此方法通常用于释放不再需要的动态资源。

上述这些方法用于配置视图,具体取决于视图的可见性。为了举一个很简单的例子,我将分析下面的 Awake 方法:

public override void Awake(
  NSObject context)
{
  base.Awake(context);
  SetTitle("Hello, watch!");
}

此代码先调用基类 (WKInterfaceController) 的 Awake 方法,再调用 SetTitle 方法,以更改在视图左上角显示的字符串。此标题是每个界面控制器的默认元素。

若要测试此修改,可以在模拟器中运行 Photos.iOS.WatchKit 应用。首先,需要使用 Visual Studio for Mac 中的工具栏下拉列表,将此应用(而不是扩展包)设置为启动项目。在此列表旁边,还有另外两个下拉列表:一个用于选择配置(“调试”或“发布”),另一个用于从模拟器列表中进行选择。接下来,我将使用“调试”配置和“Apple Watch Series 3 - 42 mm - watchOS 4.2”模拟器。选择模拟器并单击“播放”图标后,便会编译并部署应用。请注意,启动的是下面两个模拟器:iOS 模拟器及其配对的 watchOS 模拟器(请回头查看图 1 中的左侧屏幕)。

操作工作表

现在,我可以继续实现 Photos.iOS.WatchKit 应用的实际 UI。如图 1**** 所示,应用 UI 由以下三个元素组成:按钮、操作工作表和表视图。如果用户点击按钮,就会激活操作工作表。它提供了多个选项,以便于用户选择要在桌面视图中显示的一组照片。我根据 Apple 指南实现了此照片分组,以限制表视图中的行数,从而提升应用性能 (apple.co/2Cecrnt)。

首先,我将创建在操作工作表中显示的按钮列表。此按钮列表是根据从 Web 服务检索到的照片集合(照片字段)而生成,如图 5 所示。每个操作按钮都被表示为 WatchKit.WKAction 类实例。虽然此类缺少任何公共构造函数,但却实现了静态 Create 方法,可用于创建操作。如图 5**** 所示,Create 方法接受以下三个参数:

  • 定义操作按钮标题的 Title
  • 指明操作按钮样式的 Style
  • 指定在用户点击操作按钮时要执行的方法的 Handler

图 5:照片标题分区

private const int rowsPerGroup = 10;
private IEnumerable<Photo> photos;
private WKAlertAction[] alertActions;
private void CreateAlertActions()
{
  var actionsCount = photos.Count() / rowsPerGroup;
  alertActions = new WKAlertAction[actionsCount];
  for (var i = 0; i < actionsCount; i++)
  {
    var rowSelection = new RowSelection(
      i, rowsPerGroup, photos.Count());
    var alertAction = WKAlertAction.Create(
      rowSelection.Title,
      WKAlertActionStyle.Default,
      async () => { await DisplaySelectedPhotos(rowSelection); });
    alertActions[i] = alertAction;
  }
}

在图 5 中,所有操作都采用默认样式,表示为 WatchKit.WKAlertStyle 枚举的默认值。此枚举定义了另外两个值 (apple.co/2EHCAZr):Cancel 和 Destructive。第一个值可用于创建无需任何更改即可取消操作的操作。破坏性样式应适用于带来无法撤消修改的操作。

图 5**** 中的 CreateAlertActions 方法将照片分成区块,每个区块包含 10 个元素(rowsPerGroup 常量)。为了从集合中获取一组选定照片,我需要以下两个索引:一个是组的开始索引(beginIndex 变量),另一个是组的结束索引 (endIndex)。我使用 RowSelection 类计算这些索引。此类也用于为操作工作表中显示的项创建标题。RowSelection 类在 Photos.iOS.WatchKitExtension 项目 (RowSelection.cs) 中实现,它的最重要部分如图 6 所示。

图 6:使用 RowSelection 类计算索引

public int BeginIndex { get; private set; }
public int EndIndex { get; private set; }
public int RowCount { get; private set; }
public string Title { get; private set; }
private static string titlePrefix = "Elements to show:";
public RowSelection(int groupIndex, int rowsPerGroup, int elementCount)
{
  BeginIndex = groupIndex * rowsPerGroup;
  EndIndex = Math.Min((groupIndex + 1) * rowsPerGroup, elementCount) - 1;
  RowCount = EndIndex - BeginIndex + 1;
  Title = $"{titlePrefix} {BeginIndex}-{EndIndex}";
}

最后,操作工作表的每个按钮都有关联的处理程序,用于调用 DisplaySelectedPhotos 方法。此方法负责显示包含选定照片的表,稍后将进行介绍。

为了激活操作工作表,我先引用 Photos.Common 项目。为此,我在解决方案资源管理器中右键单击 Photos.iOS.WatchKitExtension 的“引用”,再依次选择“编辑引用”、“项目”选项卡和“Photos.Common”。转到“引用管理器”后,我还需要引用 Newtonsoft.Json.dll 库,以确保将它复制到输出目录中。为此,我使用“.NET 程序集”选项卡,单击“浏览”按钮,再从“packages/Newtonsoft.Json/lib/netstandard20”文件夹中选择“Newtonsoft.Json.dll”。此文件夹是在安装 Newtonsoft.Json NuGet 包后创建。

若要在 watchOS 应用中访问共用基准代码(包括之前实现的 PhotosClient),必须执行这些步骤。然后,我使用情节提要设计器修改 UI。若要详细了解 watchOS 中的布局工作原理,请参阅 Apple 文档 (apple.co/2FlzADj) 和 Xamarin 文档 (bit.ly/2EKjCRM)。

打开情节提要设计器后,我将“按钮”控件从“工具箱”拖到场景中。我使用“属性”面板将按钮的 Name 和 Title 属性分别设置为“ButtonDisplayPhotoList”和“Get list”。然后,我创建了事件处理程序。只要用户点击按钮,就会运行它。为了创建事件处理程序,我使用“属性”面板,单击“事件”选项卡,并在“操作”搜索框中键入“ButtonDisplayPhotoList_Activated”。在我按 Enter 键后,Visual Studio 便会在 InterfaceController 类中声明新方法。最后,ButtonDisplayPhotoList_Activated 定义如下:

partial void ButtonDisplayPhotoList_Activated()
{
  PresentAlertController(string.Empty,
    string.Empty,
    WKAlertControllerStyle.ActionSheet,
    alertActions);
}

我使用 PresentAlertController 创建和显示操作工作表。此方法接受以下四个参数:

  • 指明警报标题的 Title。
  • 指定要在警报正文中显示的文本的 Message。
  • 指定警报控制器样式的 PreferredStyle。Style 由 Watch­Kit.WKAlertControllerStyle 枚举中定义的下列值之一表示:Alert、SideBy­SideButtonsAlert 或 ActionSheet。apple.co/2GA57Rp 总结了这些值的区别。
  • 指定要包含在警报中的操作按钮集合的 Actions。请注意,操作数量具体取决于警报样式,如参考文档中所述。

接下来,我将 Title 和 Message 都设置为 string.Empty,而将警报样式设置为“ActionSheet”。因此,只会显示操作按钮(请回头查看图 1****)。为了确保 alertActions 在用户点击“获取列表”按钮前准备就绪,我在 Awake 方法中检索照片和标题(如图 7 所示):

图 7:检索照片和标题

public async override void Awake(NSObject context)
{
  base.Awake(context);
  SetTitle("Hello, watch!");
  // Disable button until the photos are downloaded
  ButtonDisplayPhotoList.SetEnabled(false);
  // Get photos from the web service (first album only)
  photos = await PhotosClient.GetByAlbumId(1);
  // Create actions for the alert
  CreateAlertActions();
  ButtonDisplayPhotoList.SetEnabled(true);
}

现在可以运行应用了。从远程服务器检索相册后,按钮就会处于启用状态。单击按钮即可激活操作工作表(如图 1**** 的中间部分所示)。

表视图

为了完成此实现,我需要创建显示一组选定照片的表视图。为此,我打开情节提要设计器,并将“表”控件从工具箱拖放到场景中(我将这个控件放置在“获取列表”按钮正下方)。

在下一步中,我需要定义单元格布局。此布局默认包含一个控件,即“组”。我可以将此控件用作其他控件的父项,但我首先需要确保“组”控件处于活动状态。因此,我将依次单击“文档大纲”面板(位于图 4 底部)和“表/表行”下的“组控制”。接下来,我将“图像”和“标签”控件拖放到表上。此时,“图像”和“标签”显示在表视图和“文档大纲”中。我将按如下所示配置所有控件属性:图像(Name:ImagePhotoPreview;Size:50 像素固定宽度和高度)、标签(Name:LabelPhotoTitle)、表(Name:TablePhotos)、组(Size:50 像素固定高度)。

请务必显式设置控件名称,这样才能通过代码轻松引用它们。在我指定控件名称后,Visual Studio 便会在 InterfaceController.designer.cs 文件下进行相应声明。

在 watchOS 中,每行都有专门的行控制器,用于控制行外观。接下来,我将使用此控制器,指定每行的图像和标签内容。若要创建行控制器,请选择“文档大纲”面板中的“表行”,再打开“属性”选项卡,并在其中的“类”文本框中键入“PhotoRowController”。此时,便会添加新文件 PhotoRowController.cs。其中包含同名的类。然后,我使用另一种方法补充此类的定义,如下所示:

public async Task SetElement(Photo photo)
{
  Check.IsNull(photo);
  // Retrieve image data and use it to create UIImage
  var imageData = await PhotosClient.GetImageData(photo);
  var image = UIImage.LoadFromData(NSData.FromArray(imageData));
  // Set image and title
  ImagePhotoPreview.SetImage(image);
  LabelPhotoTitle.SetText(photo.Title);
}

SetElement 函数接受一个类型为 Photo 的参数,以在表视图的相应行中显示照片缩略图和照片标题。然后,为了实际加载和配置表行,我使用以下方法扩展了 InterfaceController 的定义:

private async Task DisplaySelectedPhotos(RowSelection rowSelection)
{
  TablePhotos.SetNumberOfRows(rowSelection.RowCount, "default");
  for (int i = rowSelection.BeginIndex, j = 0;
    i <= rowSelection.EndIndex; i++, j++)
  {
    var elementRow = (PhotoRowController)TablePhotos.GetRowController(j);
    await elementRow.SetElement(photos.ElementAt(i));
  }
}

RowSelection 传递给 DisplaySelectedPhotos 方法,以提供要显示行的必要信息。具体来说就是,RowCount 属性用于设置要添加到表中的行数 (TablePhotos.SetNumberOfRows)。随后,DisplaySelectedPhotos 循环访问表行,以设置每行的内容。在每次迭代中,我先获取对与当前行关联的 PhotoRowController 的引用。获取此引用后,我调用 PhotoRowController.SetElement 方法,以获取在表单元格中显示的图像数据和标题。

最后,运行此应用后生成的内容如前面图 1**** 所示。

总结

本文介绍了如何使用 Xamarin、Visual Studio for Mac 和 .NET Standard 类库中实现的共用 C# .NET 基准代码开发 Apple Watch 应用。在此过程中,我探究了 watchOS 应用的一些最重要元素,包括应用结构、界面控制器和选定 UI 控件(按钮、操作工作表、表视图)。由于共用基准代码的实现方式与移动应用相同,因此可以将移动解决方案轻松扩展为定目标到智能可穿戴设备。若要详细了解 watchOS,可以参阅 Apple 文档 (apple.co/2EFLeaL) 和 Xamarin 详细文档 (bit.ly/2ohSwLU)。


Dawid Borycki 是软件工程师、生物医学研究员、作家和会议演讲者。**他喜欢学习有关软件实验和原型设计的新技术。

衷心感谢以下技术专家对本文的审阅:Brad Umbaugh
Brad Umbaugh 为 Microsoft Xamarin 团队撰写 iOS 和相关文档。可以在 Twitter 上联系 Brad Umbaugh (@bradumbaugh),他主要转发恶搞双关语的推文。


在 MSDN 杂志论坛讨论这篇文章