使用 C++/CX 构建 WinRT 组件
本文演示了如何创建实现一组通用接口的不同版本的 WinRT 组件。
引言
Windows 8 已于上个月发布,Visual Studio 2012 也已发布数月。您可以找到许多文章和博客,介绍如何使用 Windows 8 实现 WinRT 组件。我在一台旧电脑上安装了 Windows 8 和 Visual Studio 2012 Premium 版本,并尝试开发一些我多年来一直使用这项新技术实现的模式。在此过程中,我遇到了一些问题,在各种博客上寻找解决方案时,我找不到任何能展示我在这篇短文中介绍的内容的文章。
背景
尽管 Microsoft 将 WinRT 宣传为一项新技术,但实际上它是一项相当古老且经过验证的技术,WinRT 在后台使用它。那些在 1995 年至 2000 年初之间在 Microsoft 生态系统中使用过 C++ 编程的人肯定已经发现,WinRT 实际上是基于 Don Box 在 MSDN 期刊上的文章中广为人知的 COM 技术。因此,如果您和我一样是软件老兵,您会在 WinRT 和本文中发现许多熟悉的东西。
COM 的强大之处
COM 代表 Component Object Model,关于 COM 已有很多介绍,我在这里不会用我自己的话重复这项技术的每一个细节。对于那些真正想理解这项技术及其理念的人,我建议您阅读 Don Box 撰写的《Essential COM》一书。
首先,COM 不是 .NET,并且 COM 在技术上与 .NET 有很大不同。COM,事实上,WinRT 是 COM 的一个演进,都是原生技术。.NET (C#) 和 Java 使用虚拟机来执行其代码,这个虚拟机位于机器语言之上,抽象了程序的执行,使其独立于用原生代码编写的系统 API。原生代码是可以由您的计算机微处理器直接执行的代码。在过去的 10 到 15 年里,时尚一直是使用托管语言,这些语言是高级语言,生成虚拟机代码,并将开发者从编写原生(也称为非托管代码)时需要承担的许多责任中解放出来。
像 C++ 这样的语言与 C# 或 Java 等现代语言相比相对静态,缺乏公开可被其他环境和语言(如 VB、Java、Python、JavaScript 等)共享和动态加载的对象的的能力。那些使用过 C# 的原生互操作性的人知道他们可以使用 Win32 API 的原生方法,但他们不能直接使用用原生 C++(不是 C++/CLI!)开发的类。COM 能够将二进制对象实现的非托管接口暴露给其他语言,甚至 JavaScript,而且这已经很久了。我在 2000 年编写了一个使用 COM 架构的零售应用程序,该应用程序由 HTMLA(HTML for application)使用,其中编程语言是 MS JavaScript,执行引擎是 Internet Explorer 的特定版本。基本上,一个桌面应用程序,它使用 HTML 作为 GUI,JavaScript 作为编程语言,COM 组件作为核心服务。这听起来是不是很熟悉?Windows 8 Metro 应用程序的编程模型之一是什么?HTML5 配合 JavaScript 和 WinRT 组件。嘿,微软先生,看起来您正在复活 IE5 和 IE5.5 中存在的 10 到 12 年前的技术!!
COM 的主要优点之一是您可以轻松构建支持插件架构或我称之为基于组件的架构的应用程序,在其中您可以支持新功能或数据类型,而无需修改主应用程序的代码。这得益于 COM 对象能够公开任意数量的接口,并使用工厂机制从中央存储库动态加载到内存中。一旦通过其名称加载到内存中,应用程序就会询问该组件是否可以生成应用程序所需的接口来执行某些功能。这也被称为后期绑定。
我将在本文中演示如何用不同的 WinRT 组件实现相同的接口。我还将介绍两种不同的架构,用于使用 C++/CX 实现 COM/WinRT 组件。
您肯定已经读过 WinRT 组件不能继承,本文也将对此进行清晰的阐述,解释为什么这不可能。
接口和实现类
WinRT 接口,也称为 COM 接口,使用 C++ vtable 的二进制结构,这使得 C++ 成为实现 WinRT 组件的自然语言。然而,Microsoft 在使 COM 在 21 世纪更易用方面做了大量工作,使用 .NET 和 C# 等语言编写 WinRT 组件也是可能的,就像使用 .NET 编写 COM 组件一样。
每个 COM 对象都必须实现 IUnknown
接口,该接口提供了 3 个非常重要的方法:
AddRef
:增加对象的引用计数。Release
:减少对象的引用计数。QueryInterface
:允许调用者查询对象是否实现了给定的接口。
IInspectable
,它基本上为 COM 带来了反射机制。我还没有探索这个接口及其用法,所以不会多谈。只需知道 WinRT 始终实现这个接口,您无需手动实现,编译器扩展会为您完成。
我将在本文中定义四个简单的接口,并用 C++/CX 实现它们。然后,我们将看看这两种架构是否可以相互以及与像 .NET 这样的健壮客户端技术互操作。
以下图表提供了架构的简单视图。
我来详细解释一下这个图表。
- COM 接口是对象向外部世界公开的接口。
- COM 类是组件的实现类。在 C++/CX 中,这些类是公共的 ref 类。它们用于实例化组件。
- C++ 实现类是内部类,用于创建实现的对象层次结构。
ICitizen
继承自 IPerson
,并与 IAddress
相关联。在 COM 类层,没有类层次结构,只有 Citizen
和 Address
之间的关联。
然后在 C++ 类层,我们再次拥有完整的层次结构和关联图。
这个模式是我将在本文中实现的一个模式。使用此模式的想法是利用 C++ 的完整面向对象特性,并使用继承、多态性以及该现代语言提供的所有功能。
第二种模式也表示在上面的图表中,您只需删除 C++ 类实现即可获得它。每个 COM 类都将在不使用 C++ 类内部对象模型的情况下实现。由于没有实现继承,这可能会导致一些代码重复。然而,在许多情况下并非如此,并且可能实现某种程度的重用。
使用组合类实现
这种模式很简单。对象模型是用 C++ 使用您拥有的所有面向对象概念创建的,然后将其映射到 COM 类,以便向外部边界公开。这个模型有一些重要的含义,因为我们正在映射两个具有不同概念的世界。让我解释一下我的意思。
COM/WinRT 组件由工厂创建,其生命周期取决于引用计数。每次将该对象的引用传递给方法时,其引用计数都会增加,当方法或对象不再使用它时,必须减少引用计数。当计数变为 0 时,COM 组件将从内存中删除。所有指向 COM 组件的引用都指向同一个实例,这类似于 C# 或 Java 中的引用。
在 C++ 世界中,我们使用指针、类引用和值类,它们呈现不同的生命周期管理。在组合模型中,每个 COM 实现都有一个代表对象的 C++ 类,并且在外部边界中存在的关联也映射到 C++ 类模型中。这意味着实例的一致性必须在两个模型之间得到维护。
当您处理 COM 组件的引用时,您实际上是在处理组合类的内部实例。最大的问题是,当您创建一个 COM 组件并将其关联到另一个 COM 组件时,关联也必须在组合类级别创建。我们将看到这会产生复杂的后果!
理解 COM 的一个非常重要的概念是:一旦对象被实例化,访问它的唯一方式就是对象向外部世界公开的公共接口。实现类及其受保护、内部和私有成员对外部世界不可访问。即使您使用 C# 或 C++/CX 创建该对象的实例,您使用的名称是 COM 类名,您获得的指针**不是**指向该类的指针,而是指向默认接口的指针。
然而,当您在 C++ 中创建该对象,并且您知道您正在使用一个进程内 COM 的特定实现时,您可以将接口强制转换为实现类,并访问内部成员。内部成员实际上是实现类的公共成员,但没有被 DLL 导出。
以下代码片段说明了此模式最关键的方面。
void Citizen::Address::set(IAddress^ value)
{
WinRTCompV2::Address^ refAddress = nullptr;
try
{
refAddress = (WinRTCompV2::Address^) value;
}
catch(InvalidCastException^ ex)
{
OutputDebugString(std::wstring().append(
L"Address::set raised exception: " , ex->Message->Data()).c_str());
}
if (refAddress == nullptr)
{
refAddress = ref new WinRTCompV2::Address(value->Street, value->ZipCode, value->City);
}
CAddress* ptrAddress = &refAddress->GetAddress();
m_ptrCitizen->SetAddress(ptrAddress, false);
m_address = refAddress;
Citizen 的 set 属性的类型是 IAddress^
。这意味着当您设置地址时,您将 COM Address
的新实例传递给 COM Citizen
。
难点在于,在 COM 组件之间修改关联的同时,在 CCitizen
和 CAddress
对象之间创建关联。该方法假设 IAddress
接口由 WinRTCompV2::Address
对象实现,这意味着我们可以访问该对象的内部方法,特别是 GetAddress()
方法,该方法返回指向 WinRTCompV2::Address
实现的内部 CAddress
对象的指针。
代码检查是否可以正确完成转换,并且在实现不符合预期的情况下,创建一个新的 Address
实例并在组合类层创建关联。结果是,Address 引用与调用程序传递的引用不是同一个,这意味着如果您在设置后修改传递的 Address
对象的内容,这些更改将不会反映在 Citizen
对象中。这在我提供的 WinRT 组件代码的单元测试之一中得到了说明。
在 WinRT 组件主体内直接实现
这是我在使用新的 C++/CX 编译器时遇到困难的地方!在 COM 的早期,我会定义接口并将该接口的两个实现编码在同一个 DLL 中。但当时您需要在 IDL 文件中声明您的接口并为其分配一个 GUID,然后使用 ATL 编写类的实现。现在更容易了,即使您不需要在其他对象中实现接口,也不需要显式创建它。但是,如果您尝试在同一个 DLL 中包含接口和两个实现,就会出现一个小问题。编译器由于 .winmd 文件的生成而不高兴。我找不到将两个实现放在同一个 DLL 中的解决方案,但幸运的是,我可以将它们放在第二个 DLL 中,并引用声明接口的那个。
我还没有找到微软的解释,但它与命名空间机制和元数据文件的名称有关。这看起来很像 Java,它要求您有一个与类同名的源文件,并将命名空间层次结构与目录结构匹配,如果我没记错的话,这是我过去的 Java 经历!
说了这些,WinRT 比原始 COM 技术引入了更多的规则和限制,这也是其中之一。
第二个实现是直接在 COM 类主体中完成的,没有组合类,如果您还记得这些类的另一个限制,您不能继承另一个类,您只能继承(我更喜欢实现)接口。
这意味着 Citizen
类不能继承 Person
类,这会导致代码重复。然而,原始 COM 通过两种方式解决了这个问题。一种称为 COM 聚合,另一种称为动态组合。我不知道如何在 C++/CX 中进行 COM 聚合,但组合的实现非常简单。
Citizen
对象创建一个 Person
对象实例,并将其 Person 接口的方法/属性包装在自己的实现中。
namespace WinRTCompV3
{
Citizen::Citizen(void) :
m_person(ref new Person()),
m_iAddress(ref new WinRTCompV3::Address())
{
}
Citizen::Citizen(String^ name, String^ surname) :
m_person(ref new Person(name, surname)),
m_iAddress(ref new WinRTCompV3::Address())
{
}
Citizen::Citizen(String^ name, String^ surname, WinRTCompV3::Address^ address) :
m_person(ref new Person(name, surname)),
m_iAddress(address)
{
}
String^ Citizen::Name::get()
{
return m_person->Name;
}
void Citizen::Name::set(String^ value)
{
m_person->Name = value;
}
String^ Citizen::Surname::get()
{
return m_person->Surname;
}
void Citizen::Surname::set(String^ value)
{
m_person->Surname = value;
}
WinRTCompV2::IAddress^ Citizen::Address::get()
{
return m_iAddress;
}
void Citizen::Address::set(IAddress^ value)
{
m_iAddress = value;
}
bool Citizen::CanSave()
{
return m_person->CanSave() && m_iAddress->CanSave();
}
}
上面的实现说明了这种模式的简单性。在本例中,直接实现 Citizen
、Person
、Address
层次结构比使用一组组合类要容易得多。在更复杂的情况下,使用组合类可能更有趣。这是我十多年前用来实现更复杂的 COM 对象层次结构(使用 ATL)的模式。使用 ATL 还为源代码重用和模板提供了更多的可能性,因为 COM 类是直接用 C++ 实现的。
C++/CX 模型在编写 WinRT/COM 组件方面引入了更多简化,但对于像我一样大量使用过 ATL 的人来说,显然我们失去了框架的控制权。然而,我才刚刚触及这种实现 WinRT/COM 组件的新方法的表面,它还有一些巨大的好处,比如消除了 SAFEARRAY
烦恼的投影。那些使用过它们的人知道我在说什么!
这个小测试的最后一个有趣之处是混合了我定义的接口的两种实现。
混合这两种实现
混合ICitizen
和 IAddress
接口的两种实现完美地说明了 WinRT 组件的边界,以及为什么在创建它们时必须极其小心。在基于接口的体系结构中,您不应假设您可以知道哪个类实现了某个接口。
有了反射等机制,许多开发人员构建了不推荐用于 COM 等组件技术的模式。当您设计基于 COM 的体系结构,并打算将您的组件发布并提供给您用于开发它们之外的其他语言或技术时,那么您就不应假设您可以要求一个接口来获取一个给定的实现类。最好在假定访问对象的唯一方式是其公开的接口的情况下构建整个体系结构。
以下代码说明了与仅与自身兼容的实现混合时的错误行为。
public void UnitTestMixingWinRTCompV2withWinRTCompV3()
{
WinRTCompV2.IAddress addressV2 =
new WinRTCompV2.Address(TestData.TEXT_STR1, TestData.TEXT_ZIP1, TestData.TEXT_CTY1);
WinRTCompV2.IAddress addressV3 =
new WinRTCompV3.Address(TestData.TEXT_STR2, TestData.TEXT_ZIP2, TestData.TEXT_CTY2);
WinRTCompV2.ICitizen citizenV2 =
new WinRTCompV2.Citizen(TestData.TEXT_NAME1, TestData.TEXT_SURNAME1);
WinRTCompV2.ICitizen citizenV3 =
new WinRTCompV3.Citizen(TestData.TEXT_NAME2, TestData.TEXT_SURNAME2);
citizenV2.Address = addressV2;
addressV2.ZipCode = TestData.TEXT_ZIP2;
string zip2 = citizenV2.Address.ZipCode;
Assert.AreEqual(TestData.TEXT_ZIP2, zip2);
citizenV3.Address = addressV3;
addressV3.ZipCode = TestData.TEXT_ZIP1;
string zip3 = citizenV3.Address.ZipCode;
Assert.AreEqual(TestData.TEXT_ZIP1, zip3);
// Mixing Address V3 with Citizen V2
citizenV2.Address = addressV3;
addressV3.Street = TestData.TEXT_STR1;
// The Street property has been changed AFTER the Address V3 was used for the Citizen V2
string street2 = citizenV2.Address.Street;
// Because it was not possible to create the association at the composition classes level,
// the Street value in Citizen.Address is not updated.
Assert.IsFalse(TestData.TEXT_STR1 == citizenV2.Address.Street);
// However it is possible to mix V2 with V3 because in the V3 implementation the association
// is between WinRT instances only
citizenV3.Address = addressV2;br> addressV2.City = TestData.TEXT_CTY2;
string city3 = citizenV3.Address.City;
Assert.IsTrue(TestData.TEXT_CTY2 == citizenV3.Address.City);
}
我不得不使用 IsTrue
和 IsFalse
,原因不明,尽管字符串相同,AreEqual
返回 false 进行此测试。
IAddress
属性(使用组合类)时。因为属性的实现未能获取 Address
WinRT 组件的 CAddress
对象,所以它无法与该对象创建关联。一个新类型的正确 Address
引用在内部创建,并在组合类中建立关联,这就是为什么在设置到 Citizen
后在 Address
中更新的 Street
在内部创建的 Citizen
的 Address
中不显示的原因。
在第二种情况下,使用 Address
V2 和 Citizen
V3 可以正常工作,因为关联直接在 COM 实例之间,并且单个 Address
V2 对象工作正常。
在同一个 DLL 中实现第二套组件中的接口
我最初尝试在同一个 DLL 中使用不同的命名空间实现第二套组件,但具有相同的实现类名。此方法失败,我不得不创建两个独立的 DLL 来实现此目标。我有点沮丧无法做到,所以我尝试思考是否有办法做到。在 COM 的原始实现中,您可以在同一个 DLL 中创建许多组件,这些组件可以继承自同一个接口,我做过很多次。其中一个不同之处在于,组件和接口由 GUID 标识,并且不使用命名空间。这就是我找到解决方案的方法:如果您想在同一个 DLL 中创建同一个接口的多个实现,您需要将接口和组件放在**同一个**命名空间中。
#pragma once
#pragma warning( disable: 4251 )
#include "../CompRTV2/CompRTV2.h"
#include <string>
using namespace Platform;
namespace WinRTCompV2
{
namespace WUXD = Windows::UI::Xaml::Data;
[WUXD::Bindable]
public ref class Address2 sealed : IAddress
{
private:
std::wstring m_street;
std::wstring m_zipCode;
std::wstring m_city;
public:
Address2(void);
Address2(String^ street, String^ zip, String^ city);
public:
virtual property String^ Street {
String^ get();
void set(String^ value);
}
virtual property String^ ZipCode {
String^ get();
void set(String^ value);
}
virtual property String^ City {
String^ get();
void set(String^ value);
}
virtual bool CanSave();
};
}
这是实现 IAddress
接口的 Address2
类的声明代码。由于这只是一个示例,我使用了与另一个 DLL 中的实现相同的实现。
我还修复了 WinRTCompV2::Citizen
的 set 方法中 catch((InvalidCastException^ ex)
的一个 bug。新代码如下:
try
{
refAddress = (WinRTCompV2::Address^) value;
}
catch(InvalidCastException^ ex)
{
std::wstring msg = L"Address::set raised exception: ";
msg.append(ex->Message->Data());
OutputDebugString(msg.c_str());
}
WinRT 在实现多个组件的接口时遵循以下规则:
- 在一个 DLL 中,接口及其实现必须出现在同一个命名空间中。
- 如果一个接口由不同命名空间的组件实现,那么每个命名空间必须使用一个 DLL。
关注点
我希望本文及其附带的代码能帮助您理解实现 WinRT 组件和使用它们创建软件体系结构的挑战。
总而言之,我认为,尽管组合模式在某些情况下可能有助于减小代码占用空间,但它也引入了必须考虑的复杂性和风险。在我这个例子中,实际上,直接的 COM 类实现更小、更安全,因为没有代码重复,而使用动态对象组合来实现 Person
对 Citizen
的继承是有效的。
在 WinRT/COM 等组件技术中,您不能覆盖方法或属性,也不能访问您封装在另一个组件中的 COM 实现的受保护或内部成员,即使它是您基本接口的实现。但是您可以继承行为,并且可以创建方法的新版本。这项技术存在明显的局限性,但也有许多优点。
我的背景是电子学。想象一下,您使用一些晶体管来构建另一个组件。您不能拿出晶体管的一个引脚并用一个新引脚替换它,但您可以使用该引脚上的信号,对其进行处理,并将组合的结果作为具有自身特性的新组件公开。这就是 WinRT/COM 组件的作用。您可以将它们组装起来创建新组件或完整的系统。
就像晶体管一样,只要实现尊重其接口所描述的合同,您就可以用另一个实现替换一个实现。