在 C# Windows 应用中实现小组件提供程序

本文将向你详细介绍如何创建实现 IWidgetProvider 接口的简单小组件提供程序。 该接口的方法由小组件主机调用,以请求定义小组件的数据,或让小组件提供程序响应用户对小组件执行的操作。 小组件提供程序可以支持一个或多个小组件。 在此示例中,我们将定义两个不同的小组件。 其中一个小组件是模拟天气小组件,它阐释了自适应卡片框架提供的一些格式设置选项。 第二个小组件将通过维护一个计数器来演示用户操作和自定义小组件状态功能,只要用户单击小组件上显示的按钮,该计数器就会递增。

简单天气小组件的屏幕截图。该小组件显示一些与天气相关的图形、数据,以及一些说明正在显示中型小组件模板的诊断文本。

简单计数小组件的屏幕截图。该小组件显示一个字符串,其中包含要递增的数值和一个标记为“递增”的按钮,以及一些说明正在显示小型小组件模板的诊断文本。

本文中的此示例代码改编自 Windows 应用 SDK 小组件示例。 要使用 C++/WinRT 实现小组件提供程序,请参阅在 win32 应用中实现小组件提供程序 (C++/WinRT)

先决条件

  • 设备必须启用开发人员模式。 有关详细信息,请参阅启用用于开发的设备
  • 具有通用 Windows 平台开发工作负载的 Visual Studio 2022 或更高版本。 请确保从可选下拉列表添加 C++ (v143) 组件。

创建新的 C# 控制台应用

在 Visual Studio 中,创建新的项目。 在“创建新项目”对话框中,将语言筛选器设置为“C#”,将平台筛选器设置为 Windows,然后选择“控制台应用”项目模板。 将新项目命名为“ExampleWidgetProvider”。 出现提示时,将目标 .NET 版本设置为 8.0。

当项目加载时,在“解决方案资源管理器”中,右键单击项目名称,然后选择“属性”。 在“常规”页上,向下滚动到“目标 OS”并选择“Windows”。 在“目标 OS 版本”下,选择版本 10.0.19041.0 或更高版本。

要更新项目以支持 .NET 8.0,请在解决方案资源管理器中右键单击项目名称并选择编辑项目文件。 在 PropertyGroup 内,添加以下 RuntimeIdentifiers 元素。

<RuntimeIdentifiers>win-x86;win-x64;win-arm64</RuntimeIdentifiers>

请注意,本演练使用的控制台应用在激活小组件时会显示控制台窗口,以便进行调试。 准备好发布小组件提供程序应用后,可以按照将控制台应用程序转换为 Windows 应用程序中的步骤将控制台应用转换为 Windows 应用

添加对 Windows 应用 SDK 的引用

此示例使用最新稳定版 Windows 应用 SDK NuGet 包。 在“解决方案资源管理器”中,右键单击“依赖项”,然后选择“管理 NuGet 包...”。在 NuGet 包管理器中,选择“浏览”选项卡并搜索“Microsoft.WindowsAppSDK”。 在“版本”下拉列表中选择最新稳定版本,然后单击“安装”。

添加 WidgetProvider 类来处理小组件操作

在 Visual Studio 中,右键单击“解决方案资源管理器”中的 ExampleWidgetProvider 项目并选择“添加”->“类”。 在“添加类”对话框中,将类命名为 WidgetProvider,然后单击“添加”。 在生成的 WidgetProvider.cs 文件中,更新类定义以指示它实现 IWidgetProvider 接口。

// WidgetProvider.cs
internal class WidgetProvider : IWidgetProvider

准备跟踪已启用的小组件

小组件提供程序可以支持一个或多个小组件。 每当小组件主机使用小组件提供程序启动操作时,它都会传递一个 ID 来标识与操作关联的小组件。 每个小组件还有一个关联的名称和一个可用于存储自定义数据的状态值。 在此示例中,我们将声明一个简单的帮助程序结构,用于存储每个固定小组件的 ID、名称和数据。 小组件还可以处于活动状态(如下面的激活和停用部分所述),我们将使用布尔值跟踪每个小组件的此状态。 将以下定义添加到 WidgetProvider.cs 文件,该文件位于 ExampleWidgetProvider 命名空间内,但在 WidgetProvider 类定义的外部。

// WidgetProvider.cs

public class CompactWidgetInfo
{
    public string? widgetId { get; set; }
    public string? widgetName { get; set; }
    public int customState = 0;
    public bool isActive = false;

}

在 WidgetProvider.cs 的 WidgetProvider 类定义中,使用小组件 ID 作为每个条目的键,为映射添加一个成员,以维护已启用的小组件列表。

// WidgetProvider.cs

// Class member of WidgetProvider
public static Dictionary<string, CompactWidgetInfo> RunningWidgets = new Dictionary<string, CompactWidgetInfo>(); 

声明小组件模板 JSON 字符串

此示例将声明一些静态字符串来定义每个小组件的 JSON 模板。 为方便起见,这些模板存储在 WidgetProvider 类的成员变量中。 如果需要模板的通用存储,可以将其包含在应用程序包中:访问包文件。 有关创建小组件模板 JSON 文档的信息,请参阅使用自适应卡片设计器创建小组件模板

// WidgetProvider.cs

// Class members of WidgetProvider
        const string weatherWidgetTemplate = """
{
    "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
    "type": "AdaptiveCard",
    "version": "1.0",
    "speak": "<s>The forecast for Seattle January 20 is mostly clear with a High of 51 degrees and Low of 40 degrees</s>",
    "backgroundImage": "https://messagecardplayground.azurewebsites.net/assets/Mostly%20Cloudy-Background.jpg",
    "body": [
        {
            "type": "TextBlock",
            "text": "Redmond, WA",
            "size": "large",
            "isSubtle": true,
            "wrap": true
        },
        {
            "type": "TextBlock",
            "text": "Mon, Nov 4, 2019 6:21 PM",
            "spacing": "none",
            "wrap": true
        },
        {
            "type": "ColumnSet",
            "columns": [
                {
                    "type": "Column",
                    "width": "auto",
                    "items": [
                        {
                            "type": "Image",
                            "url": "https://messagecardplayground.azurewebsites.net/assets/Mostly%20Cloudy-Square.png",
                            "size": "small",
                            "altText": "Mostly cloudy weather"
                        }
                    ]
                },
                {
                    "type": "Column",
                    "width": "auto",
                    "items": [
                        {
                            "type": "TextBlock",
                            "text": "46",
                            "size": "extraLarge",
                            "spacing": "none",
                            "wrap": true
                        }
                    ]
                },
                {
                    "type": "Column",
                    "width": "stretch",
                    "items": [
                        {
                            "type": "TextBlock",
                            "text": "°F",
                            "weight": "bolder",
                            "spacing": "small",
                            "wrap": true
                        }
                    ]
                },
                {
                    "type": "Column",
                    "width": "stretch",
                    "items": [
                        {
                            "type": "TextBlock",
                            "text": "Hi 50",
                            "horizontalAlignment": "left",
                            "wrap": true
                        },
                        {
                            "type": "TextBlock",
                            "text": "Lo 41",
                            "horizontalAlignment": "left",
                            "spacing": "none",
                            "wrap": true
                        }
                    ]
                }
            ]
        }
    ]
}
""";

    const string countWidgetTemplate = """
{                                                                     
    "type": "AdaptiveCard",                                         
    "body": [                                                         
        {                                                               
            "type": "TextBlock",                                    
            "text": "You have clicked the button ${count} times"    
        },
        {
                "text":"Rendering Only if Small",
                "type":"TextBlock",
                "$when":"${$host.widgetSize==\"small\"}"
        },
        {
                "text":"Rendering Only if Medium",
                "type":"TextBlock",
                "$when":"${$host.widgetSize==\"medium\"}"
        },
        {
            "text":"Rendering Only if Large",
            "type":"TextBlock",
            "$when":"${$host.widgetSize==\"large\"}"
        }                                                                    
    ],                                                                  
    "actions": [                                                      
        {                                                               
            "type": "Action.Execute",                               
            "title": "Increment",                                   
            "verb": "inc"                                           
        }                                                               
    ],                                                                  
    "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
    "version": "1.5"                                                
}
""";

实现 IWidgetProvider 方法

在接下来的几个部分中,我们将实现 IWidgetProvider 接口的方法。 本文稍后将介绍其中几个方法实现中调用的帮助程序方法 UpdateWidget。

注意

传递到 IWidgetProvider 接口的回调方法的对象仅保证在回调中有效。 不应存储对这些对象的引用,因为它们在回调上下文之外的行为未定义。

CreateWidget

如果用户在小组件主机中已固定应用的其中一个小组件,则小组件主机会调用 CreateWidget。 首先,此方法获取关联的小组件的 ID 和名称,并将帮助程序结构 CompactWidgetInfo 的新实例添加到已启用的小组件集合中。 接下来,发送在 UpdateWidget 帮助程序方法中封装的小组件的初始模板和数据。

// WidgetProvider.cs

public void CreateWidget(WidgetContext widgetContext)
{
    var widgetId = widgetContext.Id; // To save RPC calls
    var widgetName = widgetContext.DefinitionId;
    CompactWidgetInfo runningWidgetInfo = new CompactWidgetInfo() { widgetId = widgetId, widgetName = widgetName };
    RunningWidgets[widgetId] = runningWidgetInfo;


    // Update the widget
    UpdateWidget(runningWidgetInfo);
}

DeleteWidget

如果用户在小组件主机中已取消固定应用的其中一个小组件,则小组件主机会调用 DeleteWidget。 发生这种情况时,我们将从已启用的小组件列表中删除关联的小组件,以便不会为该小组件发送任何进一步的更新。

// WidgetProvider.cs

public void DeleteWidget(string widgetId, string customState)
{
    RunningWidgets.Remove(widgetId);

    if(RunningWidgets.Count == 0)
    {
        emptyWidgetListEvent.Set();
    }
}

在此示例中,除了从已启用小组件列表中删除指定的小组件外,我们还会检查列表现在是否为空,如果是,我们将设置一个事件,稍后将使用该事件以允许应用在没有已启用的小组件时退出。 在类定义中,添加 ManualResetEvent 和公共访问器函数的声明。

// WidgetProvider.cs
static ManualResetEvent emptyWidgetListEvent = new ManualResetEvent(false);

public static ManualResetEvent GetEmptyWidgetListEvent()
{
    return emptyWidgetListEvent;
}

OnActionInvoked

当用户与小组件模板中定义的操作交互时,小组件主机将调用 OnActionInvoked。 对于此示例中使用的计数器小组件,在小组件的 JSON 模板中声明了一个谓词值为“inc”的操作。 小组件提供程序代码将使用此谓词值来确定要执行哪些操作来响应用户交互。

...
    "actions": [                                                      
        {                                                               
            "type": "Action.Execute",                               
            "title": "Increment",                                   
            "verb": "inc"                                           
        }                                                               
    ], 
...

在 OnActionInvoked 方法中,通过检查传递到方法中的 WidgetActionInvokedArgs 的 Verb 属性来获取谓词值。 如果谓词为“inc”,则表示需要递增小组件的自定义状态中的计数。 在 WidgetActionInvokedArgs 中,获取 WidgetContext 对象,然后获取 WidgetId 以获取要更新的小组件的 ID。 在已启用的小组件映射中查找具有指定 ID 的条目,然后更新用于存储增量数的自定义状态值。 最后,使用 UpdateWidget 帮助程序函数新值更新小组件内容。

// WidgetProvider.cs

public void OnActionInvoked(WidgetActionInvokedArgs actionInvokedArgs)
{
    var verb = actionInvokedArgs.Verb;
        if (verb == "inc")
        {
            var widgetId = actionInvokedArgs.WidgetContext.Id;
            // If you need to use some data that was passed in after
            // Action was invoked, you can get it from the args:
            var data = actionInvokedArgs.Data;
            if (RunningWidgets.ContainsKey(widgetId))
            {
                var localWidgetInfo = RunningWidgets[widgetId];
                // Increment the count
                localWidgetInfo.customState++;
                UpdateWidget(localWidgetInfo);
            }
        }
}

有关自适应卡片的 Action.Execute 语法的信息,请参阅 Action.Execute。 有关为小组件设计交互的指导,请参阅小组件交互设计指南

OnWidgetContextChanged

在当前版本中,仅当用户更改固定小组件的大小时,才会调用 OnWidgetContextChanged。 可以选择将不同的 JSON 模板/数据返回到小组件主机,具体取决于请求的大小。 还可根据 host.widgetSize 的值使用条件呈现将模板 JSON 设计为支持所有可用大小。 如果不需要发送新模板或数据来考虑大小更改,可以使用 OnWidgetContextChanged 进行遥测。

// WidgetProvider.cs

public void OnWidgetContextChanged(WidgetContextChangedArgs contextChangedArgs)
{
    var widgetContext = contextChangedArgs.WidgetContext;
    var widgetId = widgetContext.Id;
    var widgetSize = widgetContext.Size;
    if (RunningWidgets.ContainsKey(widgetId))
    {
        var localWidgetInfo = RunningWidgets[widgetId];
        UpdateWidget(localWidgetInfo);
    }
}
    

激活和停用

调用 Activate 方法以通知小组件提供程序,小组件主机当前有兴趣从提供程序接收更新的内容。 例如,这可能意味着用户当前正在主动查看小组件主机。 调用 Deactivate 方法以通知小组件提供程序,小组件主机不再请求内容更新。 这两种方法定义了一个窗口,在该窗口中,小组件主机最感兴趣的是显示最新内容。 小组件提供程序可以随时向小组件发送更新,例如响应推送通知,但与任何后台任务一样,请务必在提供最新内容的同时,兼顾资源问题(如电池使用时间)。

Activate 和 Deactivate 按小组件调用。 此示例跟踪 CompactWidgetInfo 帮助程序结构中每个小组件的活动状态。 在 Activate 方法中,我们调用 UpdateWidget 帮助程序方法来更新小组件。 请注意,Activate 和 Deactivate 之间的时间窗口可能很小,因此建议尽量加快小组件更新代码路径。

// WidgetProvider.cs

public void Activate(WidgetContext widgetContext)
{
    var widgetId = widgetContext.Id;

    if (RunningWidgets.ContainsKey(widgetId))
    {
        var localWidgetInfo = RunningWidgets[widgetId];
        localWidgetInfo.isActive = true;

        UpdateWidget(localWidgetInfo);
    }
}
public void Deactivate(string widgetId)
{
    if (RunningWidgets.ContainsKey(widgetId))
    {
        var localWidgetInfo = RunningWidgets[widgetId];
        localWidgetInfo.isActive = false;
    }
}

更新小组件

定义 UpdateWidget 帮助程序方法以更新已启用的小组件。 在此示例中,我们检查传递给方法的 CompactWidgetInfo 帮助程序结构中的小组件名称,然后根据要更新的小组件设置适当的模板和数据 JSON。 WidgetUpdateRequestOptions 使用要更新的小组件的模板、数据和自定义状态进行初始化。 调用 WidgetManager::GetDefault 获取 WidgetManager 类的实例,然后调用 UpdateWidget 将更新的小组件数据发送到小组件主机。

// WidgetProvider.cs

void UpdateWidget(CompactWidgetInfo localWidgetInfo)
{
    WidgetUpdateRequestOptions updateOptions = new WidgetUpdateRequestOptions(localWidgetInfo.widgetId);

    string? templateJson = null;
    if (localWidgetInfo.widgetName == "Weather_Widget")
    {
        templateJson = weatherWidgetTemplate.ToString();
    }
    else if (localWidgetInfo.widgetName == "Counting_Widget")
    {
        templateJson = countWidgetTemplate.ToString();
    }

    string? dataJson = null;
    if (localWidgetInfo.widgetName == "Weather_Widget")
    {
        dataJson = "{}";
    }
    else if (localWidgetInfo.widgetName == "Counting_Widget")
    {
        dataJson = "{ \"count\": " + localWidgetInfo.customState.ToString() + " }";
    }

    updateOptions.Template = templateJson;
    updateOptions.Data = dataJson;
    // You can store some custom state in the widget service that you will be able to query at any time.
    updateOptions.CustomState= localWidgetInfo.customState.ToString();
    WidgetManager.GetDefault().UpdateWidget(updateOptions);
}

在启动时初始化已启用的小组件列表

首次初始化小组件提供程序时,最好询问 WidgetManager,提供程序当前是否提供任何正在运行的小组件。 在计算机重启或提供程序发生故障时,它将帮助将应用恢复到以前的状态。 调用 WidgetManager.GetDefault 以获取应用的默认小组件管理器实例。 然后调用 GetWidgetInfos,这将返回 WidgetInfo 对象的数组。 将小组件 ID、名称和自定义状态复制到帮助程序结构 CompactWidgetInfo 中,并将其保存到 RunningWidgets 成员变量中。 将以下代码粘贴到 WidgetProvider 类的类定义中。

// WidgetProvider.cs

public WidgetProvider()
{
    var runningWidgets = WidgetManager.GetDefault().GetWidgetInfos();

    foreach (var widgetInfo in runningWidgets)
    {
        var widgetContext = widgetInfo.WidgetContext;
        var widgetId = widgetContext.Id;
        var widgetName = widgetContext.DefinitionId;
        var customState = widgetInfo.CustomState;
        if (!RunningWidgets.ContainsKey(widgetId))
        {
            CompactWidgetInfo runningWidgetInfo = new CompactWidgetInfo() { widgetId = widgetName, widgetName = widgetId };
            try
            {
                // If we had any save state (in this case we might have some state saved for Counting widget)
                // convert string to required type if needed.
                int count = Convert.ToInt32(customState.ToString());
                runningWidgetInfo.customState = count;
            }
            catch
            {

            }
            RunningWidgets[widgetId] = runningWidgetInfo;
        }
    }
}

实现将按请求实例化 WidgetProvider 的类工厂

要使小组件主机与小组件提供程序通信,必须调用 CoRegisterClassObject。 此函数要求我们创建 IClassFactory 的实现,以便为 WidgetProvider 类创建类对象。 我们将在自包含帮助程序类中实现类工厂。

在 Visual Studio 中,右键单击“解决方案资源管理器”中的 ExampleWidgetProvider 项目并选择“添加”->“类”。 在“添加类”对话框中,将类命名为 FactoryHelper,然后单击“添加”。

将 FactoryHelper.cs 文件的内容替换为以下代码。 此代码定义 IClassFactory 接口,并实现它的两种方法:CreateInstanceLockServer。 此代码是用于实现类工厂的典型样本,并不特定于小组件提供程序的功能,除非我们指出正在创建的类对象实现了 IWidgetProvider 接口。

// FactoryHelper.cs

using Microsoft.Windows.Widgets.Providers;
using System.Runtime.InteropServices;
using WinRT;

namespace COM
{
    static class Guids
    {
        public const string IClassFactory = "00000001-0000-0000-C000-000000000046";
        public const string IUnknown = "00000000-0000-0000-C000-000000000046";
    }

    /// 
    /// IClassFactory declaration
    /// 
    [ComImport, ComVisible(false), InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid(COM.Guids.IClassFactory)]
    internal interface IClassFactory
    {
        [PreserveSig]
        int CreateInstance(IntPtr pUnkOuter, ref Guid riid, out IntPtr ppvObject);
        [PreserveSig]
        int LockServer(bool fLock);
    }

    [ComVisible(true)]
    class WidgetProviderFactory<T> : IClassFactory
    where T : IWidgetProvider, new()
    {
        public int CreateInstance(IntPtr pUnkOuter, ref Guid riid, out IntPtr ppvObject)
        {
            ppvObject = IntPtr.Zero;

            if (pUnkOuter != IntPtr.Zero)
            {
                Marshal.ThrowExceptionForHR(CLASS_E_NOAGGREGATION);
            }

            if (riid == typeof(T).GUID || riid == Guid.Parse(COM.Guids.IUnknown))
            {
                // Create the instance of the .NET object
                ppvObject = MarshalInspectable<IWidgetProvider>.FromManaged(new T());
            }
            else
            {
                // The object that ppvObject points to does not support the
                // interface identified by riid.
                Marshal.ThrowExceptionForHR(E_NOINTERFACE);
            }

            return 0;
        }

        int IClassFactory.LockServer(bool fLock)
        {
            return 0;
        }

        private const int CLASS_E_NOAGGREGATION = -2147221232;
        private const int E_NOINTERFACE = -2147467262;

    }
}

创建一个 GUID 来表示小组件提供程序的 GUID

接下来,需要创建一个表示 CLSID 的 GUID,以标识用于激活 COM 的小组件提供程序。 打包应用时也会使用此相同的值。 转到“工具”->“创建 GUID”,在 Visual Studio 中生成 GUID。 选择注册表格式选项并单击“复制”,然后将其粘贴到文本文件中,以便稍后可以复制它。

向 OLE 注册小组件提供程序类对象

在可执行文件的 Program.cs 文件中,我们将调用 CoRegisterClassObject 向 OLE 注册小组件提供程序,以便小组件主机可以与其交互。 将 Program.cs 的内容替换为以下代码。 此代码将导入 CoRegisterClassObject 函数并调用它,传入我们在上一步中定义的 WidgetProviderFactory 接口。 请务必更新 CLSID_Factory 变量声明,以使用在上一步中生成的 GUID。

// Program.cs

using System.Runtime.InteropServices;
using ComTypes = System.Runtime.InteropServices.ComTypes;
using Microsoft.Windows.Widgets;
using ExampleWidgetProvider;
using COM;
using System;

[DllImport("kernel32.dll")]
static extern IntPtr GetConsoleWindow();

[DllImport("ole32.dll")]

static extern int CoRegisterClassObject(
            [MarshalAs(UnmanagedType.LPStruct)] Guid rclsid,
            [MarshalAs(UnmanagedType.IUnknown)] object pUnk,
            uint dwClsContext,
            uint flags,
            out uint lpdwRegister);

[DllImport("ole32.dll")] static extern int CoRevokeClassObject(uint dwRegister);

Console.WriteLine("Registering Widget Provider");
uint cookie;

Guid CLSID_Factory = Guid.Parse("XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX");
CoRegisterClassObject(CLSID_Factory, new WidgetProviderFactory<WidgetProvider>(), 0x4, 0x1, out cookie);
Console.WriteLine("Registered successfully. Press ENTER to exit.");
Console.ReadLine();

if (GetConsoleWindow() != IntPtr.Zero)
{
    Console.WriteLine("Registered successfully. Press ENTER to exit.");
    Console.ReadLine();
}
else
{
    // Wait until the manager has disposed of the last widget provider.
    using (var emptyWidgetListEvent = WidgetProvider.GetEmptyWidgetListEvent())
    {
        emptyWidgetListEvent.WaitOne();
    }

    CoRevokeClassObject(cookie);
}

请注意,此代码示例导入 GetConsoleWindow 函数以确定应用是否作为控制台应用程序运行,这是本演练的默认行为。 如果函数返回的指针有效,我们会将调试信息写入控制台。 否则,应用将作为 Windows 应用运行。 在这种情况下,如果已启用小组件列表为空,当退出应用时,我们将等待在 DeleteWidget 方法中设置的事件。 有关将示例控制台应用转换为 Windows 应用的信息,请参阅将控制台应用转换为 Windows 应用

打包小组件提供程序应用

在当前版本中,只有打包的应用才能注册为小组件提供程序。 以下步骤将详细介绍打包应用并更新应用清单以将应用注册到 OS 作为小组件提供程序的过程。

创建 MSIX 打包项目

在“解决方案资源管理器”中,右键单击所需解决方案,然后选择“添加”->“新项目...”。在“添加新项目”对话框中,选择“Windows 应用程序打包项目”模板,然后单击“下一步”。 将项目名称设置为“ExampleWidgetProviderPackage”,然后单击“创建”。 出现提示时,将目标版本设置为版本 1809 或更高版本,然后单击“确定”。 接下来,右键单击“ExampleWidgetProviderPackage”项目,然后选择“添加”->“项目引用”。 选择 ExampleWidgetProvider 项目,然后单击“确定”。

将 Windows 应用 SDK 包引用添加到打包项目

需要将对 Windows 应用 SDK nuget 包的引用添加到 MSIX 打包项目。 在“解决方案资源管理器”中,双击 ExampleWidgetProviderPackage 项目以打开 ExampleWidgetProviderPackage.wapproj 文件。 在 Project 元素中添加以下 xml。

<!--ExampleWidgetProviderPackage.wapproj-->
<ItemGroup>
    <PackageReference Include="Microsoft.WindowsAppSDK" Version="1.2.221116.1">
        <IncludeAssets>build</IncludeAssets>
    </PackageReference>  
</ItemGroup>

注意

确保 PackageReference 元素中指定的版本与在上一步中引用的最新稳定版本匹配。

如果计算机上已安装正确版本的 Windows 应用 SDK,并且你不希望在包中捆绑 SDK 运行时,则可以在 ExampleWidgetProviderPackage 项目的 Package.appxmanifest 文件中指定包依赖项。

<!--Package.appxmanifest-->
...
<Dependencies>
...
    <PackageDependency Name="Microsoft.WindowsAppRuntime.1.2-preview2" MinVersion="2000.638.7.0" Publisher="CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US" />
...
</Dependencies>
...

更新包清单

在“解决方案资源管理器”中,右键单击 Package.appxmanifest 文件并选择“查看代码”以打开清单 xml 文件。 接下来,需要为我们将使用的应用包扩展添加一些命名空间声明。 将以下命名空间定义添加到顶级 Package 元素。

<!-- Package.appmanifest -->
<Package
  ...
  xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
  xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10"

在 Application 元素内,创建名为 Extensions 的新空元素。 请确保此新元素位于 uap:VisualElements 的结束标记之后。

<!-- Package.appxmanifest -->
<Application>
...
    <Extensions>

    </Extensions>
</Application>

需要添加的第一个扩展是 ComServer 扩展。 这会向 OS 注册可执行文件的入口点。 此扩展是打包的应用,等效于通过设置注册表项注册 COM 服务器,它并不特定于小组件提供程序。添加以下 com:Extension 元素作为 Extension 元素的子元素。 将 com:Class 元素的 Id 属性中的 GUID 更改为在上一步中生成的 GUID。

<!-- Package.appxmanifest -->
<Extensions>
    <com:Extension Category="windows.comServer">
        <com:ComServer>
            <com:ExeServer Executable="ExampleWidgetProvider\ExampleWidgetProvider.exe" DisplayName="ExampleWidgetProvider">
                <com:Class Id="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" DisplayName="ExampleWidgetProvider" />
            </com:ExeServer>
        </com:ComServer>
    </com:Extension>
</Extensions>

接下来,添加将应用注册为小组件提供程序的扩展。 将 uap3:Extension 元素粘贴到以下代码片段中,作为 Extension 元素的子元素。 请务必将 COM 元素的 ClassId 属性替换为前面步骤中使用的 GUID。

<!-- Package.appxmanifest -->
<Extensions>
    ...
    <uap3:Extension Category="windows.appExtension">
        <uap3:AppExtension Name="com.microsoft.windows.widgets" DisplayName="WidgetTestApp" Id="ContosoWidgetApp" PublicFolder="Public">
            <uap3:Properties>
                <WidgetProvider>
                    <ProviderIcons>
                        <Icon Path="Images\StoreLogo.png" />
                    </ProviderIcons>
                    <Activation>
                        <!-- Apps exports COM interface which implements IWidgetProvider -->
                        <CreateInstance ClassId="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
                    </Activation>

                    <TrustedPackageFamilyNames>
                        <TrustedPackageFamilyName>Microsoft.MicrosoftEdge.Stable_8wekyb3d8bbwe</TrustedPackageFamilyName>
                    </TrustedPackageFamilyNames>

                    <Definitions>
                        <Definition Id="Weather_Widget"
                            DisplayName="Weather Widget"
                            Description="Weather Widget Description"
                            AllowMultiple="true">
                            <Capabilities>
                                <Capability>
                                    <Size Name="small" />
                                </Capability>
                                <Capability>
                                    <Size Name="medium" />
                                </Capability>
                                <Capability>
                                    <Size Name="large" />
                                </Capability>
                            </Capabilities>
                            <ThemeResources>
                                <Icons>
                                    <Icon Path="ProviderAssets\Weather_Icon.png" />
                                </Icons>
                                <Screenshots>
                                    <Screenshot Path="ProviderAssets\Weather_Screenshot.png" DisplayAltText="For accessibility" />
                                </Screenshots>
                                <!-- DarkMode and LightMode are optional -->
                                <DarkMode />
                                <LightMode />
                            </ThemeResources>
                        </Definition>
                        <Definition Id="Counting_Widget"
                                DisplayName="Microsoft Counting Widget"
                                Description="Couting Widget Description">
                            <Capabilities>
                                <Capability>
                                    <Size Name="small" />
                                </Capability>
                            </Capabilities>
                            <ThemeResources>
                                <Icons>
                                    <Icon Path="ProviderAssets\Counting_Icon.png" />
                                </Icons>
                                <Screenshots>
                                    <Screenshot Path="ProviderAssets\Counting_Screenshot.png" DisplayAltText="For accessibility" />
                                </Screenshots>
                                <!-- DarkMode and LightMode are optional -->
                                <DarkMode>

                                </DarkMode>
                                <LightMode />
                            </ThemeResources>
                        </Definition>
                    </Definitions>
                </WidgetProvider>
            </uap3:Properties>
        </uap3:AppExtension>
    </uap3:Extension>
</Extensions>

有关所有这些元素的详细说明和格式信息,请参阅小组件提供程序包清单 XML 格式

将图标和其他图像添加到打包项目

在“解决方案资源管理器”中,右键单击 ExampleWidgetProviderPackage 并选择“添加”->“新文件夹”。 将此文件夹命名为 ProviderAssets,因为这是上一步在 Package.appxmanifest 中使用的内容。 我们将在此处存储小组件的图标和屏幕截图。 添加所需的图标和屏幕截图后,请确保图像名称与 Package.appxmanifest 中 Path=ProviderAssets\ 之后的名称匹配,否则小组件将不会显示在小组件主机中。

有关屏幕截图图像的设计要求和本地化屏幕截图的命名约定的信息,请参阅与小组件选取器集成

测试小组件提供程序

确保已从“解决方案平台”下拉列表中选择与开发计算机匹配的体系结构,例如“x64”。 在“解决方案资源管理器”中,右键单击所需解决方案,然后选择“生成解决方案”。 完成此操作后,右键单击 ExampleWidgetProviderPackage 并选择“部署”。 在当前版本中,小组件板是唯一受支持的小组件主机。 要查看小组件,需要打开小组件板,然后选择右上角的“添加小组件”。 滚动到可用小组件的底部,应会看到在本教程中创建的模拟天气小组件和 Microsoft 计数小组件。 单击小组件以将其固定到小组件板并测试其功能。

测试小组件提供程序

固定小组件后,小组件平台将启动小组件提供程序应用程序,以便接收和发送有关小组件的相关信息。 要调试正在运行的小组件,可以将调试程序附加到正在运行的小组件提供程序应用程序,也可以将 Visual Studio 设置为在启动小组件提供程序进程后自动开始调试小组件提供程序进程。

要附加到正在运行的进程,请执行以下操作:

  1. 在 Visual Studio 中,单击“调试”->“附加到进程”。
  2. 筛选进程并找到所需的小组件提供程序应用程序。
  3. 附加调试程序。

要在小组件最初启动时将调试程序自动附加到进程,请执行以下操作:

  1. 在 Visual Studio 中,选择“调试”->“其他调试目标”->“调试安装的应用包”>
  2. 筛选包并查找所需的小组件提供程序包。
  3. 选择它并选中显示“不启动,但在启动时调试代码”的框。
  4. 单击 “附加”

将控制台应用转换为 Windows 应用

要将本演练中创建的控制台应用转换为 Windows 应用,请在解决方案资源管理器中右键单击 ExampleWidgetProvider 项目,然后选择“属性”。 在“应用程序”->“常规”下,将“输出类型”从“控制台应用程序”更改为“Windows 应用程序”。

显示输出类型设置为 Windows 应用程序的 C# 小组件提供程序项目属性的屏幕截图

发布小组件

开发和测试小组件后,必须在 Microsoft Store 上发布应用,以便用户在其设备上安装小组件。 有关发布应用的分步指南,请参阅在 Microsoft Store 中发布应用

小组件应用商店集合

在 Microsoft Store 上发布应用后,可以请求将应用包含在小组件应用商店集合中,以帮助用户发现具有 Windows 小组件功能的应用。 若要提交请求,请参阅提交小组件信息以添加到应用商店集合

Microsoft Store 的屏幕截图,其中显示了小组件集合,用户可在其中查找具有 Windows 小组件的应用。

实现小组件自定义

从 Windows App SDK 1.4 开始,小组件可以支持用户自定义。 实现此功能后,“自定义小组件”选项将添加到“取消固定小组件”选项上方的省略号菜单中。

包含显示了自定义对话框的小组件的屏幕截图。

以下步骤汇总了小组件自定义的过程。

  1. 在正常操作中,小组件提供程序使用常规小组件体验的模板和数据有效负载响应来自小组件主机的请求。
  2. 用户单击省略号菜单中的“自定义小组件”按钮。
  3. 该小组件在小组件提供程序上引发 OnCustomizationRequested 事件,以指示用户已请求小组件自定义体验。
  4. 小组件提供程序会设置一个内部标志,以指示小组件处于自定义模式。 在自定义模式下,小组件提供程序为小组件自定义 UI 而不是常规小组件 UI 发送 JSON 模板。
  5. 在自定义模式下,小组件提供程序在用户与自定义 UI 交互时接收 OnActionInvoked 事件,并根据用户的操作调整其内部配置和行为。
  6. 当与 OnActionInvoked 事件关联的操作是应用定义的“退出自定义”操作时,小组件提供程序会重置其内部标志,以指示它不再处于自定义模式,并继续发送常规小组件体验的视觉和数据 JSON 模板,反映自定义期间请求的更改。
  7. 小组件提供程序将自定义选项保存到磁盘或云中,以便在小组件提供程序的调用之间保留更改。

注意

对于使用 Windows App SDK 生成的小组件,Windows 小组件板存在一个已知 bug,导致自定义卡显示后省略号菜单变得无响应。

在典型的小组件自定义场景中,用户将选择在小组件上显示哪些数据或调整小组件的视觉呈现。 为简单起见,本部分中的示例将添加自定义行为,允许用户重置在前面的步骤中实现的计数小组件的计数器。

注意

小组件自定义仅在 Windows App SDK 1.4 及更高版本中受支持。 请确保将项目中的引用更新到最新版本的 Nuget 包。

更新包清单以声明自定义支持

若要让小组件主机知道小组件支持自定义,请将 IsCustomizable 属性添加到小组件的 Definition 元素,并将其设置为 true。

...
<Definition Id="Counting_Widget"
    DisplayName="Microsoft Counting Widget"
    Description="CONFIG counting widget description"
    IsCustomizable="true">
...

跟踪小组件何时处于自定义模式

本文中的示例使用帮助程序结构 CompactWidgetInfo 来跟踪活动小组件的当前状态。 添加 inCustomization 字段,该字段将用于跟踪小组件主机何时期望我们发送自定义 json 模板而不是常规小组件模板。

// WidgetProvider.cs
public class CompactWidgetInfo
{
    public string widgetId { get; set; }
    public string widgetName { get; set; }
    public int customState = 0;
    public bool isActive = false;
    public bool inCustomization = false;
}

实现 IWidgetProvider2

小组件自定义功能通过 IWidgetProvider2 接口公开。 更新 WidgetProvider 类定义来实现此接口。

// WidgetProvider.cs
internal class WidgetProvider : IWidgetProvider, IWidgetProvider2

为 IWidgetProvider2 接口的 OnCustomizationRequested 回调添加实现。 此方法使用与使用的其他回调相同的模式。 我们从 WidgetContext 中获取要自定义的小组件的 ID,查找与该小组件关联的 CompactWidgetInfo 帮助程序结构,并将 inCustomization 字段设置为 true。

// WidgetProvider.cs
public void OnCustomizationRequested(WidgetCustomizationRequestedArgs customizationInvokedArgs)
{
    var widgetId = customizationInvokedArgs.WidgetContext.Id;
    if (RunningWidgets.ContainsKey(widgetId))
    {
        var localWidgetInfo = RunningWidgets[widgetId];
        localWidgetInfo.inCustomization = true;
        UpdateWidget(localWidgetInfo);
    }
}

现在,声明一个字符串变量,该变量定义小组件自定义 UI 的 JSON 模板。 在本示例中,我们有一个“重置计数器”按钮和一个“退出自定义”按钮,其将指示提供程序返回到常规小组件行为。 将此定义置于其他模板定义旁边。

// WidgetProvider.cs
const string countWidgetCustomizationTemplate = @"
{
    ""type"": ""AdaptiveCard"",
    ""actions"" : [
        {
            ""type"": ""Action.Execute"",
            ""title"" : ""Reset counter"",
            ""verb"": ""reset""
            },
            {
            ""type"": ""Action.Execute"",
            ""title"": ""Exit customization"",
            ""verb"": ""exitCustomization""
            }
    ],
    ""$schema"": ""http://adaptivecards.io/schemas/adaptive-card.json"",
    ""version"": ""1.5""
}";

在 UpdateWidget 中发送自定义模板

接下来,我们将更新 UpdateWidget 帮助程序方法,该方法将数据和可视 JSON 模板发送到小组件主机。 更新计数小组件时,我们会根据 inCustomization 字段的值发送常规小组件模板或自定义模板。 为了简洁起见,此代码片段中省略了与自定义无关的代码。

// WidgetProvider.cs
void UpdateWidget(CompactWidgetInfo localWidgetInfo)
{
    ...
    else if (localWidgetInfo.widgetName == "Counting_Widget")
    {
        if (!localWidgetInfo.inCustomization)
        {
            templateJson = countWidgetTemplate.ToString();
        }
        else
        {
            templateJson = countWidgetCustomizationTemplate.ToString();
        }
    
    }
    ...
    updateOptions.Template = templateJson;
    updateOptions.Data = dataJson;
    // You can store some custom state in the widget service that you will be able to query at any time.
    updateOptions.CustomState = localWidgetInfo.customState.ToString();
    WidgetManager.GetDefault().UpdateWidget(updateOptions);
}

响应自定义操作

当用户与自定义模板中的输入交互时,它会调用与在用户与常规小组件体验交互时相同的 OnActionInvoked 处理程序。 为了支持自定义,我们从自定义 JSON 模板中查找谓词“reset”和“exitCustomization”。 如果操作适用于“重置计数器”按钮,我们会将帮助程序结构的 customState 字段中保留的计数器重置为 0。 如果操作适用于“退出自定义”按钮,我们会将 inCustomization 字段设置为 false,以便在调用 UpdateWidget 时,我们的帮助程序方法将发送常规 JSON 模板而不是自定义模板。

// WidgetProvider.cs
public void OnActionInvoked(WidgetActionInvokedArgs actionInvokedArgs)
{
    var verb = actionInvokedArgs.Verb;
    if (verb == "inc")
    {
        var widgetId = actionInvokedArgs.WidgetContext.Id;
        // If you need to use some data that was passed in after
        // Action was invoked, you can get it from the args:
        var data = actionInvokedArgs.Data;
        if (RunningWidgets.ContainsKey(widgetId))
        {
            var localWidgetInfo = RunningWidgets[widgetId];
            // Increment the count
            localWidgetInfo.customState++;
            UpdateWidget(localWidgetInfo);
        }
    } 
    else if (verb == "reset") 
    {
        var widgetId = actionInvokedArgs.WidgetContext.Id;
        var data = actionInvokedArgs.Data;
        if (RunningWidgets.ContainsKey(widgetId))
        {
            var localWidgetInfo = RunningWidgets[widgetId];
            // Reset the count
            localWidgetInfo.customState = 0;
            localWidgetInfo.inCustomization = false;
            UpdateWidget(localWidgetInfo);
        }
    }
    else if (verb == "exitCustomization")
    {
        var widgetId = actionInvokedArgs.WidgetContext.Id;
        var data = actionInvokedArgs.Data;
        if (RunningWidgets.ContainsKey(widgetId))
        {
            var localWidgetInfo = RunningWidgets[widgetId];
            // Stop sending the customization template
            localWidgetInfo.inCustomization = false;
            UpdateWidget(localWidgetInfo);
        }
    }
}

现在,部署小组件时,应会在省略号菜单中看到“自定义小组件”按钮。 单击“自定义”按钮将显示自定义模板。

显示了小组件自定义 UI 的屏幕截图。

单击“重置计数器”按钮将计数器重置为 0。 单击“退出自定义”按钮返回到小组件的常规行为。