Android 性能优化之布局优化实战

2019-12-04 15:39:38 +08:00
 winlee28

​本文首发于微信公众号「 Android 开发之旅」,欢迎关注

Android 绘制原理

手机渲染主要依赖于两个硬件:CPU 和 GPU,其中 CPU 主要负责计算显示内容,其中包括视图创建、布局计算、图片解码和文本绘制等。GPU 主要负责栅格化( UI 元素绘制到屏幕上),比如将 Button、Bitmap 拆分成不同的像素进行显示,最后完成绘制。

手机上显示的文字就是先通过 CPU 换算成纹理后在交给 GPU 进行渲染。而图片的显示首先通过 CPU 进行计算,然后再加载到内存中,传给 GPU 进行渲染。

我们都知道 Android 系统每隔 16ms 就会发出 Vsync 信号(具体是由 RootViewImpl 类发起)触发 UI 渲染,即要求每一帧都要在 16ms 内渲染完成,所以不管你的布局逻辑多么的复杂,你都要在 16ms 内绘制完成,否则就会出现界面卡顿的现象。

我们市面上绝大部分 Android 手机的屏幕刷新频率基本都是 60Hz,因为 60Hz 每秒是人眼和大脑之间合作的极限,就像动画每秒 24 帧一样。

优化工具选择

Systrace

这个我们在启动优化中讲过具体的使用,这里呢,我们主要关注他的 Frames 一行,显示绿色圆点表示正常,显示黄色或者红色表示出现了丢帧,出现丢帧的情况的时候我们需要去查看 Alerts 栏。

Layout Inspector

这个是 Android Studio 自带的检测工具,在 Tools 栏目下。它可以帮助我们查看视图的层次结构。

从图中我们可以看到左侧一览显示布局的层级。

ChoreoGrapher

choreoGrapher 可以帮助我们获取应用的 FPS,即上文中的 60Hz,并且可以线上使用,具备实时性。但是有一点需要注意的是必须 API 16 后使用。如下代码:

    private var mStartFrameTime: Long = 0
    private var mFrameCount = 0
    private val MONITOR_INTERVAL = 160L //单次计算 FPS 使用 160 毫秒
    private val MONITOR_INTERVAL_NANOS = MONITOR_INTERVAL * 1000L * 1000L
    private val MAX_INTERVAL = 1000L //设置计算 fps 的单位时间间隔 1000ms,即 fps/s;
​
    override fun onCreate(savedInstanceState: Bundle?) {
        setTheme(R.style.AppTheme)
        super.onCreate(savedInstanceState)
​
        setContentView(R.layout.activity_main)
        
        getFPS()
    }
​
​
    @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
    private fun getFPS() {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) {
            return
        }
        Choreographer.getInstance().postFrameCallback(object : Choreographer.FrameCallback {
            override fun doFrame(frameTimeNanos: Long) {
                if (mStartFrameTime == 0L) {
                    mStartFrameTime = frameTimeNanos
                }
                val interval = frameTimeNanos - mStartFrameTime
                if (interval > MONITOR_INTERVAL_NANOS) {
                    val fps = (mFrameCount.toLong() * 1000L * 1000L).toDouble() / interval * MAX_INTERVAL
                    Log.i("fps", fps.toString())
                    mFrameCount = 0
                    mStartFrameTime = 0
                } else {
                    ++mFrameCount
                }
​
                Choreographer.getInstance().postFrameCallback(this)
            }
        })
​
​
    }

执行代码后输出:

fps: 60.0158955700371
fps: 60.00346688030940
fps: 60.01226146521353
fps: 59.98537016806971
fps: 60.00205735054243

每次打印的数据都在 60 左右,说明页面刷新没有出现卡顿。

布局加载原理

我们经常写的 XML 布局文件是如何被加载的呢?又是如何显示出来的?下面就带着大家顺着源码往下看,这里就不截图了,读者朋友们看完本章后自己可以去熟悉下这块代码。

首先要从 setContentView 方法开始说起了,其中调用了 getDeleate().setContentView(resid)方法,接着调用了 LayoutInflater.from(this.mContext).inflate(resId, contentParent)来填充布局,这个 API 我们大家应该都很熟悉了吧。紧接着调用 getLayout 方法,在 getlayout 方法中通过 loadXmlResourceParser 加载并解析 XML 布局文件,后面调用 createViewFromTag 方法,根据标签创建相对应为 view,具体 view 的创建则是由 Factory 或者 Factory2 来完成的,首先先判断了 Factory2 为否为 null,不为 null,则用其创建 view,否则就判断 Factory 是否为 null,不为 null,则由其创建。如果两个都为 null,则不创建 view,紧接着判断了 mPrivateFactory 是否为 null,这里需要说明的是 mPrivateFactory 是一个隐藏的 API 只有 framework 才能调用,如果都没创建,那么 view 则由后续逻辑通过 onCreateView 或者 createView 通过反射来创建。具体流程图如下:

从上面的分析中我们可以看出加载布局是有瓶颈的。其中有两个瓶颈分别是在布局文件解析的时候是一个 IO 过程,这肯定是比较耗时的。再一个就是最后创建 View 的时候是通过反射的方式进行的。既然是反射性能肯定也是有影响的,后面我们也是围绕这两点进行布局加载的优化。

获取界面布局耗时

我们做优化的前提就是得知道哪里是比较耗时的,所以检测耗时的 UI 还是蛮重要的。只有知道问题在哪了才能针对性的解决它。这里讲到检测耗时,读过我启动优化一文的读者肯定能想到至少两种方式,一种是手动埋点,另外一种就是 AOP 的方式。手动埋点呢就是在 setContentView 方法的前后执行的地方手动打点。

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        LaunchRecord.startRecord()
        setContentView(R.layout.activity_main)
        LaunchRecord.endRecord("setContentView")
​
    }

打印:

===setContentView===170

这种方式呢不够优雅而且对代码有侵入性。

下面我们看下 AOP 的方式,操作和启动优化一文中的一样。

    @Around("call(* android.app.Activity.setContentView(..))")
    public void getSetContentViewTime(ProceedingJoinPoint joinPoint) {
        Signature signature = joinPoint.getSignature();
        String name = signature.toShortString();
        long time = System.currentTimeMillis();
        try {
            joinPoint.proceed();
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }
        Log.d("ContentViewTime", name + " cost " + (System.currentTimeMillis() - time));
    }

控制台打印:

ContentViewTime: MainActivity.setContentView(..) cost 74

以上两种方法都是获取全部布局被加载完成后的时间,那么如果想获取单个控件的加载耗时如何做呢?这里给大家介绍 LayoutInflaterCompat.setFactory2 方式(大家以后看到带有 Compat 字段的都是兼容的 API ),其使用必须在 super.onCreate 之前调用。

public class MainActivity extends AppCompatActivity {
​
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
​
        LayoutInflaterCompat.setFactory2(getLayoutInflater(), new LayoutInflater.Factory2() {
            @Override
            public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
​
                long start = System.currentTimeMillis();
                View view = getDelegate().createView(parent, name, context, attrs);
                long cost = System.currentTimeMillis() - start;
                Log.d("onCreateView", "==" + name + "==cost==" + cost);
                return view;
            }
​
            @Override
            public View onCreateView(String name, Context context, AttributeSet attrs) {
                return null;
            }
        });
​
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }
}

控制台打印:

onCreateView: ==LinearLayout=cost==16
onCreateView: ==ViewStub=cost==0
onCreateView: ==FrameLayout=cost==0
onCreateView: ==android.support.v7.widget.ActionBarOverlayLayout=cost==0
onCreateView: ==android.support.v7.widget.ContentFrameLayout=cost==0
onCreateView: ==android.support.v7.widget.ActionBarContainer=cost==0
onCreateView: ==android.support.v7.widget.Toolbar=cost==0
onCreateView: ==android.support.v7.widget.ActionBarContextView=cost==0
onCreateView: ==android.support.constraint.ConstraintLayout=cost==0
onCreateView: ==TextView=cost==3
onCreateView: ==ImageView=cost==24

LayoutInflaterCompat.setFactory2 的 API 不仅仅是可以统计 View 创建的时间,其实我们还可以用来替换系统控件的操作,比如某一天产品经理提了一个需求要我们将应用的 TextView 统一改成某种样式,我们就可以使用这种方式来做。如:

 LayoutInflaterCompat.setFactory2(getLayoutInflater(), new LayoutInflater.Factory2() {
            @Override
            public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
​
               if(TextUtils.equals("TextView",name)){
                   //替换为我们自己的 TextView
​
               }
​
               return null;//返回自定义 View
            }
​
            @Override
            public View onCreateView(String name, Context context, AttributeSet attrs) {
                return null;
            }
        });

只要我们在基类 Activity 的 onCreate 中定义这个方法,就可以实现相关效果。

布局加载优化

基于布局加载的两个性能问题,谷歌给我们提供了一个类 AsyncLayoutInflater,它可以从侧面解决布局加载耗时的问题,AsyncLayoutInflater 是在工作线程中加载布局,加载好后会回调到主线程,这样可以节省主线程的时间。这个类没有包含在 SDK 中,需要我们在 gradle 中配置,如:

implementation 'com.android.support:asynclayoutinflater:28.0.0-alpha1'

使用:

public class MainActivity extends AppCompatActivity {
​
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
​
        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); //view 以及加载完成
                //可以在这里 findViewById 相关操作
            }
        });
​
        super.onCreate(savedInstanceState);
      //  setContentView(R.layout.activity_main); //这里就不用设置布局文件了
    }
}

我们在 inflate 的时候就将布局文件设置给 AsyncLayoutInflater,所以下面我们就不需要在 setContentView 了。

上面说的 AsyncLayoutInflater 是从侧面解决布局加载耗时问题,那么我们如何从根本上解决这个问题呢?主要问题就是我们书写的 XML 文件需要加载解析和绘制,那如果我们不使用 XML 文件写布局文件,问题是不是就解决?在 Android 中,还有另外一种方式来写布局文件,那就是 Java 代码,通过 Java 代码来写布局,本质上是解决了性能问题,但是不便于开发,没有实时预览,而且可维护性太差。那么如果能有一种解决方式就是,我们开发人员还是正常写 XML 文件,但是在加载的时候加载的是 Java 代码,那这样是不是很完美了。

所以下面给大家介绍一个新的框架:X2C,这是掌阅开源的一个框架,它保留了 XML 的优点,同时解决了性能问题,开发人员写 XML 文件,加载的时候只加载 Java 代码。

X2C 的原理就是通过 APT 编译期时将 XML 翻译为 Java 代码。使用也很简单,首先 gradle 配置:

    annotationProcessor 'com.zhangyue.we:x2c-apt:1.1.2'
    implementation 'com.zhangyue.we:x2c-lib:1.0.6'

Java 代码使用:

@Xml(layouts = "activity_main")
public class MainActivity extends AppCompatActivity {
​
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
      //  setContentView(R.layout.activity_main); //这里就不用设置布局文件了
    }
}

编译之后会在 build/generated/source/apt/debug/ 下面生成相关的文件。如我们的 activity_main 的布局文件会被翻译为:

      Resources res = ctx.getResources();
​
        ConstraintLayout constraintLayout0 = new ConstraintLayout(ctx);
​
        TextView textView1 = new TextView(ctx);
        ConstraintLayout.LayoutParams layoutParam1 = new ConstraintLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,ViewGroup.LayoutParams.WRAP_CONTENT);
        textView1.setId(R.id.mTextView);
        layoutParam1.topMargin= (int)(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,100,res.getDisplayMetrics())) ;
        textView1.setText("Hello World!");
        layoutParam1.leftToLeft = 0 ;
        layoutParam1.rightToRight = 0 ;
        layoutParam1.topToTop = 0 ;
        layoutParam1.validate();
        textView1.setLayoutParams(layoutParam1);
        constraintLayout0.addView(textView1);
​
        ImageView imageView2 = new ImageView(ctx);
        ConstraintLayout.LayoutParams layoutParam2 = new ConstraintLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,ViewGroup.LayoutParams.WRAP_CONTENT);
        layoutParam2.topMargin= (int)(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,20,res.getDisplayMetrics())) ;
        imageView2.setImageResource(R.mipmap.ic_launcher);
        layoutParam2.leftToLeft = 0 ;
        layoutParam2.rightToRight = 0 ;
        layoutParam2.topToBottom = R.id.mTextView ;
        layoutParam2.validate();
        imageView2.setLayoutParams(layoutParam2);
        constraintLayout0.addView(imageView2);

运行 APP,效果也是一样的。

X2C 虽好,但也有一些问题,就是部分属性 Java 不支持,而且失去了系统的兼容性( AppCompat )。所以如果要带到线上使用,那么就要兼容不同的版本,所以需要定制化修改源码。

视图绘制优化

我们知道视图的绘制通常经历三个阶段,测量,确定 view 的大小。布局,确定 view 的具体位置包括 view 和 viewGroup 等。绘制,将 view 绘制完成。不管是测量、布局还是绘制,每一个阶段都是比较耗时的,都是自上而下的遍历每一个 view,在某些场景下还会触发多次,比如嵌套使用 RelativeLayout 布局。

所以为了减少三个阶段的耗时,我们需要减少 view 树的层级,不要嵌套使用 RelativeLayout 布局,不在嵌套使用的 LinearLayout 中使用 weight 属性。适当的使用 merge 标签,它可以减少一个 view 层级,但是必须使用在根 view 上。

这里推荐大家使用 ConstraintLayout 布局,ConstraintLayout 几乎实现了完全扁平化的布局,而且在构建复杂布局上面性能更高,同时他还具备了 RelativeLayout 和 LinearLayout 的特性,使用很方便。

同时我们在书写布局的时候还要注意避免过度绘制。Android 手机在开发者选项中有个功能叫:调试 GPU 过度绘制。打开后手机界面会有一层蒙版,其中蓝色表示可以接受,红色表色出现过度绘制了。那我们如何避免过度绘制呢?首先是去掉多余的背景色,减少复杂 shape 的使用,避免层级叠加,在用自定义 view 的时候使用 ClipRect 屏蔽被遮盖 view 的绘制。

还有其他的一些优化视图绘制,比如使用 Viewstub,它是一个高效的占位符,可以用来延迟加载 view 布局。还有就是我们在 onDraw 中避免创建较大的对象和做耗时的操作等等。

总结

以上就是相关布局优化相关的操作,也是从耗时到优化各个阶段的说明和操作。读者朋友们在看完本章节后,自己动手实践下,只有实际实践了才能发现问题,加深自己印象。

扫描下方二维码关注公众号,及时获取文章推送。

9183 次点击
所在节点    Android
2 条回复
kimiler
2019-12-04 15:59:32 +08:00
棒棒的
am757058am
2019-12-04 20:13:51 +08:00
好文

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/625900

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX