写点什么

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

  • 2022 年 3 月 11 日
  • 本文字数:8762 字

    阅读完需:约 29 分钟

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


作者:刘天宇(谦风)


系列文章回顾《向工程腐化开炮 | 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 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.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 代码是大部分 Android 开发同学,最主要的创作产出。基于代码使用检测能力,对各种基础功能,进行统一的管控治理,除了本文讲到的这三项,还有更多的场景可以挖掘。例如,使用原生 SharedPreferences,进行 kv 存储,不支持主线程,容易造成 anr,可以统计迁移到更好的实现库。类似这样的场景,我们还会持续的进行探索。


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


【参考文档】


  • 【google】使用 Java 8 语言功能和 API:https://developer.android.com/studio/write/java8-support

  • 【jakewharton】Android's Java 8 Support:https://jakewharton.com/androids-java-8-support/


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

发布于: 刚刚阅读数: 2
用户头像

还未添加个人签名 2018.07.07 加入

阿里巴巴移动&终端技术官方账号。

评论

发布
暂无评论
向工程腐化开炮 | Java代码治理_Java_阿里巴巴移动技术_InfoQ写作平台