向应用中添加零售演示 (RDX) 功能

在你的 Windows 应用中包括零售演示模式,以便在销售场所试用电脑和设备的客户可以直接进入。

当客户在零售店时,他们希望能够试用 PC 和设备的演示。 客户经常花费大量时间通过零售演示体验 (RDX) 来游玩应用。

你可以设置你的应用以在正常或零售模式下提供不同的体验。 例如,如果你的应用从设置过程开始,你可以在零售模式下跳过此过程,并使用示例数据和默认设置预先填充应用,以便客户可以直接进入到应用中。

从客户的角度来看,只有一个应用。 为了帮助客户区分这两种模式,我们建议在你的应用处于零售模式时,在标题栏或合适的位置突出显示“零售”一词。

除了对应用的 Microsoft Store 要求,RDX 应用还必须与 RDX 设置、清理和更新过程兼容,以确保客户在零售商店具有一致良好的体验。

设计原理

  • 展示你的精华。 使用零售演示体验展示你的应用的精彩之处。 这可能是客户首次看到你的应用,因此请展示它们最精华的部分!

  • 快速查找。 客户可能没有耐心 - 用户越快体验应用的真正价值越好。

  • 使故事保持简单。 零售演示体验是对应用价值的电梯游说。

  • 专注于体验。 给用户时间来理解你的内容。 尽管使用户快速到达精华部分很重要,但设计合适的暂停可帮助他们完全享受体验。

技术要求

由于 RDX 感知应用旨在向零售客户展示应用的精华,因此它们必须满足这些技术要求并遵守 Microsoft Store 针对所有零售演示体验应用的隐私法规。

这可用作一个清单,帮助你为验证过程做准备,并在测试过程中提供清晰度。 请注意,不仅仅在验证过程中,还必须在零售演示体验应用的整个生存期中保留这些要求;只要应用在零售演示设备上保持运行。

关键要求

不满足这些关键要求的 RDX 感知应用将尽快从所有零售演示设备中删除。

  • 不要求个人身份信息 (PII)。 这包括登录信息、Microsoft 帐户信息或联系方式。

  • 无错误体验。 你的应用必须毫无错误地运行。 此外,不应向使用零售演示设备的客户显示任何错误弹出窗口或通知。 错误会对应用本身、你的品牌、设备品牌、设备制造商品牌和 Microsoft 品牌产生负面影响。

  • 付费应用必须具有试用模式。 你的应用要么是免费的,要么包括试用模式。 客户不希望为零售商店中的体验付费。

高优先级要求

不满足这些高优先级要求的 RDX 感知应用将立即受到调查以获取修复。 如果无法立即找到修复,此应用可能从所有零售演示设备中删除。

  • 令人难忘的离线体验。 你的应用需要展现绝佳的离线体验,因为大约 50% 的设备在零售地点处于离线状态。 这是为了确保与离线应用交互的客户仍然能够获得有意义且正面的体验。

  • 更新的内容体验。 联机时,应用不应提示进行更新。 如果需要更新,则应静默执行。

  • 无匿名通信。 由于使用零售演示设备的客户是匿名用户,他们不应能够从设备发消息或共享内容。

  • 通过使用清理过程提供一致的体验。 当客户走到零售演示设备前时,每个客户都应具有相同的体验。 你的应用应使用清理过程在每次使用后回到相同的默认状态。 我们不希望下一个客户看到最后一个客户留下了什么。 这包括计分牌、成就和解锁。

  • 适合年龄的内容。 所有应用内容需要分配为“青少年”或更低的分级类别。 要了解详细信息,请参阅使应用由 IARC 分级ESRB 分级

中等优先级要求

Windows 零售商店团队可能直接联系开发人员,以设置有关如何解决这些问题的讨论。

  • 能够在广泛的设备上成功运行。 应用必须在所有设备上运行良好,包括带有低端规范的设备。 如果应用安装在不满足最低规范的设备上,则应用需要向用户明确通知这一点。 必须公布最低设备要求,以便应用可以始终高性能地运行。

  • 满足零售商店应用大小要求。 应用必须小于 800MB。 如果你的 RDX 感知应用不满足大小要求,请直接联系 Windows 零售商店团队进行进一步讨论。

RetailInfo API:为演示模式准备代码

IsDemoModeEnabled

RetailInfo 实用工具类中的 IsDemoModeEnabled 属性是 Windows 10 和 Windows 11 SDK 中 Windows.System.Profile 命名空间的一部分,用作布尔指示器,用于指定应用在哪个代码路径上运行 - 普通模式或零售模式。

using Windows.Storage;

StorageFolder folder = ApplicationData.Current.LocalFolder;

if (Windows.System.Profile.RetailInfo.IsDemoModeEnabled) 
{
    // Use the demo specific directory
    folder = await folder.GetFolderAsync("demo");
}

StorageFile file = await folder.GetFileAsync("hello.txt");
// Now read from file
using namespace Windows::Storage;

StorageFolder^ localFolder = ApplicationData::Current->LocalFolder;

if (Windows::System::Profile::RetailInfo::IsDemoModeEnabled) 
{
    // Use the demo specific directory
    create_task(localFolder->GetFolderAsync("demo").then([this](StorageFolder^ demoFolder)
    {
        return demoFolder->GetFileAsync("hello.txt");
    }).then([this](task<StorageFile^> fileTask)
    {
        StorageFile^ file = fileTask.get();
    });
    // Do something with file
}
else
{
    create_task(localFolder->GetFileAsync("hello.txt").then([this](StorageFile^ file)
    {
        // Do something with file
    });
}
if (Windows.System.Profile.retailInfo.isDemoModeEnabled) {
    console.log("Retail mode is enabled.");
} else {
    Console.log("Retail mode is not enabled.");
}

RetailInfo.Properties

IsDemoModeEnabled 返回 true 时,可使用 RetailInfo.Properties 查询一组关于设备的属性,以生成更加自定义的零售演示体验。 这些属性包括 ManufacturerNameScreensizeMemory 等。

using Windows.UI.Xaml.Controls;
using Windows.System.Profile

TextBlock priceText = new TextBlock();
priceText.Text = RetailInfo.Properties[KnownRetailInfo.Price];
// Assume infoPanel is a StackPanel declared in XAML
this.infoPanel.Children.Add(priceText);
using namespace Windows::UI::Xaml::Controls;
using namespace Windows::System::Profile;

TextBlock ^manufacturerText = ref new TextBlock();
manufacturerText.set_Text(RetailInfo::Properties[KnownRetailInfoProperties::Price]);
// Assume infoPanel is a StackPanel declared in XAML
this->infoPanel->Children->Add(manufacturerText);
var pro = Windows.System.Profile;
console.log(pro.retailInfo.properties[pro.KnownRetailInfoProperties.price);

IDL

//  Copyright (c) Microsoft Corporation. All rights reserved.
//
//  WindowsRuntimeAPISet

import "oaidl.idl";
import "inspectable.idl";
import "Windows.Foundation.idl";
#include <sdkddkver.h>

namespace Windows.System.Profile
{
    runtimeclass RetailInfo;
    runtimeclass KnownRetailInfoProperties;

    [version(NTDDI_WINTHRESHOLD), uuid(0712C6B8-8B92-4F2A-8499-031F1798D6EF), exclusiveto(RetailInfo)]
    [version(NTDDI_WINTHRESHOLD, Platform.WindowsPhone)]
    interface IRetailInfoStatics : IInspectable
    {
        [propget] HRESULT IsDemoModeEnabled([out, retval] boolean *value);
        [propget] HRESULT Properties([out, retval, hasvariant] Windows.Foundation.Collections.IMapView<HSTRING, IInspectable *> **value);
    }

    [version(NTDDI_WINTHRESHOLD), uuid(50BA207B-33C4-4A5C-AD8A-CD39F0A9C2E9), exclusiveto(KnownRetailInfoProperties)]
    [version(NTDDI_WINTHRESHOLD, Platform.WindowsPhone)]
    interface IKnownRetailInfoPropertiesStatics : IInspectable
    {
        [propget] HRESULT RetailAccessCode([out, retval] HSTRING *value);
        [propget] HRESULT ManufacturerName([out, retval] HSTRING *value);
        [propget] HRESULT ModelName([out, retval] HSTRING *value);
        [propget] HRESULT DisplayModelName([out, retval] HSTRING *value);
        [propget] HRESULT Price([out, retval] HSTRING *value);
        [propget] HRESULT IsFeatured([out, retval] HSTRING *value);
        [propget] HRESULT FormFactor([out, retval] HSTRING *value);
        [propget] HRESULT ScreenSize([out, retval] HSTRING *value);
        [propget] HRESULT Weight([out, retval] HSTRING *value);
        [propget] HRESULT DisplayDescription([out, retval] HSTRING *value);
        [propget] HRESULT BatteryLifeDescription([out, retval] HSTRING *value);
        [propget] HRESULT ProcessorDescription([out, retval] HSTRING *value);
        [propget] HRESULT Memory([out, retval] HSTRING *value);
        [propget] HRESULT StorageDescription([out, retval] HSTRING *value);
        [propget] HRESULT GraphicsDescription([out, retval] HSTRING *value);
        [propget] HRESULT FrontCameraDescription([out, retval] HSTRING *value);
        [propget] HRESULT RearCameraDescription([out, retval] HSTRING *value);
        [propget] HRESULT HasNfc([out, retval] HSTRING *value);
        [propget] HRESULT HasSdSlot([out, retval] HSTRING *value);
        [propget] HRESULT HasOpticalDrive([out, retval] HSTRING *value);
        [propget] HRESULT IsOfficeInstalled([out, retval] HSTRING *value);
        [propget] HRESULT WindowsVersion([out, retval] HSTRING *value);
    }

    [version(NTDDI_WINTHRESHOLD), static(IRetailInfoStatics, NTDDI_WINTHRESHOLD)]
    [version(NTDDI_WINTHRESHOLD, Platform.WindowsPhone), static(IRetailInfoStatics, NTDDI_WINTHRESHOLD, Platform.WindowsPhone)]
    [threading(both)]
    [marshaling_behavior(agile)]
    runtimeclass RetailInfo
    {
    }

    [version(NTDDI_WINTHRESHOLD), static(IKnownRetailInfoPropertiesStatics, NTDDI_WINTHRESHOLD)]
    [version(NTDDI_WINTHRESHOLD, Platform.WindowsPhone), static(IKnownRetailInfoPropertiesStatics, NTDDI_WINTHRESHOLD, Platform.WindowsPhone)]
    [threading(both)]
    [marshaling_behavior(agile)]
    runtimeclass KnownRetailInfoProperties
    {
    }
}

清理过程

在购物者停止与设备交互后两分钟开始清理。 零售演示开始播放,Windows 开始重置联系人、照片和其他应用程序中的所有示例数据。 根据设备,这可能需要1-5 分钟才能完全将一切恢复到正常。 这确保了零售店中的每个客户都可以走到设备前并在与设备交互时获得相同的体验。

步骤 1:清理

  • 关闭所有 Win32 和应用商店应用
  • 删除已知文件夹(如 PicturesVideosMusicDocumentsSavedPicturesCameraRollDesktopDownloads 文件)中的所有文件夹。
  • 删除非结构化和结构化漫游状态
  • 删除结构化本地状态

步骤 2:设置

  • 对于离线设备:文件夹保持空白
  • 对于联机设备:零售演示资源可从 Microsoft Store 推送到设备

跨用户会话存储数据

要跨用户会话存储数据,可将信息存储在 ApplicationData.Current.TemporaryFolder 中,因为默认清理过程不会自动删除此文件夹中的数据。 请注意,将在清理过程期间删除使用 LocalState 存储的信息。

自定义清理过程

要自定义清理过程,请在应用中实现 Microsoft-RetailDemo-Cleanup 应用服务。

需要自定义清理逻辑的方案包括运行耗费资源的设置、下载和缓存数据或者不希望删除 LocalState 数据。

步骤 1:在应用清单中声明 Microsoft-RetailDemo-Cleanup 服务。

  <Applications>
      <Extensions>
        <uap:Extension Category="windows.appService" EntryPoint="MyCompany.MyApp.RDXCustomCleanupTask">
          <uap:AppService Name="Microsoft-RetailDemo-Cleanup" />
        </uap:Extension>
      </Extensions>
   </Application>
  </Applications>

步骤 2:使用下面的示例模板在 AppdataCleanup 案例函数下实现自定义清理逻辑。

using System;
using System.IO;
using System.Runtime.Serialization.Json;
using System.Threading;
using System.Threading.Tasks;
using Windows.ApplicationModel.AppService;
using Windows.ApplicationModel.Background;
using Windows.Foundation.Collections;
using Windows.Storage;

namespace MyCompany.MyApp
{
    public sealed class RDXCustomCleanupTask : IBackgroundTask
    {
        BackgroundTaskCancellationReason _cancelReason = BackgroundTaskCancellationReason.Abort;
        BackgroundTaskDeferral _deferral = null;
        IBackgroundTaskInstance _taskInstance = null;
        AppServiceConnection _appServiceConnection = null;

        const string MessageCommand = "Command";

        public void Run(IBackgroundTaskInstance taskInstance)
        {
            // Get the deferral object from the task instance, and take a reference to the taskInstance;
            _deferral = taskInstance.GetDeferral();
            _taskInstance = taskInstance;
            _taskInstance.Canceled += new BackgroundTaskCanceledEventHandler(OnCanceled);

            AppServiceTriggerDetails appService = _taskInstance.TriggerDetails as AppServiceTriggerDetails;
            if ((appService != null) && (appService.Name == "Microsoft-RetailDemo-Cleanup"))
            {
                _appServiceConnection = appService.AppServiceConnection;
                _appServiceConnection.RequestReceived += _appServiceConnection_RequestReceived;
                _appServiceConnection.ServiceClosed += _appServiceConnection_ServiceClosed;
            }
            else
            {
                _deferral.Complete();
            }
        }

        void _appServiceConnection_ServiceClosed(AppServiceConnection sender, AppServiceClosedEventArgs args)
        {
        }

        async void _appServiceConnection_RequestReceived(AppServiceConnection sender, AppServiceRequestReceivedEventArgs args)
        {
            //Get a deferral because we will be calling async code
            AppServiceDeferral requestDeferral = args.GetDeferral();
            string command = null;
            var returnData = new ValueSet();

            try
            {
                ValueSet message = args.Request.Message;
                if (message.ContainsKey(MessageCommand))
                {
                    command = message[MessageCommand] as string;
                }

                if (command != null)
                {
                    switch (command)
                    {
                        case "AppdataCleanup":
                            {
                                // Do custom clean up logic here
                                break;
                            }
                    }
                }
            }
            catch (Exception e)
            {
            }
            finally
            {
                requestDeferral.Complete();
                // Also release the task deferral since we only process one request per instance.
                _deferral.Complete();
            }
        }

        private void OnCanceled(IBackgroundTaskInstance sender, BackgroundTaskCancellationReason reason)
        {
            _cancelReason = reason;
        }
    }
}