过度绘制
说道绘制优化,免不了要谈一谈过度绘制,那什么是过度绘制呢
过度绘制(Overdraw)描述的是屏幕上的某个像素在同一帧的时间内被绘制了多次。在多层次重叠的 UI 结构里面,如果不可见的 UI 也在做绘制的操作,会导致某些像素区域被绘制了多次,同时也会浪费大量的 CPU 以及 GPU 资源。 在 Android 手机的开发者选项中,有一个『调试 GPU 过度绘制』的选项,该选项开启之后,手机显示如下,显示出来的蓝色、绿色的色块就是过度绘制信息。
比如上面界面中的『调试 GPU 过度绘制 』的那个文本显示为蓝色,表示其过度绘制了一次,因为背景是白色的,然后文字是黑色的,导致文字所在的区域就会被绘制两次:一次是背景,一次是文字,所以就产生了过度重绘。 在官网的 Debug GPU Overdraw Walkthrough 说明中对过度重绘做了简单的介绍,其中屏幕上显示不同色块的具体含义如下所示:
每个颜色的说明如下: - 原色:没有过度绘制 - 蓝色:1 次过度绘制 - 绿色:2 次过度绘制 - 粉色:3 次过度绘制 - 红色:4 次及以上过度绘制
过度绘制的存在会导致界面显示时浪费不必要的资源去渲染看不见的背景,或者对某些像素区域多次绘制,就会导致界面加载或者滑动时的不流畅、掉帧,对于用户体验来说就是 App 特别的卡顿。为了提升用户体验,提升应用的流畅性,优化过度绘制的工作还是很有必要做的。
优化原则
- 一些过度绘制是无法避免的,比如之前说的文字和背景导致的过度绘制,这种是无法避免的。
- 应用界面中,应该尽可能地将过度绘制控制为 2 次(绿色)及其以下,原色和蓝色是最理想的。
- 粉色和红色应该尽可能避免,在实际项目中避免不了时,应该尽可能减少粉色和红色区域。 不允许存在面积超过屏幕 1/4 区域的 3 次(淡红色区域)及其以上过度绘制。
优化方法
移除默认的 Window 背景
一般应用默认继承的主题都会有一个默认的 windowBackground ,比如默认的 Light 主题:
代码语言:javascript复制<style name="Theme.Light">
<item name="isLightTheme">true</item>
<item name="windowBackground">@drawable/screen_background_selector_light</item>
...
</style>
但是一般界面都会自己设置界面的背景颜色或者列表页则由 item 的背景来决定,所以默认的 Window 背景基本用不上,如果不移除就会导致所有界面都多 1 次绘制。
可以在应用的主题中添加如下的一行属性来移除默认的 Window 背景:
代码语言:javascript复制<item name="android:windowBackground">@android:color/transparent</item>
<!-- 或者 -->
<item name="android:windowBackground">@null</item>
或者在 BaseActivity 的 onCreate() 方法中使用下面的代码移除:
getWindow().setBackgroundDrawable(null);
或者
getWindow().setBackgroundDrawableResource(android.R.color.transparent);
移除不必要的背景 还是上面的那个界面,因为移除了默认的 Window 背景,所以在布局中设置背景为白色:
代码语言:javascript复制<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/white"
android:orientation="vertical">
<android.support.v7.widget.RecyclerView
android:id="@ id/rv_apps"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="visible"/>
</LinearLayout>
然后在列表的 item 的布局如下所示:
代码语言:javascript复制<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/white"
android:orientation="horizontal"
android:padding="@dimen/mid_dp">
<ImageView
android:id="@ id/iv_app_icon"
android:layout_width="40dp"
android:layout_height="40dp"
tools:src="@mipmap/ic_launcher"/>
<TextView
android:id="@ id/tv_app_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginLeft="@dimen/mid_dp"
android:textColor="@color/text_gray_main"
android:textSize="@dimen/mid_sp"
tools:text="test"/>
</LinearLayout>
自定义控件使用 clipRect() 和 quickReject() 优化
当某些控件不可见时,如果还继续绘制更新该控件,就会导致过度绘制。但是通过 Canvas clipRect() 方法可以设置需要绘制的区域,当某个控件或者 View 的部分区域不可见时,就可以减少过度绘制。
先看一下 clipRect() 方法的说明:
Intersect the current clip with the specified rectangle, which is expressed in local coordinates.
顾名思义就是给 Canvas 设置一个裁剪区,只有在这个裁剪矩形区域内的才会被绘制,区域之外的都不绘制。 DrawerLayout 就是一个很不错的例子,先来看一下使用 DrawerLayout 布局的过度绘制结果:
按道理左边的抽屉布局出来时,应该是和主界面的布局叠加起来的,但是为什么抽屉的背景过度绘制只有一次呢?如果是叠加的话,那最少是主界面过度绘制次数 1,但是结果并不是这样。直接看源码:
代码语言:javascript复制@Override
protected boolean drawChild(Canvas canvas, View child, long drawingTim
final int height = getHeight();
final boolean drawingContent = isContentView(child);
int clipLeft = 0, clipRight = getWidth();
final int restoreCount = canvas.save();
if (drawingContent) {
final int childCount = getChildCount();
for (int i = 0; i < childCount; i ) {
final View v = getChildAt(i);
if (v == child || v.getVisibility() != VISIBLE
|| !hasOpaqueBackground(v) || !isDrawerView(v)
|| v.getHeight() < height) {
continue;
}
if (checkDrawerViewAbsoluteGravity(v, Gravity.LEFT)) {
final int vright = v.getRight();
if (vright > clipLeft) clipLeft = vright;
} else {
final int vleft = v.getLeft();
if (vleft < clipRight) clipRight = vleft;
}
}
canvas.clipRect(clipLeft, 0, clipRight, getHeight());
}
......
}
在 DrawerLayout
的 drawChild()
方法一开始会判断是是否是 DrawerLayout 的 ContentView,即非抽屉布局,如果是的话,则遍历 DrawerLayout 的 child view,拿到抽屉布局,如果是左边抽屉,则取抽屉布局的右边边界作为裁剪区的左边界,得到的裁剪矩形就是下图中的红色框部分,然后设置裁剪区域。右边抽屉同理。
这样一来,只有裁剪矩形内的界面需要绘制,自然就减少了抽屉布局的过度绘制。自定义控件时可以参照这个来优化过度绘制问题。
除了 clipRect()
以外,还可以使用 canvas.quickreject()
来判断和某个矩形相交,如果相交的话,则可以跳过相交的区域减少过度绘制。
clipPath(Path)
会触发昂贵的裁剪操作,因此也需要尽量避免。在可能的情况下,应该尽量直接绘制出需要的形状,而不是裁剪成相应的图形;这样性能更高,并且支持反锯齿;
例如下面这个clipPath
操作:
canvas.save();
canvas.clipPath(mCirclePath);
canvas.drawBitmap(mBitmap);
canvas.restore();
可以用如下代替:
// one time init:
mPaint.setShader(new BitmapShader(mBitmap, TileMode.CLAMP, TileMode.CLAMP));
// at draw time:
canvas.drawPath(mCirclePath, mPaint);
绘制原理
CPU
负责计算显示内容GPU
负责栅格化,UI
元素绘制显示在屏幕上16ms
发出VSync
信号触发UI渲染- 大多数
Android
设备屏幕刷新频率为60Hz
优化工具
Systrace
- 关注
Frames
- 正常:绿色圆点
- 丢帧:黄色或者红色圆点
Alerts
栏
Layout Inspector
Android studio
自带的布局工具,在Tools
目录下,查看视图层次结构
Choreographer
我们知道Android系统每隔16ms
都会发出VSYNC
信号,触发UI
的绘制,而我们可以拿到回调的监听。如果16ms
没有回调的话我们就知道发生了卡顿。
Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
@Override
public void doFrame(long l) {
}
});
备注:这种方式的原理也比较简单,但是可用性不高,只能测出界面绘制的卡顿
获取实时Fps,线上使用,具备实时性
- Api 16
之后使用
- Choreographer.getInstance().postFrameCallback()
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
private void getFPS() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) {
return;
}
Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback() {
@Override
public void doFrame(long frameTimeNanos) {
if (mStartFrameTime == 0) {
mStartFrameTime = frameTimeNanos;
}
long interval = frameTimeNanos - mStartFrameTime;
//判断间隔时间是否超过所设的值,超过就开始计算fps值
if (interval > MONITOR_INTERVAL_NANOS) {
//fps 为用间隔时间除以在间隔时间发生的次数
double fps = (((double) (mFrameCount * 1000L * 1000L)) / interval) * MAX_INTERVAL;
LogUtils.i("fps=" fps);
mFrameCount = 0;
mStartFrameTime = 0;
} else {
mFrameCount;
}
Choreographer.getInstance().postFrameCallback(this);
}
});
}
LayoutInflater.Factory
通过LayoutInflater
创建View时候的一个回调,可以通过LayoutInflater.Factory
来改造 XML 中存在的 tag。
比如通过在XML中写一个TextView
,可以在此方法中,判断当前name是TextView
,将TextView
修改成Button
为什么调用LayoutInflater.from(this).setFactory2,就需要在onCreate中的super.onCreate之前,
因为在onCreate
源码中,AppCompatActivity
会自动设置一个 Factory2,而setFactory2
只能被调用一次,所以就报错。
为什么需要设置Factory2
主要是为了解决版本兼容性问题,向下兼容,AppCompatActivity
设置 Factory 是为了将一些 widget 自动变成 兼容widget (例如将 TextView 变成 AppCompatTextView
)以便于向下兼容新版本中的效果,在高版本中的一些 widget 新特性就是这样在老版本中也能展示的。
那如果我们设置了自己的 Factory 岂不是就避开了系统的兼容?其实系统的兼容我们仍然可以保存下来,因为系统是通过 AppCompatDelegate.onCreateView
方法来实现 widget 兼容的,那我们就可以在设置 Factory
的时候先调用 AppCompatDelegate.onCreateView
方法,再来做我们的处理。
LayoutInflater.from(this).setFactory2(new LayoutInflater.Factory2() {
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
// 调用 AppCompatDelegate 的createView方法
getDelegate().createView(parent, name, context, attrs);
// 再来执行我们的定制化操作
return null;
}
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
return null;
}
});
LayoutInflaterCompat
LayoutInflaterCompat
是一个兼容类,带Compat
后缀的表示是一个兼容类,效果更好,必须在super.onCreate(savedInstanceState)
;之前调用,否则无效
LayoutInflaterCompat.setFactory2(getLayoutInflater(), new LayoutInflater.Factory2() {
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
//替换项目中的TextView,生成自定义文本
if (TextUtils.equals(name, "TextView")) {
// 生成自定义TextView
}
//获取控件的加载耗时
long time = System.currentTimeMillis();
View view = getDelegate().createView(parent, name, context, attrs);
LogUtils.i(name " cost " (System.currentTimeMillis() - time));
return view;
}
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
return null;
}
});
AsyncLayoutInflate
注意事项
- 使用异步
inflate
,那么需要这个layout
的 parent 的generateLayoutParams
函数是线程安全的; - 所有构建的 View 中必须不能创建
Handler
或者是调用Looper.myLooper;
(因为是在异步线程中加载的,异步线程默认没有调用Looper.prepare
); - 异步转换出来的 View 并没有被加到 parent view中,
AsyncLayoutInflater
是调用了LayoutInflater.inflate(int, ViewGroup, false)
,因此如果需要加到parent view
中,就需要我们自己手动添加; AsyncLayoutInflater
不支持设置LayoutInflater.Factory
或者LayoutInflater.Factory2;
- 不完全支持加载包含 `Fragment 的 layout;
- 如果
AsyncLayoutInflater
失败,那么会自动回退到UI线程来加载布局;
简称为异步Inflater
workThread
加载布局- 回调到主线程
- 节省主线程时间
new AsyncLayoutInflater(MainActivity.this).inflate(R.layout.activity_main, null, new AsyncLayoutInflater.OnInflateFinishedListener() {
@Override
public void onInflateFinished(@NonNull View view, int i, @Nullable ViewGroup viewGroup) {
setContentView(view);
mRecyclerView = findViewById(R.id.recycler_view);
mRecyclerView.setLayoutManager(new LinearLayoutManager(MainActivity.this));
mRecyclerView.setAdapter(mNewsAdapter);
mNewsAdapter.setOnFeedShowCallBack(MainActivity.this);
}
});
AsyncLayoutInflate 不足
- 不能设置
LayoutInflater.Factory
(自定义解决) - 注意
view
中不能有依赖于主线程的操作
X2C框架加载布局
一般大家在写页面时都是通过xml
写布局,通过setContentView、或LayoutInflater.from(context).inflate
方法将xml
布局加载到内存中。
优点
- 可维护性好
- 支持即时预览
- 代码结构清晰
缺点
- 读取xml
很耗时
- 递归解析xml
较耗时
- 反射生成对象的耗时是new
的3倍以上
X2C
框架在编译的时候将xml
文件自动转换成java
文件
Lancet 框架使用
代码语言:javascript复制@Proxy("i")
@TargetClass("android.util.Log")
public static int i(String tag,String msg){
msg = msg "lancet";
return (int)Origin.call();
}
这里有几个关键点:
- @TargetClass
指定了将要被织入代码的目标类android.util.Log
- @Proxy
指定了将要被织入代码目标方法i
,使用新的方法替换原有方法
- 织入方式为Proxy
- Origin.call()
代表了Log.i()
这个目标方法,有返回值
如果被织入的代码是静态方法,这里也需要添加static
关键字,否则不会生效
所以这个示例Hook方法的作用就是将代码中所有Log.i(tag,msg)
替换为Log.i(tag,msg "lancet")
,将生成的apk反编译后,查看代码,所有调用Log.i的地方都会变为
_lancet.com_xxx_xxx_xxx(类名)_i(方法名)("tag", "msg");
- @Insert
将新代码插入到目标方法原有代码前后
- @Insert
常用于操作App与library
的类,并且可以通过This操作目标类的私有属性与方法
- @Insert
当目标方法不存在时,还可以使用mayCreateSuper
参数来创建目标方法。
TargetClass
通过类查找
@TargetClass
的value是一个类的全称
- Scope.SELF
仅代表匹配value指定的目标类
- Scope.DIRECT
代表匹配value指定类的直接子类
- Scope.ALL
代表匹配value指定类的所有子类
- Scope.LEAF
代表匹配value指定类的最终子类。众所周知java是单继承,所以继承关系是树形结构,这里代表了指定类为顶点的继承树的所有叶子节点。
@ImplementedInterface
通过接口查找,情况比通过类查找稍微复杂一些
@ImplementedInterface
的value
可以填写多个接口的全名。
- Scope.SELF
:代表直接实现所有指定接口的类。
- Scope.DIRECT
:代表直接实现所有指定接口,以及指定接口的子接口的类。
- Scope.ALL
:代表Scope.DIRECT
指定的所有类及他们的所有子类。
- Scope.LEAF
:代表Scope.ALL
指定的森林结构中的所有叶节点。
Origin
Origin
用来调用原目标方法,可以被多次调用
- Origin.call()
用来调用有返回值的方法。
- Origin.callVoid()
用来调用没有返回值的方法。
另外,如果你又捕捉异常的需求,可以使用
Origin.call/callThrowOne/callThrowTwo/callThrowThree()
Origin.callVoid/callVoidThrowOne/callVoidThrowTwo/callVoidThrowThree()
代码语言:javascript复制public class ActivityRecord {
public long mOnCreateTime;
public long mOnWindowsFocusChangedTime;
}
public class ActivityHooker {
public static ActivityRecord sActivityRecord;
static {
sActivityRecord = new ActivityRecord();
}
public static String trace;
//mayCreateSuper 当目标函数不存在进行创建
@Insert(value = "onCreate",mayCreateSuper = true)
@TargetClass(value = "android.support.v7.app.AppCompatActivity",scope = Scope.ALL)
protected void onCreate(Bundle savedInstanceState) {
sActivityRecord.mOnCreateTime = System.currentTimeMillis();
Origin.callVoid();
}
//scope = Scope.ALL 作用范围
@Insert(value = "onWindowFocusChanged",mayCreateSuper = true)
@TargetClass(value = "android.support.v7.app.AppCompatActivity",scope = Scope.ALL)
public void onWindowFocusChanged(boolean hasFocus) {
sActivityRecord.mOnWindowsFocusChangedTime = System.currentTimeMillis();
LogUtils.i("onWindowFocusChanged cost " (sActivityRecord.mOnWindowsFocusChangedTime - sActivityRecord.mOnCreateTime));
Origin.callVoid();
}
public static long sStartTime = 0;
@Insert(value = "acquire")
@TargetClass(value = "com.optimize.performance.wakelock.WakeLockUtils",scope = Scope.SELF)
public static void acquire(Context context){
trace = Log.getStackTraceString(new Throwable());
sStartTime = System.currentTimeMillis();
Origin.callVoid();
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
WakeLockUtils.release();
}
},1000);
}
@Insert(value = "release")
@TargetClass(value = "com.optimize.performance.wakelock.WakeLockUtils",scope = Scope.SELF)
public static void release(){
LogUtils.i("PowerManager " (System.currentTimeMillis() - sStartTime) "/n" trace);
Origin.callVoid();
}
public static long runTime = 0;
@Insert(value = "run")
@TargetClass(value = "java.lang.Runnable",scope = Scope.ALL)
public void run(){
runTime = System.currentTimeMillis();
Origin.callVoid();
LogUtils.i("runTime " (System.currentTimeMillis() - runTime));
}
//对Log.i进行hook 在后面加ActivityHooker后缀,对系统方法hook,TargetClass Hook 那个类的那个方法
@Proxy("i")
@TargetClass("android.util.Log")
public static int i(String tag, String msg) {
msg = msg "ActivityHooker";
return (int) Origin.call();
}
}