Flutter 做桌面应用时,窗口并不是“写个页面就完事”。Windows 端由 Win32 窗口承载 Flutter engine,窗口样式、标题栏、阴影圆角、单例、注册表读写、调用系统控制台,都需要单独处理。本文基于一次从 0 到 1 的桌面项目实践,梳理 Flutter 在 Windows(以及 Android 大屏桌面设备)上做窗口化时真正会踩到的技术点。
一、Windows 端窗口从哪里来
Flutter 的 Windows 模板会在原生层创建一个 Win32Window,再由 FlutterWindow 创建 FlutterViewController 并挂载引擎视图。模板里的创建逻辑大致如下:
- bool Win32Window::CreateAndShow(const std::wstring& title,
- const Point& origin,
- const Size& size) {
- HWND window = CreateWindow(
- window_class, title.c_str(), WS_POPUP | WS_SYSMENU | WS_MINIMIZEBOX | WS_MAXIMIZEBOX,
- Scale(origin.x, scale_factor), Scale(origin.y, scale_factor),
- Scale(size.width, scale_factor), Scale(size.height, scale_factor),
- nullptr, nullptr, GetModuleHandle(nullptr), this);
- UpdateWindow(window);
- if (!window) {
- return false;
- }
- return OnCreate();
- }
- bool FlutterWindow::OnCreate() {
- if (!Win32Window::OnCreate()) {
- return false;
- }
- RECT frame = GetClientArea();
- flutter_controller_ = std::make_unique<flutter::FlutterViewController>(
- frame.right - frame.left, frame.bottom - frame.top, project_);
- if (!flutter_controller_->engine() || !flutter_controller_->view()) {
- return false;
- }
- RegisterPlugins(flutter_controller_->engine());
- SetChildContent(flutter_controller_->view()->GetNativeWindow());
- return true;
- }
复制代码
也就是说,Windows 平台天然就是窗口化的,Flutter 不需要像移动端那样“造窗口”。但反过来说,Flutter 作为 UI 框架也无法直接修改 Win32 窗口的外观,标题栏、边框这些仍然要回到原生层或窗口管理插件处理。
二、去掉系统标题栏并让窗口透明居中
模板创建出来的窗口默认带系统导航栏。实际项目通常需要自定义导航栏,因此要先把原生标题栏隐藏。原文使用的是 leanFlutter 社区提供的 window_manager,版本为 ^0.2.6。在 runApp 之前完成初始化:
- WindowManager w = WindowManager.instance;
- await w.ensureInitialized();
- WindowOptions windowOptions = WindowOptions(
- size: normalWindowSize,
- center: true,
- titleBarStyle: TitleBarStyle.hidden
- );
- w.waitUntilReadyToShow(windowOptions, () async {
- await w.setBackgroundColor(Colors.transparent);
- await w.show();
- await w.focus();
- await w.setAsFrameless();
- });
复制代码
这里有一个容易忽略的现象:应用启动时会在左下角闪一下再居中。原因是原生 Win32 窗口默认先在左上角显示,之后才由插件完成居中。处理思路是在原生代码里先让窗口不显示,等 window_manager.show() 时再展示,避免用户看到位置跳变。
三、Android 大屏桌面设备的窗口化思路
如果目标设备是 Android 大屏桌面设备,窗口化要麻烦一些。Activity 默认全屏,而且出于安全考虑不允许点击穿透。原文的做法是把承载 Flutter 的 Activity 主题改成 Dialog,同时让窗口背景透明,并禁止点击外部关闭:
- <style name="Theme.DialogApp" parent="Theme.AppCompat.Light.Dialog">
- <item name="android:windowBackground">@drawable/launch_application</item>
- <item name="android:windowIsTranslucent">true</item>
- <item name="android:windowContentOverlay">@null</item>
- <item name="android:backgroundDimEnabled">false</item>
- <item name="windowActionBar">false</item>
- <item name="windowNoTitle">true</item>
- </style>
复制代码
Activity 清单中把 theme 指向该样式,并在 FlutterActivity 中返回透明模式、设置 setFinishOnTouchOutside(false),再把窗口宽高设为全屏:
- class MainActivity : FlutterActivity() {
- override fun getTransparencyMode(): TransparencyMode {
- return TransparencyMode.transparent
- }
- override fun onResume() {
- super.onResume()
- setFinishOnTouchOutside(false)
- var lp = window.attributes
- lp.width = -1
- lp.height = -1
- window.attributes = lp
- }
- }
复制代码
这样得到的是一个全屏透明窗口,真正的“窗口”由 Flutter 侧的盒子控件负责边距和阴影。原文在 MaterialApp 外层套了 GlobalBoxManager,通过 Container 设置水平和垂直留白,让应用内容看起来像悬浮窗口。
四、窗口阴影、圆角与自定义导航栏
窗口外观确定后,美化就交给 Flutter。原文在 MaterialApp 外层再包一层 Container,用 clipBehavior 裁剪、margin 留白、borderRadius 做圆角、boxShadow 做阴影;Android 和桌面平台通过 Platform.isAndroid 区分边距。
导航栏则回到 Scaffold 的 AppBar,用 PreferredSize 固定高度,并在外层套 GestureDetector 处理拖拽。Windows 平台可以调用 windowManager.startDragging() 实现拖动窗口:
- appBar: PreferredSize(
- preferredSize: const Size.fromHeight(64),
- child: GestureDetector(
- behavior: HitTestBehavior.translucent,
- onPanStart: (details) {
- if (Platform.isWindows) windowManager.startDragging();
- },
- onDoubleTap: () {},
- child: AppBar(
- title: Text(widget.title),
- centerTitle: true,
- actions: [
- GestureDetector(
- behavior: HitTestBehavior.opaque,
- child: const Padding(
- padding: EdgeInsets.symmetric(horizontal: 16),
- child: Icon(Icons.close, size: 24),
- ),
- ),
- ],
- ),
- ),
- ),
复制代码
这样一套代码可以在多个平台得到一致的窗口效果,这也是选择用 Flutter 处理窗口外观而不是全部写原生代码的原因:跨平台复用。
五、Windows 平台交互:注册表读写
桌面应用经常需要读写注册表。Dart 已经通过 win32 包暴露了相关 Win32 API,原文没有额外引入注册表库,而是直接用 dart:ffi 封装了一个工具类。涉及的关键 API 包括 RegOpenKeyEx、RegEnumValue、RegEnumKeyEx、RegSetKeyValue 和 RegDeleteKey。
设置注册表值使用 RegSetKeyValue,字符串类型为 REG_SZ:
- static setRegeditValue(String regPath, String key, String value,
- {int hKeyValue = HKEY_CURRENT_USER}) {
- final phKey = calloc<HANDLE>();
- final lpKeyPath = regPath.toNativeUtf16();
- final lpKey = key.toNativeUtf16();
- final lpValue = value.toNativeUtf16();
- try {
- if (RegSetKeyValue(hKeyValue, lpKeyPath, lpKey, REG_SZ, lpValue,
- lpValue.length * 2) != ERROR_SUCCESS) {
- throw Exception("Can't set registry key");
- }
- return phKey.value;
- } finally {
- free(phKey);
- free(lpKeyPath);
- free(lpKey);
- free(lpValue);
- RegCloseKey(HKEY_CURRENT_USER);
- }
- }
复制代码
读取某个键下的值则通过 RegEnumValue 逐个枚举,并根据 lpType 判断类型:REG_DWORD 转成 Uint32 再转字符串,REG_SZ 转成 Utf16 字符串。遇到 ERROR_NO_MORE_ITEMS 说明枚举结束,ERROR_MORE_DATA 则说明缓冲区不够。枚举子键使用 RegEnumKeyEx,删除子键使用 RegDeleteKey。原文特别提示:部分注册表操作需要管理员权限,应用提权逻辑要提前做好。
六、执行系统控制台指令与应用单例
Windows 上很多系统能力可以直接通过控制台命令或系统面板打开。Dart 的 io 库提供了 Process 类,例如打开网络连接面板:
- Process.start('ncpa.cpl', [], runInShell: true);
复制代码
另一个桌面端必须处理的问题是应用单例。Windows 默认每次点击图标都会启动新进程,而 Android 默认就是单例。原文使用 windows_single_instance 插件,在 runApp 之前执行:
- static setSingleInstance(List<String> args) async {
- await WindowsSingleInstance.ensureSingleInstance(args, "desktop_open",
- onSecondWindow: (args) async {
- if (await windowManager.isMinimized()) await windowManager.restore();
- windowManager.focus();
- });
- }
复制代码
再次启动时不会多开进程,而是把已有窗口恢复并置顶,符合桌面用户的操作预期。
七、桌面交互习惯的两个细节
第一是按钮点击态。移动端常见的水波纹在小屏幕上够用,但桌面显示区域大、鼠标指针小,水波纹往往来不及被用户感知。桌面应用更合适的做法是按下立即改变按钮颜色(文字和背景均可),松开恢复。第二是页面切换和启动参数:桌面窗口尺寸变化大,切换页面时需要考虑不同大小的布局;同时从系统唤起应用时要能拿到启动参数,用于打开指定文件或路由。原文提到这两点在桌面端与移动端的行为差异明显,需要在项目初期就统一处理。
整体来看,Flutter 做 Windows 桌面应用的关键不在于 UI,而在于理解 Win32 窗口的生命周期、窗口管理插件与原生代码的配合边界,以及注册表、进程、单例这些桌面系统能力如何通过 Dart FFI 和插件补齐。把这些基础能力封装好,后续页面开发才能真正复用一套代码。 |