向工程腐化开炮 | Java代码治理

向工程腐化开炮 | Java代码治理_第1张图片

作者:刘天宇(谦风)

系列文章回顾《向工程腐化开炮 | proguard治理》《向工程腐化开炮 | manifest治理》。本文为系列文章第三篇,虽然标题是java代码,但准确来讲,本文主要聚焦的是jvm字节码,因此相关工具和治理,对于kotlin也同样适用,如无特殊情况,不再单独说明。此外,还会涉及到java资源。

java代码腐化和失控,主要体现在不合理代码使用不断累积。这里“不合理”的定义,由上层场景决定,例如在当前隐私合规监管态势下,我们不允许非预期的,代码直接调用系统敏感API,那么“对系统敏感API的直接调用”就是不合理。java代码治理,正是围绕这种“不合理“代码使用,逐步展开。

基础知识

本章不会介绍java语言本身,相信大家对此已有足够熟悉。相对的,会从工程应用角度,讲解几个有意思的技术点。

1.1 由源码到apk

源代码是如何经历多重处理,最终呈现在apk中,了解这个过程,有助于我们认清java代码腐化的一些原因。从apk构建视角来看,java(kotlin)代码完整处理过程如下:

向工程腐化开炮 | Java代码治理_第2张图片

上述流程中,无论是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,而是进行了一些定制后的子集。

向工程腐化开炮 | Java代码治理_第3张图片

java8新语言特性,有一些涉及到新的jvm指令集,这些需要运行时vm能够支持。否则,就需要在编译工具链中,能够使用兼容的指令集来替换这些新指令集的功能,这个过程就是大家熟悉的“脱糖”,AndroidGradlePlugin3.0及以上版本,已经对此实现了较好的支持。由于Android系统中Art虚拟机,直到8.0版本,才完全实现对java8新指令集的支持,因此当apk构建的minSdkVersion设置为26(8.0)以下时,会触发脱糖处理。java8的主要新语言特性如下:

向工程腐化开炮 | Java代码治理_第4张图片

对于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%:

向工程腐化开炮 | Java代码治理_第5张图片

在优酷这边,由DX升级到D8后,apk包大小降低约9%(额外对dex合并进行了优化,dex数量降低,对包大小收益较大),由此冷启动阶段dex加载耗时也降低了50ms。编译耗时无法做拆解性统计,但是肯定有正向收益。

向工程腐化开炮 | Java代码治理_第6张图片

此外,对于上一小节讲到的脱糖处理,DX并不包含,因此需要单独的前置脱糖处理,而D8则可以在转换过程中,直接进行脱糖,在速度和脱糖支持的全面性上,均有提升。

1.4 java资源

java资源如何定义,如何使用,在这里不做讲解。在Android开发领域,有一点需要关注的是,Java资源会原封不动放置到最终apk内。这就意味着,如果java资源相对路径,与apk内其它类型元素一致,java资源会“伪装”为其它类型元素,如果和对应类型元素有同路径文件,还会发生覆盖,引发运行时风险,提高问题排查难度。

向工程腐化开炮 | Java代码治理_第7张图片

其中,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 ()V
    ALOAD 0
    INVOKESPECIAL java/lang/Object. ()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 [[email protected]|, [email protected]|]

project:library-aar-1:1.0
|-- com.example.libraryaar1.desugar.LambdaUsage [[email protected]|]

自定义检测,支持以下四种元规则:

  • 类: 。例如java.lang.Thread;
  • 方法: method@|。例如[email protected].Location|getLongitude;
  • 变量: field@|。例如[email protected].Util|fieldOne;
  • 字符常量: cst_string@。例如cst_string@i am string。

上述四种元规则,可以进行“逻辑与”组合,形成复合规则:在同一个方法体内,这些元规则必须都命中,这条规则才算命中。例如:[email protected].Class|forName&[email protected].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异常。

向工程腐化开炮 | Java代码治理_第8张图片

诸如此类问题,都需要收敛到统一的线程池实现解决,但是如何能够做到这种收敛,前述的「代码使用检测」能力,正好可以发挥作用。通过配置所有可能的非统一线程池调用规则,进行检测和拦截,有效实现了对线程使用的管控:

向工程腐化开炮 | Java代码治理_第9张图片

优酷2021年1月,上线此线程管控卡口。对于存量问题类,降低了1/8左右,后续会持续对自研代码进行清理,二、三方SDK则通过线程池注入的方式完成收敛。对于新增非统一线程池使用,累计拦截19次,防控效果明显。

在实际效果上,线程数超限导致的Java崩溃,已经大幅减少,从此“淡出”top crash行列。此外,收敛到的自研统一线程池,包含详细的各阶段耗时统计,线程不销毁和长时间占用等问题,都能够得到有效监控、定位和解决。

敏感API管控

当前隐私合规监管态势,日趋严格,敏感权限、以及敏感信息获取,是其中两个非常重点的监管项。在代码层面,对相关信息获取涉及到的API调用,进行统一收口管控,是一种非常有效的手段。

向工程腐化开炮 | Java代码治理_第10张图片

权限声明的治理和管控,在「[向工程腐化开炮 | manifest治理]()」一文中,已经给出了解决方案。对于敏感信息获取,如何能够实现以上运行时的整体收口管控呢?同样,基于「代码使用检测」能力,通过对所有系统敏感信息对应的API,配置为检测规则,将这些调用收敛到统一封装的SDK中。

向工程腐化开炮 | Java代码治理_第11张图片

优酷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字节码,不会再进行源码编译期各项检查,容易出现代码间引用关系不匹配情况。下面我们来看看这个不兼容引用,到底是怎么发生的:

向工程腐化开炮 | Java代码治理_第12张图片

在上图示例中,模块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月上线此卡口,新增防控情况如下:

向工程腐化开炮 | Java代码治理_第13张图片

存量问题,进行了定向到团队的分发,有一小部分得到了及时清理,为了避免对各业务团队日常研发活动,产生较大影响,添加到了白名单,待后续适当时机再发起清理。新增问题实现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
|   |-- [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代码治理_第14张图片

还能做些什么

相对于其它类型元素,java代码是大部分Android开发同学,最主要的创作产出。基于代码使用检测能力,对各种基础功能,进行统一的管控治理,除了本文讲到的这三项,还有更多的场景可以挖掘。例如,使用原生SharedPreferences,进行kv存储,不支持主线程,容易造成anr,可以统计迁移到更好的实现库。类似这样的场景,我们还会持续的进行探索。

与工程腐化的对抗,路漫修远,道阻且长,与诸君共勉。

【参考文档】

关注【阿里巴巴移动技术】微信公众号,每周 3 篇移动技术实践&干货给你思考!

你可能感兴趣的:(向工程腐化开炮 | Java代码治理)