worldsite / blog.sc

Blogging soul chat, stay cool. via: https://blog.sc
3 stars 0 forks source link

Win32 C++项目移植到UWP #49

Open suhao opened 1 year ago

suhao commented 1 year ago

概述

场景:将现有Win32平台Dll移植到UWP平台,供采用C#开发的Win Phone APP使用,而该DLL还依赖其他C++静态、动态库。

在Visual Studio中新建项目,模板选择Visual C++/Windows/通用页面,包含如下几个工程类型:

其中Dll和静态库可以被空白应用和运行时组件使用,并且是语言相关的,不能跨语言调用。运行时组件可以被空白应用使用,与语言无关,不管是C++还是C#应用均可调用。我们可以通过如下步骤来实现Win32到UWP的移植:

  1. 工程转换
  2. 编译问题
  3. 磁盘操作
  4. 数据类型转换
  5. 接口封装处理

请下载Universal Windows Platform (UWP) app samples,参考微软官方方案的示例。

一、工程转换

win32工程依赖关系为:app 依赖a.dll,a.dll 链接c++.lib,a.dll 依赖c.dll。

整体的工程关系转换为:a.dll -> a_rt.lib,c++.lib -> c++_rt.lib,c.dll -> c_rt.lib。其中通用Window版组件加_rt以作区分,Windows 运行时组件(通用 Windows)外壳定义为 shell_rt.dll。

新的依赖关系:app 依赖shell_rt.dll,shell_rt.dll 链接a_rt.lib、c++_rt.lib、c_rt.lib, 并且shell_rt.dll 负责重新封装a.dll的接口。app 可由 C++ 或 C# 开发。

注意: 创建 Windows 运行时组件(通用 Windows) 工程时,必须保证工程内的最外层命名空间名字和最终生成的dll名字(包括winmd文件)完全一致,这也是官方的要求。

通过阅读 官方文档 得知在不重新创建工程的情况下将现有工程转换为UWP工程的方法,如下:

  1. 打开DLL项目的项目属性

  2. 配置改为所有配置

  3. 打开C/C++常规选项,将使用Windows运行时扩展设置为是(/Zw),这将启用组件扩展(C++/CX);注意此步骤只能用于C++项目,C语言项目需将此设置为否;如果C++项目中包含C文件,可以单独将C文件设置为否

  4. 解决方案资源管理器中,选择项目节点,打开快捷菜单,然后选择重定向SDK版本目标,点击确定

  5. 解决方案资源管理器中,选择项目节点,打开快捷菜单,然后选择卸载项目

  6. 解决方案资源管理器中,选择卸载的项目节点,然后选择编辑项目文件,找到WindowsTargetPlatformVersion元素并将其替换为如下元素,然后关闭vcxproj文件,然后选择重新加载项目

    <AppContainerApplication>true</AppContainerApplication>
    <ApplicationType>Windows Store</ApplicationType>
    <WindowsTargetPlatformVersion>10.0.10156.0</WindowsTargetPlatformVersion>
    <WindowsTargetPlatformMinVersion>10.0.10156.0</WindowsTargetPlatformMinVersion>
    <ApplicationTypeRevision>10.0</ApplicationTypeRevision>

经过以上步骤处理,项目将被标识为通用Windows项目。

总结来讲,就是修改平台工具集、使用Windows运行扩展、运行库。

二、编译问题

工程转换完成后,需要处理编译问题。由于编译问题各式各样,哪里有问题就改哪里。

例如可能需要增加如下宏定义,以解决环境问题:

#ifdef _M_ARM
    #define WINAPI_FAMILY WINAPI_FAMILY_PHONE_APP
#endif

三、磁盘操作

UWP 不支持fopen,CreateFile此类操作。用来替换的是CreateFile2,用法和CreateFile类似。但该API只能处理特殊目录,例如程序安装目录、图片、文档、视频等。对于磁盘中任意的目录,都没有操作权限。因此,对于期望可操作任意目录文件的需求,只能放弃使用CreateFile2,改用以下UWP组件中的磁盘操作类:

其它相关类请在类名上按F12打开对象浏览器查看。

看过类里的函数之后可以发现大部分函数都有Asyn后缀,带Asyn后缀的函数均为异步函数,Windows不希望UI线程及其它某些线程因为同步调用导致响应迟钝。C++中异步函数的调用方式大致为:

// 使用 task 和以下命名空间中的类时需开启 /ZW 选项,即开启 C++/CX 支持
#include <collection.h>
#include <ppltasks.h>

using namespace concurrency;
using namespace Platform;
using namespace Windows::Storage;
using namespace Windows::Storage::Streams;

// 从一个文件对象获取其目录对象

void Test(StorageFile ^file)
{
    create_task(file->GetParentAsync()).then([this, file](StorageFolder ^parentFolder)
    {
        if(parentFolder != nullptr)
        {
            // do something
        }
    });
}

线程A调用Test函数,通过create_task创建一个task对象,并将一个lamda函数(位于then()中,[this, file]中声明的变量可在函数中使用)作为委托传递给task对象的then方法,并继续向下执行并退出Test函数。task中的file->GetParentAsyn()操作实际由线程B调用,待函数返回后,再将结果交由线程A执行委托函数。

^ 这个符号读作hat,这里用来声明句柄对象。String ^str;这里的str就是一个String的句柄类型,初始值或无对象指向时为nullptr,释放时可以使用delete str 也可以让作用域控制自动释放。可以简单的理解为类似智能指针。

异步方法虽然可以避免对线程A的阻塞,但实际使用中并不方便。因为,大部分情况下,我们都会为耗时的网络或磁盘操作专门开启线程处理,而不是直接使用UI线程操作。因此如果都使用这种异步方式,在某些场景下,代码会写的很反人类,例如下面这个比较完整的文件读取操作:

// 头文件、命名空间省略,变量判断、异常处理省略

void ReadBytesFromFile(String ^strFilePath)
{
    // 根据文件路径获取文件对象;
    create_task(StorageFile::GetFileFromPathAsync(strFilePath)).then([](StorageFile ^file)
    {
        if (file != nullptr)
        {
            // 以读写的方式打开文件;
            create_task(file->OpenAsync(FileAccessMode::ReadWrite)).then([](IRandomAccessStream ^stream)
            {
                if (stream != nullptr)
                {
                    auto buf = ref new Buffer(10); // 读取10个字节;
                    create_task(stream->ReadAsync(buf, 10, InputStreamOptions::None)).then([buf](IBuffer ^buffer)
                    {
                        // buf 和 buffer 中包含读取到的数据;
                    });
                }
            });
        }
    });
}

昔日Win32的一个CreateFile操作,在这里变的无比繁琐。而且,上面传入一个String路径打开文件的方式因为权限文件,并不可行。

在系统中,除几个个别目录(安装目录、图片目录、视频目录等)在app配置权限后可用于直接操作权限外,app是无法直接使用任意字符串路径进行文件操作的。正确的方式应该是:

  1. 使用FolderPicker或FilePicker获取一个StorageFolder或StorageFile对象
  2. 将对象加入到权限列表中 AccessCache::StorageApplicationPermissions::FutureAccessList->Add(file);
  3. 如果多模块间传递的是String类型,此时可以从StorageFolder或StorageFile对象的Path属性获取String类型路径字符串,之后可以使用该路径字符串转换(见数据类型间的转换)为StorageFolder或StorageFile对象,此时权限仍旧有效。

如果需要在某目录下新建文件,则应该使用FolderPicker获取StorageFolder对象,将对象加入权限列表,再使用该StorageFolder对象创建文件。

考虑到在做代码移植时,调整某些线程的同异步模式将会导致原有框架结构变的混乱,因此,出现了下面的用法:

// 将file文件中偏移5开始的10个字符写入到偏移2开始的位置;
auto taskOpen = create_task(file->OpenAsync(FileAccessMode::ReadWrite));
if(taskOpen.wait() == canceled)
    return false;
IRandomAccessStream ^stream = taskOpen.get();
stream->Seek(5);
auto buffer = ref new Buffer(10);
auto taskRead = create_task(stream->ReadAsync(buffer, 10, InputStreamOptions::None));
if(taskRead.wait() == canceled)
    return false;

auto data = ref new Array<byte>(buffer->Length);
auto reader = DataReader::FromBuffer(buffer);
reader->ReadBytes(data);

stream->Seek(2);
auto taskWrite = create_task(stream->WriteAsync(buffer));
if(taskWrite.wait() == canceled)
    return false;
auto taskFlush = create_task(stream->FlushAsync());
if(taskFlush.wait() == canceled)
    return false;

这种 task.wait() 的调用方式并不能应用到所有线程。参见ppltask.h文件的task_status _Wait();函数及其中的_IsNonBlockingThread函数内部实现。请自行调试实验各类线程调用wait()中的_IsNonBlockingThread函数时的返回情况。 (经过验证,以上这种写文件的写法效率较低,在频繁调用时尤为明显,包括前面列出的对GetFileFromPathAsync 的调用)

四、数据类型转换

Platform::Array<unsigned char> ^UnsignedChar2Array(unsigned char *pBuffer, unsigned int uSize)
{
    return ref new Platform::Array<unsigned char>(pBuffer, 10);
}

std::wstring PlatformString2StdWstring(Platform::String ^str)
{
    return std::wstring(str->Data());
}

std::string Unicode2Utf8(Platform::String ^str)
{
    std::wstring wstrTemp(str->Data());

    std::string strUtf8;
    int iUtf8Len = ::WideCharToMultiByte(CP_UTF8, 0, wstrTemp.c_str(), wstrTemp.length(), NULL, 0, NULL, NULL);
    if (0 == iUtf8Len)
        return "";

    char* pBuf = new char[iUtf8Len + 1];
    memset(pBuf, 0, iUtf8Len + 1);
    ::WideCharToMultiByte(CP_UTF8, 0, wstrTemp.c_str(), wstrTemp.length(), pBuf, iUtf8Len, NULL, NULL);

    strUtf8 = pBuf;
    delete[] pBuf;

    return strUtf8;
}

using namespace Windows::Storage::Streams;
IBuffer ^UnsignedChar2Buffer(unsigned char *pBuffer, unsigned int uSize)
{
    DataWriter writer;
    writer.WriteBytes(Platform::ArrayReference<uint8>(pBuffer, uSize));
    return writer.DetachBuffer();
}

void Buffer2UnsignedChar(IBuffer ^buffer, unsigned char **pBuffer, unsigned int *uSize)
{
    DataReader ^reader = DataReader::FromBuffer(buffer);
    *uSize = buffer->Length;
    *pBuffer = new uint8[*uSize];
    reader->ReadBytes(Platform::ArrayReference<uint8>(*pBuffer, *uSize));
}

五、接口封装处理

UWP 组件的接口不同于Win32 DLL 的导出接口,UWP 的接口是一个winmd 文件,包含语言无关类型信息MetaData(元数据)。使用组件时只需要 xxx.dll 和 xxx.winmd 两个文件,不需要头文件。

在导出接口时,首先需要最外层有一个和库文件名相同的命名空间名,导出的类需要声明成如下格式(需带public ref sealed声明 ):

namespace test // 组件名为test.dll
{
    public ref class CInterface sealed
    {
    }
}

因为接口可能被跨语言使用,因此下面这种接口参数的写法就要避免:

void Func(Platform::String ^*pStr);

这种写法只能被C++使用,如果C#调用的话,会出现崩溃。不过,Platform::Array^ 这种写法倒是没有问题。int、int 诸如此类,都是可以的,int *对于C# 的调用,使用out进行修饰。

// C++方式的接口导出函数声明
void Func1(int *pParam);
void Func2(const Platform::Array<unsigned char>^ inArray);
void Func3(Platform::Array<unsigned char>^ *outArray);

// C#看到的接口声明
// void Func1(out int pParam);
// void Func2(byte[] inArray);
// void Func3(out byte[] outArray);

// C#方式的接口调用
Int32 param = 0;
Func1(out param);

byte[] inArray;
Func2(inArray);

byte[] outArray;
Func3(out outArray);

如果接口需要传递回调函数,需要封装成类,可以从接口导出一个interface 修饰的类:

namespace test
{
    public interface ICallback
    {
    public:
        virtual void func() = 0;
    }

    public ref class CInterface sealed
    {
        void RegCallback(ICallback ^callback)
        {
            // 对于callback我做了一层回调封装映射,由此处的ICallback ^ 类型与内部原有的C++ 回调形成映射关系(中间过渡)
            // 避免C++/CX 语法深入内部
        }
    }
}

// C# 使用时
class CCallback : test.ICallback
{
    public void func()
    {
        // do something
    }
}

CCallback callback = new CCallback();
test.CInterface inter = new test.CInterface();
inter.RegCallback(callback);

六、调研与实战

我们可以将第三方库转为UWP模块项目,工程文件由原Win32的工程文件转换得来。skia项目除外,skia是由gn构建工具生成的。生成工程编译后,我们可以创建一个UWP的demo来调用测试。

  1. google base库的MessagePump移植

在传统的Win32程序中,窗口和消息循环是分割的,我们可以使用 CreateWindow 来创建多个窗口,而多个窗口是共用一个消息循环,由于消息循环的独立性,我们的消息循环的代码通常像下面这样:

MSG msg = { 0 };
while (::GetMessage(&msg, NULL, 0, 0)) {
    ::TranslateMessage(&msg);
    ::DispatchMessage(&msg);
}

即使没有Win32的窗口,消息循环也可以正常运行起来。同时,所有窗口共用的消息循环是运行在一个线程中的,也就是常说的主线程(UI线程)。而在UWP程序中,微软对窗口,消息循环进行了整合:

image

上图中,CoreApplication代表着整个UWP的进程,该UWP程序中,可以拥有多个CoreApplicationView,每一个CoreApplicationView是一个窗口和一个消息循环(消息分发)(CoreDispatcher)的结合,而且每一个CoreApplicationView独占一个线程。

也就是说,上图中的两个CoreApplicationView分别跑在不同的线程当中。

因此,UWP程序中,窗口和消息循环是不可分割的,是由UWP框架决定的,我们无法变更。

故而,google base库的MessagePump的Win32的实现模式(消息循环独立),在UWP中将不可复用。

参考SDL2对UWP的支持模式,对于MessagePump,初步设想为:MessagePump使用 CoreApplication.run 来实现,意味着必然会创建一个窗口,对于执行顺序问题,参考SDL2的处理方式,以回调函数的方式把创建窗口的操作延后。

  1. skia的最小绘制单元

skia目前找到的资料以及示例,Dx绘制的最小单元是一个CoreWindow。

  1. Win32的窗口

UWP的窗口是CoreWindow,但是不支持子窗口,目前未知如何将一个CoreWindow设置为另外一个CoreWindow的子窗口方法。通过上边讲述的CoreWindow对应一个消息循环,所以子窗口在UWP是不存在的,不然我们的消息循环就没有问题了。

另外,CoreWindow不支持窗口的标题隐藏,在自绘窗口的情况下,会有移植问题;并且多个CoreWindow会在任务栏显示多个图标,不符合Win32的窗口视觉设计。

所以对Win32的窗口,移植到UWP应该是一个控件,例如为image。但是skia最小绘制单元为CoreWindow,不能对单独一个控件进行画面更新提交,可以通过Dx接口获取到刷新完成后的画面,然后使用UWP方法将图片刷新到image控件中。

UWP原生菜单不支持超出窗口大小,这种情况和Win32的菜单窗口行为是不同的,需要特殊注意。

  1. 事件响应

UWP项目中,xaml后缀问题提供了大量的工具箱控件,但是并不能支持win32的所有事件。需要使用AddHandler来为控件添加事件响应。

例如:Button本来是监听不到鼠标左键的按下和抬起事件。哪怕你监听PointerPressedEvent事件,并在相应的事件处理函数中判断左键也不行,但是用AddHandler函数把PointerPressedEvent再添加一遍就可以监听到。

  1. UWP项目添加skin资源目录

skin文件夹必须在uwp项目所在文件夹内,skin下所有文件必须添加到vs项目中,而且必须作为内容,不然appx包里就不会有当前文件。

image

  1. UWP项目添加依赖库

如果所依赖的库在当前工程中,只需要右击引用添加即可;如果不在工程中,将依赖库以现有项的方式添加到工程中,并将属性页的文件内容改为是。

  1. 应用程序项目配置

开发的前提是dll中不使用windows10已弃用的API,应该程序所需要的所有dll都需要放在appx目录下,debug模式默认目录下,除了configuration\debug\c++uwp_called_win32dll\appx里面有一个exe,在configuration\Debug\c++uwp_called_win32dll下也有一个exe