Flutter in Windows Desktop 工程实践

1,876 阅读19分钟

Flutter虽然在Windows桌面端发力,也发布了正式版。但是,其开发重心仍然在移动端上。和其他成熟的桌面端框架比起来,Flutter还像个小孩子,从设计理念和环境生态很多地方都还是一个小学僧的状态。

本文为笔者在Windows桌面端程序开发中实际的工程化经验汇总,包含单进程应用、托盘化、命令行参数、设置exe文件的应用信息、打包依赖库、ffi、硬编码数据安全等章节,欢迎留言讨论。

1. 实现单进程应用

在windows上单进程我们通常使用互斥量(Mutex)来实现单进程的判断,但是由于dart的基础库对于windows的支撑除了ffi几近于无。又由于dart是单线程模式,所以它社区有一个Mutex库,但是只是单线程内在异步场景使用的。所以,想要通过dart来实现标准的单进程程序在长期来看也是不可能的事情。

那么,我们将目光转向Flutter的runner层,其中 windows\runner\win32_window.cpp这个文件负责实现windows窗口的创建、显示和销毁等。

我们在其中Create方法中加入代码,完整函数代码如下:

#include "synchapi.h"

// others code

HANDLE hMutexHandle;
bool Win32Window::Create(const std::wstring& title,
const Point& origin,
const Size& size) {
    Destroy();

    hMutexHandle = CreateMutex(NULL, TRUE, L"mutex.my.app");

    if (hMutexHandle == NULL) {
        printf("CreateMutex error: %d\n", GetLastError());
        return false;
    }
    DWORD errorCode = GetLastError();
    if (errorCode == ERROR_INVALID_HANDLE) {
        printf("Mutex name be used.");
        return false;
    }
    else if (errorCode == ERROR_ALREADY_EXISTS) {
        printf("Mutex name already exists.");
        Destroy();

        // 这个FindWindowA的方法有个bug,
        // 如果打开了一个资源管理器的文件目录和应用同名,那么它可能会找到这个资源管理器。
        // HWND handle = FindWindowA(NULL, "My App");
        
        // 所以我们通过进程名来获取其主窗口的句柄
        // 因为flutter是单页应用只有一个窗口,所以只要获取窗口句柄不用区分就认为是主窗口
        // Get process id by process execute name
        DWORD dwProcessId = GetProcessIdByName(TEXT("PICStudio.exe"));
        if (dwProcessId == 0)
        {
            printf("Process not found!");
            return false;
        }

        // Get window handle by process id
        HWND handle = GetMainWindowHandle(dwProcessId);
        if (handle == NULL)
        {
            printf("Main window not found!");
            return false;
        }


        WINDOWPLACEMENT place = { sizeof(WINDOWPLACEMENT) };
        GetWindowPlacement(handle, &place);
        switch (place.showCmd)
        {
            case SW_SHOWMAXIMIZED:
                // 错误用法:ShowWindow(handle, SW_SHOWMAXIMIZED);
                SendMessage(handle, WM_SYSCOMMAND, SW_SHOWMAXIMIZED, NULL);
                break;
            case SW_SHOWMINIMIZED:
                SendMessage(handle, WM_SYSCOMMAND, SW_RESTORE, NULL);
                break;
            default:
                if (IsWindowVisible(handle))
                    SendMessage(handle, WM_SYSCOMMAND, SW_NORMAL, NULL);
                else 
                    SendMessage(handle, WM_SYSCOMMAND, SC_RESTORE, NULL);
                break;
        }
        
        SetForegroundWindow(handle);

        return false;
    }

    // others code
}

其中,通过CreateMutexwin32 api来创建有名称的全局互斥量。如果互斥量已经被人使用了,则GetLastError方法就会获取到ERROR_ALREADY_EXISTS值。

互斥量的命名请采用不容易重复的方式,如果和其他的全局事件、信号灯、可等待计时器、作业或文件映射对象的名称相同,则会创建失败,GetLastError方法就会获取到ERROR_INVALID_HANDLE值。

写好互斥量的逻辑判断后,如果出现互斥,那么说明已经启动了一个进程了,那么当前进程就不应该继续启动。通常做法就是直接唤出已经启动了的进程,我们通过GetProcessIdByName找到进程号,通过GetMainWindowHandle找到进程的主窗口句柄,这两个方法是自定义的,附在后面。然后调用GetWindowPlacement获取窗口位置,SendMessage向已启动的进程窗口发送窗口命令的消息通知,最后调用SetForegroundWindow将窗口设置到前台。

在窗口被隐藏(不是最小化)的情况下,窗口的状态为正常状态,通过判断窗口是否是Visible的,进而发送不同的消息让窗口恢复显示。

if (IsWindowVisible(handle))
    SendMessage(handle, WM_SYSCOMMAND, SW_NORMAL, NULL);
else 
    SendMessage(handle, WM_SYSCOMMAND, SC_RESTORE, NULL);
break;

🚨WARNING: 显示另外进程窗口这里有个很大的问题,笔者也是花了大量的时间找了很多资料。网上很多文章人云亦云,都说使用 ShowWindow() 就可以实现。但实际上 ShowWindow() 被设计来在当前进程使用,当其他线程调用这个方法时,在目标窗口最小化的情况下是可能不工作的,使用 SendMessage() 才是正规符合设计的用法。

参考网站:learn.microsoft.com/zh-cn/windo…

附上 GetProcessIdByNameGetMainWindowHandle 方法实现,需要自行在win32_window.h头文件中定义私有方法。

头文件 win32_window.h:

  struct EnumContext {
      DWORD pid;
      std::vector<HWND> result;
  };

  // Get process id by process execute name
  DWORD GetProcessIdByName(LPCTSTR szProcessName);

  // Get window handle by process id
  HWND GetMainWindowHandle(DWORD dwProcessId);

cpp文件 win32_window.cpp:

DWORD Win32Window::GetProcessIdByName(LPCTSTR szProcessName)
{
    DWORD dwProcessId = 0;
    HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if (hSnapshot == INVALID_HANDLE_VALUE)
        return 0;

    PROCESSENTRY32 process;
    process.dwSize = sizeof(PROCESSENTRY32);

    if (Process32First(hSnapshot, &process))
    {
        do
        {
            if (std::wstring(process.szExeFile) == szProcessName)
            {
                dwProcessId = process.th32ProcessID;
                break;
            }
        } while (Process32Next(hSnapshot, &process));
    }

    CloseHandle(hSnapshot);
    return dwProcessId;
}

HWND Win32Window::GetMainWindowHandle(DWORD processId)
{
    //std::vector<HWND> vHwnds;
    EnumContext ctx;
    ctx.pid = processId;

    // 枚举所有窗口句柄
    EnumWindows(
        [](HWND hwnd, LPARAM lParam) -> BOOL {
            EnumContext* ctx = (EnumContext*)lParam;

            DWORD dwProcessId = 0;
            GetWindowThreadProcessId(hwnd, &dwProcessId);

            if (dwProcessId == ctx->pid && IsWindowVisible(hwnd) && IsWindowEnabled(hwnd)) {
                ctx->result.push_back(hwnd);
                return FALSE;
            }

            return TRUE;
        },
        (LPARAM)&ctx
    );

    return ctx.result.empty() ? NULL : ctx.result[0];
}

2. 托盘化

桌面程序托盘化是一个很常见的形态,它不是一个具体的库或者技术,而是一套技术方案。它需要实现以下几点:

  • 在托盘中显示图标,点击可以恢复窗口状态
  • 托盘可以右键菜单
  • 点关闭窗口并不关闭,而是将窗口隐藏(与最小化的区别是任务栏不显示图标)

很显然,Flutter框架是不支持Desktop的托盘化和窗口显示状态的控制,不怪我喷他桌面这块的水平像个小学僧,完全摆烂等社区自己做。

感谢社区开发者开源了两个托盘库:system_traytray_manager,这两个库我选择 tray_manager,就因为它使用方式更简单,windows上的右键的UI更现代化。

感谢bitsdojo大佬在flutter desktop还是未正式发布时就在带病为爱发电的 bitsdojo_window 库,都过去快3年了。因为我们写的Home组件只是一个窗口内容区域,窗口是由runner的c++调用系统api创建的,Flutter并没有提供在UI内直接控制和访问窗口的能力。所以这个库直到现在还是替Flutter官方弥补这一块缺失的重要工具,它提供了一个appWindow对象基本覆盖你对窗口常规能用到的所有点。一般用于实现隐藏系统标题栏,自己实现标题栏。

2.1. 创建托盘图标

首先,我们创建托盘图标,只需要在home.dart中加入下列代码:

import 'package:tray_manager/tray_manager.dart';

class _HomeState extends State<Home> with TrayListener {
  @override
  void initState() {
    String iconPath = Platform.isWindows ? 'assets/images/app.ico' : 'assets/images/app.png';
    trayManager.addListener(this);
    trayManager.setIcon(iconPath);   
    // ...
  }

  @override
  void dispose() {
    trayManager.removeListener(this);
    super.dispose();
  }
}

在初始化widget的时候开启托盘的监听并且设置托盘图标,在销毁widget的时候移除监听。托盘图标对于托盘化是必要的,请务必添加。

其次,实现点击托盘图标恢复窗口显示状态,代码:

import 'package:bitsdojo_window/bitsdojo_window.dart';

class _HomeState extends State<Home> with TrayListener {
  @override
  void onTrayIconMouseDown() {
    appWindow.restore();
  }
}

这里实现了抽象类型 TrayListener 的 onTrayIconMouseDown方法,它是 tray_manager 库提供的4个鼠标回调之一,都有:

  • onTrayIconMouseDown()
  • onTrayIconMouseUp()
  • onTrayIconRightMouseDown()
  • onTrayIconRightMouseUp()

分别是左键按下托盘图标,左键从托盘图标上弹起,右键按下托盘图标,右键从托盘图标上弹起。但是,经过实测,使用 tray_manager: 0.2.0,两个Up方法都未能被触发,暂时不清楚是否还有隐含的信息。

所以,我们在鼠标左键按下的时候调用 appWindow.restore()恢复窗口,十分简单。

2.2. 托盘右键菜单

tray_manager库提供了美观的托盘菜单,使用方式是先设置菜单,再弹出菜单。代码:

import 'package:tray_manager/tray_manager.dart';

class _HomeState extends State<Home> with TrayListener {
  late Menu trayMenu;
    
  @override
  void onTrayIconRightMouseDown() {
    trayMenu = Menu(items: [
      MenuItem(
        label: 'Open',
        onClick: (item) => onTrayIconMouseDown(),
      ),
      MenuItem.separator(),
      MenuItem(
        label: 'Exit',
        onClick: (item) => exit(0),
      ),
    ]);
    trayManager.setContextMenu(trayMenu);
    trayManager.popUpContextMenu();
  }
}

我们在 didChangeDependencies() 触发时设置菜单,这个时候UI已经准备好了。实现了抽象类型 TrayListener 的onTrayIconRightMouseDown方法,在鼠标右键点击托盘鼠标的时候,弹出托盘菜单。

🚨WARNING: 尝试过在initState()的时候初始化菜单,结果是调用了弹出窗口无反应。更新:使用 didChangeDependencies() 时初始化菜单,在更新flutter到3.10后失效,需要在弹出时设置菜单。

2.3. 点击关闭窗口隐藏

因为关闭按钮在标题栏上,属于系统窗口的一部分,所以它。。。编不下去了,就是Flutter不支持你控制这些,你把握不住!

其实,我们只需要在runner的win32_window.cpp中处理窗口消息回调中手动增加关闭消息的C++代码,将窗口设置为隐藏窗口就可以了。

LRESULT Win32Window::MessageHandler(HWND hwnd,
                            UINT const message,
                            WPARAM const wparam,
                            LPARAM const lparam) noexcept {
  switch (message) {
    case WM_CLOSE:
        ShowWindow(hwnd, SW_HIDE);
        return 0;
    //case ...
  }

  return DefWindowProc(window_handle_, message, wparam, lparam);
}

2.4. 启动程序为托盘

很多桌面程序都有一种启动后无感直接托盘化的模式,flutter按理来说也应该实现这样一种能力,但是不出意外它没有。所以,自己动手!

NOTE:笔者已经尝试了使用windowsManager在 main 方法里等待界面准备完成后将将其隐藏。但是由于flutter控制的是窗口内容区域,需要先显示窗口再进行渲染。此时窗口已经弹出,然后出现画面,然后窗口隐藏。对于用户来说就是突然闪了一下,这样看起来像个得瑟极了的流氓软件,这种方法请不要再多加尝试。

启动为托盘的实现思路都是创建窗口后将其隐藏,然后根据条件控制是否显示。

所以为了放置flutter将其唤醒,我们修改win32_window.cpp文件中的Show方法,让其默认显示一个隐藏的窗口:

bool Win32Window::Show() {
  return ShowWindow(window_handle_, SW_HIDE);
}

然后,再到dart的 main()方法里根据条件控制窗口初始化正常显示或不显示:

void main() async {
  // ...
  const needTary = true;
  windowManager.waitUntilReadyToShow(windowOptions, () async {
    if (!needTary) {
      await windowManager.show();
      await windowManager.focus();
    }
  });
  // ...
}

综合起来,我们就实现了窗口关闭托盘化,点击托盘图标恢复窗口显示,右键托盘图标显示菜单的一整套托盘化方案的核心内容。可以以这个为基础框架,细化实现自己的托盘功能。

3. 命令行参数

Dart本身就支持main函数接收命令行参数,而flutter框架也解析了命令行参数并将其传递给了drat的main函数。

// main.dart
void main(List<String> arguments) async {
}

命令行参数arguments是List 字符串数组类型,和其他语言相同,你可以自己解析参数内容。也可以使用解析的库,我推荐使用解析库 args

> dart pub add args

NOTE: 根据笔者的经验,flutter在3.10前的版本对于短横线参数(如:-l)的 flag 会解析漏掉。这个问题在3.10没有遇到,尽量使用新版flutter。

首先,构建一个参数的解析器:

final parser = ArgParser()
  ..addFlag("add", defaultsTo: false, abbr: "a")
  ..addFlag("delete", defaultsTo: false, abbr: "d")
  ..addFlag("query", defaultsTo: false, abbr: "q")
  ..addFlag("update", defaultsTo: false, abbr: "u");

addFlag的参数中定义你需要解析的flag类参数,第一个传入值是flag的完整名称,第二个值defaultsTo是当未解析到时的默认值,第三个值abbr时flag的单字符缩写。名称和缩写决定着传参的形式:

> myapp.exe --add
> myapp.exe -a

其他的还有addOption,addCommand,addMultiOption,addSeparator等满足所有参数类型的需求。

接着解析命令行参数得到解析结果,对于flag类型的参数通过wasParsed函数判断是否是包含指定命令行参数。

ArgResults argResults = parser.parse(arguments);
if(argResults.wasParsed("add")){
  // do add logic
}

4. 设置exe的应用信息

对于一个用于生产使用的软件产品来说,完善的应用信息比不可少,一般包含以下几个部分:

  1. 专业的程序图标
  2. 可执行程序名
  3. 程序文件信息
  4. 语义化的版本号

4.1. 设置图标

图标在可执行程序文件上显示,在程序的标题栏上显示,也应用于基于可执行程序创建的桌面和开始菜单的快捷方式上限制,还在任务栏上和任务管理器中显示。

在flutter中设置图标的方式比较简单,将你的图标放置在windows\runner\resources目录下。然后,找到 windows\runner 下的Runner.rc文件,替换其中的图标文件名就可以了。

IDI_APP_ICON            ICON                    "resources\your_app.ico"

4.2. 设置程序名称

默认情况下编译出的可执行程序名称与项目名称一致,单往往很多时候项目名称并不是我们最终发布出去的产品名称,这种情况下就需要修改编译生成的程序名称。

找到 windows 目录下的CMakeList文件,修改下面这行中的name为你的产品名称。

set(BINARY_NAME "YourAppName")

4.3. 设置程序文件信息

程序文件信息可在编译出的可执行程序文件上右键,查看属性->详细信息

这些信息在 windows\runner 下的Runner.rc文件中修改如下字符内容。

BEGIN
    BLOCK "StringFileInfo"
    BEGIN
        BLOCK "040904e4"
        BEGIN
            VALUE "CompanyName", "Your Company Inc" "\0"
            VALUE "FileDescription", "YourApp Descritions" "\0"
            VALUE "FileVersion", VERSION_AS_STRING "\0"
            VALUE "InternalName", "YourApp" "\0" //invalid
            VALUE "LegalCopyright", "Copyright (C) 2023 Your Company Inc. All rights reserved." "\0"
            VALUE "OriginalFilename", "YourAppName.exe" "\0" //invalid
            VALUE "ProductName", "Your App Name" "\0"
            VALUE "ProductVersion", VERSION_AS_STRING "\0"
        END
    END
    BLOCK "VarFileInfo"
    BEGIN
        VALUE "Translation", 0x409, 1252
    END
END

4.4. 设置语义化的版本号

一个合理的版本发布过程和一套合理的版本编撰规则对于产品持续保持活力都是十分有利的,flutter支持语义化的版本(2.0.0)规则。设置版本在pubspec.yaml中修改 version 的值就可以了。

version: 1.0.0-rc1

默认情况下版本号为1.0.0+1,+1为build号。编译出来的文件版本(FileVersio)为1.0.0.1,产品版本(ProductVersion)为1.0.0+1。改为+2则文件版本为1.0.0.2,产品版本为1.0.0+2。

同时,它也支持预发布版,编译出来的文件版本为1.0.0.0,产品版本为1.0.0-rc1:

version: 1.0.0-rc1

上述规则也可以同时存在,编译出来的文件版本为1.0.0.1,产品版本为1.0.0-rc1+1:

version: 1.0.0-rc1+1

5. 打包依赖库

一个复杂的应用程序一般都会包含一些文档、图片、库文件、字体等资源文件,其中特别是将外部的依赖库二进制文件(.dll .so)作为本地资源文件需要编译到程序包中,在程序运行时进行调用。

flutter提供了资源文件的配置方式,在pubspec.yaml文件中配置:

flutter:
  assets:
    - assets/images/
    - lib/native/win/libA.dll
    - lib/native/linux/libA.so
    - "docs/doc.pdf"

然后,实现getPlatformResourcePath方法根据assets的key获取资源文件的绝对路径。

getPlatformResourcePath("lib/native/win/libA.dll")

String getPlatformResourcePath(String key) {
  if (Platform.isLinux) {
    return path.join(Directory.current.path, key);
  } else if (Platform.isWindows) {
    var assetsPath = path.join(Directory.current.path, "data/flutter_assets", key);
    if (!File(assetsPath).existsSync()) {
      assetsPath = path.join(Directory.current.path, key);
    }
    return assetsPath;
  } else {
    throw Exception('Unsupported platform');
  }
}

执行命令:

flutter build windows --release

build\windows\runner\Release下的data\flutter_asserts\lib\native\win下就能找得到库文件了。

6. 条件编译

Flutter开发框架目前还不支持根据宏命令进行条件编译,在桌面开发生态里无疑是一个大缺失。不过,我们可以使用一些三方工具来弥补这个问题。

Definetool工具,这是一个批量宏命令替换工具,可以将代码中的宏包括的代码按照条件进行注释或解开注释。这样我们在编译前运行一下工具指定需要编译的宏,再进行编译就可以得到我们想要的代码了。基本上支持flutter项目下的绝大多数后缀文件,比如 ".dart", ".yaml", ".yml", ".podspec", ".java", ".kt", ".go", ".rs", ".js", ".ts", ".php", ".cs", ".swift", ".py", ".cpp", ".c", ".rc"

Definetool工具没有提供二进制release,需要自行本机编译,将编译后的definetool.exe放入flutter项目根目录下。链接:orange-pig/definetool (github.com)

代码示例:控制widget的属性

    Container(
        decoration: const BoxDecoration(
          image: DecorationImage(
// ###ifdef DEBUG
            image: AssetImage("assets/images/a.png"),

// ###endif
// ###ifdef RELEASE
/*            image: AssetImage("assets/images/b.png"),
*/
// ###endif
            fit: BoxFit.fill,
          ),
        ),
      )

然后按照需求执行下面其中一条命令即可完成相应的代码切换。

> ./definetool -define DEBUG
> ./definetool -define RELEASE

7. 使用命令行工具简化命令执行

推荐使用Rust生态的just工具,这是一个便捷的执行命令的工具,Just 用户指南

当你的电脑上已经全局安装了just工具之后,按照官方文档在你的项目中创建.justfile。我们就可以在.justfile文件后追加命令的配方。

release:
    ./definetool -define RELEASE

在命令行里运行 just release就可以执行其对应的命令了。

也可以,一个配方指定多条命令,它们将按顺序依次执行。

release:
    ./definetool -define RELEASE
    flutter build windows --release

还可以,指定执行其他配方。

def_release:
    ./definetool -define RELEASE

release:
    just def_release
    flutter build windows --release

或者,将其他配方作为当前命令的prepare配方,简化命令定义。prepare配方在执行当前的实际命令前执行,可以同时指定多个按空格隔开,它们按次序执行。

def_release:
    ./definetool -define RELEASE

def_moudleA:
    ./definetool -define MOUDLE_A

release: def_release def_moudleA
    flutter build windows --release

甚至,你还可以设置变量和命令参数。

version := `(Select-String -Pattern '^version:' pubspec.yaml).Line -replace '^version:\s*', ''`
mode := "Release"

package:
    & 'C:\Program Files\7-Zip\7z.exe' u -tzip "./build/windows/runner/Release/MyApp_{{trim(version)}}_{{mode}}.zip" -r ./build/windows/runner/Release/*.*  -xr0!'*.zip'
    
def_release:
    ./definetool -define RELEASE

release: def_release
    flutter build windows --release

publish_release: release
    just mode=Release package

这些功能的组合,基本就能满足常见的需求了,更多的请查看just工具的官方文档和示例。

8. 日志

日志记录使用 logger | Dart package 库,它可以输出漂亮的控制台日志,也可以按我们想要的格式输出到文件中。

创建 my_logger.dart 文件定义你的logger。其中的宏注释是条件编译,见第6节。logger 库已经定义了  FileOuput类,如果你想自己定义想要的格式输出,请自己实现 YourFileOuput

import 'dart:convert';
import 'dart:io';

import 'package:logger/logger.dart';
import 'package:path/path.dart';

late Logger mylogger;

void loggerInit() {
  final logDic = Directory("your_log_path");
  if (!logDic.existsSync()) {
    logDic.createSync(recursive: true);
  }

  plogger = Logger(
    filter: ProductionFilter(),
    printer: SimplePrinter(printTime: true),
    output: MultiOutput([
// ###ifdef DEBUG
      ConsoleOutput(),
// ###endif
      MyFileOutput(file: File(join(logDic.path, "p.log"))),
    ]),
// ###ifdef DEBUG
    level: Logger.level
// ###endif
// ###ifdef RELEASE
/*      level: Level.info

*/
// ###endif
  );
}

class MyFileOutput extends LogOutput {
  final File file;
  final bool overrideExisting;
  final Encoding encoding;

  late IOSink _sink;

  MyFileOutput({
    required this.file,
    this.overrideExisting = false,
    this.encoding = utf8,
  });

  @override
  void init() {
    _sink = file.openWrite(
      mode: overrideExisting ? FileMode.writeOnly : FileMode.writeOnlyAppend,
      encoding: encoding,
    );
  }

  @override
  void output(OutputEvent event) {
    _sink.writeAll(event.lines, '\r\n');
    _sink.write('\r\n'); // end of last line
  }

  @override
  void destroy() async {
    await _sink.flush();
    await _sink.close();
  }
}

使用方式:

// 在main函数里初始化
loggerInit();

// 在需要记录日志的地方调用
mylogger.i("****** Hello Logger ******");

9. FFI的用法

在桌面开发过程中,我们经常会涉及到对系统 win32 api的调用或者对一些动态链接库进行调用。win32 的调用已经被封装成了一个库 win32 | Dart package。动态链接库我们还是得自己通过ffi进行访问,教程网上很多,这里主要介绍一些用法。

定义常量

const int BLOCK_SIZE = 32;

定义结构体

final class XXENTRY extends ffi.Struct {
  @ffi.UnsignedLong() // ffi提供了很多基础类型供你使用
  external int len;

  @ffi.UnsignedChar() // dart里头只有int,不管你是啥子类型,到了dart都要变成int
  external int count;

  @ffi.Array.multi([66]) // 定义数组,需要指定数组长度
  external ffi.Array<ffi.UnsignedChar> messages;

  external TIME createTime; // 其他结构体类型
}

定义函数导出

// 打开动态库
ffi.DynamicLibrary dylib = ffi.DynamicLibrary.open("libraryPath");

// 获取lookup
final ffi.Pointer<T> Function<T extends ffi.NativeType>(String symbolName) _lookup = dylib.lookup;

// 按C接口定义获取导出函数地址
final _MessageBoxWPtr = _lookup<ffi.NativeFunction<ffi.Int32> Function(ffi.IntPtr, ffi.Pointer<ffi.Utf16>, ffi.Pointer<ffi.Utf16>, ffi.Uint32)>('MessageBoxW');

// 转换为dart定义
final MessageBoxW = _MessageBoxWPtr.asFunction<int Function(int, ffi.Pointer<ffi.Utf16>, ffi.Pointer<ffi.Utf16>, int uType)>();

// 前两步也可以简化为,拆开是为了理解其含义
final MessageBoxW = user32.lookupFunction<
      Int32 Function(IntPtr hWnd, Pointer<Utf16> lpText, Pointer<Utf16> lpCaption, Uint32 uType),
      int Function(int hWnd, Pointer<Utf16> lpText, Pointer<Utf16> lpCaption, int uType)>('MessageBoxW');

结构体的创建

final wndClassLen = ffi.sizeOf<WNDCLASSW>();
final ffi.Pointer<WNDCLASSW> wndClassPtr = calloc.allocate<WNDCLASSW>(wndClassLen);
wndClass.ref.style = 0;
wndClass.ref.lpfnWndProc = nullptr; // 用 nullptr 简化示例
wndClass.ref.cbClsExtra = 0;
wndClass.ref.cbWndExtra = 0;
wndClass.ref.hInstance = nullptr;
wndClass.ref.hIcon = nullptr;
wndClass.ref.hCursor = nullptr;
wndClass.ref.hbrBackground = nullptr;
wndClass.ref.lpszMenuName = nullptr;
wndClass.ref.lpszClassName = 'DartWindowClass'.toNativeUtf16();

// free
calloc.free(wndClassPtr);

指针的转换

final wndClassLen = ffi.sizeOf<WNDCLASSW>();
final ffi.Pointer<WNDCLASSW> wndClassPtr = calloc.allocate<WNDCLASSW>(wndClassLen);

var wndClassPtrVoid = ffi.Pointer<ffi.Void>.fromAddress(wndClassPtr.address);

二进制数组转字符串

String castString(ffi.Array<ffi.Char> array) {
  // 1. read array to list
  List<int> utf8List = <int>[];
  int len = 0;
  do {
    utf8List.add(array[len]);
    len++;
  } while (len <= 64 && array[len] != 0);
  utf8List.add(0x00); // add '\0', end the string

  // 2. alloc memory for string list
  final Pointer<Uint8> ptr = calloc(utf8List.length);
  final Uint8List list = ptr.asTypedList(utf8List.length);
  list.setAll(0, utf8List);

  // 3. cast utf8 pointer to string
  final Pointer<Utf8> utf8 = ptr.cast<Utf8>();
  final String retString = utf8.toDartString();

  calloc.free(ptr);

  return retString;
}

二进制数组赋值

var data = [0x01, 0x03, 0x05, 0x07];
final ffi.Pointer<ffi.Char> dataPointer = calloc.allocate<ffi.Char>(4);
for (var i = 0; i < data.length; i++) {
  dataPointer.elementAt(i).value = data[i];
}

10. 数据安全

10.1. 静态编码安全

在软件中往往会出现key等静态编码的字符串信息,如果直接在代码中硬编码,即使你做了混淆也很简单就能被找到。我们得通过复杂一些的手段来保护我的静态字符串。

我们使用 envied_generator | Dart package 库使用环境变量生成混淆后的字符串编码。

在项目根目录下创建 .env 文件写入你的静态字符串:

SECURE_KEY = "your_key"

在 lib 目录里创建 env/env.dart:

import 'package:envied/envied.dart';

part 'env.g.dart';

@Envied(path: '.env')
abstract class Env {
  @EnviedField(varName: 'SECURE_KEY', obfuscate: true)
  static final String secureKey = _Env.secureKey;
}

执行命令生成 env.g.dart:

flutter pub run build_runner build --delete-conflicting-outputs

在需要使用字符串的地方通过Env类使用:

final key = Env.secureKey;

编译时,对代码进行混淆:

flutter build windows --release --obfuscate --split-debug-info=symbols

10.2. 动态信息安全存储

使用 flutter_secure_storage | Flutter package 库访问windows的安全存储区,可以用来安全存储运行中产生或者获得的信息。

final secureStorage = FlutterSecureStorage();
secureStorage.write(key: "your_key", value: "your_secure_value");
final value = await secureStorage.read(key: "your_key");
secureStorage.delete(key: "your_key");
// If containsKey always return true, use follow codes.
if(await secureStorage.read(key: acc_flag) != null){
  ...
}

11. 一些坑点

11.1. Dart的路径

Dart由于是一个老语言被Flutter挖坟给挖出来重新动手术打扮成了新的样子,众所周知的是Dart 3 和以前完全是两个语言。但是,一些垃圾还是留在了里面。其中之一就是,不要相信Dart给你的任何路径字符串,它在比较长的情况下会对其进行省略缩写,所以用路径字符串拼接前,请使用纯净的路径。

下面提供了一个FileSystemEntity的拓展方法 purifyPath:

extension FileSystemEntityExt on FileSystemEntity {
  String purifyPath() {
    try {
      return getLongPathName(this.absolute.path);
    } catch (error) {
      plogger.e(error);
      return this.absolute.path;
    }
  }
}

const MAX_PATH = 260;// windows路径最大长度限制

String getLongPathName(String shortPath) {
  return using<String>((arena) {
    Pointer<Utf16> nativeValue = shortPath.toNativeUtf16();
    final buffer = wsalloc(MAX_PATH);
    final result = GetLongPathName(nativeValue, buffer, MAX_PATH);

    if (result == 0) {
      GetLastError();
      throw Exception('Failed to get LongPathName: error 0x${result.toRadixString(16)}');
    }

    return buffer.toDartString();
  });
}

11.2. 获取AppData路径

由于 Dart 的金主 Flutter 的桌面端是一个私生子,所以很多桌面端的东西都是不全的。如果你想要访问系统的各种路径,抱歉没有提供直接可用的,得自己想办法。

比如,获取AppData路径:

// 方法1:从系统环境变量中获取
final localAppData = Platform.environment['LOCALAPPDATA'];

// 方法2:用 path_provider 库
import 'package:path_provider/path_provider.dart';
var tempDic = await getTemporaryDirectory();
final path = tempDic.purifyPath();// purifyPath 见上面小节

final index = path.lastIndexOf('Temp');
appDataPath = path.substring(0, index - 1);