【Flutter for OpenHarmony 跨平台征文】Flutter 三方库 permission_handler 的鸿蒙化适配与权限处理实战指南
Flutter权限处理实战指南:鸿蒙适配与权限管理 摘要 本文详细介绍了如何在Flutter应用中实现跨平台权限管理,特别是针对鸿蒙设备的适配。主要内容包括: 权限需求分析:心率检测App需要相机、存储、通知等核心权限,以及处理权限被拒绝的各种情况。 鸿蒙特殊性:对比了鸿蒙权限模型与Android/iOS的区别,包括更细的权限粒度、首次启动弹窗等特性。 技术实现: 使用permission_han
【Flutter for OpenHarmony 跨平台征文】Flutter 三方库 permission_handler 的鸿蒙化适配与权限处理实战指南
🎯 写在前面
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
👋 自我介绍
大家好上海某高校大一计算机学生 👨💻。这是心率检测系列的最后一篇文章了!前面七篇我们搞定了心率采集、ECG 绘制、心跳动画、数据可视化、健康算法、数据持久化、UI 设计,今天我们来聊聊一个超级重要但经常被忽略的话题 —— 权限处理!
说起来,权限是我踩坑最多的地方 😓。每次写 App 的时候,权限申请这块总是出问题:
- 弹窗闪一下就消失了?
- 权限被拒绝了却不知道怎么处理?
- 用户永久拒绝了怎么引导?
- 鸿蒙的权限配置跟 Android 不一样?
今天这篇文章,就是把我研究权限处理的过程记录下来,纯纯的实战干货!
📌 这篇文章要讲什么?
今天的目标:用 Flutter 在鸿蒙设备上实现完整的权限处理方案。
具体包括:
- 🔐 相机权限:心率检测必须
- 📷 存储权限:保存历史数据
- 🔔 通知权限:新消息提醒
- ⚙️ 权限被永久拒绝的处理:引导用户去设置页
- 📱 鸿蒙权限配置:module.json5 怎么写
一、功能引入:为什么权限处理这么重要?
1.1 健康 App 的权限需求
我们的心率检测 App 需要以下权限:
| 权限 | 用途 | 必需性 |
|---|---|---|
| 相机 | PPG 心率检测 | 必须 |
| 存储 | 保存历史数据 | 建议 |
| 通知 | 测量结果提醒 | 可选 |
| 生物识别 | 隐私保护 | 可选 |
1.2 权限处理的常见问题
| 问题 | 表现 | 影响 |
|---|---|---|
| 权限弹窗闪退 | 申请后立即被系统拒绝 | 功能无法使用 |
| 用户拒绝 | 用户点击了拒绝 | 需要降级处理 |
| 永久拒绝 | 用户勾选了"不再询问" | 必须引导去设置 |
| 权限被撤销 | 用户在设置中关闭了权限 | 需要检测并提醒 |
1.3 鸿蒙权限的特殊性
在鸿蒙设备上,权限模型有一些特殊之处:
| 特点 | 说明 |
|---|---|
| 权限粒度更细 | 鸿蒙的权限分类更细致 |
| 首次启动弹窗 | 必须在应用启动时立即申请 |
| 配置方式不同 | 使用 module.json5 而不是 AndroidManifest |
| 权限组 | 部分权限可以打包申请 |
二、环境与依赖配置
2.1 pubspec.yaml 依赖
name: permission_handling_app
description: "Flutter for OpenHarmony 权限处理实战"
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.2.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
# === 核心依赖 ===
# 权限处理 - 最重要的库!
permission_handler: ^11.1.0
# 跳转到应用设置页
app_settings: ^2.0.1
# 应用信息(可选)
package_info_plus: ^5.0.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.1
flutter:
uses-material-design: true
2.2 permission_handler 库介绍
permission_handler 是 Flutter 中最流行的权限处理库:
- ✅ 统一 API:一套代码处理 Android/iOS/鸿蒙
- ✅ 状态检测:检查权限当前状态
- ✅ 请求处理:发起权限申请
- ✅ 设置跳转:引导用户去设置页
- ✅ 鸿蒙兼容:持续更新适配
2.3 鸿蒙权限配置
鸿蒙的权限需要在 module.json5 中声明(Flutter for OpenHarmony 项目中对应 entry/src/main/module.json5)。
三、分步实现:完整的权限处理代码
3.1 权限服务 PermissionService
新建文件 lib/services/permission_service.dart:
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:app_settings/app_settings.dart';
/// 权限服务
///
/// 提供统一的权限申请和状态检测接口
///
/// 功能:
/// - 检查权限状态
/// - 申请单个或多个权限
/// - 处理权限被拒绝的情况
/// - 引导用户去设置页
///
/// 作者:小 J(上海本科大一计算机学生)
class PermissionService {
// ==================== 单例模式 ====================
/// 单例实例
static final PermissionService _instance = PermissionService._internal();
factory PermissionService() => _instance;
PermissionService._internal();
// ==================== 核心方法 ====================
/// 检查相机权限状态
///
/// 返回值:
/// - PermissionStatus.granted: 已授权
/// - PermissionStatus.denied: 被拒绝(可重试)
/// - PermissionStatus.permanentlyDenied: 永久拒绝
/// - PermissionStatus.restricted: 系统限制
/// - PermissionStatus.limited: 部分授权
Future<PermissionStatus> checkCameraPermission() async {
final status = await Permission.camera.status;
print('[权限服务] 相机权限状态: $status');
return status;
}
/// 请求相机权限
///
/// [showRationale] - 是否显示申请理由对话框
Future<PermissionResult> requestCameraPermission({
bool showRationale = true,
}) async {
// 先检查当前状态
var status = await checkCameraPermission();
// 如果已经授权,直接返回成功
if (status.isGranted) {
return PermissionResult(
granted: true,
status: status,
message: '相机权限已授权',
);
}
// 如果用户之前拒绝过,显示理由
if (status.isDenied && showRationale) {
final shouldShowRationale = await Permission.camera.shouldShowRequestRationale;
if (shouldShowRationale) {
// 这里可以弹出一个自定义对话框解释为什么需要权限
print('[权限服务] 显示权限申请理由');
}
}
// 发起权限申请
status = await Permission.camera.request();
print('[权限服务] 相机权限申请结果: $status');
return _handlePermissionResult(status);
}
/// 检查存储权限状态
Future<PermissionStatus> checkStoragePermission() async {
final status = await Permission.storage.status;
print('[权限服务] 存储权限状态: $status');
return status;
}
/// 请求存储权限
Future<PermissionResult> requestStoragePermission() async {
var status = await checkStoragePermission();
if (status.isGranted) {
return PermissionResult(
granted: true,
status: status,
message: '存储权限已授权',
);
}
status = await Permission.storage.request();
return _handlePermissionResult(status);
}
/// 检查通知权限状态(iOS/Android 13+)
Future<PermissionStatus> checkNotificationPermission() async {
final status = await Permission.notification.status;
print('[权限服务] 通知权限状态: $status');
return status;
}
/// 请求通知权限
Future<PermissionResult> requestNotificationPermission() async {
var status = await checkNotificationPermission();
if (status.isGranted) {
return PermissionResult(
granted: true,
status: status,
message: '通知权限已授权',
);
}
status = await Permission.notification.request();
return _handlePermissionResult(status);
}
/// 批量请求多个权限
Future<Map<Permission, PermissionResult>> requestMultiplePermissions(
List<Permission> permissions,
) async {
final results = <Permission, PermissionResult>{};
// 发起批量请求
final statuses = await permissions.request();
for (final entry in statuses.entries) {
results[entry.key] = _handlePermissionResult(entry.value);
}
return results;
}
/// 检查是否需要跳转到设置页
///
/// 当权限被永久拒绝时,需要引导用户去设置页手动开启
Future<bool> shouldShowSettings() async {
// 检查相机权限是否被永久拒绝
final cameraStatus = await checkCameraPermission();
if (cameraStatus.isPermanentlyDenied) {
return true;
}
// 检查存储权限
final storageStatus = await checkStoragePermission();
if (storageStatus.isPermanentlyDenied) {
return true;
}
return false;
}
/// 跳转到应用设置页
///
/// 当权限被永久拒绝时,调用此方法引导用户手动开启权限
Future<void> openAppSettings() async {
print('[权限服务] 跳转到应用设置页');
// 使用 app_settings 插件跳转
await AppSettings.openAppSettings();
// 也可以使用 permission_handler 的方法
// await openAppSettings();
}
/// 检查所有必需权限是否都已授权
Future<bool> checkAllRequiredPermissions() async {
final camera = await checkCameraPermission();
return camera.isGranted;
}
/// 打开应用设置并等待用户返回
///
/// [onSettingsOpened] - 跳转到设置页前的回调
Future<PermissionResult> openSettingsAndWait({
VoidCallback? onSettingsOpened,
}) async {
// 触发回调
onSettingsOpened?.call();
// 跳转到设置页
await openAppSettings();
// 等待用户返回(这里简化处理,实际可能需要监听应用生命周期)
await Future.delayed(const Duration(milliseconds: 500));
// 重新检查权限状态
final cameraStatus = await checkCameraPermission();
return PermissionResult(
granted: cameraStatus.isGranted,
status: cameraStatus,
message: cameraStatus.isGranted
? '用户已在设置中开启权限'
: '用户仍未开启权限',
);
}
// ==================== 私有方法 ====================
/// 处理权限申请结果
PermissionResult _handlePermissionResult(PermissionStatus status) {
switch (status) {
case PermissionStatus.granted:
case PermissionStatus.limited:
return PermissionResult(
granted: true,
status: status,
message: '权限申请成功',
);
case PermissionStatus.denied:
return PermissionResult(
granted: false,
status: status,
message: '权限被拒绝,可以重试',
);
case PermissionStatus.permanentlyDenied:
return PermissionResult(
granted: false,
status: status,
message: '权限被永久拒绝,需要去设置页开启',
shouldOpenSettings: true,
);
case PermissionStatus.restricted:
return PermissionResult(
granted: false,
status: status,
message: '权限被系统限制',
shouldOpenSettings: false,
);
case PermissionStatus.provisional:
return PermissionResult(
granted: true,
status: status,
message: '权限被临时授权',
);
default:
return PermissionResult(
granted: false,
status: status,
message: '未知状态',
);
}
}
}
/// 权限申请结果
class PermissionResult {
/// 是否已授权
final bool granted;
/// 权限状态
final PermissionStatus status;
/// 状态消息
final String message;
/// 是否应该跳转到设置页
final bool shouldOpenSettings;
PermissionResult({
required this.granted,
required this.status,
required this.message,
this.shouldOpenSettings = false,
});
String toString() {
return 'PermissionResult(granted: $granted, status: $status, message: $message)';
}
}
3.2 权限申请对话框组件
新建文件 lib/widgets/permission_dialog.dart:
import 'package:flutter/material.dart';
import '../theme/app_theme.dart';
/// 权限申请对话框
///
/// 在申请权限前,向用户解释为什么需要这个权限
///
/// 使用方式:
/// ```dart
/// showDialog(
/// context: context,
/// builder: (context) => PermissionRationaleDialog(
/// icon: Icons.camera_alt,
/// title: '相机权限',
/// description: '心率检测需要使用相机来采集皮肤颜色变化...',
/// onConfirm: () => Navigator.pop(context, true),
/// onCancel: () => Navigator.pop(context, false),
/// ),
/// );
/// ```
///
/// 作者:小 J(上海本科大一计算机学生)
class PermissionRationaleDialog extends StatelessWidget {
// ==================== 构造函数参数 ====================
/// 图标
final IconData icon;
/// 标题
final String title;
/// 描述文字
final String description;
/// 确认按钮文字
final String confirmText;
/// 取消按钮文字
final String cancelText;
/// 确认回调
final VoidCallback onConfirm;
/// 取消回调
final VoidCallback onCancel;
// ==================== 构造函数 ====================
const PermissionRationaleDialog({
super.key,
required this.icon,
required this.title,
required this.description,
this.confirmText = '继续',
this.cancelText = '取消',
required this.onConfirm,
required this.onCancel,
});
// ==================== UI 构建 ====================
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: const Color(0xFF1A1A2E),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: Colors.white.withOpacity(0.1),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 图标
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: AppTheme.primary.withOpacity(0.2),
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 40,
color: AppTheme.primary,
),
),
const SizedBox(height: 20),
// 标题
Text(
title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
// 描述
Text(
description,
style: TextStyle(
fontSize: 14,
color: Colors.white.withOpacity(0.7),
height: 1.5,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// 按钮
Row(
children: [
// 取消按钮
Expanded(
child: TextButton(
onPressed: onCancel,
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
cancelText,
style: TextStyle(
color: Colors.white.withOpacity(0.7),
fontSize: 16,
),
),
),
),
const SizedBox(width: 12),
// 确认按钮
Expanded(
child: ElevatedButton(
onPressed: onConfirm,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
confirmText,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
],
),
),
);
}
}
/// 权限被永久拒绝的提示对话框
class PermissionDeniedDialog extends StatelessWidget {
/// 权限名称
final String permissionName;
/// 打开设置的回调
final VoidCallback onOpenSettings;
const PermissionDeniedDialog({
super.key,
required this.permissionName,
required this.onOpenSettings,
});
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: const Color(0xFF1A1A2E),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: Colors.red.withOpacity(0.3),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 警告图标
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(
Icons.warning_amber_rounded,
size: 40,
color: Colors.red,
),
),
const SizedBox(height: 20),
// 标题
const Text(
'权限被拒绝',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
// 描述
Text(
'$permissionName 权限被拒绝后无法正常使用。\n\n请点击下方按钮前往设置页开启权限。',
style: TextStyle(
fontSize: 14,
color: Colors.white.withOpacity(0.7),
height: 1.5,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// 按钮
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
Navigator.pop(context);
onOpenSettings();
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(
'前往设置',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(height: 12),
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'稍后再说',
style: TextStyle(
color: Colors.white.withOpacity(0.5),
),
),
),
],
),
),
);
}
}
3.3 权限检查页面
新建文件 lib/pages/permission_check_page.dart:
import 'package:flutter/material.dart';
import '../services/permission_service.dart';
import '../theme/app_theme.dart';
import '../widgets/permission_dialog.dart';
/// 权限检查页面
///
/// 在应用启动时检查并申请必要权限
///
/// 作者:小 J(上海本科大一计算机学生)
class PermissionCheckPage extends StatefulWidget {
/// 权限检查完成后的回调
final VoidCallback onPermissionsGranted;
/// 权限检查失败后的回调
final VoidCallback? onPermissionsDenied;
const PermissionCheckPage({
super.key,
required this.onPermissionsGranted,
this.onPermissionsDenied,
});
State<PermissionCheckPage> createState() => _PermissionCheckPageState();
}
class _PermissionCheckPageState extends State<PermissionCheckPage> {
// ==================== 状态变量 ====================
/// 权限服务
final PermissionService _permissionService = PermissionService();
/// 相机权限状态
PermissionStatus _cameraStatus = PermissionStatus.denied;
/// 是否正在检查
bool _isChecking = true;
/// 当前检查的步骤
String _currentStep = '正在检查权限...';
/// 错误信息
String? _errorMessage;
// ==================== 生命周期 ====================
void initState() {
super.initState();
_checkPermissions();
}
// ==================== 核心方法 ====================
/// 检查所有权限
Future<void> _checkPermissions() async {
setState(() {
_isChecking = true;
_errorMessage = null;
});
try {
// 步骤 1: 检查相机权限
setState(() {
_currentStep = '检查相机权限...';
});
_cameraStatus = await _permissionService.checkCameraPermission();
print('[权限检查] 相机权限状态: $_cameraStatus');
// 如果已经授权,直接进入应用
if (_cameraStatus.isGranted) {
setState(() {
_isChecking = false;
});
widget.onPermissionsGranted();
return;
}
// 步骤 2: 申请相机权限
setState(() {
_currentStep = '申请相机权限...';
});
final result = await _permissionService.requestCameraPermission(
showRationale: true,
);
print('[权限检查] 权限申请结果: $result');
// 如果申请成功
if (result.granted) {
setState(() {
_isChecking = false;
});
widget.onPermissionsGranted();
return;
}
// 如果权限被永久拒绝
if (result.shouldOpenSettings) {
setState(() {
_isChecking = false;
_errorMessage = '相机权限被永久拒绝';
});
// 显示引导对话框
_showDeniedDialog();
return;
}
// 权限被拒绝,但可以重试
setState(() {
_isChecking = false;
_errorMessage = '相机权限被拒绝';
});
// 显示拒绝对话框
_showDeniedDialog();
} catch (e) {
print('[权限检查] ❌ 发生错误: $e');
setState(() {
_isChecking = false;
_errorMessage = '权限检查失败: $e';
});
}
}
/// 显示权限被拒绝的对话框
void _showDeniedDialog() {
showDialog(
context: context,
barrierDismissible: false, // 不允许点击外部关闭
builder: (context) => PermissionDeniedDialog(
permissionName: '相机',
onOpenSettings: _openSettings,
),
);
}
/// 打开应用设置
Future<void> _openSettings() async {
setState(() {
_currentStep = '正在打开设置...';
});
final result = await _permissionService.openSettingsAndWait(
onSettingsOpened: () {
print('[权限检查] 已跳转到设置页');
},
);
print('[权限检查] 用户从设置页返回: $result');
if (result.granted) {
// 权限已开启
widget.onPermissionsGranted();
} else {
// 用户仍未开启权限
setState(() {
_errorMessage = '请在设置中开启相机权限';
});
}
}
/// 重试权限申请
void _retryPermissions() {
_checkPermissions();
}
// ==================== UI 构建 ====================
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppTheme.gradientStart,
AppTheme.gradientEnd,
],
),
),
child: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.all(40),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 状态图标
_buildStatusIcon(),
const SizedBox(height: 40),
// 标题
const Text(
'权限申请',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 16),
// 当前步骤
Text(
_currentStep,
style: TextStyle(
fontSize: 16,
color: Colors.white.withOpacity(0.7),
),
),
const SizedBox(height: 40),
// 错误信息
if (_errorMessage != null) ...[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.red.withOpacity(0.3),
),
),
child: Row(
children: [
const Icon(
Icons.error_outline,
color: Colors.red,
),
const SizedBox(width: 12),
Expanded(
child: Text(
_errorMessage!,
style: const TextStyle(
color: Colors.red,
fontSize: 14,
),
),
),
],
),
),
const SizedBox(height: 24),
],
// 加载指示器
if (_isChecking) ...[
const CircularProgressIndicator(
color: AppTheme.primary,
),
] else ...[
// 重试按钮
if (_errorMessage != null)
ElevatedButton.icon(
onPressed: _retryPermissions,
icon: const Icon(Icons.refresh),
label: const Text('重试'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 14,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
const SizedBox(height: 12),
// 设置按钮
if (_errorMessage != null)
TextButton(
onPressed: _openSettings,
child: const Text(
'前往设置',
style: TextStyle(
color: Colors.white70,
),
),
),
],
const SizedBox(height: 40),
// 权限说明
_buildPermissionInfo(),
],
),
),
),
),
),
);
}
Widget _buildStatusIcon() {
if (_isChecking) {
return Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: AppTheme.primary.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(
Icons.camera_alt,
size: 60,
color: AppTheme.primary,
),
);
}
if (_cameraStatus.isGranted) {
return Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(
Icons.check_circle,
size: 60,
color: Colors.green,
),
);
}
return Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(
Icons.warning_amber_rounded,
size: 60,
color: Colors.red,
),
);
}
Widget _buildPermissionInfo() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.05),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.info_outline,
color: Colors.white54,
size: 20,
),
const SizedBox(width: 8),
const Text(
'权限说明',
style: TextStyle(
color: Colors.white70,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 12),
_buildPermissionItem(
Icons.camera_alt,
'相机权限',
'用于心率检测时的皮肤颜色采集',
),
const SizedBox(height: 8),
_buildPermissionItem(
Icons.storage,
'存储权限',
'用于保存历史测量数据(可选)',
),
],
),
);
}
Widget _buildPermissionItem(IconData icon, String name, String description) {
final isGranted = name == '相机权限' && _cameraStatus.isGranted;
return Row(
children: [
Icon(
icon,
color: isGranted ? Colors.green : Colors.white54,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: TextStyle(
color: isGranted ? Colors.green : Colors.white,
fontSize: 14,
),
),
Text(
description,
style: TextStyle(
color: Colors.white.withOpacity(0.5),
fontSize: 12,
),
),
],
),
),
Icon(
isGranted ? Icons.check_circle : Icons.radio_button_unchecked,
color: isGranted ? Colors.green : Colors.white54,
size: 20,
),
],
);
}
}
3.4 完整的应用入口
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'pages/permission_check_page.dart';
import 'pages/health_dashboard_page.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
),
);
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
bool _permissionsGranted = false;
Widget build(BuildContext context) {
return MaterialApp(
title: '心率检测',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFFFF3B5C),
brightness: Brightness.dark,
),
),
home: _permissionsGranted
? const HealthDashboardPage()
: PermissionCheckPage(
onPermissionsGranted: () {
setState(() {
_permissionsGranted = true;
});
},
),
);
}
}
3.5 鸿蒙权限配置详解
在 Flutter for OpenHarmony 项目中,权限配置需要在以下文件中进行:
3.5.1 module.json5 配置
文件路径:entry/src/main/module.json5
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.CAMERA",
"reason": "$string:camera_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "always"
}
},
{
"name": "ohos.permission.WRITE_MEDIA",
"reason": "$string:storage_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "always"
}
},
{
"name": "ohos.permission.READ_MEDIA",
"reason": "$string:storage_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "always"
}
}
],
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"description": "$string:EntryAbility_desc",
"icon": "$media:icon",
"label": "$string:EntryAbility_label",
"startWindowIcon": "$media:icon",
"startWindowBackground": "$color:start_window_background",
"permissions": [
"ohos.permission.CAMERA"
]
}
]
}
}
3.5.2 string.json 配置
文件路径:entry/src/main/resources/base/element/string.json
{
"string": [
{
"name": "camera_reason",
"value": "心率检测功能需要使用相机来采集皮肤颜色变化,以计算您的心率。请放心,所有数据仅在本地处理,不会上传到任何服务器。"
},
{
"name": "storage_reason",
"value": "存储权限用于保存您的健康测量历史记录,方便您随时查看和管理健康数据。"
},
{
"name": "EntryAbility_desc",
"value": "心率检测应用"
},
{
"name": "EntryAbility_label",
"value": "心率检测"
}
]
}
3.5.3 AndroidManifest.xml 配置(备用)
如果你的项目也需要在 Android 上运行,还需要配置 entry/src/main/AndroidManifest.xml:
<manifest xmlns:ohos="http://schemas.huawei.com/res/ohos"
xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 相机权限 -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="true" />
<!-- 存储权限 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<!-- 通知权限(Android 13+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<!-- ... 其他配置 ... -->
</application>
</manifest>
四、开发踩坑与挫折:真实还原遇到的报错
4.1 第一个坑:权限弹窗闪一下就消失了
问题描述:申请权限时,系统的权限弹窗闪了一下就消失了,根本没给用户选择的机会。
崩溃日志:
D/PermissionHandler: Permission request result: PermissionStatus.denied
排查过程:
- 检查了
request()的调用时机 —— 发现是在initState()里同步调用的 - 问题是:权限申请必须在 UI 完全加载后才能调用,否则会被系统忽略
解决方案:使用 addPostFrameCallback 确保 UI 完全加载
void initState() {
super.initState();
// ✅ 正确的做法:等待 UI 完全加载后再申请权限
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkPermissions();
});
}
// ❌ 错误的做法:initState 中直接调用
void initState() {
super.initState();
_checkPermissions(); // 太早了!UI 还没准备好
}
4.2 第二个坑:权限被永久拒绝后,不知道怎么引导用户
问题描述:用户勾选了"不再询问",之后权限申请直接返回 permanentlyDenied,但我不知道该怎么处理。
排查过程:
- 查阅了
permission_handler的文档 - 发现可以使用
openAppSettings()跳转到系统设置页
解决方案:显示引导对话框 + 跳转设置页
// 检查是否需要跳转到设置页
final shouldShowSettings = await _permissionService.shouldShowSettings();
if (shouldShowSettings) {
// 显示引导对话框
showDialog(
context: context,
builder: (context) => PermissionDeniedDialog(
permissionName: '相机',
onOpenSettings: () {
Navigator.pop(context);
_permissionService.openAppSettings();
},
),
);
}
4.3 第三个坑:鸿蒙设备权限配置不对
问题描述:在鸿蒙设备上,权限申请总是返回 restricted,提示"权限被系统限制"。
崩溃日志:
E/PermissionHandler: Permission permanently denied without navigation possible
排查过程:
- 检查了
module.json5配置,发现权限名称写错了 - 鸿蒙的权限名称格式是
ohos.permission.XXX,不是android.permission.XXX
解决方案:修正权限配置
// ✅ 正确的权限名称
{
"name": "ohos.permission.CAMERA",
"reason": "$string:camera_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "always"
}
}
// ❌ 错误的权限名称
{
"name": "android.permission.CAMERA" // 这是 Android 的格式!
}
4.4 第四个坑:应用被系统杀死后权限丢失
问题描述:应用在后台被系统杀死后,权限状态变成了 denied。
排查过程:
- 测试发现,只有在应用完全退出后再进入,权限状态才会丢失
- 这是因为
permission_handler内部缓存了权限状态
解决方案:每次需要权限时都重新检查
// ✅ 正确的做法:每次需要权限时都重新检查
Future<void> useCamera() async {
// 不要相信缓存的状态,每次都重新检查
final status = await Permission.camera.status;
if (status.isGranted) {
// 使用相机
} else {
// 申请权限
await Permission.camera.request();
}
}
五、鸿蒙专属适配方案
5.1 鸿蒙权限模型详解
鸿蒙的权限模型与 Android 有一些区别:
| 特性 | Android | 鸿蒙 |
|---|---|---|
| 权限声明 | AndroidManifest.xml | module.json5 |
| 权限名称格式 | android.permission.XXX |
ohos.permission.XXX |
| 权限分组 | 可选 | 部分权限必须分组 |
| 首次启动 | 延迟到首次使用时 | 启动时立即申请 |
5.2 鸿蒙常用权限对照表
| 功能 | 鸿蒙权限 | 说明 |
|---|---|---|
| 相机 | ohos.permission.CAMERA |
拍照和录像 |
| 存储(写) | ohos.permission.WRITE_MEDIA |
写入媒体文件 |
| 存储(读) | ohos.permission.READ_MEDIA |
读取媒体文件 |
| 位置 | ohos.permission.LOCATION |
获取位置信息 |
| 网络 | ohos.permission.INTERNET |
访问网络 |
5.3 AtomGit 权限相关资源
鸿蒙权限配置的参考资源:
- OpenHarmony 权限开发指南:https://developer.harmonyos.com
- OpenHarmony-SIG 权限适配仓库:https://atomgit.com/organization/OpenHarmony-SIG
六、最终实现效果【权限问题,为底层解决,本图片仅供参考】

6.1 功能验证结果
经过调试优化,权限处理功能达到以下效果:
- ✅ 权限检查:启动时自动检查所有必需权限
- ✅ 权限申请:显示申请理由,用户明确后发起申请
- ✅ 永久拒绝处理:引导用户去设置页手动开启
- ✅ 状态检测:实时检测权限状态变化
6.2 在鸿蒙设备上的表现
(此处附鸿蒙设备运行截图)
七、个人学习总结与心得
7.1 作为大一学生的收获
说实话,权限处理是我之前最不愿意碰的东西 😓。总觉得那玩意儿是系统层面的东西,Flutter 开发者碰不得。
但通过这次研究,我发现:
- 权限处理其实不复杂:
permission_handler封装得很好,API 很简洁 - 理解用户心理很重要:用户拒绝权限往往是因为不知道你要干什么,所以理由说明很重要
- 降级处理是必须的:就算权限被拒绝,App 也不能直接崩溃,要优雅地降级
7.2 踩坑反思
最让我印象深刻的是 权限弹窗闪一下就消失 的问题。
一开始我怎么都想不通,明明代码写得没问题啊,为什么权限弹窗不显示?
后来才明白:是调用时机的问题!权限申请必须在 UI 完全加载后才能发起,否则会被系统忽略。
这让我学到了一个很重要的道理:Flutter 的生命周期和时序很重要,不能想当然地写代码。
7.3 系列文章总结
好了,这就是心率检测系列的最后一篇文章了!🎉
回顾这 8 篇文章,我们从 0 到 1 实现了一个完整的 Flutter for OpenHarmony 心率检测应用:
| 文章 | 主题 | 核心技能 |
|---|---|---|
| HR1 | 相机心率检测原理 | camera, PPG 信号处理 |
| HR2 | ECG 波形绘制 | CustomPainter, 60fps 动画 |
| HR3 | 心形搏动动画 | flutter_animate, BPM 同步 |
| HR4 | 渐变圆环进度条 | fl_chart, 数据可视化 |
| HR5 | 健康状态判断算法 | Dart 算法, 多指标评分 |
| HR6 | 数据持久化 | hive, 趋势图表 |
| HR7 | 新拟态 UI 设计 | 玻璃态, 深色主题 |
| HR8 | 权限处理 | permission_handler, 鸿蒙适配 |
7.4 后续学习计划
虽然心率检测系列写完了,但我还有很多想学的东西:
- 🔔 推送通知:鸿蒙的远程推送怎么接入?
- 📊 数据同步:云端备份怎么做?
- 🤖 AI 辅助:能不能用 ML Kit 做更准确的心率检测?
- 📱 上架应用商店:鸿蒙应用怎么上架?
如果大家对这些话题感兴趣,请留言告诉我!
创作日期:2026 年 4 月
版权所有,转载须注明出处
更多推荐




所有评论(0)