借助 C++ 进行 Windows 开发

Windows 运行时应用程序模型

Kenny Kerr

Kenny Kerr我们的生活充满了抽象。作为开发人员,如果我们不了解抽象的定义就去使用它,则通常会让我们陷入困境。抽象有时是零散的,且无法完全隐藏基本复杂性。别误解我的意思,其实抽象是很有用的。它们能为用户和开发人员提供帮助,而如果您深入研究您通常依赖的抽象来了解其运作方式,则会让您受益匪浅。此外,承认这一现实的库通常比不承认这一现实的库更为成功,部分是因为前者允许您在必要时绕过抽象。

Windows 运行时 (WinRT) 就是一个这样的抽象,在本月的专栏中,我将通过研究 WinRT 核心应用程序模型来说明此抽象。此抽象以 CoreWindow 类为中心,并且每个“新型”Windows 应用商店和 Windows Phone 应用中都包含一个该类的实例。但是很少有开发人员知道该实例的存在,更不必说该实例的运作方式了。这可能是对抽象成功的最好证明。

自 Windows 8 API 最初于 2011 年发布以来,已有大量关于通过 Windows 运行时提供抽象的各种语言投射的报道和文章。但是,了解 Windows 运行时的最佳方式是避开各种语言投射(包括 C++/CX)并使用标准 C++ 和经典 COM。只有 C++ 能让您透过表象看到实际情况(从技术上说,C 也可以,但会造成一些不必要的麻烦)。您仍可以选择使用这样或那样的语言投射(但愿是 C++/CX),因为您可能应该这样做,但您至少要更清楚地了解实际情况。

若要开始此操作,请打开 Visual Studio 2012 并为 Windows 应用商店或 Windows Phone 应用创建新的 Visual C++ 项目。随便您使用哪种模板。加载后,转到解决方案资源管理器并删除一切不重要的内容。如果您选取了基于 XAML 的模板,则删除所有 XAML 文件。您也可以删除所有 C++ 源文件。您可能需要保留预编译头,但请务必删除其中包含的所有内容。应保留的内容是部署应用、图像、证书和 XML 清单所需的包资产。

接下来,打开项目的属性页并选择编译器属性 - 树中左侧的 C/C++ 节点。找到称作“使用 Windows 运行时扩展”的 /ZW 编译器选项对应的行,并选择“否”以禁用 C++/CX 语言扩展。这样,您就可以确保不会有比标准 C++ 编译器更神秘的东西了。到了这一步,您也可以将编译器的警告级别设置为 /W4。

如果您尝试编译项目,则应会收到一个链接器错误,告知您找不到项目的 WinMain 入口点函数。将新的 C++ 源文件添加到项目,您要做的第一件事是添加缺少的 WinMain 函数:

int __stdcall wWinMain(HINSTANCE, HINSTANCE, PWSTR, int)
{
}

如您所见,这是用于基于 C 运行时库 (CRT) 的 Windows 应用程序的旧 WinMain 函数。 当然,HINSTANCE 和 PWSTR 不是基本 C++ 类型,因此您将需要包含 Windows 头:

#include <windows.h>

如果您保留了项目的预编译头,则可在此处包含它。 此外,由于我还将使用 Windows 运行时 C++ 模板库 (WRL) 中的 ComPtr,因此现在最好是包含它:

#include <wrl.h>

在接下来的几个专栏中,我将更详细地介绍 WRL。 现在,我将利用 ComPtr 类模板来维护 COM 接口指针。 在这一阶段中,您只需记住 WRL ComPtr 只是一个 COM 接口智能指针。 尽管它提供了某些特定于 Windows 运行时的功能,但我不会在本月的专栏中探讨这些功能。 您可以很轻松地改用活动模板库 (ATL) CComPtr 或您选择的任何 COM 接口智能指针。 WRL ComPtr 是在 Microsoft::WRL 命名空间中定义的:

using namespace Microsoft::WRL;

我还将使用 ASSERT 宏和 HR 函数来进行错误处理。 由于我之前已讨论这些,因此在这里不再讲述。 如果您对这些步骤不确定,请参阅我在 2013 年 5 月撰写的专栏“Direct2D 1.1 简介”(msdn.microsoft.com/magazine/dn198239)。

最后,若要使用本专栏中提到的任何 WinRT 函数,您需要为链接器提供 .lib 文件的名称:

#pragma comment(lib, "RuntimeObject.lib")

应用程序模型首先需要的是多线程单元 (MTA)。 是的,这就是 COM 单元模型所在的位置。 Windows 运行时提供了 RoInitialize 函数,它是 CoInitializeEx 的薄包装:

HR(RoInitialize(RO_INIT_MULTITHREADED));

尽管使用 CoInitializeEx 通常已足够,但我还是建议您使用 RoInitialize。 利用此函数,您将来可以对 Windows 运行时进行改进而不用承担破坏经典 COM 的风险。 RoInitialize 类似于 OleInitialize,后者也称为 CoInitializeEx 等等。 就我看来,您的应用程序的主线程没什么神秘的。 唯一可能有点让人吃惊的是它不是单线程单位 (STA)。 别担心,您的应用程序的窗口仍将从 STA 线程中运行,但 Windows 运行时将是其创建者。 实际上,此 STA 是略微不同的应用程序 STA (ASTA),我以后会详细介绍它。

接下来的内容会有一些棘手。 Windows 运行时放弃了使用基于 GUID 的类标识符的传统 COM 激活模型,而采用了根据文本类标识符激活类的模型。 文本名称基于 Java 和 Microsoft .NET Framework 提出的以命名空间为作用域的类名称,但在您对注册表嗤之以鼻并表示终于得到解脱之前,请记住这些新的类标识符仍存储在注册表中。 从技术上说,仅第一方类型是在注册表中注册的,而第三方类型仅在每个应用程序清单中注册。 这种变化有利也有弊。 缺点之一是,在调用 WinRT 函数时描述类标识符有点困难。 Windows 运行时定义了一个新的远程字符串类型来替代传统的 BSTR 字符串类型,任何类标识符都需要通过此新媒介提供。 调用 HSTRING 时出现的错误比调用 BSTR 时出现的错误要少得多,这主要是因为前者是固定不变的。 使用 WindowsCreateString 函数创建 HSTRING 最为轻松:

wchar_t buffer[] = L"Poultry.Hatchery";
HSTRING string;
HR(WindowsCreateString(buffer,
                       _countof(buffer) - 1,
                       &string));

WindowsCreateString 将分配足够的内存来存储源字符串的副本和终止 null 字符,然后将源字符串复制到此缓冲区。 若要释放后备缓冲区,您必须记住调用 WindowsDeleteString,除非字符串的所有权返回到调用函数:

HR(WindowsDeleteString(string));

如果使用的是 HSTRING,您可以使用 WindowsGetStringRawBuffer 函数获取指向其后备缓冲区的指针:

wchar_t const * raw = WindowsGetStringRawBuffer(string, nullptr);

可选的第二个参数将返回字符串的长度。 该长度与字符串存储在一起,这样您无需扫描字符串即可确定其长度。 在运行和写入 C++ 包装类之前,您需要注意的是,在为字符串文本或常量数组生成代码时,C++/CX 编译器不会干扰 WindowsCreateString 和 WindowsDelete­String。 相反,它会使用所谓的快速传递字符串,以避免我之前提到的过度的内存分配和复制。 这还将避免内存泄漏的风险。 WindowsCreate­StringReference 函数将创建快速传递字符串:

HSTRING_HEADER header;
HSTRING string;
HR(WindowsCreateStringReference(buffer,
                                _countof(buffer) - 1,
                                &header,
                                &string));

此函数使用调用方提供的 HSTRING_HEADER 来避免堆栈分配。 在这种情况下,HSTRING 的后备缓冲区是源字符串本身,因此您必须确保源字符串(以及标头)在 HSTRING 的整个生命周期内保持不变。 当您需要将字符串返回到调用函数时,此方法明显没有任何用处,但当您需要将字符串作为输入传递到生存期受堆栈限制的其他函数(非异步函数)时,此方法值得优化。 WRL 还为 HSTRING 和快速传递函数提供了包装。

RoGetActivationFactory 只是这样一个函数,用于获取给定类的激活工厂或静态接口。 这与 COM CoGetClassObject 函数类似。 如果此函数通常用于由 MIDL 编译器生成的常量数组,则写入简单函数模板来提供快速传递字符串包装会很有用。 图 1 显示了其可能的外观。

图 1 GetActivationFactory 函数模板

template <typename T, unsigned Count>
auto GetActivationFactory(WCHAR const (&classId)[Count]) -> ComPtr<T>
{
  HSTRING_HEADER header;
  HSTRING string;
  HR(WindowsCreateStringReference(classId,
                                  Count - 1,
                                  &header,
                                  &string));
  ComPtr<T> result;
  HR(RoGetActivationFactory(string,
    __uuidof(T),
    reinterpret_cast<void **>(result.GetAddressOf())));
  return result;
}

GetActivationFactory 函数模板将自动推断字符串长度,从而消除了易出错的缓冲区长度参数或成本高昂的运行时扫描。 然后,该模板会在调用实际 RoGetActivationFactory 函数前准备快速传递字符串。 此时该函数模板将再次推断接口标识符,并安全地返回在 WRL ComPtr 中包装的生成的 COM 接口指针。

您现在可以使用此帮助程序函数来获取 ICoreApplication 接口:

using namespace ABI::Windows::ApplicationModel::Core;
auto app = GetActivationFactory<ICoreApplication>(
  RuntimeClass_Windows_ApplicationModel_Core_CoreApplication);

ICoreApplication 接口用来让您的应用程序开始运行。 若要使用此 COM 接口,您需要包含应用程序模型标头:

#include <Windows.ApplicationModel.Core.h>

此标头定义了 ABI::Windows::ApplicationModel::Core 命名空间内的 ICoreApplication,以及 Core­Application 的文本类标识符。 您真正需要考虑的唯一接口方法是 Run 方法。

在继续讨论之前,有必要赞赏一下 Windows 运行时为您的应用程序带来的灵活性。 正如我前面提到的,Windows 运行时只不过将您视为您自己的进程中的一个来宾。 这与 Windows 服务多年来的工作原理类似。 对于 Windows 服务,Windows 服务控制管理器 (SCM) 使用 CreateProcess 函数或其变体之一启动服务。 该管理器随后会等待进程调用 StartServiceCtrlDispatcher 函数。 此函数将建立返回 SCM 的连接,该服务和 SCM 可通过它进行通信。 例如,如果该服务未能及时调用 StartService­CtrlDispatcher,则 SCM 将认为出错并终止进程。 StartServiceCtrl­Dispatcher 函数仅在该服务终止后返回,因此 SCM 需要为服务创建辅助线程以便接收回调通知。 该服务很少对事件进行响应且受 SCM 的支配。 正如您将发现的,这与 WinRT 应用程序模型极其相似。

Windows 运行时等待进程获取 ICore­Application 接口并调用其 Run 方法。 与 SCM 类似,如果进程未能及时执行此操作,则 Windows 运行时将认为存在错误并终止进程。 幸运的是,如果连接了调试器,则 Windows 运行时将发出通知并禁用超时,这一点与 SCM 不同。 但是,其模型是相同的。 Windows 运行时负责应用程序并在事件发生时对运行时创建的线程调用应用程序。 当然,Windows 运行时是基于 COM 的,它期望应用程序提供 Run 方法与可用于调用应用程序的 COM 接口,而不是提供回调函数(这与 SCM 相同),并且 Windows 运行时依赖进程生命周期管理器 (PLM) 做到这一点。

您的应用程序必须提供 IFramework­ViewSource 的实现(也来自 ABI::Windows::ApplicationModel::Core 命名空间);在创建您的应用程序的 UI 线程后,CoreApplication 将调用其独立的 CreateView 方法。 IFrameworkViewSource 不会真的将 CreateView 视为方法。 IFrameworkViewSource 派生自 WinRT 基接口 IInspectable。 反过来,IInspectable 又派生自 COM 基接口 IUnknown。

WRL 为实现 COM 类提供了广泛的支持,不过关于这方面的内容,我将留到接下来的专栏中讲解。 现在,我想介绍 Windows 运行时如何真正来源于 COM,以及哪一种方法比实现 IUnknown 更能展示这一点。 对于我而言,记住这一点很有用:将实现 IFrameworkViewSource 和其他一些接口的 C++ 类的生存期通过堆栈来定义。 基本说来,应用程序的 WinMain 函数可以归纳为:

HR(RoInitialize(RO_INIT_MULTITHREADED));
auto app = GetActivationFactory<ICoreApplication>( ...
SampleWindow window;
HR(app->Run(&window));

剩下来要做的只是写入 SampleWindow 类,以便让它正确实现 IFrameworkViewSource。 尽管 CoreApplication 不关注自己的实现位置,但至少来说,您的应用程序不但需要实现 IFrameworkViewSource,而且需要实现 IFrameworkView 和 IActivatedEventHandler 接口。 在本示例中,SampleWindow 类可以将它们全部实现:

struct SampleWindow :
  IFrameworkViewSource,
  IFrameworkView,
  IActivatedEventHandler
{};

此外,IFrameworkView 接口也是在 ABI::Windows::ApplicationModel::Core 命名空间中定义的,但更难约束 IActivatedEvent­Handler。 我在下面给出了自己所做的定义:

using namespace ABI::Windows::Foundation;
using namespace ABI::Windows::ApplicationModel::Activation;
typedef ITypedEventHandler<CoreApplicationView *, 
  IActivatedEventArgs *>
  IActivatedEventHandler;

如果您有一些使用 COM 的经验,则可能会认为这看起来不够正统 - 您是对的。 正如您期望的,ITypedEventHandler 只是一个类模板,而定义 COM 接口的方法非常奇怪 - 最明显的问题是您无法知道要将其归于哪种接口标识符。 幸运的是,所有这些接口都由 MIDL 编译器生成,该编译器将负责专用化每个接口,并基于这些专用化来附加表示接口标识符的 GUID。 该编译器将定义直接派生自 IUnknown 的 COM 接口并提供一个称为 Invoke 的方法,这与之前的 typedef 可能出现的情况一样复杂。

我有几个要实现的接口方法,那么让我们开始吧。 首先是 IUnknown 和强大的 QueryInterface 方法。 我在这里不想在 IUnknown 和 IInspectable 上花太多的时间,因为我将在接下来的专栏中详细介绍它们。 图 2 提供了针对基于堆栈的类的简单 QueryInterface 实现,如下所示。

图 2 SampleWindow QueryInterface 方法

auto __stdcall QueryInterface(IID const & id,
                              void ** result) -> HRESULT
{
  ASSERT(result);
  if (id == __uuidof(IFrameworkViewSource) ||
      id == __uuidof(IInspectable) ||
      id == __uuidof(IUnknown))
  {
    *result = static_cast<IFrameworkViewSource *>(this);
  }
  else if (id == __uuidof(IFrameworkView))
  {
    *result = static_cast<IFrameworkView *>(this);
  }
  else if (id == __uuidof(IActivatedEventHandler))
  {
    *result = static_cast<IActivatedEventHandler *>(this);
  }
  else
  {
    *result = nullptr;
    return E_NOINTERFACE;
  }
  // static_cast<IUnknown *>(*result)->AddRef();
  return S_OK;
}

对于此实现,有一些方面值得注意。 首先,该方法断言其参数是有效的。 更正确的实现可能返回 E_POINTER,但人们认为此类错误是可在开发过程中解决的 Bug,因此无需在运行时浪费额外的周期。 这通过导致访问冲突和非常易于分析的崩溃转储提供了可能最好的行为。 如果您返回了 E_POINTER,中断的调用方可能会直接忽略它。 最佳策略是提早失败。 实际上,很多实现都采用了该位置,包括 DirectX 和 Windows 运行时。 对于正确实现 QueryInterface 进行了深入地研究。 COM 规范很特别,这使得 COM 类将始终正确和一致地提供某些对象标识保证。 如果 if 语句链看起来令人生畏,别担心。 我将在适当的时候介绍它。

关于此实现,值得提及的最后一点是调用 AddRef 不会很麻烦。 通常,在返回前,QueryInterface 必须在生成的 IUnknown 接口指针上调用 AddRef。 但是,由于 SampleWindow 类主流位于堆栈上,因此没有必要进行引用计数。 同样,实现 IUnknown AddRef 和 Release 方法也很简单:

auto __stdcall AddRef()  -> ULONG { return 2; }
auto __stdcall Release() -> ULONG { return 1; }

这些方法的结果只是建议,因此您可以利用此事实,任何非零值都行。 这里需要注意的一点是: 您可能希望覆盖运算符 new 和 delete 以便明确表示该类仅用于堆栈。 或者,您也可以直接实现引用计数,以防万一。

接下来,我需要实现 IInspectable,但由于它不会用于此简单应用,我将略施小计,以使其方法不实现,如图 3 所示。 这不是符合标准的实现,不能保证一定有效。 再次声明,我将在接下来的专栏中介绍 IInspectable,但这还不足以让 SampleWindow IInspectable 派生的接口启动和运行。

图 3 SampleWindow IInspectable 方法

auto __stdcall GetIids(ULONG *,
                       IID **) -> HRESULT
{
  return E_NOTIMPL;
}
auto __stdcall GetRuntimeClassName(HSTRING *) -> HRESULT
{
  return E_NOTIMPL;
}
auto __stdcall GetTrustLevel(TrustLevel *) -> HRESULT
{
  return E_NOTIMPL;
}

接下来,我需要实现 IFrameworkViewSource 及其 CreateView 方法。 由于 SampleWindow 类也将实现 IFrameworkView,因此上述实现很简单。 再次提醒,在返回前,您通常需要在生成的 IUnknown 派生接口指针上调用 AddRef。 您可能希望在下列函数的主体中调用 AddRef,以防万一:

auto __stdcall CreateView(IFrameworkView ** result) -> HRESULT
{
  ASSERT(result);
  *result = this;
  // (*result)->AddRef();
  return S_OK;
}

IFrameworkView 接口是让应用程序最终变得有趣的地方。 在调用 CreateView 以从应用程序检索接口指针后,Windows 运行时将依次快速调用其大多数方法。 您必须快速响应这些调用,因为它们在用户等待您的应用程序启动的这段时间内将进行计数。 第一个称为 Initialize,这是应用程序必须注册 Activated 事件的地方。 Activated 事件表示应用程序已激活,但要由该应用程序激活其 CoreWindow。 Initialize 方法非常简单:

auto __stdcall Initialize(ICoreApplicationView * view) -> HRESULT
{
  EventRegistrationToken token;
  HR(view->add_Activated(this, &token));
  return S_OK;
}

随后将调用 SetWindow 方法,用来为应用程序提供实际的 ICoreWindow 实现。 ICoreWindow 仅对 Windows 运行时内的常规桌面 HWND 建模。 与之前的应用程序模型接口不同。ICoreWindow 是在 ABI::Windows::UI::Core 命名空间中定义的。 在 SetWindow 方法中,您应复制接口指针,因为您很快会用到它:

using namespace ABI::Windows::UI::Core;
ComPtr<ICoreWindow> m_window;
auto __stdcall SetWindow(ICoreWindow * window) -> HRESULT
{
  m_window = window;
  return S_OK;
}

Load 方法是下一个方法,您应该将所有代码粘贴到这里,以便准备应用程序以进行初始呈现:

auto __stdcall Load(HSTRING) -> HRESULT
{
  return S_OK;
}

您至少应注册与窗口大小和可见性更改以及对 DPI 缩放的更改相关的事件。 您也可以抓住机会来创建各种 DirectX 工厂对象、加载与设备无关的资源等。 此处之所以是完成所有这些操作的好地方,是因为用户在这里通过您的应用程序的启动画面呈现。

当 Load 方法返回时,Windows 运行时将认为您的应用程序已作好激活准备并触发 Activated 事件,我将通过实现 IActivatedEventHandler Invoke 方法处理该事件,如下所示:

auto __stdcall Invoke(ICoreApplicationView *,
                      IActivatedEventArgs *) -> HRESULT
{
  HR(m_window->Activate());
  return S_OK;
}

激活窗口后,应用程序便做好了运行准备:

auto __stdcall Run() -> HRESULT
{
  ComPtr<ICoreDispatcher> dispatcher;
  HR(m_window->get_Dispatcher(dispatcher.GetAddressOf()));
  HR(dispatcher->ProcessEvents(CoreProcessEventsOption_ProcessUntilQuit));
  return S_OK;
}

可通过很多方法实现这一点。 我在这里采用的办法是检索窗口的 ICoreDispatcher 接口,它表示窗口的消息泵。 最后还有一个 Uninitialize 方法,它可能偶然被调用,但在其他情况下可以被安全地忽略:

auto __stdcall Uninitialize() -> HRESULT
{
  return S_OK;
}

就是这样。 您现在可以编译和运行应用程序。 当然,您在这里不会真的绘制什么东西。 您可以从 dx.codeplex.com 获得 dx.h 的副本并开始添加一些 Direct2D 呈现代码(有关更多信息,请参见我在 2013 年 6 月的专栏“一个用于 DirectX 编程的现代库”,网址为 msdn.microsoft.com/magazine/dn201741),或者等到我的下一个专栏,我将为您演示如何通过最好的方式将 Direct2D 与 WinRT 核心应用程序模型集成。

Kenny Kerr 是加拿大的一名计算机程序员,他是 Pluralsight 的作者,也是 Microsoft MVP。 他的博客网址是 kennykerr.ca,您可以通过 Twitter twitter.com/kennykerr 关注他。

衷心感谢以下技术专家对本文的审阅: James McNellis (Microsoft)
James McNellis 是 C++ 迷,也是 Microsoft 的 Visual C++ 团队的软件开发人员,他的工作是构建精彩的 C++ 库和维护 C 运行时库 (CRT)。他的 Tweet 是 @JamesMcNellis,也可以通过 http://jamesmcnellis.com/ 的其他位置在线联系他。