[Flutter翻译]Dart FFI的Windows乐趣

2,610 阅读12分钟

对Flutter和Dart的原生Win32 API支持。

原文地址:medium.com/@timsneath/…

原文作者:medium.com/@timsneath?…

发布地址:2020年9月23日 - 8分钟阅读

作为一个开发者框架和编程语言的产品经理,在工作日里要抽出时间来写代码并不容易。但我认为这是一项重要的任务,以便对客户的需求感同身受。所以我在这里和那里涉猎各种能引起我兴趣的项目;在过去的几个月里,我一直在探索一个项目,这个项目结合了我在Windows上多年的工作经验和我目前对FlutterDart的关注,最终形成了一个包,包装了Windows API的很大一部分,供Dart和Flutter应用消费。但这个历程本身也是一个相当有趣的故事。

小步快跑。控制台API

这一切都要从一个小小的文本编辑器说起。通过HackerNews,我看到了Kilo,一个用不到1000行C语言编写的UNIX风格的终端文本编辑器,还有一个写得非常好的教程,你可以跟着一起从头开始构建它。我决定试一试,但我一边走一边把代码移植到Dart上。这真是太有趣了。

有什么不好玩的呢?VT逃逸序列,这就是Kilo如何操纵控制台进行光标移动、清除屏幕和在屏幕更新时隐藏光标等操作。这个现代Mac和Linux终端中的老古董,在没有重大现代化改造的情况下,已经生存了四十多年。在一个了不起的复古作品中,它甚至在最近几年被引入到Windows终端中。在这个过程中,我忘了是什么时候,我开始把VT控制台的一些命令保理成一个单独的包,这样我就可以写一些连贯的东西,比如console.hideCursor(),而不是stdout.write('\x1b[?25l')

完成教程后,我有了dart_kilo,一个简单的文本编辑器,它可以在macOS或Linux上运行,就像原来的一样,但只有大约500行代码,这要归功于我单独的轻量级控制台库。

但后来我开始想--我能不能把我的Dart版本的kilo移植到Windows上?大部分代码都能正常工作,至少在现代Windows终端上是这样,除了几个POSIX系统调用来获取控制台窗口尺寸将终端设置原始模式,这两个都需要转换。

介绍FFI

Dart包含了dart:ffi,一个用于对C风格API进行外函数接口调用的库。使用FFI,你可以为一个基于C的API声明一个原型,并从你的Dart代码中调用它。举个例子,Win32 API函数SetConsoleCursorPosition可以用以下几行Dart代码来调用。

// C signature for Win32 API in kernel32.dll:
// BOOL WINAPI SetConsoleCursorPosition(
//   _In_ HANDLE hConsoleOutput,
//   _In_ COORD  dwCursorPosition
// );

typedef nativePrototype = Int32 Function(
    IntPtr hConsoleOutput, Int32 dwCursorPos);
typedef dartPrototype = int Function(int hConsoleOutput, int dwCursorPos);

final kernel32 = DynamicLibrary.open('kernel32.dll');
final SetConsoleCursorPosition = kernel32
    .lookupFunction<nativePrototype, dartPrototype>('SetConsoleCursorPosition');

// outputHandle is STDOUT (not shown here)
SetConsoleCursorPosition(outputHandle, 0);

现在我所要做的就是创建这些POSIX系统调用的Win32等价物,然后Kilo编辑器就可以在我的Windows笔记本上运行,而不需要对编辑器本身做任何改动。

kilo.dart。一个运行在Windows、macOS和Linux上的约500行代码的控制台文本编辑器。

从控制台到图形用户界面

在dart_console中,ANSI扩展了256色支持。

有一段时间,我继续探索Win32控制台API,并逐渐将更多的API封装在我的dart_console包中。Dart有一套比较基本的控制台功能,所以我逐渐建立了一个功能相当丰富的包,提供了颜色选择、光标操作、REPL式的命令输入和控制键处理。通过将接口和实现分离,我能够添加在Windows和UNIX类终端上都支持的功能,甚至包括不支持VT转义序列的Windows 7控制台应用。

但我很想知道我是否能走得更远。毕竟任何自尊的语言都应该能够调用Win32 MessageBox函数。

final result = MessageBox(
    NULL,
    TEXT("This is one of those messages that nobody will ever read. They'll "
        "just click the default button, and about a millisecond later, "
        "they'll experience a pang of concern as they wonder whether Windows "
        "was giving them one last opportunity to perform a critical task "
        "before all their files were wiped.\n\nWhat even is the difference "
        "between Cancel, Try Again and Continue?"),
    TEXT('Critical system message'),
    MB_ICONEXCLAMATION | // Warning
        MB_CANCELTRYCONTINUE | // Action button
        MB_DEFBUTTON2 // Second button is the default
    );

然后我开始思考Windows应用程序的必要条件 Charles Petzold在他的《Windows编程》一书中提出的hello.c Hello World程序。能否在不需要Visual Studio或Windows SDK的情况下将其引入Dart?

从表面上看,hello.c是一个更复杂的挑战:它需要在堆上分配结构,回调函数,一个WinMain()条目,以及大约20个Win32调用。当时,我还没有真正理解dart:fi中的结构,也没有完全掌握各种Dart基元和它们的C等价物之间的转换。简而言之,我并不期待成功。

所以在经历了大量的尝试和错误之后,当Windows显示出这个小小的但令人激动的窗口时,我有些惊呆了。

有史以来最没有想象力的应用程序。

但最酷的是,对于任何一个写传统Windows代码的人来说,Dart的代码和原来的代码是多么的相似,尽管它有一个更宽容的、强类型语言的优势支持。

// Basic Petzoldian "hello world" Win32 app

import 'dart:ffi';
import 'package:ffi/ffi.dart';

import 'package:win32/win32.dart';

final hInstance = GetModuleHandle(nullptr);

int mainWindowProc(int hWnd, int uMsg, int wParam, int lParam) {
  switch (uMsg) {
    case WM_DESTROY:
      PostQuitMessage(0);
      return 0;

    case WM_PAINT:
      final ps = PAINTSTRUCT.allocate();
      final hdc = BeginPaint(hWnd, ps.addressOf);
      final rect = RECT.allocate();
      final msg = TEXT('Hello, Dart!');

      GetClientRect(hWnd, rect.addressOf);
      DrawText(
          hdc, msg, -1, rect.addressOf, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
      EndPaint(hWnd, ps.addressOf);

      free(ps.addressOf);
      free(rect.addressOf);
      free(msg);

      return 0;
  }
  return DefWindowProc(hWnd, uMsg, wParam, lParam);
}

void main() {
  // Register the window class.

  final className = TEXT('Sample Window Class');

  final wc = WNDCLASS.allocate();
  wc.style = CS_HREDRAW | CS_VREDRAW;
  wc.lpfnWndProc = Pointer.fromFunction<WindowProc>(mainWindowProc, 0);
  wc.hInstance = hInstance;
  wc.lpszClassName = className;
  wc.hCursor = LoadCursor(NULL, IDC_ARROW);
  wc.hbrBackground = GetStockObject(WHITE_BRUSH);
  RegisterClass(wc.addressOf);

  // Create the window
  final hWnd = CreateWindowEx(
      0, // Optional window styles.
      className, // Window class
      TEXT('Dart Native Win32 window'), // Window caption
      WS_OVERLAPPEDWINDOW, // Window style

      // Size and position
      CW_USEDEFAULT,
      CW_USEDEFAULT,
      CW_USEDEFAULT,
      CW_USEDEFAULT,
      NULL, // Parent window
      NULL, // Menu
      hInstance, // Instance handle
      nullptr // Additional application data
      );

  if (hWnd == 0) {
    final error = GetLastError();
    throw WindowsException(HRESULT_FROM_WIN32(error));
  }

  ShowWindow(hWnd, SW_SHOWNORMAL);
  UpdateWindow(hWnd);

  // Run the message loop
  final msg = MSG.allocate();
  while (GetMessage(msg.addressOf, NULL, 0, 0) != 0) {
    TranslateMessage(msg.addressOf);
    DispatchMessage(msg.addressOf);
  }
}

从这里开始,我开始对它的潜力感到兴奋。我为Win32 APIs创建了一个单独的包,并开始包装更多的API。我开始寻找其他小型的Windows C应用程序,并将它们移植到Dart中。

比如,这里的俄罗斯方块

这个世界需要另一个俄罗斯方块的实现。这一次,是在Dart中。

(再次感谢Charles Petzold),一个记事本的实现,包括菜单、快捷键、查找/替换和字体选择。

大多数程序员都有一个文本编辑器。显然我有两个。

COM和Dart

现在我越来越有信心了。在用Dart构建(或翻译)这些更高级的应用程序时,我已经封装了一百多个Win32 API,包括一些更艰苦的工作,以实现使用它们所需的各种常量和结构,添加尽可能多的测试,并开始为我为dart包管理器pub.dev发布的雏形包构建文档。

但我开始遇到一些限制:特别是,最近的Win32 API经常使用基于类的COM模型,这带来了一系列不同的挑战。我想这可能是我的玻璃天花板,因为Dart中基于C的互操作库与COM的C++假设并不能很好地融合。但后来我发现了一篇可以追溯到2006年的CodeProject文章,其中描述了如何从普通的C语言中调用COM组件,所以我决定试一试。

从C语言调用COM是很丑陋的。一个COM组件支持一个或多个接口,如IUnknownIFileDialog,它们通过这些接口向调用的应用程序暴露方法。方法本身的地址存储在一个虚拟函数表中,而对象的细节则以一种相当痛苦的格式存储,称为MIDL(或微软接口定义语言)。

我曾经知道一些关于COM的知识,在我年轻的时候,但到现在所有存放这些知识的脑细胞早已萎缩。所有关于COM的好书都是二十年前写的,(他们以附带光盘为自豪点做广告,因为当时互联网还远未普及)。令我感到好笑的是,我发现自己在网上的二手书店里搜罗,试图为我在世纪之交送走的书目找回替代品。如果你在2000年告诉我,我在2020年还能找到这些书,我会笑或哭。

我的调试方法,可视化。

我花了几个月--我是说几个月--试图让这个该死的东西工作起来。理论上我明白我需要做什么:初始化COM并创建一个类的实例,在vtable中找到我需要的方法,将该方法的地址映射到我创建的Dart原型,然后调用结果。但我当时的提交展示了一连串失败的尝试,无休止的print语句和在Dart和C中徒劳的探索,试图找出我做错了什么。我把它放起来几个星期,然后再去做别的事情,然后再回来犯同样的错误,希望得到不同的答案。亲爱的读者:我的编码相当于在迷宫中随意走动,希望与希望相反的是,我的布朗运动最终会把我引向出口。

然后,突然,天亮了! 我第三次发现了一个指针脱引用的bug,但在这次,阻碍我取得进展的另外两个bug已经被克服了。我的代码很糟糕,但对于我所需要的单一方法来说,还是很实用的。

我的第一个COM成功:用IFileDialogOpen打开现代Windows文件对话框。

有一个问题仍然存在:我在Win32中采用的手动方法在COM中是不可行的。仅IFileDialog接口就有23个方法,这还不算我需要实现的30多个其他方法,以实现其他相关接口。显然,我需要一个不同的方法。所以我开始对Windows SDK中的一些头元数据进行蛮力解析,允许我读取这样的文件。

#include "windows.h"
#include "Shobjidl.h"

// vtable_start 4
MIDL_INTERFACE("42f85136-db7e-439c-85f1-e4075d135fc8")
IFileDialog : public IModalWindow
{
public:
    virtual HRESULT STDMETHODCALLTYPE SetFileTypes( 
        /* [in] */ UINT cFileTypes,
        /* [size_is][in] */ __RPC__in_ecount_full(cFileTypes) const COMDLG_FILTERSPEC *rgFilterSpec) = 0;
    
    virtual HRESULT STDMETHODCALLTYPE SetFileTypeIndex( 
        /* [in] */ UINT iFileType) = 0;
    
    virtual HRESULT STDMETHODCALLTYPE GetFileTypeIndex( 
        /* [out] */ __RPC__out UINT *piFileType) = 0;
    
    virtual HRESULT STDMETHODCALLTYPE Advise( 
        /* [in] */ __RPC__in_opt IFileDialogEvents *pfde,
        /* [out] */ __RPC__out DWORD *pdwCookie) = 0;

    // ...
  
}

并将其转换为这样的文件

import 'dart:ffi';
import 'package:ffi/ffi.dart';

// more imports

import 'IModalWindow.dart';

/// @nodoc
const IID_IFileDialog = '{42f85136-db7e-439c-85f1-e4075d135fc8}';

typedef _SetFileTypes_Native = Int32 Function(
    Pointer obj, Uint32 cFileTypes, Pointer<COMDLG_FILTERSPEC> rgFilterSpec);
typedef _SetFileTypes_Dart = int Function(
    Pointer obj, int cFileTypes, Pointer<COMDLG_FILTERSPEC> rgFilterSpec);

typedef _SetFileTypeIndex_Native = Int32 Function(
    Pointer obj, Uint32 iFileType);
typedef _SetFileTypeIndex_Dart = int Function(Pointer obj, int iFileType);

//...

/// {@category Interface}
/// {@category com}
class IFileDialog extends IModalWindow {
  // vtable begins at 4, ends at 26

  IFileDialog(Pointer<COMObject> ptr) : super(ptr);

  int SetFileTypes(int cFileTypes, Pointer<COMDLG_FILTERSPEC> rgFilterSpec) =>
      Pointer<NativeFunction<_SetFileTypes_Native>>.fromAddress(
                  ptr.ref.vtable.elementAt(4).value)
              .asFunction<_SetFileTypes_Dart>()(
          ptr.ref.lpVtbl, cFileTypes, rgFilterSpec);

  int SetFileTypeIndex(int iFileType) =>
      Pointer<NativeFunction<_SetFileTypeIndex_Native>>.fromAddress(
              ptr.ref.vtable.elementAt(5).value)
          .asFunction<_SetFileTypeIndex_Dart>()(ptr.ref.lpVtbl, iFileType);
          
  // ...
  
}

这可能是一些有史以来最不吸引人的Dart代码(至少在美学上),但这没关系。它是机器生成的,忠实于原始的COM接口,而且不太可能需要仔细检查。有了一个轻量级的封装器来屏蔽非指令性的代码,你现在可以写出这样的东西。

import 'package:filepicker_windows/filepicker_windows.dart';

void main() {
  final file = OpenFilePicker()
    ..filterSpecification = {
      'Word Document (*.doc)': '*.doc',
      'Web Page (*.htm; *.html)': '*.htm;*.html',
      'Text Document (*.txt)': '*.txt',
      'All Files': '*.*'
    }
    ..defaultExtension = 'doc'
    ..title = 'Please select a document';

  final result = file.getFile();
  if (result != null) {
    print(result.path);
  }
}

现在我可以开始构建其他依赖于COM APIs的包了,比如filepicker_windows。甚至可以写出简单的Windows实用程序,将Flutter UI与Win32 API结合起来

用Flutter编写的一个基本的Windows专用应用程序,可以从用户选择的文件中设置桌面背景。非常无用,但却是Flutter和Win32 API和谐工作的良好端到端测试。

Win32包

在过去的几个月里,我一直在逐步完善和改进这个软件包,构建出样本并添加文档。现在,该软件包支持数百个API,以及各种各样的COM API,代码生成器完成了大部分繁重的工作。最近,我一直在努力为UWP应用中使用的最新Windows Runtime APIs提供一个预测,这开启了一些其他有趣的可能性。但这是另一个时间的故事。

为社区贡献这样一个包的最令人欣慰的方面之一就是让其他人提出问题,依赖你的代码,甚至提交拉请求。我很高兴看到像 Tomek Polanski 这样的人用它来做他的 fast_flutter_driver 测试线束,并和其他人一起为像 biometric_storage 这样的包添加新的 API。我们现在甚至还在用它来实现Windows的Flutter包,比如path_provider

而作为一个产品经理,这整个经历也不断完善和提高了我对我们产品给别人感觉的理解。有一些以前只是学术性的摩擦点,现在已经可以直观地感受到了;我发现了一些新的bug(并针对这些bug提交了问题);并提交了许多文档拉取请求,希望可以为后续的人减轻负担。同时,产品经理需要避免过于强调自己的直接观察,这是个极具诱惑力的陷阱--我们是为客户构建的,用数据驱动的洞察力和客户反馈来调和个人体验至关重要。

Win32现在可以在pub.dev上使用,如果你在自己的项目中使用它,我会感到很荣幸。

"路一直在走,一直在走... ...如果可以的话,我必须跟着走... ..."


通过www.DeepL.com/Translator (免费版)翻译