喜马拉雅车载版-项目优化点小结

目前的现状

app在低版本上面卡顿比较明显,掉帧严重,布局嵌套严重
酷我的app业务层开始是第4层,平均ui显示在第7-11层,我们的app从第8层开始展现业务,平均层级在14-18层
酷我的车机执行滑动时绘制时间平均为500ms,我们的app绘制时间平均为800多ms(仅从主页来讲)

因此将部分在长安车机上面验证的优化点列下来,之后组内性能优化的时候可以有个参照,另外也附注了部分源码级别的解释,大家可以看看优化的原理,如果有不同的也可以讨论讨论。此文档长期维护,优化点会逐渐增加。

优化指标主要这几个方面:cpu占用率、内存占用、帧率、电量、流量、过度绘制、包大小、启动时间、crash率

整理的优化点

recyclerview

recyclerview的优化主要在数据预加载、复用性提升、降低绘制次数上面做文章,其中复用性的提升亦会带来绘制次数的降低

数据预加载

getExtraLayoutSpace

1
2
3
4
5
6
LinearLayoutManager linearLayoutManager = new LinearLayoutManager(getContext(), LinearLayoutManager.HORIZONTAL, false){
@Override
protected int getExtraLayoutSpace(RecyclerView.State state) {
return 10000;
}
};

初始化recyclerview的layoutmanager的时候,通过复写getextralayoutspace,可以通过增加绘制时间,来提前加载不可见区域的视图

其原理主要是在layoutmanager.onLayoutChildren->fill()中执行的下面这段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
while((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) {
layoutChunkResult.resetInternal();
this.layoutChunk(recycler, state, layoutState, layoutChunkResult);
if (layoutChunkResult.mFinished) {
break;
}

layoutState.mOffset += layoutChunkResult.mConsumed * layoutState.mLayoutDirection;
if (!layoutChunkResult.mIgnoreConsumed || this.mLayoutState.mScrapList != null || !state.isPreLayout()) {
layoutState.mAvailable -= layoutChunkResult.mConsumed;
remainingSpace -= layoutChunkResult.mConsumed;
}

if (layoutState.mScrollingOffset != -2147483648) {
layoutState.mScrollingOffset += layoutChunkResult.mConsumed;
if (layoutState.mAvailable < 0) {
layoutState.mScrollingOffset += layoutState.mAvailable;
}

this.recycleByLayoutState(recycler, layoutState);
}

if (stopOnFocusable && layoutChunkResult.mFocusable) {
break;
}
}

其影响到了remainingSpace的值,复写会提升remainingspace的大小,带来的效果就是会在加载完毕限制的viewholder之后在加载一段空间,起到了预加载的地步,但是由于其实际上会造成加载时间增加的问题,因此建议用在例如推荐卡片页,电台自选页面,分类自选页面这种没有分页的页面,写的数值根据各自的情况来定,数值的单位是像素。

复用性提升

setItemViewCacheSize

1
setItemViewCacheSize()

使用setItemViewCacheSize,可以在更新viewholder的时候指定复用的viewholder数量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void updateViewCacheSize() {
int extraCache = RecyclerView.this.mLayout != null ? RecyclerView.this.mLayout.mPrefetchMaxCountObserved : 0;
this.mViewCacheMax = this.mRequestedCacheMax + extraCache;

for(int i = this.mCachedViews.size() - 1; i >= 0 && this.mCachedViews.size() > this.mViewCacheMax; --i) {
this.recycleCachedViewAt(i);
}
}

void recycleCachedViewAt(int cachedViewIndex) {
RecyclerView.ViewHolder viewHolder = (RecyclerView.ViewHolder)this.mCachedViews.get(cachedViewIndex);
this.addViewHolderToRecycledViewPool(viewHolder, true);
this.mCachedViews.remove(cachedViewIndex);
}

其原理如上,我们指定的viewholder数量会影响到将cachedViews的最大数量,而cachedView就是我们刚才看到的视图,如果viewholder一旦被从cachedView移除,进入recyclerpool之中的话,其页面的内容就会被清空,滑回去在希望显示的话,就会出现重新bind,重新绘制,影响效率。

setItemPrefetchEnabled(true)

上面updateViewCacheSize中还有一个mPrefetchMaxCountObserved,是recyclerview升级到25.1之后才有的特性
其原理数据预取
总结就是通过将主线程对viewholder的create和bind的操作提前到render画布绘制的过程中进行,以降低主线程卡顿掉帧的时间。这也也算是复用性提升的一点。
默认是开启的

setSupportsChangeAnimations(false)

1
((SimpleItemAnimator) rv.getItemAnimator()).setSupportsChangeAnimations(false); 

其生效代码为recyclerview.scrapview

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void scrapView(View view) {
RecyclerView.ViewHolder holder = RecyclerView.getChildViewHolderInt(view);
if (!holder.hasAnyOfTheFlags(12) && holder.isUpdated() && !RecyclerView.this.canReuseUpdatedViewHolder(holder)) {
if (this.mChangedScrap == null) {
this.mChangedScrap = new ArrayList();
}

holder.setScrapContainer(this, true);
this.mChangedScrap.add(holder);
} else {
if (holder.isInvalid() && !holder.isRemoved() && !RecyclerView.this.mAdapter.hasStableIds()) {
throw new IllegalArgumentException("Called scrap view with an invalid view. Invalid views cannot be reused from scrap, they should rebound from recycler pool." + RecyclerView.this.exceptionLabel());
}

holder.setScrapContainer(this, false);
this.mAttachedScrap.add(holder);
}

}

其作用总结为:当更新一个itemview的时候,其更新的步骤最终会走到scrapView这个方法中,这个方法决定是否废弃一个view,如果将其默认的itemanimator关闭的话,则不会废弃这个viewholder,而会继续使用这个viewholder,将其添加到attachscrap中进行数据更新的操作,反之,若没有关闭itemanimator的话,则会创建一个新的viewholder,而将老的viewholder塞入废弃表中。

复用性究极提升,复写RecyclerView.onViewRecycled(holder)

通过复写recyclerview.onviewrecycled的方法,可以取到状态变更时新建或者废弃的viewholder,通过我们自己兴建一个共享holder池的方法,可以全局管理我们项目中viewholder的数量。但是这个设计的操作过于复杂,改动的代码过于庞大,大家可以仅做了解。

降低绘制

填充数据时使用notifyItemXXX而非notifyDataSetChanged

众所周知,notifyItemXXX是部分刷新,而notifyDataSetChanged则是全部刷新,recyclerview中虽然对全部刷新中在cachedView这张list中的view做了复用处理,但是在部分情况下仍然会出现许多cachedview中的viewholder进入到了Scrap View、recyclerpool、甚至removedview这三张表中,导致刷新的时候走重新bind和重绘的操作。比较明显的是会带来一次闪烁。

因此项目中在刷新部分视图时,多使用notifyItemXXX方法,而非notifydatasetchanged。

setHasFixedSize(true)

在item的布局不会动态变化高度和宽度的时候,添加这个方法,可以降低requestlayout的次数

其作用的代码为recyclerview.onmeasure,recyclerview.triggerupdateprocessor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
 protected void onMeasure(int widthSpec, int heightSpec) {
if (this.mLayout == null) {
this.defaultOnMeasure(widthSpec, heightSpec);
} else {
if (!this.mLayout.isAutoMeasureEnabled()) {
if (this.mHasFixedSize) {
this.mLayout.onMeasure(this.mRecycler, this.mState, widthSpec, heightSpec);
return;
}
....
}
}

void triggerUpdateProcessor() {
if (RecyclerView.POST_UPDATES_ON_ANIMATION && RecyclerView.this.mHasFixedSize && RecyclerView.this.mIsAttached) {
ViewCompat.postOnAnimation(RecyclerView.this, RecyclerView.this.mUpdateChildViewsRunnable);
} else {
RecyclerView.this.mAdapterUpdateDuringMeasure = true;
RecyclerView.this.requestLayout();
}

}

其作用点在于将测量过程交与layoutmanager进行,不同的layoutmanager对于测量有自己的优化手段,另外就是在调用notifyItemXXX的时候,不在直接对整个recyclerview进行重绘请求,而是直接执行操作的runnable,调用consumePendingUpdateOperations,最终仅对变动的子item进行重绘,降低了很多重绘的成本。

这个方法发现了一个问题,就是如果仅仅只执行notifyItemXXX的时候,会造成不刷新。主要原因是新增加的数据改变了rv的宽高,导致的问题。因此需要使用这个的时候,在更改数据的时候,需要调用notifydataSetChanged()

针对4.2车机的优化

4.2的车机,目前接触的是长安车机,其有一个原生的bug,在recyclerview绘制过程中会重复调用resolveRtlPropertiesIfNeeded()最终在canResolveTextDirection()中浪费大量的时间,导致应用十分卡顿但却不会anr,针对这个问题处理的方式是在viewholder中对每一个view执行setLayoutDirection

模版代码如下:

1
2
3
4
5
6
7
if ( Android.os.Build.VERSION.SDK_INT == Build.VERSION_CODES.JELLY_BEAN_MR1 ){
helper.getView(R.id.card_bg).setLayoutDirection(View.LAYOUT_DIRECTION_LTR);
helper.getView(R.id.iv_card_play).setLayoutDirection(View.LAYOUT_DIRECTION_LTR);
helper.getView(R.id.iv_card_cover).setLayoutDirection(View.LAYOUT_DIRECTION_LTR);
helper.getView(R.id.tv_card_title).setLayoutDirection(View.LAYOUT_DIRECTION_LTR);
helper.itemView.setLayoutDirection(View.LAYOUT_DIRECTION_LTR);
}

布局优化

使用constraintlayout

约束布局可以将复杂的布局打散,以达到减少到仅一层布局的地步,使用约束布局仅仅需要固定一个坐标系的原点view,即可生成一套完整的坐标系。
了解一下以下的知识点即可以使用约束布局替代任何布局

group:替代contaier的效果,操作一个group,可以将很多视图的可见性绑定起来进行控制

示例代码:

1
2
3
4
5
<Android.support.constraint.Group
Android:id="@+id/container_coupon"
Android:layout_width="wrap_content"
Android:layout_height="wrap_content"
app:constraint_referenced_ids="tv_coupon,bg_coupon"/>

在代码中控制R.id.container_coupon的visiability即可同时控制tv_coupon和bg_coupon的可见性

坑1:使用group控制的view,无法操作自己的可见性,其可见性会被group的可见性覆盖,因此需要将group的粒度控制到最小,确保不会出现一个group的view拥有自己独立的可见性

坑2:使用group控制的view,设置了自己的可见性,立即获取其可见性,此时是设置的可见性,而在下一次重绘之后,则会被覆盖变成group的可见性,业务代码尽量不通过getvisibility()的方式来写业务。

constraintDimensionRatio:可以自由的操作宽高比例

使用这个后可以完全替代项目中的FixedWidthHeightRatioXXXLayout

其用法为

1
2
3
4
5
6
app:layout_constraintDimensionRatio="4:3"

app:layout_constraintDimensionRatio="h,4:3"(默认的,不设置为时为H,可以理解为竖屏时的宽高比例)

app:layout_constraintDimensionRatio="w,4:3"(这个设置时理解为横屏时的宽高比)

chain:替代weight,space,将几个view组合起来平分或者按比例分配竖直或者横向的空间

在xml视图中切换到视图模式,按住command可以连续选择几个view,然后右键,创建chain,即可使这几个view平分或者设置比例分配视图的空间。

goneMargin:视图属性为gone时,以自己为坐标的视图会额外拥有的margin边距

这个可以保证即使当前view不存在时,后续视图仍有坐标系可遵循,否则的话后续view将直接以当前view依赖的前置view直接显示自身,会导致布局错乱。

textview设置宽度为0dp,然后设置好startof和endof,即可充足利用一段空间,并且从头开始排布

这个既可以满足textview的宽度动态变化,又可以保证textview的绘制是从这段空间的起始处开始。

include和merge标签共用

代码如下

1
2
3
4
5
6
7
8
9
10
11
12
<LinearLayout xmlns:Android="http://schemas.Android.com/apk/res/Android"
Android:layout_width="match_parent"
Android:layout_height="match_parent"
Android:orientation="horizontal">

<include layout="@layout/A_layout"/>
<TextView
Android:layout_width="wrap_content"
Android:layout_height="wrap_content"
Android:text="@string/hello_world" />

</LinearLayout>

其中A_layout可以写为如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<merge xmlns:Android="http://schemas.Android.com/apk/res/Android">

<TextView
Android:layout_width="wrap_content"
Android:layout_height="wrap_content"
Android:text="张三" />

<TextView
Android:layout_width="wrap_content"
Android:layout_height="wrap_content"
Android:text="李四" />

<TextView
Android:layout_width="wrap_content"
Android:layout_height="wrap_content"
Android:text="王五" />

</merge>

这样在绘制A_layout时会将这三个textview直接添加到linearlayout中,与helloworld的textview同层级。而往往我们会将A_layout写为有父layout的布局,导致多了一层绘制。

使用viewstub

在基于业务的场景下,很多复杂的布局在点击或者别的事件触发时才会加载,如果页面过于复杂,使用viewstub比较好。
但是viewstub涉及到了一次inflate,inflate是io操作,会导致主线程卡顿,在加载视图十分复杂时使用,简单视图不推荐使用。

去除背景

imageview如果是没有设置背景,而只设置了src的话,只有一层绘制,但是如果同时设置了背景和src的话,则就产生了两层绘制。

因此尽量不要使用setbackground的操作,设置的话可以通过设置背景为selector,尽可能区分状态,在大部分状态设为透明。

部分工具使用操作

检测app启动方法耗时

app启动速度分为冷启动和热启动,冷启动时进程不存在,热启动则相反。

启动方法耗时无法通过traceview实时抓取。仅可通过app自己抓取。

抓取样例