Android 卡顿分析与布局优化

一、什么是卡顿?或者说我们怎么感知APP卡顿?

这里面涉及到android UI渲染机制,我们先了解一下android UI是怎么渲染的,android的View到底是如何一步一步显示到屏幕上的?

android系统渲染页面流程:

1)通过 LayoutInflater 将 View 组件解析成 View 对象,对象中封装了组件位置 、显示图片等信息,加载到内存中;

2)CPU 将 View 对象进行计算处理,最终得到该组件对应的多维向量图形 ( 使用向量表示的图形 ) ;

3)GPU 接收上述多维向量图形,GPU 将该向量图进行栅格化,将向量图转为位图 ( 矢量图转为像素图 ) ,计算出对应屏幕上每个像素点显示的值,将图像数据写入到 Back Buffer;

4)Android系统每隔大概16.6ms发出VSYNC信号,触发对UI进行下一帧的渲染,显示屏会使用Frame Buffer跟Back buffer进行交互,拿到最新的一帧数据的渲染到屏幕上。

这个渲染过程如果每次渲染都成功,就能够达到一个流畅的画面,如果16.6ms内CPU和GPU无法处理完一帧画面,就会导致Frame Buffer没能交换,导致上一帧被重复显示,即丢了一帧,当丢帧频率越高时,用户越能感觉画面卡顿。

这里的16.6ms刷新一帧是由人眼对于每秒60帧的刷新频率感觉是很流畅的,计算出来即一帧16.6ms,为了能够实现60fps,这意味着程序的大多数操作都必须在16ms内完成。 

二、Systrace&CPU Profiler卡顿分析:

Systrace是Android平台提供的一款工具,用于记录短期内的设备活动。该工具会生成一份报告,其中汇总了Android 内核中的数据,例如 CPU 调度程序、磁盘活动和应用线程。Systrace主要用来分析绘制性能方面的问题。在发生卡顿时,通过这份报告可以知道当前整个系统所处的状态,从而帮助开发者更直观的分析系统瓶颈,改进性能。

也可以使用上一节说的CPU Profiler进行卡顿分析,CPU Profiler不仅能分析出代码卡顿时间,还能精准的定位到代码内容。连接:http://t.csdn.cn/WGzhA

三、App层面监控卡顿:

目前业界两种主流有效的app监控方式如下:

1)利用UI线程的Looper打印的日志匹配;

2)使用Choreographer.FrameCallback。

1、Looper日志检测卡顿

Android主线程更新UI。如果界面1秒钟刷新少于60次,即FPS小于60,用户就会产生卡顿感觉。简单来说,Android使用消息机制进行UI更新,UI线程有个Looper,在其loop方法中会不断取出message,调用其绑定的Handler在UI线程执行。如果在handler的dispatchMesaage方法里有耗时操作,就会发生卡顿。

public static void loop() {
	//......
	for (;;) {
		//......
		Printer logging = me.mLogging;
		if (logging != null) {
			logging.println(">>>>> Dispatching to " + msg.target + " " +
			msg.callback + ": " + msg.what);
		}
		msg.target.dispatchMessage(msg);
		if (logging != null) {
			logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
		}
		//......
	}
}

只要检测 msg.target.dispatchMessage(msg) 的执行时间,就能检测到部分UI线程是否有耗时的操作。注意到系统源码的这行代码的执行前后,有两个logging.println函数,如果设置了logging,会分别打印出>>>>> Dispatching to和<<<<< Finished to 这样的日志,这样我们就可以通过两次log的时间差值,来计算dispatchMessage的执行时间,从而设置阈值判断是否发生了卡顿。这里我们可以自定义LogMonitor,并设置到Looper中,替换系统的LogMonitor来实现,这种方式也是 BlockCanary 的实现原理。

系统Looper和Printer 接口:

public final class Looper {
	private Printer mLogging;
	
	public void setMessageLogging(@Nullable Printer printer) {
		mLogging = printer;
	}
}

public interface Printer {
	void println(String x);
}

自定义BlockCanary:

public class BlockCanary {
	public static void install() {
		LogMonitor logMonitor = new LogMonitor();
		Looper.getMainLooper().setMessageLogging(logMonitor);
	}
}

自定义LogMonitor:

public class LogMonitor implements Printer {
    private StackSampler mStackSampler;
    private boolean mPrintingStarted = false;
    private long mStartTimestamp;
    // 卡顿阈值
    private long mBlockThresholdMillis = 3000;
    //采样频率
    private long mSampleInterval = 1000;
    private Handler mLogHandler;

    public LogMonitor() {
        mStackSampler = new StackSampler(mSampleInterval);
        HandlerThread handlerThread = new HandlerThread("block-canary-io");
        handlerThread.start();
        mLogHandler = new Handler(handlerThread.getLooper());
    }

    @Override
    public void println(String x) {
        //从if到else会执行 dispatchMessage,如果执行耗时超过阈值,输出卡顿信息
        if (!mPrintingStarted) {
            //记录开始时间
            mStartTimestamp = System.currentTimeMillis();
            mPrintingStarted = true;
            mStackSampler.startDump();
        } else {
            final long endTime = System.currentTimeMillis();
            mPrintingStarted = false;
            //出现卡顿
            if (isBlock(endTime)) {
                notifyBlockEvent(endTime);
            }
            mStackSampler.stopDump();
        }
    }

    private void notifyBlockEvent(final long endTime) {
        mLogHandler.post(new Runnable() {
            @Override
            public void run() {
                //获得卡顿时 主线程堆栈
                List stacks = mStackSampler.getStacks(mStartTimestamp, endTime);
                for (String stack : stacks) {
                    Log.e("block-canary", stack);
                }
            }
        });
    }

    private boolean isBlock(long endTime) {
        return endTime - mStartTimestamp > mBlockThresholdMillis;
    }
}

自定义StackSampler:

public class StackSampler {
    public static final String SEPARATOR = "\r\n";
    public static final SimpleDateFormat TIME_FORMATTER =
            new SimpleDateFormat("MM-dd HH:mm:ss.SSS");
    private Handler mHandler;
    private Map mStackMap = new LinkedHashMap<>();
    private int mMaxCount = 100;
    private long mSampleInterval;
    //是否需要采样
    protected AtomicBoolean mShouldSample = new AtomicBoolean(false);

    public StackSampler(long sampleInterval) {
        mSampleInterval = sampleInterval;
        HandlerThread handlerThread = new HandlerThread("block-canary-sampler");
        handlerThread.start();
        mHandler = new Handler(handlerThread.getLooper());
    }

    /**
     * 开始采样 执行堆栈
     */
    public void startDump() {
        //避免重复开始
        if (mShouldSample.get()) {
            return;
        }
        mShouldSample.set(true);
        mHandler.removeCallbacks(mRunnable);
        mHandler.postDelayed(mRunnable, mSampleInterval);
    }

    public void stopDump() {
        if (!mShouldSample.get()) {
            return;
        }
        mShouldSample.set(false);
        mHandler.removeCallbacks(mRunnable);
    }

    public List getStacks(long startTime, long endTime) {
        ArrayList result = new ArrayList<>();
        synchronized (mStackMap) {
            for (Long entryTime : mStackMap.keySet()) {
                if (startTime < entryTime && entryTime < endTime) {
                    result.add(TIME_FORMATTER.format(entryTime)
                            + SEPARATOR
                            + SEPARATOR
                            + mStackMap.get(entryTime));
                }
            }
        }
        return result;
    }

    private Runnable mRunnable = new Runnable() {
        @Override
        public void run() {
            StringBuilder sb = new StringBuilder();
            StackTraceElement[] stackTrace = Looper.getMainLooper().getThread().getStackTrace();
            for (StackTraceElement s : stackTrace) {
                sb.append(s.toString()).append("\n");
            }
            synchronized (mStackMap) {
                //最多保存100条堆栈信息
                if (mStackMap.size() == mMaxCount) {
                    mStackMap.remove(mStackMap.keySet().iterator().next());
                }
                mStackMap.put(System.currentTimeMillis(), sb.toString());
            }
            if (mShouldSample.get()) {
                mHandler.postDelayed(mRunnable, mSampleInterval);
            }
        }
    };
}

2、Choreographer.FrameCallback检测卡顿

Android系统每隔16ms发出VSYNC信号,来通知界面进行重绘、渲染,每一次同步的周期约为16.6ms,代表一帧的刷新频率。通过Choreographer类设置它的FrameCallback函数,当每一帧被渲染时会触发回调FrameCallback.doFrame (long frameTimeNanos) 函数。frameTimeNanos是底层VSYNC信号到达的时间戳 。

public class ChoreographerHelper {

    public static void start() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
            Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
                long lastFrameTimeNanos = 0;

                @Override
                public void doFrame(long frameTimeNanos) {
                    //上次回调时间
                    if (lastFrameTimeNanos == 0) {
                        lastFrameTimeNanos = frameTimeNanos;
                        Choreographer.getInstance().postFrameCallback(this);
                        return;
                    }
                    long diff = (frameTimeNanos - lastFrameTimeNanos) / 1_000_000;
                    if (diff > 16.6f) {
                        //掉帧数
                        int droppedCount = (int) (diff / 16.6);
                    }
                    lastFrameTimeNanos = frameTimeNanos;
                    Choreographer.getInstance().postFrameCallback(this);
                }
            });
        }
    }
}

通过 ChoreographerHelper 可以实时计算帧率和掉帧数,实时监测App页面的帧率数据,发现帧率过低,还可以自动保存现场堆栈信息。

Looper比较适合在发布前进行测试或者小范围灰度测试然后定位问题,ChoreographerHelper适合监控线上环境的 app 的掉帧情况来计算 app 在某些场景的流畅度然后有针对性的做性能优化。

四、布局优化 

1、Layout Inspector层级优化 

measure、layout、draw这三个过程都包含自顶向下的View Tree遍历耗时,如果视图层级太深自然需要更多的时间来完成整个绘测过程,从而造成启动速度慢、卡顿等问题。而onDraw在频繁刷新时可能多次出发,因此onDraw更不能做耗时操作,同时需要注意内存抖动。

使用Layout Inspector来检查应用的视图层次结构,

Android 卡顿分析与布局优化_第1张图片

选择需要查看的进程与Activity,在id为content之下的就是我们写在XML中的布局。 

排查是否存在Layout的多层嵌套,我们应该尽量减少其层级,也可以使用 ConstraintLayout 约束布局使得布局尽量扁平化,移除非必需的UI组件。 

2、使用merge标签: 

当我们有一些布局元素需要被多处使用时,这时候我们会考虑将其抽取成一个单独的布局文件。在需要使用的地方通过 include 加载。 



    
    




    

这时候我们的主布局文件是垂直的LinearLayout,include的 "layout_merge" 也是垂直的LinearLayout,这时候include的布局中使用的LinearLayout就没意义了,使用的话反而减慢你的UI表现。这时可以使用merge标签优化。 



    

修改为merge后,通过LayoutInspector能够发现,include的布局中TextView直接被加入到父布局中。 

3、使用ViewStub 标签:

当我们布局中存在一个View/ViewGroup,在某个特定时刻才需要他的展示时,可能会把这个元素在xml中定义为invisible或者gone,在需要显示时再设置为visible可见。比如在登陆时,如果密码错误在密码输入框上显示提示。

1)invisible

view设置为invisible时,view在layout布局文件中会占用位置,但是view为不可见,该view还是会创建对象,会被初始化,会占用资源。 

2)gone

view设置gone时,view在layout布局文件中不占用位置,但是该view还是会创建对象,会被初始化,会占用资源。 

如果view不一定会显示,此时可以使用 ViewStub 来包裹此View 以避免不需要显示view但是又需要加载view消耗资源。viewstub是一个轻量级的view,它不可见,不用占用资源,只有设置viewstub为visible或者调用其inflater()方法时,其对应的布局文件才会被初始化。 



    


加载viewStub后,可以通过 inflatedId 找到layout_viewstub 中的根View。

五、过度渲染: 

过度绘制是指系统在渲染单个帧的过程中多次在屏幕上绘制某一个像素。例如,如果我们有若干界面卡片堆叠在一起,每张卡片都会遮盖其下面一张卡片的部分内容。但是,系统仍然需要绘制堆叠中的卡片被遮盖的部分。 

1、GPU 过度绘制检查

手机开发者选项中能够显示过度渲染检查功能,通过对界面进行彩色编码来帮我们识别过度绘制。开启步骤如下:

1. 进入开发者选项 (Developer Options)。

2. 找到调试 GPU 过度绘制(Debug GPU overdraw)。

3. 在弹出的对话框中,选择显示过度绘制区域(Show overdraw areas)。 

Android 将按如下方式为界面元素着色,以确定过度绘制的次数: 

1. 真彩色:没有过度绘制
2. 蓝色:过度绘制 1 次
3. 绿色:过度绘制 2 次
4. 粉色:过度绘制 3 次
5. 红色:过度绘制 4 次或更多次 

有些过度绘制是不可避免的。在优化应用的界面时,应尝试达到大部分显示真彩色或仅有 1 次过度绘制(蓝色)的视觉效果。 

2、解决过度绘制问题:

可以采取以下几种策略来减少甚至消除过度绘制:

1. 移除布局中不需要的背景:

默认情况下,布局没有背景,这表示布局本身不会直接渲染任何内容。但是,当布局具有背景时,其有可能会导致过度绘制。移除不必要的背景可以快速提高渲染性能。不必要的背景可能永远不可见,因为它会被应用在该视图上绘制的任何其他内容完全覆盖。例如,当系统在父视图上绘制子视图时,可能会完全覆盖父视图的背景。

2.使视图层次结构扁平化:

可以通过优化视图层次结构来减少重叠界面对象的数量,从而提高性能。

3.降低透明度:

对于不透明的 view ,只需要渲染一次即可把它显示出来。但是如果这个 view 设置了 alpha 值,则至少需要渲染两次。这是因为使用了 alpha 的 view 需要先知道混合 view 的下一层元素是什么,然后再结合上层的 view 进行Blend混色处理。透明动画、淡入淡出和阴影等效果都涉及到某种透明度,这就会造成了过度绘制。可以通过减少要渲染的透明对象的数量,来改善这些情况下的过度绘制。例如,如需获得灰色文本,可以在 TextView 中绘制黑色文本,再为其设置半透明的透明度值。但是,简单地通过用灰色绘制文本也能获得同样的效果,而且能够大幅提升性能。

六、布局加载优化: 

1、异步加载 

LayoutInflater加载xml布局的过程会在主线程使用IO读取XML布局文件进行XML解析,再根据解析结果利用反射创建布局中的View/ViewGroup对象。这个过程随着布局的复杂度上升,耗时自然也会随之增大。Android为我们提供了 Asynclayoutinflater 把耗时的加载操作在异步线程中完成,最后把加载结果再回调给主线程。 

2、添加依赖: 

dependencies {
	implementation "androidx.asynclayoutinflater:asynclayoutinflater:1.0.0"
}

3、使用AsyncLayoutInflater

new AsyncLayoutInflater(this)
	.inflate(R.layout.activity_main, null, new AsyncLayoutInflater.OnInflateFinishedListener() {
		@Override
		public void onInflateFinished(@NonNull View view, int resid, @Nullable ViewGroup parent) {
			setContentView(view);
			//......
		}
	});

1. 使用异步 inflate,那么需要这个 layout 的 parent 的 generateLayoutParams 函数是线程安全的;

2. 所有构建的 View 中必须不能创建 Handler 或者是调用 Looper.myLooper;(因为是在异步线程中加载的,异步线程默认没有调用 Looper.prepare );

3. AsyncLayoutInflater 不支持设置 LayoutInflater.Factory 或者 LayoutInflater.Factory2;

4. 不支持加载包含 Fragment 的 layout;

5. 如果 AsyncLayoutInflater 失败,那么会自动回退到UI线程来加载布局。

你可能感兴趣的:(android,android,卡顿分析与布局优化)