作者:刘天宇 (谦风)
系列文章回顾《向工程腐化开炮 | proguard 治理》《向工程腐化开炮 | manifest 治理》。本文为系列文章第三篇,尽管题目是 java 代码,但精确来讲,本文次要聚焦的是 jvm 字节码,因而相干工具和治理,对于 kotlin 也同样实用,如无非凡状况,不再独自阐明。此外,还会波及到 java 资源。
java 代码腐化和失控,次要体现在不合理代码应用一直累积。这里“不合理”的定义,由下层场景决定,例如在以后隐衷合规监管态势下,咱们不容许非预期的,代码间接调用零碎敏感 API,那么“对系统敏感 API 的间接调用”就是不合理。java 代码治理,正是围绕这种“不合理“代码应用,逐渐开展。
基础知识
本章不会介绍 java 语言自身,置信大家对此已有足够相熟。绝对的,会从工程利用角度,解说几个有意思的技术点。
1.1 由源码到 apk
源代码是如何经验多重解决,最终出现在 apk 中,理解这个过程,有助于咱们认清 java 代码腐化的一些起因。从 apk 构建视角来看,java(kotlin)代码残缺处理过程如下:
上述流程中,无论是 java 还是 kotlin 代码,都会首先编译为 jvm 字节码。这里须要留神,app/ 子工程中的 local jar、flat aar,以及通过内部依赖形式引入的 jar、aar,都是间接蕴含编译好的 jvm 字节码,这会带来如下优劣势:
- 【劣势】无需再进行由源码到字节码的编译,在代码完全相同状况下,工程的模块化(jar/aar)水平越高,越可能缩短整体 apk 构建耗时;
- 【劣势】提前编译好的 jvm 字节码,不会再进行源码编译期各项查看,容易呈现代码间援用关系不匹配状况,具体后文「不兼容援用」局部会详述。
此外,对于 jvm 和 Dalvik 字节码,一个最外围的区别是:前者的指令,基于栈,后者基于寄存器。基于寄存器的劣势,次要是运行时指令执行性能的晋升。此外,jvm 字节码,每个类位于独立.class 文件,而 dalvik 字节码,所有类均位于同一(几)个 dex 文件,可能更好的复用代码数据,因而存储占用更低。
1.2 应用 java8
java8 是一个比拟有代表性的 java 语言版本,在 Android 中应用 java8,这个话题自身会比较复杂。首先,从 java8 新内容来看,次要分为新语言个性和新 API;其次,从编码到运行的整个链路来看,波及编译工具链、设施预装 jdk、设施 vm(Dalvik/Art)三个局部的反对;此外,Android 自身应用的 jdk 并不是规范的 oracle jdk 或 openjdk,而是进行了一些定制后的子集。
java8 新语言个性,有一些波及到新的 jvm 指令集,这些须要运行时 vm 可能反对。否则,就须要在编译工具链中,可能应用兼容的指令集来替换这些新指令集的性能,这个过程就是大家相熟的“脱糖”,AndroidGradlePlugin3.0 及以上版本,曾经对此实现了较好的反对。因为 Android 零碎中 Art 虚拟机,直到 8.0 版本,才齐全实现对 java8 新指令集的反对,因而当 apk 构建的 minSdkVersion 设置为 26(8.0)以下时,会触发脱糖解决。java8 的次要新语言个性如下:
对于 java8 新 API,两组具备代表性的是「流式编程 streams」和「函数式编程 functional」,直到 os7.0 才提供了较完整的反对。具体如何在更低 minSdkVersion 时应用这些 java8 新 API,能够参考文末官网文档。
本节对在 Android 中应用 java8 的一些基础知识,进行了解说,对于在工程中如何具体配置,官网文档曾经给出了清晰的阐明,在此不赘述。
1.3 DX vs D8
由 jvm 字节码“转换到”Dalvik 字节码,须要编译工具来实现,这一工作由 DX 或 D8 承当。DX 是第一代工具,D8 是第二代工具,绝对于 DX,D8 在编译速度、产物大小、代码性能方面,全面超过 DX,官网 blog 中,给出的编译速度收益约 25%,产物大小收益约 5%:
在优酷这边,由 DX 降级到 D8 后,apk 包大小升高约 9%(额定对 dex 合并进行了优化,dex 数量升高,对包大小收益较大),由此冷启动阶段 dex 加载耗时也升高了 50ms。编译耗时无奈做拆解性统计,然而必定有正向收益。
此外,对于上一大节讲到的脱糖解决,DX 并不蕴含,因而须要独自的前置脱糖解决,而 D8 则能够在转换过程中,间接进行脱糖,在速度和脱糖反对的全面性上,均有晋升。
1.4 java 资源
java 资源如何定义,如何应用,在这里不做解说。在 Android 开发畛域,有一点须要关注的是,Java 资源会一成不变搁置到最终 apk 内。这就意味着,如果 java 资源相对路径,与 apk 内其它类型元素统一,java 资源会“假装”为其它类型元素,如果和对应类型元素有同门路文件,还会产生笼罩,引发运行时危险,进步问题排查难度。
其中,res 目录下资源“假装”不残缺,对应资源在 R 类,以及资源符号表 resources.arsc 中,并没有记录,因而,在运行时无奈当作失常资源应用。此外,google 也留神到 java 资源对 Android 特有元素的这种烦扰,在 AndroidGradlePlugin7.0 及以上版本,以 java 资源模式“混”入到最终 apk 内的 so,会被剔除。
治理实际
随着工程模块 / 代码减少,腐化逐渐积攒,对 app 负面影响也逐渐加深:定位一个类、java 资源的老本越来越高;线程随便应用,导致线上卡顿、解体(华为某些机型对线程总数有限度);敏感 API 调用不足整体管控,存量隐衷合规整改,老本极高,新增敏感 API 调用,对监管机构复测埋下隐患;不兼容援用,必然产生运行时异样,如果波及性能定投或被非预期 catch 住,很容易产生重大故障。在此不逐个列举,后文会具体介绍。
优酷在与 java 代码“腐化”奋斗中,从下层理论需要(例如阻塞集成、线上故障、隐衷合规、线程 /Phenix 图片库应用管控)登程,通过相干工具建设无效的检测能力,并基于此造成日常研发卡口机制。在确保问题零新增前提下,逐渐消化已有存量问题。
在问题定位、排查过程中,疾速获取 java 代码、java 资源来自哪个模块,以及代码逻辑,是最根底的诉求。二、三方模块大量引入,以及 app 工程模块化水平进步,都让上述信息获取的老本变得越来越高。为此,在工具层面,开发了以下几项辅助剖析性能。
- 模块蕴含 java 类列表。能够疾速查看,指标类位于哪个模块(内部依赖模块、app 工程、subproject 工程、local jar、flat aar):
com.android.support:support-annotations:26.0.0
|-- android.support.annotation.AnyThread
|-- android.support.annotation.ColorRes
project:library-aar-2:1.0
|-- com.example.libraryaar2.LibraryAar2ClassOne
|-- com.example.libraryaar2.CodeUsage2
project:app:1.0
|-- com.example.myapplication.proguard.TestProguardParcel$1
- 模块蕴含 java 资源列表。用于查看指标 java 资源,来自哪个模块:
com.youku.support:moduleOne:1.6.3.9
|-- com/abc/security/readme.txt
com.youku.arch:moduleTwo:1.6.3.9
|-- com/tencent/mm/sdk/platformtools/rep5402863540997075488.tmp
|-- com/abc/svc.manifest
- class 代码打印。将所有.class 字节码,反编译为可读文件(与 javap 反编译后格局统一),为全局排查相干问题提供便当:
# 目录构造示例:build/outputs
└── op-code
├── com.youku.android-moduleOne-1.0.2.23
│ └── com
│ └── youku
│ └── android
│ ├── AnimEndEvent.txt
│ ├── AnimIntervalEvent.txt
│ ├── AnimKeyFrameData.txt
│ ├── AnimMotionCaptureNode.txt
│ ├── AnimNodeBase.txt
│ ├── AnimSequenceBase.txt
# 内容示例:// class version 52.0 (52)
// access flags 0x21
public class com/example/libraryaar1/LibraryAarClassOne {
// access flags 0x1
public Ljava/lang/String; fieldOne
// access flags 0x1
public <init>()V
ALOAD 0
INVOKESPECIAL java/lang/Object.<init> ()V
ALOAD 0
LDC "library_aar_class_one_field_one"
PUTFIELD com/example/libraryaar1/LibraryAarClassOne.fieldOne : Ljava/lang/String;
RETURN
MAXSTACK = 2
MAXLOCALS = 1
// access flags 0x9
public static test()V
LDC "LibraryAarClassOne"
LDC "just test"
INVOKESTATIC android/util/Log.e (Ljava/lang/String;Ljava/lang/String;)I
POP
RETURN
MAXSTACK = 2
MAXLOCALS = 0
}
有了以上几项辅助剖析工具作为根底,接下来逐个对各项腐化治理实际进行解说。
2.1 代码应用检测
当 app 性能越来越简单时,想要对立对某个根底能力的正确应用,会变得十分困难,腐化由此变得一发不可收拾。在优酷中就遇到了不少这类问题,迫切需要一种无效的形式,拦挡不标准的代码应用。
为此,开发了基于规定的,代码间动态援用检测。以线程为例,假如咱们规定所有对线程的应用,必须对立到指定线程池实现,那么咱们就须要禁止所有其它的线程应用形式:’new java.lang.Thread’, ‘new java.util.concurrent.ThreadPoolExecutor’, ‘new java.util.concurrent.Executors’, ‘new java.util.Timer’, ‘new android.os.AsyncTask’],示例检测后果,列出了哪些模块中的哪些类,存在对上述代码的应用:
com.youku.arch:Hd:2.8.15
|-- com.youku.arch.hd.HChk$2 [method@android.os.AsyncTask|<init>, method@java.util.concurrent.Executors|<init>]
project:library-aar-1:1.0
|-- com.example.libraryaar1.desugar.LambdaUsage [method@java.lang.Thread|<init>]
自定义检测,反对以下四种元规则:
- 类: <class_full_name>。例如 java.lang.Thread;
- 办法: method@<class_full_name>|<method_name>。例如 method@android.location.Location|getLongitude;
- 变量: field@<class_full_name>|<field_name>。例如 field@com.onepiece.demo.Util|fieldOne;
- 字符常量: cst_string@<string>。例如 cst_string@i am string。
上述四种元规则,能够进行“逻辑与”组合,造成复合规定:在同一个办法体内,这些元规则必须都命中,这条规定才算命中。例如:method@java.lang.Class|forName&cst_string@com.android.internal.R$dimen&cst_string@status_bar_height,能够用来检测反射获取 status_bar 高度的代码:
public class CodeUsage {public static void getStatusBarHeight(Context context) {
try {Class<?> c = Class.forName("com.android.internal.R$dimen");
Object obj = c.newInstance();
Field field = c.getField("status_bar_height");
int x = Integer.parseInt(field.get(obj).toString());
context.getResources().getDimensionPixelSize(x);
} catch (Exception e) {Log.d("ViewUtils", "get status bar height fail");
e.printStackTrace();}
}
}
与此同时,提供白名单配置,临时排除一些二、三方库。更进一步,提供选项,当检测后果不通过时,终止构建过程,造成卡口机制。目前,优酷将这个检测能力,利用到了三个具体场景,上面逐个道来。
线程管控
对线程的应用,非常容易“失控”:线程数过多,减少 os 线程调度耗时,以及内存占用;创立不销毁,长时间占用等状况,定位排查艰难;某些厂商定制 os 对线程数有限度,超限后再创立线程,会间接抛出 OOM 异样。
诸如此类问题,都须要收敛到对立的线程池实现解决,然而如何可能做到这种收敛,前述的「代码应用检测」能力,正好能够发挥作用。通过配置所有可能的非对立线程池调用规定,进行检测和拦挡,无效实现了对线程应用的管控:
优酷 2021 年 1 月,上线此线程管控卡口。对于存量问题类,升高了 1 / 8 左右,后续会继续对自研代码进行清理,二、三方 SDK 则通过线程池注入的形式实现收敛。对于新增非对立线程池应用,累计拦挡 19 次,防控成果显著。
在实际效果上,线程数超限导致的 Java 解体,曾经大幅缩小,从此“淡出”top crash 行列。此外,收敛到的自研对立线程池,蕴含具体的各阶段耗时统计,线程不销毁和长时间占用等问题,都可能失去无效监控、定位和解决。
敏感 API 管控
以后隐衷合规监管态势,日趋严格,敏感权限、以及敏感信息获取,是其中两个十分重点的监管项。在代码层面,对相干信息获取波及到的 API 调用,进行对立收口管控,是一种十分无效的伎俩。
权限申明的治理和管控,在「[向工程腐化开炮 | manifest 治理]()」一文中,曾经给出了解决方案。对于敏感信息获取,如何可能实现以上运行时的整体收口管控呢?同样,基于「代码应用检测」能力,通过对所有零碎敏感信息对应的 API,配置为检测规定,将这些调用收敛到对立封装的 SDK 中。
优酷 2021 年 6 月,上线敏感 API 管控卡口,随着监管力度增强,检测规定也从 10 几条增长到当初近 50 条,目前已趋于稳定。对存量敏感 API 调用,随着历次合规整改,也进行了大量的收敛革新;对新增问题,累计拦挡 11 次,无效保障敏感信息获取的可控性,防止遭逢监管“回头看”式的抽查问责危险。
Phenix 图片库管控
Phenix 图片库是阿里团体内的图片加载中间件,提供了优良的性能体验,以及丰盛的性能。优酷很早就基于 Phenix 非管道模式进行了下层扩大,反对对 url 模式图片进行 webp 转换、尺寸自适应裁剪等解决,进步客户端图片加载性能,升高内存占用,同时也升高服务端老本。在 2017 年加载图片的 webp 占比一度达到 68%,而 2021 年 webp 占比升高至 24%,其中新增 284 个以管道形式加载图片的 java 类,是 webp 占比升高的重要因素。
为此,同样利用「代码应用检测」能力,对 Phenix 管道模式应用进行限度,以充分发挥非管道模式的劣势。对于间接应用管道模式的存量代码,曾经实现全面排查,并发动整改治理。对应卡口于 21 年 11 月底刚上线,用于无效拦挡 Phenix 图片库的不合理应用。
2.2 不兼容援用
置信 Android 开发同学,对运行时 NoClassDefFoundError、NoSuchMethodError、NoSuchFieldError 并不生疏,这几类 Java 异样,正是因为不兼容的代码援用导致。前文「由源码到 apk」一节,曾经讲到了产生不兼容援用起因:jar 包中提前编译好的 jvm 字节码,不会再进行源码编译期各项查看,容易呈现代码间援用关系不匹配状况。上面咱们来看看这个不兼容援用,到底是怎么产生的:
在上图示例中,模块 B 工程首先编译并公布 1.0 版本到 maven 中。而后模块 A 工程中以内部依赖模式,申明对模块 B 的援用,A 类调用 B 类中的 b 办法,编译并公布 1.0 版本到 maven 仓库。此时 apk1 以内部依赖模式引入模块 A 和 B,打包为 apk1 后,一切正常。尔后,模块 B 将 B 类改名为 C,而后公布 2.0 版本到 maven,apk 更新模块 B 版本号到 2.0,打包为 apk2 后,不兼容援用问题产生:类 B 在 apk 中并不存在,运行时必然导致 NoClassDefFoundError。
总结一下,不兼容援用,是指代码中调用了不存在(不匹配)的变量 / 办法,会导致运行时产生异样。有以下几种典型状况:
- 被调用类不存在;
- 被调用类的变量不存在,或者变量签名不匹配;
- 被调用类的办法不存在,或者办法签名不匹配。
对此,开发了针对性的检测能力。来看一份示例后果:
# 解释:oh 模块,FileUtil 类 copyFile 办法中,调用的 com.alibaba.fastjson.util.IOUtils->close,对应 class 不存在。一旦执行到,必定会抛出 NoClassDefFoundError 异样。com.youku.android:oh:0.3.35.34
|-- com.youku.arch.util.FileUtil
| |-- copyFile : (Ljava/lang/String;Ljava/lang/String;)V
| | |-- [class-no-module] com.alibaba.fastjson.util.IOUtils->close : (Ljava/io/Closeable;)V
# 解释:AFManager 模块,AFManagerImpl 类 aFCheck 办法中,调用的 msdk.ApiLockHelper->lock,class 存在然而无此办法定义(无平安没有这个办法,或者办法签名不对)。一旦执行到,必定会抛出 NoSuchMethodError 异样。com.youku.android:AFManager:1.0.1
|-- com.youku.android.af.AFManagerImpl
| |-- aFCheck : (Ljava/lang/String;Landroid/content/Context;ILjava/util/Map;)Z
| | |-- msdk.ApiLockHelper->lock : (Ljava/lang/String;J)V (com.youku.android:msdk:1.0)
优酷于 21 年 6 月上线此卡口,新增防控状况如下:
存量问题,进行了定向到团队的散发,有一小部分失去了及时清理,为了防止对各业务团队日常研发流动,产生较大影响,增加到了白名单,待后续适当机会再发动清理。新增问题实现 100% 全拦挡,线上未产生代码变更导致的此类 crash 或业务异样。
2.3 同名类
同名类,是指类名(全限定名)雷同的类。如果两个类,仅存在大小写不同,那么在大小写不敏感的文件存储系统中(macos 默认就是),会被认为是同一个类,这会导致无奈编译通过。在优酷历次迭代中,就产生过这样一个案例:一个二方模块工程的混同配置问题,导致 jar 包中呈现不同类,仅存在大小写不同,在打包平台(Linux)中能够胜利构建,并进入集成,间接导致开发同学无奈在本地 macos 机器中进行打包,影响了研发效率,两个版本(1 个月)后失去修复。
对于这个问题,开发了同名类检测能力,无论在 Linux 还是 macos 中,均能够统一的检测出同名类:
com.ali.sty.ridentity.build.va
|-- com.ali.sty.ridentity.build.Va : com.ali.sty.ridentity:rpsdk:4.8.5
|-- com.ali.sty.ridentity.build.va : com.ali.sty.ridentity:rpsdk:4.8.5
com.ali.sty.ridentity.build.vb
|-- com.ali.sty.ridentity.build.Vb : com.ali.sty.ridentity:rpsdk:4.8.5
|-- com.ali.sty.ridentity.build.vb : com.ali.sty.ridentity:rpsdk:4.8.5
这项检测能力,同样提供了选项,当检测后果不通过时,终止构建过程,用于造成卡口。优酷于 21 年 7 月上线对应卡口,至今未呈现新增。
2.4 硬编码文本
隐衷合规检测机构,会检测 apk 中的一些敏感文本,做为隐衷合规问题的重点狐疑 & 验证点,例如「发票低头」、「身份证」等。其中一部分就是来自于 java 代码中的硬编码文本(另外可能的起源是资源、so)。硬编码文本,存在以下毛病:
- 易冗余。多处代码应用同一文本时,如果最终不在同一个 dex,会导致不同 dex 常量池中存在多份此文本;
- 不灵便。当线上版本呈现问题时(例如各类经营流动),难以动静批改;
- 低平安。一些敏感信息,如果以明文硬编码文本模式存在,非常容易被获取后,用于不正当用于。
对于这类问题,开发了对应检测能力,能够自定义正则表达式,对代码中的硬编码文本(字节码指令中的字符串常量)进行匹配。检测后果中,依照模块、类进行逐级聚合。以所有中文字符检测为例:
# 示例代码(局部):
public class TestCodeB {public static void methodA(Context context) {Toast.makeText(context, "I'am english text hardcoded with java code2.", Toast.LENGTH_SHORT)
.show();}
public void methodB(Context context) {Toast.makeText(context, "我是 java 代码中硬编码的中文文本 3.", Toast.LENGTH_SHORT).show();}
}
# 示例检测后果:
project:app:1.0
|-- com.example.myapplication.proguard.TestProguardClass
| |-- 我是 java 代码中硬编码的中文文本.
|-- com.example.myapplication.code.TestCodeB
| |-- 我是 java 代码中硬编码的中文文本 3.
|-- com.example.myapplication.code.TestCodeA
| |-- 我是 java 代码中硬编码的中文文本 1.
| |-- 我是 java 代码中硬编码的中文文本 2.
目前在优酷,隐衷合规相干的一些敏感文本,是一个正在进行的摸索方向,因为目前没有明确规定,因而还没有理论落地应用。不过,在日常研发过程,对于须要查找特定硬编码文本的场景,曾经可能起到很好的辅助提效作用。
2.5 非法 java 资源
如第一章「java 资源」大节所述,java 资源能够“假装”为 dex、Android 资源、动态链接库 so,一旦产生同名笼罩,会引发非预期的运行时异样。即便不产生笼罩,也会给相干元素在构建过程的解决,以及 app 运行时问题定位和排查,带来不必要麻烦。
针对这种非法 java 资源,开发了相应检测能力,能够自定义非法 java 资源规定,与规定匹配的 java 资源会被检测进去。以下示例,配置了 dex、android 资源(res/assets)、动态链接库 so,规定汇合为:[‘^lib/.+’, ‘^res/.+’, ‘^assets/.+’, ‘^classes\d*\.dex’],示例内容如下:
* project:library-aar-1:1.0
|-- [ignored] classes20.dex
| |-- [hitRule] ^classes\d*.dex
|-- res/drawable/fake_drawable.png
| |-- [hitRule] ^res/.+
|-- assets/java_resource_under_assets.xml
| |-- [hitRule] ^assets/.+
|-- lib/arm64-v8a/libdwebp.so
| |-- [hitRule] ^lib/.+
.....
与此同时,提供白名单配置,排除一些非凡非法 java 资源的影响。更近一步,提供选项,当检测后果不通过时,终止构建过程,造成卡口机制。以上也是以后优酷在应用的规定,各 app 在实践中能够依据理论状况,进行自在定制。
优酷这边的存量非法 java 资源,蕴含了两个 flutter 的 so,以及三方 sdk 引入的 assets,整改工作量不大,后续会择机进行。对于前者,在 AndroidGradlePlugin7.0 及以上会间接疏忽,有了这个检测后果,就能够提前整改,为 AGP 降级到 7.0 扫清阻碍。非法 java 资源卡口,接下来很快会在优酷上线,保障后续无新增。这个例子,体现出对工程腐化问题的治理,既要面向当下,严防死守,也要防备于未然,主动出击。
2.6 治理全景
至此,对于 java 代码,进行了较全面无效的防腐化能力建设和治理。最初,给出一份全景图:
还能做些什么
绝对于其它类型元素,java 代码是大部分 Android 开发同学,最次要的创作产出。基于代码应用检测能力,对各种根底性能,进行对立的管控治理,除了本文讲到的这三项,还有更多的场景能够开掘。例如,应用原生 SharedPreferences,进行 kv 存储,不反对主线程,容易造成 anr,能够统计迁徙到更好的实现库。相似这样的场景,咱们还会继续的进行摸索。
与工程腐化的反抗,路漫修远,道阻且长,与诸君共勉。
【参考文档】
- 【google】应用 Java 8 语言性能和 API:https://developer.android.com…
- 【jakewharton】Android’s Java 8 Support:https://jakewharton.com/andro…
关注【阿里巴巴挪动技术】微信公众号,每周 3 篇挪动技术实际 & 干货给你思考!