本文以应用商店整改权限申请为背景,介绍一个利用"字节码插桩",高效整改"权限申请提示"的方案。
1. 背景
从Android6开始,Android官方推出了"动态权限申请",对于一些危险的权限如相机、媒体照片、录音等,需要在使用时向用户动态申请。
下文提到的"权限申请",皆为"动态权限申请"。
为了进一步规范权限申请,应用商店要求应用在申请权限时,需要告知用户申请的目的,这个就是"权限申请提示"。应用商店给出的提示样例如下:
作为开发者,我们要做的是找到每一处权限申请,在申请时同步展示申请的理由。
2. 问题分析
下面我们分析下面临的问题
-
2.1 排查适配工作量大
"权限申请"广泛存在于
APP
中,包括工程代码及第三方库代码,逐个排查适配较麻烦。而且需要整改的APP
不止一个,工作量呈倍速增长。 -
2.2 为每个权限单独弹出"权限申请提示"
实际开发时,我们会调用
requestPermissions
申请一个或多个权限,接着系统就会逐个弹出申请弹窗。如果能知道每个权限申请的开始和完成,我们就可以给每个权限弹出"权限申请提示"。很遗憾,系统只会在所有权限申请完毕后,回调
onRequestPermissionsResult
方法返回所有结果,没有单个权限申请完成的回调。那么想为每个权限单独弹出"权限申请提示",似乎只能将多个权限申请拆解成一个个进行申请了,但这样需要大量修改现有代码,入侵性太大了。
3. 解决方案
目前我们面临着"排查适配工作量大"、"权限单独提示入侵性大"等问题。对解决方案的要求就是:高效找到所有的权限申请;在不影响原代码逻辑的基础上,为每个权限申请进行提示;总结起来就是"高效"、"低入侵"。
-
3.1 "字节码插桩"替换原申请方法
"高效"、"低入侵"是理想方案应该具备的,比如"全局换肤","屏幕适配"等,这些方案有一个共同点就是:找到合适的
hook
点并在此做文章,从而高效、低入侵的解决问题。比如"全局换肤"利用LayoutInflater.Factory
、"屏幕适配"利用density
。下面是权限申请的五种方式。通过阅读相关源码,尝试寻找合适的
hook
点,可以统一处理所有的权限申请。kotlin// 1 Activity.requestPermissions(...) // 2 ActivityCompat.requestPermissions(...) // 3 androidx.fragment.app.Fragment.requestPermissions(...) // 4 android.app.Fragment.requestPermissions(...) // 5 val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission(), callback) permissionLauncher.launch(...)
可惜经过一番研究,并没有收获。唯一发现比较有可能的是
ActivityCompat.setPermissionCompatDelegate
,但这个要求所有的权限申请都通过ActivityCompat.requestPermissions
,这在实际中是不可能的。在源码上找
hook
这条路是行不通了。其实权限申请就是一行代码的事,有没有办法把这行代码换成代理方法呢?然后在代理方法内完成"权限申请提示"。这个时候就轮到"字节码插桩"啦!"字节码插桩"能够在编译期修改
APP
的所有代码,包括替换方法调用。具有高效彻底、使用者无感、低入侵等优点,符合我们前面的要求。因此我们通过"字节码插桩"在编译期查找并替换相关方法,将权限申请这件事统一拦截下来,交给代理方法去做特殊处理。不熟悉"字节码插桩"的朋友,推荐另外一篇文章:"字节码插桩(一) -- 新手入门篇"。从小白的角度,把学习"字节码插桩"需要知道的知识串联起来。
-
3.2 "代理申请"为权限单独提示
上一小节,我们讨论了如何处理所有权限申请,最终采用"字节码插桩"将所有权限申请拦截,交给代理方法处理。这一小节,将讨论在代理方法内,如何去完成我们的权限申请及提示。
首先,我们的需求是:为每个权限单独弹出"权限申请提示"。所以代理方法需要:将当次请求的所有权限,拆分成一个个权限进行申请,每申请一个权限弹出一个提示 。我们都知道权限申请的结果,会通过
onRequestPermissionsResult
等回调进行通知。所以为了不影响原代码逻辑,代理方法需要:收集每个权限申请的结果,统一返回给相应的回调。通过拦截权限申请,由代理方法去拆分权限申请并做提示,最后收集所有结果统一返回给对应的回调。这样下来,我们就可以在不修改原代码的基础上,完成我们的需求,做到"低入侵"。
-
3.3 完整方案结构
前两个小节,讨论了如何"高效"、"低入侵"的进行"权限申请提示",下面总结下整体的架构。
4. 方案注意点
-
4.1 方法替换
我们以"替换Activity的权限申请"为例,看下这个过程需要注意的地方。
kotlin// 代码A class MainActivity : AppCompatActivity() { fun test() { requestPermissions(arrayOf(Manifest.permission.CAMERA), 1) } } // 代码B class MainActivity : AppCompatActivity() { fun test() { PermissionInjectDelegateWrapper.requestPermissions(this, arrayOf(Manifest.permission.CAMERA), 1) } }
我们的目的是把"代码A"替换成"代码B",接下来利用
ASMPlugin
对比它们在ASM
代码上的差别,然后利用ASM
框架把"代码A"替换成"代码B"。不熟悉的,推荐先看下:"字节码插桩(一) -- 新手入门篇" 上面分别是"代码A"和"代码B"的部分ASM
代码,唯一的差别是红色框中的内容。所以我们可以通过如下代码,将"代码A"替换成"代码B"。kotlinclass PermissionMethodVisitor(methodVisitor: MethodVisitor) : MethodVisitor(Opcodes.ASM9, methodVisitor) { override fun visitMethodInsn( opcode: Int, owner: String?, name: String?, descriptor: String?, isInterface: Boolean ) { // 注意点1 if (opcode == Opcodes.INVOKEVIRTUAL && name == "requestPermissions" && descriptor == "([Ljava/lang/String;I)V" ) { // 注意点2 super.visitMethodInsn( Opcodes.INVOKESTATIC, "com/youdao/permission/inject/library/PermissionInjectDelegateWrapper", "requestPermissions", "(Ljava/lang/Object;[Ljava/lang/String;I)V", false ) } else { super.visitMethodInsn(opcode, owner, name, descriptor, isInterface) } } }
-
注意点1: 对owner的判断
kotlinclass MainActivity : AppCompatActivity() { fun test() { requestPermissions(arrayOf(Manifest.permission.CAMERA), 1) } }
visitMethodInsn
方法的owner
参数,表示当前调用的方法属于哪个类。如上,owner
取值为MainActivity
,而实际项目中owner
可能会是AActivity
、BActivity
...... 甚至可能是一个普通的类,一个单例......因此前面的
if
语句,不把owner
作为判断条件之一,这样就能把所有可能的方法调用都替换成代理方法。然后在代理方法内再去做判断,非Activity
的情况,则通过反射调用原方法。kotlinobject PermissionInjectDelegate { fun requestPermissions(caller: Any, permissions: Array<String>, requestCode: Int) { val isRequestDelegate = when { caller is Activity -> checkRequestPermissionsDelegate(...) else -> false } if (!isRequestDelegate) { try { val name = "requestPermissions" val method = caller.getDeclaredMethodOrNull( name, Array<String>::class.java, Int::class.java ) method?.isAccessible = true method?.invoke(caller, permissions, requestCode) } catch (ignored: Exception) { Log.w(TAG, "requestPermissions method not found") } } } }
-
注意点2: kotlin单例的影响
前面代码中存在
PermissionInjectDelegate
和PermissionInjectDelegateWrapper
,前者是kotlin
单例,后者是java
静态类。在替换的时候,我们选择替换成PermissionInjectDelegateWrapper
,然后在PermissionInjectDelegateWrapper
中再去调用PermissionInjectDelegate
中的方法。如下:less// PermissionInjectDelegateWrapper.java public class PermissionInjectDelegateWrapper { public static void requestPermissions( final @NonNull Object caller, final @NonNull String[] permissions, final @IntRange(from = 0) int requestCode ) { PermissionInjectDelegate.INSTANCE.requestPermissions(caller, permissions, requestCode); } } // PermissionInjectDelegate.kt object PermissionInjectDelegate {...}
做这一层中转的原因是:
kotlin
单例其实就是一个类的静态属性INSTANCE
,我们在使用前需要先加载这个静态属性。这就导致直接调用PermissionInjectDelegate
会比调用PermissionInjectDelegateWrapper
多一行ASM
代码,如下所示:多出来的这行代码,会增加替换的难度,因此我们选择用
PermissionInjectDelegateWrapper
做一层中转。 -
注意点3: 代码布局的影响 我们在对比
ASM
代码差异时,最好把代码都变成一行,比如:scss// 多行 requestPermissions( arrayOf(Manifest.permission.CAMERA), 1 ) // 一行 requestPermissions(arrayOf(Manifest.permission.CAMERA), 1)
因为多行的情况下,查看
ASM
代码会有插入行号的代码,影响我们比较差异。而行号相关的ASM
代码不会影响运行逻辑,所以我们可以把代码都处理成一行,再去比较。
-
-
4.2 代理申请
前面我们知道了"代理申请"的任务就是:将当次请求的所有权限,拆分成一个个权限进行申请,每申请一个权限弹出一个提示。
在这个过程,需要保证"代理申请"与"原申请"的行为一致,那样才能不影响原有代码逻辑和使用预期。需要注意的点有:
-
注意点1: 重复请求
arduino// Activity public final void requestPermissions(...) { ...... if (mHasCurrentPermissionsRequest) { Log.w(TAG, "Can request only one set of permissions at a time"); // Dispatch the callback with empty arrays which means a cancellation. onRequestPermissionsResult(requestCode, new String[0], new int[0]); return; } ...... mHasCurrentPermissionsRequest = true; } private void dispatchRequestPermissionsResult(int requestCode, Intent data) { mHasCurrentPermissionsRequest = false; }
Activity
中有这样一段代码,保证只有一个权限申请在进行。而不管是用fragment
还是ActivityCompat
或是ActivityResult
去申请权限,最终都是通过Activity.requestPermissions
去申请。因此在"代理申请"内同样需要做到这点。
kotlinobject PermissionInjectDelegate { private fun checkRequestPermissionsDelegate(...): Boolean { if (mHasCurrentPermissionsDelegateRequest) { // 同时请求申请多批权限,模仿[Activity.requestPermissions]的处理方式并返回true,表示此次请求被消耗。 Log.w(TAG, "Can request only one set of permissions at a time") // Dispatch the callback with empty arrays which means a cancellation. when (caller) { is Activity -> { caller.onRequestPermissionsResult(requestCode, emptyArray(), IntArray(0)) } is Fragment -> { caller.activity?.onRequestPermissionsResult(requestCode, emptyArray(), IntArray(0)) } is android.app.Fragment -> { caller.activity?.onRequestPermissionsResult(requestCode, emptyArray(), IntArray(0)) } } return true } else { ...... } } }
-
注意点2: 权限合并
申请权限时,需要传入一个权限数组,表示需要申请的权限。系统在收到权限数组时,会做下面这些处理:
-
重复权限合并。
当权限数组是["A", "B", "A"]的时候,系统会把其处理成["A", "B"]。
因此"代理申请"需要合并重复权限。
-
同组权限合并
当我们同时申请
Manifest.permission.WRITE_EXTERNAL_STORAGE
和Manifest.permission.READ_EXTERNAL_STORAGE
时,系统只会弹一个权限申请弹窗。因为它们同属于Manifest.permission_group.STORAGE
,系统会将它们合并处理。类似的还有蓝牙权限、定位权限等,详细分组可以查看PermissionMapping.kt。如果我们把同组权限,拆分成一个个去申请,那样会导致重复弹出相同的申请弹窗。
因此,在"代理申请"内,我们按照PermissionMapping.kt的分组,将相同分组的权限合并申请:
scss// PermissionDelegateActivity.kt private fun loopRequestPermission() { ...... if (mWait2RequestPermissions.isEmpty()) { dealResultAndFinish() return } val permission = mWait2RequestPermissions.removeAt(0) val curRequestPermissions = mutableListOf<String>() curRequestPermissions.add(permission) // 将同一组的权限进行合并请求 PermissionMapping.getPermissionListByGroup().forEach { groupedPermissions -> val sameGroupPermissions = mWait2RequestPermissions.removeSameGroupPermission(permission, groupedPermissions) curRequestPermissions.addAll(sameGroupPermissions) } ...... requestPermissions(curRequestPermissions.toTypedArray(), mRequestCode) }
-
-
-
4.3 结果回调
"代理申请"结束后,需要将结果返回。可以分成两类:
-
Activity、ActivityCompat、androidx.fragment.app.Fragment、android.app.Fragment
scss// 1 Activity.requestPermissions(...) // 2 ActivityCompat.requestPermissions(...) // 3 androidx.fragment.app.Fragment.requestPermissions(...) // 4 android.app.Fragment.requestPermissions(...)
这一类,直接回调对应的
onRequestPermissionsResult
即可。比如方式1
和方式2
,直接回调Activity.onRequestPermissionsResult
;方式3
和方式4
,直接回调Fragment.onRequestPermissionsResult
-
ActivityResultLauncher
scss// 5 val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission(), callback) permissionLauncher.launch(...)
ActivityResultLauncher
的申请结果回调比较特殊,是回调的registerForActivityResult(..., callback)
方法的callback
参数。因此在"方法替换"这一步,除了替换申请函数launch
外,还需要替换registerForActivityResult
方法,从而获取到对应的callback
。而后在"代理申请"结束后,通过callback
将结果返回。
在处理上述两种回调时,需要持有
Activity
、Fragment
、callback
等,这块需要注意生命周期管理,避免内存泄漏。 -
5. 总结
上图是最终的效果图,可以看到申请权限的时候,在顶部有相应的提示。目前该方案已在我们的多个APP
中使用,适配效果还不错。
文章介绍一个利用"字节码插桩",高效整改"权限申请提示"的方案。这是我的第一次"字节码插桩"实践,在这个过程中学到了不少知识。因此总结了两篇文章:
字节码插桩(一) -- 新手入门篇 记录"字节码插桩"的基础知识。
字节码插桩(二) -- "权限申请提示"实践篇,即本文。把实施过程中的"字节码插桩"注意点,"权限申请"注意点等进行总结归纳,希望能对类似方案的设计提供思路。
6.摄影环节
- 拍摄时间:2023.11.26
- 地点:广州 / 南沙慧谷超级堤
- 自言自语:很喜欢的一张照片。当时看到这个芦苇,我就一直在等待一个主体经过我的画面:一对挽手的情侣、把孩子举过头顶的父亲...... 我设想了无数画面,终于一艘小船缓缓驶入,一切都是那么平静安逸。