Android | 权限申请与前置说明弹窗同时展示的优雅方案

引言:权限申请的痛点

在 Android 应用开发中,权限申请是必不可少的环节。如果直接申请权限弹窗,显得有点突兀,用户不了解为何需要此权限,所以通常在申请权限之前会有个说明弹窗,当用户同意之后再去弹系统权限的弹窗。然后这种方案也有缺点,就是每次申请权限都需要2个弹窗:说明弹窗+系统权限弹窗,不过现在主流App的方案都是将这两个弹窗合二为一了,说明弹窗和系统权限弹窗同时展示,比如: 本文就实现一下这种效果。

权限申请流程优化

优化后流程:

复制代码
用户点击功能 → 展示自定义说明 → 自动触发系统弹窗 → 处理结果

效果图

示例代码

以相机权限为例,在Fragment中申请,核心代码如下:

kotlin 复制代码
/**
 * 权限申请时,自定义顶部TIPS
 */
class PermissionRequestFragment : BaseFragment() {
    private val btnPermission: Button by id(R.id.btn_permission_request)

    private val requestCameraPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
            handleCameraPermissionResult(isGranted)
        }

    override fun getLayoutId(): Int {
        return R.layout.layout_permission_request
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        btnPermission.setOnClickListener { requestPermission() }
    }

    private fun handleCameraPermissionResult(isGranted: Boolean) {
        removeTopTipsView(requireActivity())
        PermissionUtils.setCameraPermissionRequested(requireActivity(), true)

        log("isGranted:$isGranted")
        if (isGranted) {
            //权限授予成功,打开相机
            showToast("权限授予成功,打开相机")
        } else {
            //权限被拒绝,检查是否永久拒绝
            val shouldShowRationale = shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)

            if (!shouldShowRationale) {
                //用户选择了"不再询问",属于永久拒绝
                PermissionUtils.setCameraPermissionRequested(requireActivity(), false)
                showGoToSettingsDialog()
            } else {
                //临时拒绝
                showToast("相机权限被拒绝,无法使用拍照功能")
            }
        }
    }

    private fun showGoToSettingsDialog() {
        AlertDialog.Builder(requireActivity())
            .setTitle("相机权限被永久拒绝")
            .setMessage("相机权限已被永久拒绝,请到应用设置中手动开启权限。")
            .setPositiveButton("去设置") { _, _ ->
                openAppSettings()
            }
            .setNegativeButton("取消", null)
            .setCancelable(false)
            .show()
    }

    /**
     * 打开应用设置页面
     */
    private fun openAppSettings() {
        val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
        intent.data = "package:${requireActivity().packageName}".toUri()
        startActivity(intent)
    }

    private fun requestPermission() {
        if (hasCameraPermission()) {
            showToast("已经有对应权限了")
            return
        }
        //展示tips
        if (shouldShowPermissionTips()) {
            addTopTipsView(requireActivity())
        }
        requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
    }

    /**
     * 判断是否应该显示权限提示 tips
     * 返回 true 的情况:用户第一次请求权限,或者之前只是临时拒绝
     * 返回 false 的情况:
     * 1. 已经有权限
     * 2. 用户永久拒绝了权限(选择了"不再询问")
     * 3. 其他不应该展示的情况
     */
    private fun shouldShowPermissionTips(): Boolean {
        //如果已经有权限,不需要提示
        if (hasCameraPermission()) {
            return false
        }
        //判断用户之前是否请求过权限
        val hasBeenRequestedBefore = PermissionUtils.hasCameraPermissionBeenRequested(requireActivity())
        if (!hasBeenRequestedBefore) {
            return true
        }

        // 检查是否需要显示权限说明
        // 注意:shouldShowRequestPermissionRationale() 在以下情况返回 false:
        //   - 第一次请求权限
        //   - 用户永久拒绝(选择了"不再询问")
        //   - 用户已经授予权限
        // 只有在用户临时拒绝时返回 true
        val shouldShowRationale = shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)

        // 如果shouldShowRationale为true,说明用户之前临时拒绝过,现在再次请求,这种情况下可以展示提示
        return shouldShowRationale
    }


    private fun hasCameraPermission(): Boolean {
        return ContextCompat.checkSelfPermission(requireActivity(), Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
    }

    /**
     * 顶部展示权限说明Tips
     */
    private fun addTopTipsView(activity: FragmentActivity) {
        runCatching {
            val decorView = activity.window.decorView
            (decorView as? ViewGroup)?.let { viewGroup ->
                //检查是否已经存在tips,通过Tag查找
                val existingView = viewGroup.findViewWithTag<TextView>(CAMERA_PERMISSION_TIPS_TAG)
                if (existingView != null) return@let

                val textView = TextView(activity)
                textView.run {
                    tag = CAMERA_PERMISSION_TIPS_TAG
                    setBackgroundResource(R.drawable.shape_black_bg)
                    setTextColor(Color.WHITE)
                    typeface = Typeface.DEFAULT_BOLD
                    text = "请相机权限:"为了给您提供'拍照搜题'服务,需要申请使用您的相机权限。我们承诺仅用于此功能,保障您的隐私安全。""
                    textSize = 16f
                    setLineSpacing(2.dp2px().toFloat(), 1f)
                    val dpLength = 12.dp2px()
                    setPadding(dpLength, 18.dp2px(), dpLength, 18.dp2px())
                    val layoutParams = FrameLayout.LayoutParams(
                        ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
                    )
                    layoutParams.leftMargin = dpLength
                    layoutParams.rightMargin = dpLength
                    layoutParams.topMargin = dpLength * 3
                    decorView.addView(textView, layoutParams)
                }
            }
        }
    }

    /**
     * 删除Tips
     */
    private fun removeTopTipsView(activity: FragmentActivity) {
        runCatching {
            val decorView = activity.window.decorView
            (decorView as? ViewGroup)?.let { viewGroup ->
                //通过Tag精确查找并移除
                val tipsView = viewGroup.findViewWithTag<TextView>(CAMERA_PERMISSION_TIPS_TAG)
                if (tipsView != null) {
                    viewGroup.removeView(tipsView)
                }
            }
        }
    }

    companion object {
        private const val CAMERA_PERMISSION_TIPS_TAG = "camera_permission_tips_tag"
    }

}

object PermissionUtils {
    private const val PREFS_NAME = "app_permissions_prefs"
    private const val KEY_CAMERA_REQUESTED_ONCE = "camera_requested_once"

    private fun getPrefs(context: Context): SharedPreferences {
        return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
    }

    // 标记相机权限已经被请求过
    fun setCameraPermissionRequested(context: Context, status: Boolean) {
        getPrefs(context).edit { putBoolean(KEY_CAMERA_REQUESTED_ONCE, status) }
    }

    //检查相机权限是否被请求过(用于区分"第一次"和"永久拒绝")
    fun hasCameraPermissionBeenRequested(context: Context): Boolean {
        return getPrefs(context).getBoolean(KEY_CAMERA_REQUESTED_ONCE, false)
    }
}

上述代码中的解释已经很详细了,主要是在弹系统弹窗的同时需要展示我们自己的说明弹窗,通过addTopTipsView()展示说明弹窗,其方法内部是通过activity.window.decorView找到页面的根布局,通过addView将说明弹窗展示出来;当权限申请结束时通过removeTopTipsView()删除说明弹窗即可。

相关推荐
千里马学框架2 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台2 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone2 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc2 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo2 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077002 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼2 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone2 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen2 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone2 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui