diff --git "a/Android 13 \345\252\222\344\275\223\346\235\203\351\231\220\351\200\202\351\205\215\346\214\207\345\215\227.md" "b/Android 13 \345\252\222\344\275\223\346\235\203\351\231\220\351\200\202\351\205\215\346\214\207\345\215\227.md" new file mode 100644 index 0000000..66c8156 --- /dev/null +++ "b/Android 13 \345\252\222\344\275\223\346\235\203\351\231\220\351\200\202\351\205\215\346\214\207\345\215\227.md" @@ -0,0 +1,187 @@ +在 Android 系统最近的几个大版本里,更新方向有很大一部分都集中在了隐私安全这一方面,每个版本都会新增隐私安全限制,或者是对之前的隐私项进行进一步的升级 + +- Android 10。分区存储、限制访问不可重置的硬件标识符、限制对剪贴板数据的访问权限 +- Android 11。强制执行分区存储、单次授权、自动重置权限、软件包可见性 +- Android 12。授予大致位置信息权限、剪贴板访问通知、更安全的组件导出 +- Android 13。细化的媒体权限、内置图片选择器、隐藏剪贴板中的敏感内容、屏蔽不匹配的 Intent、针对 Wifi 设备的新运行时权限、广告 ID 权限 + +Android 13 在最近也发布了正式版,此次版本中新增的隐私安全限制也终于能够解决众多应用长久以来的两个问题了 + +在很多 Android 应用中,都会通过内置一个 **图片选择器** 来向用户展示系统相册内的所有图片,常见于 “上传用户头像、发送图片” 等业务场景,这就需要通过获得 READ_EXTERNAL_STORAGE 权限来实现了。而这个权限也存在极大的隐私风险,应用也许会向用户说明该权限仅仅只会在选择图片时使用,但除了应用开发者外,谁又能确保应用不会依靠该权限在后台偷偷做些什么呢?而对于开发者来说也属于无奈之举,应用也许仅仅只是想拿到系统相册内的图片而已,却由于 Android 系统的机制被迫要去申请一个应用范围更高的权限 + +平心而论,我觉得在应用中内置一个图片选择器的确算作是一个比较能提升用户体验的点。因为 Android 系统内置的图片选择器的功能长久以来一直很弱,应用的业务场景往往都需要限制图片的类型、允许选择多张图片并限制最大选择数,但系统却无法满足这些需求,如果我们等到用户选择图片返回后再提示用户不支持该图片格式,或者是让用户多次往返选择图片的话,那的确是挺让人反感的 + +以上两个问题,依靠在 Android 13 中新增的两个隐私安全项:**细化的媒体权限** 和 **内置图片选择器**,也终于能够得到解决了,后面来一一进行讲解 + +# 细化的媒体权限 + +在 Android 13 之前,应用如果想要访问设备中的媒体资源的话,都必须通过 READ_EXTERNAL_STORAGE 权限才能实现。从 Android 13 开始,系统将 READ_EXTERNAL_STORAGE 细分为了三个更加明确的权限,分别用于访问用户的三类媒体资源:Image、Video、Audio,从而让用户能够按需授权,避免隐私风险无序扩大 + +| 媒体类型 | 请求权限 | +| :------- | :---------------- | +| 图片 | READ_MEDIA_IMAGES | +| 视频 | READ_MEDIA_VIDEO | +| 音频 | READ_MEDIA_AUDIO | + +我们需要同时通过 **应用的 targetSdkVersion** 和 **设备的系统版本** 来适配这三个权限 + +- 如果应用还未适配 Android 13,也即 targetSdkVersion 小于 33 + - 此时不管系统版本是多少,依然还是通过 READ_EXTERNAL_STORAGE 权限来访问媒体资源 +- 如果应用已适配 Android 13,也即 targetSdkVersion 大于等于 33 + - 如果系统版本小于 33,此时依然要通过 READ_EXTERNAL_STORAGE 才能访问媒体资源 + - 如果系统版本大于等于 33,此时必须通过这三个细分权限才能访问媒体资源,READ_EXTERNAL_STORAGE 权限已失效 + +简单来说,如果系统版本和 targetSdkVersion 都大于等于 33 的话,此时就必须通过这三个细分权限才能访问媒体资源,其它情况还是需要依赖于 READ_EXTERNAL_STORAGE 权限 + +看个实际的例子 + +几个月前我发布了一个开源库, 一个用 Jetpack Compose 实现的 Android 图片选择框架:[Matisse](https://github.com/leavesCZY/Matisse) + +也发表了一篇文章进行介绍:[Jetpack Compose 实现一个图片选择框架](https://juejin.cn/post/7108420791502372895) + +![](https://upload-images.jianshu.io/upload_images/2552605-8772ec10193468d5.png) + +Matisse 的特点 & 优势: + +- 完全用 Kotlin & Jetpack Compose 实现 +- 适配到 Android 13,解决了多个系统兼容性问题 +- 支持精准筛选图片类型,获取图片的详细信息 +- 支持精细自定义主题,默认提供日夜间两套主题 +- 支持直接启动拍照流程,一共包含四种拍照策略,可以自由选择是否要申请权限 + +Matisse 刚发布时我是适配到 Android 12,目前 Android 13 已发布正式版,这阵子我也就将其适配到了 Android 13 + +Matisse 作为一个开源的图片选择框架,自然需要同时顾及 **引用方的 targetSdkVersion** 和 **设备的系统版本** 这两个变量,针对 Android 13 的 READ_MEDIA_IMAGES 权限,其实也仅需要将以前固定申请 READ_EXTERNAL_STORAGE 权限的方式,改为选择性申请即可 + +```kotlin +private fun requestReadImagesPermission() { + val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + applicationInfo.targetSdkVersion >= Build.VERSION_CODES.TIRAMISU + ) { + Manifest.permission.READ_MEDIA_IMAGES + } else { + Manifest.permission.READ_EXTERNAL_STORAGE + } + if (PermissionUtils.checkSelfPermission(context = this, permission = permission)) { + //已获得必要权限,可以去加载系统相册图片了 + } else { + //去申请必要权限 + } +} +``` + +此外,我们也要根据实际情况来声明最合适且最少的权限 + +如果应用的 targetSdkVersion 小于 33,则还是继续声明 READ_EXTERNAL_STORAGE 权限即可 + +```kotlin + +``` + +如果应用的 targetSdkVersion 大于等于 33,则需要同时声明 READ_EXTERNAL_STORAGE 和 READ_MEDIA_IMAGES 两个权限。此外,在这种情况下,READ_EXTERNAL_STORAGE 权限已无法用于 Android 13 开始之后的系统版本了,所以可以将此权限的 maxSdkVersion 设为 32,从而不会出现在 Android 13 开始之后的系统版本中 + +```kotlin + + +``` + +# 内置图片选择器 + +从 Android 13 开始,Android 系统内置了一个功能更为强大的图片选择器,可以让应用更加灵活地访问设备中的媒体资源,且无需拥有查看设备上所有媒体文件的权限。此外,虽然官方将其命名为图片选择器,但实际上也支持选择设备中的视频文件 + +引用 Google 官方的描述: + +照片选择器提供了一个可浏览、可搜索的界面,其中按日期从最近到最早的顺序向用户呈现其媒体库中的文件。此工具为用户提供了一种安全的内置图片和视频选择方式,让其无需向应用授予对整个媒体库的访问权限 + +如果您允许系统配置照片选择器,则该工具适用于满足以下条件的设备(Android Go 设备除外): + +- 搭载 Android 11(API 级别 30)或更高版本 +- 通过 Google 系统更新接收对模块化系统组件的更改 + +此外,如果您允许系统配置照片选择器,该工具会自动更新,并随着时间推移为应用的用户提供扩展的功能,而无需对代码进行任何更改 + +![](https://upload-images.jianshu.io/upload_images/2552605-b1dabff57b2f4934.gif) + +再来看下应用如何来使用该图片选择器 + +首先,由于 Google 会通过 Google Play 将该新型的图片选择器推送给 Android 11 及以上系统的设备 (不包括 Go 设备),所以该功能不仅仅只能在 Android 13 开始后的系统可以使用,我们可以通过如下方法来检查当前设备是否支持该功能 + +```kotlin +//compileSdkVersion 需要至少为 33 才可以调用此方法 +fun isPhotoPickerAvailable(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + true + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + getExtensionVersion(Build.VERSION_CODES.R) >= 2 + } else { + false + } +} +``` + +我们可以通过以下两种 ActivityResultContract 来启动图片选择器: + +- PickVisualMedia。用于选择单张图片或单个视频 +- PickMultipleVisualMedia。用于选择多张图片或多个视频 + +例如,在只需要选择单张图片或者单个视频的情况下,可以这么使用: + +```kotlin +private val pickMedia = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri != null) { + //TODO + } +} + +//选择图片或视频 +pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo)) + +//仅选择图片 +pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + +//仅选择视频 +pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly)) + +//仅选择 Gif 图片 +val mimeType = "image/gif" +pickMedia.launch( + PickVisualMediaRequest( + ActivityResultContracts.PickVisualMedia.SingleMimeType( + mimeType + ) + ) +) +``` + +在这种情况下,图片选择器会以半屏模式打开 + +![](https://upload-images.jianshu.io/upload_images/2552605-f80611ac84b7674e.png) + +类似的,如果想要选择多张图片或者是多个视频,可以通过 PickMultipleVisualMedia 来限定最大的选取数量 + +```kotlin +private val pickMultipleMedia = registerForActivityResult(ActivityResultContracts.PickMultipleVisualMedia(5)) { uris -> + if (uris.isNotEmpty()) { + //TODO + } +} +``` + +此时,图片选择器会以全屏展开的形式进行展示,当资源数量超限时也会对用户进行提示 + +![](https://upload-images.jianshu.io/upload_images/2552605-6b712682fcd9a8b6.png) + +需要注意,以上代码需要添加 1.6.0 或更高版本的 `androidx.activity` 库后才可以使用。此外,从 PickVisualMedia 和 PickMultipleVisualMedia 的源码可以看到,Android 13 内置的图片选择器对应的是 `MediaStore.ACTION_PICK_IMAGES` 这个新增的 Intent,而如果当前设备不支持媒体选择器功能的话,就会改为通过调用 `Intent.ACTION_OPEN_DOCUMENT` 来选择媒体资源,这种情况下 PickMultipleVisualMedia 设定的数量上限自然也就失效了 + +# 结尾 + +Android 13 细化的媒体权限解决了 READ_EXTERNAL_STORAGE 带来的隐私风险无序扩大的问题,让用户可以按需授权。而内置的图片选择器又能够让应用在无需用户授权的情况下就可以灵活地选取媒体资源,应用也就完全没有必要自己内置一个图片选择器了 + +所以说,平衡用户体验和用户隐私安全最好的做法应该是: + +- 在 Android 13 之前依然还是通过应用内置的图片选择器来实现业务功能 +- 在 Android 13 开始之后的版本仅使用系统内置的图片选择器,完全弃用 READ_EXTERNAL_STORAGE 权限 + +此次 Android 13 版本还是有点东西的 ~ \ No newline at end of file diff --git "a/Android ASM \345\255\227\350\212\202\347\240\201\346\217\222\346\241\251\345\272\223\357\274\214\344\271\237\350\256\270\346\234\211\344\275\240\351\234\200\350\246\201\347\232\204.md" "b/Android ASM \345\255\227\350\212\202\347\240\201\346\217\222\346\241\251\345\272\223\357\274\214\344\271\237\350\256\270\346\234\211\344\275\240\351\234\200\350\246\201\347\232\204.md" new file mode 100644 index 0000000..6fd3c7b --- /dev/null +++ "b/Android ASM \345\255\227\350\212\202\347\240\201\346\217\222\346\241\251\345\272\223\357\274\214\344\271\237\350\256\270\346\234\211\344\275\240\351\234\200\350\246\201\347\232\204.md" @@ -0,0 +1,395 @@ +# 一、Track + +在最近的一年多时间里,我先后写过六篇关于 Android 字节码插桩的文章,一共对应四个功能点 + +- [ASM 字节码插桩:实现双击防抖](https://juejin.cn/post/7042328862872567838) +- [ASM 字节码插桩:进行线程整治](https://juejin.cn/post/7044339202997092383) +- [ASM 字节码插桩:助力隐私合规](https://juejin.cn/post/7046207125785149448) +- [ASM 字节码插桩:监控大图加载](https://juejin.cn/post/7074970389188706318) +- [ASM 字节码插桩:从 Lambda 表达式讲起](https://juejin.cn/post/7151798531672506398) +- [ASM 字节码插桩:Jetpack Compose 实现双击防抖](https://juejin.cn/post/7158061389503250445) + +发布这几篇文章后,有读者问过我是否支持直接远程依赖使用,由于当时我并没有将代码托管到公共 Maven,仅是在 Github 开源了相关的实现代码而已:[asm-samples](https://github.com/leavesCZY/asm-samples),所以在当时是不支持的 + +最近比较有空,想着很久没有写文章了,想水一篇,就花了点时间将其中两个我觉得还比较实用的功能点抽取了出来,并新增了两个功能点一并发布到了 Github:[Track](https://github.com/leavesCZY/Track) + +Track 也发布到了 Gradle Plugins 官网,方便开发者在自己项目中直接远程依赖使用 + +这四个功能点包括: + +- View Click 双击防抖 +- Jetpack Compose Click 双击防抖 +- 替换 Class 的继承关系。应用场景包括:非侵入式地实现监控大图加载的功能 +- 修复 Toast 在 Android 7.1 上的系统 bug。用于解决在 Android 7.1 系统上 Toast 由于 WindowToken 失效从而导致应用崩溃的问题 + +下面就来介绍如何在项目中接入 Track ,主要的实现思路参照以上文章即可 + +# 二、引入 + +在项目根目录下的 `build.gradle.kts` 或者 `build.gradle` 中引入插件 + +```kotlin +plugins { + id("io.github.leavesczy.track").version("latestVersion").apply(false) +} +``` + +在项目主模块中应用插件,需要哪些功能点就为其设置对应的参数 + +```kotlin +plugins { + id("io.github.leavesczy.track") +} + +viewClickTrack { + onClickClass = "" + onClickMethodName = "" + uncheckViewOnClickAnnotation = "" + include = setOf() + exclude = setOf() +} + +composeClickTrack { + onClickClass = "" + onClickWhiteList = "" +} + +replaceClassTrack { + originClass = "" + targetClass = "" + include = setOf() + exclude = setOf() +} + +toastTrack { + toasterClass = "" + showToastMethodName = "" +} +``` + +# 三、viewClickTrack + +viewClickTrack 用于为 Android 原生的 View 体系实现双击防抖功能 + +开发者一共需要设置两个必填参数和三个可选参数 + +```kotlin +viewClickTrack { + //必填参数 + onClickClass = "" + onClickMethodName = "" + //可选参数 + uncheckViewOnClickAnnotation = "" + include = setOf() + exclude = setOf() +} +``` + +viewClickTrack 实现应用双击防抖功能的本质,就是为项目中所有 `View.OnClickListener` 的回调方法都插入一段逻辑代码,该段代码会计算前后两次点击事件的时间间隔,如果判断到时间间隔小于某个阈值的话就直接 return,否则就让其继续执行 + +伪代码如下所示 + +```kotlin +//插桩前 +view.setOnClickListener(object : View.OnClickListener { + override fun onClick(view: View) { + //TODO + } +}) + +//插桩后 +view.setOnClickListener(object : View.OnClickListener { + override fun onClick(view: View) { + if (!ViewClickMonitor.isEnabled(view)){ + return + } + //TODO + } +}) +``` + +开发者需要在自己的项目中提供一个方法,用于承接 viewClickTrack 转发的所有 View 点击事件。viewClickTrack 就负责将开发者提供的 `ViewClickMonitor.isEnabled(View)` 方法插入到 `View.OnClickListener` 的回调函数中,由方法返回值来决定是否要执行本次点击事件 + +ViewClickMonitor 的包名、类名、方法名均可以随意命名,viewClickTrack 仅要求其包含一个静态方法,方法签名和 `isEnabled` 保持一致即可,返回值为 true 即代表允许执行本次点击事件 + +```kotlin +object ViewClickMonitor { + + @JvmStatic + fun isEnabled(view: View): Boolean { + val isEnabled: Boolean + //TODO + return isEnabled + } + +} +``` + +例如,开发者可以照着以下代码来实现 ViewClickMonitor,将每次点击事件的最小时间间隔设为五百毫秒。开发者可以根据自己的需要来进行自定义,不必局限于以下实现 + +```kotlin +package github.leavesczy.track + +object ViewClickMonitor { + + private const val MIN_DURATION = 500L + + private var lastClickTime = 0L + + @JvmStatic + fun isEnabled(view: View): Boolean { + val currentTime = SystemClock.elapsedRealtime() + val isEnabled = currentTime - lastClickTime > MIN_DURATION + if (isEnabled) { + lastClickTime = currentTime + } + return isEnabled + } + +} +``` + +然后将 ViewClickMonitor 的类名和对应的方法名传给 viewClickTrack 即可 + +```kotlin +viewClickTrack { + onClickClass = "github.leavesczy.track.ViewClickMonitor" + onClickMethodName = "isEnabled" +} +``` + +在默认情况下,viewClickTrack 会对整个项目中的所有 `onClick` 事件均进行拦截检测。如果想过滤特定的点击事件,或者是想过滤特定类或者是特定包名,可以通过 viewClickTrack 的另外三个可选参数来实现 + +```kotlin +viewClickTrack { + //过滤包含特定注解的 onClick 事件 + uncheckViewOnClickAnnotation = "" + //仅对特定类或者特定包名中的 onClick 事件进行拦截检测 + include = setOf() + //过滤特定类或者特定包名中的 onClick 事件 + exclude = setOf() +} +``` + +例如,开发者可以自己声明一个 UncheckViewOnClick 注解 + +```kotlin +package github.leavesczy.track + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class UncheckViewOnClick +``` + +将该注解的全路径传给 `uncheckViewOnClickAnnotation` 后,以下的点击事件就会被过滤 + +```kotlin +findViewById(R.id.tvObjectUnCheck).setOnClickListener( + object : View.OnClickListener { + @UncheckViewOnClick + override fun onClick(view: View) { + onClickView() + } + }) +``` + +`include` 和 `exclude` 两个参数则用于以类名或包名为单位,共同控制 viewClickTrack 的生效范围 + +- include 用于设定 viewClickTrack 的生效范围。参数值在为空的情况下代表着对所有模块均生效,传值后则只对参数值代表的模块生效 +- exclude 用于设定 viewClickTrack 的排除范围。用于在 include 限定的范围内再排除特定模块 + +`include` 和 `exclude` 均通过正则表达式来进行传值,viewClickTrack 每当遍历到一个类时,均会拿其类名和 `include` 和 `exclude` 一起进行匹配,均匹配通过后才会对该类进行双击防抖 + +例如,以下参数就表示: + +- 包含 UncheckViewOnClick 注解的 `onClick` 回调不会进行双击防抖 +- 仅在 `github.leavesczy.track.xxx` 包名下的类会进行双击防抖,但 `github.leavesczy.track.mylibrary.xxx` 包名下的类除外 + +```kotlin +viewClickTrack { + uncheckViewOnClickAnnotation = "github.leavesczy.track.UncheckViewOnClick" + include = setOf("^github\\.leavesczy\\.track.*") + exclude = setOf("^github\\.leavesczy\\.track\\.mylibrary.*") +} +``` + +# 四、composeClickTrack + +composeClickTrack 用于为 Jetpack Compose 实现双击防抖功能 + +开发者一共需要设置一个必填参数和一个可选参数 + +```kotlin +composeClickTrack { + //必填参数 + onClickClass = "" + //可选参数 + onClickWhiteList = "" +} +``` + +和 View 体系一样,开发者也需要在自己项目中声明一个符合以下签名的类,ComposeOnClick 的包名和类名均可以随意命名,将该类的全路径作为参数值传递给 `onClickClass` 即可 + +```kotlin +class ComposeOnClick(private val onClick: () -> Unit) : Function0 { + + override fun invoke() { + //TODO + } + +} +``` + +例如,开发者可以照着以下代码来实现 ComposeOnClick + +```kotlin +package github.leavesczy.track + +class ComposeOnClick(private val onClick: () -> Unit) : Function0 { + + companion object { + + private const val MIN_DURATION = 500L + + private var lastClickTime = 0L + + } + + override fun invoke() { + val currentTime = SystemClock.elapsedRealtime() + val isEnabled = currentTime - lastClickTime > MIN_DURATION + if (isEnabled) { + lastClickTime = currentTime + onClick() + } + } + +} +``` + +另外,`onClickWhiteList` 即点击事件的白名单,对于某些不希望执行双击防抖的 `Modifier.clickable` 和 `Modifier.combinedClickable` 方法,通过将其 `onClickLabel` 设置为 `onClickWhiteList` 的属性值后就不会进行双击防抖 + +例如,以下参数就表示:`Modifier.clickable` 和 `Modifier.combinedClickable` 方法触发的点击事件均会被移交给 ComposeOnClick 处理,`onClickLabel` 属性值为 `notCheck` 的点击事件除外 + +```kotlin +composeClickTrack { + onClickClass = "github.leavesczy.track.ComposeOnClick" + onClickWhiteList = "notCheck" +} +``` + +# 五、replaceClassTrack + +replaceClassTrack 用于替换项目中类的继承关系 + +也就是说,replaceClassTrack 会将项目中每一个 `originClass` 的直接子类,均将其改为直接继承于 `targetClass`。此外,replaceClassTrack 还包含 `include` 和 `exclude` 两个可选参数,其作用和 viewClickTrack 中的同名参数一致 + +```kotlin +replaceClassTrack { + //必填参数 + originClass = "x" + targetClass = "x" + //可选参数 + include = setOf() + exclude = setOf() +} +``` + +这个功能有什么意义呢? + +举个例子。假设现在要来检测项目中的所有 ImageView 加载的图片尺寸是否过大,此时我们就可以自定义实现一个 ImageView 的子类 MonitorImageView,在其中实现好大图检测的功能,然后再通过 replaceClassTrack 将所有直接继承于 ImageView 的子类均改为直接继承于 MonitorImageView,从而使得大图检测的功能对整个项目均能生效,而且还不必手动修改现有代码 + +例如,以下参数就表示:将项目中所有直接继承于 ImageView 的子类,均改为直接继承于 MonitorImageView,但类名为 IgnoreImageView 的子类除外 + +```kotlin +replaceClassTrack { + originClass = "android.widget.ImageView" + targetClass = "github.leavesczy.track.MonitorImageView" + include = setOf() + exclude = setOf(".*\\.IgnoreImageView\$") +} +``` + +# 六、toastTrack + +toastTrack 用于聚拢项目中所有调用系统 Toast 的显示操作,用于解决 Android 7.1 系统中 Toast 由于 WindowToken 失效从而导致应用崩溃的问题 + +开发者一共需要设置两个必填参数 + +```kotlin +toastTrack { + toasterClass = "" + showToastMethodName = "" +} +``` + +开发者需要在自己的项目中提供一个方法,用于承接 toastTrack 转发的所有 Toast 显示操作。toastTrack 就负责将项目中所有调用了 `toast.show()` 的操作都聚拢到开发者指定的方法下,开发者可以在该方法内对 Android 7.1 中 Toast 的系统 bug 进行修复 + +例如,开发者可以像以下代码一样来承接 `toast.show()` 操作,在 Android 7.1 系统版本上捕获系统抛出的异常。Toaster 的包名、类名、方法名均可以随意命名,toastTrack 仅要求其包含一个静态方法,方法签名符合 `showToast` 的规则即可 + +```kotlin +package github.leavesczy.track + +object Toaster { + + @JvmStatic + fun showToast(toast: Toast) { + hookToastIfNeed(toast) + toast.show() + } + + @SuppressLint("DiscouragedPrivateApi") + private fun hookToastIfNeed(toast: Toast) { + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.N_MR1) { + try { + val cToast = Toast::class.java + val fTn = cToast.getDeclaredField("mTN") + fTn.isAccessible = true + val oTn = fTn.get(toast) + val cTn = oTn.javaClass + val fHandle = cTn.getDeclaredField("mHandler") + fHandle.isAccessible = true + fHandle.set(oTn, ProxyHandler(fHandle.get(oTn) as Handler)) + } catch (e: Throwable) { + e.printStackTrace() + } + } + } + + private class ProxyHandler(private val mHandler: Handler) : Handler(mHandler.looper) { + + override fun handleMessage(msg: Message) { + try { + mHandler.handleMessage(msg) + } catch (e: Throwable) { + e.printStackTrace() + } + } + + } + +} +``` + +然后,将 Toaster 对应的全路径和方法名传给 toastTrack 即可 + +```kotlin +toastTrack { + toasterClass = "github.leavesczy.track.Toaster" + showToastMethodName = "showToast" +} +``` + +# 七、结尾 + +Track 现阶段就包含以上四个功能点,后续看时间规划我再继续更新 + +另外还有一些注意事项 + +- Track 在 AGP 7.0+ 和 8.0+ 均已测试通过,更低版本的 AGP 则没有再特意进行试验 +- Track 目前处于刚起步阶段,可能还会存在一些 bug,但由于 Track 是以 Gradle Plugin 的形式引入到项目中的,引入成本和移除成本都很低,有需要的话还是值得一试的 +- Track Plugin 也托管到了 GradlePluginPortal,可以在 [Track](https://plugins.gradle.org/plugin/io.github.leavesczy.track) 查看 + +希望对你有所帮助 ~ + +Track 的 Github 地址:[Track](https://github.com/leavesCZY/Track) \ No newline at end of file diff --git "a/Android ASM \345\255\227\350\212\202\347\240\201\346\217\222\346\241\251\357\274\232Jetpack Compose \345\256\236\347\216\260\345\217\214\345\207\273\351\230\262\346\212\226.md" "b/Android ASM \345\255\227\350\212\202\347\240\201\346\217\222\346\241\251\357\274\232Jetpack Compose \345\256\236\347\216\260\345\217\214\345\207\273\351\230\262\346\212\226.md" new file mode 100644 index 0000000..1ea5ca8 --- /dev/null +++ "b/Android ASM \345\255\227\350\212\202\347\240\201\346\217\222\346\241\251\357\274\232Jetpack Compose \345\256\236\347\216\260\345\217\214\345\207\273\351\230\262\346\212\226.md" @@ -0,0 +1,519 @@ +Android 开发者们对 Jetpack Compose 应该已经很熟悉了吧?我在项目中也已经大规模应用了 Jetpack Compose,极大地解放了开发者的心智负担 + +最近我想要来为项目做一些能够提升 **应用性能** 或者是 **用户体验** 的优化项,想了想就敲定了一个目标:通过字节码插桩的方式,来为项目中所有和 Jetpack Compose 关联的业务实现全局的双击防抖功能 + +在之前,我已经为 Android 的 View 体系实现过相同的功能了:[ASM 字节码插桩:实现双击防抖](https://juejin.cn/post/7042328862872567838) ,想着在 Jetpack Compose 中应该也差不多,不会太麻烦,可在编码过程中才发现这一个功能并不好做,遇到了一些不太好解决的问题,后面来一一进行讲解 + +# 一、基本思路 + +在 Jetpack Compose 中,我们一般都是通过 Modifier 的 `clickable` 或者 `combinedClickable` 这两个扩展函数来为可组合函数的点击事件设置监听,方法均位于 `compose.foundation` 库的 Clickable 类中,一共有四个方法可供使用 + +`clickable` 和 `combinedClickable` 方法均包含了重载函数,差别只在于是否包含 `interactionSource` 和 `indication` 这两个入参参数,重载函数之间还是属于直接调用的关系,所以只需关注第二个和第四个方法即可 + + +```kotlin +fun Modifier.clickable( + enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, + onClick: () -> Unit +) { + Modifier.clickable( + enabled = enabled, + onClickLabel = onClickLabel, + onClick = onClick, + role = role, + indication = LocalIndication.current, + interactionSource = remember { MutableInteractionSource() } + ) +} + +fun Modifier.clickable( + interactionSource: MutableInteractionSource, + indication: Indication?, + enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, + onClick: () -> Unit +) { + //TODO +} + +fun Modifier.combinedClickable( + enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, + onLongClickLabel: String? = null, + onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, + onClick: () -> Unit +){ + Modifier.combinedClickable( + enabled = enabled, + onClickLabel = onClickLabel, + onLongClickLabel = onLongClickLabel, + onLongClick = onLongClick, + onDoubleClick = onDoubleClick, + onClick = onClick, + role = role, + indication = LocalIndication.current, + interactionSource = remember { MutableInteractionSource() } + ) +} + +fun Modifier.combinedClickable( + interactionSource: MutableInteractionSource, + indication: Indication?, + enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, + onLongClickLabel: String? = null, + onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, + onClick: () -> Unit +){ + //TODO +} +``` + +为了实现双击防抖,我们需要限制 `onClick` 被重复执行时的最小时间间隔。此时最直接的思路,就是引入一个包装类 OnClickWrap,将 `onClick` 均改为 OnClickWrap,然后在 OnClickWrap 中实现防抖逻辑,选择性地执行 `onClick` 方法即可 + +```kotlin +class OnClickWrap(private val onClick: (() -> Unit)) : Function0 { + + companion object { + + private const val MIN_DURATION = 500L + + private var lastClickTime = 0L + + } + + override fun invoke() { + val currentTime = System.currentTimeMillis() + if (currentTime - lastClickTime > MIN_DURATION) { + lastClickTime = currentTime + onClick() + log("onClick isEnabled : true") + } else { + log("onClick isEnabled : false") + } + } + + private fun log(log: String) { + Log.e( + "OnClickWrap", + "${System.identityHashCode(this)} ${System.identityHashCode(onClick)} $log" + ) + } + +} +``` + +也即是说,在插桩后,`clickable` 和 `combinedClickable` 这两个方法的伪代码应该如下所示 + +```kotlin +fun Modifier.clickable( + interactionSource: MutableInteractionSource, + indication: Indication?, + enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, + onClick: () -> Unit +) { + onClick = OnClickWrap(onClick = onClick) + //TODO +} + +fun Modifier.combinedClickable( + interactionSource: MutableInteractionSource, + indication: Indication?, + enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, + onLongClickLabel: String? = null, + onLongClick: (() -> Unit)? = null, + onDoubleClick: (() -> Unit)? = null, + onClick: () -> Unit +){ + onClick = OnClickWrap(onClick = onClick) + //TODO +} +``` + +# 二、反编译 Clickable 类 + +有了基本思路后,我们先通过 ClassVisitor 打印出 Clickable 类中所有方法的签名信息,这样在 Transform 阶段才能知道如何识别到目标方法 + +```kotlin +private class ComposeDoubleClickClassVisitor( + private val nextClassVisitor: ClassVisitor, + private val classData: ClassData +) : ClassNode(Opcodes.ASM5) { + + private companion object { + + private const val ComposeClickClassName = "androidx.compose.foundation.ClickableKt" + + } + + override fun visitEnd() { + super.visitEnd() + val className = classData.className + if (className == ComposeClickClassName) { + methods.forEach { methodNode -> + val methodName = methodNode.name + val methodDesc = methodNode.desc + LogPrint.log("methodName: $methodName \n methodDesc: $methodDesc") + } + } + accept(nextClassVisitor) + } + +} +``` + +除去一些无关方法后,令我意外的是,最终输出的方法签名信息居然有八个:四个 `clickable` 方法、四个 `combinedClickable` 方法 + +```kotlin +methodName: clickable-XHw0xAI +methodDesc: (Landroidx/compose/ui/Modifier;ZLjava/lang/String;Landroidx/compose/ui/semantics/Role;Lkotlin/jvm/functions/Function0;)Landroidx/compose/ui/Modifier; + +methodName: clickable-XHw0xAI$default +methodDesc: (Landroidx/compose/ui/Modifier;ZLjava/lang/String;Landroidx/compose/ui/semantics/Role;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Landroidx/compose/ui/Modifier; + +methodName: clickable-O2vRcR0 +methodDesc: (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/foundation/Indication;ZLjava/lang/String;Landroidx/compose/ui/semantics/Role;Lkotlin/jvm/functions/Function0;)Landroidx/compose/ui/Modifier; + +methodName: clickable-O2vRcR0$default +methodDesc: (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/foundation/Indication;ZLjava/lang/String;Landroidx/compose/ui/semantics/Role;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Landroidx/compose/ui/Modifier; + +methodName: combinedClickable-cJG_KMw +methodDesc: (Landroidx/compose/ui/Modifier;ZLjava/lang/String;Landroidx/compose/ui/semantics/Role;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)Landroidx/compose/ui/Modifier; + +methodName: combinedClickable-cJG_KMw$default +methodDesc: (Landroidx/compose/ui/Modifier;ZLjava/lang/String;Landroidx/compose/ui/semantics/Role;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Landroidx/compose/ui/Modifier; + +methodName: combinedClickable-XVZzFYc +methodDesc: (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/foundation/Indication;ZLjava/lang/String;Landroidx/compose/ui/semantics/Role;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;)Landroidx/compose/ui/Modifier; + +methodName: combinedClickable-XVZzFYc$default +methodDesc: (Landroidx/compose/ui/Modifier;Landroidx/compose/foundation/interaction/MutableInteractionSource;Landroidx/compose/foundation/Indication;ZLjava/lang/String;Landroidx/compose/ui/semantics/Role;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Landroidx/compose/ui/Modifier; +``` + +将开头的两个方法签名信息转换成易于理解的 Java 代码,方便读者理解 + +```kotlin +public static final Modifier clickable-XHw0xAI(Modifier modifier, boolean enabled, String onClickLabel, + Role role, Function0 onClick) { + +} + +public static Modifier clickable-XHw0xAI$default(Modifier modifier, boolean enabled, String onClickLabel, + Role role, Function0 onClick, int flag, Object object) { + +} +``` + +结合日志信息和伪代码,就可以很容易看出一些小细节了: + +- 第一个方法对应的是 Clickable 类中仅包含四个参数的 `clickable` 方法。多出一个 Modifier 参数很正常,因为 Kotlin 的扩展函数本质上就相当于 Java 中的静态方法,扩展对象会成为该静态方法的第一个入参参数,大多数开发者应该都知道这一点 +- 八个方法的命名方式明显是带有某种规律:一个 `methodName` 对应一个 `methodName$default`。例如:`clickable-XHw0xAI` 方法就对应着 `clickable-XHw0xAI$default` 方法 +- 名字带有 `$default` 的方法,其入参列表的末尾都会多出两个参数:`ILjava/lang/Object;`,也即 Int 类型 和 Object 类型 + +打印出的日志信息明显和 Clickable 类差别很大,我以为是哪里出问题了,就再尝试着反编译打包进 Apk 中的 Clickable 类的源代码,最终代码总不会骗人 + +最后,我发现反编译出的源代码中也是有八个相同签名信息的方法,方法的命名规则和上述完全一致,而且多出来的 Int 类型参数在方法内部还进行了一系列 & 运算,似乎还起到了决定其它入参参数值的作用? + +![](https://upload-images.jianshu.io/upload_images/2552605-e0a153fb68b6c364.png) + +# 三、剖析原因 + +Clickable 类中只声明了四个相关方法,为何这里找到的又会是八个?多出来的 Int 类型和 Object 类型的入参参数的作用是什么?参数值又根据什么来决定的呢? + +先说结论:**之所以会多出两个 Int 类型 和 Object 类型的入参参数,是由于扩展函数存在默认参数值导致的,扩展函数需要依靠该 Int 值来决定是否应该使用默认参数值** + +这个结论听着很抽象,不太好理解,这里来举个小例子 + +为了方便理解,这里来模拟 `clickable` 方法的定义规则,声明 Modifier 和 Role 两个接口,以及相应的扩展函数 + +```kotlin +interface Modifier { + + companion object { + + val INSTANCE = object : Modifier { + + } + + } + +} + +interface Role + +fun Modifier.clickable( + enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, + onClick: () -> Unit +) { + +} + +fun clickableDemo() { + val modifier = Modifier.INSTANCE + modifier.clickable(enabled = true, onClickLabel = "clickableDemo", role = null) { + + } + modifier.clickable(enabled = true) { + + } + modifier.clickable(onClickLabel = "clickableDemo") { + + } +} +``` + +将以上代码反编译为 Java 代码,格式化后大致如下所示 + +```java +public static final void clickable(@NotNull Modifier modifier, + boolean enabled, + @Nullable String onClickLabel, + @Nullable Role role, + @NotNull Function0 onClick) { + Intrinsics.checkNotNullParameter(modifier, "modifier"); + Intrinsics.checkNotNullParameter(onClick, "onClick"); +} + +public static void clickable$default(Modifier modifier, + boolean enabled, + String onClickLabel, + Role role, + Function0 onClick, + int flag, + Object object) { + if ((flag & 1) != 0) { + enabled = true; + } + if ((flag & 2) != 0) { + onClickLabel = (String) null; + } + if ((flag & 4) != 0) { + role = (Role) null; + } + clickable(modifier, enabled, onClickLabel, role, onClick); +} + +public static final void clickableDemo() { + Modifier modifier = Modifier.Companion.getINSTANCE(); + clickable(modifier, true, "clickableDemo", (Role) null, (Function0) null.INSTANCE); + clickable$default(modifier, true, (String) null, (Role) null, (Function0) null.INSTANCE, 6, (Object) null); + clickable$default(modifier, false, "clickableDemo", (Role) null, (Function0) null.INSTANCE, 5, (Object) null); +} +``` + +以上就可以发现一些和 Clickable 类一致的地方了: + +- 扩展函数最终会对应 `clickable` 和 `clickable$default` 这两个静态方法。命名规则和上面讲的一样 +- `clickable` 方法相比原扩展函数会多出一个 modifier 参数,`clickable$default` 方法会多出 modifier、flag、object 三个参数。方法签名信息也和上面讲的一样 +- `clickableDemo` 方法中,只有后两个方法调用的是 `clickable$default` 方法,传入的 flag 值分别是 6 和 5,object 值均为 null + +多出一个 modifier 参数的原因上面已经讲了,object 值没有使用到,这里也不理会 + +那 flag 值的作用是什么呢?值的生成规则又是什么呢? + +其实,flag 值起到的是一个标识作用:由于扩展函数的 `enabled`、`onClickLabel`、`role` 这三个参数都包含默认值,所以 Kotlin 就需要有一个标识符用于标识开发者到底有没有主动传入这三个参数值,没有的话就需要去使用默认值 + +Kotlin 会根据一个二进制数来标识开发者是否有主动传入方法的入参参数:如果这三个参数都有传入,就对应二进制 000;如果只传入了 enabled,就对应二进制 110。也即是说,参数在方法的参数列表中越靠前,在二进制中的位置就越靠后,有传入值的话就用 0 表示,没传入值就用 1 表示 + +对应上述的三个方法: + +- 第一个方法我传入了所有参数,此时所有入参参数都不用使用到默认值,因此调用的是 Java 代码中的 `clickable` 方法 +- 第二个和第三个方法我分别只传入了 `enabled` 和 `onClickLabel`,对应的二进制就是 110 和 101,得到的十进制值就分别是 6 和 5 了。`clickable$default` 方法内部就会根据 & 运算,来判断对应位置的二进制位是否为 1,是 1 的话就说明开发者没有主动传入该参数,此时就需要将该入参赋值为默认值了 + +所以说,虽然 Clickable 类只定义了四个和点击事件相关的扩展函数,但由于每个方法均包含默认参数值,所以在编译过后就会变成八个方法,多出来的方法也会多包含 flag 和 object 两个参数,这都是由 Kotlin 扩展函数的实现原理决定的 + +# 四、动手插桩 + +知道了 Kotlin 扩展函数的实现原理以及会带来的影响后,我们就可以明白 ClassVisitor 输出的方法签名信息并没有错,这些方法对应的就是源码中的那四个扩展函数,我们仅需要处理那两个不包含 flag 和 object 两个参数的方法即可,后面就可以来进行实际编码了 + +回顾一开始的思路:将 `clickable` 和 `combinedClickable` 这两个方法的入参参数 `onClick` 均重新赋值为 OnClickWrap 实例,在 OnClickWrap 中实现防抖逻辑,然后选择性地执行 `onClick` 方法 + +```kotlin +package github.leavesczy.asm.doubleClick.compose + +class OnClickWrap(private val onClick: (() -> Unit)) : Function0 { + + companion object { + + private const val MIN_DURATION = 500L + + private var lastClickTime = 0L + + } + + override fun invoke() { + val currentTime = System.currentTimeMillis() + if (currentTime - lastClickTime > MIN_DURATION) { + lastClickTime = currentTime + onClick() + log("onClick isEnabled : true") + } else { + log("onClick isEnabled : false") + } + } + + private fun log(log: String) { + Log.e( + "OnClickWrap", + "${System.identityHashCode(this)} ${System.identityHashCode(onClick)} $log" + ) + } + +} +``` + +对于 ClassVisitor 来说,Class 文件中的每一个方法均会对应一个 MethodNode,我们可以通过对比 MethodNode 的签名信息 desc 来识别到 `clickable` 和 `combinedClickable` 这两个方法。在编译过后,这两个方法的 `onClick` 参数在所有入参参数中的索引分别是 6 和 9,我们通过该索引就可以拿到入参值并将之重新赋值为 OnClickWrap 实例 + +对应以下代码: + +```kotlin +methods.forEach { methodNode -> + val methodName = methodNode.name + val methodDesc = methodNode.desc + LogPrint.log("methodName: $methodName \n methodDesc: $methodDesc") + val onClickArgumentIndex = when (methodDesc) { + ClickableMethodDesc -> { + 6 + } + CombinedClickableMethodDesc -> { + 9 + } + else -> { + -1 + } + } + if (onClickArgumentIndex > 0) { + val instructions = methodNode.instructions + val input = InsnList() + input.add( + TypeInsnNode( + Opcodes.NEW, + "github/leavesczy/asm/doubleClick/compose/OnClickWrap" + ) + ) + input.add(InsnNode(Opcodes.DUP)) + input.add(VarInsnNode(Opcodes.ALOAD, onClickArgumentIndex)) + input.add( + MethodInsnNode( + Opcodes.INVOKESPECIAL, + "github/leavesczy/asm/doubleClick/compose/OnClickWrap", + "", + "(Lkotlin/jvm/functions/Function0;)V", + false + ) + ) + input.add(VarInsnNode(Opcodes.ASTORE, onClickArgumentIndex)) + instructions.insert(input) + } +} +``` + +还有个问题需要关注:由于上述代码是直接对 Clickable 类进行插桩,所以我们项目中所有使用到 `clickable` 和 `combinedClickable` 这两个方法的可组合函数都会受到影响,但未必所有地方都需要实现防抖,为了灵活性考虑,我们需要实现一个白名单机制,在白名单内的点击事件则不进行检查 + +这里我通过判断 `onClickLabel` 的值是否是 `noCheck` 来决定是否要启用双击防抖功能,伪代码如下所示 + +```kotlin +fun Modifier.clickable( + interactionSource: MutableInteractionSource, + indication: Indication?, + enabled: Boolean = true, + onClickLabel: String? = null, + role: Role? = null, + onClick: () -> Unit +) { + if (onClickLabel != "noCheck") { + onClick = OnClickWrap(onClick) + } + //TODO +} +``` + +对应的插桩代码: + +```kotlin +if (onClickArgumentIndex > 0) { + val onClickLabelArgumentIndex = 4 + val input = InsnList() + input.add(LdcInsnNode("noCheck")) + input.add(VarInsnNode(Opcodes.ALOAD, onClickLabelArgumentIndex)) + input.add( + MethodInsnNode( + Opcodes.INVOKEVIRTUAL, + "java/lang/String", + "equals", + "(Ljava/lang/Object;)Z", + false + ) + ) + val label = LabelNode() + input.add(JumpInsnNode(Opcodes.IFNE, label)) + input.add( + TypeInsnNode( + Opcodes.NEW, + "github/leavesczy/asm/doubleClick/compose/OnClickWrap" + ) + ) + input.add(InsnNode(Opcodes.DUP)) + input.add(VarInsnNode(Opcodes.ALOAD, onClickArgumentIndex)) + input.add( + MethodInsnNode( + Opcodes.INVOKESPECIAL, + "github/leavesczy/asm/doubleClick/compose/OnClickWrap", + "", + "(Lkotlin/jvm/functions/Function0;)V", + false + ) + ) + input.add(VarInsnNode(Opcodes.ASTORE, onClickArgumentIndex)) + input.add(label) + methodNode.instructions.insert(input) +} +``` + +最终的防抖效果就可以很明显的看出来,当快速点击有启用双击防抖功能的控件时,index 值的递增速度明显慢于不防抖的控件 + +![](https://upload-images.jianshu.io/upload_images/2552605-83d2676cb7ce552a.gif) + +# 五、还遗留的问题 + +以上的效果看着还可以,但其实并不完善,还遗留着几个问题 + +## 作用域不可控 + +由于我是直接修改 Clickable 类,因此防抖逻辑除了会作用于项目主体外,还包括其它任何使用了 Clickable 类的依赖库,从而导致我们无法自由控制防抖逻辑的作用域 + +## 白名单机制不完善 + +如果我们在代码中都是通过直接调用`clickable` 和 `combinedClickable` 这两个方法来监听点击事件的话,那通过判断 `onClickLabel` 的值来实现白名单功能是完全可行的,但对于 Jetpack Compose 提供的一些封装了点击事件的控件就不适用了。例如 Button 和 TextButton 内部都封装了 `clickable` 方法,但没有开放设置 `onClickLabel` 值的入口,所以这类控件就无法加入白名单了 + +## 防抖逻辑会互相关联 + +进行双击防抖的时候,防抖逻辑可以分为两种: + +- 每个控件之间的防抖逻辑是互相关联的。也即是所,如果分别快速点击两个不同的控件,那么只有第一个点击事件会生效 +- 每个控件之间的防抖逻辑是互相独立的。也即是说,如果分别快速点击两个不同的控件,那么这两个点击事件均会生效 + +在上文中隐藏了一个小细节:OnClickWrap 中定义的 `lastClickTime` 是静态变量。这就会导致不同的点击事件源会共享 `lastClickTime` 值且同时受到该值的限制,因此本文实现的效果是第一种。如果声明为非静态变量,那对应的就是第二种逻辑 + +而我之所以不将`lastClickTime` 声明为非静态变量,是因为被可组合函数的 **重组** 限制了:假如点击事件会造成对应的可组合函数重组,那么将 `onClick` 重置为 OnClickWrap 实例的代码将被再一次执行,此时声明为非静态变量的 `lastClickTime` 就又变成了默认值 0,从而也就无法实现双击防抖的效果了 + +# 六、结尾 + +想要解决以上遗留的问题,也许需要将思路改为从调用方进行插桩才能解决,但插桩逻辑就会变得很复杂,我目前还不知道该如何实现。希望本文能抛砖引玉,读者看完后能提出更好的实现方式 + +相关的代码我都上传到了 Github:[asm-samples](https://github.com/leavesCZY/asm-samples) + +希望对你有所帮助 🤣🤣🤣 \ No newline at end of file diff --git "a/Android ASM \345\255\227\350\212\202\347\240\201\346\217\222\346\241\251\357\274\232\344\273\216 Lambda \350\241\250\350\276\276\345\274\217\350\256\262\350\265\267.md" "b/Android ASM \345\255\227\350\212\202\347\240\201\346\217\222\346\241\251\357\274\232\344\273\216 Lambda \350\241\250\350\276\276\345\274\217\350\256\262\350\265\267.md" new file mode 100644 index 0000000..dfe20db --- /dev/null +++ "b/Android ASM \345\255\227\350\212\202\347\240\201\346\217\222\346\241\251\357\274\232\344\273\216 Lambda \350\241\250\350\276\276\345\274\217\350\256\262\350\265\267.md" @@ -0,0 +1,345 @@ +字节码插桩是如今 Android 开发中非常普遍的一种技术手段,其应用范围非常广泛,涉及各种业务强关联或者和业务无关的领域,例如:无痕埋点、隐私合规检测、耗时方法统计、性能检测、双击防抖等 + +我之前就写过几篇文章,介绍了几种通过 ASM 实现字节码插桩的案例 + +- [ASM 字节码插桩:实现双击防抖](https://juejin.cn/post/7042328862872567838) +- [ASM 字节码插桩:进行线程整治](https://juejin.cn/post/7044339202997092383) +- [ASM 字节码插桩:助力隐私合规](https://juejin.cn/post/7046207125785149448) +- [ASM 字节码插桩:监控大图加载](https://juejin.cn/post/7074970389188706318) + +本篇文章再来详细介绍下在实现字节码插桩的过程中,我们难免会遇到的一个难点,也即从 Java 8 开始支持的一个新语法:Lambda 表达式,再以此扩展介绍向 Lambda 表达式进行字节码插桩的大致思路 + +# 匿名内部类 + +我们知道,如果想要声明某个接口或者抽象类的实例的话,我们可以不显式声明实现类,而是可以直接采取 **匿名内部类** 的方式来得到其实例对象 + +看一个小例子 + +```java +public class Lambda { + + private void lambda() { + Runnable runnable = new Runnable() { + @Override + public void run() { + System.out.println("Hello World!"); + } + }; + } + +} +``` + +将 `Lambda.java`文件编译为字节码 + +```java +javac Lambda.java +``` + +最终会生成两个 class 文件:`Lambda$1.class`、`Lambda.class` + +![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/a6bc74a94ad941f4ba7aa223102f0a36~tplv-k3u1fbpfcp-zoom-1.image) + +`Lambda$1.class` 可以很明确地就看出其实现了 Runnable 接口,是编译器自动生成的实现类。从 `Lambda.class` 文件也可以明确看出,`lambda` 方法中 new 的对象指向的也是 `Lambda$1`。所以说,对于代码中的匿名内部类,编译器会自动为其生成一个实现类,包含了其原有的内部逻辑:`System._out_.println("Hello World!")`,并将原有的匿名内部类自动替换为该具体的实现类 + +# Lambda 表达式 + +由于 Runnable 接口属于函数式接口,因此我们可以将上述代码转化为 Lambda 表达式,再来看其字节码会有什么变化 + +```java +public class Lambda { + + private void lambda() { + Runnable runnable = () -> System.out.println("Hello World!"); + } + +} +``` + +最终只会生成一个 class 文件:`Lambda.class` + +![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/54d1898879c64aacbfa23f9e8df0dd70~tplv-k3u1fbpfcp-zoom-1.image) + +前后两份字节码文件主要的差异点在于: + +- 匿名内部类声明的 Runnable 变量,最终会指向一个具体的接口实现类,在字节码中也可以看到有明确的声明类实例并调用其构造方法的过程,对应 `new、dup、aload_0、invokespecial` 等指令。该实现类的 `run` 方法中包含了所要执行的代码块 +- Lambda 语法声明的 Runnable 变量,这个操作对应的是 `invokedynamic、astore_1` 等指令。Runnable 所要执行的代码块是在自动生成的静态方法 `lambda$lambda$0()` 中 + +从这可以推导出一个结论:**在编译阶段,Lambda 表达式并不会生成相应的实现类,Lambda 语法的实现机制有别于匿名内部类** + +当中的重点就在于 invokedynamic 指令了,Java 目前一共包含五种字节码调用指令 + +| 指令 | 作用 | +| --------------- | ------------------------------------------------------------ | +| invokevirtual | 调用实例方法 | +| invokestatic | 调用静态方法 | +| invokeinterface | 调用接口方法 | +| invokespecial | 调用特殊实例方法,包括实例初始化方法、父类方法 | +| invokedynamic | 由用户引导方法决定,运行时动态解析出调用点限定符所引用的方法 | + +在编译期间生成的 class 文件中,前四种指令通过常量池(Constant Pool)已经固定了目标方法的符号信息,包括 **类和接口的全局限定名、字段的名称和描述符、方法的名称和描述符** 等,运行阶段就可以依靠该符号信息直接定位到具体的方法从而直接调用 + +而 invokedynamic 是在 Java 7 中新增的字节码调用指令,作为 Java 支持动态类型语言的改进之一,在 Java 8 开始应用,Lambda 表达式底层就依靠该指令来实现。invokedynamic 指令在常量池中并没有包含其目标方法的具体符号信息,存储的是 BootstapMethod 信息,在运行时再来通过引导方法机制动态确定方法的所属者和类型 + +而不管怎么说,在编译过后,Lambda 表达式所对应的 **对象类型、要调用的方法的签名信息、要执行的代码块** 等信息依然是要被保存在字节码中的。进一步查看 `Lambda.class` 的详细字节码信息,看这些信息是存储在哪里 + +![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c0f9795ebce8462c9865ecd9f95d329f~tplv-k3u1fbpfcp-zoom-1.image) + +可以看到,第十八行的 invokedynamic 指令就包含了 Runnable 接口和 run 方法完整的签名信息:`run:()Ljava/lang/Runnable`,同时指向了第四十一行的 BootstapMethods 区域, 当中会通过 invokestatic 指令去调用 LambdaMetafactory 的静态方法 `metafactory()`,通过该方法在内存中来生成关联的接口实现类 + +同时,Method arguments 所列出的参数有三个: + +- 原始方法泛型擦除后的方法签名信息,也即 run 方法 +- Lambda 表达式原本所要执行的代码块,也即自动生成的静态方法 `lambda$lambda$0()` 的签名信息,当中包含了 Lambda 表达式原本所要执行的代码块 +- 原始方法泛型擦除前的方法签名信息,也即 run 方法。由于 run 方法不包含泛型,所以和第一个参数的签名信息一样 + +可以通过反射调用 `lambda$lambda$0()`方法来验证该方法是否真的存在,运行以下代码就会发现 Hello World! 打印了两次 + +```java +public class Lambda { + + private void lambda() { + Runnable runnable = () -> System.out.println("Hello World!"); + runnable.run(); + } + + public static void main(String[] args) { + Lambda lambda = new Lambda(); + lambda.lambda(); + try { + Lambda.class.getDeclaredMethod("lambda$lambda$0").invoke(null); + } catch (Throwable throwable) { + throwable.printStackTrace(); + } + } + +} +``` + +所以说,Lambda 表达式所对应的 **对象类型(Runnable 接口)、要调用的方法的签名信息(run 方法)、要执行的代码块(lambda$lambda$0 方法)** 等信息都是有被保存下来的,这样在运行时才能构成一个完整的调用链 + +# Lambda 的状态 + +假如一个 Lambda 表达式没有 "捕获" 任何外部实例对象,该表达式无需依赖于外部实例对象就可以单独运行,那么该 Lambda 表达式可以称为是 “无状态的";假如使用到了外部实例对象,那么该 Lambda 表达式就是 “有状态的” + +例如,在上述例子中,Runnable Lambda 表达式最终会对应一个自动生成的静态方法 `lambda$lambda$0()`,用于存储所要执行的代码逻辑。因为该表达式并没有使用到任何实例对象,所以可以以静态方法的形式存在。而 “有状态的” Lambda 表达式对应的方法将是实例方法 + +例如,以下四个 Lambda 表达式,因为只有 runnable1 使用到了实例变量,因此也只有它会生成一个实例方法。此外,runnable3 捕获的是局部变量,该变量和具体实例无关,因此也将对应一个静态方法,并将捕获的局部变量作为参数传入 + +```java +public class Lambda { + + private static String log1 = "Hello World!"; + + private String log2 = "Hello World!"; + + private void lambda() { + Runnable runnable0 = () -> System.out.println(log1); + Runnable runnable1 = () -> System.out.println(log2); + Runnable runnable2 = () -> System.out.println("Hello World!"); + String log = "Hello World!"; + Runnable runnable3 = () -> System.out.println(log); + } + +} +``` + +![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e97859df45cb47b9afec0d3750470837~tplv-k3u1fbpfcp-zoom-1.image) + +# Android Lambda + +知道标准 Java 平台是如何处理 Lambda 表达式后,再来讲下 Android 平台是如何支持 Lambda 表达式的,因为 Android 的 Lambda 和 Java 的 Lambda 并不等同 + +虽然每一个 Android 应用进程都对应一个 Java 虚拟机,但 Android 虚拟机并不完全遵循 Java 虚拟机标准, Java-Bytecode(JVM 字节码)是不能直接运行在 Android 系统上的,需要转换成 Android-Bytecode(Dalvik / ART 字节码),而 Dalvik / ART 并不支持 invokedynamic 指令,导致目前 Android 系统对 Java 8 以及更高版本的 JDK 支持得并不彻底。某些 Java API 也只有高版本系统才可以使用,例如,`LocalDateTime.now()` 至少要 Android 8.0 的系统才可以使用 + +为了能够支持 Java 8,目前 AGP 是通过在 D8/R8 将 class 文件编译成 dex 文件的过程中,对字节码进行转换来实现的,这个转换过程称为 desugar,也即 **脱糖** + +![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0c797e40ed1b495eb699cece97cb81c7~tplv-k3u1fbpfcp-zoom-1.image) + +desugar 操作就用于将某些 Android 系统目前还不支持的语法糖还原为简单的 **基础语法结构** 。例如,上述的 Runnable Lambda 表达式经过 desugar 之后,就会被转换为具体的实现类,并将生成的实现类直接写入到 dex 文件中,就如同普通的匿名内部类一样,因此也就不存在兼容性问题了,从而保证了 Lambda 表达式也能够在 Android 低版本系统上正常运行 + +# 字节码插桩 + +由于 Android APK 编译流程中 Transform 和 desugar 两个操作的先后顺序问题,就给我们的字节码插桩带来了一点点困扰 + +举个例子。我曾经通过字节码插桩的方式为项目实现了一个全局的 **双击防抖** 功能。简单来说,我通过字节码插桩的方式来为整个项目中所有使用了 OnClickListener 的回调方法中都插入了一段逻辑代码,该段逻辑代码会对前后两次点击的时间进行判断,如果判断到时间小于某个阈值的话就直接 return + +就像以下代码所示 + +```kotlin +//插桩前 +view.setOnClickListener(object : View.OnClickListener { + override fun onClick(view: View) { + //TODO + } +}) + +//插桩后 +view.setOnClickListener(object : View.OnClickListener { + override fun onClick(view: View) { + if (!ViewDoubleClickCheck.canClick(view)){ + return + } + //TODO + } +}) +``` + +Kotlin 中的 `object : View.OnClickListener` 就相当于 Java 中的匿名内部类,在编译阶段就会直接生成具体的实现类,因此可以很直接地通过 `View.OnClickListener` 接口和 `onClick` 方法两者的签信息名定位到需要插入代码的位置,难度不大 + +比较麻烦的是 Lambda 表达式 + +```kotlin +view.setOnClickListener { + //TODO +} +``` + +由于 AGP 的 Transform 流程是在 desugar 之前执行的,Transform 时还未生成各个 Lambda 表达式的具体实现类,所以此时的 Lambda 表达式还对应着 invokedynamic 指令,我们无法直接 “看到” Lambda 表达式对应的代码块,因此也不能简单地通过签名信息就定位到目标方法 + +想要解析 Lambda 表达式,就还是要依靠上文介绍的 BootstapMethods,通过 BootstapMethods 来找到出目标方法 + +对于上述 Lambda 表达式,通过 ASM 框架,在字节码层面上我们能够获取到的信息有: + +- 该表达式包含一条 invokedynamic 指令,对应 ASM 中的 InvokeDynamicInsnNode +- invokedynamic 指令中包含了要生成的接口实例的签名信息,即 invokedynamic 指令中标明了要生成的是 OnClickListener 对象,且包含一个 onClick 方法,所以此时就可以通过遍历项目全局的 InvokeDynamicInsnNode 的 name 和 desc 两个属性,来查找到和 OnClickListener Lambda 表达式关联的 InvokeDynamicInsnNode +- 上文已经讲到,invokedynamic 指令指向了字节码中的 BootstapMethod 区域,而 BootstapMethod 中已经标明了三个入参参数,第二个参数指向的是编译期间自动生成的方法,当中就包含了 onClick 方法应该执行的代码块。这三个参数就对应 InvokeDynamicInsnNode 的 bsmArgs 属性,所以通过 bsmArgs 我们就能够知道 onClick 方法最终要调用的方法的签名信息,通过向该方法插入需要的逻辑就可以实现插桩了 + +所以说,对于匿名内部类,我们的插桩思路是向 OnClickListener 接口的实现类的 onClick 方法插入代码;对于 Lambda 表达式,我们的插桩思路可以改为向其自动生成的方法插入代码,两者的最终效果都是一样的 + +对应具体代码 + +第一步。需要先遍历每一个 MethodNode 包含的所有指令 instructions,找出 name 和 desc 都符合的 InvokeDynamicInsnNode。此处之所以通过 endsWith 而非 equals 来筛选 desc,是因为 Lambda 有可能引用了外部实例,此时外部实例就会成为 OnClickListener 实现类的构造参数,那么 desc 就会变成类似于 `(Lgithub/leavesczy/asm/MainActivity;)Landroid/view/View$OnClickListener;` 这样的形式,所以需要通过 endsWith 来进行筛选 + +```kotlin +val dynamicInsnNodes = methodNode.filterLambda { + val nodeName = it.name + val nodeDesc = it.desc + nodeName == "onClick" && nodeDesc.endsWith("Landroid/view/View\$OnClickListener;") +} + +fun MethodNode.filterLambda(filter: (InvokeDynamicInsnNode) -> Boolean): List { + val mInstructions = instructions ?: return emptyList() + val dynamicList = mutableListOf() + mInstructions.forEach { instruction -> + if (instruction is InvokeDynamicInsnNode) { + if (filter(instruction)) { + dynamicList.add(instruction) + } + } + } + return dynamicList +} +``` + +第二步。找到需要插桩的 Lambda 表达式后,拿到 Method arguments 的第二个参数 `it.bsmArgs[1]`,该值就对应编译器要自动生成的方法,再通过该方法的签名信息 nameWithDesc 从 methods 中筛选出对应的 MethodNode,通过向该方法植入代码就可以实现插桩了 + +```kotlin +val shouldHookMethodList = mutableSetOf() + +dynamicInsnNodes.forEach { + val handle = it.bsmArgs[1] as? Handle + if (handle != null) { + val nameWithDesc = handle.name + handle.desc + val method = methods.find { it.nameWithDesc == nameWithDesc }!! + shouldHookMethodList.add(method) + } +} +``` + +第三步。此步骤就要来向目标方法植入代码了,但还有个问题需要先解决。由于 匿名内部类 和 Lambda 表达式 都有可能引用到了外部实例对象,因此经过 desugar 后,就会像以下代码所示,MainActivity 成为 OnClickListener 实现类的构造参数,该实现类再通过 MainActivity 对象来调用目标方法。这使得我们需要先知道 View 对象是作为当前方法的第几个参数,取到值后才能去调用 ViewDoubleClickCheck 进行防抖检查 + +```java +public final class MainActivity extends AppCompatActivity { + + /* access modifiers changed from: protected */ + @Override // androidx.activity.ComponentActivity, androidx.core.app.ComponentActivity, androidx.fragment.app.FragmentActivity + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + ((TextView) findViewById(R.id.tvViewDoubleClickCheck)).setOnClickListener(new MainActivity$$ExternalSyntheticLambda0(this)); + } + + /* access modifiers changed from: private */ + /* renamed from: onCreate$lambda-0 reason: not valid java name */ + public static final void m60onCreate$lambda0(MainActivity this$0, View view) { + if (ViewDoubleClickCheck.canClick(view)) { + //TODO + } + } + +} + +public final /* synthetic */ class MainActivity$$ExternalSyntheticLambda0 implements View.OnClickListener { + public final /* synthetic */ MainActivity f$0; + + public /* synthetic */ MainActivity$$ExternalSyntheticLambda0(MainActivity mainActivity) { + this.f$0 = mainActivity; + } + + public final void onClick(View view) { + MainActivity.m60onCreate$lambda0(this.f$0, view); + } +} +``` + +此外,点击事件不单单局限于 setOnClickListener 方法,RecyclerView 的每一个 item 的点击事件也需要进行防抖检查,这种情况也一样需要解析出 View 对象是作为当前方法的第几个参数 + +```kotlin +val clickDemoAdapter = ClickDemoAdapter() +clickDemoAdapter.setOnItemClickListener(object : OnItemClickListener { + override fun onItemClick(adapter: BaseQuickAdapter<*, *>, view: View, position: Int) { + if (ViewDoubleClickCheck.canClick(view)) { + //TODO + } + } +}) +``` + +因此,我们需要先计算出 viewArgumentIndex,通过 viewArgumentIndex 执行 ALOAD 操作加载到 View 对象,之后才能去调用 ViewDoubleClickCheck + +```kotlin +private fun hookMethod(modeNode: MethodNode) { + val argumentTypes = Type.getArgumentTypes(modeNode.desc) + //计算 View 对象是方法的第几个入参参数 + val viewArgumentIndex = argumentTypes?.indexOfFirst { + it.descriptor == "Landroid/view/View;" + } ?: -1 + if (viewArgumentIndex >= 0) { + val instructions = modeNode.instructions + if (instructions != null && instructions.size() > 0) { + val list = InsnList() + //引用 View 对象 + list.add( + VarInsnNode( + Opcodes.ALOAD, getVisitPosition( + argumentTypes, + viewArgumentIndex, + modeNode.isStatic + ) + ) + ) + //去调用 ViewDoubleClickCheck 的 canClick 方法 + list.add( + MethodInsnNode( + Opcodes.INVOKESTATIC, + config.formatDoubleCheckClass, + config.doubleCheckMethodName, + config.doubleCheckMethodDescriptor + ) + ) + val labelNode = LabelNode() + list.add(JumpInsnNode(Opcodes.IFNE, labelNode)) + list.add(InsnNode(Opcodes.RETURN)) + list.add(labelNode) + instructions.insert(list) + } + } +} +``` + +# 结尾 + +本文相关的代码都上传到了 Github:[asm-samples](https://github.com/leavesCZY/asm-samples) + +包含了几个完整的字节码插桩实践案例,读者可以一起参照 \ No newline at end of file diff --git "a/Android ASM \345\255\227\350\212\202\347\240\201\346\217\222\346\241\251\357\274\232\345\212\251\345\212\233\351\232\220\347\247\201\345\220\210\350\247\204.md" "b/Android ASM \345\255\227\350\212\202\347\240\201\346\217\222\346\241\251\357\274\232\345\212\251\345\212\233\351\232\220\347\247\201\345\220\210\350\247\204.md" new file mode 100644 index 0000000..b1916cc --- /dev/null +++ "b/Android ASM \345\255\227\350\212\202\347\240\201\346\217\222\346\241\251\357\274\232\345\212\251\345\212\233\351\232\220\347\247\201\345\220\210\350\247\204.md" @@ -0,0 +1,404 @@ +近两年来工信部对于应用的隐私合规安全问题愈加重视,对 Android 平台的管控程度也要比 IOS 平台严格很多,很多不合规的应用也先后被下架要求整改 + +![](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f01addaeea7c4865a77119108fd2c86c~tplv-k3u1fbpfcp-zoom-1.image) + +要避免出现隐私合规安全问题,最主要的就是要做到以下两点: + +- 在用户同意隐私协议之前,不能有收集用户隐私数据的行为。例如,在用户同意之前不能去获取 Android ID、Device ID、MAC 等隐私数据 +- 在用户同意隐私协议之后,搜集用户隐私数据的行为不能超出实现服务场景所必需的最低频率。例如,某些应用会在每次网络请求时将当前设备的 Android ID 作为 header 一起上报,如果没有对 Android ID 进行缓存处理的话,搜集该数据的行为频率就会非常高,此时一样存在隐私合规问题 + +针对以上两点问题有不同的应对措施 + +- 对于第一点。需要统计出整个项目中所有涉及到隐私行为的相关代码,根据业务流程来判断该隐私行为是否合理、以及是否会在用户同意隐私协议之前被触发。这就需要对整个项目进行 **静态扫描** 了 +- 对于第二点。需要在应用运行时动态记录每次触发隐私行为的时间点和调用链信息,根据触发时间来判断该隐私行为是否过量执行,根据调用链信息来辅助判断具体是哪一块业务在获取隐私数据。这就需要对应用进行 **动态记录** 了 + +以上应对措施如果单纯靠开发人员来肉眼识别代码和编码统计的话,工作量非常大而且也很不现实,因为一个大型项目往往都会引入多个依赖库和第三方 SDK,我们可以规范自有代码,但没法修改和有效约束外部依赖,也很难理清楚依赖库的内部逻辑和调用链关系。此外,当检测到隐私行为后,也要输出相对应的记录报告,以便开发人员能够在开发阶段排查问题 + +此时就可以依靠 ASM + Transform 来应对这种非业务侵入式的开发场景了,本文要介绍的就是如何通过 ASM + Transform 来助力通过隐私合规安全审核 + +# 静态扫描 + +要进行静态扫描,首先就需要先明确哪些操作是属于隐私行为,这里以获取 Device ID 和 Build.BRAND 为例。在 Activity 中通过点击按钮来模拟获取隐私数据的行为,后续实现 **动态记录** 时需要记录的调用链指的也就是从点击按钮到执行 `DeviceUtils.getDeviceId` 方法的整个过程 + +```kotlin +package github.leavesc.asm.privacy_sentry + +class PrivacySentryActivity : AppCompatActivity() { + + private val btnGetDeviceId by lazy { + findViewById