性能优化

532 阅读21分钟

一、性能优化的重要性及方向

1、性能优化的重要性及意义

  • 因为其决定了应用程序的开发质量:可用性、流畅性、稳定性等,是提高用户留存率,转化率的关键。

2、优化原则

  • 权衡利弊:在能够保证产品稳定、按时完成需求的前提下去优化。
  • 使用低配置的设置:同样的程序,在低端配置的设备中,相同的问题会暴露得更为明显。
  • 评估性能优化的效果,应该保持足够多的测量,用数据说话(使用各种性能工具测试及快速定位问题)。

3、优化方法

  • 了解问题(分为可感知和不可感知的性能问题):对于性能问题来讲,这个步骤只适用于某些明显的性能问题,很多无法感知的性能问题需要通过工具定位。例如:内存泄漏、层级冗余、过度绘制等无法感知。滑动卡顿是可以感知的。
  • 定位问题:通过工具检测、数据分析、定位在什么地方存在性能问题。
  • 分析问题:找到问题后,分析针对这个问题该如何解决,确定解决方案。
  • 解决问题:根据分析结果寻找解决方案。

4、优化性能四个方向

性能问题的主要原因有相同的,也有不同的,但归根到底,不外乎内存使用、代码效率、核实的策略逻辑、代码质量、安装包体积这一类问题。

image.png

从途中可以看到,打造一个高质量的应用应该以4个方向为目标:快、稳、省、小。

  • 快:使用时避免 出现卡顿,响应速度快,减少用户等待的时间,满足用户期待。
  • 稳:减低crash率,不要在用户使用过程中崩溃和无响应。
  • 省:节省流量和耗电,减少用户使用成本,避免使用时导致手机发烫。
  • 小:安装包小可以降低用户的安装成本。

5、流畅性优化方向

  • 优化原因:利于减少使用中的卡顿、响应时间久等问题,给与用于一个操作流畅的体验。
  • 优化方向:主要利于3个方面优化:启动速度、页面显示速度、响应速度。

image.png (1)页面显示速度

  • 优化原因(即页面显示速度慢的原因)
    • 页面需绘制的内容(布局&控件)太多,从而导致页面测量时间过长
    • 绘制效率过低,从而导致绘制时间过长
  • 优化方案
    • 布局层级优化:把线性布局转换成constrainlayout约束布局,把布局层级给他拍平,在复杂布局中效果十分明显。
    • 异步加载xml:使用AsyncInflater异步布局解析器来解析xml文件,从而释放主线程的压力,在复杂布局中效果也是十分明显的。
    • 离屏预渲染:比如首页的数据和列表上需要的view在闪屏页或者说在启动阶段就使用线程提前预加载缓存的数据并创建好缓存的view,放在一个view库中,然后等进入首页列表之后,就从view库中取出预先创建好的view交给recyclerview就可以了,这种方法在秒开里面是十分明显的。

(2)启动速度优化

  • 优化原因(启动速度慢的原因)初次打开应用时,大量初始化任务 or 需加载很多资源
  • 优化方案:
    • 异步并发初始化
    • 分步初始化、延时初始的策略,减少启动应用时加载的任务,从而提高启动速度

(3)响应速度

应用程序出现响应慢的情况,会出现ANR

  • 优化原因
    • 在5s内未响应用户的输入时间(按键、触摸输入)、在10s内未处理完BroadcastReceiver接收到的事件、在20s内未处理完Service接收到的事件、多线程锁竞争
    • 主线程阻塞、挂起、死循环、执行比较长的耗时操作、其他线程(子线程)对CPU的时间占用率过高,导致主进程(线程)抢不到CPU的时间片。
  • 优化方案
    • 当发生ANR的时候,ActivityManagerService(AMS)会把ANR信息写到LogCat日志中。主要通过ANRManager、ActivityManager等字段过滤出我们想要的信息
    • 线上阶段可以使用爱奇艺开源的xcrash

image.png xcrash:可以收集JAVA、native和ANR的错误信息,实现方案是通过C++实现的,原理都是系统发生了ANR crash之后会发出相应的signal信号量,我们可以在C++层注册信号量,然后在回调里面dom出堆栈信息。

6、内存优化

做内存优化前,需要了解当前应用的内存使用现状,通过现状去分析哪些数据类型有问题,各种类型的分步情况如何,以及在发现问题后如何发现是哪些具体对象导致的,这就需要相关工具来帮助我们。

  • Memory Profiler(tools profiler) MAT是一个快速,功能丰富的Java Heap 分析工具,通过分析Java进程的内存快照HPROF分析,从众多的对象中分析,快速计算出内存中对象占用的大小,查看哪些对象不能被垃圾收集器回收,并可以通过视图直观低查看可能造成这种结果的对象。
  • 内存抖动
  • 内存占用趋势
  • 内存泄漏链路

常见内存泄漏场景

如果在内存泄漏发生后再去找原因并修复会增加开发的成本,最好在编写代码时就能够很好地考虑内存问题,写出更高质量的代码,这里列出一些常见的内存泄漏场景,在以后的开发过程中需要避免这类问题。

  1. 资源性对象未关闭:比如Cursor、File文件等,往往都用了一些缓冲,在不使用时,应该及时关闭他们。
  2. 注册对象未注销:比如事件注册后未注销,会导致观察者列表中维持着对象的引用
  3. 单例引用短生命周期对象
  4. 非静态内部类的静态实例
  5. handler临时性内存泄漏:如果Handler是非静态的,容易导致Activity或Service不会被回收
  6. WebView:WebView存在着内存泄漏的问题,在应用中只要使用一次WebView,内存就不会被释放掉

优化内存空间

没有内存泄漏,并不意味着内存就不需要优化,在移动设备上,由于物理设备的存储空间有限,Android系统对每个应用进程也都分配了有限的堆内存,因此使用最小内存对象或者资源可以减小内存开销,同时让GC能更高效地回收不再需要使用的对象,让应用堆内存保持充足的可用内存,使应用更稳定高效地运行。

image.png

线上内存监控方案

  • LeakCanary
  • 微信的Matrix
  • 美团的Probe
  • 快手KOOM

7、稳定性优化

Android应用的稳定性定义很宽泛,影响稳定性的原因很多,比如内存使用不合理、代码异常场景考虑不周全、代码异常场景考虑不周全、代码逻辑不合理等,都会对应用的稳定性造成影响。其中常见的两个场景:Crash,这个错误将会使得程序无法使用,比较常用的解决方式如下:

image.png

8、流量优化

流量优化针对于下沉市场的用户意义重大,他们不都是无线连接,无线流量。减少数据量也可以加快页面页面渲染速度

  • 主要通过缓存减少网络流量,采用三级缓存方案:即内存缓存 - 硬盘缓存 - cdn。
  • 升级https协议到https2.0:因为http2.0协议里面对heared部分的数据做了大幅度的压缩,同时body部分做了二进制的分贞,多路传输,无论是流量还是传输效率都比http1.1提升很多。
  • 打包网络请求:在一个页面里面如果存在多个接口的请求,那么这些数据可以让服务端同学帮忙组合起来,用一个接口去请求就可以了。
  • 调整数据传输:根据网络不同的状态,response返回的数据可以是不同的,如WIFI下返回30条,5G下返回15条,3G下返回10条。
  • IP直连与HttpDns
  • 大文件处理断点续传
  • 数据上传时压缩

9、安装包大小优化

应用安装包大小对应用使用没有影响,但应用的安装包越大,用户下载的门槛越高,特别是在移动网络情况下,用户在下载应用时,对安装包大小的要求更高,因此,减少安装包大小可以让更多用户愿意下载和体验产品

image.png 减少安装包大小的常用方案

image.png

总结

性能优化不是更新一两个版本就可以解决的,是持续性的需求,持续集成迭代反馈。在实际的项目中,在项目刚开始的时候,由于人力和项目完成时间限制,性能优化的优先级比较低,等进入项目投入使用阶段,就需要把优先级提高,但在项目初期,在设计架构方案时,性能优化的点也需要提早考虑进去,这就体现出一个程序员的技术功底了。

什么时候开始有性能功能的需求,往往都是从发现问题开始,然后分析问题及背景,进而寻找最优解决方案,最终解决问题,这也是日常工作中常会用到的处理方式。

二、启动优化之耗时统计&启动白屏优化

1、启动耗时统计

冷启动流程可以分为三步:创建进程、启动应用和绘制界面。

(1) 创建进程

主要做了下面三件事,这三件事都是系统做的。

  • 加载startingWindow
  • 创建应用进程
  • 启动App

(2)启动应用

启动应用阶段主要做了下面三件事,从这些开始,随后的任务和我们自己写的代码有一定的关系。

  • 启动主线程
  • 创建Application
  • 创建MainActivity

(3)绘制界面

主要做了下面三件事:

  • 加载布局
  • 首帧绘制

image.png 统计方法一:打点统计启动耗时

1.application
attachBaseContext:          0ms
attachBaseContext(end):     13ms
onCreate:                   85ms
onCreate(end):              300ms

2.MainActivity
onCreate:                   370ms
onCreate(end):              667ms
onResume(end):              777ms
onWindowFocusChanged(end):  2s215msms  //xml渲染成view tree,用户可交互

3.RecyclerView:BannerItem.onPreDrawCallback
onFirstDraw:                 2s425ms   //response数据返回,第一帧开始渲染

统计方法二:systrace统计启动耗时

分析系统给关键方法和应用方法耗时

  • 系统在重要模块的关键方法(Framework,SystemServer,Android UI Component)插入代码段(Label)
  • 通过Label的开始和结束来确定某个核心过程的执行时间,把这些Label信息收集起来得到系统关键路径的运行时间信息,最后得到整个系统的运行性能信息;
  • Android Framework里面一些重要的模块都插入了label信息,用户App中可以添加自定义的Lable。 (1)指定统计时间为5s内所有的操作

shced:cpu调度信息

gfx:图形信息

view:视图

wm:窗口管理

am:活动管理

app:应用信息

webview:WebView信息

-a:指定目标应用程序的包名

-o:生成的systrace.html文件

$ cd ANDROID_HOME/plateform_tools/systrace //切换到systrace工作所在的目录
$ python systrace.py -t 5 sched gfx view wm am app -a 包名 -o start.html

在Slice标签下的耗时信息包括Wall Duration 和 CPU Duration,下面是他们的区别。

  • Wall Duration

    Wall Time 是执行这段代码耗费CPU的时间

    例如我们的代码要进入锁的临界区,如果锁被其他线程持有,当前线程就进入了阻塞状态,而等待的时间是会被计算到Wall Time中的。

  • CPU Duration

    CPU Duration是CPU真正花在这段代码上的时间,是我们关心的优化指标

    但如果Wall Duration 和 CPU Duration相差比较大,就要考虑该方法是否有延迟,阻塞主线程的情况。

制定优化的目标 由于App启动速度在不同的设备上差别很大,所以目标不太好定,但是做事情总得要有个目标吧。设备分类device-year-class(高端机、中端机、低端机)指定不同的目标。

  • 高端机型1.5秒内打开(比如华为mate40)
  • 中端机型2秒内打开(比如小米8)
  • 低端机型3秒内打开(比如vivo y67)
int year = YearClass.get(getApplicationContext());
if (year >= 2020) {
    
} else if (year > 2018) {
    
} else {
    
}

2、启动白屏优化

在styles.xml文件中添加背景图

// drawable/launch_splash.xml
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">

    <item android:drawable="@color/color_white"/>
    <item 
        android:drawable="@mipmap/launch_splash_center"
        android:gravity="center"/>
</layer-list>
<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Theme.MyApplication" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
        <!-- Primary brand color. -->
        <item name="colorPrimary">@color/purple_500</item>
        <item name="colorPrimaryVariant">@color/purple_700</item>
        <item name="colorOnPrimary">@color/white</item>
        <!-- Secondary brand color. -->
        <item name="colorSecondary">@color/teal_200</item>
        <item name="colorSecondaryVariant">@color/teal_700</item>
        <item name="colorOnSecondary">@color/black</item>
        <!-- Status bar color. -->
        <item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
        <!-- Customize your theme here. -->
    </style>
    
    <style name="LaunchTheme" parent="Theme.MyApplication">
        <item name="android:windowBackground">@drawable/launch_splash</item>
    </style>
</resources>

三、异步并发启动框架

在应用启动的时候,我们通常会有很多工作需要做,为了提高启动速度,我们会尽可能让这些工作并发进行。但这些工作之间可能存在前后依赖的关系,所以我们又需要想办法保证他们执行顺序的正确性。

image.png Taskflow类关系图

image.png

说明
Task启动阶段的每一个需要被初始化的任务\n可以是同步阻塞执行,异步执行,延迟执行
TaskState任务运行时的状态
Project任务组,管理一组Task的依赖关系,先后执行顺序\n不允许直接构建
ITaskCreator面向接口,使用方使用它来创建具体的任务
Project.Builder使用它来构建Project任务组对象
Project.TaskFactory使用它配合着ITaskCreator来存储,创建Task任务
TaskRuntime根据不同的策略,调度task的管理类
TaskFlowManager对TaskRuntime的进一步包装,对外暴露的类

github项目地址

使用示例

object TaskStartUp {
    const val TASK_BLOCK_1 = "block_task_1"
    const val TASK_BLOCK_2 = "block_task_2"
    const val TASK_BLOCK_3 = "block_task_3"

    const val TASK_ASYNC_1 = "async_task_1"
    const val TASK_ASYNC_2 = "async_task_2"
    const val TASK_ASYNC_3 = "async_task_3"

    @JvmStatic
    fun start() {
        // 指定任务组的名称,并创建ITaskCreator用于Taskflow按需创建Task
        val project = Project.Builder("TaskStartUp", createTaskCreator())
            .add(TASK_BLOCK_1)
            .add(TASK_BLOCK_2)
            .add(TASK_BLOCK_3)
                //添加任务-------添加依赖
            .add(TASK_ASYNC_1).dependOn(TASK_BLOCK_1)
            .add(TASK_ASYNC_2).dependOn(TASK_BLOCK_2)
            .add(TASK_ASYNC_3).dependOn(TASK_BLOCK_3)
            .build()

        TaskFlowManager
                //指定哪些任务是必须初始化完成才能拉起launchActivity的
            .addBlockTask(TASK_BLOCK_1)
            .addBlockTask(TASK_BLOCK_2)
            .addBlockTask(TASK_BLOCK_3)
                //启动任务组
            .start(project)
    }

    private fun createTaskCreator(): ITaskCreator {
        return object : ITaskCreator {
            override fun createTask(taskName: String): Task {
                when (taskName) {
                    TASK_ASYNC_1 -> return createTask(taskName, true)
                    TASK_ASYNC_2 -> return createTask(taskName, true)
                    TASK_ASYNC_3 -> return createTask(taskName, true)

                    TASK_BLOCK_1 -> return createTask(taskName, false)
                    TASK_BLOCK_2 -> return createTask(taskName, false)
                    TASK_BLOCK_3 -> return createTask(taskName, false)
                }
                return createTask("default", false)
            }
        }
    }

    fun createTask(taskName: String, isAsync: Boolean): Task {
        return object : Task(taskName, isAsync) {
            override fun run(name: String) {
                Thread.sleep(if (isAsync) 2000 else 1000)
                Log.e("TaskStartUp", "task $taskName, $isAsync,finished")
            }
        }
    }
}

运行时状态日志输出

image.png

四、页面加载耗时优化

1、cpu profiler查看方法耗时

image.png Top Down 标签显示一个调用列表,在该列表中展开方法或函数节点会显示它的被调用方。图中的箭头都是从调用方指向被调用方。

  • Self:方法或函数调用在执行自己的代码(而非被调用方的代码)上所花的时间
  • Children:方法或函数调用在执行它的被调用方(而非自己的代码)上所花的时间
  • Total:方法的Self时间和Children时间的总和。这表示应用在执行调用时所用的总时间 BottomUp 标签页显示一个调用列表,在该列表中展开函数或方法的节点会显示他的调用方。

下图提供了方法C的"Bottom Up"树。在该"Bottom Up"树中打开方法C的节点会显示它独有的各个调用方,即方法B和D。请注意,尽管B调用C两次,但在"Bottom Up"树中展开方法C的节点时,B仅显示一次。在此之后,展开B的节点会显示它的调用方,即方法A和D。

Bottom Up 标签页用于按照占用的CPU时间由多到少(或由少到多)的顺序对方法或函数排序。您可以检查每个节点以确定哪些调用方在调用这些方法或函数上所花的CPU时间最多。与"Top Down"树相比,"Bottom Up"树中每个方法或函数的时间信息参照的是每个树顶部的方法(顶部节点)。CPU时间也可表示为在该记录期间占线程总时间的百分比。

image.png FlameChart 标签页提供一个倒置的调用图标,将具有相同调用顺序的完全相同的方法收集起来,并在火焰图中将它们表示为一个较长的横条(而不是将它们显示为多个较短的横条,如调用图表中所示)。这样更方便查看哪些方法或函数消耗的时间最多。

image.png

2、aop切面编程统计方法耗时

AspectJ Aop (Aspect Oriented Programming)切面编程 面向切面编程,通过编译期植入代码段和运行期动态代理实现程序功能的一种技术。

(1)作用

利用AOP可以对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合性降低,提高程序的可重用性,同时大大提高了开发效率。

image.png

(2)AOP核心概念

  • 连接点(JoinPoint):被拦截到的点(方法、字段、构造器)。
  • 切入点(PointCut):对JoinPoint进行拦截的定义。
  • 通知(Advice):拦截到JoinPoint后要执行的代码,分为前置、后置、环绕三种类型。 首先,为了在Android使用AOP埋点需要引入AspectJ,在项目根目录的build.gradle下加入:(github开源地址)
classpath 'com.hujiang.aspectjx:gradle-android-plugin-aspectjx:2.0.8'

然后,在app目录下的build.gradle下加入:

apply plugin: 'android-aspectjx'
//或者这样也可以
apply plugin: 'com.hujiang.android-aspectjx'

AOP埋点实战JoinPoint一般定位在如下位置:

  • Before:PointCut之前执行
  • After:PointCut之后执行
  • Around:PointCut之前、之后分别执行
// 在execution中的是一个匹配规则,第一个*代表匹配任意的方法返回值类型
// 后面的语法代码匹配所有Activity中on开头的任意方法
@Before("execution(* android.app.Activity.on**(..))")
public void onActivityMethodBefore(JoinPoint joinPoint) throws Throwable {
    //在这里可以为 插入任意代码段(耗时统计,log日志,登录判断...)
}
@Around("call(* android.widget.Toast.setText(java.lang.CharSequence))")
public void handleToastText(ProceedingJoinPoint proceedingJoinPoint){
    Log.d(TAG, "start handleToastText");
    proceedingJoinPoint.proceed(new Object[]{"处理过的toast"}); //这里把他的参数换了
    Log.d(TAG, "end handleToastText");
}
@Aspect
public void ActivityAspectJ {
    @Around("execution(* android.app.Activity.**(..))")
    public void getTime(ProceedingJoinPoint joinPoint) {
         Signature signature = joinPoint.getSignature();
         String name = signature.toShortString();
         long time - System.currentTimeMillis();
         //上面三行代码会被插入到 Activity 中任意方法的最前面执行
         
         joinPoint.proceed();//开始执行原方法的代码
         
         //这行代码在原方法之后执行,输出方法的执行耗时
         Log.e(TAG, name + "cost" + (System.currentTimeMillis() - time));
    }
}
  • Method Signature 表达式格式说明及常用方法 @注解(before,after,around)、访问权限(call,execution,set,get)、返回值的类型(Object,Int)、包名、函数名(参数),例子:

    @before(execution(* android.app.Activity.on**(..))) @Around("execution(void android.view.View.OnClickListener.onClick(..))") @Around("execution(.onTouch(..))") @Around("execution(android.app.Activity.(..))") @Around("execution(android.widget.Toast.show())") 任意公共方法的执行:execution(public * (..)) 任何一个以set开始的方法的执行:execution( set(..)) 接口或类 com.a.b.Demo 中的任意方法的执行:execution(* com.a.b.Demo.(..)) 定义在 com.a.b 包里的任意方法的执行:execution( com.a.b..(..)) 定义在 com.a.b 包和所有子包里的任意类的任意方法的执行:execution(* com.a.b...(..)) 定义在 com.a.b 包和所有子包里的Demo类的任意方法的执行:execution(* com.a.b..Demo.(..)) 方法和构造器特定的某个方法:public void Accout.debit(float) throws InsufficientBalanceException Account中以set开头,并且只有一个参数类型的方法:public void Account.set() Account中所有的没有参数的public void 方法:public void Account.() Account中所有没有参数的public方法:public * Account.() Account中所有的public方法:public * Account.(..) Account中所有的方法:* Account.(..) Account中的所有的public方法,包括子类的方法: Account*.(..) Account中所有的非public方法:!public * Account.(..) 所有的read方法:* java.io.Reader.read(..) 所有以read(char[])开始的方法,包括read(char[])和read(char[], int, int):* java.io.Reader.read(char[], ..) 所有以add开始以Listener结尾的方法,参数为EventListener或子类:* javax...addListener(EventListener) 抛出RemoteException的所有方法:* .(..) throws RemoteException (3)JoinPoint连接点

image.png

(4)AOP的场景有哪些

  • 无痕埋点--分离业务代码和统计代码
  • 安全控制--比如全局的登录状态流程控制
  • 日志记录--侵入性更低更利于管控的日志系统
  • 事件防抖--防止View被连续点击触发多次事件
  • 性能统计--检测方法耗时。可以采用AOP思想对每个方法做一个切点,在执行之后打印方法耗时

3、页面滑动流畅度FPS

internal class FrameMonitor : Choreographer.FrameCallback {
    private val choreographer = Choreographer.getInstance()
    private var frameStartTime: Long = 0//这个是记录 上一针到达的时间戳
    private var frameCount = 0//1s 内确切绘制了多少帧

    private var listeners = arrayListOf<FpsMonitor.FpsCallback>()
    override fun doFrame(frameTimeNanos: Long) {
        val currentTimeMills = TimeUnit.NANOSECONDS.toMillis(frameTimeNanos)
        if (frameStartTime > 0) {
            //计算两针之间的 时间差
            // 500ms  100ms
            val timeSpan = currentTimeMills - frameStartTime
            //fps 每秒多少帧  frame per second
            frameCount++
            if (timeSpan > 1000) {
                val fps = frameCount * 1000 / timeSpan.toDouble()
                HiLog.e("FrameMonitor", fps)
                for (listener in listeners) {
                    listener.onFrame(fps)
                }
                frameCount = 0
                frameStartTime = currentTimeMills
            }
        } else {
            frameStartTime = currentTimeMills
        }
        start()
    }

    fun start() {
        choreographer.postFrameCallback(this)
    }

    fun stop() {
        frameStartTime = 0
        listeners.clear()
        choreographer.removeFrameCallback(this)
    }

    fun addListener(l: FpsMonitor.FpsCallback) {
        listeners.add(l)
    }
}
object FpsMonitor {
    private val fpsViewer = FpsViewer()
    fun toggle() {
        fpsViewer.toggle()
    }

    fun listener(callback: FpsCallback) {
        fpsViewer.addListener(callback)
    }

    interface FpsCallback {
        fun onFrame(fps: Double)
    }

    private class FpsViewer {
        private var params = WindowManager.LayoutParams()
        private var isPlaying = false
        private val application: Application = AppGlobals.get()!!
        private var fpsView =
            LayoutInflater.from(application).inflate(R.layout.fps_view, null, false) as TextView

        private val decimal = DecimalFormat("#.0 fps")
        private var windowManager: WindowManager? = null

        private val frameMonitor = FrameMonitor()

        init {
            windowManager = application.getSystemService(Context.WINDOW_SERVICE) as WindowManager

            params.width = WindowManager.LayoutParams.WRAP_CONTENT
            params.height = WindowManager.LayoutParams.WRAP_CONTENT

            params.flags =
                WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL

            params.format = PixelFormat.TRANSLUCENT
            params.gravity = Gravity.RIGHT or Gravity.TOP

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                params.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
            } else {
                params.type = WindowManager.LayoutParams.TYPE_TOAST
            }

            frameMonitor.addListener(object : FpsCallback {
                override fun onFrame(fps: Double) {
                    fpsView.text = decimal.format(fps)
                }
            })

            ActivityManager.instance.addFrontBackCallback(object :
                ActivityManager.FrontBackCallback {
                override fun onChanged(front: Boolean) {
                    if (front) {
                        play()
                    } else {
                        stop();
                    }
                }

            })
        }

        private fun stop() {
            frameMonitor.stop()
            if (isPlaying) {
                isPlaying = false
                windowManager!!.removeView(fpsView)
            }
        }

        private fun play() {
            if (!hasOverlayPermission()) {
                startOverlaySettingActivity()
                HiLog.e("app has no overlay permission")
                return
            }

            frameMonitor.start()
            if (!isPlaying) {
                isPlaying = true
                windowManager!!.addView(fpsView, params)
            }
        }

        private fun startOverlaySettingActivity() {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                application.startActivity(
                    Intent(
                        Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
                        Uri.parse("package:" + application.packageName)
                    ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
                )
            }
        }

        private fun hasOverlayPermission(): Boolean {
            return Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(
                application
            )
        }

        fun toggle() {
            if (isPlaying) {
                stop()
            } else {
                play()
            }
        }

        fun addListener(callback: FpsCallback) {
            frameMonitor.addListener(callback)
        }
    }
}

五、不合理大图检测插件

1、一张图片加载到内存到底会占多少内存?

分别从如下的几种考虑点相互组合的场景中,加载同一张图片,看一下占用的内存空间大小分别是多少:

  • 图片的不同来源:磁盘、res资源文件
  • 图片文件的不同格式:png、jpg
  • 不同的Android系统设备 加载分辨率为1080*452 的 png 格式的图片,图片文件本身大小 56KB

如果按照图片大小的计算公式:分辨率*像素点大小1080 * 452 * 4B = 1952640B = 1.86MB

这里像素点大小以4B来计算是因为,当没有特别指定时,系统默认为ARGB_8888作为像素点的数据格式,其他的格式如下:

  • ALPHA_8 -- (1B)
  • RGB_565 -- (2B)
  • ARGB_4444 -- (2B)
  • ARGB_8888 -- (4B)
  • RGBA_F16 -- (8B)
private void loadResImage(ImageView imageView) {
    BitmapFactory.Options options = new BitmapFactory.Options();
    Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.splash, options);
    imageView.setImageBitmap(bitmap);
    Log.e("TAG", "bitmap: ByteCount = " + bitmap.getByteCount() + ":::bitmap:AllocationByteCount = " + bitmap.getAllocationByteCount());
}
  • dpi=240加载各个res目录下的图片

image.png

  • dpi=180加载各个res目录下的图片

image.png

  • 不同设备加载、磁盘加载

image.png 如果去看下Bitmap.decodeResource()源码,会发现,系统在加载res目录下的资源图片时,会根据图片存放的不同目录做一次分辨率的转换,而转换的规则是: 新图的高度 = 原图高度 * (设备的dpi/目录对应的dpi)

目录名称与dpi的对应关系如下,drawable没带后缀对应的160dpi:

image.png 位于res内的不同资源目录中的图片,当加载进内存时,会先经过一次分辨率的转换,然后再计算大小,转换的影响因素是设备的dpi和不同的资源目录。

2、图片资源的优化

  • 降低分辨率 如果能够让系统在加载图片时,不以原图分辨率为准,而是降低一定的比例,那么,自然也就能够达到减少图片内存的效果。 同样的系统提供了相关的API: BitmapFactory.Options.inSampleSize

设置inSampleSize之后,Bitmap的宽、高都会缩小inSampleSize值。例如:一张宽高为2048 * 1536 的图片,设置inSampleSize为4之后,实际加载到内存中的图片宽高是512*384,占有的内存就是0.75M而不是12M,足足节省了15倍。

  • 减少每个像素点大小 毕竟系统默认是以ARGB_8888格式进行处理,那么每个像素点就要占据4B的大小,改变这个格式自然就能降低图片占据内存的大小。

常见的是,将 ARGB_8888 换成 RGB_565 格式,但后者不支持透明度,所以此方案并不通用,取决于你 app 中图片的透明度需求,当然也可以缓存ARGB_4444,但会降低质量。

3、不合理大图检测插件

(1)常规代码

private void setImageDrawable(Drawable drawable) {
    //宽高 可能还是0 view.post
    post(new RunnableImpl(this, drawable));
}

class RunnableImpl implements Runnable {
    android.view.View view;
    android.graphics.drawable.Drawable drawable;

    public RunnableImpl(View view, Drawable drawable) {
        this.view = view;
        this.drawable = drawable;
    }

    @Override
    public void run() {
        int width = view.getWidth();
        int height = view.getHeight();

        int drawableWidth = drawable.getIntrinsicWidth();
        int drawableHeight = drawable.getIntrinsicHeight();

        if (width > 0 && height > 0) {
            if (drawableWidth >= 2 * width && drawableHeight >= 2 * height) {
                android.util.Log.e("LargeBitmapChecker", "bitmap:[" + drawableWidth + "," + drawableHeight + "],view:[" + width + "," + height + "],className:" + getContext().getClass().getSimpleName());
            }
        }
        android.util.Log.e("LargeBitmapChecker", "bitmap:[" + drawableWidth + "," + drawableHeight + "],view:[" + width + "," + height + "],className:" + getContext().getClass().getSimpleName());
    }
}

(2)字节码插桩方式

class TinyPngPTransform extends Transform {
    private ClassPool classPool = ClassPool.getDefault()

    TinyPngPTransform(Project project) {
        //为了能够查找到android 相关的类,需要把android.jar包的路径添加到classPool  类搜索路径
        classPool.appendClassPath(project.android.bootClasspath[0].toString())

        classPool.importPackage("android.os.Bundle")
        classPool.importPackage("android.widget.Toast")
        classPool.importPackage("android.app.Activity")

        classPool.importPackage("java.lang.Runnable")
        classPool.importPackage("android.widget.ImageView")
        classPool.importPackage("androidx.appcompat.widget.AppCompatImageView")
        classPool.importPackage("android.graphics.drawable.Drawable")
    }

    @Override
    String getName() {
        return "TinyPngPTransform"
    }

    @Override
    Set<QualifiedContent.ContentType> getInputTypes() {
        //接收的输入数据的类型
        return TransformManager.CONTENT_CLASS
    }

    @Override
    Set<? super QualifiedContent.Scope> getScopes() {
        return TransformManager.SCOPE_FULL_PROJECT
    }

    @Override
    boolean isIncremental() {
        return false
    }

    @Override
    void transform(TransformInvocation transformInvocation) throws TransformException, InterruptedException, IOException {
        //1. 对inputs -->directory-->class 文件进行遍历
        //2 .对inputs -->jar-->class 文件进行遍历
        //3. //符合我们的项目包名,并且class文件的路径包含Activity.class结尾,还不能是buildconfig.class,R.class $.class

        def outputProvider = transformInvocation.outputProvider
        transformInvocation.inputs.each { input ->

            input.directoryInputs.each { dirInput ->
                println("dirInput abs file path:" + dirInput.file.absolutePath)
                handleDirectory(dirInput.file)

                //把input->dir->class-->dest目标目录下去。
                def dest = outputProvider.getContentLocation(dirInput.name, dirInput.contentTypes, dirInput.scopes, Format.DIRECTORY)
                FileUtils.copyDirectory(dirInput.file, dest)
            }

            input.jarInputs.each { jarInputs ->
                println("jarInputs abs file path :" + jarInputs.file.absolutePath)
                //对jar 修改完之后,会返回一个新的jar文件
                def srcFile = handleJar(jarInputs.file)

                //主要是为了防止重名
                def jarName = jarInputs.name
                def md5 = DigestUtils.md5Hex(jarInputs.file.absolutePath)
                if (jarName.endsWith(".jar")) {
                    jarName = jarName.substring(0, jarName.length() - 4)
                }
                //获取jar包的输出路径
                def dest = outputProvider.getContentLocation(md5 + jarName, jarInputs.contentTypes, jarInputs.scopes, Format.JAR)
                FileUtils.copyFile(srcFile, dest)
            }
        }

        classPool.clearImportedPackages()
    }

    //处理当前目录下所有的class文件
    void handleDirectory(File dir) {
        classPool.appendClassPath(dir.absolutePath)

        if (dir.isDirectory()) {
            dir.eachFileRecurse { file ->
                def filePath = file.absolutePath
                ///Users/timian/Desktop/AndroidArchitect/AndroidArchitect/ASProj/app/build/intermediates/transforms/AndroidEntryPointTransform/debug/1/org/devio/as/proj/main/degrade/DegradeGlobalActivity.class
                println("handleDirectory file path:" + filePath)
                if (shouldModifyClass(filePath)) {
                    def inputStream = new FileInputStream(file)
                    def ctClass = modifyClass(inputStream)
                    ctClass.writeFile(dir.name)
                    ctClass.detach()
                }
            }
        }
    }

    File handleJar(File jarFile) {
        classPool.appendClassPath(jarFile.absolutePath)
        //ssesWithTinyPngPTransformForDebug
        //jarInputs abs file path :/Users/timian/Desktop/AndroidArchitect/AndroidArchitect/ASProj/app/build/intermediates/transforms/com.alibaba.arouter/debug/0.jar
        def inputJarFile = new JarFile(jarFile)
        def enumeration = inputJarFile.entries()

        def outputJarFile = new File(jarFile.parentFile, "temp_" + jarFile.name)
        if (outputJarFile.exists()) outputJarFile.delete()
        def jarOutputStream = new JarOutputStream(new BufferedOutputStream(new FileOutputStream(outputJarFile)))
        while (enumeration.hasMoreElements()) {
            def inputJarEntry = enumeration.nextElement()
            def inputJarEntryName = inputJarEntry.name

            def outputJarEntry = new JarEntry(inputJarEntryName)
            jarOutputStream.putNextEntry(outputJarEntry)
            //com/leon/channel/helper/BuildConfig.class
            println("inputJarEntryName: " + inputJarEntryName)

            def inputStream = inputJarFile.getInputStream(inputJarEntry)
            if (!shouldModifyClass2(inputJarEntryName)) {
                jarOutputStream.write(IOUtils.toByteArray(inputStream))
                inputStream.close()
                continue
            }

            def ctClass = modifyClass2(inputStream)
            def byteCode = ctClass.toBytecode()
            ctClass.detach()
            inputStream.close()

            jarOutputStream.write(byteCode)
            jarOutputStream.flush()
        }
        inputJarFile.close()
        jarOutputStream.closeEntry()
        jarOutputStream.flush()
        jarOutputStream.close()
        return outputJarFile
    }


    //这个方法是 往appcomimageview -setimagedrawable --插入不合理大图检测的代码段
    CtClass modifyClass2(InputStream is) {
        def classFile = new ClassFile(new DataInputStream(new BufferedInputStream(is)))
        //org.devio.as.proj.main.degrade.DegradeGlobalActivity
        println("modifyClass name:" + classFile.name)//全类名
        def ctClass = classPool.get(classFile.name)
        if (ctClass.isFrozen()) {
            ctClass.defrost()
        }

        def drawable = classPool.get("android.graphics.drawable.Drawable")
        CtClass[] params = Arrays.asList(drawable).toArray()
        def setImageDrawableMethod = ctClass.getDeclaredMethod("setImageDrawable", params)


        CtClass runnableImpl = classPool.makeClass("org.devio.as.proj.debug.RunnableImpl")
        if (runnableImpl.isFrozen()) {
            runnableImpl.defrost()
        }

        CtField viewField = new CtField(classPool.get("androidx.appcompat.widget.AppCompatImageView"), "view", runnableImpl)
        viewField.setModifiers(Modifier.PUBLIC)
        runnableImpl.addField(viewField)


        CtField drawableField = new CtField(classPool.get("android.graphics.drawable.Drawable"), "drawable", runnableImpl)
        drawableField.setModifiers(Modifier.PUBLIC)
        runnableImpl.addField(drawableField)

        runnableImpl.addConstructor(CtNewConstructor.make("public RunnableImpl(android.view.View view, android.graphics.drawable.Drawable drawable) {\n" +
                "            this.view = view;\n" +
                "            this.drawable = drawable;\n" +
                "        }", runnableImpl))

        runnableImpl.addInterface(classPool.get("java.lang.Runnable"))

        CtMethod runMethod = new CtMethod(CtClass.voidType, "run", null, runnableImpl)
        runMethod.setModifiers(Modifier.PUBLIC)
        runMethod.setBody("{int width = view.getWidth();\n" +
                "            int height = view.getHeight();\n" +
                "            int drawableWidth = drawable.getIntrinsicWidth();\n" +
                "            int drawableHeight = drawable.getIntrinsicHeight();\n" +
                "            if (width > 0 && height > 0) {\n" +
                "                if (drawableWidth >= 2 * width && drawableHeight >= 2 * height) {\n" +
                "                    android.util.Log.e(\"LargeBitmapChecker\", \"bitmap:[\" + drawableWidth + \",\" + drawableHeight + \"],view:[\" + width + \",\" + height + \"],className:\" + getContext().getClass().getSimpleName());\n" +
                "                }\n" +
                "            }\n" +
                "            android.util.Log.e(\"LargeBitmapChecker\", \"bitmap:[\" + drawableWidth + \",\" + drawableHeight + \"],view:[\" + width + \",\" + height + \"],className:\" + getContext().getClass().getSimpleName());}")
        runnableImpl.addMethod(runMethod)
        runnableImpl.writeFile("hi_debugtool/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes")
        runnableImpl.toClass()

        classPool.insertClassPath("org.devio.as.proj.debug.RunnableImpl")

        setImageDrawableMethod.insertBefore("if(drawable=!=null){ post(new RunnableImpl(this, drawable));}")
        return ctClass
    }

    CtClass modifyClass(InputStream is) {
        def classFile = new ClassFile(new DataInputStream(new BufferedInputStream(is)))
        //org.devio.as.proj.main.degrade.DegradeGlobalActivity
        println("modifyClass name:" + classFile.name)//全类名
        def ctClass = classPool.get(classFile.name)
        if (ctClass.isFrozen()) {
            ctClass.defrost()
        }

        def bundle = classPool.get("android.os.Bundle")
        CtClass[] params = Arrays.asList(bundle).toArray()
        def method = ctClass.getDeclaredMethod("onCreate", params)

        def message = classFile.name
        method.insertAfter("Toast.makeText(this," + "\"" + message + "\"" + ",Toast.LENGTH_SHORT).show();")

        return ctClass
    }

    boolean shouldModifyClass2(String filePath) {
        return filePath.contains("androidx/appcompat/widget/AppCompatImageView")
    }

    boolean shouldModifyClass(String filePath) {
        return (filePath.contains("org/devio/as/proj")
                && filePath.endsWith("Activity.class")
                && !filePath.contains("R.class")
                && !filePath.contains('$')
                && !filePath.contains('R$')
                && !filePath.contains("BuildConfig.class"))
    }
}