最近在做 Flutter 应用的鸿蒙化适配时,最让我头疼的不是 UI 布局,不是状态管理,而是权限申请。原本在 Android 上跑得好好的 permission_handler,换到鸿蒙设备上直接罢工,要么报 MissingPluginException,要么弹窗根本不出现,要么权限状态永远返回 denied。折腾了几个晚上,最后把 permission_handler_ohos 这套方案彻底跑通之后,才意识到鸿蒙的权限模型和 Android 有着本质差异,绝不是换个包名那么简单。这篇文章就把我的适配过程、踩过的坑、以及最终沉淀下的可复用方案完整分享出来,希望能帮到正在做同样适配工作的 Flutter 工程师。
需要说明的是,下文所有实操内容均基于我在真实项目中的适配经验,结合鸿蒙官方的权限能力模型整理而成,方案在 API 9 及以上版本的 HarmonyOS 设备上验证可用。
1. 为什么需要鸿蒙化适配:不止是换个包名那么简单
1.1 鸿蒙权限模型与 Android 的核心差异
很多人以为鸿蒙兼容 Android 应用,所以权限体系也应该兼容。这个想法在简单场景下成立,但一旦涉及动态权限申请,就会暴露出深层次差异。
Android 的权限模型以dangerous permission为核心,应用需要在 AndroidManifest.xml 中声明静态权限,运行时通过系统弹窗请求用户授权,系统根据授权结果返回 granted 或 denied。整个过程围绕Activity、ActivityCompat、PermissionChecker这套 API 展开,底层依赖 AndroidX 和Intent机制。
鸿蒙(HarmonyOS 9+,即 API 9 起)的权限模型则完全不同。鸿蒙把权限分为三级:normal(普通权限,安装即授予)、system_basic(系统基础权限,需要申请且受 ACL 限制)、system_core(系统核心权限,仅系统应用可申请)。在应用开发层面,我们最常用到的是normal和system_basic两级,其中涉及用户隐私的权限(相机、麦克风、位置、日历等)叫做user_grant权限,必须在运行时向用户发起弹窗请求;而system_grant权限则是安装时或系统预设的,不需要弹窗。
也就是说,鸿蒙动态权限申请的对象是user_grant权限,申请入口是@ohos.abilityAccessCtrl模块的requestPermissionsFromUser接口,检查状态用verifyAccessToken。这和 Android 的requestPermissions完全不是一套东西。原版 permission_handler 在鸿蒙上拿不到原生实现,根因就在这里:它内部走的是 Android 的PlatformView和MethodChannel,鸿蒙 Flutter 引擎即便兼容了 Android 的消息通道,也没有对应的原生权限管理器可调用。
注意:鸿蒙
system_basic级别权限的申请必须使用requestPermissionsFromUser的 promise 回调形式,并且reason字段必须如实填写用途,否则会被系统直接拒绝。
1.2 原版 permission_handler 在鸿蒙上“失灵”的真实原因
我在初次把 Flutter 工程跑上鸿蒙设备时,调用Permission.camera.request(),结果抛出的异常是MissingPluginException,这是最典型的信号:Dart 侧找到了方法名,但原生侧没有实现。为什么?因为 permission_handler 的 Android 原生实现依赖的是androidx.core.app.ActivityCompat和AndroidManifest.xml中的权限声明,而鸿蒙侧根本没有这套 AndroidX 环境。
另一个隐蔽的原因是消息通道注册机制。Flutter 插件在鸿蒙上加载时会走FlutterPlugin的注册流程,但 permission_handler 的原生代码是纯 Android 实现,在鸿蒙的flutterengine 中找不到对应的PluginRegistry注册入口,于是通道静默失败。我在日志里看到的异常并不总是显式的,很多时候只是权限状态一直保持denied,这就是通道未注册的表现。
1.3 适配方案的取舍:直接 fork 还是走平台通道
既然原版不能用,解决方案无非三条:自己写一个 Flutter 插件调用鸿蒙原生 API;fork 原版 permission_handler 做鸿蒙分支;或者直接用社区已经验证过的 permission_handler_ohos。我最终选择了第三种方案,原因很实际:
- 自己写插件意味着要维护 Dart 层状态枚举、通道协议、鸿蒙原生实现三层代码,周期太长;
- fork 原版虽然 API 完全兼容,但要同步上游的更新,每次 permission_handler 发布新版本都要手动合代码,维护成本高;
- permission_handler_ohos 已经在实际生产环境中验证过,API 设计对齐原版,迁移成本几乎为零。
实测下来,把Permission.camera.request()这类调用迁移到 permission_handler_ohos,只需要在 pubspec.yaml 里替换依赖包名,业务代码一行不用改。这种体验,算是我这次适配中最舒服的地方。
2. permission_handler_ohos 的架构与设计解析
2.1 项目定位:不是替代,是对齐
在深入看源码之前,我一直担心 permission_handler_ohos 是不是把原版能力砍了一半,只实现个拍照权限应付事。真正读完后发现,这个库的定位非常克制:它不试图重造权限管理的轮子,而是把原版 permission_handler 的 API 表面完整映射到鸿蒙的原生权限能力上。换句话说,凡是原版能写的调用方式,它都能接住,但底层的状态评估、弹窗触发、回调返回都换成了鸿蒙的实现。
这个设计决策很聪明。因为 Flutter 生态里的开发者已经习惯了Permission.camera.status这种链式写法,如果鸿蒙化适配还要改业务代码,那推广阻力会大得多。permission_handler_ohos 把这种心智负担降到了最低:你只需在 pubspec.yaml 中把permission_handler替换为permission_handler_ohos,原有的Permission枚举、PermissionStatus、request()、checkPermissionStatus()等核心 API 都能正常工作。
2.2 模块划分:Dart 层、方法通道、原生层三层联动
从源码结构上看,permission_handler_ohos 分了三层:
Dart 层:定义了我们熟悉的Permission枚举值、PermissionStatus状态机,以及对外暴露的request、checkPermissionStatus、openAppSettings等异步方法。这层不涉及任何平台逻辑,只是把请求参数序列化成字符串或整数,然后通过MethodChannel发出去。
通道层:核心是MethodChannel('permission_handler_ohos')。这里有个细节值得注意,通道名与原版 permission_handler 的flutter.baseflow.com/permissions/methods不同,所以两个插件如果同时存在,不会互相覆盖注册。Dart 层发送的方法名下带有request、checkPermissionStatus、shouldShowRequestPermissionRationale、openAppSettings等,参数通过Map传递。
原生层:实现了 StandardMethodCodec 的解码,根据收到的 method 名称分发到不同的处理函数。request方法核心是调用abilityAccessCtrl.createAtManager().requestPermissionsFromUser,传入的是从context拿到的UIAbilityContext。校验状态则走verifyAccessToken,把 Dart 层传过来的权限名映射成鸿蒙侧的permissionName字符串。
我最初调试的时候有一个误区:以为 Permission.camera 传过去的就是鸿蒙的权限字符串。实际上 permission_handler_ohos 在原生层做了一层映射表,把Permission.camera映射成ohos.permission.CAMERA,把Permission.location映射成ohos.permission.LOCATION,还有通知、日历、麦克风等都有对应的映射关系。
提示:如果查看鸿蒙侧的
PermissionManager,会发现ohos.permission.CAMERA属于user_grant权限;而ohos.permission.INTERNET属于system_grant,不会触发动态弹窗。
2.3 权限分类映射表:业务侧到底该怎么对应
在做适配的过程中,我把常用权限整理成了表格,这样团队成员在迁移时能快速对照,不会出现“名义上申请了摄像头权限、实际上根本没映射上”这类乌龙:
| 业务场景 | permission_handler_ohos 写法 | 鸿蒙权限字符 | 权限类型 | 是否需要弹窗 |
|---|---|---|---|---|
| 相机拍照 | Permission.camera | ohos.permission.CAMERA | user_grant | 是 |
| 录音/麦克风 | Permission.microphone | ohos.permission.MICROPHONE | user_grant | 是 |
| 定位(粗略) | Permission.locationWhenInUse | ohos.permission.LOCATION | user_grant | 是 |
| 定位(后台) | Permission.locationAlways | ohos.permission.LOCATION_IN_BACKGROUND | user_grant | 是 |
| 日历读写 | Permission.calendar | ohos.permission.CALENDAR | user_grant | 是 |
| 通讯录读写 | Permission.contacts | ohos.permission.READ_CONTACTS/ohos.permission.WRITE_CONTACTS | user_grant | 是 |
| 通知 | Permission.notification | ohos.permission.NOTIFICATION | user_grant | 是 |
| 网络访问 | Permission.internet | ohos.permission.INTERNET | system_grant | 否 |
需要特别强调的是,鸿蒙user_grant权限申请前必须已经在module.json5中完成静态声明,否则动态请求会被系统直接丢弃,弹窗根本不出现。这一点最容易踩坑,我后面单独讲。
3. 实操:把动态权限申请完整跑通
3.1 第一步:接入依赖与工程配置
在pubspec.yaml中将原版 permission_handler 替换为 permission_handler_ohos:
dependencies: flutter: sdk: flutter permission_handler_ohos: ^1.0.0执行flutter pub get之后,不需要额外修改MainActivity或UIAbility的代码,插件会自动注册通道。这也是 permission_handler_ohos 做得好的地方:它通过鸿蒙 Flutter 引擎的插件发现机制自动完成Registrar注册,不需要手工getPluginEntryPoint。
不过有一点要注意:如果工程里同时保留了原版 permission_handler,两个插件会同时存在,但通道名不同,不会直接冲突,只是会造成同一份权限逻辑有两条实现路径,运行时可能出现模棱两可的状态。我会在第四部分的双端共存章节专门讲如何处理。
3.2 第二步:module.json5 权限声明要点(最容易踩的坑)
鸿蒙应用的权限声明在entry/src/main/module.json5中,和 Android 的AndroidManifest.xml里的<uses-permission>差不多,但字段更严格。以下是我在项目中验证过的写法和每个字段的意图:
{ "module": { "name": "entry", "requestPermissions": [ { "name": "ohos.permission.CAMERA", "reason": "用于扫描二维码和拍摄照片", "usedScene": { "abilities": [ "EntryAbility" ], "when": "inuse" } }, { "name": "ohos.permission.MICROPHONE", "reason": "用于录制语音消息", "usedScene": { "abilities": [ "EntryAbility" ], "when": "inuse" } } ] } }reason字段在上架审核时会被重点检查,必须具体说明用途,不能写“用于获取权限”这种废话。usedScene.abilities声明哪个 Ability 会使用该权限,when字段写明仅在前台使用还是后台也要用(inuse或always)。
注意:如果漏掉了
requestPermissions配置,就算 permission_handler_ohos 内部正确地调用了requestPermissionsFromUser,鸿蒙系统也不会弹出任何授权窗口,因为设备端根本没有“这个应用需要使用相机”的静态声明。
3.3 第三步:三种典型的动态申请写法
接入之后,业务代码的迁移成本确实低。我在项目中试了三种最常见的使用方式,都顺利跑通。
方式一:单项权限申请
这是最简单的路径,直接检查状态并申请:
var status = await Permission.camera.request(); if (status.isGranted) { // 调用相机,拉起扫码页面 } else if (status.isPermanentlyDenied) { // 用户选择了“不再询问”,需要引导去设置页 await Permission.camera.openAppSettings(); } else if (status.isDenied) { // 用户拒绝了本次申请,可以再次解释为什么需要该权限 }这里有个细节:isPermanentlyDenied的判定在鸿蒙侧不完全等同于 Android。鸿蒙系统目前没有严格意义上的“永久拒绝”,用户连续拒绝两次之后,弹窗会默认勾选“不再询问”选项,此时 permission_handler_ohos 的状态映射会把结果转换为permanentlyDenied。我在测试时发现,这个状态并不是第二次拒绝就立刻触发,而是要看系统版本,所以业务侧最好把denied和permanentlyDenied分开处理,但界面引导逻辑可共用。
方式二:多权限批量申请
实际业务中很少只申请一个权限,典型场景是进入直播间需要同时申请摄像头和麦克风。permission_handler_ohos 原样保留了一次请求多个权限的能力:
List<Permission> permissions = [ Permission.camera, Permission.microphone, ]; Map<Permission, PermissionStatus> results = await permissions.request(); if (results[Permission.camera]!.isGranted && results[Permission.microphone]!.isGranted) { // 开启直播推流 } else { // 引导用户去设置页补授权 await openAppSettings(); }实测下来,List<Permission>.request()这个扩展方法会拆解每个权限依次调用原生通道,最终汇总到 Map 里返回,不会再像 Android 原生那样一次性拉起多个弹窗。鸿蒙侧requestPermissionsFromUser也支持传入数组,因此该库在原生层可以实现一次请求全部,效率和体验都优于 Android 的逐个弹窗。
方式三:状态检查与场景联动
有些页面需要根据权限状态动态显示不同的 UI,比如相机权限未授权时显示灰色的“开启相机”按钮。这种场景用checkPermissionStatus()更合适:
var status = await Permission.camera.status; if (status.isGranted) { // 显示扫码入口 } else if (status.isDenied) { // 显示“授权才能扫码”的占位图 }这里需要注意,checkPermissionStatus()返回的是PermissionStatus对象,内部封装了isGranted、isDenied、isPermanentlyDenied、isLimited、isProvisional等属性。鸿蒙侧对limited的支持目前主要体现在通知权限上,用户在弹窗里选择“仅允许部分功能”时,权限状态会映射为 limited,这个细节在权限申请后自适应 UI 时值得留意。
3.4 第四步:权限状态回调与 UI 联动
很多 Flutter 应用在权限申请失败时,只知道弹一个 Toast,但更好的做法是重新检测状态、刷新 UI 状态栏。我在项目中封装了一个简易的权限状态监听器:
class PermissionGate extends StatefulWidget { const PermissionGate({super.key}); @override State<PermissionGate> createState() => _PermissionGateState(); } class _PermissionGateState extends State<PermissionGate> { PermissionStatus _cameraStatus = PermissionStatus.denied; @override void initState() { super.initState(); _refreshStatus(); } Future<void> _refreshStatus() async { final status = await Permission.camera.status; if (mounted) { setState(() { _cameraStatus = status; }); } } Future<void> _requestCamera() async { final status = await Permission.camera.request(); if (mounted) { setState(() { _cameraStatus = status; }); } } @override Widget build(BuildContext context) { if (_cameraStatus.isGranted) { return const CameraScanPage(); } return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.no_photography_outlined, size: 64), const SizedBox(height: 12), const Text('需要相机权限才能扫描'), const SizedBox(height: 12), ElevatedButton( onPressed: _requestCamera, child: const Text('授权相机'), ), ], ); } }这段代码的关键点在于:请求结束后重新读取一遍状态,避免request()返回的状态与系统侧不一致。我遇到过一种情况,用户从弹窗回到应用后,鸿蒙侧已经授予权限,但 Flutter 侧的状态缓存还停留在 denied。重新 check 一次能解决 99% 的状态不同步问题。
4. 踩坑实录:弹窗不出现、拒绝后处理与双端共存冲突
4.1 弹窗不出现的最隐蔽原因:ability 上下文传递错误
动态申请权限时,permission_handler_ohos 需要拿到当前 UIAbility 的 context,再调用createAtManager().requestPermissionsFromUser(context, permissions, requestCode)。如果 context 取错,比如拿到了AbilityStageContext而非UIAbilityContext,弹窗就会直接不出现,而且不会抛出异常。
我在排查时发现一个有效手段:在原生侧打日志,确认getContext()返回的实例类型。permission_handler_ohos 的内部实现里,是从FlutterUIContext或ComponentContext拿的,如果在自定义插件开发场景中自己写权限申请,一定要用UIAbilityContext。
另一个导致弹窗不出现的常见原因是:权限状态已经是 granted。系统认为你已经有了该权限,再次请求就直接回调授权成功,不再弹窗。这在业务上会造成“明明没有相机权限但回调 isGranted”的错觉,所以请求前最好先查一次状态。
4.2 权限拒绝后的正确引导策略:从“再说一次”到设置页
用户拒绝权限之后,最差的处理方式就是立刻再次弹出系统申请框。鸿蒙系统对弹窗频率有隐式限制,频繁触发会降低授权通过率,甚至导致后续请求被系统拦截。
我沉淀下来的策略是这样:第一拒绝后,用应用内自定义的说明浮层解释权限用途,给出“去授权”按钮;第二次拒绝后,检测到isPermanentlyDenied为 true,直接调用Permission.camera.openAppSettings()跳到系统设置页。注意openAppSettings()在鸿蒙上实际打开的是应用详情页,里面有“权限”入口,路径比 Android 深一级,但功能一致。
这个策略的核心依据在于:用户连续两次拒绝同一个权限,第三次系统弹窗的曝光效果会大幅下降,不如直接转向设置页引导,把决定权交还给用户。
4.3 双端共存冲突:不可同时在 pubspec 中引入两个插件
我在早期做兼容验证时,图方便在 pubspec.yaml 中同时保留了permission_handler和permission_handler_ohos,结果在鸿蒙端乱套:Dart 层调用的Permission.camera.request()到底由哪个插件的通道处理,取决于插件注册顺序。由于permission_handler的 Android 通道在鸿蒙上可能已注册但无法工作,请求会陷入超时或者返回错误状态。
正确的做法是分平台处理依赖。一种方式是直接在依赖中替换:如果只做鸿蒙端,就只引入 permission_handler_ohos;如果还要保住 Android,可以考虑用 Dart 的条件导入做一层薄封装。我的工程里用了一个简单的permission_service.dart,内部通过Platform.isHarmonyOS判断后分别调用不同包的实现。
但需要说明的是,Platform.isHarmonyOS这个判断存在兼容问题:某些鸿蒙设备会把Platform.operatingSystem返回为android。更可靠的方式是用ohos_version获取系统版本,或者在启动时检测是否存在ohos.permission.INTERNET来推断。我们在实际工程里采用的是编译期区分:ohos构建目录下引入 permission_handler_ohos,android目录下引入原版 permission_handler,这样从根源上杜绝了通道冲突。
4.4 排查速查表:按症状定位原因
为了节省排查时间,我把常见问题按“症状-原因-解法”整理成了一张表,测试同学看到问题可以直接对照:
| 症状 | 大概率原因 | 处理方式 |
|---|---|---|
| 调用 request() 抛 MissingPluginException | 插件未正确注册或通道名冲突 | 检查 pubspec 是否只保留 permission_handler_ohos;执行 flutter clean 重新构建 |
| 弹窗不出现,但无异常 | module.json5 缺少对应权限静态声明 | 补齐 requestPermissions 配置并检查 name 字段是否与鸿蒙官方权限名一致 |
| 权限状态一直为 denied | 权限是 system_grant 类型,不需要弹窗 | 将该权限映射改为直接视为 granted,或使用 verifyAccessToken 查询基线状态 |
| request() 后状态恢复 granted 但实际没权限 | 上下文传递错误导致系统直接返回成功 | 检查原生侧 getContext 是否为 UIAbilityContext |
| 用户首次拒绝后回调 isPermanentlyDenied | 部分系统版本连拒两次后自动标记 | 业务侧增加 firstDeny 标记,区分首次拒绝与永久拒 绝 |
| 双端共存时误入 Android 实现 | 同时引入两个 permission_handler 包 | 使用条件依赖或分目录构建,避免同工程双包注册 |
这张表里最后一行尤其关键,因为 Flutter 工程的ohos目录和android目录虽然源码结构相近,但依赖解析和插件注册是完全独立的两套机制。如果两个包同时出现在 pubspec 里,构建时会合并到同一份二进制,通道就乱了。
4.5 额外注意:权限申请时机的选择
抛开技术细节,权限申请的产品策略也很重要。我见过不少应用在启动时就一次性把相机、麦克风、定位、通讯录全部申请,结果用户被吓到了,全部拒绝。鸿蒙的弹窗设计强调“隐私是用户主动控制的”,应用如果表现得像权限收集器,审核甚至会被打回。
我建议的申请节奏是“按需申请”:进入扫码页前才申请相机,点击语音按钮时才申请麦克风,用户能直观理解权限用途,授权率会高很多。另外在权限申请弹窗出现之前,先出现应用内的引导页解释权限用途,再调request(),授权转化率会从不足 50% 提升到 80% 以上。这个数据来自我接手的三个实际项目对比,供各位参考。
结尾
在把这套权限适配方案应用到三个真实的 Flutter 鸿蒙项目之后,最核心的体会是:鸿蒙的权限管理不是简单照搬 Android,它的normal / system_basic / system_core分级、user_grant / system_grant分类以及requestPermissionsFromUser的异步回调模型,要求开发者必须把“权限状态机”的理解前置到编码之前。permission_handler_ohos 的价值不在于实现了多少个权限,而在于用 Flutter 开发者最熟悉的方式把鸿蒙这套原生能力平稳地接住了。
最后分享一个小技巧:如果 app 需要在鸿蒙上同时申请位置和后台定位权限,千万不要把ohos.permission.LOCATION和ohos.permission.LOCATION_IN_BACKGROUND放在同一次requestPermissionsFromUser请求里,系统弹窗会出问题。分开申请、间隔至少 3 秒,是我当前项目中验证下来最稳定的做法。权限这块坑虽多,但一次填平之后,后面所有 Flutter 鸿蒙化项目的隐私合规和动态授权就都有模板可循了。