字节码插桩(二) -- "权限申请提示"实践篇

本文以应用商店整改权限申请为背景,介绍一个利用"字节码插桩",高效整改"权限申请提示"的方案。

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"。

    kotlin 复制代码
    class 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的判断

      kotlin 复制代码
      class MainActivity : AppCompatActivity() {
        fun test() {
          requestPermissions(arrayOf(Manifest.permission.CAMERA), 1)
        }
      }

      visitMethodInsn方法的owner参数,表示当前调用的方法属于哪个类。如上,owner取值为MainActivity,而实际项目中owner可能会是AActivityBActivity...... 甚至可能是一个普通的类,一个单例......

      因此前面的if语句,不把owner作为判断条件之一,这样就能把所有可能的方法调用都替换成代理方法。然后在代理方法内再去做判断,非Activity的情况,则通过反射调用原方法。

      kotlin 复制代码
      object 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单例的影响

      前面代码中存在PermissionInjectDelegatePermissionInjectDelegateWrapper,前者是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去申请。

      因此在"代理申请"内同样需要做到这点。

      kotlin 复制代码
      object 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_STORAGEManifest.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将结果返回。

    在处理上述两种回调时,需要持有ActivityFragmentcallback等,这块需要注意生命周期管理,避免内存泄漏。

5. 总结

上图是最终的效果图,可以看到申请权限的时候,在顶部有相应的提示。目前该方案已在我们的多个APP中使用,适配效果还不错。

文章介绍一个利用"字节码插桩",高效整改"权限申请提示"的方案。这是我的第一次"字节码插桩"实践,在这个过程中学到了不少知识。因此总结了两篇文章:

字节码插桩(一) -- 新手入门篇 记录"字节码插桩"的基础知识。

字节码插桩(二) -- "权限申请提示"实践篇,即本文。把实施过程中的"字节码插桩"注意点,"权限申请"注意点等进行总结归纳,希望能对类似方案的设计提供思路。

6.摄影环节

  • 拍摄时间:2023.11.26
  • 地点:广州 / 南沙慧谷超级堤
  • 自言自语:很喜欢的一张照片。当时看到这个芦苇,我就一直在等待一个主体经过我的画面:一对挽手的情侣、把孩子举过头顶的父亲...... 我设想了无数画面,终于一艘小船缓缓驶入,一切都是那么平静安逸。
相关推荐
Amd7947 分钟前
Nuxt.js 应用中的 webpack:compiled 事件钩子
前端·webpack·开发·编译·nuxt.js·事件·钩子
生椰拿铁You16 分钟前
09 —— Webpack搭建开发环境
前端·webpack·node.js
狸克先生27 分钟前
如何用AI写小说(二):Gradio 超简单的网页前端交互
前端·人工智能·chatgpt·交互
baiduopenmap42 分钟前
百度世界2024精选公开课:基于地图智能体的导航出行AI应用创新实践
前端·人工智能·百度地图
loooseFish1 小时前
小程序webview我爱死你了 小程序webview和H5通讯
前端
菜牙买菜1 小时前
让安卓也能玩出Element-Plus的表格效果
前端
请叫我欧皇i1 小时前
html本地离线引入vant和vue2(详细步骤)
开发语言·前端·javascript
533_1 小时前
[vue] 深拷贝 lodash cloneDeep
前端·javascript·vue.js
闲暇部落1 小时前
‌Kotlin中的?.和!!主要区别
android·开发语言·kotlin
guokanglun1 小时前
空间数据存储格式GeoJSON
前端