Android 面试之必问性能优化

Posted 涂程

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android 面试之必问性能优化相关的知识,希望对你有一定的参考价值。

对于android开发者来说,懂得基本的应用开发技能往往是不够,因为不管是工作还是面试,都需要开发者懂得大量的性能优化,这对提升应用的体验是非常重要的。对于Android开发来说,性能优化主要围绕如下方面展开:启动优化、渲染优化、内存优化、网络优化、卡顿检测与优化、耗电优化、安装包体积优化、安全问题等。

1,启动优化

一个应用的启动快慢是能够直接影响用户的使用体验的,如果启动较慢可能会导致用户卸载放弃该应用程序。

1.1 冷启动、热启动和温启动的优化

1.1.1 概念

对于Android应用程序来说,根据启动方式可以分为冷启动,热启动和温启动三种。

  • 冷启动:系统不存在App进程(如APP首次启动或APP被完全杀死)时启动App称为冷启动。
  • 热启动:按了Home键或其它情况app被切换到后台,再次启动App的过程。
  • 温启动:温启动包含了冷启动的一些操作,不过App进程依然存在,这代表着它比热启动有更多的开销。

可以看到,热启动是启动最快的,温启动则是介于冷启动和热启动之间的一种启动方式。下而冷启动则是最慢的,因为它会涉及很多进程的创建,下面是冷启动相关的任务流程:

1.1.2 视觉优化

在冷启动模式下,系统会启动三个任务:

  • 加载并启动应用程序。
  • 启动后立即显示应用程序空白的启动窗口。
  • 创建应用程序进程。

一旦系统创建应用程序进程,应用程序进程就会进入下一阶段,并完成如下的一些事情。

  • 创建app对象
  • 启动主线程(main thread)
  • 创建应用入口的Activity对象
  • 填充加载布局View
  • 在屏幕上执行View的绘制过程.measure -> layout -> draw

应用程序进程完成第一次绘制后,系统进程会交换当前显示的背景窗口,将其替换为主活动。此时,用户可以开始使用该应用程序了。因为App应用进程的创建过程是由手机的软硬件决定的,所以我们只能在这个创建过程中进行一些视觉优化。

1.1.3 启动主题优化

在冷启动的时候,当应用程序进程被创建后,就需要设置启动窗口的主题。目前,大部分的 应用在启动会都会先进入一个闪屏页(LaunchActivity) 来展示应用信息,如果在 Application 初始化了其它第三方的服务,就会出现启动的白屏问题。

为了更顺滑无缝衔接我们的闪屏页,可以在启动 Activity 的 Theme中设置闪屏页图片,这样启动窗口的图片就会是闪屏页图片,而不是白屏。

    <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
        <item name="android:windowBackground">@drawable/lunch</item>  //闪屏页图片
        <item name="android:windowFullscreen">true</item>
        <item name="android:windowDrawsSystemBarBackgrounds">false</item>
    </style>

1.2 代码方面的优化

设置主题的方式只能应用在要求不是很高的场景,并且这种优化治标不治本,关键还在于代码的优化。为了进行优化,我们需要掌握一些基本的数据。

1.2.1 冷启动耗时统计

ADB命令方式 在Android Studio的Terminal中输入以下命令可以查看页面的启动的时间,命令如下:

adb shell am start  -W packagename/[packagename].首屏Activity

执行完成之后,会在控制台输出如下的信息:

Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.optimize.performance/.MainActivity }
Status: ok
Activity: com.optimize.performance/.MainActivity
ThisTime: 563
TotalTime: 563
WaitTime: 575
Complete

在上面的日志中有三个字段信息,即ThisTime、TotalTime和WaitTime。

  • ThisTime:最后一个Activity启动耗时
  • TotalTime:所有Activity启动耗时
  • WaitTime:AMS启动Activity的总耗时

日志方式 埋点方式是另一种统计线上时间的方式,这种方式通过记录启动时的时间和结束的时间,然后取二者差值即可。首先,需要定义一个统计时间的工具类:

class LaunchRecord {

    companion object {

        private var sStart: Long = 0

        fun startRecord() {
            sStart = System.currentTimeMillis()
        }

        fun endRecord() {
            endRecord("")
        }

        fun endRecord(postion: String) {
            val cost = System.currentTimeMillis() - sStart
            println("===$postion===$cost")
        }
    }
}

启动时埋点我们直接在Application的attachBaseContext中进行打点。那么启动结束应该在哪里打点呢?结束埋点建议是在页面数据展示出来进行埋点。可以使用如下方法:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        mTextView.viewTreeObserver.addOnDrawListener {
            LaunchRecord.endRecord("onDraw")
        }

    }

    override fun onWindowFocusChanged(hasFocus: Boolean) {
        super.onWindowFocusChanged(hasFocus)
        LaunchRecord.endRecord("onWindowFocusChanged")
    }
}

1.2.2 优化检测工具

在做启动优化的时候,可以借助三方工具来帮助我们理清各个阶段的方法或者线程、CPU的执行耗时等情况。这里主要介绍以下TraceView和SysTrace两款工具。

TraceView

TraceView是以图形的形式展示执行时间、调用栈等信息,信息比较全面,包含所有线程,如下图所示。

使用TraceView检测生成生成的结果会放在Andrid/data/packagename/files路径下。因为Traceview收集的信息比较全面,所以会导致运行开销严重,整体APP的运行会变慢,因此我们无法区分是不是Traceview影响了我们的启动时间。

SysTrace Systrace是结合Android内核数据,生成html报告,从报告中我们可以看到各个线程的执行时间以及方法耗时和CPU执行时间等。

再API 18以上版本,可以直接使用TraceCompat来抓取数据,因为这是兼容的API。

开始:TraceCompat.beginSection("tag ")
结束:TraceCompat.endSection()

然后,执行如下脚本。

python systrace.py -b 32768 -t 10 -a packagename -o outputfile.html sched gfx view wm am app

这里可以大家普及下各个字端的含义:

  • b: 收集数据的大小
  • t:时间
  • a:监听的应用包名
  • o: 生成文件的名称

Systrace开销较小,属于轻量级的工具,并且可以直观反映CPU的利用率。

2,UI渲染优化

Android系统每隔16ms就会重新绘制一次Activity,因此,我们的应用必须在16ms内完成屏幕刷新的全部逻辑操作,每一帧只能停留16ms,否则就会出现掉帧现象。Android应用卡顿与否与UI渲染有直接的关系。

2.1CPU、GPU

对于大多数手机的屏幕刷新频率是60hz,也就是如果在1000/60=16.67ms内没有把这一帧的任务执行完毕,就会发生丢帧的现象,丢帧是造成界面卡顿的直接原因,渲染操作通常依赖于两个核心组件:CPU与GPU。CPU负责包括Measure,Layout等计算操作,GPU负责Rasterization(栅格化)操作。

所谓栅格化,就是将矢量图形转换为位图的过程,手机上显示是按照一个个像素来显示的,比如将一个Button、TextView等组件拆分成一个个像素显示到手机屏幕上。而UI渲染优化的目的就是减轻CPU、GPU的压力,除去不必要的操作,保证每帧16ms以内处理完所有的CPU与GPU的计算、绘制、渲染等等操作,使UI顺滑、流畅的显示出来。

2.2 过度绘制

UI渲染优化的第一步就是找到Overdraw(过度绘制),即描述的是屏幕上的某个像素在同一帧的时间内被绘制了多次。在重叠的UI布局中,如果不可见的UI也在做绘制的操作或者后一个控件将前一个控件遮挡,会导致某些像素区域被绘制了多次,从而增加了CPU、GPU的压力。

那么如何找出布局中Overdraw的地方呢?很简单,就是打开手机里开发者选项,然后将调试GPU过度绘制的开关打开即可,然后就可以看到应用的布局是否被Overdraw,如下图所示。

蓝色、淡绿、淡红、深红代表了4种不同程度的Overdraw情况,1x、2x、3x和4x分别表示同一像素上同一帧的时间内被绘制了多次,1x就表示一次(最理想情况),4x表示4次(最差的情况),而我们需要消除的就是3x和4x。

2.3 解决自定义View的OverDraw

我们知道,自定义View的时候有时会重写onDraw方法,但是Android系统是无法检测onDraw里面具体会执行什么操作,从而系统无法为我们做一些优化。这样对编程人员要求就高了,如果View有大量重叠的地方就会造成CPU、GPU资源的浪费,此时我们可以使用canvas.clipRect()来帮助系统识别那些可见的区域。

这个方法可以指定一块矩形区域,只有在这个区域内才会被绘制,其他的区域会被忽视。下面我们通过谷歌提供的一个小的Demo进一步说明OverDraw的使用。

在下面的代码中,DroidCard类封装的是卡片的信息,代码如下。

public class DroidCard {

public int x;//左侧绘制起点
public int width;
public int height;
public Bitmap bitmap;

public DroidCard(Resources res,int resId,int x){
this.bitmap = BitmapFactory.decodeResource(res,resId);
this.x = x;
this.width = this.bitmap.getWidth();
this.height = this.bitmap.getHeight();
 }
}

自定义View的代码如下:

public class DroidCardsView extends View {
//图片与图片之间的间距
private int mCardSpacing = 150;
//图片与左侧距离的记录
private int mCardLeft = 10;

private List<DroidCard> mDroidCards = new ArrayList<DroidCard>();

private Paint paint = new Paint();

public DroidCardsView(Context context) {
super(context);
initCards();
}

public DroidCardsView(Context context, AttributeSet attrs) {
super(context, attrs);
initCards();
}
/**
* 初始化卡片集合
*/
protected void initCards(){
Resources res = getResources();
mDroidCards.add(new DroidCard(res,R.drawable.alex,mCardLeft));

mCardLeft+=mCardSpacing;
mDroidCards.add(new DroidCard(res,R.drawable.claire,mCardLeft));

mCardLeft+=mCardSpacing;
mDroidCards.add(new DroidCard(res,R.drawable.kathryn,mCardLeft));
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
for (DroidCard c : mDroidCards){
drawDroidCard(canvas, c);
}
invalidate();
}

/**
* 绘制DroidCard
*/
private void drawDroidCard(Canvas canvas, DroidCard c) {
canvas.drawBitmap(c.bitmap,c.x,0f,paint);
}
}

然后,我们运行代码,打开手机的overdraw开关,效果如下:

可以看到,淡红色区域明显被绘制了三次,是因为图片的重叠造成的。那怎么解决这种问题呢?其实,分析可以发现,最下面的图片只需要绘制三分之一即可,保证最下面两张图片只需要回执其三分之一最上面图片完全绘制出来就可。优化后的代码如下:

public class DroidCardsView extends View {

//图片与图片之间的间距
private int mCardSpacing = 150;
//图片与左侧距离的记录
private int mCardLeft = 10;

private List<DroidCard> mDroidCards = new ArrayList<DroidCard>();

private Paint paint = new Paint();

public DroidCardsView(Context context) {
super(context);
initCards();
}

public DroidCardsView(Context context, AttributeSet attrs) {
super(context, attrs);
initCards();
}
/**
* 初始化卡片集合
*/
protected void initCards(){
Resources res = getResources();
mDroidCards.add(new DroidCard(res, R.drawable.alex,mCardLeft));

mCardLeft+=mCardSpacing;
mDroidCards.add(new DroidCard(res, R.drawable.claire,mCardLeft));

mCardLeft+=mCardSpacing;
mDroidCards.add(new DroidCard(res, R.drawable.kathryn,mCardLeft));
}

@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
for (int i = 0; i < mDroidCards.size() - 1; i++){
drawDroidCard(canvas, mDroidCards,i);
}
drawLastDroidCard(canvas,mDroidCards.get(mDroidCards.size()-1));
invalidate();
}

/**
* 绘制最后一个DroidCard
* @param canvas
* @param c
*/
private void drawLastDroidCard(Canvas canvas,DroidCard c) {
canvas.drawBitmap(c.bitmap,c.x,0f,paint);
}

/**
* 绘制DroidCard
* @param canvas
* @param mDroidCards
* @param i
*/
private void drawDroidCard(Canvas canvas,List<DroidCard> mDroidCards,int i) {
DroidCard c = mDroidCards.get(i);
canvas.save();
canvas.clipRect((float)c.x,0f,(float)(mDroidCards.get(i+1).x),(float)c.height);
canvas.drawBitmap(c.bitmap,c.x,0f,paint);
canvas.restore();
 }
}

在上面的代码中,我们使用Canvas的clipRect方法,绘制之前裁剪出一个区域,这样绘制的时候只在这区域内绘制,超出部分不会绘制出来。重新运行上面的代码,效果如下图所示。

2.4 Hierarchy Viewer

Hierarchy Viewer 是 Android Device Monitor 中内置的一种工具,可让开发者测量布局层次结构中每个视图的布局速度,以及帮助开发者查找视图层次结构导致的性能瓶颈。Hierarchy Viewer可以通过红、黄、绿三种不同的颜色来区分布局的Measure、Layout、Executive的相对性能表现情况。

打开

  1. 将设备连接到计算机。如果设备上显示对话框提示您允许 USB 调试吗?,请点按确定。
  2. 在 Android Studio 中打开您的项目,在您的设备上构建并运行项目。
  3. 启动 Android Device Monitor。Android Studio 可能会显示 Disable adb integration 对话框,因为一次只能有一个进程可以通过 adb 连接到设备,并且 Android Device Monitor 正在请求连接。因此,请点击 Yes。
  4. 在菜单栏中,依次选择 Window > Open Perspective,然后点击 Hierarchy View。
  5. 在左侧的 Windows 标签中双击应用的软件包名称。这会使用应用的视图层次结构填充相关窗格。

提升布局性能的关键点是尽量保持布局层级的扁平化,避免出现重复的嵌套布局。如果我们写的布局层级比较深会严重增加CPU的负担,造成性能的严重卡顿,关于Hierarchy Viewer的使用可以参考:使用 Hierarchy Viewer 分析布局

2.5 内存抖动

在我们优化过view的树形结构和overdraw之后,可能还是感觉自己的app有卡顿和丢帧,或者滑动慢等问题,我们就要查看一下是否存在内存抖动情况了。所谓内存抖动,指的是内存频繁创建和GC造成的UI线程被频繁阻塞的现象。

Android有自动管理内存的机制,但是对内存的不恰当使用仍然容易引起严重的性能问题。在同一帧里面创建过多的对象是件需要特别引起注意的事情,在同一帧里创建大量对象可能引起GC的不停操作,执行GC操作的时候,所有线程的任何操作都会需要暂停,直到GC操作完成。大量不停的GC操作则会显著占用帧间隔时间。如果在帧间隔时间里面做了过多的GC操作,那么就会造成页面卡顿。

在Android开发中,导致GC频繁操作有两个主要原因:

  • 内存抖动,所谓内存抖动就是短时间产生大量对象又在短时间内马上释放。
  • 短时间产生大量对象超出阈值,内存不够,同样会触发GC操作。

Android的内存抖动可以使用Android Studio的Profiler进行检测。

然后,点击record记录内存信息,查找发生内存抖动位置,当然也可直接通过Jump to Source定位到代码位置。

为了避免发生内存抖动,我们需要避免在for循环里面分配对象占用内存,需要尝试把对象的创建移到循环体之外,自定义View中的onDraw方法也需要引起注意,每次屏幕发生绘制以及动画执行过程中,onDraw方法都会被调用到,避免在onDraw方法里面执行复杂的操作,避免创建对象。对于那些无法避免需要创建对象的情况,我们可以考虑对象池模型,通过对象池来解决频繁创建与销毁的问题,但是这里需要注意结束使用之后,需要手动释放对象池中的对象。

3,内存优化

3.1 内存管理

在前面Java基础环节,我们对Java的内存管理模型也做了基本的介绍,参考链接:Android 高频面试必问之Java基础

3.1.1 内存区域

在Java的内存模型中,将内存区域划分为方法区、堆、程序计数器、本地方法栈、虚拟机栈五个区域,如下图。

方法区

  • 线程共享区域,用于存储类信息、静态变量、常量、即时编译器编译出来的代码数据。
  • 无法满足内存分配需求时会发生OOM。

  • 线程共享区域,是JAVA虚拟机管理的内存中最大的一块,在虚拟机启动时创建。
  • 存放对象实例,几乎所有的对象实例都在堆上分配,GC管理的主要区域。

虚拟机栈

  • 线程私有区域,每个java方法在执行的时候会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。方法从执行开始到结束过程就是栈帧在虚拟机栈中入栈出栈过程。
  • 局部变量表存放编译期可知的基本数据类型、对象引用、returnAddress类型。所需的内存空间会在编译期间完成分配,进入一个方法时在帧中局部变量表的空间是完全确定的,不需要运行时改变。
  • 若线程申请的栈深度大于虚拟机允许的最大深度,会抛出SatckOverFlowError错误。
  • 虚拟机动态扩展时,若无法申请到足够内存,会抛出OutOfMemoryError错误。

本地方法栈

  • 为虚拟机中Native方法服务,对本地方法栈中使用的语言、数据结构、使用方式没有强制规定,虚拟机可自有实现。
  • 占用的内存区大小是不固定的,可根据需要动态扩展。

程序计数器

  • 一块较小的内存空间,线程私有,存储当前线程执行的字节码行号指示器。
  • 字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令:分支、循环、跳转等。
  • 每个线程都有一个独立的程序计数器
  • 唯一一个在java虚拟机中不会OOM的区域

3.1.2 垃圾回收

标记清除算法 标记清除算法主要分为有两个阶段,首先标记出需要回收的对象,然后咋标记完成后统一回收所有标记的对象; 缺点:

  • 效率问题:标记和清除两个过程效率都不高。
  • 空间问题:标记清除之后会导致很多不连续的内存碎片,会导致需要分配大对象时无法找到足够的连续空间而不得不触发GC的问题。

复制算法 将可用内存按空间分为大小相同的两小块,每次只使用其中的一块,等这块内存使用完了将还存活的对象复制到另一块内存上,然后将这块内存区域对象整体清除掉。每次对整个半区进行内存回收,不会导致碎片问题,实现简单且效率高效。 缺点: 需要将内存缩小为原来的一半,空间代价太高。

标记整理算法 标记整理算法标记过程和标记清除算法一样,但清除过程并不是对可回收对象直接清理,而是将所有存活对象像一端移动,然后集中清理到端边界以外的内存。

分代回收算法 当代虚拟机垃圾回收算法都采用分代收集算法来收集,根据对象存活周期不同将内存划分为新生代和老年代,再根据每个年代的特点采用最合适的回收算法。

  • 新生代存活对象较少,每次垃圾回收都有大量对象死去,一般采用复制算法,只需要付出复制少量存活对象的成本就可以实现垃圾回收;
  • 老年代存活对象较多,没有额外空间进行分配担保,就必须采用标记清除算法和标记整理算法进行回收;

3.2 内存泄漏

所谓内存泄露,指的是内存中存在的没有用的确无法回收的对象。表现的现象是会导致内存抖动,可用内存减少,进而导致GC频繁、卡顿、OOM。

下面是一段模拟内存泄漏的代码:

/**
 * 模拟内存泄露的Activity
 */
public class MemoryLeakActivity extends AppCompatActivity implements CallBack{
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_memoryleak);
        ImageView imageView = findViewById(R.id.iv_memoryleak);
        Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.mipmap.splash);
        imageView.setImageBitmap(bitmap);

        // 添加静态类引用
        CallBackManager.addCallBack(this);
    }
    @Override
    protected void onDestroy() {
        super.onDestroy();
//        CallBackManager.removeCallBack(this);
    }
    @Override
    public void dpOperate() {
        // do sth
    }

当我们使用Memory Profiler工具查看内存曲线,发现内存在不断的上升,如下图所示。

如果想分析定位具体发生内存泄露位置,我们可以借助MAT工具。首先,使用MAT工具生成hprof文件,点击dump将当前内存信息转成hprof文件,需要对生成的文件转换成MAT可读取文件。执行一下转换命令即可完成转换,生成的文件位于Android/sdk/platorm-tools路径下。

hprof-conv 刚刚生成的hprof文件 memory-mat.hprof

使用mat打开刚刚转换的hprof文件,然后使用Android Studio打开hprof文件,如下图所示。

然后点击面板的【Historygram】,搜索MemoryLeakActivity,即可查看对应的泄漏文件的相关信息。

然后,查看所有引用对象,并得到相关的引用链,如下图。

可以看到GC Roots是CallBackManager

所以,我们在Activity销毁时将CallBackManager引用移除即可。

@Override
protected void onDestroy() {
    super.onDestroy();
    CallBackManager.removeCallBack(this);
}

当然,上面只是一个MAT分析工具使用的示例,其他的内存泄露都可以借助MAT分析工具解决。

3.3 大图内存优化

在Android开发中,经常会遇到加载大图导致内存泄露的问题,对于这种场景,有一个通用的解决方案,即使用ARTHook对不合理图片进行检测。我们知道,获取Bitmap占用的内存主要有两种方式:

  • 通过getByteCount方法,但是需要在运行时获取
  • width * height * 一个像素所占内存 * 图片所在资源目录压缩比

通过ARTHook方法可以优雅的获取不合理图片,侵入性低,但是因为兼容性问题一般在线下使用。使用ARTHook需要安装以下依赖:

implementation 'me.weishu:epic:0.3.6'

然后自定义实现Hook方法,如下所示。

public class CheckBitmapHook extends XC_MethodHook {
    @Override protected void afterHookedMethod(MethodHookParam param) throws Throwable {
        super.afterHookedMethod(param);
        ImageView imageView = (ImageView)param.thisObject;
        checkBitmap(imageView,imageView.getDrawable());
    }
    private static void checkBitmap(Object o,Drawable drawable) {
        if(drawable instanceof BitmapDrawable && o instanceof View) {
            final Bitmap bitmap = ((BitmapDrawable) drawable).getBitmap();
            以上是关于Android 面试之必问性能优化的主要内容,如果未能解决你的问题,请参考以下文章

Android 面试之必问性能优化

Android 面试之必问Android基础

Android 面试之必问高级知识点

Android 面试之必问高级知识点

Android 面试之必问Android基础

面试之必问Android基础,面试通行证请收好