本文同步发表于我的微信公众号,扫一扫文章底部的二维码或在微信搜索 郭霖 即可关注,每个工作日都有文章更新。
大家早上好,今天带来一篇原创。很高兴告诉大家,PermissionX又出新版本了。
之前因为很长一段时间都在准备GDG的演讲,手头上的不少工作都暂时放了一放。而GDG结束之后,我又立马恢复了之前的工作状态,以最快的速度发布了新版的PermissionX。
从我对这个项目的更新频率上大家应该就可以看出,这并不是我随便写着玩的一个项目,而是真的准备长期维护下去的开源项目。大家在使用过程中如果发现了什么问题,也都可以反馈给我。
截至目前为止,PermissionX已经迭代更新了三个版本,而最新的1.3.0版本更是加入了非常重要的自定义权限提醒对话框的功能。如果你觉得之前PermissionX自带的权限提醒对话框太丑,从而无法投入正式的生产环境,那么这次你将可以充分发挥自己的UI实力,打造出一个漂亮的权限提醒界面。
如果你对PermissionX的用法还完全没有了解,可以先去参考之前我发布的两篇文章 Android运行时权限终极方案,用PermissionX吧 和 PermissionX现在支持Java了!还有Android 11权限变更讲解
下面我们就来看一下1.3.0版本到底增加了哪些新特性。
在上一个版本当中,PermissionX引入了对Android 11权限变更的支持。为了更好地保护用户隐私,在Android 11系统当中,ACCESS_BACKGROUND_LOCATION权限变成了一个要去单独申请的权限,如果你将它和前台定位权限一起申请,则会产生崩溃。
但是在Android 10当中,前台定位权限和后台定位权限却是可以一起申请,分开申请虽然也是可以的,但是用户体验方面较差,因为要弹两次权限申请对话框。
为了减少这种系统差异型的适配,PermissionX专门针对Android 11这部分的权限变更做了适配,大家不需要单独去为Android 10和Android 11系统分别写一套权限处理的逻辑,而是统统交给PermissionX就行了,PermissionX内部会自动针对不同的系统版本做出相应的逻辑处理。
不过,我发现在实际的使用过程中,有一些开发者还是没能搞清楚Android 11权限适配这部分的正确用法,并且向我提出了一些问题。因此在开始介绍1.3.0新版功能之前,我先来请大家演示一下后台定位权限的正确申请方式。
首先来看问题是什么,这个问题我被问了不止一次。
这位朋友说,PermissionX在8.0系统中获取后台定位权限,该权限会直接进入deniedList,也就是拒绝列表当中。
为什么会出现这个现象呢?因为ACCESS_BACKGROUND_LOCATION是在Android 10系统中引入的新权限,8.0系统中并没有这个权限。
API level 29就是Android 10系统的意思。
那么8.0系统中没有ACCESS_BACKGROUND_LOCATION这个权限,但是我却去申请了这个权限,进入到拒绝列表当中也就是自然而然的事情了。
虽说是自然而然的事情,但是这样的请求结果会给一些朋友的使用造成困扰。我们来看如下一段代码:
PermissionX.init(this)
.permissions(Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_BACKGROUND_LOCATION)
.request { allGranted, grantedList, deniedList ->
if (allGranted) {
Toast.makeText(activity, "所有申请的权限都已通过", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(activity, "您拒绝了如下权限:$deniedList", Toast.LENGTH_SHORT).show()
}
}
这里我们同时请求了ACCESS_FINE_LOCATION和ACCESS_BACKGROUND_LOCATION两个权限,如果在Android 10以上系统运行的话,只要用户同时将前台定位和后台定位权限都授权给了我们,那么最终回调时allGranted就会是true。但是在Android 10以下系统运行的话,由于ACCESS_BACKGROUND_LOCATION是永远不会授权的,所以allGranted也就一定会是false。
这种请求结果确实会给一些开发者的编码逻辑造成困扰,有些朋友认为这是一个bug,应该在Android 10以下的系统版本中自动授权ACCESS_BACKGROUND_LOCATION权限,因为在低于Android 10的系统版本中,本身就是允许后台定位功能的。
关于这个建议我也思考了很久,在低于Android 10系统版本的时候ACCESS_BACKGROUND_LOCATION权限到底应该是进入授权列表还是拒绝列表?
最终我还是保留了现有的逻辑,原因也很简单,因为如果你在低于Android 10系统中调用系统的API来判断ACCESS_BACKGROUND_LOCATION权限是否授权,答案也是否定的。因此,保持和系统API一致的返回结果对我来说更加重要,因为PermissionX本质上还是对系统权限API的封装,我不应该擅自篡改系统返回的授权结果。
但是刚才提到的,如果同时申请了前台和后台权限,不同系统版本中的逻辑处理要怎么办呢?因为低于Android 10系统时,allGranted一定会是false。
这个问题其实并不难解决,我们先来看一下按照上述的写法,Android Studio是否认为是完全正确的呢?
可以看到,当申请ACCESS_BACKGROUND_LOCATION权限时,Android Studio给出了一个警告提示,说我们调用的API是在level 29(Android 10.0)时才加入的,而当前项目工程兼容的最低系统版本是15(Android 4.0)。
也就是说,这种申请权限的写法本身就是不合理的,在低版本的手机系统当中,系统根本就不能认别ACCESS_BACKGROUND_LOCATION到底是个什么东西。
因此,最正确的做法是,当我们的程序运行在低于Android 10系统的手机上时,就不应该去申请ACCESS_BACKGROUND_LOCATION权限,而不是纠结为什么ACCESS_BACKGROUND_LOCATION的返回结果是未授权。
下面我来改造一下上述的代码:
val permissionList = ArrayList<String>()
permissionList.add(Manifest.permission.ACCESS_FINE_LOCATION)
if (Build.VERSION.SDK_INT >= 29) {
permissionList.add(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
}
PermissionX.init(this)
.permissions(permissionList)
.request { allGranted, grantedList, deniedList ->
if (allGranted) {
Toast.makeText(activity, "所有申请的权限都已通过", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(activity, "您拒绝了如下权限:$deniedList", Toast.LENGTH_SHORT).show()
}
}
可以看到,这里我将即将申请的权限放到了一个List集合当中,但是只有在系统版本大于等于29时,才会将ACCESS_BACKGROUND_LOCATION加入集合。因此,在低版本的手机系统当中,是不会申请后台定位权限的。这样,allGranted变量的值也就不会再受到影响了。
另外,使用这种写法后,Android Studio也不会再给我们警告提示了。
现在Fragment的使用貌似比Activity还要普遍,而上个版本的PermissionX在初始化时只支持传入Activity类型的实例,确实是我考虑不周了。
有好几位朋友请我询问,在Fragment中要如何使用PermissionX来申请权限?这个问题说实话,一下子把我问懵了,好像我之前确实没考虑过这个问题。
不过后来我反应过来之后想到,在Fragment中不是也可以获取到Activity的实例吗?那么getActivity()之后再传给PermissionX不就可以了嘛。
我认为这样是可以解决问题的,但是根据目前得到的一些反馈,在Fragment中使用PermissionX可能会造成一种IllegalStateException。
这个问题因为也是不止有一个人遇到了,所以我认为可能并不是一种偶然的现象。
但奇怪的是,我自己想尽了各种办法去重现这个问题,都始终没能重现,不知道是不是和使用的Fragment版本有关。
不过没关系,即使没能重现这个问题,也并不影响我解决它。根据stackoverflow上的解答(解决Android问题的神网站),当我们在Fragment中再去添加另一个子Fragment时,应该使用ChildFragmentManager而不是FragmentManager。
那么很明显,如果使用刚才getActivity()的方式,PermissionX在内部去添加请求权限的隐藏Fragment时,使用的肯定还是FragmentManager,我想这大概就是造成问题的原因。
而1.3.0版本的PermissionX引入了对Fragment的原生支持,当我们在Fragment中使用PermissionX时不需要再调用getActivity()了,而是可以直接传入this,示例写法如下:
class MainFragment : Fragment() {
...
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
PermissionX.init(this)
.permissions(Manifest.permission.ACCESS_FINE_LOCATION)
.request { allGranted, grantedList, deniedList ->
}
}
}
PermissionX在内部会自动判断,如果开发者初始化时传入的是Activity,那么将会自动使用FragmentManager。而如果传入的是Fragment,那么将会自动使用ChildFragmentManager。部分源码实现如下所示:
private InvisibleFragment getInvisibleFragment() {
FragmentManager fragmentManager;
if (fragment != null) {
fragmentManager = fragment.getChildFragmentManager();
} else {
fragmentManager = activity.getSupportFragmentManager();
}
...
}
当然,这只是我根据有限的错误信息以及stackoverflow上的解答,推断出来的一种解决方案。我自己这边是无从验证的,因为我本身就没能重现这个问题。
如果大家在使用1.3.0版本的PermissionX之后还是有遇到这个问题,那么请继续反馈给我,并且最好能指导我一下如何将这个问题重现。
自定义权限提醒对话框应该是1.3.0版本最重磅的一个功能了。
之前的PermissionX虽然在权限处理流程方面考虑的非常周全,比如说我们申请的权限被拒绝了怎么办?我们申请的权限被永久拒绝了怎么办?但是,PermissionX在权限被拒绝时的提醒对话框是系统默认的样式,而且只能输入文字内容,满足不了很多开发者的要求。如下图所示。
无法任意地定制自己想要的界面,可能是限制PermissionX投入正式生产环境的最大因素。
而1.3.0版本则完全解决了这个问题,现在大家可以自定义各种各样的对话框界面,使其与你的项目UI风格完全一致。
至于这部分的用法也非常简单,PermissionX 1.3.0版本提供了一个RationalDialog的抽象类,当你需要自定义权限提醒对话框的时候,只需要继承自这个类即可。而RationaleDialog实际上继承的也是系统的Dialog类,因此在自定义对话框的用法上面,和你平时编写的代码并没有什么两样。
只不过由于我们这个对话框的作用是为了向用户解释为什么我们需要申请这些权限,以及让用户理解原因之后同意申请。因此,对话框上面必须要有一个确定按钮,以及一个可选的取消按钮(如果是必须授予的权限,可不提供取消按钮)。另外,我们还必须要知道即将申请哪些权限,否则界面上不知该显示什么样的提示信息。
因此,RationaleDialog类中定义了三个抽象方法,这三个抽象方法是你在自定义对话框的时候必须要实现的,如下所示:
public abstract class RationaleDialog extends Dialog {
/**
* Return the instance of positive button on the dialog. Your dialog must have a positive button to proceed request.
* @return The instance of positive button on the dialog.
*/
abstract public @NonNull View getPositiveButton();
/**
* Return the instance of negative button on the dialog.
* If the permissions that you request are mandatory, your dialog can have no negative button.
* In this case, you can simply return null.
* @return The instance of positive button on the dialog, or null if your dialog has no negative button.
*/
abstract public @Nullable View getNegativeButton();
/**
* Provide permissions to request. These permissions should be the ones that shows on your rationale dialog.
* @return Permissions list to request.
*/
abstract public @NonNull List<String> getPermissionsToRequest();
}
getPositiveButton()方法用于返回当前自定义对话框上的确定按钮;getNegativeButton()方法用于返回当前自定义对话框上的取消按钮,如果对话框不可取消的话,直接返回null即可;getPermissionsToRequest()方法用于返回即将申请哪些权限。
RationaleDialog只强制要求你实现以上三个方法,至于其他的自定义界面部分完全由你自由发挥,怎样实现都可以。
现在,当权限被拒绝时,我们只需要将自定义的对话框传给showRequestReasonDialog()方法即可,代码如下所示:
val myRationaleDialog = ...
scope.showRequestReasonDialog(myRationaleDialog)
一切搞定!
这样看下来,自定义权限提醒对话框这个功能,PermissionX的工作倒是非常简单,最难的还是在于自定义UI界面这部分。因此,下面我来演示一种自定义对话框的实现方法,供大家参考。
一个好看的自定义对框界面需要分为很多步去完成,这里我向大家一步步进行展示。首先第一步要定义一个主题,编辑styles.xml文件,并添加如下内容:
<resources>
...
<style name="CustomDialog" parent="android:style/Theme.Dialog">
- "android:windowBackground"
>@android:color/transparent
- "android:windowNoTitle">true
- "android:windowFrame">@null
- "android:windowIsFloating">true
- "android:backgroundDimEnabled">true
style>
resources>
接下来我们要提供对话框的背景样式,以及确定按钮和取消按钮的背景样式。在drawable目录下新建custom_dialog_bg.xml、positive_button_bg.xml、negative_button_bg.xml三个文件,代码分别如下所示。
custom_dialog_bg.xml:
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#272727" />
<corners android:radius="20dp" />
shape>
positive_button_bg.xml
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#2084c2" />
<corners android:radius="20dp" />
shape>
negative_button_bg.xml
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#7d7d7d" />
<corners android:radius="20dp" />
shape>
然后在layout目录下新建custom_dialog_layout.xml文件,用于作为我们自定义对话框的布局,代码如下:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/custom_dialog_bg"
android:orientation="vertical"
>
<TextView
android:id="@+id/messageText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="20sp"
android:textColor="#fff"
android:layout_marginLeft="20dp"
android:layout_marginRight="20dp"
android:layout_marginTop="20dp"
/>
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_margin="20dp"
android:scrollbars="none"
android:layout_weight="1">
<LinearLayout
android:id="@+id/permissionsLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
/>
ScrollView>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginLeft="20dp"
android:layout_marginRight="20dp"
android:layout_marginBottom="10dp">
<Button
android:id="@+id/negativeBtn"
android:layout_width="120dp"
android:layout_height="46dp"
android:background="@drawable/negative_button_bg"
android:textColor="#fff"
android:text="拒绝"/>
<Button
android:id="@+id/positiveBtn"
android:layout_width="120dp"
android:layout_height="46dp"
android:layout_marginStart="30dp"
android:layout_marginLeft="30dp"
android:background="@drawable/positive_button_bg"
android:textColor="#fff"
android:text="开启"/>
LinearLayout>
LinearLayout>
一个很简单的界面,这里就不具体解释了。
另外,由于我们会在对话框当中动态显示要申请哪些权限,因此还需要定义一个额外的布局来显示动态内容。在layout目录下新建一个permissions_item.xml文件,代码如下所示:
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/bodyItem"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="5dp"
android:textSize="16sp"
android:textColor="#fff">
TextView>
动态内容不用多复杂,直接使用一个TextView即可。
好了,将上述布局文件都定义好了之后,接下来我们就可以进行编码实现了。新建一个CustomDialog类,并让它继承自RationaleDialog,然后编写如下代码:
@TargetApi(30)
class CustomDialog(context: Context, val message: String, val permissions: List<String>) : RationaleDialog(context, R.style.CustomDialog) {
private val permissionMap = mapOf(Manifest.permission.READ_CALENDAR to Manifest.permission_group.CALENDAR,
Manifest.permission.WRITE_CALENDAR to Manifest.permission_group.CALENDAR,
Manifest.permission.READ_CALL_LOG to Manifest.permission_group.CALL_LOG,
Manifest.permission.WRITE_CALL_LOG to Manifest.permission_group.CALL_LOG,
Manifest.permission.PROCESS_OUTGOING_CALLS to Manifest.permission_group.CALL_LOG,
Manifest.permission.CAMERA to Manifest.permission_group.CAMERA,
Manifest.permission.READ_CONTACTS to Manifest.permission_group.CONTACTS,
Manifest.permission.WRITE_CONTACTS to Manifest.permission_group.CONTACTS,
Manifest.permission.GET_ACCOUNTS to Manifest.permission_group.CONTACTS,
Manifest.permission.ACCESS_FINE_LOCATION to Manifest.permission_group.LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION to Manifest.permission_group.LOCATION,
Manifest.permission.ACCESS_BACKGROUND_LOCATION to Manifest.permission_group.LOCATION,
Manifest.permission.RECORD_AUDIO to Manifest.permission_group.MICROPHONE,
Manifest.permission.READ_PHONE_STATE to Manifest.permission_group.PHONE,
Manifest.permission.READ_PHONE_NUMBERS to Manifest.permission_group.PHONE,
Manifest.permission.CALL_PHONE to Manifest.permission_group.PHONE,
Manifest.permission.ANSWER_PHONE_CALLS to Manifest.permission_group.PHONE,
Manifest.permission.ADD_VOICEMAIL to Manifest.permission_group.PHONE,
Manifest.permission.USE_SIP to Manifest.permission_group.PHONE,
Manifest.permission.ACCEPT_HANDOVER to Manifest.permission_group.PHONE,
Manifest.permission.BODY_SENSORS to Manifest.permission_group.SENSORS,
Manifest.permission.ACTIVITY_RECOGNITION to Manifest.permission_group.ACTIVITY_RECOGNITION,
Manifest.permission.SEND_SMS to Manifest.permission_group.SMS,
Manifest.permission.RECEIVE_SMS to Manifest.permission_group.SMS,
Manifest.permission.READ_SMS to Manifest.permission_group.SMS,
Manifest.permission.RECEIVE_WAP_PUSH to Manifest.permission_group.SMS,
Manifest.permission.RECEIVE_MMS to Manifest.permission_group.SMS,
Manifest.permission.READ_EXTERNAL_STORAGE to Manifest.permission_group.STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE to Manifest.permission_group.STORAGE,
Manifest.permission.ACCESS_MEDIA_LOCATION to Manifest.permission_group.STORAGE
)
private val groupSet = HashSet<String>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.custom_dialog_layout)
messageText.text = message
buildPermissionsLayout()
window?.let {
val param = it.attributes
val width = (context.resources.displayMetrics.widthPixels * 0.8).toInt()
val height = param.height
it.setLayout(width, height)
}
}
override fun getNegativeButton(): View? {
return negativeBtn
}
override fun getPositiveButton(): View {
return positiveBtn
}
override fun getPermissionsToRequest(): List<String> {
return permissions;
}
private fun buildPermissionsLayout() {
for (permission in permissions) {
val permissionGroup = permissionMap[permission]
if (permissionGroup != null && !groupSet.contains(permissionGroup)) {
val textView = LayoutInflater.from(context).inflate(R.layout.permissions_item, permissionsLayout, false) as TextView
textView.text = context.packageManager.getPermissionGroupInfo(permissionGroup, 0).loadLabel(context.packageManager)
permissionsLayout.addView(textView)
groupSet.add(permissionGroup)
}
}
}
}
这段代码其实在自定义界面部分的篇幅并不多,就是在onCreate()方法当中通过setContentView()显示了我们刚才自定义的布局而已。
但是permissionMap这部分代码所占的篇幅却比较大,为什么要写这段代码呢?我来向大家解释一下。
Android的权限机制其实是由权限和权限组共同组成的。一个权限组中可能会包含多个权限,比如CALENDAR权限组中就包含了READ_CALENDAR和WRITE_CALENDAR这两个权限。
我们平时在申请权限时,需要使用权限名来申请,而不能使用权限组名,但是当权限组中的某个权限被授权之后,同组的其他权限也会被自动授权,不需要再去逐个申请。
因此,当我们收到了一个要申请的权限列表时,其实并不需要将这个列表中的权限全部显示到界面上,而是只显示要申请的权限组名即可,这样可以让界面更精简。根据我之前的统计,Android 10系统中的运行时权限有30个,而权限组只有11个。
上述代码中的permissionMap以及buildPermissionsLayout()方法其实就是在处理这个逻辑,根据传入的权限来获取其相应的权限组,然后动态添加到对话框当中。
除此之外,getPositiveButton()、getNegativeButton()、getPermissionsToRequest()这三个方法都是进行了最基本的实现,将对话框中的确定按钮、取消按钮、以及要申请的权限列表返回即可。
这样我们就将自定义权限提醒对话框完成了!接下来的工作就是如何使用它,这就非常简单了,代码如下所示:
PermissionX.init(this)
.permissions(Manifest.permission.CAMERA, Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.RECORD_AUDIO)
.onExplainRequestReason { scope, deniedList, beforeRequest ->
val message = "PermissionX需要您同意以下权限才能正常使用"
val dialog = CustomDialog(context, message, deniedList)
scope.showRequestReasonDialog(dialog)
}
.onForwardToSettings { scope, deniedList ->
val message = "您需要去设置中手动开启以下权限"
val dialog = CustomDialog(context, message, deniedList)
scope.showForwardToSettingsDialog(dialog)
}
.request { allGranted, grantedList, deniedList ->
if (allGranted) {
Toast.makeText(activity, "所有申请的权限都已通过", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(activity, "您拒绝了如下权限:$deniedList", Toast.LENGTH_SHORT).show()
}
}
绝大部分的用法和之前版本的PermissionX并没有什么区别,我就不详细解释了。最需要关注的点在onExplainRequestReason和onForwardToSettings这两个方法的Lambda表达式中,这里我们创建了CustomDialog的实例,然后分别调用scope.showRequestReasonDialog()和scope.showForwardToSettingsDialog()方法,并将CustomDialog的实例传入即可。
大功告成!现在运行一下程序,你将会体验到非常棒的权限请求流程,如下图所示。
当然,这还只是我实现的一个比较基础的自定义权限提醒对话框,现在充分发挥你的UI实力的时候到了。
上述自定义对话框的完整代码实现,我都放到了PermissionX的开源项目工程当中,下载之后直接运行就可以看到上图中的效果了。
PermissionX开源库地址:https://github.com/guolindev/PermissionX
关于PermissionX新版本的内容变化就介绍到这里,升级的方式非常简单,修改一下dependencies当中的版本号即可:
dependencies {
...
implementation 'com.permissionx.guolindev:permissionx:1.3.0'
}
另外,如果你的项目还没有升级到AndroidX,那么可以使用Permission-Support这个版本,用法都是一模一样的,只是dependencies中的依赖声明需要改成:
dependencies {
...
implementation 'com.permissionx.guolindev:permission-support:1.3.0'
}
总体让我评价一下的话,自定义权限提醒对话框给大家带来了更多的可能性,但是在易用性方面还是有些不足,因为自定义一个对话框总体还是比较麻烦的。因此,下个版本当中,我准备内置一些对话框的实现,从而让那些对界面要求不太高的朋友们可以更加轻松地使用PermissionX。
如果想要学习Kotlin和最新的Android知识,可以参考我的新书 《第一行代码 第3版》,点击此处查看详情。
关注我的技术公众号,每天都有优质技术文章推送。
微信扫一扫下方二维码即可关注: