沐鸣娱乐


        和无用代码说再见!阿里文娱无损代码覆盖率统计方案(阿里文娱app)

        和无用代码说再见!阿里文娱无损代码覆盖率统计方案(阿里文娱app)

        作者 | 阿里巴巴文娱高级无线开发工程师 孙珑达

        责编 | 屠敏

        和无用代码说再见!阿里文娱无损代码覆盖率统计方案(阿里文娱app)

        背景

        为了适应产品的快速迭代,通常大量的研发资源会投入在新功能的开发上,而针对无用功能的治理却很少被关注。随着时间的推移,线上应用会积累大量的无用代码,再加上人员更迭以及功能交接,治理无用代码的成本越来越高 。最终应用安装包过大 ,导致应用下载转化率降低 、应用平台上架受限(例如超过100M的应用不能上架谷歌商店) 、研发效率降低等等 。

        如何治理无用代码?首先是代码静态扫描 。对于androids应用,ProGuard工具可以在构建阶段静态分析代码引用关系,自动裁减掉没有被引用到的代码,减少安装包大小。

        当然,只有代码静态扫描是不够的,因为它不能代表线上用户实际的使用情况,所以还需要一套线上用户代码覆盖率的统计方案。

        下面我将从androids应用的线上代码覆盖率统计切入,分享优酷的无用代码治理的技术思考和落地方案。

        和无用代码说再见!阿里文娱无损代码覆盖率统计方案(阿里文娱app)

        传统采集方案

        首先,在需要统计的代码处加上统计代码。当代码被执行时,进行统计和上报。应用的代码行数通常都数以万计 ,手动添加显然是不现实的,所以一般会在构建阶段通过面向切面编程(AOP)来插入统计代码(以下简称为插桩),可以借助一些成熟的AOP中间件完成 ,例如,Jacoco 、ASM。

        其次,需要思考是 ,我们期望采集的粒度是什么 ?一般来说,粒度从细到粗分为 :指令 、分支、方法 、类级别,粒度越细 ,代码覆盖率结果越准确 ,但性能损耗也越大。例如 ,如果想采集的粒度为指令级别 ,就需要对每个指令进行插桩,但这种插装会导致指令数也翻倍,安装包增大并且运行时性能下降。

        优酷曾尝试过用Jacoco进行分支粒度的插桩,当时希望覆盖尽量多的用户,因为覆盖的用户越多结果越准确。但经测试 ,此方案使安装包增大10M,运行时性能严重恶化 ,果断放弃了此方案 。

        为了权衡性能和采集粒度 ,目前我们一般都采取类级别粒度的插桩,一方面是因为这样对性能影响较小,另一方面过细的采集粒度反而会加重业务方治理的难度 。但此方案还不够完美 :

        1)运行时性能:当类首次加载时会执行统计代码 ,App启动过程会加载成千上万个类  ,会对启动性能造成一定影响 ;

        2)包大小 :有多少个类,就会插入多少行统计代码,对于像优酷这种大型App,也会增加不少的安装包大小;

        3)构建耗时:因为构建过程中需要对每个类进行插桩 ,增加了构建耗时 ;

        和无用代码说再见!阿里文娱无损代码覆盖率统计方案(阿里文娱app)

        新采集方案—SlimLady

        目标

        优酷希望有一套方案可以无损地采集线上代码覆盖率,核心目标如下:

        1. 运行时性能:无任何影响;

        2. 包大小 :无任何影响;

        3. 构建耗时 :无任何影响;

        实现

        通过研究源码 ,发现可以通过动态查询DVM虚拟机已加载类的信息来获取类级别的代码覆盖率,下图中“覆盖率采集”部分即为SlimLady采集的原理图,这里我们只关注这部分,其他部分将在后面的整体方案中进行讲解。

        和无用代码说再见!阿里文娱无损代码覆盖率统计方案(阿里文娱app)

        ClassTable

        Java虚拟机规范规定,类在使用前要先被虚拟机加载。androids中,是通过ClassLoader来完成类加载的,最后保存在Native层的ClassTable中,所以如果我们获取了所有ClassLoader的ClassTable对象 ,就有可能判断出虚拟机加载了哪些类。

        首先,获取所有的ClassLoader对象。对于APK中的类 ,如果无特殊声明 ,一般都会被默认的PathClassLoader加载 ;对于动态加载的类,需要在自定义的ClassLoader中加载 ,例如Atlas会为每个Bundle创建一个相应的ClassLoader ,通过这个ClassLoader来加载Bundle中的类。一旦明确了App中用到了哪些ClassLoder,获取是易如反掌的

        其次,通过ClassLoader来获取ClassTable的对象的地址。通过Java层ClassLoader类的源码可知,ClassLoader有一个成员变量classTable(7.0及以上版本),这个变量保存了Native层ClassTable对象的地址 ,我们可以通过反射获取这个地址:

        ClassLoader classLoader = XXX;
        Field classTableField = ClassLoader.class.getDeclaredField("classTable");
        classTableField.setAccessible(true);
        long classTableAddr = classTableField.getLong(classLoader);

        但在9.0的系统中成员变量classTable被加入了深灰名单,限制了直接反射,需要通过系统类进行反射绕过此限制:

        ClassLoader classLoader = XXX;
        Method metaGetDeclaredField = Class.class.getDeclaredMethod("getDeclaredField", String.class);
        Field classTableField = (Field) metaGetDeclaredField.invoke(ClassLoader.class, "classTable");
        classTableField.setAccessible(true);
        long classTableAddr = classTableField.getLong(classLoader);

        至此,我们就获得了所有的ClassTable对象的地址,里面保存了全部的类加载信息。

        类名列表

        通过阅读源码发现ClassTable有个方法可以通过类名查询类是否被加载过(下节将详细介绍),这样我们只需要获得所有类名的列表,再调用那个方法,即可以判断类是否被加载过。

        APK中的类名列表可以通过DexFile进行获取,如下:

        List<String> classes = new ArrayList<>;
        DexFile df = new DexFile(context.getPackageCodePath);
        for (Enumeration<String> iter = df.entries; iter.hasMoreElements; ) {
        classes.add(iter.nextElement);
        }

        同理 ,动态加载的类也可以通过DexFile获取 ;

        类是否被加载

        通过阅读源码发现class_table.cc中 ,ClassTable有个Lookup方法,传入类名和类名的hash值 ,返回类对象的地址,如下:

        mirror::Class* ClassTable::Lookup(const char* descriptor, size_t hash)

        如果返回值为ptr ,说明没有加载过此类,否则 ,说明加载过 。

        mirror::Class* ClassTable::Lookup(const char* descriptor, size_t hash)

        获取此方法地址的方法 :

        1. 加载so:class_table.cc在libart.so中 ,所有我们需要用dlopen加载libart.so获得此so的handler 。其实在加载前,libart.so在当前进程一定已经被加载过了 ,此次加载只是为了获得handler,并不耗时;

        2. 符号表 :通过readelf查询Lookup的符号:_ZN3art10ClassTable6LookupEPKcj ;

        3. 方法指针:调用dlsym,传入handler和符号表,即可以找到Lookup方法的地址;

        注 :从7.0系统开始,Google禁止了调用系统Native的API ,这里我们通过/proc/self/maps找到libart.so的地址,将里面的符号表进行拷贝 ,进而绕过此限制 ;

        至此,我们就可以通过调用ClassTable的Lookup方法,传入类名和hash值,判断类是否被加载过了。

        总结

        这样,我们就能知道某一时刻有哪些类被加载过,对其上传,进行聚合和处理,再通过对比所有类名列表,就能得到代码覆盖率数据了。此方案不需要插桩,所以可以无损地采集覆盖率 。

        和无用代码说再见!阿里文娱无损代码覆盖率统计方案(阿里文娱app)

        新方案整体设计

        上面提到的采集方案是整个方案的核心,除此之外还有上下游的配套流程,整体方案的设计如下图 :

        和无用代码说再见!阿里文娱无损代码覆盖率统计方案(阿里文娱app)

        1)APK分发:通过构建中心构建出最新的APK,分发给用户 ;

        2)触发采集 :用户安装应用,在使用过程中,将APP退后台10s后,通过采样率计算是否命中,若命中,则触发代码覆盖率采集

        3)配置下发 :在需要时,可以通过配置中心下发配置来动态调整功能开关、采样率等配置;

        4)数据采集 :代码覆盖率采集中间件(SlimLady)统计出被加载的类,将已加载的类名保存在文件中 ,进行压缩,将压缩后的数据传给上传中间件;

        5)数据上传 :上传中间件将数据上传到云端;

        6)数据下载 :服务器定期对云端数据进行下载 ;

        7)类信息提供 :服务器从构建中心获取类信息,包括所有类名列表和混淆文件 ;

        8)数据解析 :服务器按版本对代码覆盖率数据进行解压、反混淆、聚合统计 ,聚合统计后的结果包含了被加载过的类及次数,与所有类名列表进行对比 ,即可以知道哪些类没有被加载过,将结果保存至数据库;

        9)结果聚合 :网页端从数据库读取聚合结果 ,按模块展示代码覆盖率、模块热度、模块大小等信息。

        和无用代码说再见!阿里文娱无损代码覆盖率统计方案(阿里文娱app)

        总结

        本方案突破了传统的插桩埋点统计,动态获取虚拟机信息,无损地采集代码覆盖率。有了代码覆盖率数据 ,能做的治理有很多 ,例如 :下线无用代码 、模块 ;瘦身或下线调用低频 、体积大的模块;在集成阶段添加代码覆盖率卡口等等 。

        相关新闻

        联系我们
        联系我们
        分享本页
        返回顶部

          XML地图