Flutter 基础体系 · 第 67/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter Web 与桌面:渲染器、窗口、文件、输入和平台差异

Flutter 的核心模型是:开发者用 Dart 描述 Widget 树,Flutter 框架将 Widget 转换为 Element 和 RenderObject,最终由 Flutter Engine 完成布局、绘制、文字排版、合成和事件分发。

当目标从 Android、iOS 扩展到 Web 和桌面时,Dart 与 Flutter API 的大部分仍然可以复用,但“应用如何显示、如何获得文件、如何接收输入、如何控制窗口”会受到宿主平台的根本影响:

  • Web 运行在浏览器页面中,受到浏览器安全模型和页面生命周期约束。
  • Windows、macOS、Linux 运行在原生窗口中,具有文件系统路径、窗口管理和系统级输入能力。
  • Android、iOS 运行在移动端应用容器中,窗口通常由系统管理,存储和生命周期又与桌面不同。
  • Flutter Web 和 Flutter 桌面都不是“把移动端界面简单放大”,它们拥有不同的渲染后端和平台边界。

本文从渲染器、窗口、文件、输入四条主线说明这些差异,并给出跨平台代码的组织方式。


一、先建立运行模型:Flutter 代码经过了哪些层

一个 Flutter 应用可以抽象为以下数据流:

flowchart TD
    A[Dart Widget 代码] --> B[Flutter Framework]
    B --> C[Element / RenderObject]
    C --> D[布局与绘制指令]
    D --> E[Flutter Engine]
    E --> F{宿主平台}
    F --> G[Android / iOS 原生 Surface]
    F --> H[Windows / macOS / Linux 原生窗口]
    F --> I[浏览器 Canvas / WebAssembly / JavaScript]
    G --> J[屏幕]
    H --> J
    I --> J

    K[鼠标 / 触摸 / 键盘 / IME] --> L[平台事件]
    L --> E
    E --> B

    M[文件 / 窗口 / 系统服务] --> N[平台 API 或插件]
    N --> A

这里有三个容易混淆的概念。

1. Flutter Framework 不是操作系统 API

ScaffoldTextListViewFocus 等属于 Flutter Framework。它们提供跨平台的 UI 抽象,但不直接拥有操作系统文件句柄,也不直接创建原生窗口。

例如:

final size = MediaQuery.sizeOf(context);

得到的是当前 Flutter View 的逻辑尺寸,而不是 Windows 窗口对象,也不是浏览器 DOM 元素。

2. Flutter Engine 负责把绘制结果交给不同宿主

Flutter Engine 负责:

  • Dart 与引擎通信;
  • 布局后的绘制和合成;
  • 文字、图片、路径和阴影等图形操作;
  • 键盘、触摸、鼠标、窗口变化等事件接入;
  • 与平台插件通信。

在移动端和桌面端,Engine 通常绘制到原生平台提供的 Surface 或窗口表面。在 Web 中,Engine 运行在浏览器环境中,需要借助浏览器支持的 Canvas、WebGL、WebAssembly 等能力。

3. 插件是“跨平台 API”和“平台能力”之间的适配层

例如文件选择器可以提供统一的 Dart API:

final result = await FilePicker.platform.pickFiles();

但在不同平台上,它背后的实现可能完全不同:

  • Windows:调用系统文件选择对话框;
  • macOS:调用 macOS 原生文件选择能力;
  • Android:可能使用系统文档选择器并返回 URI;
  • iOS:使用系统文档浏览器;
  • Web:调用浏览器的文件选择能力,通常只能拿到文件内容和元数据,而不能获得本地绝对路径。

因此,统一 API 不代表平台语义完全相同。


二、渲染器:Flutter Web 和桌面到底如何绘制

2.1 渲染器的定义

渲染器是把 Flutter 的绘制操作转换成宿主平台可显示结果的实现。绘制操作包括:

  • 绘制矩形、路径和圆角;
  • 绘制文字;
  • 应用变换、裁剪和透明度;
  • 绘制图片;
  • 执行图层合成;
  • 处理阴影、滤镜和着色器。

渲染器不是 Widget,也不是布局系统。布局决定“在哪里、占多大”,渲染器决定“怎样把结果画出来”。

同一个 Container

Container(
  width: 200,
  height: 100,
  color: Colors.blue,
)

在不同渲染器中,布局结果应保持相同,但具体的像素生成路径可能不同。


2.2 移动端和桌面端:Flutter 通常直接绘制到原生表面

在 Android、iOS、Windows、macOS 和 Linux 上,Flutter 通常使用自己的绘制管线,而不是把每个 Text 转换为原生按钮或把每个 Container 转换为原生控件。

这带来两个重要结果:

  1. Flutter 控件在不同平台上具有较高的视觉一致性;
  2. 原生辅助功能、输入法、窗口管理等能力仍需要通过 Engine 和平台实现接入。

桌面端的“窗口”是原生窗口,但窗口内部的 Flutter 内容通常由 Flutter Engine 绘制。换句话说:

原生窗口负责承载 Flutter View,Flutter 负责绘制 View 内部的内容。

因此,修改窗口标题、最小尺寸和系统边框,通常不是通过 Container 完成的。


2.3 Flutter Web 的渲染后端

Flutter Web 运行在浏览器中,当前稳定版本的 Web 构建通常涉及以下渲染后端:

  • 基于 CanvasKit 的渲染路径;
  • 基于 WebAssembly 的 skwasm 渲染路径;
  • 某些旧版本或特定构建方式中存在的 HTML 渲染路径。

具体可用的命令行选项会随 Flutter 版本变化。应以当前 SDK 的帮助输出为准:

flutter build web -h
flutter run -d chrome -h

不要把某个旧版本教程中的 --web-renderer html--web-renderer canvaskit 或 WASM 参数直接当成永久稳定接口。不同 Flutter 版本可能调整默认渲染器、参数名称和实验性状态。

CanvasKit 路径

CanvasKit 是基于 Skia 的 Web 渲染实现,通常借助浏览器的 Canvas、WebGL 和 WebAssembly 能力完成绘制。

它的特点是:

  • 绘制语义更接近 Flutter 在移动端和桌面端的引擎;
  • 复杂绘制、裁剪、阴影和自定义绘制通常具有更一致的行为;
  • 初始加载资源可能更大;
  • 性能依赖浏览器的 GPU、WebGL 和设备环境。

skwasm 路径

skwasm 使用 WebAssembly 形态的 Skia 渲染路径。它需要浏览器支持相应的 WebAssembly 能力,并且会受到 Flutter Web 当前 WASM 构建链的约束。

它不是“把 Dart 应用自动变成原生桌面程序”,而是 Web 页面中的另一条执行和绘制路径。使用前应检查:

  • 浏览器兼容性;
  • 部署服务器的 MIME 类型和缓存策略;
  • 当前 Flutter 版本是否将相关选项标记为稳定;
  • 第三方插件是否兼容 WASM 构建;
  • 是否仍然需要 JavaScript 互操作。

HTML 路径的边界

历史上的 HTML 渲染器会更多利用浏览器 DOM 和 CSS。它在文本选择、浏览器可访问性和页面元素交互方面有一定优势,但与 Flutter 的完整绘制语义并不完全一致。

在当前版本中,不应假定 HTML 渲染器仍然是推荐或默认方案。尤其是以下能力不能仅靠选择 HTML 渲染器解决:

  • 任意 Flutter Widget 自动变成可访问 DOM;
  • 自定义绘制自动获得浏览器原生文本选择;
  • 所有第三方插件都自动支持 Web;
  • Canvas 内绘制内容自动具备 HTML 元素的语义。

2.4 渲染器选择会影响什么

渲染器差异通常表现为以下几类。

首次加载成本

Web 应用在首次打开时需要下载 JavaScript、字体、图片以及渲染器相关资源。可以用浏览器开发者工具观察:

  • JavaScript 和 WASM 资源;
  • Canvas 初始化时间;
  • 首帧时间;
  • 网络缓存命中情况;
  • GPU 初始化是否失败。

不能只根据 APK 或桌面可执行文件大小推断 Web 首次加载性能。

像素一致性

自定义绘制、阴影、混合模式、滤镜和字体渲染可能在不同浏览器、操作系统和 GPU 上产生细小差异。

例如一个依赖精确像素比较的测试:

CustomPaint(
  painter: MyPainter(),
)

在 Chrome、Safari、Windows DirectX 和 macOS Metal 上不一定生成逐像素相同的结果。视觉回归测试应设置合理的容差,而不是默认所有平台像素完全一致。

文本和可访问性

Flutter 绘制的文本和浏览器真正的 DOM 文本不是同一个概念。即使用户看到的是文字,也不能推断:

  • 浏览器可以直接选中它;
  • 浏览器搜索可以找到它;
  • 屏幕阅读器一定能读到它;
  • 自动化测试工具一定能把它当作 HTML 文本定位。

Flutter 的语义树会向平台辅助功能系统提供信息,但 Web、桌面和移动端的辅助功能支持路径不同。需要辅助功能时,应使用正确的语义 Widget、可访问名称、焦点顺序和键盘操作,而不是依赖渲染器的偶然行为。


2.5 为什么不能把“Web 使用 HTML,桌面使用原生控件”作为简单模型

Flutter 的桌面端通常也不是由大量原生控件拼装而成,Flutter Web 的 CanvasKit 路径也不是 HTML 控件集合。

更准确的模型是:

Flutter Widget
  -> Flutter 布局与绘制模型
  -> 不同平台的 Engine/Renderer
  -> 原生 Surface、原生窗口或浏览器 Canvas

因此,以下推断是不成立的:

  • “Web 一定支持 CSS 选择器查找所有 Flutter 控件”;
  • “桌面上的 Flutter Button 一定是 Windows 原生 Button”;
  • “Web 和桌面只要分辨率相同,输入行为就相同”;
  • “同样的字体文件在所有平台产生相同字宽”。

三、窗口与 View:页面尺寸不是窗口对象

3.1 窗口、View、MediaQuery 的区别

窗口是宿主系统提供的可移动、可调整大小的顶级容器。

Flutter View是 Flutter Engine 中的渲染视图,包含逻辑尺寸、设备像素比和视图相关的显示信息。

**MediaQuery**是 Flutter 根据当前 View 提供给 Widget 树的环境数据,包括:

  • size:逻辑宽高;
  • devicePixelRatio:设备像素比;
  • padding:系统安全区域或页面内嵌边距;
  • viewInsets:键盘等遮挡区域;
  • platformBrightness:明暗模式;
  • textScaler:文字缩放信息;
  • navigationMode:导航模式等。

可以用下面的代码观察它们的关系:

class ViewInfo extends StatelessWidget {
  const ViewInfo({super.key});

  @override
  Widget build(BuildContext context) {
    final media = MediaQuery.of(context);
    final view = View.of(context);

    return Text(
      'logical: ${media.size}\n'
      'devicePixelRatio: ${view.devicePixelRatio}\n'
      'padding: ${media.padding}\n'
      'viewInsets: ${media.viewInsets}',
    );
  }
}

MediaQuery.sizeOf(context) 返回的是逻辑像素。物理像素近似为:

Pw=Lw×d,Ph=Lh×dP_w = L_w \times d,\qquad P_h = L_h \times d

其中:

  • Lw,LhL_w, L_h 是逻辑宽高;
  • dd 是设备像素比;
  • Pw,PhP_w, P_h 是渲染表面的物理像素尺寸。

例如逻辑尺寸为 800×600800 \times 600,设备像素比为 22,渲染表面大约需要 1600×12001600 \times 1200 个物理像素。Widget 布局仍然使用逻辑像素,不应手动把每个尺寸乘以 devicePixelRatio


3.2 Web 的窗口变化

Web 应用通常运行在浏览器 Tab 中。Flutter 能观察到的是页面中 Flutter View 的视口变化,而不是整个浏览器窗口的所有状态。

下面的 Widget 会在视口变化时重建:

class ResponsiveRoot extends StatelessWidget {
  const ResponsiveRoot({super.key});

  @override
  Widget build(BuildContext context) {
    final width = MediaQuery.sizeOf(context).width;

    if (width < 600) {
      return const MobileLayout();
    }
    if (width < 1000) {
      return const TabletLayout();
    }
    return const DesktopLayout();
  }
}

这里的断点只是应用设计选择,不是 Flutter 规定的标准值。正确的断点应由内容约束推导。例如:

  • 导航栏最小可用宽度;
  • 表格列的最小宽度;
  • 文本行的可读宽度;
  • 操作按钮是否还能同时显示。

不要把“运行在 Web”直接等同于“必须使用桌面布局”。Web 页面也可能在手机浏览器中打开。


3.3 桌面窗口变化

桌面端窗口通常可由用户拖动边缘调整大小。Flutter Widget 应通过约束和环境数据响应这一变化,而不是只在启动时读取一次尺寸。

class WindowAware extends StatefulWidget {
  const WindowAware({super.key});

  @override
  State<WindowAware> createState() => _WindowAwareState();
}

class _WindowAwareState extends State<WindowAware>
    with WidgetsBindingObserver {
  AppLifecycleState? lifecycle;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    setState(() {
      lifecycle = state;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Text(
      'size: ${MediaQuery.sizeOf(context)}\n'
      'lifecycle: $lifecycle',
    );
  }
}

WidgetsBindingObserver 监听的是 Flutter 应用和 View 的生命周期变化,不等于可以捕获所有操作系统窗口事件。例如:

  • 用户最小化窗口;
  • 系统暂停应用;
  • 浏览器页面进入后台;
  • 窗口关闭请求;
  • 显示器或 DPI 变化。

这些事件在各平台的时序和可观测程度不同。不能假定 AppLifecycleState 在所有平台都提供完全相同的状态序列。


3.4 控制桌面窗口需要平台能力或插件

Flutter 核心 Widget API 不提供完整的跨桌面窗口管理接口,例如:

  • 设置顶级窗口初始位置;
  • 设置窗口标题;
  • 设置最小和最大尺寸;
  • 隐藏系统标题栏;
  • 监听关闭请求;
  • 创建多个独立顶级窗口。

常见做法是使用桌面窗口插件。以 window_manager 为例,使用前先加入依赖:

flutter pub add window_manager

然后在 main.dart 中初始化:

import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await windowManager.ensureInitialized();

  const options = WindowOptions(
    size: Size(1100, 700),
    minimumSize: Size(700, 450),
    center: true,
    title: '跨平台示例',
  );

  await windowManager.waitUntilReadyToShow(options, () async {
    await windowManager.show();
    await windowManager.focus();
  });

  runApp(const App());
}

class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: Center(child: Text('Window manager example')),
      ),
    );
  }
}

这个例子成立的前提是:

  1. 当前 Flutter 项目已经启用桌面平台;
  2. window_manager 版本支持目标平台;
  3. 依赖安装后完成了对应平台的构建配置;
  4. 不把这段初始化用于 Web、Android 或 iOS。

如果同一份代码要同时编译 Web 和移动端,不能无条件导入桌面窗口插件。应使用条件导入或在平台层隔离。


四、文件:路径、内容、权限和用户授权不是一回事

4.1 “文件”至少包含四个不同概念

跨平台文件处理时,应区分:

  1. 文件内容:字节序列;
  2. 文件名:用户可见的名称;
  3. 文件路径或 URI:操作系统定位文件的标识;
  4. 访问权限:当前应用是否有权读取或写入。

Web 文件选择往往能可靠得到第 1 和第 2 项,但不保证提供第 3 项。移动端可能得到 URI 而不是传统路径。桌面端通常可以得到路径,但路径的长期有效性仍取决于文件是否被移动、删除或权限是否变化。


4.2 一个可运行的跨平台文件选择示例

安装文件选择插件:

flutter pub add file_picker

完整示例:

import 'dart:typed_data';

import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const MaterialApp(home: FileDemo()));
}

class FileDemo extends StatefulWidget {
  const FileDemo({super.key});

  @override
  State<FileDemo> createState() => _FileDemoState();
}

class _FileDemoState extends State<FileDemo> {
  String? message;
  Uint8List? content;

  Future<void> pickFile() async {
    setState(() {
      message = '正在选择文件……';
      content = null;
    });

    try {
      final result = await FilePicker.platform.pickFiles(
        withData: true,
        type: FileType.custom,
        allowedExtensions: <String>['txt', 'json', 'csv'],
      );

      if (!mounted) {
        return;
      }

      if (result == null || result.files.isEmpty) {
        setState(() {
          message = '用户取消了选择';
        });
        return;
      }

      final file = result.files.single;
      final bytes = file.bytes;

      if (bytes == null) {
        setState(() {
          message = '未获得文件内容;请检查插件参数和平台实现';
        });
        return;
      }

      setState(() {
        content = bytes;
        message = [
          'name: ${file.name}',
          'size: ${file.size} bytes',
          'path: ${file.path ?? '(当前平台不提供路径)'}',
        ].join('\n');
      });
    } catch (error, stackTrace) {
      debugPrint('pick file failed: $error\n$stackTrace');

      if (!mounted) {
        return;
      }

      setState(() {
        message = '选择文件失败:$error';
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('文件选择')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            ElevatedButton(
              onPressed: pickFile,
              child: const Text('选择文本文件'),
            ),
            const SizedBox(height: 16),
            SelectableText(message ?? '尚未选择文件'),
            if (content != null) ...[
              const SizedBox(height: 16),
              Text('已载入 ${content!.length} 个字节'),
            ],
          ],
        ),
      ),
    );
  }
}

输入是用户选择的 txtjsoncsv 文件,预期输出是文件名、大小、可用路径和字节数。

这段代码中的几个细节具有平台原因:

  • withData: true 要求插件返回文件字节;
  • file.bytes 是可空的,不能无条件使用;
  • file.path 在 Web 中通常为空,不能用它判断选择失败;
  • result == null 表示用户取消,不应当当成异常;
  • try/catch 处理的是权限、插件实现、系统对话框等运行时失败;
  • 选择后把整个文件读入内存,适合小文件,不适合无界的大文件。

4.3 这段代码在不同平台的真实语义

Web

浏览器会弹出文件选择界面。应用一般可以获得:

  • 文件名;
  • 文件大小;
  • 文件类型;
  • 文件内容。

应用通常不能获得用户本地文件的绝对路径,也不能任意读取用户磁盘中的其他文件。浏览器刻意阻止了这类访问。

因此以下代码不应作为 Web 逻辑:

final path = file.path!;

即使某些浏览器或插件版本返回了看似路径的字符串,也不能把它当作可长期使用的本地路径。

Web 保存文件通常表现为浏览器下载,而不是应用自由写入用户指定目录。若需要写入用户选择的位置,应使用浏览器提供的文件系统能力,并确认目标浏览器和 Flutter 插件支持情况。

Windows、macOS、Linux

通常可以获得本地路径,也可以通过 dart:io 打开文件:

import 'dart:io';

Future<String> readTextFile(String path) {
  return File(path).readAsString();
}

但这个文件不能被 Web 编译。dart:io 属于非 Web 平台库,导入阶段就可能导致 Web 编译失败。

Android

现代 Android 通常包含应用沙盒和分区存储约束。通过系统文档选择器获得的可能是 URI,而不是可以直接交给 File(path) 的路径。应用应优先使用插件提供的读取能力,或使用平台 API 将 URI 内容流式读取。

iOS

应用通常运行在沙盒中。用户通过文档选择器选择的文件可能受安全作用域和系统授权管理。不能把“用户选过一次”简单理解为应用永久拥有该文件的任意访问权。


4.4 应用数据目录和用户选中文件不是同一类存储

path_provider 可以帮助应用获取平台相关目录:

flutter pub add path_provider

例如桌面或移动端可以使用:

import 'dart:io';

import 'package:path_provider/path_provider.dart';

Future<File> appConfigFile() async {
  final directory = await getApplicationSupportDirectory();
  return File('${directory.path}${Platform.pathSeparator}config.json');
}

这里仍然存在两个边界:

  1. 代码使用了 dart:io,不能直接用于 Web;
  2. ApplicationSupportDirectory 是应用支持目录,不等于用户可见的“下载”目录,也不等于任意外部文件位置。

如果应用要保存用户明确选择的文档,应保存文档标识、复制内容,或者通过平台提供的持久授权机制管理访问,而不是把一次性临时路径当作永久数据库主键。


4.5 大文件的内存风险

示例使用 withData: true,其数据流可以表示为:

磁盘或浏览器文件
    -> 插件读取
    -> Uint8List
    -> Dart 堆内存
    -> 应用解析

若文件大小为 SS,则至少需要与 SS 同量级的字节存储;解析为字符串、JSON 对象或图片时还会产生额外副本。实际峰值可能接近:

MpeakMbytes+Mdecoded+MtemporaryM_{\text{peak}} \approx M_{\text{bytes}} + M_{\text{decoded}} + M_{\text{temporary}}

所以:

  • 小型配置文件可以使用 withData: true
  • 大型日志、视频和数据库文件应优先考虑流式读取;
  • Web 端尤其要注意浏览器 Tab 的内存上限;
  • 图片解码后的内存可能远大于压缩文件大小。

五、输入:鼠标、触摸、键盘和输入法走不同路径

5.1 Flutter 输入事件的共同流程

输入事件大致经过:

操作系统或浏览器
    -> Embedder
    -> Flutter Engine
    -> Flutter Binding
    -> 命中测试 / Focus
    -> 手势识别器或快捷键系统
    -> Widget 回调

同一个“点击”在不同平台上可能来自:

  • 鼠标左键;
  • 手指触摸;
  • 触控笔;
  • 触控板模拟的指针事件;
  • 浏览器触摸事件转换结果。

因此,使用 GestureDetector 时得到的是 Flutter 的统一手势抽象,而不是原始设备的全部信息。

如果需要区分设备类型,可以读取 PointerEvent.kind

Listener(
  onPointerDown: (event) {
    debugPrint('pointer kind: ${event.kind}');
  },
  child: const SizedBox(
    width: 200,
    height: 100,
    child: ColoredBox(color: Colors.blue),
  ),
)

5.2 Pointer、Gesture 和滚轮不是同一层

  • Listener 接收较底层的指针事件;
  • GestureDetector 识别点击、拖动、缩放等手势;
  • MouseRegion 处理鼠标进入、离开和移动;
  • ScrollableScrollPosition 和滚动物理处理滚动;
  • PointerSignalEvent 可表示滚轮等指针信号。

例如鼠标悬停在桌面端有意义,但触摸屏通常没有同等的 hover 语义:

MouseRegion(
  onEnter: (_) => debugPrint('mouse entered'),
  onExit: (_) => debugPrint('mouse exited'),
  child: const Text('桌面悬停区域'),
)

不能把 hover 作为完成操作的唯一入口,因为:

  • 手机没有持续悬停;
  • Web 可能在触摸设备上运行;
  • 触控笔的 hover 支持依赖硬件和平台;
  • 辅助输入设备可能不产生普通鼠标事件。

5.3 键盘事件、字符输入和输入法是三件事

键盘相关概念应分开:

  1. 物理按键事件:用户按下了哪个键;
  2. 逻辑按键:该按键在当前键盘布局中代表什么;
  3. 文本输入事件:输入法最终提交了什么字符。

例如用户按下物理键 A

  • 英文布局可能得到 a
  • 大写锁定可能得到 A
  • 中文输入法可能先产生组合状态,之后提交汉字;
  • macOS、Windows 和 Web 的修饰键语义不同。

文本编辑框应使用 TextFieldEditableText 等文本输入控件,不要通过普通键盘事件自行拼接用户文字,否则会破坏中文、日文、韩文输入法和组合字符处理。


5.4 使用 Focus 和 KeyEvent 实现快捷键

当前 Flutter 键盘 API 使用 KeyEvent 体系和 HardwareKeyboard。下面的示例实现一个跨平台保存快捷键:

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class SaveShortcut extends StatelessWidget {
  const SaveShortcut({
    super.key,
    required this.onSave,
    required this.child,
  });

  final VoidCallback onSave;
  final Widget child;

  KeyEventResult handleKeyEvent(
    FocusNode node,
    KeyEvent event,
  ) {
    final isDown = event is KeyDownEvent;
    final isSaveKey = event.logicalKey == LogicalKeyboardKey.keyS;

    final isPrimaryModifier =
        HardwareKeyboard.instance.isControlPressed ||
        HardwareKeyboard.instance.isMetaPressed;

    if (isDown && isPrimaryModifier && isSaveKey) {
      onSave();
      return KeyEventResult.handled;
    }

    return KeyEventResult.ignored;
  }

  @override
  Widget build(BuildContext context) {
    return Focus(
      autofocus: true,
      onKeyEvent: handleKeyEvent,
      child: child,
    );
  }
}

使用:

SaveShortcut(
  onSave: () {
    debugPrint('save requested');
  },
  child: const TextField(
    decoration: InputDecoration(
      labelText: '编辑内容,然后按 Ctrl/Cmd + S',
    ),
  ),
)

这里的判断过程是:

  1. event is KeyDownEvent:只处理按下,避免按住键时重复触发释放逻辑;
  2. logicalKey == keyS:判断逻辑键,而不是硬编码物理扫描码;
  3. isControlPressed:覆盖 Windows、Linux 和常见 Web 场景;
  4. isMetaPressed:覆盖 macOS 的 Command 键;
  5. 返回 handled:表示快捷键已经消费,不继续向上层传播;
  6. 其他事件返回 ignored:让焦点树中的其他处理器继续有机会处理。

对复杂快捷键系统,优先使用 ShortcutsActionsFocusableActionDetector,因为它们可以把“按键组合”和“业务动作”分离:

class SaveIntent extends Intent {
  const SaveIntent();
}

class SaveButton extends StatelessWidget {
  const SaveButton({super.key});

  @override
  Widget build(BuildContext context) {
    return Shortcuts(
      shortcuts: const <ShortcutActivator, Intent>{
        SingleActivator(
          LogicalKeyboardKey.keyS,
          control: true,
        ): SaveIntent(),
        SingleActivator(
          LogicalKeyboardKey.keyS,
          meta: true,
        ): SaveIntent(),
      },
      child: Actions(
        actions: <Type, Action<Intent>>{
          SaveIntent: CallbackAction<SaveIntent>(
            onInvoke: (_) {
              debugPrint('save');
              return null;
            },
          ),
        },
        child: const TextField(),
      ),
    );
  }
}

需要注意,快捷键是否生效还取决于焦点是否位于该 Widget 子树中,以及文本编辑控件是否优先消费了该事件。


5.5 输入法和 TextField 的生命周期

TextField 的输入流程通常是:

FocusNode 获得焦点
    -> Flutter 向宿主请求文本输入连接
    -> 平台键盘或输入法产生编辑状态
    -> Flutter 收到组合文本和提交文本
    -> TextEditingController 更新
    -> Widget 重建

如果页面切换、弹窗打开或窗口失活,没有正确管理焦点,可能出现:

  • 输入法仍然弹出;
  • 键盘事件发给错误的控件;
  • 组合文本中途丢失;
  • 桌面端焦点环消失;
  • Web 输入框看似聚焦但快捷键失效。

不要在每次 build 中新建 TextEditingControllerFocusNode。它们属于有生命周期的对象,应在 State 中创建并在 dispose 中释放:

class NameEditor extends StatefulWidget {
  const NameEditor({super.key});

  @override
  State<NameEditor> createState() => _NameEditorState();
}

class _NameEditorState extends State<NameEditor> {
  late final TextEditingController controller;
  late final FocusNode focusNode;

  @override
  void initState() {
    super.initState();
    controller = TextEditingController();
    focusNode = FocusNode();
  }

  @override
  void dispose() {
    controller.dispose();
    focusNode.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: controller,
      focusNode: focusNode,
    );
  }
}

六、平台识别:编译平台、运行平台和能力判断

6.1 dart.library.iodart.library.html

条件导入在编译期选择实现,适合隔离 Web 与非 Web 不可共存的库。

公共文件 platform_info.dart

export 'platform_info_stub.dart'
    if (dart.library.io) 'platform_info_io.dart'
    if (dart.library.html) 'platform_info_web.dart';

默认实现 platform_info_stub.dart

bool get supportsNativeFilePath => false;

非 Web 实现 platform_info_io.dart

bool get supportsNativeFilePath => true;

Web 实现 platform_info_web.dart

bool get supportsNativeFilePath => false;

业务代码只导入公共文件:

import 'platform_info.dart';

void printCapabilities() {
  if (supportsNativeFilePath) {
    print('可以调用非 Web 文件实现');
  } else {
    print('使用 Web 或通用内容流实现');
  }
}

这样做的原因是:即使代码位于 if (kIsWeb) 分支中,编译器仍可能在编译阶段处理无条件导入的 dart:io。运行时判断不能修复编译期库不可用问题。


6.2 Platform.isWindows 不能用于 Web 通用代码

下面的代码只能出现在已经确认可编译到 dart:io 的实现中:

import 'dart:io';

if (Platform.isWindows) {
  // Windows 专用逻辑
}

它不应直接出现在需要编译 Web 的公共 Dart 文件中。

Flutter 还提供 defaultTargetPlatform,但它主要表示 Flutter 平台枚举,不等于完整能力探测:

switch (Theme.of(context).platform) {
  case TargetPlatform.windows:
    // ...
    break;
  default:
    // ...
}

平台枚举适合选择视觉风格或交互提示;对于文件选择、窗口控制、通知、剪贴板等能力,更可靠的方式是:

  1. 使用跨平台插件;
  2. 在插件层处理平台差异;
  3. 对不可用能力返回明确错误;
  4. 必要时做能力探测,而不是只看平台名称。

6.3 “平台判断”和“能力判断”不同

例如:

平台判断:当前是不是 Web?
能力判断:当前是否可以取得本地绝对路径?

两者不是同一个问题。某些桌面沙盒环境也可能限制文件访问,某些 Web 浏览器也可能提供更强的文件系统 API。

业务层更应该依赖能力接口:

abstract interface class DocumentStore {
  Future<DocumentData?> open();
  Future<void> save(DocumentData data);
}

然后分别提供:

  • WebDocumentStore:上传和下载;
  • DesktopDocumentStore:文件路径和原生选择器;
  • MobileDocumentStore:系统文档 URI 或应用沙盒。

这样 UI 关心的是“打开文档”和“保存文档”,而不是散落在 Widget 中的 if (kIsWeb)


七、平台差异对照

能力 Android / iOS Windows / macOS / Linux Web
顶级窗口大小 通常由系统管理,应用响应可用区域 用户可调整,应用可通过插件控制 浏览器和页面视口控制
Flutter 绘制 Engine 绘制到原生表面 Engine 绘制到原生窗口表面 Canvas、WebGL、WebAssembly 等 Web 路径
本地绝对路径 受沙盒、URI 和系统存储策略影响 通常可用 通常不可用
用户文件选择 系统文档选择器 原生文件对话框 浏览器文件选择器
任意磁盘访问 不允许 取决于权限和系统策略 不允许
鼠标 hover 设备支持时可用 常见 桌面浏览器常见,触摸设备不一定
键盘 外接键盘或软键盘 常见 依赖浏览器焦点和页面快捷键
输入法 系统 IME 系统 IME 浏览器 IME
应用关闭 生命周期由移动系统管理 可监听部分关闭流程 Tab、页面、浏览器生命周期
多窗口 通常不是普通应用模型 需要窗口插件或原生实现 多 Tab 或浏览器窗口,不等于 Flutter 多 View

表格中的“通常”不能理解为规范保证。插件、操作系统版本、浏览器设置、沙盒配置和硬件都会改变实际行为。


八、常见失败表现与诊断顺序

8.1 Web 构建报 dart:io 不可用

失败原因通常不是 Platform.isWindows 判断写错,而是公共代码直接导入了 dart:io

诊断:

flutter build web

检查错误堆栈中最早出现的非 Web 库导入位置。修复方法是:

  • 用条件导入隔离;
  • 把文件实现放到平台专用文件;
  • 公共层只依赖抽象接口。

8.2 Web 中 file.path 为空

这通常不是文件选择失败。Web 的安全模型本来就不要求向页面暴露本地绝对路径。

应检查:

if (file.bytes != null) {
  // 使用内容
} else if (file.path != null) {
  // 在非 Web 平台使用路径
} else {
  // 当前插件实现没有提供可读取数据
}

不能通过把浏览器返回的文件名拼接到某个目录来“构造路径”。


8.3 桌面窗口尺寸设置无效

常见原因包括:

  • 没有安装或初始化窗口插件;
  • runApp 后才设置初始窗口,导致首帧已经显示;
  • 只设置了 Widget 尺寸,没有设置顶级窗口;
  • 在 Web 或移动端调用桌面插件;
  • 插件版本与当前 Flutter 桌面嵌入实现不匹配。

应分别验证:

窗口外框尺寸
    != Flutter View 尺寸
    != MediaQuery 逻辑尺寸
    != 实际物理像素尺寸

先用 MediaQueryView.of(context) 打印 Flutter 视图信息,再检查窗口插件日志,避免把两个层次混为一谈。


8.4 桌面快捷键不工作

按以下顺序诊断:

  1. 目标 Widget 是否拥有焦点;
  2. Focus 是否位于事件目标的祖先树中;
  3. 是否判断了 KeyDownEvent
  4. 是否误用了物理键和逻辑键;
  5. 文本编辑控件是否先消费了事件;
  6. macOS 是否应使用 Command 而不是 Control;
  7. 浏览器是否拦截了浏览器级快捷键;
  8. 是否在 build 中反复创建并丢失了 FocusNode

可以先打印:

onKeyEvent: (node, event) {
  debugPrint(
    'event=$event '
    'logical=${event.logicalKey} '
    'physical=${event.physicalKey}',
  );
  return KeyEventResult.ignored;
},

先确认事件是否到达,再检查业务条件。


8.5 Web 首次打开慢,但桌面端正常

应分开测量:

  • 静态资源下载时间;
  • Web 渲染器初始化;
  • Dart 应用初始化;
  • 首帧构建;
  • 图片和字体解码;
  • 运行时网络请求。

不要只看 flutter build web 输出目录大小,也不要用桌面端启动时间代替浏览器用户体验。部署时还应验证:

  • WASM 文件的 Content-Type
  • gzip 或 Brotli 压缩;
  • 缓存头;
  • CDN 是否错误缓存入口 HTML;
  • 浏览器控制台是否有 WebGL、WASM 或跨域错误。

九、工程边界:把共享逻辑和平台实现分开

一个适合跨平台项目的分层方式如下:

UI 层
  -> DocumentController / WindowController / InputController
  -> 抽象能力接口
  -> 平台实现
      - Web
      - Desktop
      - Android
      - iOS

例如,文件控制器不直接暴露 File

class DocumentData {
  const DocumentData({
    required this.name,
    required this.bytes,
  });

  final String name;
  final Uint8List bytes;
}

abstract interface class DocumentRepository {
  Future<DocumentData?> open();
  Future<void> save(DocumentData document);
}

这样 UI 可以处理统一的 DocumentData,而平台实现分别决定:

  • 使用路径还是字节;
  • 使用系统选择器还是浏览器上传;
  • 保存为本地文件还是浏览器下载;
  • 是否需要权限;
  • 是否支持流式读取。

这比在每个按钮回调中同时判断 Web、Windows、Android 和 iOS 更容易测试,也能避免公共代码误导入不可用库。


十、验证一个跨平台功能的最小矩阵

涉及渲染、窗口、文件和输入的功能,至少应验证以下组合:

Web:桌面浏览器 + 触摸或窄视口
Windows 或 macOS:鼠标 + 键盘 + 可调整窗口
Android:触摸 + 系统输入法 + 返回/后台
iOS:触摸 + 输入法 + 沙盒文件选择

每个平台应验证不同的事实:

  • 渲染:首帧、缩放、字体、阴影、自定义绘制;
  • 窗口:尺寸变化、DPI、最小化、关闭和恢复;
  • 文件:取消、权限拒绝、路径为空、大文件和格式错误;
  • 输入:焦点、IME、快捷键、鼠标 hover、触摸拖动;
  • 生命周期:后台、恢复、页面切换、窗口关闭。

Flutter 的跨平台价值在于共享 Widget、状态管理和业务逻辑,但平台差异并不会因为 API 名称相同而消失。准确的做法是:共享数据和意图,隔离平台能力;共享布局规则,适配可用窗口;共享输入动作,保留设备和输入法差异;共享文件内容模型,不假设所有平台都存在同一种路径。

当这些边界被明确后,Flutter Web、桌面、移动端之间的差异就不再是零散的兼容性问题,而会变成可以通过渲染后端、View 生命周期、能力接口和平台实现分别验证的工程模型。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。