柚子/Flutter 桌面端开发实录:我如何驯服“多窗口”这匹野马

Created Mon, 21 Jul 2025 00:00:00 +0000 Modified Sun, 20 Jul 2025 16:03:46 +0000
By Yoyo 2795 Words 13 min Edit

最近,我刚从一个 Flutter 桌面端项目的泥潭里爬出来,感触颇深。今天不聊高大上的架构,就想跟大家唠唠嗑,分享一下我在这个项目中遇到的一个“大坑”——如何让原生插件在多窗口模式下“雨露均沾”。

故事的开头总是美好的。我们要做一个功能强大的桌面工具,Flutter 以其卓越的跨平台能力和漂亮的 UI 成为我们的不二之选。项目初期,一切都像官方文档里描述的那样丝滑。直到一个核心需求摆在了面前:我们需要支持多窗口

这听起来很酷,对吧?用户可以同时打开一个主窗口和多个功能子窗口。我兴致勃勃地找到了社区里最流行的插件 desktop_multi_window,以为分分钟就能搞定。然而,真正的挑战才刚刚开始。我很快发现,项目中集成的其他原生插件(比如文件选择器、数据库等)突然“罢工”了——它们只在主窗口里听话,到了新创建的子窗口就变成了“聋子和瞎子”。

这就是我们今天要面对的核心挑战:如何让原生插件在 desktop_multi_window 创建的所有窗口中都能正常工作? 这不仅仅是一个 Bug,更是一次深入理解 Flutter 桌面端原生集成机制的旅程。

从“插件失忆症”到“记忆唤醒”:多窗口的插件注册难题

为什么插件会“失忆”?

要解决问题,首先得理解问题出在哪。为什么插件到了子窗口就“水土不服”了呢?

我们可以把 Flutter 的原生插件机制比喻成一个**“服务总台”**。当我们的 App 启动时,主窗口会创建一个 FlutterEngine,并在这个引擎上执行一个叫做 RegisterPlugins 的初始化操作。这个操作就像是在“服务总台”上注册好所有可用的服务(文件选择、数据库访问等)。主窗口里的 Dart 代码通过这个总台,就能轻松调用这些原生服务。

问题来了,desktop_multi_window 创建的新窗口,本质上是创建了一个全新的、独立的 FlutterEngine 实例。这个新的引擎,就像一个刚开张的、空空如也的“分店服务台”,它完全不知道主窗口那个“总店”注册过哪些服务。这就是“插件失忆症”的根源。我们所有的原生插件,都只在主窗口的引擎上注册了,子窗口的引擎对此一无所知。

最简单粗暴的方案?当然是放弃,然后告诉产品经理“这个需求做不了”。但作为一个有追求的工程师,这显然不是我的风格。

如何“唤醒”子窗口的记忆?

既然知道了病因,那治疗方案也就清晰了:在每个子窗口创建时,手动为它的引擎也执行一次 RegisterPlugins 操作。我们得想办法在子窗口的 FlutterEngine 准备好后,把那些原生插件再注册一遍。

幸运的是,desktop_multi_window 的作者也考虑到了这一点,为我们提供了一个至关重要的“钩子”——一个回调函数,它会在新窗口创建完成后被触发。我们只需要抓住这个时机,执行插件注册逻辑即可。

听起来简单,但魔鬼藏在细节里。因为 Flutter 桌面端支持 Windows、macOS 和 Linux,所以我们需要分别为这三个平台进行“手术”。

第一刀:Windows (C++)

在 Windows 平台,我们需要修改 windows/runner/flutter_window.cpp 文件。核心是在 FlutterWindow::OnCreate() 函数的末尾,添加我们的回调逻辑。

第 1 个坑:头文件引用

我一开始兴冲冲地把回调代码粘进去,结果编译器直接给了我一堆错误。定睛一看,原来是忘记了引入 desktop_multi_window 的头文件。这真是个低级但又极其常见的错误。

解决方案: 在文件顶部,确保加入了 #include <desktop_multi_window/desktop_multi_window_plugin.h>

// windows/runner/flutter_window.cpp

#include "flutter_window.h"
#include <optional>
#include "flutter/generated_plugin_registrant.h"
// 就是这行!别忘了!
#include <desktop_multi_window/desktop_multi_window_plugin.h>

bool FlutterWindow::OnCreate() {
  if (!Win32Window::OnCreate()) {
    return false;
  }

  // ... (省略原有代码)

  RegisterPlugins(flutter_controller_->engine());

  // --- 这是我们添加的核心代码 ---
  // 设置一个回调,当新窗口被创建时,我们同样为新窗口的引擎注册插件
  DesktopMultiWindowSetWindowCreatedCallback([](void *controller) {
    auto *flutter_view_controller =
        reinterpret_cast<flutter::FlutterViewController *>(controller);
    auto *registry = flutter_view_controller->engine();
    RegisterPlugins(registry);
  });
  // --- 添加结束 ---

  SetChildContent(flutter_controller_->view()->GetNativeWindow());

  // ... (省略原有代码)
  
  return true;
}

第二刀:macOS (Swift)

macOS 平台的修改相对优雅一些,在 macos/Runner/MainFlutterWindow.swift 文件中进行。

第 2 个坑:回调时机

在 Swift 中,awakeFromNib() 是一个很自然的初始化时机。我们需要在这个函数里,调用 RegisterGeneratedPlugins 为主窗口注册插件,然后紧接着设置 setOnWindowCreatedCallback 为未来的子窗口做准备。

// macos/Runner/MainFlutterWindow.swift

import Cocoa
import FlutterMacOS
import desktop_multi_window // 同样,别忘了 import

class MainFlutterWindow: NSWindow {
  override func awakeFromNib() {
    let flutterViewController = FlutterViewController()
    let windowFrame = self.frame
    self.contentViewController = flutterViewController
    self.setFrame(windowFrame, display: true)

    // 首先,为当前主窗口注册插件
    RegisterGeneratedPlugins(registry: flutterViewController)
    
    // --- 这是我们添加的核心代码 ---
    // 设置回调,为之后创建的所有新窗口注册插件
    FlutterMultiWindowPlugin.setOnWindowCreatedCallback { registry in
      RegisterGeneratedPlugins(registry: registry)
    }
    // --- 添加结束 ---

    super.awakeFromNib()
  }
}

第三刀:Linux (C)

最后轮到了 Linux。我们需要修改 linux/my_application.cc 文件。Linux 平台的代码风格和 Windows、macOS 又有所不同,使用的是基于 GObject 的 C 语言 API。

第 3 个坑:找对注册函数

在 Linux 这边,插件注册函数名叫 fl_register_plugins,而不是 RegisterPlugins。这个细微的差别一度让我很困惑,查阅了 generated_plugin_registrant.h 文件后才恍然大悟。这提醒我,即使是自动生成的代码,也需要仔细阅读,不能想当然。

// linux/runner/my_application.cc

// ... (省略各种 include)
#include "flutter/generated_plugin_registrant.h"
// 引入 desktop_multi_window 的头文件
#include "desktop_multi_window/desktop_multi_window_plugin.h"

// ... (省略 my_application_class_init 等函数)

static void my_application_activate(GApplication* application) {
  // ... (省略大量创建窗口和 view 的代码)

  // 为主窗口的 view 注册插件
  fl_register_plugins(FL_PLUGIN_REGISTRY(view));

  // --- 这是我们添加的核心代码 ---
  // 设置回调,当新窗口创建时,为其注册插件
  desktop_multi_window_plugin_set_window_created_callback([](FlPluginRegistry* registry) {
    fl_register_plugins(registry);
  });
  // --- 添加结束 ---

  gtk_widget_grab_focus(GTK_WIDGET(view));
}

// ... (省略剩余代码)

经过这三刀“手术”,我们的 App 终于恢复了正常。无论打开多少个子窗口,文件选择器、数据库等原生插件都能愉快地工作了。问题解决了,但反思才刚刚开始。

踩过的坑与更高维度的反思

这次经历让我深刻地体会到,Flutter 的跨平台是“有条件的”

在应用层,它的确做到了“一次编写,多端运行”,效率极高。但一旦你的需求触及到底层、需要和原生平台进行深度交互时(比如多窗口、系统级 API 调用),你就必须戴上“原生开发者”的帽子,去直面每个平台的差异性。

这次的解决方案看似简单,只是在三个文件里各加了几行代码。但它暴露了一个维护性的风险:我们修改的是 Flutter 自动生成的项目模板文件。如果未来 Flutter 的版本更新修改了这些文件的结构,或者改变了插件的注册方式 (RegisterPlugins 函数名变了),我们手动添加的代码就可能失效,甚至导致编译失败。

因此,在添加这些代码时,详尽的注释是必不可少的。我们必须清楚地写明为什么需要这段代码,它解决了什么问题,以便未来的自己或同事在项目升级时能够快速定位和修复。

总结:我的几点核心启示

这次和 desktop_multi_window 的“搏斗”,让我收获良多,总结下来就是以下几点:

  • 插件注册与 FlutterEngine 绑定:要牢记,Flutter 的原生插件是注册在某个具体的 FlutterEngine 实例上的。一个 App 内可以有多个引擎,它们之间的插件注册默认是不共享的。

  • 回调是“天降神兵”:当遇到需要在特定时机执行操作的难题时,多去翻翻你所使用的插件文档,看看它是否提供了类似 CallbackHook 的机制。这往往是解决问题的钥匙。

  • 拥抱原生,但保持敬畏:不要害怕深入 C++、Swift 或 C 代码。直面原生层是解决复杂问题的必经之路。但同时也要认识到,这会增加项目的复杂度和维护成本。

  • 注释是你最好的朋友:对于所有针对原生模板文件的“魔改”,请务必留下清晰的注释。它能在未来的某个深夜,将你从项目升级的噩梦中拯救出来。

希望我这次“踩坑”的经历能对大家有所帮助。如果你也在做类似的工具,或者在 Flutter 桌面端开发中遇到了什么有趣的挑战,非常欢迎在评论区交流你的踩坑心得!