Flutter Android 权限请求实现:处理拒绝与设置页回流
Flutter 项目接上相机、扫码、录音或定位后,最容易被低估的不是 Manifest,而是拒绝后的交互。很多应用会在启动时一次性请求权限;用户拒绝后,业务页只剩灰色按钮;用户进入系统设置重新允许,回到 Flutter 页面却还是旧状态。这样的链路难以排查,也会让权限请求脱离具体用途。本文只解决一个常青问题:怎样把 Android 运行时权限做成 Flutter 功能入口中的可验证状态机。
示例使用相机权限,验证范围为 Android 6.0(API 23)及以上、Kotlin、AndroidX Activity Result API 和 Flutter platform channel。它同样适用于麦克风或已经完成产品评审的定位入口,但不能直接套到后台定位、悬浮窗、精确闹钟等特殊访问。Android 官方说明危险权限需要在运行时按实际操作检查和请求;实现前先核对 Android 运行时权限流程。
先把权限拆成可解释的状态
把权限保存成一个 bool,通常会同时丢掉用户历史和下一步动作。相机入口至少需要四种状态:
granted:本次检查已授权,才创建相机控制器。request:用户刚触发功能,允许发起系统弹窗。rationale:此前拒绝过,先用应用内文案解释用途。settings:应用已经请求过且系统不建议继续解释,提供设置页入口。
这四种状态是 UI 模型,不是对系统“永久拒绝”的绝对判断。shouldShowRequestPermissionRationale() 在首次请求前也可能返回 false,因此不能只靠它断言用户勾选了“不再询问”。示例额外保存 asked_camera_once,它只表示应用是否曾经发起请求:首次为 false 时显示 request;已经请求且没有授权、也不适合再解释时转为 settings。真正是否能访问相机,始终以每次调用前的 checkSelfPermission 为准。
这种边界也与 Flutter Android 后台同步配置:验证 WorkManager 约束重试 一致:需要网络的延后工作放在原生调度器,而运行时权限只能在用户可见、主动点击的 Activity 链路中请求。不要在应用启动、列表预加载、后台 Worker 或统计回调里突然弹出授权框。
Manifest 和原生请求通道
先只声明当前功能真正需要的权限。相册选择应优先评估 Android 的 Photo Picker,它能把选择范围交给系统,通常无需申请广泛的媒体读取权限。不要因为“将来可能用到”就同时加入通讯录、存储、蓝牙和位置权限;这会增加用户打断、测试组合和数据审核范围。
在 android/app/src/main/AndroidManifest.xml 声明相机:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<application android:label="permission_demo">
<!-- 保留现有 Activity 与 Flutter metadata -->
</application>
</manifest>
接着把 AndroidX 的请求合同收在一个原生入口。Flutter 的 platform channel 文档 要求平台侧回调遵守 UI 线程和 Activity 生命周期;此外,连续点击时只能有一个等待中的 MethodChannel.Result。否则第二次覆盖第一次的 result,Dart 会出现永远不完成的 Future。
package com.example.permission_demo
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
private val prefs by lazy { getSharedPreferences("permission_gate", Context.MODE_PRIVATE) }
private var pending: MethodChannel.Result? = null
private val launcher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
pending?.success(mapOf("state" to if (granted) "granted" else state()))
pending = null
}
override fun configureFlutterEngine(engine: FlutterEngine) {
super.configureFlutterEngine(engine)
MethodChannel(engine.dartExecutor.binaryMessenger, "app.permission/camera")
.setMethodCallHandler { call, result ->
when (call.method) {
"cameraStatus" -> result.success(mapOf("state" to state()))
"requestCamera" -> request(result)
else -> result.notImplemented()
}
}
}
private fun request(result: MethodChannel.Result) {
when (state()) {
"granted", "settings" -> result.success(mapOf("state" to state()))
else -> {
if (pending != null) {
result.error("REQUEST_IN_FLIGHT", "camera request already active", null)
return
}
prefs.edit().putBoolean("asked_camera_once", true).apply()
pending = result
launcher.launch(Manifest.permission.CAMERA)
}
}
}
private fun state(): String {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) return "granted"
if (shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) return "rationale"
return if (prefs.getBoolean("asked_camera_once", false)) "settings" else "request"
}
}
代码有两个刻意的限制。第一,通道返回状态,不返回“相机业务已成功”;相机 SDK 初始化、扫码引擎打开和服务端上传是后续独立失败点。第二,Dart 不持久化授权结果。用户可以在系统设置撤销权限,Android 也可能自动重置长期不用的授权;保存一个曾经为真的布尔值会制造更隐蔽的线上故障。
Flutter 层只负责展示和恢复
目录按功能边界放置,避免每个页面各自直接调用通道:
lib/
features/camera/presentation/camera_entry_page.dart
platform/permission/permission_gateway.dart
android/app/src/main/kotlin/.../MainActivity.kt
PermissionGateway 将原生 map 转成类型化状态,页面决定向用户解释什么、何时打开设置和授权后进入哪里:
import 'package:flutter/services.dart';
enum CameraPermission { granted, request, rationale, settings }
class PermissionGateway {
static const _channel = MethodChannel('app.permission/camera');
Future<CameraPermission> status() => _read('cameraStatus');
Future<CameraPermission> request() => _read('requestCamera');
Future<CameraPermission> _read(String method) async {
final value = await _channel.invokeMapMethod<String, dynamic>(method);
return CameraPermission.values.byName(value?['state'] as String);
}
}
用户点击“扫码录入订单”时,先执行 status()。只有 granted 才创建相机控制器;request 才调用 request();rationale 先展示“仅用于扫描订单二维码,不读取相册”的说明,并提供继续与暂不操作两个按钮;settings 只展示“前往系统设置”和取消。用户取消说明对话框是正常退出,不要马上再拉起系统弹窗。
设置页回流是最容易漏掉的一步。通过原生封装发送 ACTION_APPLICATION_DETAILS_SETTINGS 后,在 AppLifecycleState.resumed 调用 status(),不要复用页面跳转前的 state。如果系统设置已经允许但页面仍是禁用态,先检查恢复事件和通道异常,再检查相机控制器是否还保留着旧的初始化失败实例。
用日志和测试把状态机验收掉
在每个分支记录结构化日志,例如 permission=camera state=rationale source=camera_button session=...。日志可以记录状态、入口、Android API level、版本号和是否发生并发请求,但不能包含用户照片、麦克风内容、坐标或持久设备标识。debug 包可显示详细错误,release 包只保留采样指标:入口点击数、实际系统请求数、授权后进入相机页数、设置页回流后的重新检查次数、REQUEST_IN_FLIGHT 次数。
先执行静态检查和 Dart 测试,再以 Android 13+ 真机覆盖系统弹窗。将包名替换成实际应用 ID:
flutter analyze
flutter test
flutter build apk --debug
adb install -r build/app/outputs/flutter-apk/app-debug.apk
adb shell pm revoke com.example.permission_demo android.permission.CAMERA
adb logcat -c
adb logcat | Select-String "permission=camera|Camera"
验收记录至少覆盖以下场景:
- 清除应用数据,点相机入口,观察首次
request;允许后相机页能真正打开。 - 在系统设置撤销权限,再进入,确认先走
rationale;取消说明不会出现系统弹窗。 - 按系统流程使请求不可继续时,确认只出现设置页入口;从设置允许后回到应用,
resumed查询变为granted。 - 在设置中撤销、结束进程并重开,确认不会沿用 Dart 内存中旧的授权状态。
- 连续双击入口,日志只出现一个系统请求;第二次得到
REQUEST_IN_FLIGHT,没有悬挂的 Future。
Widget 测试可注入假的 PermissionGateway,分别断言四种状态下的文案、按钮和导航。Android 仪器测试验证 RequestPermission 回调。adb shell pm grant 只适合准备测试环境,不能代替用户拒绝、设置页回流和一次性授权的真机验证。失败报告应附上 flutter --version、./gradlew -version、设备 API、versionCode、状态日志和复现步骤。
版本边界与发布复盘
相机、麦克风和位置可能出现一次性允许;应用离开前台或进程结束后,真正执行受保护 API 前仍要重新检查。通知权限、附近设备、后台定位、悬浮窗和精确闹钟拥有不同的系统页面或版本前提,不能复制本文单权限代码。后台定位还需要先完成前台位置的明确产品场景和额外审核。
发布前依次复盘:Manifest 是否最小化;每个受保护 API 前是否刷新状态;用户解释是否与实际数据访问一致;拒绝后是否仍有手动输入、系统选择器或稍后重试等降级路径;日志是否能关联入口、状态、回流与测试结果且不泄露敏感信息。这样线上出现“某型号相机打不开”时,团队可以先区分是系统授权、通道并发还是相机初始化,再进入针对性排查。
把权限门收进功能层后,下一步应为相机、文件上传或定位分别建立“业务动作—最小权限—降级能力”的验收表,而不是继续扩大一个无边界的全局权限管理器。
版本兼容与故障定位顺序
权限故障要按证据层次排查。先用 adb shell dumpsys package 包名 确认实际安装包声明了相机权限,并记录设备 API、targetSdk、versionCode 与当前授权标记;Manifest 改动后必须重新安装,热重载不会刷新系统声明。然后在原生 request() 前后记录状态、入口和 pending 是否存在:如果已经是 granted,问题在相机初始化而不是弹窗;如果请求后没有回调,检查 Activity 是否被别的原生页面覆盖;如果回调拒绝且 rationale 为 false,先核对 asked_camera_once 并清除应用数据复现,不要立即给用户贴上永久拒绝的标签。
Android 11 以后,相机、麦克风和位置可能获得一次性允许。应用退到后台、进程被结束或用户在系统设置更改选择后,旧的 Dart 内存值都可能失效;因此每次调用相机前与每次从设置页返回后都要重新查询。Android 13 的通知权限、附近设备,以及后台位置和特殊访问,都应按各自的系统流程建立独立分支与测试记录,不能把本例的 CAMERA 结论复制过去。
在 CI 中分三层验证:Widget 测试覆盖四种状态的文案和降级操作;Android 仪器测试验证 RequestPermission 回调;连接真机的发布候选测试覆盖真实弹窗、拒绝与设置页回流。失败报告至少保存 flutter --version、./gradlew -version、设备型号、系统版本、命令输出、状态日志和复现步骤。这样线上出现“相机打不开”时,团队能先判断是系统授权、通道并发还是 SDK 初始化,再进行针对性修复,而不是让用户反复重装。