也算是老生常談的問題,最近正好有這方面的需求,查閱了很多官方文檔和優秀的博客,加上自己的理解編寫了這篇文章。
Android 渲染機制
大多數用戶感知到的卡頓等性能問題的最主要根源都是因為渲染性能。從設計師的角度,他們希望App能夠有更多的動畫,圖片等時尚元素來實現流暢的用 戶體驗。但是Android系統很有可能無法及時完成那些復雜的界面渲染操作。Android系統每隔16ms發出VSYNC信號,觸發對UI進行渲染, 如果每次渲染都成功,這樣就能夠達到流暢的畫面所需要的60fps,為了能夠實現60fps,這意味著程序的大多數操作都必須在16ms內完成。
04080416_dgEb.png
如果你的某個操作花費時間是24ms,系統在得到VSYNC信號的時候就無法進行正常渲染,這樣就發生了丟幀現象。那么用戶在32ms內看到的會是同一幀畫面。
04080416_cWwX.png
用戶容易在UI執行動畫或者滑動ListView的時候感知到卡頓不流暢,是因為這里的操作相對復雜,容易發生丟幀的現象,從而感覺卡頓。有很多原 因可以導致丟幀,也許是因為你的layout太過復雜,無法在16ms內完成渲染,有可能是因為你的UI上有層疊太多的繪制單元,還有可能是因為動畫執行 的次數過多。這些都會導致CPU或者GPU負載過重。
性能優化
影響Android渲染性能的無非以下幾個方面:
過度繪制OverDraw
頁面布局層級太深
頻繁GC導致的頁面卡頓
本篇我們主要探討前兩種原因導致的UI性能問題,可以從一下幾個方面進行優化:
- 避免過度繪制OverDraw
- 移除不必要的背景
- 透明背景不會被繪制
- 移除不必要的背景可以有效的優化過度繪制
- phonewindow背景
- activity嵌套布局背景
- ImageView設置的無效背景src和background
- 自定義控件
- 使用clipRect減少疊加處的重復繪制
- google官方還提到了一點
降低透明度
,可以理解為盡量減少半透明對象的使用- 繪制半透明像素會帶來額外的成本
- 移除不必要的背景
- 減少布局層級,布局盡量扁平化
- 使用merge減少布局層級
- 盡量使用ConstraintLayout
- 嵌套過深使用RelativeLayout而不要使用LinearLayout
- ViewSub和incluce
- ViewSub只在需要時加載控件
- incluce布局復用
過度繪制OverDraw
過度繪制是單幀內同一個像素被重復繪制了多次。為了追求復雜的UI效果,界面上同一位置通常疊加了多個控件,頂部控件遮蓋了底部控件時,系統仍然需要繪制被遮蓋的部分,從而導致了多度繪制問題。
移除不必要的背景
移除不必要控件的背景,可以有效的優化過度繪制問題。例如某個Activity有一個背景,其上的Layout也有一個背景,Layout中的N個子View也有背景,通過移除Activity或者Layout的背景,可以減少大量紅色OverDraw區域。能夠顯著的提升程序性能。
常見的不必要的背景的場景有:
-
父View和子View同時設置了background
例如以下布局:
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:background="#FFFFFF"> <RelativeLayout android:id="@+id/content_rel" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="#FFFFFF"> <!-- 子View --> </RelativeLayout> </FrameLayout>
很明顯content_rel的backgroud是重復的,去掉background后可以有效降低其子View過度繪制的次數。
-
ImageView設置了background
例如:
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:background="#FFFFFF"> <RelativeLayout android:id="@+id/content_rel" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="#FFFFFF"> <ImageView android:id="@+id/img_iv" android:layout_width="200dp" android:layout_height="200dp" android:background="#ededed"/> </RelativeLayout> </FrameLayout>
定義了一個ImageView并設置了背景色
#ededed
,乍看可能沒問題,但當我們帶代碼中去加載顯示一個圖片時。ImageView imgIv = new ImageView(this); //加載resource資源 imgIv.setImageResource(R.mipmap.ic_launcher); //通過Glide加載url Glide.with(this).load(imageUrl).apply( new RequestOptions() .centerCrop() .placeholder(R.mipmap.ic_launcher)) .into(imgIv);
ImageView就會被繪制2次,可以通過以下兩種方式進行優化:
- 通過不設置background或者設置src
<ImageView android:id="@+id/img_iv" android:layout_width="200dp" android:layout_height="200dp" android:src="#ededed"/>
-
判斷src加載之后取消background的方式都可以優化這個問題。
Glide.with(this).load(imageUrl).apply( new RequestOptions() .centerCrop() .placeholder(R.mipmap.ic_launcher)) .listener(new RequestListener<Drawable>() { @Override public boolean onLoadFailed(@Nullable GlideException e, Object model, Target<Drawable> target, boolean isFirstResource) { //加載失敗 return false; } @Override public boolean onResourceReady(Drawable resource, Object model, Target<Drawable> target, DataSource dataSource, boolean isFirstResource) { //加載成功 imgIv.setBackgroundDrawable(null); return false; } }) .into(imgIv);
-
PhoneWindow背景
這種情況可能不太容易被注意到,當已經在Activity根布局設置了background時,window的背景是無效的,可以設置為null。
getWindow().setBackgroundDrawable(null);
優化前后的對比,可以說十分明顯了。
自定義View——clipRect
使用比較簡單,直接引用官方說明了。
對于那些過于復雜的自定義的View(重寫了onDraw方法),Android系統無法檢測具體在onDraw里面會執行什么操作,系統無法監控并自動優化,也就無法避免Overdraw了。但是我們可以通過canvas.clipRect()來 幫助系統識別那些可見的區域。這個方法可以指定一塊矩形區域,只有在這個區域內才會被繪制,其他的區域會被忽視。這個API可以很好的幫助那些有多組重疊 組件的自定義View來控制顯示的區域。同時clipRect方法還可以幫助節約CPU與GPU資源,在clipRect區域之外的繪制指令都不會被執 行,那些部分內容在矩形區域內的組件,仍然會得到繪制。
for (int i = 0; i < mCards.length; i++) {
canvas.translate(120, 0);
canvas.save();
if (i < mCards.length - 1) {
//只繪制這個區域內
canvas.clipRect(0, 0, 120, mCards[i].getHeight());
}
canvas.drawBitmap(mCards[i], 0, 0, null);
canvas.restore();
}
- 避免使用透明度
對于解決過度繪制問題,Google官方文檔還提到了一種方式,減少透明元素的使用。
在屏幕上渲染透明像素,即所謂的透明度渲染,是導致過度繪制的重要因素。在普通的過度繪制中,系統會在已繪制的現有像素上繪制不透明的像素,從而將其完全遮蓋,與此不同的是,透明對象需要先繪制現有的像素,以便達到正確的混合效果。諸如透明動畫、淡出和陰影之類的視覺效果都會涉及某種透明度,因此有可能導致嚴重的過度繪制。您可以通過減少要渲染的透明對象的數量,來改善這些情況下的過度繪制。例如,如需獲得灰色文本,您可以在
TextView
中繪制黑色文本,再為其設置半透明的透明度值。但是,您可以簡單地通過用灰色繪制文本來獲得同樣的效果,而且能夠大幅提升性能。
減少布局層級
- 使用merge減少布局層級
- 使用ViewSub只在需要時加載控件
- 使用incluce標簽實現布局復用
- 盡量使用ConstraintLayout
- 嵌套過深使用RelativeLayout而不要使用LinearLayout
merge
- merge既不是View也不是ViewGroup,只是一種標記。
- merge必須在布局的根節點。
- 當merge所在布局被添加到容器中時,merge節點被合并不占用布局,merge下面的所有視圖轉移到容器中。
通過一種比較常用的場景來比較下使用merge和不使用的區別。
不使用merge
Activity布局:
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent" >
<RelativeLayout
android:id="@+id/title_rel"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<include layout="@layout/layout_merge"/>
</RelativeLayout>
<RelativeLayout
android:id="@+id/content_rel"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@id/title_rel"/>
</RelativeLayout>
ToolBar布局:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools" >
<ImageView
android:id="@+id/home_iv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:src="@mipmap/ic_launcher"/>
<TextView
android:id="@+id/title_tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_below="@id/home_iv"
android:gravity="center_vertical"
android:textSize="25sp"
android:textColor="#000000"
tools:text="測試標題"/>
</RelativeLayout>
實際Activity布局層級:
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent" >
<RelativeLayout
android:id="@+id/title_rel"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content" >
<ImageView
android:id="@+id/home_iv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:src="@mipmap/ic_launcher"/>
<TextView
android:id="@+id/title_tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_below="@id/home_iv"
android:gravity="center_vertical"
android:textSize="25sp"
android:textColor="#000000"
tools:text="測試標題"/>
</RelativeLayout>
</RelativeLayout>
<RelativeLayout
android:id="@+id/content_rel"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@id/title_rel"/>
</RelativeLayout>
使用merge進行優化:
優化后的ToolBar布局:
<merge xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools"
tools:parentTag="android.widget.RelativeLayout">
<ImageView
android:id="@+id/home_iv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:src="@mipmap/ic_launcher"/>
<TextView
android:id="@+id/title_tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_below="@id/home_iv"
android:gravity="center_vertical"
android:textSize="25sp"
android:textColor="#000000"
tools:text="測試標題"/>
</merge>
使用tools:parentTag
屬性可以指定父布局類型,方便在Android Studio中編寫布局時進行預覽。
實際Activity布局層級,可以通過Layout Inspector來查看具體布局層級:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent" >
<RelativeLayout
android:id="@+id/title_rel"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/home_iv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:src="@mipmap/ic_launcher"/>
<TextView
android:id="@+id/title_tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_below="@id/home_iv"
android:gravity="center_vertical"
android:textSize="25sp"
android:textColor="#000000"
tools:text="測試標題"/>
</RelativeLayout>
<RelativeLayout
android:id="@+id/content_rel"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@id/title_rel"/>
</RelativeLayout>
可以看到使用merge之后布局層級減少了一層。
使用場景
上面例子可能不太合適,這么寫布局容易被打。
來看一種使用頻率更高的應用場景——自定義View,大家應該都實現過,比如要定義一個通用的天氣控件,通常是自定義一個WeatherView 繼承自RelativeLayout,然后通過inflate動態引入布局,那么布局怎么寫呢?不使用merge的情況下根布局肯定是RelativeLayout,引入WeatherView之后豈不是嵌套了一層RelativeLayout。這時候就可以在布局中使用merge進行優化。
還有一種應用場景,如果Activity的根布局是FrameLayout可以使用merge進行替換,使用之后可以使Activity的布局層級減少一層。為什么會這樣呢?首先我們要了解Activity頁面的布局層級,最外層是PhoneWindow其下是一個DecorView下面就是TitleView和ContentView,ContentView就是我們通過SetContentView設置的Activity的布局,沒錯ContentView是一個FrameLayout,所以在Activity布局中使用merge可以減少層級。
使用merge后可以有效的減少一層布局嵌套。
ViewSub
- ViewStub是一種沒有大小,不占用布局的View。
- 直到當調用
inflate()
方法或者可見性變為VISIBLE時,才會將指定的布局加載到父布局中。 - ViewStub加載完指定布局之后會被移除,不再占用空間。(所以
inflate()
方法只能調用一次 )
因為這些特性ViewStub可以用來懶加載布局,優化UI性能。
使用:
布局
在布局中添加ViewStub標簽并通過layout屬性指定要替換的布局。
<ViewStub
android:id="@+id/visible_view_stub"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout="@layout/layout_view_stub_content" />
代碼
在需要展示布局的地方調用 inflate()
方法或者將ViewStub的可見性設置為VISIBLE。
private View viewStubContentView = null;
visibleViewStub.setVisibility(View.VISIBLE);
if(viewStubContentView == null){
viewStubContentView = inflateViewStub.inflate();
}
注意 :inflate()
方法只能調用一次,重復調用被拋出IllegalStateException
異常。
inflate()
方法會返回替換的布局的根View而設置VISIBLE不會返回,如果需要獲取替換布局的實例,如:需要為替換的布局設置監聽事件,這是需要使用inflate()
方法而不是VISIBLE。
ViewStub源碼分析
針對我們前面說的ViewStub的幾個特點,我們來分析下源碼是如何實現的。分析源碼可以學習別人優秀的代碼設計,也可以為我們日后類似需求的實現提供借鑒。
- ViewSutb沒有大小,不占用布局
ViewStub在構造方法中設置了控件可見性為GONE并且指定不進行繪制。
public ViewStub(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context);
final TypedArray a = context.obtainStyledAttributes(attrs,
R.styleable.ViewStub, defStyleAttr, defStyleRes);
mInflatedId = a.getResourceId(R.styleable.ViewStub_inflatedId, NO_ID);
mLayoutResource = a.getResourceId(R.styleable.ViewStub_layout, 0);
mID = a.getResourceId(R.styleable.ViewStub_id, NO_ID);
a.recycle();
//設置不可見
setVisibility(GONE);
//指定不進行繪制
setWillNotDraw(true);
}
并且重寫了onMeasure(widthMeasureSpec, heightMeasureSpec)
設置尺寸為(0,0),并且重寫了draw(canvas)
和dispatchDraw(canvas)
方法,并且沒有做任何繪制操作。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//指定尺寸為0,0
setMeasuredDimension(0, 0);
}
@Override
public void draw(Canvas canvas) {
}
@Override
protected void dispatchDraw(Canvas canvas) {
}
-
setVisibility()
和inflate()
方法
//定義了一個View的弱引用
private WeakReference<View> mInflatedViewRef;
@Override
@android.view.RemotableViewMethod(asyncImpl = "setVisibilityAsync")
public void setVisibility(int visibility) {
if (mInflatedViewRef != null) {
//如果弱引用不為空且View不為空,調用View的setVisibility方法
View view = mInflatedViewRef.get();
if (view != null) {
view.setVisibility(visibility);
} else {
throw new IllegalStateException("setVisibility called on un-referenced view");
}
} else {
super.setVisibility(visibility);
if (visibility == VISIBLE || visibility == INVISIBLE) {
//弱引用為空且可見性設置為VISIBLE或者INVISIBLE,調用inflate()方法
inflate();
}
}
}
到這里基本可以分析出弱引用持有的對象就是替換布局的View。繼續往下看mInflatedViewRef是在哪里初始化的。
inflate()方法,核心方法執行具體的布局替換操作。
public View inflate() {
//獲取父布局
final ViewParent viewParent = getParent();
if (viewParent != null && viewParent instanceof ViewGroup) {
if (mLayoutResource != 0) {
final ViewGroup parent = (ViewGroup) viewParent;
//獲取要替換的View對象
final View view = inflateViewNoAdd(parent);
//執行替換操作
replaceSelfWithView(view, parent);
//初始化弱引用持有View對象
mInflatedViewRef = new WeakReference<>(view);
if (mInflateListener != null) {
//觸發監聽
mInflateListener.onInflate(this, view);
}
return view;
} else {
throw new IllegalArgumentException("ViewStub must have a valid layoutResource");
}
} else {
throw new IllegalStateException("ViewStub must have a non-null ViewGroup viewParent");
}
}
inflate()方法中獲取要替換的View對象并執行了替換操作,mInflatedViewRef持有的確實是替換View對象的實例。
- ViewStub加載完指定布局之后會被移除,不再占用空間
我們繼續來看inflateViewNoAdd()
方法和replaceSelfWithView()
方法。
private View inflateViewNoAdd(ViewGroup parent) {
final LayoutInflater factory;
if (mInflater != null) {
factory = mInflater;
} else {
factory = LayoutInflater.from(mContext);
}
//動態加載View
final View view = factory.inflate(mLayoutResource, parent, false);
if (mInflatedId != NO_ID) {
view.setId(mInflatedId);
}
return view;
}
inflateViewNoAdd()
方法比較簡單,沒什么好解釋的。
private void replaceSelfWithView(View view, ViewGroup parent) {
final int index = parent.indexOfChild(this);
//從父布局中移除自己
parent.removeViewInLayout(this);
final ViewGroup.LayoutParams layoutParams = getLayoutParams();
if (layoutParams != null) {
//添加替換布局
parent.addView(view, index, layoutParams);
} else {
//添加替換布局
parent.addView(view, index);
}
}
replaceSelfWithView()
執行了移除和替換兩步操作。這也解釋了為什么inflate()
方法只能執行一次,因為執行replaceSelfWithView()
自身已經被移除,再次執行inflate()
方法獲取getParent()
會為空,從而拋出IllegalStateException
異常。
使用場景
app頁面中總會有一些布局是不常顯示的,如一些特殊提示和頁面loading等,這時可以使用ViewStub來實現懶加載的功能,優化UI性能。
篇幅有限,其他幾種方式相對簡單,在此不詳細展開了。
減少ContentView嵌套層級
每一個Activity都對應一個Window也就是PhoneWindow的實例,PhoneWindow對應布局是DecorView,也就是所有Activity的根布局都是DecorView,DecorView是一個FrameLayout,DecorView之下是一個豎向的LinearLayout,包含一個ActionBar和content,content也就是承載我們編寫的Activity布局的控件是一個FrameLayout,也是我們調用setContentView所設置布局的父控件,整個結構如下:
通過Android Studio自帶的Layout Inspector工具可以更清楚的看到整個Activity布局層級。
可以看到在加載自定義的Activity布局之前,DecorView中已經嵌套了三層布局了,而且action_bar在國內開發中幾乎用不到了,那么我們直接把自定義的布局添加到DecorView中,可以至少減少2層嵌套,按照這個思路我們來實現一個基類。
public abstract class BaseDecorActivity extends FragmentActivity {
protected final String TAG = getClass().getSimpleName();
private Unbinder unbinder;
private View rootView;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
initLayoutView();
unbinder = ButterKnife.bind(this);
create();
}
/**
* 添加布局
*/
private void initLayoutView(){
if(initLayout() != 0){
if(getWindow().getDecorView() instanceof FrameLayout){
//獲取DecorView
FrameLayout decorView = ((FrameLayout)getWindow().getDecorView());
//移除DecorView的所有子View
decorView.removeAllViews();
//初始化子View,并attach到DecorView中
rootView = LayoutInflater.from(this).inflate(initLayout(),decorView, true);
} else{
setContentView(initLayout());
}
}
}
/**
* get rootView
* @return
*/
protected View getRootView(){
return rootView;
}
@Override
protected void onDestroy() {
super.onDestroy();
if(unbinder != null){
unbinder.unbind();
}
}
/**
* 初始化布局
* @return
*/
protected abstract int initLayout();
protected abstract void create();
}
通過Layout Inspector來看一下修改后的效果:
可以看到效果很明顯,優化后減少了兩層布局嵌套。
根據上面的代碼我們也知道DecorView是一個FrameLayout,既然..那么,如果我們使用merge對布局再次優化呢?
Activity布局如下:
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity"
tools:parentTag="android.widget.FrameLayout">
<androidx.viewpager.widget.ViewPager
android:id="@+id/main_view_pager"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#1E1E1E"/>
<com.google.android.material.tabs.TabLayout
android:id="@+id/main_tab_layout"
android:layout_width="match_parent"
android:layout_height="@dimen/x120"
android:paddingLeft="@dimen/x30"
android:paddingRight="@dimen/x30"
android:background="@drawable/main_tab_bg"/>
</merge>
優化后的結果:
[圖片上傳失敗...(image-8c2570-1634107619063)]
可以看到布局層級已經很少了,基本達到了最優狀態。但是此種方案,未做過多驗證,在實際項目中謹慎使用。
寫在最后
在進行UI布局優化時,注意配置檢測工具使用,文中對這部分未做過多介紹,但是網上有很多關于功能的使用說明,也可以參考Google官方的文檔。
相關文檔:
Google官方說明:
Android性能優化典范(強烈推薦):
其他博客: