【Android 性能優化】—— 打造絲滑的 UI 界面

本文同時發佈在簡書上,歡迎查看

#1. 前言
隨着最近幾年移動市場蓬勃發展,引來大批人員投入到Android、IOS的開發前線,與此同時全國各大培訓機構每月都培養出成千上萬名號稱擁有2到3年工作經驗的開發者。當然,這都已經不是什麼祕密了,從目前來看,中國IT行業的主力軍基本上都走過培訓的道路。

但問題是,這號稱23年工作經驗者,使招聘單位錯誤的認爲:23年開發經驗和剛剛結束的培訓經歷,基本上劃等號。這就導致了企業大幅度提高用人標準,造成了爲何如今移動開發市場依舊火熱,但是工作卻不好找的現狀。

最悲慘的例子恐怕就是前幾年IOS如日中天,可時間就過了一年開發人員就出現了井噴的情況,大量IOS開發者找不到工作。

總的來說:工作機會的確是很多,但是企業把用人要求都大大提高了。如何在萬千人羣中脫穎而出,走上人生巔峯,迎娶白富美,沒有亮點,是萬萬不行滴。。。

接下來我就一起學習Android UI優化吧

#2. Android渲染機制分析

大家在開發應用的時候或多或少都遇到過可感知的界面卡頓現象,尤其是在佈局層次嵌套太多,存在不必要的繪製,或者onDraw方法中執行了過多耗時操作、動畫執行的次數過多等情況下,很容易造成此類情況。如今APP設計都要求界面美觀、擁有更多的動畫、圖片等時尚元素從而打造良好的用戶體驗。但是大量複雜的渲染工作很可能造成Android系統壓力過大,無法及時完成渲染工作。那麼多久執行一次渲染,才能讓界面流暢運行呢?

一圖勝千言

如上圖所示,Android系統每隔16ms就會發送一個VSYNC信號(VSYNC:vertical synchronization 垂直同步,幀同步),觸發對UI進行渲染,如果每次渲染都成功,這樣就能夠達到流暢的畫面所需要的正常幀率:60fps。一旦這時候系統正在做大於16ms的耗時操作,系統就會無法響應VSYNC信號,執行渲染工作,導致發生丟幀現象。

大家在察覺到APP卡頓的時候,可以看看logcat控制檯,會有drop frames類似的警告
本引用來自:[ Android UI性能優化實戰 識別繪製中的性能問題](http://blog.csdn.net/lmj623565791/article/details/45556391/)

丟幀啦。。。。

例如上圖所示:如果你的某個操作花費時間是24ms,系統在得到VSYNC信號的時候就無法進行正常渲染,只能等待下一個VSYNC信號(第二個16ms)才能執行渲染工作。那麼用戶在32ms內看到的會是同一幀畫面。(我就是感覺google給的圖給錯了,明明是 32ms,怎麼給標了一個34ms,難道是有其他寓意我沒有理解上去???)

用戶容易在UI執行動畫、ListView、RecyclerView滑動的時候感知到界面的卡頓與不流暢現象。所以開發者一定要注意在設計佈局時不要嵌套太多層,多使用 include方法引入佈局。同時不要讓動畫執行次數太多,導致CPU或者GPU負載過重

看到這裏同學可能會疑問:爲什麼是16ms渲染一次,和60fps有什麼關係呢?下面讓我們看一下原理:

16ms意味着着1000/60hz,相當於60fps。

那麼只要解釋爲什麼是60fps,這個問題就迎刃而解:

這是因爲人眼和大腦之間的寫作無法感知超過60fps的畫面更新,12fps大概類似手動快速翻動書籍的幀率,這是明顯可以感知到不夠順滑的。
24fps使得人眼感知的是連續的線性運動,這其實是歸功於運動模糊效果,24fps是電影膠圈通常使用的幀率,因爲這個幀率已經足夠支撐大部分電影畫面需要表達的內容,同時能夠最大的減少費用支出。
但是低於30fps是
無法順暢表現絢麗的畫面內容的,此時就需要用到60fps來達到想要的效果,當然超過60fps是沒有必要的
本引用來源:Google 發佈 Android 性能優化典範 - 開源中國社區

#3.1 界面卡頓的主要元兇—— 過度繪製(Overdraw)
##3.1 什麼是過度繪製?
**過渡繪製是指屏幕上某個像素在同一幀的時間內繪製了多次。**在多層次的UI結構裏面,如果不可見的UI也在做繪製操作,這就會導致某些像素區域被繪製了多次,這就是很大程度上浪費了CPU和GPU資源。最最常見的過度繪製,就是設置了無用的背景顏色!!!

3.2 如何發現過度繪製?

對於Overdraw這個問題還是很容易發現的,我們可以通過以下步驟打開顯示GPU過度繪製(Show GPU Overrdraw)選項

設置 -> 開發者選項 -> 調試GPU過度繪製 -> 顯示GPU過度繪製

打開以後之後,你會發現屏幕上有各種顏色,此時你可以切換到需要檢測的程序與界面,對於各個色塊的含義,請看下圖:

Overdraw的參考圖

藍色,淡綠,淡紅,深紅代表了4種不同程度的Overdraw情況,
藍色: 意味着overdraw 1倍。像素繪製了兩次。大片的藍色還是可以接受的(若整個窗口是藍色的,可以擺脫一層)。
綠色: 意味着overdraw 2倍。像素繪製了三次。中等大小的綠色區域是可以接受的但你應該嘗試優化、減少它們。
淡紅: 意味着overdraw 3倍。像素繪製了四次,小範圍可以接受。
深紅: 意味着overdraw 4倍。像素繪製了五次或者更多。這是錯誤的,要修復它們。
我們的目標就是儘量減少紅色Overdraw,看到更多的藍色區域。

3.3 解決問題的工具和方法

通過Hierarchy Viewer去檢測渲染效率,去除不必要的嵌套
通過Show GPU Overdraw去檢測Overdraw,最終可以通過移除不必要的背景。

4. UI優化實踐

4.1 移除不必要的background

(由於公司項目還處於保密階段,所以摘取了Android UI性能優化實戰 識別繪製中的性能問題的部分示例)
下面看一個簡單的展示ListView的例子:

  • activity_main
?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:paddingLeft="@dimen/activity_horizontal_margin"
              android:paddingRight="@dimen/activity_horizontal_margin"
              android:background="@android:color/white"
              android:paddingTop="@dimen/activity_vertical_margin"
              android:paddingBottom="@dimen/activity_vertical_margin"
              android:orientation="vertical"
    >

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="@dimen/narrow_space"
        android:textSize="@dimen/large_text_size"
        android:layout_marginBottom="@dimen/wide_space"
        android:text="@string/header_text"/>

    <ListView
        android:id="@+id/id_listview_chats"
        android:layout_width="match_parent"
        android:background="@android:color/white"
        android:layout_height="wrap_content"
        android:divider="@android:color/transparent"
        android:dividerHeight="@dimen/divider_height"/>
</LinearLayout>
  • item的佈局文件
<?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:orientation="horizontal"
    android:paddingBottom="@dimen/chat_padding_bottom">

    <ImageView
        android:id="@+id/id_chat_icon"
        android:layout_width="@dimen/avatar_dimen"
        android:layout_height="@dimen/avatar_dimen"
        android:src="@drawable/joanna"
        android:layout_margin="@dimen/avatar_layout_margin" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@android:color/darker_gray"
        android:orientation="vertical">

        <RelativeLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:background="@android:color/white"
            android:textColor="#78A"
            android:orientation="horizontal">

            <TextView xmlns:android="http://schemas.android.com/apk/res/android"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_alignParentLeft="true"
                android:padding="@dimen/narrow_space"
                android:text="@string/hello_world"
                android:gravity="bottom"
                android:id="@+id/id_chat_name" />

            <TextView xmlns:android="http://schemas.android.com/apk/res/android"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_alignParentRight="true"
                android:textStyle="italic"
                android:text="@string/hello_world"
                android:padding="@dimen/narrow_space"
                android:id="@+id/id_chat_date" />
        </RelativeLayout>

        <TextView xmlns:android="http://schemas.android.com/apk/res/android"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:padding="@dimen/narrow_space"
            android:background="@android:color/white"
            android:text="@string/hello_world"
            android:id="@+id/id_chat_msg" />
    </LinearLayout>
</LinearLayout>
  • Activity的代碼
package com.zhy.performance_01_render;

import android.os.Bundle;
import android.os.PersistableBundle;
import android.support.annotation.Nullable;
import android.support.v7.app.AppCompatActivity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.ImageView;
import android.widget.ListView;
import android.widget.TextView;

/**
 * Created by zhy on 15/4/29.
 */
public class OverDrawActivity01 extends AppCompatActivity
{
    private ListView mListView;
    private LayoutInflater mInflater ;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);

        setContentView(R.layout.activity_overdraw_01);

        mInflater = LayoutInflater.from(this);
        mListView = (ListView) findViewById(R.id.id_listview_chats);

        mListView.setAdapter(new ArrayAdapter<Droid>(this, -1, Droid.generateDatas())
        {
            @Override
            public View getView(int position, View convertView, ViewGroup parent)
            {

                ViewHolder holder = null ;
                if(convertView == null)
                {
                    convertView = mInflater.inflate(R.layout.chat_item,parent,false);
                    holder = new ViewHolder();
                    holder.icon = (ImageView) convertView.findViewById(R.id.id_chat_icon);
                    holder.name = (TextView) convertView.findViewById(R.id.id_chat_name);
                    holder.date = (TextView) convertView.findViewById(R.id.id_chat_date);
                    holder.msg = (TextView) convertView.findViewById(R.id.id_chat_msg);
                    convertView.setTag(holder);
                }else
                {
                    holder = (ViewHolder) convertView.getTag();
                }

                Droid droid = getItem(position);
                holder.icon.setBackgroundColor(0x44ff0000);
                holder.icon.setImageResource(droid.imageId);
                holder.date.setText(droid.date);
                holder.msg.setText(droid.msg);
                holder.name.setText(droid.name);

                return convertView;
            }

            class ViewHolder
            {
                ImageView icon;
                TextView name;
                TextView date;
                TextView msg;
            }

        });
    }


}

實體的代碼

package com.zhy.performance_01_render;

import java.util.ArrayList;
import java.util.List;

public class Droid
{
    public String name;
    public int imageId;
    public String date;
    public String msg;


    public Droid(String msg, String date, int imageId, String name)
    {
        this.msg = msg;
        this.date = date;
        this.imageId = imageId;
        this.name = name;
    }

    public static List<Droid> generateDatas()
    {
        List<Droid> datas = new ArrayList<Droid>();

        datas.add(new Droid("Lorem ipsum dolor sit amet, orci nullam cra", "3分鐘前", -1, "alex"));
        datas.add(new Droid("Omnis aptent magnis suspendisse ipsum, semper egestas", "12分鐘前", R.drawable.joanna, "john"));
        datas.add(new Droid("eu nibh, rhoncus wisi posuere lacus, ad erat egestas", "17分鐘前", -1, "7heaven"));
        datas.add(new Droid("eu nibh, rhoncus wisi posuere lacus, ad erat egestas", "33分鐘前", R.drawable.shailen, "Lseven"));

        return datas;
    }


}

現在的效果是:

注意,我們的需求是整體是Activity是個白色的背景。
開啓Show GPU Overdraw以後:

對比上面的參照圖,可以發現一個簡單的ListView展示Item,竟然很多地方被過度繪製了4X 。 那麼,其實主要原因是由於該佈局文件中存在很多不必要的背景,仔細看上述的佈局文件,那麼開始移除吧。

  • 不必要的Background 1

我們主佈局的文件已經是background爲white了,那麼可以移除ListView的白色背景

  • 不必要的Background 2

Item佈局中的LinearLayout的android:background="@android:color/darker_gray"

  • 不必要的Background 3

Item佈局中的RelativeLayout的android:background="@android:color/white"

  • 不必要的Background 4

Item佈局中id爲id_msg的TextView的android:background="@android:color/white"

這四個不必要的背景也比較好找,那麼移除後的效果是:

對比之前的是不是好多了~~~接下來還存在一些不必要的背景,你還能找到嗎?

  • 不必要的Background 5

這個背景比較難發現,主要需要看Adapter的getView的代碼,上述代碼你會發現,首先爲每個icon設置了背景色(主要是當沒有icon圖的時候去顯示),然後又設置了一個頭像。那麼就造成了overdraw,有頭像的完全沒必要去繪製背景,所有修改代碼:

Droid droid = getItem(position);
                if(droid.imageId ==-1)
                {
                    holder.icon.setBackgroundColor(0x4400ff00);
                    holder.icon.setImageResource(android.R.color.transparent);
                }else
                {
                    holder.icon.setImageResource(droid.imageId);
                    holder.icon.setBackgroundResource(android.R.color.transparent);
                }

ok,還有最後一個,這個也是非常容易被忽略的。

  • 不必要的Background 6

記得我們之前說,我們的這個Activity要求背景色是白色,我們的確在layout中去設置了背景色白色,那麼這裏注意下,我們的Activity的佈局最終會添加在DecorView中,這個View會中的背景是不是就沒有必要了,所以我們希望調用mDecor.setWindowBackground(drawable);,那麼可以在Activity調用getWindow().setBackgroundDrawable(null);

setContentView(R.layout.activity_overdraw_01); 
getWindow().setBackgroundDrawable(null);

ok,一個簡單的listview顯示item,我們一共找出了6個不必要的背景,現在再看最後的Show GPU Overdraw 與最初的比較。
ok,對比參照圖,基本已經達到了最優的狀態。

4.2 使用佈局標籤優化佈局

###4.2.1 標籤
相信大家使用的最多的佈局標籤就是 <include>了。 <include>的用途就是將佈局中的公共部分提取出來以供其他Layout使用,從而實現佈局的優化。這種佈局的編寫方式大大便利了開發,個人感覺這種思想和React Native中的面向組件編程思想有着異曲同工之妙,都是將特定功能抽取成爲一個獨立的組件,只要控制其中傳入的參數就可以滿局不同的需求。例如:我們在編輯Android界面的時候常常需要添加標題欄,如果在不使用<include>的情況下,只能在每一個需要顯示標題欄的xml文件中編寫重複的代碼,費時費力。但是隻要我們將這個需要多次被使用的標題欄佈局抽取成一個獨立的xml文件,然後在需要的地方使用<include>標籤引入即可。
下面以在一個佈局main.xml中用include引入另一個佈局foot.xml爲例。main.mxl代碼如下:

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ListView
        android:id="@+id/simple_list_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_marginBottom="80dp" />

    <include
        android:id="@+id/my_foot_ly"
        layout="@layout/foot" />

</RelativeLayout>

其中include引入的foot.xml爲公用的頁面底部,代碼如下:

?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="match_parent" 
    android:id="@+id/my_foot_parent_id">

    <Button
        android:id="@+id/button"
        android:layout_width="match_parent"
        android:layout_height="@dimen/dp_40"
        android:layout_above="@+id/title_tv"/>

    <TextView
        android:id="@+id/title_tv"
        android:layout_width="match_parent"
        android:layout_height="@dimen/dp_40"
        android:layout_alignParentBottom="true"
        android:text="@string/app_name" />
</RelativeLayout>

<include>使用起來很簡單,只需要指定一個layout屬性爲需要包含的佈局文件即可。當然還可以根據需求指定 android:idandroid:heightandroid:width屬性來覆蓋被引入根節點屬性。

注意
在使用<include>標籤最常見的問題就是 findViewById查找不到<include>進來地控件的跟佈局,而這個問題出現的前提就是在include的時候設置了id當設置id後,原有的foot.xml跟佈局Id已經被替換爲在 <include>中指定的id,所以在 findViewById查找原有id的時候就會報空指針異常。

View titleView = findViewById(R.id.my_foot_parent_id) ; // 此時id已經被覆蓋 titleView 爲空,找不到。此時空指針 
View titleView = findViewById(R.id.my_foot_ly) ; //重寫指定id即可

<include>標籤簡單的說就是相當與將layout指定的佈局整體引入到main.xml中。所以我們就和操作直接在main.xml中的佈局是一樣的只不過有一個上面提到的更佈局id被覆蓋的問題。

###4.2.2 標籤
ViewStub標籤同include一樣可以用來引入一個外部佈局。不同的是,ViewStub引入的佈局默認是不會顯示也不會佔用位置的,從而在解析的layout的時候可以節省cpu、內存等硬件資源。

ViewStub常常用來引入那些默認不顯示,只在特定情況下才出現的佈局,例如:進度條,網絡連接失敗顯示的提示佈局等。
下面以在一個佈局main.xml中加入網絡錯誤時的提示頁面network_error.xml爲例。main.mxl代碼如下:

<?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="match_parent" >

……
    <ViewStub
        android:id="@+id/network_error_layout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout="@layout/network_error" />

</RelativeLayout>

其中network_error.xml爲只有在網絡錯誤時才需要顯示的佈局,默認不會被解析,示例代碼如下:

<?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="match_parent" >

    <Button
        android:id="@+id/network_setting"
        android:layout_width="@dimen/dp_160"
        android:layout_height="wrap_content"
        android:layout_centerHorizontal="true"
        android:text="@string/network_setting" />

    <Button
        android:id="@+id/network_refresh"
        android:layout_width="@dimen/dp_160"
        android:layout_height="wrap_content"
        android:layout_below="@+id/network_setting"
        android:layout_centerHorizontal="true"
        android:layout_marginTop="@dimen/dp_10"
        android:text="@string/network_refresh" />

</RelativeLayout>

在代碼中通過(ViewStub)findViewById(id)找到ViewStub,通過stub.inflate()展開ViewStub,然後得到子View,如下:

private View networkErrorView;

private void showNetError() {
  if (networkErrorView != null) {
    networkErrorView.setVisibility(View.VISIBLE);
  }else{
    ViewStub stub = (ViewStub)findViewById(R.id.network_error_layout);
    if(stub !=null){
      networkErrorView = stub.inflate();
    
      //  效果和上面是一樣的
      //  stub.setVisibility(View.VISIBLE);   // ViewStub被展開後的佈局所替換
      //  networkErrorView =  findViewById(R.id.network_error_layout); // 獲取展開後的佈局
    }
 }
}

private void showNormal() {
  if (networkErrorView != null) {
    networkErrorView.setVisibility(View.GONE);
  }
}

在上面showNetError()中展開了ViewStub,同時我們對networkErrorView進行了保存,這樣下次不用繼續inflate。

注意這裏我對ViewStub的實例進行了一個非空判斷,這是因爲ViewStub在XML中定義的id只在一開始有效,一旦ViewStub中指定的佈局加載之後,這個id也就失敗了,那麼此時findViewById()得到的值也會是空
viewstub標籤大部分屬性同include標籤類似。

注意:
根據需求我們有時候需要將View的可講性設置爲GONE,在inflate時,這個View以及他的字View還是會被解析的。所以使用<ViewStub>就能避免解析其中的指定的佈局文件。從而加快佈局的解析時間,節省cpu內存等硬件資源。同時ViewStub所加載的佈局是不可以使用<merge>標籤的

###4.2.3 標籤
在使用了include後可能會導致佈局嵌套太多,導致視圖節點太多,減慢了解析速度。

merge標籤可用於兩種典型情況:

  1. 佈局頂接點是FrameLayout並且不需要設置background或者padding等屬性,可使用merge代替,因爲Activity內容視圖的parent view就是一個FrameLayout,所以可以用merge消除只能一個。
  2. 某佈局作爲子佈局被其他佈局include時,使用merge當作該佈局的頂節點,這樣在被引入時,頂結點會自動被忽略,而其自己點全部合併到主佈局中。

以【4.2.1 標籤 】中的代碼示例爲例,使用用hierarchy viewer查看main.xml佈局如下圖:

可以發現多了一層沒必要的RelativeLayout,將foot.xml中RelativeLayout改爲merge,如下:

<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent" >

    <Button
        android:id="@+id/button"
        android:layout_width="match_parent"
        android:layout_height="@dimen/dp_40"
        android:layout_above="@+id/text"/>

    <TextView
        android:id="@+id/text"
        android:layout_width="match_parent"
        android:layout_height="@dimen/dp_40"
        android:layout_alignParentBottom="true"
        android:text="@string/app_name" />

</merge>

運行後再次用hierarchy viewer查看main.xml佈局如下圖:

這樣就不會有多餘的RelativeLayout節點了。
參考:
Android UI性能優化實戰 識別繪製中的性能問題
Google 發佈 Android 性能優化典範
Android性能優化系列之佈局優化

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章