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

Flutter 平台集成:Plugin、Platform Channel、原生生命周期和权限

Flutter 应用运行在 Dart 虚拟机或编译后的 Dart 代码之上,但文件系统、相机、蓝牙、推送、传感器、系统设置等能力属于宿主平台。平台集成就是把 Dart 层的业务代码与 Android、iOS、桌面或 Web 宿主提供的能力连接起来。

这条链路通常包含四个部分:

  1. Dart 层发起调用或监听事件;
  2. Flutter 引擎通过 Platform Channel 编码并传递消息;
  3. 原生侧的 Plugin 或自定义代码接收消息,调用平台 API;
  4. 原生侧把结果、异常或事件再传回 Dart 层。
sequenceDiagram
    participant D as Dart/Flutter
    participant C as Platform Channel
    participant N as 原生 Plugin
    participant OS as Android/iOS API

    D->>C: invokeMethod("getBatteryLevel")
    C->>N: 解码方法名和参数
    N->>OS: 调用系统 API
    OS-->>N: 返回结果或错误
    N-->>C: success/error/notImplemented
    C-->>D: Future 完成或抛出异常

这不是一个“把 Dart 代码转换成原生代码”的过程。Dart 和原生代码仍然运行在各自的运行时中,Platform Channel 只负责跨运行时传递结构化消息。

Plugin、Package 与 Platform Channel 的关系

Package 是 Dart 依赖,Plugin 是带平台实现的 Package

在 Dart 和 Flutter 生态中,Package 是可复用的代码包。它可以只有 Dart 实现,例如:

my_utils/
  lib/
    my_utils.dart
  pubspec.yaml

Plugin 是一种包含平台实现的 Package,通常同时提供:

my_camera_plugin/
  lib/                  # Dart API
  android/              # Android 实现
  ios/                  # iOS 实现
  macos/                # macOS 实现
  windows/              # Windows 实现
  linux/                # Linux 实现
  web/                  # Web 实现
  pubspec.yaml

Plugin 不一定必须使用 Platform Channel。例如某些插件使用 FFI、Web API 或平台视图;但传统的 Android/iOS 系统能力集成通常使用 Platform Channel。

一个 Plugin 至少要解决三件事:

  • 暴露稳定的 Dart API;
  • 在各个平台注册并实现对应能力;
  • 处理平台生命周期、线程、权限和资源释放。

因此,下面的关系是准确的:

Flutter Package
├── 纯 Dart Package
└── Plugin
    ├── Dart API
    └── 一个或多个平台实现
        └── 可使用 Platform Channel、FFI、Platform View 等技术

pubspec.yaml 中声明依赖后,Flutter 会根据当前目标平台处理插件注册。现代 Flutter Android 使用插件自动注册机制,通常不需要在 MainActivity 中手动调用每个插件的注册方法。

使用插件不等于获得权限

插件只是封装能力。例如相机插件可能封装了:

  • 相机设备枚举;
  • 预览纹理;
  • 拍照;
  • 视频录制;
  • 音频采集;
  • 生命周期暂停与恢复。

但最终是否允许访问相机,仍由 Android 或 iOS 的权限系统决定。插件可以请求权限,也可以把权限请求交给应用层;具体行为要查看插件 API 和目标平台版本。

Platform Channel 的四种核心模型

Platform Channel 是 Flutter Engine 提供的跨平台消息机制。它使用二进制消息传递,常见模型如下:

类型 Dart API 适合场景
MethodChannel invokeMethod 一次请求对应一次结果
EventChannel receiveBroadcastStream 原生持续推送事件
BasicMessageChannel send / setMessageHandler 双向消息,不限定为方法调用
MethodChannel + 自定义协议 方法调用 复杂协议,但需要自行维护兼容性

MethodChannel:请求—响应

方法调用的抽象可以表示为:

(method: String, arguments: dynamic) -> Future<dynamic>

原生侧必须对调用结果做三选一:

  1. 成功:返回可编码的值;
  2. 业务或平台错误:返回错误码、错误消息和详细信息;
  3. 不支持:明确返回 notImplemented

Dart 侧可以使用如下通道:

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

class BatteryService {
  static const _channel = MethodChannel('com.example.device/battery');

  Future<int?> getBatteryLevel() async {
    if (!Platform.isAndroid && !Platform.isIOS) {
      return null;
    }

    try {
      final level = await _channel.invokeMethod<int>('getBatteryLevel');
      return level;
    } on PlatformException catch (e) {
      throw BatteryException(
        code: e.code,
        message: e.message ?? '读取电量失败',
        details: e.details,
      );
    } on MissingPluginException {
      throw BatteryException(
        code: 'missing_plugin',
        message: '当前平台没有注册电量插件',
      );
    }
  }
}

class BatteryException implements Exception {
  BatteryException({
    required this.code,
    required this.message,
    this.details,
  });

  final String code;
  final String message;
  final Object? details;

  @override
  String toString() => 'BatteryException($code): $message';
}

这里的 int 不是 Dart 侧强制转换得到的任意数字,而是依赖消息 Codec 将原生侧整数解码为 Dart 整数。原生侧应返回 0100 之间的整数;如果返回字符串或结构不一致,Dart 侧会出现类型错误或协议错误。

Android Kotlin 实现可以写成:

package com.example.app

import android.content.Context
import android.os.BatteryManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity : FlutterActivity() {
    private val channelName = "com.example.device/battery"

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        MethodChannel(
            flutterEngine.dartExecutor.binaryMessenger,
            channelName
        ).setMethodCallHandler { call, result ->
            when (call.method) {
                "getBatteryLevel" -> {
                    val manager =
                        getSystemService(Context.BATTERY_SERVICE) as BatteryManager

                    val level = manager.getIntProperty(
                        BatteryManager.BATTERY_PROPERTY_CAPACITY
                    )

                    if (level in 0..100) {
                        result.success(level)
                    } else {
                        result.error(
                            "unavailable",
                            "系统未返回有效电量",
                            level
                        )
                    }
                }

                else -> result.notImplemented()
            }
        }
    }
}

iOS Swift 实现:

import Flutter
import UIKit

@main
@objc class AppDelegate: FlutterAppDelegate {
    private let channelName = "com.example.device/battery"

    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions:
            [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        GeneratedPluginRegistrant.register(with: self)

        guard let controller = window?.rootViewController
            as? FlutterViewController else {
            return super.application(
                application,
                didFinishLaunchingWithOptions: launchOptions
            )
        }

        let channel = FlutterMethodChannel(
            name: channelName,
            binaryMessenger: controller.binaryMessenger
        )

        channel.setMethodCallHandler { call, result in
            guard call.method == "getBatteryLevel" else {
                result(FlutterMethodNotImplemented)
                return
            }

            UIDevice.current.isBatteryMonitoringEnabled = true
            let value = UIDevice.current.batteryLevel

            guard value >= 0 else {
                result(FlutterError(
                    code: "unavailable",
                    message: "系统未返回电池电量",
                    details: nil
                ))
                return
            }

            result(Int(value * 100))
        }

        return super.application(
            application,
            didFinishLaunchingWithOptions: launchOptions
        )
    }
}

这个例子完整展示了协议两端:

Dart 方法名:getBatteryLevel
Android 方法名:getBatteryLevel
iOS 方法名:getBatteryLevel
成功值:整数百分比
错误:PlatformException(code, message, details)
未知方法:notImplemented

如果 Dart 侧和原生侧的通道名称不完全相同,例如一侧使用 battery、另一侧使用 com.example.device/battery,调用不会进入处理器,常见结果是 MissingPluginException 或调用永远没有得到预期结果。

EventChannel:原生事件流

电量变化、位置、传感器、蓝牙状态等不是一次请求,而是一个事件序列:

E = e1, e2, e3, ...

此时 EventChannel 的关键不是“返回一个值”,而是管理订阅和取消订阅。

Dart 侧:

import 'package:flutter/services.dart';

class BatteryEvents {
  static const _channel = EventChannel('com.example.device/battery_events');

  Stream<int> watch() {
    return _channel.receiveBroadcastStream().map((value) {
      if (value is! int || value < 0 || value > 100) {
        throw StateError('非法电量事件: $value');
      }
      return value;
    });
  }
}

原生 Android 侧需要在监听开始时注册系统监听,在取消时注销:

private class BatteryStreamHandler(
    private val context: Context
) : EventChannel.StreamHandler {
    private var receiver: BroadcastReceiver? = null

    override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
        if (events == null) return

        val filter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)

        receiver = object : BroadcastReceiver() {
            override fun onReceive(context: Context, intent: Intent) {
                val level = intent.getIntExtra(
                    BatteryManager.EXTRA_LEVEL,
                    -1
                )
                val scale = intent.getIntExtra(
                    BatteryManager.EXTRA_SCALE,
                    -1
                )

                if (level >= 0 && scale > 0) {
                    events.success(level * 100 / scale)
                }
            }
        }

        context.registerReceiver(receiver, filter)
    }

    override fun onCancel(arguments: Any?) {
        receiver?.let { context.unregisterReceiver(it) }
        receiver = null
    }
}

注册:

EventChannel(
    flutterEngine.dartExecutor.binaryMessenger,
    "com.example.device/battery_events"
).setStreamHandler(BatteryStreamHandler(this))

如果忘记实现 onCancel 的资源释放,页面反复进入退出时可能积累广播监听器;如果多个 Dart 订阅共享同一个原生监听器,还必须定义引用计数或明确“单订阅”限制。事件流还可能在 Flutter 页面已经销毁后继续产生事件,因此 Dart 层应在 dispose 中取消订阅。

EventChannel 并不自动提供事件持久化。App 在后台被系统杀死期间发生的事件通常不会在恢复后自动补发,除非原生侧自行保存状态或使用系统提供的持久化机制。

BasicMessageChannel:双向消息

BasicMessageChannel 适合双方都需要主动发送消息、且消息不必表现为“调用某个方法”的场景。例如:

  • 原生 UI 状态同步;
  • 自定义文本或 JSON 协议;
  • 低层消息总线。
static const channel = BasicMessageChannel<String>(
  'com.example.protocol',
  StringCodec(),
);

Future<void> send(String message) async {
  await channel.send(message);
}

void listen() {
  channel.setMessageHandler((message) async {
    return 'Dart received: $message';
  });
}

BasicMessageChannel 的协议可靠性完全由应用负责。需要约定消息类型、版本字段、未知字段处理和错误格式,否则通道会退化成没有类型检查的字符串接口。

Codec 决定哪些数据能跨平台传递

默认 MethodCodec 通常支持:

  • null
  • 布尔值;
  • 整数和浮点数;
  • 字符串;
  • 字节数组;
  • List;
  • Map。

实际可传输的数据必须能被所选 Codec 编码。以下对象不能直接传递:

// 错误:DateTime、File、Stream 等不是默认 Codec 的直接值类型
await channel.invokeMethod('upload', {
  'file': File('/tmp/a.jpg'),
  'time': DateTime.now(),
});

应显式转换:

await channel.invokeMethod('upload', {
  'path': file.path,
  'timeMillis': DateTime.now().millisecondsSinceEpoch,
});

大文件不应通过 MethodChannel 一次性传递为超大字节数组。更常见的设计是传递文件路径、临时文件标识或分块协议,再由原生侧使用文件流处理。这样可以避免 Dart 堆、原生堆和消息复制同时占用内存。

从自定义通道发展为正式 Plugin

把通道直接写在 MainActivityAppDelegate 中适合验证概念,但不适合作为可复用 Plugin 的最终结构。原因是:

  • Activity 可能重建;
  • iOS 可能切换 Scene;
  • Plugin 需要支持多个 FlutterEngine;
  • 原生资源不应绑定某个具体页面;
  • 测试和平台扩展会变得困难。

Flutter Plugin 的 Android 侧通常实现 FlutterPlugin

class BatteryPlugin : FlutterPlugin {
    private var channel: MethodChannel? = null
    private var context: Context? = null

    override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
        context = binding.applicationContext
        channel = MethodChannel(
            binding.binaryMessenger,
            "com.example.device/battery"
        )

        channel?.setMethodCallHandler { call, result ->
            when (call.method) {
                "getBatteryLevel" -> {
                    val manager = context?.getSystemService(
                        Context.BATTERY_SERVICE
                    ) as? BatteryManager

                    val level = manager?.getIntProperty(
                        BatteryManager.BATTERY_PROPERTY_CAPACITY
                    ) ?: -1

                    if (level in 0..100) {
                        result.success(level)
                    } else {
                        result.error("unavailable", "电量不可用", null)
                    }
                }
                else -> result.notImplemented()
            }
        }
    }

    override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
        channel?.setMethodCallHandler(null)
        channel = null
        context = null
    }
}

如果能力需要 Activity,例如:

  • 请求运行时权限;
  • 启动系统选择器;
  • 显示原生页面;
  • 获取当前窗口;
  • 接收 Activity Result;

则还需要处理 ActivityAware 的绑定与解绑。不能把 Activity 永久保存为全局对象,否则 Activity 重建后会导致泄漏或调用旧窗口。

iOS Plugin 则通常在 register(with:) 中创建通道,并通过 Registrar 提供的 messenger 与宿主通信。需要依赖 UIViewController 时,应确认当前 FlutterViewController 或 Scene 的归属,不能假设应用永远只有一个窗口。

Pigeon:为通道生成类型安全接口

手写 invokeMethod<String> 的问题是方法名、参数 Map 和返回类型都依赖运行时约定。Pigeon 可以根据接口定义生成 Dart、Kotlin/Java、Swift 等代码,减少以下错误:

Dart 写 getUser,Android 写 getUserInfo
Dart 传 userId,iOS 读取 id
Dart 认为返回 int,原生返回 String

Pigeon 适合较大的 Plugin 或团队维护的稳定协议。它仍然建立在平台消息传输之上,不会消除平台 API、权限和生命周期问题。生成代码应纳入版本控制或由构建流程稳定生成,并对协议变更做兼容处理。

线程、并发与错误传播

通道调用是异步边界

Dart 侧的:

final result = await channel.invokeMethod('read');

表示当前 Future 等待原生侧结果,并不表示原生 API 一定在后台线程执行。通道本身解决的是跨运行时通信,不自动把耗时工作移到后台。

原生侧应遵守平台线程要求:

  • Android UI 和许多系统组件要求在主线程创建或操作;
  • iOS UIKit 必须在主线程使用;
  • 文件压缩、数据库查询、视频转码等耗时任务不应阻塞主线程;
  • 完成后要把结果安全地送回 Flutter 通道。

Dart 侧也不能在主 Isolate 中执行大型 CPU 计算而期待 Platform Channel 自动解决卡顿。需要时可使用 Isolate.runcompute 或原生侧合适的后台机制,但跨 Isolate 使用插件还要满足该插件支持的 BinaryMessenger 和线程条件。不能假设任意插件都能在后台 Isolate 安全调用。

一个方法只能完成一次

原生侧必须保证每次调用只对 result 调用一次:

success -> 调用结束
error -> 调用结束
notImplemented -> 调用结束

下面的逻辑是错误的:

result.success("started")
doAsyncWork {
    result.success("finished") // 同一个调用重复返回
}

如果需要“开始”和“完成”两个阶段,应设计为:

  • startWork 返回任务 ID;
  • EventChannel 或轮询报告进度;
  • getWorkResult(taskId) 获取最终结果;
  • cancelWork(taskId) 取消任务。

错误应稳定地映射为可处理的错误码:

permission_denied
service_disabled
invalid_argument
not_found
unavailable
cancelled
platform_error

Dart 层根据错误码决定显示授权提示、重试、降级或记录日志,而不是只根据自然语言 message 判断。

Flutter 生命周期与原生生命周期不是同一组状态

Flutter 生命周期

Flutter 层通过 WidgetsBindingObserver.didChangeAppLifecycleState 接收 AppLifecycleState。当前稳定 Flutter 中常见状态包括:

  • resumed:应用可见并可交互;
  • inactive:应用暂时不能正常交互,例如系统界面覆盖、切换状态或某些多窗口场景;
  • hidden:应用视图不可见;
  • paused:应用在后台,Flutter 帧回调通常暂停;
  • detached:Flutter 引擎仍存在,但没有连接到宿主视图。

状态并非所有平台都以同样顺序出现。Flutter 会根据宿主平台的生命周期事件合成跨平台状态,因此不能把 Android 的每一个 Activity 回调机械映射为一个 Flutter 状态。

class CameraLifecycleController
    with WidgetsBindingObserver {
  bool _isDisposed = false;

  void start() {
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (_isDisposed) return;

    switch (state) {
      case AppLifecycleState.resumed:
        _resumeCameraIfNeeded();
        break;
      case AppLifecycleState.inactive:
      case AppLifecycleState.hidden:
      case AppLifecycleState.paused:
        _pauseCameraIfNeeded();
        break;
      case AppLifecycleState.detached:
        _releaseCamera();
        break;
    }
  }

  void dispose() {
    _isDisposed = true;
    WidgetsBinding.instance.removeObserver(this);
    _releaseCamera();
  }

  void _resumeCameraIfNeeded() {}
  void _pauseCameraIfNeeded() {}
  void _releaseCamera() {}
}

暂停和释放不是同一件事。相机预览常在 inactivepaused 时停止采集,但对象是否可以复用取决于插件;当页面销毁、引擎分离或设备资源必须让给其他应用时,才需要彻底释放。

Android 生命周期

Android 可能发生:

Activity onCreate
  -> onStart
  -> onResume
  -> onPause
  -> onStop
  -> onDestroy

onDestroy 不一定代表用户永久退出应用。旋转屏幕、分屏、主题变化或系统回收都可能导致 Activity 重建。Flutter 页面状态与 Activity 实例不能画等号。

对于需要 Activity 的 Plugin,使用 ActivityAware 处理:

onAttachedToActivity
  -> onDetachedFromActivityForConfigChanges
  -> onReattachedToActivityForConfigChanges
  -> onDetachedFromActivity

资源应绑定到正确的生命周期边界:

  • application context:适合长期存在的非 UI 服务;
  • Activity:适合窗口、权限请求和 Activity Result;
  • FlutterEngine:适合通道注册;
  • 页面 State:适合页面订阅和 UI 状态。

将 Activity 存在单例中是常见错误,因为重建后单例仍指向旧 Activity。

iOS 生命周期

iOS 新系统通常以 UIScene 管理窗口,App Delegate 生命周期与 Scene Delegate 生命周期不能混为一谈。应用可能经历:

scene willConnect
sceneDidBecomeActive
sceneWillResignActive
sceneDidEnterBackground
sceneDidDisconnect

sceneDidDisconnect 不一定意味着进程马上结束,系统可以断开 Scene 后稍后重新连接。因此需要持久化的业务状态不能只保存在内存中的 ViewController。

当 Flutter 插件启动相机、录音、位置服务时,应同时考虑:

  • 应用进入后台;
  • Scene 失去活动状态;
  • 其他 App 或系统中断占用设备;
  • 设备锁定;
  • 权限在系统设置中被撤回;
  • 组件被销毁但异步回调尚未返回。

一个完整的状态转换例子

相机资源可以抽象为:

Uninitialized
  --initialize--> Ready
  --start--> Running
Running
  --inactive/paused--> Suspended
Suspended
  --resumed--> Running
Running/Suspended
  --dispose/error--> Released

其中必须明确两个条件:

  1. start 只有在 Ready 或可恢复的 Suspended 状态允许执行;
  2. dispose 后的所有异步回调都必须被忽略,不能再次更新已销毁的 Dart 对象。

如果只在页面的 initState 初始化相机,而不监听生命周期,常见失败表现是:

  • 从后台回到前台后预览黑屏;
  • Android 相机设备被其他 Activity 占用;
  • iOS 返回后出现 AVCaptureSession 无法启动;
  • 页面销毁后仍收到帧事件并触发 setState() called after dispose()

权限:声明、请求、检查和实际可用性

权限不是一次布尔判断,而是一条状态链:

未声明/未询问
  -> 已请求
  -> 已授权
  -> 已拒绝
  -> 永久拒绝或系统限制

此外还存在“权限已授权但能力不可用”的情况,例如:

  • 用户授权相机,但摄像头被其他应用占用;
  • 用户授权定位,但系统定位服务关闭;
  • 用户授权通知,但通知被系统或用户关闭;
  • Android 存储权限满足,但目标目录不可写;
  • iOS 照片权限为有限访问,只能看到用户选择的资源。

因此,正确流程不是:

request() == granted -> 直接使用

而是:

声明权限
  -> 检查当前状态
  -> 必要时请求
  -> 处理拒绝/永久拒绝
  -> 调用平台能力
  -> 处理服务关闭、设备忙和运行时错误

Android 权限

Android 的权限通常需要在 android/app/src/main/AndroidManifest.xml 声明。例如相机和录音:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />

    <application
        android:label="example"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">
        <!-- activities and other configuration -->
    </application>
</manifest>

危险权限还需要在运行时请求。请求顺序应考虑功能依赖,例如录像同时需要相机和麦克风;不能只声明权限而假设系统会自动弹窗。

Android 存储权限具有明显版本差异:

  • Android 10 引入分区存储;
  • Android 13 将图片、视频、音频读取权限拆分为不同权限;
  • Android 13 的通知权限是运行时权限;
  • 使用系统文件选择器、Photo Picker 或应用专属目录时,通常不需要申请传统的全盘存储权限。

因此,“上传图片”不应默认申请广泛存储权限。优先使用系统选择器或 Photo Picker,既减少权限,也符合 Android 的存储模型。具体权限名称和行为必须以目标 compileSdktargetSdk 以及设备系统版本为准。

iOS 权限

iOS 需要在 Info.plist 中声明用途说明。相机和麦克风示例:

<key>NSCameraUsageDescription</key>
<string>用于拍摄和上传照片</string>
<key>NSMicrophoneUsageDescription</key>
<string>用于录制视频声音</string>

如果使用相册:

<key>NSPhotoLibraryUsageDescription</key>
<string>用于选择需要上传的照片</string>

如果应用只写入照片库,某些系统版本和 API 可能使用单独的添加权限键;具体键名应与实际调用的 Apple API 和最低系统版本一致。缺少必要用途说明时,应用可能在请求权限或访问 API 时直接终止,而不是返回普通异常。

iOS 照片权限还可能是“有限访问”。应用必须允许用户在系统选择的资源范围内工作,不能把“已授权”解释成“可以读取整个照片库”。

使用权限封装插件

项目可以使用权限插件,例如 permission_handler。Dart 侧示例:

import 'package:permission_handler/permission_handler.dart';

Future<bool> ensureCameraPermission() async {
  var status = await Permission.camera.status;

  if (status.isGranted) {
    return true;
  }

  if (status.isPermanentlyDenied || status.isRestricted) {
    await openAppSettings();
    return false;
  }

  status = await Permission.camera.request();
  return status.isGranted;
}

调用相机前:

Future<void> takePhoto() async {
  final granted = await ensureCameraPermission();

  if (!granted) {
    throw StateError('camera_permission_denied');
  }

  // 只有权限通过后,才初始化或启动相机。
}

这里仍然需要在 Android Manifest 和 iOS Info.plist 中正确配置。权限插件不能替应用填写用途说明,也不能绕过系统权限。

对于“拒绝后再次请求”的行为,不同平台和系统版本不同。若用户选择了不再询问、限制访问或系统策略禁止,重复调用 request() 可能不会再弹窗。此时应向用户解释原因,并提供应用设置入口,而不是无限重试。

文件、媒体和相机集成中的平台边界

文件与媒体是 Platform Channel 最容易暴露差异的领域。

文件路径不是跨平台统一概念

Android、iOS、桌面和 Web 的文件模型不同:

  • Android 文件 URI 可能是 content://,不一定是普通路径;
  • iOS 沙盒路径只能由应用按权限访问;
  • Web 没有可以任意传给原生插件的本地路径;
  • Windows、macOS、Linux 的路径格式、权限和文件选择器不同。

因此,Dart API 不应强制所有平台都返回一个可永久使用的字符串路径。更稳妥的抽象可以是:

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

  final String name;
  final List<int> bytes;
  final String? path;
}

但对于大视频,直接把完整 bytes 保存在内存中又可能造成内存压力。生产 API 需要区分:

小文件:bytes 或临时文件
大文件:可读流、临时文件句柄或分块上传接口

相机常见的资源竞争

相机调用失败不一定是权限问题。完整故障路径可能是:

检查权限
  -> 权限通过
  -> 检查相机设备是否存在
  -> 创建会话
  -> 绑定预览输出
  -> 启动采集
  -> 进入后台时暂停
  -> 回到前台时恢复
  -> 页面销毁时释放

任何一步都可能失败。比如:

  • 模拟器没有摄像头;
  • Android 设备正在使用相机;
  • iOS 用户在系统设置中关闭权限;
  • 页面重复初始化了两个相机会话;
  • 预览纹理还没有准备好;
  • App 回到前台时旧的原生对象已被系统释放。

所以“权限已授权”只是必要条件,不是充分条件。

Web、桌面与移动端的差异

Web

Web 目标没有 Android/iOS Plugin 的运行时环境。MethodChannel 仍可在某些 Flutter Web 场景中存在,但不能调用 Android 或 iOS API;必须提供 Web 实现,或者在 Dart 层显式拒绝:

import 'package:flutter/foundation.dart';

Future<void> openCamera() async {
  if (kIsWeb) {
    // 使用 Web 支持的摄像头方案或显示不支持提示。
    throw UnsupportedError('请使用 Web 摄像头实现');
  }

  // 移动端或桌面端实现。
}

Web 相机还受浏览器权限、HTTPS、用户手势和浏览器兼容性限制。不能因为 Android 端已经能拍照,就推断 Web 端也支持相同的文件路径、后台行为和权限 API。

桌面

桌面 Plugin 需要分别实现 Windows、macOS、Linux。即使三个平台都能访问文件系统,它们的:

  • 文件选择器;
  • 权限模型;
  • 摄像头 API;
  • 后台行为;
  • 原生窗口生命周期;

也不相同。

macOS 可能需要在 App Sandbox 和 Entitlements 中配置访问能力;Windows 可能涉及打包清单或系统能力;Linux 发行版之间还可能存在桌面环境差异。桌面应用没有移动端那种统一的运行时危险权限流程,不能简单复用 Android/iOS 的授权界面逻辑。

常见误解与诊断路径

MissingPluginException

常见原因:

  1. 当前平台没有实现该 Plugin;
  2. 通道注册代码没有执行;
  3. 修改原生代码后只做了 Hot Reload;
  4. 通道名称不一致;
  5. 在错误的 FlutterEngine 上注册;
  6. Plugin 没有被加入当前构建目标;
  7. Web 或桌面没有对应实现。

修改原生代码后应停止并重新运行应用;Hot Reload 只更新 Dart 代码,通常不会重新加载原生注册逻辑。

调用没有返回

优先检查:

  • 原生处理器是否进入;
  • 每条分支是否调用一次 successerrornotImplemented
  • 异步回调是否可能永远不触发;
  • 是否在错误线程访问平台 API 后导致异常;
  • Dart 是否在页面销毁后仍等待或使用结果。

可以在两端记录统一的请求 ID:

final requestId = DateTime.now().microsecondsSinceEpoch;
await channel.invokeMethod('read', {'requestId': requestId});

原生日志也打印该 ID,便于确认消息到底卡在 Dart、通道、Plugin 还是系统 API。

权限“通过”但功能仍失败

诊断顺序应区分:

权限状态
-> 系统服务状态
-> 设备硬件状态
-> 原生资源状态
-> Flutter 页面状态

例如定位功能还要检查系统定位服务是否开启;蓝牙还要检查适配器状态;相机还要检查设备占用和会话状态。不要只记录 isGranted,还应记录平台错误码、系统服务状态和生命周期状态。

release 构建与 debug 构建不同

平台集成依赖发布配置,常见差异包括:

  • Android Manifest 合并结果不同;
  • iOS Release 的签名、Entitlements、Info.plist 不同;
  • 原生库在 ABI、架构或链接配置上不同;
  • Web 需要 HTTPS 才能使用部分浏览器能力;
  • 桌面打包后资源路径与开发环境不同。

验证时应检查最终产物,而不是只检查源码:

flutter build apk --release
flutter build ipa --release
flutter build web --release

命令是否成功只能证明构建链路通过,不能证明用户在运行时已经获得权限或设备能力。应在真实设备和目标系统版本上验证:

首次安装 -> 首次请求 -> 拒绝 -> 再次进入 -> 系统设置授权
-> 后台恢复 -> 页面销毁 -> App 重启

平台 API 的抽象边界

一个稳定的 Flutter 平台接口应隐藏平台实现差异,但不能伪造不存在的统一能力。例如:

abstract interface class MediaPicker {
  Future<PickedMedia?> pickImage();
  Future<PickedMedia?> pickVideo();
}

class PickedMedia {
  const PickedMedia({
    required this.name,
    required this.mimeType,
    required this.source,
    this.path,
  });

  final String name;
  final String mimeType;
  final MediaSource source;
  final String? path;
}

enum MediaSource {
  file,
  photoLibrary,
  camera,
  webUpload,
}

抽象层可以统一:

  • 调用方法;
  • 错误码;
  • 空结果;
  • 生命周期通知;
  • 文件元数据。

但以下内容通常必须暴露能力差异:

  • 是否有真实路径;
  • 是否支持后台执行;
  • 是否支持有限照片权限;
  • 是否需要用户手势;
  • 是否存在系统设置跳转;
  • 是否支持流式读取。

如果强行把所有平台都包装成“返回路径”,Web 和 Android content:// 资源都会产生错误假设。

发布前的验证与恢复

平台集成的风险通常在运行时才暴露。发布前至少应覆盖以下路径:

未授权 -> 请求授权 -> 允许
未授权 -> 请求授权 -> 拒绝
拒绝 -> 再次请求
永久拒绝/受限 -> 打开系统设置
系统服务关闭 -> 提示用户开启
设备没有能力 -> 降级或明确报错
进入后台 -> 恢复前台
页面销毁 -> 原生资源释放
App 被杀死后重启 -> 状态重新初始化

对于上传、录音、视频转码等长任务,还要设计任务恢复策略。内存中的 Future 不能代表系统任务已经完成;如果进程被杀死,Dart 回调、EventChannel 订阅和临时文件引用都可能消失。需要可靠恢复时,应使用持久化任务记录、原生后台任务机制或服务端幂等接口。

Platform Channel 的核心职责是传输协议,不是替代状态管理、任务队列、权限系统或生命周期管理。只有同时定义了:

协议:方法、参数、返回值、错误码
状态:初始化、运行、暂停、释放
资源:创建、复用、取消、销毁
权限:声明、请求、拒绝、设置
平台:Android、iOS、桌面、Web 的实现边界

Flutter 应用中的平台能力才会从“能调用一次”变成可维护、可恢复、可发布的工程接口。


系列导航与关联阅读

官方资料

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