Flutter 基础体系 · 第 14/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter 平台集成:Plugin、Platform Channel、原生生命周期和权限
Flutter 应用运行在 Dart 虚拟机或编译后的 Dart 代码之上,但文件系统、相机、蓝牙、推送、传感器、系统设置等能力属于宿主平台。平台集成就是把 Dart 层的业务代码与 Android、iOS、桌面或 Web 宿主提供的能力连接起来。
这条链路通常包含四个部分:
- Dart 层发起调用或监听事件;
- Flutter 引擎通过 Platform Channel 编码并传递消息;
- 原生侧的 Plugin 或自定义代码接收消息,调用平台 API;
- 原生侧把结果、异常或事件再传回 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>
原生侧必须对调用结果做三选一:
- 成功:返回可编码的值;
- 业务或平台错误:返回错误码、错误消息和详细信息;
- 不支持:明确返回
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 整数。原生侧应返回 0 到 100 之间的整数;如果返回字符串或结构不一致,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
把通道直接写在 MainActivity 或 AppDelegate 中适合验证概念,但不适合作为可复用 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.run、compute 或原生侧合适的后台机制,但跨 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() {}
}
暂停和释放不是同一件事。相机预览常在 inactive 或 paused 时停止采集,但对象是否可以复用取决于插件;当页面销毁、引擎分离或设备资源必须让给其他应用时,才需要彻底释放。
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
其中必须明确两个条件:
start只有在Ready或可恢复的Suspended状态允许执行;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 的存储模型。具体权限名称和行为必须以目标 compileSdk、targetSdk 以及设备系统版本为准。
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
常见原因:
- 当前平台没有实现该 Plugin;
- 通道注册代码没有执行;
- 修改原生代码后只做了 Hot Reload;
- 通道名称不一致;
- 在错误的 FlutterEngine 上注册;
- Plugin 没有被加入当前构建目标;
- Web 或桌面没有对应实现。
修改原生代码后应停止并重新运行应用;Hot Reload 只更新 Dart 代码,通常不会重新加载原生注册逻辑。
调用没有返回
优先检查:
- 原生处理器是否进入;
- 每条分支是否调用一次
success、error或notImplemented; - 异步回调是否可能永远不触发;
- 是否在错误线程访问平台 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 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 本地存储:Preferences、文件、SQLite、加密和迁移
- 下一篇:Flutter 文件与媒体:选择、上传、图片、视频、相机和生命周期
- 延伸:Flutter 应用发布:签名、Flavor、商店、Web/桌面、灰度和回滚
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论