本片是对Android的性能优化的一系列文章中的其中一篇的翻译,原文地址如下
https://developer.android.com/training/improving-layouts/optimizing-layout.html#Inspect
一.前言
布局是Android应用直接影响用户体验的一个重要的部分,如果优化的不好,那么应用很可能频繁的出现内存不足以及界面响应过慢的问题。Android的SDK已经提供了一系列的工具用于帮助开发者找出布局中的一些问题,这里会叙述一系列的案例并结合这些工具的使用来帮助开发者实现一个流畅的布局界面。
本片文章将从下面几个部分进行叙述
- 优化布局层级
正如我们日常使用的浏览器一样,一个复杂的web页面会让加载时间边长,我们的布局层级如果特别复杂同样也会导致性能问题。本篇案例阐释了如何使用SDK的工具检查应用的布局问题以及发现性能瓶颈。 - 利用<include/>复用布局
应用中的UI重复地在多处使用,就需要结合本篇案例了解如何创建一个高效的可重用的布局,并且在需要的地方include这些UI。 - 懒加载View
你可能希望只在需要的时候才让被包含的布局课件,比如当Activity正在运行的时候。这篇案例介绍了如何通过只在需要的时候加载视图来提升布局的初始化性能。 - 让ListView起飞
如果你曾经创建过一个包含复杂列表项的ListView实例,那么这个列表的滚动性能会让你抓狂。这篇案例提供了一些小的帮助点用于帮助大家创建一个流畅的ListView
二.优化布局层级
使用SDK提供的基础Layout控件就一定能构建出一个高效的布局结构是一个常见的误区,实际上我们布局中使用的所有控件在运行中都是需要经历初始化、布局、绘制三个步骤的。那么使用嵌套的LinearLayout会产生非常深的视图层级,三个步骤也就被反复执行,更有如果在这些嵌套的LinearLayout中还使用layout_weight参数的话更容易造成性能问题,因为每一个子布局都需要测量(Measure)两次。这一点是非常重要的,尤其是在一些经常被复用的布局视图如ListView或者GridView中。
下面将通过例子的方式展示Hierarchy Viewer以及Layoutopt的使用
检查布局
Android SDK中有一个叫做『Hierarchy Viewer』的工具,它能够帮助你在应用运行的过程中去分析应用的布局,以便去发现布局性能的瓶颈。
Hierarchy Viewer的使用非常简单,首先在模拟器或者已经连接上的真机中选择需要分析的进程,然后就可以看到布局树(Layout Tree)了。布局树中每一块都用红黄绿三色信号灯描述当前的测量、布局、绘制的性能,用于帮助你发现一些潜在的问题。
比如Figure 1展示了一个简单的布局,这个布局由左侧的Bitmap以及右侧竖直排序的两个TextView构成。这是一个用于ListView的Item的经典结构,因此这一布局的性能非常重要,因为它将被复用很多次,同时它的优化带来的性能提升也非常大。
Hierarchy Viewer在SDK目录下的tools目录下,打开之后会发现它展示了所有链接上的设备以及在它们之上运行的一些项目。点击Load View Hierarcy可以去查看选中的项目(注:我这边贴一张图作为补充,如下所示,可以看到我这里链接了一款乐视的手机,其中可以选择进行分析的项目有多个)
点击Load View Hierarcy之后会出现Figure 2所示的实际结果
在Figure 2中我们可以看到这里有三层视图结构,其中在展示文本项的时候似乎出现了一点问题。点击其中的每一个小块可以看到测量,布局,绘制的详细耗时情况,如Figure 3所示,这样就可以有针对性的对某一部分进行优化。
因此ListView中每一项渲染的实际耗时情况如下
- 测量: 0.977ms
- 布局: 0.167ms
- 绘制: 2.717ms
修正布局
通过Figure 2我们可以看到嵌套的LinearLayout造成了布局上的性能问题,通过将嵌套的布局拆开保证布局树的扁平化是优化的一个方向。RelativeLayout作为根节点可以达到我们的目的,实际上使用RelativeLayout之后我们原有的视图层级从3层降到了2层,分析结果也变成Figure 4所示的样子
此时,渲染ListView的一项耗时情况如下
- 测量: 0.598ms
- 布局: 0.110ms
- 绘制: 2.146ms
看起来是很小的提升,但是考虑到ListView中Item的复用性,这一点提升也不容小觑。
这些时间上的区别更多的还是由于在LinearLayout中使用了layout_weight参数导致的,这个参数会让测量部分耗时翻倍。上面只是一个简单的小例子,实际使用中我们应该根据需要更恰当的选择不同的布局。
使用Lint
在布局文件上使用lint工具用于发现布局上可以优化的点是一个非常好的习惯。lint由于具有非常强大的功能,因此替代了以前使用的Layoutopt工具,一些简单的lint规范案例如下
- 使用compound drawable
仅包含TextView和ImageView的LinearLayout布局可以使用compound drawable高效的替换。 - 合并根Frame
如果一个FrameLayout作为根节点但是没有包含背景颜色同时也不包含任何padding等信息,那么可以使用merge标签替换,这样可以略微提升性能。 - 无用叶视图
如果一个布局没有任何子视图同时也不包含背景,那么它往往可以被移除(因为它根本不可见),这样可以有助于降低整个视图的层级,从而提升布局性能。 - 无用父容器
如果一个布局仅包含一个子视图,并且自身不是ScrollView也不是根布局同时也不包含背景的话,那么它也可以被移除,同时将自己的子视图作为自己父视图的直接子视图,这样也有助于降低整个视图的层级。 - 过深布局
嵌套层级过大的布局性能往往非常差,有时候我们应该考虑使用RelativeLayout或者GridLayout来帮助我们降低视图层级,默认的最大深度是10(注:指的是默认的lint检查会在单一布局文件布局深度到10的时候出现警告)。
使用lint的另一个好处是它已经集成到Android Studio中了,lint将在应用编译的时候自动运行。使用Android Studio你可以在构建某一特定的变种版本(Build variant)或者全部变种版本的时候执行lint检查。
你可以自己配置lint的检查文件去自定义一些内容,入口在Android Studio的File>Settings>Project Settings中,如Figure 5所示
lint可以对代码提供一些建议,同时也能帮我们自动修复一些问题。
项目问题
HV工具可以很好的帮助我们发现布局中的一些问题,具体使用可以参考Optimizing Your UI,同时lint的能力不仅仅体现在布局优化上,想要运行lint,也可以直接点击Analyze>Inspect Code,最终结果会分类目详细展示出来。具体如何使用lint,可以参考Improve Your Code with Lint
三.利用<include/>复用布局
虽然Android提供了很多的控件用于帮助我们在在布局文件中进行元素复用,但是实际项目中我们也许还需要更大层面上的复用元素,比如一个特殊的布局。为了高效的复用整个布局,你可以使用<include/>和<merge/>标签将已有布局嵌入其他布局中。
使用这个能力可以让你创造出非常复杂的可复用布局,比如一个带有yes/no的按钮板,带有文字描述的进度条。这同样意为着你项目中的任何一个布局元素都可以分开进行管理,当需要的时候你只要用到include就行。
创造可复用布局
如果你已经知道哪些布局需要被重用,那只需要新建一个xml文件,然后将被重用的布局写入进去就可以了。比如下面就是一个可以被重用的布局,文件名为titlebar.xml
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/titlebar_bg">
<ImageView android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/gafricalogo" />
</FrameLayout>
使用<include>标签
在你需要的地方用<include />标签添加之前定义的布局即可重用布局。比如我需要重用上面定义的titlebar.xml布局,代码可以这样
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/app_bg"
android:gravity="center_horizontal">
<include layout="@layout/titlebar"/>
<TextView android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/hello"
android:padding="10dp" />
...
</LinearLayout>
include中所有layout开头的属性(android:layout_*)都可以被重写,但他们只有在重写了android:layout_height和android:layout_width之后才生效
使用<merge>标签
merge标签可以帮助我们剔除布局层级中无用的视图层。比如你有一个LinearLayout作为根视图的布局,它里面需要有一个包含两个连续视图(比如按钮)的可重用布局,这个可重用布局你需要重新定义它的根视图,比如你会使用LinearLayout。但是这时候使用该LinearLayout作为可重用布局的根视图会导致增加一个毫无用处的视图层级。为了避免这种现象,可以使用<merge>作为可重用布局的根视图,比如
<merge xmlns:android="http://schemas.android.com/apk/res/android">
<Button
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:text="@string/add"/>
<Button
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:text="@string/delete"/>
</merge>
此时如果你将这个可重用布局使用<include>标签包含至其他布局文件中,系统会忽略merge元素然后将两个Button直接放置在include标签所在的地方。
注意点
<include>
- 重写layout_*的属性记得先重写android:layout_height和android:layout_width。
- include如果指定了id,那么layout属性的根视图id会被强制修改成include中的id,如果不注意很容易出现空指针问题。验证起来很简单,只需要使用HV工具即可,实际的源码分析就不贴出来了,有兴趣的话可以参考LayoutInflater.inflate方法
<merge>
- 上一节提到的布局文件,复用在LinearLayout和RelativeLayout中会有不同的表现,在前者会以线性的方式布局,后者delete按钮会遮挡add按钮,所以使用merge标签一定要注意实际的根视图类型
-
merge必须放在布局文件的根节点上
- merge并不是一个ViewGroup,也不是一个View,它相当于声明了一些视图,等待被添加。
-
因为merge标签并不是View,所以在通过LayoutInflate.inflate方法渲染的时候, 第二个参数必须指定一个父容器,且第三个参数必须为true,也就是必须为merge下的视图指定一个父亲节点。
- 因为merge不是View,所以对merge标签设置的所有属性都是无效的
- 如果Activity的布局文件根节点是FrameLayout,可以替换为merge标签,这样,执行setContentView之后,会减少一层FrameLayout节点。
关于Activity根节点是FrameLayout的证据,使用HV工具可以得到。 - 自定义XXXLayout控件时,如果使用LayoutInflater.inflate(R.layout.xxx, this, true)填充视图,那么该布局的根元素最好设置成<merge>,这一点其实是和上一点相同的,有助于直接减少视图层级。
项目问题
项目中使用include的地方有四十多处,大家对这个标签其实也比较属性,相反merge的话使用的地方仅有一处,建议可以结合上面的注意点尝试使用。
四.懒加载View
你的布局中可能存在很少情况下才用到的复杂布局,比如单条详情、进图条或者是一些撤销消息等等,这些布局可以只在你需要的时候才加载以提升布局的渲染速度。
定义ViewStub
ViewStub是一个轻量级的视图,它不参与绘制也不参与任何的布局工作。因此,它在视图层级的构建中消耗的资源是非常小的。每一个ViewStub在使用时只需要通过android:layout去定义它需要加载布局文件即可。
下面给出的ViewStub承载了一个透明的进度条,它只在特定情况下才需要展现给用户。
<ViewStub
android:id="@+id/stub_import"
android:inflatedId="@+id/panel_import"
android:layout="@layout/progress_overlay"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom" />
加载ViewStub布局
当我们需要让ViewStub承载的视图展现时,只需要通过调用setVisibility(View.VISIBLE)或者inflate()方法即可。
((ViewStub) findViewById(R.id.stub_import)).setVisibility(View.VISIBLE);
// or
View importPanel = ((ViewStub) findViewById(R.id.stub_import)).inflate();
一旦ViewStub被可见或者被布局了,那么它就从视图层级中剥离出来,取代ViewStub存在于视图层级的是android:layout属性所指定的布局,该布局的id可以通过android:inflatedId指定。
这里和include一样,android:inflatedId属性也会覆盖layout中根视图的id。
注意点
ViewStub是一个比较特殊的View,与渲染相关的方法它的实现基本都是空实现,因此能节约很多性能。除此之外它的其他特性要求我们使用时要稍加注意。
- ViewStub只能被inflate一次,多次调用会出异常。第一次setVisibility(View.Visibility)会被动调用一次inflate,因此需要注意。
- ViewStub被inflate之后会从视图层级中移除,因此再次调用findViewById尝试获取ViewStub对象会返回空,不要尝试使用该对象,否则会出现空指针。
- ViewStub中layout_*属性都是为新加载的视图的根视图设置的,与<include>标签一样,ViewStub加载的根视图自身的layout_*属性会被ViewStub重写。比如layout_height,它不能指定ViewStub本身的高度,因为ViewStub本身的高度和宽度都是0,它指定的其实是需要加载的布局的根视图高度。又由于此,在布局时要注意基于ViewStub的相对布局在ViewStub未inflate之前,位置与实际位置是有偏差的。
- 一般xml文件中定义的属性都可以通过代码设置,同样ViewStub也可以通过方法setLayoutResource在代码中动态设置应该加载的layout文件,此时一个ViewStub就可以根据逻辑不同使用不同的视图。
项目问题
项目中可能大家更习惯使用Visibility的切换而不是ViewStub。如果在布局中你有需要设置可见性的地方,不妨思考是否需要频繁切换它的可见状态,是否需要懒加载,如果用户见到的可能性不大或者它本身也不经常切换自身的可见性,那么可以考虑使用ViewStub,比如『点击展开详情』这种类似的功能。
五.让ListView起飞
让ListView更流畅的最重要的一点是要牢记让主线程从繁杂的任务任务中解放出来,确保磁盘访问、网络请求或者数据库操作是在单独的线程中的。可以使用StrictMode去验证你的app是否遵循这一点。
使用后台线程
使用后台线程(也成为工作线程)去处理原本打算放在主线程中的复杂逻辑,以保证主线程更专注的处理UI绘制工作。通常情况下,使用AsyncTask提供了一个非常简单的方式用于帮助你在主线程之外处理逻辑。AsyncTask自动的将所有的execute()请求队列化,然后依次执行,当然这一策略不会影响你自己创建的线程池。
在下面的样例代码中,AsyncTask用于在后台加载图片,并且在图片加载完成后提供给主线程使用,它允许在图片加载过程中使用进度条做界面展示。
// Using an AsyncTask to load the slow images in a background thread
new AsyncTask<ViewHolder, Void, Bitmap>() {
private ViewHolder v;
@Override
protected Bitmap doInBackground(ViewHolder... params) {
v = params[0];
return mFakeImageLoader.getImage();
}
@Override
protected void onPostExecute(Bitmap result) {
super.onPostExecute(result);
if (v.position == position) {
// If this item hasn't been recycled already, hide the
// progress and set and show the image
v.progress.setVisibility(View.GONE);
v.icon.setVisibility(View.VISIBLE);
v.icon.setImageBitmap(result);
}
}
}.execute(holder);
从3.0开始,AsyncTask提供了额外的方式以允许你提高在多核手机上的并发处理能力,此时你需要用executeOnExecutor()替换之前的execute()方法。(注:AsyncTask在刚开始是以单独线程去处理所有请求的,从1.6开始被修改成以线程池的方式处理所有的请求,但是从3.0开始又改成了单独线程处理所有请求,想想谷歌是挺好玩的。不过正如这里说3.0版本之后你可以使用executeOnExecutor方法去制定自己的AsyncTask线程池。)
使用View Holder保持视图对象
你可能要使用findViewById()方法去获取视图对象,但是如果getView时也这么做的话,那么滚动的过程中就会触发N多次该方法的调用,这一点即便在Adapter提供了滚动过程中使用复用视图以避免重复inflate也无法得到改观。一个比较好的方法去避免N多次调用findViewById是使用『view holder』。
一个ViewHolder对象存储了ListView中的Item的Layout中所需要的视图组件,所以使用了ViewHolder之后你可以直接访问到它们而无需多次调用findViewById。为了使用ViewHolder首先你需要定义自己的类
static class ViewHolder {
TextView text;
TextView timestamp;
ImageView icon;
ProgressBar progress;
int position;
}
然后在需要的时候创建并存储它
ViewHolder holder = new ViewHolder();
holder.icon = (ImageView) convertView.findViewById(R.id.listitem_image);
holder.text = (TextView) convertView.findViewById(R.id.listitem_text);
holder.timestamp = (TextView) convertView.findViewById(R.id.listitem_timestamp);
holder.progress = (ProgressBar) convertView.findViewById(R.id.progress_spinner);
convertView.setTag(holder);
现在你就可以直接访问到所有的视图了,节约了很多资源。
项目问题
后台线程还有ViewHolder,大家使用的已经很多了,应该没有特别大的问题。