【译】 如何使用 .NET MAUI 构建 iOS 小部件

原文 | Toine de Boer

翻译 | 郑子铭

这是Toine de Boer的客座博文。

我是一名 .NET 开发人员,主要专注于 .NET MAUI 到 ASP.NET 后端服务的开发。由于最近我大量使用 Widgets,并在初期阶段遇到了许多障碍和非常有限的文档,因此我决定撰写本文,以证明使用 .NET MAUI 构建完整的 Widgets 是完全可行的。而且,这种方法可以像使用原生开发环境一样专业,无需担心每次构建或更新都会导致所有功能失效。

本文并非实战教程;相反,它按顺序介绍了构建 iOS 小组件时遇到的最大障碍及其最关键的部分。建议您具备 .NET MAUI 或 Xamarin 的相关经验,并且需要 macOS 系统,因为在没有 macOS 的情况下无法创建 iOS 小组件。您可以根据需要选择阅读部分内容,但我建议您从头到尾阅读,否则可能会错过一些导致小组件无法正常工作的细节。本文从创建一个简单的静态小组件开始,最终介绍一个用于构建完全交互式小组件的基本系统。

为了帮助您快速入门,我创建了一个功能齐全的交互式小部件,您可以在 GitHub 上找到它 Maui.WidgetExample

笔记

iOS 小部件是与宿主应用程序链接的独立应用程序。为简便起见,我通常将 .NET MAUI 应用程序称为"应用程序",将小部件应用程序称为"小部件"。

先决条件

开始之前,我们需要从苹果开发者控制台获取一些信息。除了现有应用的 Bundle ID 之外,您还需要 Widget 的 Bundle ID。如果您的应用使用 Widget,则com.enbyin.WidgetExample通常会在 Bundle ID 后附加一些 Widget 的前缀,例如 <Widget_id> com.enbyin.WidgetExample.WidgetExtension。此外,这两个 Bundle ID 都需要启用 App Groups 功能,并关联到一个专用的 Group。您可以通过在应用 Bundle ID 前加上 <Group_id> 来创建 Group ID group,例如 <App_id> group.enbyin.WidgetExample

需要从 Apple Developer Console 收集哪些信息:

  • 应用包 ID(例如com.enbyin.WidgetExample)
  • 小部件(应用)包 ID(例如com.enbyin.WidgetExample.WidgetExtension)
  • 组 ID(例如group.enbyin.WidgetExample)

为了演示,我创建了一个默认的 .NET MAUI 应用,目标平台仅为 iOS 和 Android。我将 iOS 目标平台设置为新创建的 Bundle ID com.enbyin.WidgetExample。我还添加了一个非常醒目的应用图标,以便我们能够轻松观察 Widget 屏幕上是否使用了正确的图标。

创建小部件项目

让我们从我作为 .NET 开发者的最大挑战------使用 Xcode 和 Swift 开始说起。在 Xcode 中创建项目之后,我建议切换到 VS Code 并搭配 Copilot,以便快速迭代。这样,你就能快速搭建一个符合苹果规范的小型应用程序。

我首先在 Xcode 中使用 App 模板,用 Swift 代码创建一个应用项目。这个项目作为基础项目,我将实际的 Widget 扩展附加到该项目上,并且我还可以选择性地用它来进行一些简单的测试。我给它分配了与 .NET MAUI 应用相同的 Bundle ID;重用 Bundle ID 不会有问题,因为这个几乎是空的 Xcode 应用永远不会发布。

应用项目创建完成后,接下来创建 Widget 扩展。在 Xcode 中,依次点击"文件">"新建">"目标",然后选择Widget Extension模板。选择一个已包含正确 Bundle ID 的名称,以避免后续编辑。为了简化示例数据的生成,请选择相应选项Include Configuration App Intent;这样可以立即生成一个可用的 Widget。

创建所有项目后,我总是会统一设置所需的 iOS 版本,确保所有 Xcode 目标使用的版本相同。要检查这一点,只需点击解决方案名称,即可在主窗口中打开解决方案设置,然后在"通用"选项卡下的"最低部署 iOS 版本"中进行设置。现在,使用 Xcode 中的"产品">"构建"在设备上进行试运行。

小部件内部的对象和流程

在 Xcode 中创建 Widget 项目会生成大量对象。一开始难免会感到不知所措,尤其因为几乎所有内容都放在同一个文件中。因此,我总是先进行重构:将每个对象移到各自的文件中,并添加一些文件夹结构。这样做不会造成任何性能损失,因为 Swift 实际上并不使用命名空间;无论文件夹结构如何,项目中的所有内容实际上都属于同一个命名空间。

重构之后,流程其实很简单。以下是主要对象、函数及其作用:

  • WidgetBundle:Widget 扩展的入口点,您可以在此处向最终用户公开一个或多个小部件。
  • 小部件:特定小部件的配置,此处列出了所有信息,例如视图、提供程序、配置意图和支持的尺寸。
  • AppIntentTimelineProvider:提供构建视图所需的数据模型,可以提供多个模型,这些模型会根据时间线发布。
    • func placeholder:在小部件加载时提供一个最小的数据模型(几乎从不可见)。
    • func snapshot:提供小部件在图库中作为预览显示以及首次添加到屏幕时的数据模型。
    • func timeline:提供一个用于常规用途的单一数据模型(或集合),这是该组件所有数据模型的主要来源。
  • TimelineEntry:数据模型实例
  • 视图:小部件的视觉元素
  • WidgetConfigurationIntent:允许最终用户配置小部件。在timeline()AppIntentTimelineProvider 中,您将收到这些设置,以便在需要时将其处理到数据模型中。

在内存中管理模型或其他任何数据(例如缓存系统或简单的静态字段)意义不大。iOS Widget 是一个静态对象,生命周期很短,执行的操作也很小。在 AppIntentTimelineProvider 中,函数几乎同时被调用,但实际上是作为独立的进程运行的。对于数据的交换和存储,最好使用某种形式的本地存储(稍后会介绍)。

应用图标

之前我遇到过小部件在不同视图下显示图标错误的问题。自从我明确地将 AppIcon 图片添加到小部件扩展的 Assets 文件夹中,并在其 info.plist 文件中引用它们之后,问题就几乎没有出现了。如果在更新 Assets 和 info.plist 文件后图标仍然显示错误,请重启测试设备,因为 iOS 似乎会对小部件的图标进行某种缓存。

在 Xcode 中,AppIcon 资源已在 Widget 项目中预定义。打开 AppIcon 资源页面,然后打开属性检查器(右上角),您可以选择 iOS 的"所有尺寸"。这样您就可以设置所有图像尺寸。我个人觉得手动操作太麻烦,所以我使用在线 iOS 图标生成器,它可以生成所有格式的图标,然后我直接将它们复制到文件Assets.xcassets/AppIcon.appiconset夹中。

要调整 plist 设置,请Info.plist在 Xcode 之外(例如在 VS Code 中)打开小部件扩展,并将以下条目插入到相应NSExtension部分中:

复制代码
<key>NSExtensionPrincipalClass</key> 
<string>MyWidgetExtension.MyWidgetBundle</string>
<key>CFBundleIcons</key>
<dict>
 <key>CFBundlePrimaryIcon</key>
 <dict>
  <key>CFBundleIconFiles</key>
  <array>
   <string>AppIcon</string>
  </array>
  <key>UIPrerenderedIcon</key>
  <false/>
 </dict>
</dict>
<key>CFBundleIconName</key>
<string>AppIcon</string>

请使用以下格式调整 NSExtensionPrincipalClass:

复制代码
<key>NSExtensionPrincipalClass</key>
<string>{YourWidgetModuleName}.{YourWidgetName}</string>

<!-- YourWidgetModuleName can be found in: Extension > Build Settings > Product Module Name -->

<!-- YourWidgetName is the name of the Widget bundle, like 'MyWidgetsBundle' in: -->
<!-- @main -->
<!-- struct MyWidgetsBundle: WidgetBundle { -->

创建小部件的发布版本

在 Xcode 中创建发布版本很容易,但找到合适的设置对我来说却很麻烦。因此,我使用一个标准脚本,它可以更轻松地将发布版本收集到一个专用文件夹中,该文件夹也可以用于构建管道。我从 Xcode 项目的根目录运行此脚本,发布版本就会被保存到一个XReleases文件夹中,之所以使用 X 是为了防止它们被 Visual Studio 默认的排除项排除.gitignore。

复制代码
rm -Rf XReleases

xcodebuild -project XCodeWidgetExample.xcodeproj \
-scheme "MyWidgetExtension" \
-configuration Release \
-sdk iphoneos \
BUILD_DIR=$(PWD)/XReleases clean build

xcodebuild -project XCodeWidgetExample.xcodeproj \
-scheme "MyWidgetExtension" \
-configuration Release \
-sdk iphonesimulator \
BUILD_DIR=$(PWD)/XReleases clean build

将 Widget 版本添加到 MAUI 应用

小部件构建输出是一个.appexmacOS 的神奇捆绑文件夹,类似于.app.js 文件。之前在 Windows 系统上使用 Visual Studio 时,我经常遇到找不到 appex 文件的构建错误。为了避免这种情况,我现在将发布输出放在 .js 目录下Platforms/iOS/,并使用 .js 文件包含它们CopyToOutput。

请在代码中使用以下代码片段.csproj来获取构建所需的文件:

复制代码
<ItemGroup Condition="$(TargetFramework.Contains('-ios'))"> 
   <Content Remove="Platforms\iOS\WidgetExtensions\**" />
   <Content Condition="'$(ComputedPlatform)' == 'iPhone'" Include=".\Platforms\iOS\WidgetExtensions\Release-iphoneos\MyWidgetExtension.appex\**" CopyToOutputDirectory="PreserveNewest" />
   <Content Condition="'$(ComputedPlatform)' == 'iPhoneSimulator'" Include=".\Platforms\iOS\WidgetExtensions\Release-iphonesimulator\MyWidgetExtension.appex\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

现在将 Widget 扩展添加到 .NET MAUI 应用程序项目中。下面的 ItemGroup 确保在构建过程中完成此操作,请注意路径和文件名,因为要求非常严格。

复制代码
<ItemGroup Condition="$(TargetFramework.Contains('-ios'))">
   <!-- the appex folder path without the platform suffix -->
   <AdditionalAppExtensions Include="$(MSBuildProjectDirectory)/Platforms/iOS/WidgetExtensions">
      <!-- the appex file without the .appex suffix -->
      <Name>MyWidgetExtension</Name>
      <!-- the appex folder platform suffixes -->
      <BuildOutput Condition="'$(ComputedPlatform)' == 'iPhone'">Release-iphoneos</BuildOutput>
      <BuildOutput Condition="'$(ComputedPlatform)' == 'iPhoneSimulator'">Release-iphonesimulator</BuildOutput>
   </AdditionalAppExtensions>
</ItemGroup>

此时,该组件应该可以在您的 .NET MAUI 应用构建中看到。目前,它是一个完全独立运行的组件,无需与您的 .NET MAUI 应用进行任何数据或通信。

笔记

当您从 Visual Studio 为"iOS 本地设备"构建时,小部件扩展很可能不可见。

应用与小部件之间的数据共享

iOS 小部件最好被视为独立应用。.NET MAUI 应用和小部件无法自由交换数据或进行通信。我们可以使用 .NET MAUI Preferences 来实现数据交换,它对应于 iOS 上的 UserDefaults。为了确保它们使用相同的数据源,两个项目都需要指定Entitlements.plist相同的 Group ID,该 ID 是我们之前在使用 App Groups 功能设置 Bundle ID 时创建的。

Entitlements.plist以组 ID为例group.com.enbyin.WidgetExample:

复制代码
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
  <key>com.apple.security.application-groups</key>
  <array>
  <string>group.com.enbyin.WidgetExample</string>
  </array>
  </dict>
</plist>

为明确起见:Widget Xcode 项目和 .NET MAUI 项目都必须使用这些授权,并且在添加授权后不要忘记为 Xcode 项目创建一个新的版本。此外,Widget Xcode 项目的授权也必须在 .NET MAUI 项目的 <head> 元素中引用,AdditionalAppExtensions以.csproj用于 .NET MAUI 构建。

复制代码
<ItemGroup Condition="$(TargetFramework.Contains('-ios'))">
   <!-- the appex folder path without the platform suffix -->
   <AdditionalAppExtensions Include="$(MSBuildProjectDirectory)/Platforms/iOS/WidgetExtensions">
      <!-- the appex file without the .appex suffix -->
      <Name>MyWidgetExtension</Name>
      <!-- the appex folder platform suffixes -->
      <BuildOutput Condition="'$(ComputedPlatform)' == 'iPhone'">Release-iphoneos</BuildOutput>
      <BuildOutput Condition="'$(ComputedPlatform)' == 'iPhoneSimulator'">Release-iphonesimulator</BuildOutput>

      <!-- entitlements for the appex, without this the shared storage won't work -->
      <!-- errors that entitlements could not be found: include the entitlements with CopyToOutput -->
      <!-- errors when reading entitlements during build: store entitlements file with line-ending type LF -->
      <CodesignEntitlements>Platforms/iOS/Entitlements.MyWidgetExtension.plist</CodesignEntitlements>
   </AdditionalAppExtensions>
</ItemGroup> 

此时,应用程序和控件应该能够使用相同的数据源。在两个项目中,都必须在代码中明确指定使用特定的组 ID。在 .NET MAUI 中,请勿使用分号Preferences.Default(😉,而应在参数中提供组 ID sharedName。

复制代码
// example how to store data in .NET MAUI.
Preferences.Set("MyDataKey", "my data to share", "group.com.enbyin.WidgetExample");

// example how to store data in Swift.
UserDefaults(suiteName: "group.com.enbyin.WidgetExample")?.set("my data to share", forKey: "MyDataKey")
// example how to get data in Swift.
let data = UserDefaults(suiteName: "group.com.enbyin.WidgetExample")?.string(forKey: "MyDataKey")

笔记

存储键区分大小写;我建议保持键名简洁,并可选择性地始终使用小写字母,以避免出现问题。

应用与小部件之间的通信

Widget 不知道 App 何时共享数据,App 也不知道 Widget 何时共享数据。App 向 Widget 发送新可用数据信号与 Widget 向 App 发送信号使用不同的机制。使用 Apple 的 WidgetKit API 可以轻松地从 App 向 Widget 发送信号。此 API 在 .NET MAUI 中不可用,因此您必须自行创建绑定。这是一个非常小的 API,非常适合自行尝试绑定。在本演示中,我使用了一个 NuGet 包,WidgetKit.WidgetCenterProxy其中已经为我们完成了这项工作。

WidgetKit API 主要提供两种选项:重新加载设备上的所有 Widget 或仅重新加载特定 Widget 的 Widget kind。我总是使用后者,因为如果频繁使用其中一种选项,平台会忽略你的请求;我猜他们也更希望你只更新你自己的特定 Widget。kind在 Swift 中,你很容易在 Widget 对象中找到 Widget 的kind属性。

复制代码
// Example on how to refresh all Widgets of kind 'MyWidget' in .NET MAUI
var widgetCenterProxy = new WidgetKit.WidgetCenterProxy();
widgetCenterProxy.ReloadTimeLinesOfKind("MyWidget");

笔记

WidgetKit 的重新加载功能是对操作系统发出的礼貌性请求;操作系统会决定何时执行此操作以及您是否过于频繁地使用它。通常情况下,小部件刷新会立即发生。

从组件到应用程序的通信

Widget 与 App 之间的通信可以通过两种方式进行,并且可以选择性地传输少量数据。默认情况下,点击 Widget 会打开相应的 App;如果您覆盖此默认行为,widgetUrl()则可以使用包含数据的深度链接打开 App。缺点是 Widget 是静态对象,因此在使用widgetUrl深度链接时,必须在设置 Widget 视图时预先确定 URL(通常在提供程序中完成),并通过数据模型以字符串形式传递。

复制代码
// example of using a DeepLink URL in Swift
struct MyView: View {
   var body: some View {
      // my views
   }.widgetURL(URL(string: "mywidget://something?var1=dummy-data"))
}

AppIntent 提供了一种不同的通信方式。AppIntent 是一种执行操作/逻辑的方法,您可以将其附加到按钮等交互元素上。操作系统也会利用 AppIntent 来延迟执行耗时操作,例如发起 HTTP 请求。将本地存储与 AppIntent 结合使用,您可以创建完整的"交互式组件",如下图所示的渲染循环示例。

例如,您可以将自定义 AppIntent 附加到 Widget 中的按钮,该按钮会更改存储中的值,之后 AppIntent 本身会触发 Widget 的刷新。系统会通过调用 AppIntentTimelineProvider 来重新加载 Widget,创建一个新的数据模型(时间线条目),并使用更新后的数据重新渲染视图。

复制代码
// example of an AppIntent changing data and reloading widget
struct IncrementCounterIntent: AppIntent {
   static var title: LocalizedStringResource { "Increment Counter" }
   static var description: IntentDescription { "Increments the counter by 1" }

   func perform() async throws -> some IntentResult {

   var currentCount = 0

   let userDefaults = UserDefaults(suiteName: Settings.groupId)
   let storedValue = userDefaults?.integer(forKey: Settings.appIncommingDataKey)
   if let storedValueCount = storedValue {
      currentCount = storedValueCount
   }

   // do action
   let newCount = currentCount + 1

   // Save new value
   userDefaults?.set(newCount, forKey: Settings.appIncommingDataKey)

   // Reload timelines > refreshing widget
   WidgetCenter.shared.reloadTimelines(ofKind: "MyWidget")

   return .result()
}

// example of Button using AppIntent in Swift
struct MyWidgetView : View {
   var entry: Provider.Entry

   var body: some View {
      VStack(spacing:4) {
         Button(intent: IncrementCounterIntent()) {
            Text("+")
         }
      }
      .padding()
   }
}

小部件到应用程序的静默通信

在 iOS 系统中,Widget 无法在后台与 App 通信。任何直接调用都会将 App 切换到前台。为了在保持 App 关闭的情况下执行操作,您可以在 Widget 中使用 AppIntent 来调用后端服务。后端可以执行相应的操作,并在需要时向 App 发送静默推送通知。App 随后可以根据需要处理后台更新。这可以通过任何 Web 服务和现有的推送通知提供程序来实现;因此,我在演示代码中仅提供了一个示例 SilentNotificationService 作为入口点,而不是完整的实现。

创建可配置的小部件

由于我在创建 Xcode Widget 扩展时选择了"配置应用意图"选项,因此已经存在一个允许用户配置 Widget 的系统。在创建数据模型的循环中AppIntentTimelineProvider,此配置可用,并可被处理成传递给 Widget 视图的数据。这是一个简单的基础系统,所有功能都实现在一个为特定 WidgetWidgetConfigurationIntent指定的组件中WidgetConfiguration。

复制代码
struct MyWidget: Widget {
   let kind: String = "MyWidget"

   var body: some WidgetConfiguration {
      AppIntentConfiguration(kind: kind, 
      intent: ConfigurationAppIntent.self,
      ...

从视觉上看,WidgetConfigurationIntent 中没有任何需要自定义的内容。您只需添加用于指定用户配置设置的"@Parameter"字段;平台会自动处理用户界面。

复制代码
struct ConfigurationAppIntent: WidgetConfigurationIntent {
   // title: mainly for Siri/shortcuts, keep it simple if you don't use Siri
   static var title: LocalizedStringResource { "Configuration" }
   // description: mainly for developers in the app intents system, users will never see this 
   static var description: IntentDescription { "This is an example widget." } 

   // An example configurable parameter.
   @Parameter(title: "Favorite Emoji", default: "")
   var favoriteEmoji: String
}

大多数内置类型都可以使用 @Parameter 注解,例如 String、Int、Bool、Date 等。对于列表或自定义对象等更复杂的类型,可以创建一个单独的类型来实现 @Parameter 接口AppEntity。这是一个比较复杂的主题,这里就不赘述了。原理很简单:AppEntity 对象是一个可选择的数据模型,它通过 @Parameter 提供自己的选项EntityQuery。@Parameter 会返回一个 AppEntity 对象集合,用户可以从中选择。需要注意的是,平台可能会多次遍历 @Parameter 的不同函数,而这些函数之间很难交换数据。我的建议是使用本地存储(UserDefaults)来缓存数据,并在不同的函数之间交换数据。

总结与实用技巧

交互式组件完全实现后,下一步自然是完善逻辑、布局和整体设计。虽然大部分核心逻辑可以保留在 .NET MAUI 应用程序中以便跨平台复用,但对于一些组件特有的任务,例如处理存储、构建视图或执行轻量级后端操作,始终需要一些 Swift 代码。以下是一些最终提示,可帮助您快速从 C# 过渡到 Swift:

  • 使用 VS Code 与 Copilot 进行结对编程来创建 Swift 代码。
  • 保持 Xcode 打开,以便快速构建和预览,及早发现问题。
  • 在 Xcode 中打开 Canvas 视图,并使用 #preview 数据,以便快速查看视觉变化。

如果您有兴趣将您的组件开发技能应用到 Android 平台,好消息:一篇关于使用 .NET MAUI 构建 Android 组件的文章即将发布。敬请期待!

原文链接

How to Build iOS Widgets with .NET MAUI

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

欢迎转载、使用、重新发布,但务必保留文章署名 郑子铭 (包含链接: http://www.cnblogs.com/MingsonZheng/ ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。

如有任何疑问,请与我联系 (MingsonZheng@outlook.com)