【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

排查过程

  1. 检查了 request() 的调用时机 —— 发现是在 initState() 里同步调用的
  2. 问题是:权限申请必须在 UI 完全加载后才能调用,否则会被系统忽略

解决方案:使用 addPostFrameCallback 确保 UI 完全加载


void initState() {
  super.initState();

  // ✅ 正确的做法:等待 UI 完全加载后再申请权限
  WidgetsBinding.instance.addPostFrameCallback((_) {
    _checkPermissions();
  });
}

// ❌ 错误的做法:initState 中直接调用

void initState() {
  super.initState();
  _checkPermissions();  // 太早了!UI 还没准备好
}

4.2 第二个坑:权限被永久拒绝后,不知道怎么引导用户

问题描述:用户勾选了"不再询问",之后权限申请直接返回 permanentlyDenied,但我不知道该怎么处理。

排查过程

  1. 查阅了 permission_handler 的文档
  2. 发现可以使用 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

排查过程

  1. 检查了 module.json5 配置,发现权限名称写错了
  2. 鸿蒙的权限名称格式是 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

排查过程

  1. 测试发现,只有在应用完全退出后再进入,权限状态才会丢失
  2. 这是因为 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 开发者碰不得。

但通过这次研究,我发现:

  1. 权限处理其实不复杂permission_handler 封装得很好,API 很简洁
  2. 理解用户心理很重要:用户拒绝权限往往是因为不知道你要干什么,所以理由说明很重要
  3. 降级处理是必须的:就算权限被拒绝,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 月
版权所有,转载须注明出处

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐