平台能力:权限、相机、通知与插件

Flutter 的 UI 是自绘的,但相机、定位、通知、文件系统仍然属于操作系统,框架只能通过插件调用。平台能力的难点从来不是 API 记不住,而是三件事:声明漏了、权限被拒了、插件在该平台没实现。本章按“选插件 → 声明并申请权限 → 常见能力落地 → 不可用时降级”的顺序讲一遍。

插件与 pubspec 依赖

能力推荐插件平台差异
权限permission_handleriOS 权限文案必须写进 Info.plist,否则审核被拒
相册与拍照image_picker无需自己写预览,适合“选一张图/拍一张”
相机预览camera要自己管生命周期,前后台切换需重开
本地通知flutter_local_notificationsAndroid 13+ 需申请通知权限,iOS 需授权
打开链接/拨号url_launcherAndroid 11+ 要配 <queries>
dependencies:
  permission_handler: ^11.3.1
  image_picker: ^1.1.2
  flutter_local_notifications: ^17.2.2
  url_launcher: ^6.3.0
  path_provider: ^2.1.4

权限:声明 + 运行时申请

权限要过两道关:先在原生工程里声明,再在运行时申请。漏掉第一道,第二道会被系统直接拒绝且不弹提示。

<!-- android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <!-- Android 13+ -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- ios/Runner/Info.plist:文案会显示在系统弹窗上,必须写清用途 -->
<key>NSCameraUsageDescription</key>
<string>用于拍摄商品图片并上传</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>用于选择相册中的图片</string>
Future<bool> ensureCameraPermission(BuildContext context) async {
  var status = await Permission.camera.status;
  if (status.isGranted) return true;
  status = await Permission.camera.request(); // 首次弹窗
  if (status.isGranted) return true;

  if (status.isPermanentlyDenied && context.mounted) {
    // 永久拒绝后系统不再弹窗,只能带用户去应用设置页
    await showDialog<void>(context: context, builder: (ctx) => AlertDialog(
      title: const Text('需要相机权限'),
      content: const Text('请在系统设置中开启相机权限后重试'),
      actions: [
        TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')),
        TextButton(onPressed: openAppSettings, child: const Text('去设置')),
      ],
    ));
  }
  return false;
}

相机与相册

final picker = ImagePicker();
final gallery = await picker.pickMultiImage(imageQuality: 80); // 压缩后再上传
// 在原生侧就压缩,比 Dart 侧再缩放省内存
final shot = await picker.pickImage(source: ImageSource.camera, maxWidth: 1920, imageQuality: 85);
if (shot == null) return;                 // 用户取消
final bytes = await shot.readAsBytes();   // 只读一次,别在 build 里反复解码

camera 做实时预览的关键点:CameraControllerinitState 里异步初始化、在 dispose 里释放,并在 AppLifecycleState.inactive 时暂停,否则 iOS 切后台容易黑屏或崩溃。

本地通知

Future<void> initNotifications() async {
  final plugin = FlutterLocalNotificationsPlugin(); // 在 main 里初始化并缓存实例
  await plugin.initialize(const InitializationSettings(
    android: AndroidInitializationSettings('@mipmap/ic_launcher'),
    iOS: DarwinInitializationSettings(requestAlertPermission: true),
  ));
  // Android 13+ 必须在运行时申请,否则通知被静默丢弃
  await plugin.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()?.requestNotificationsPermission();
}

// 定时通知:本地触发,不依赖服务端推送
await plugin.zonedSchedule(
  1, '该喝水了', '起来活动一下', tz.TZDateTime.now(tz.local).add(const Duration(minutes: 30)),
  const NotificationDetails(android: AndroidNotificationDetails('reminder', '提醒')),
  androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, // 精确闹钟需额外权限
);

打开外链、拨号与邮件

final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
  await launchUrl(uri, mode: LaunchMode.externalApplication); // 跳出应用打开
}
await launchUrl(Uri(scheme: 'tel', path: '10086')); // 拨号页;邮件换成 mailto scheme

Android 11+ 需在 <queries> 中声明要探测的 scheme,否则 canLaunchUrl 恒为 false;iOS 需在 Info.plistLSApplicationQueriesSchemes 里登记自定义 scheme。

文件与目录

final dir = await getApplicationDocumentsDirectory(); // 选文件用 file_picker,目录一律走 path_provider
await File('${dir.path}/favorites.json').writeAsString(jsonEncode(ids)); // 少量数据也可用 shared_preferences
目录特点用途
getTemporaryDirectory系统可随时清理图片缓存、临时下载
getApplicationDocumentsDirectory应用私有、随系统备份用户数据、离线内容

插件不可用时的降级

Future<void> scanQr() async {
  if (!Platform.isAndroid && !Platform.isIOS) {
    showToast('当前平台暂不支持扫码'); // 明确提示,而不是抛异常
    return;
  }
  await _startScan();
}

Web 端读 Platform 会直接抛异常,所以判断顺序是先 kIsWeb、再 Platform,最后才到具体能力实现。

常见坑

现象正确做法
权限只申请不声明iOS 审核被拒、Android 直接返回拒绝先改 AndroidManifest.xmlInfo.plist
永久拒绝后反复申请弹窗不再出现,用户以为坏了isPermanentlyDenied 引导 openAppSettings
大图直接解码内存暴涨、低端机 OOMpickImage 时限制 maxWidthimageQuality

小结:平台能力按“选成熟插件、原生侧声明、运行时申请、失败时降级”四步走;权限要同时改 AndroidManifest.xmlInfo.plist,被永久拒绝时引导到 openAppSettings;相机、通知、外链各有平台前提(Android 13 通知权限、<queries>LSApplicationQueriesSchemes),最后凡是插件调用都兜住异常与不支持的平台。

笔记加载中…