作者:刘天宇(谦风)

系列文章回顾《向工程腐化开炮 | 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.ColorResproject:library-aar-2:1.0|-- com.example.libraryaar2.LibraryAar2ClassOne|-- com.example.libraryaar2.CodeUsage2project:app:1.0|-- com.example.myapplication.proguard.TestProguardParcel$1
  • 模块蕴含java资源列表。用于查看指标java资源,来自哪个模块:
com.youku.support:moduleOne:1.6.3.9|-- com/abc/security/readme.txtcom.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 0x21public 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.5com.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|   |-- [text] 我是java代码中硬编码的中文文本.|-- com.example.myapplication.code.TestCodeB|   |-- [text] 我是java代码中硬编码的中文文本3.|-- com.example.myapplication.code.TestCodeA|   |-- [text] 我是java代码中硬编码的中文文本1.|   |-- [text] 我是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 篇挪动技术实际&干货给你思考!