基於XDanmuku的Android效能優化實戰

orzangleli發表於2019-03-01

V1.0版本於4天前首發與我的掘金專欄,釋出後大家的支援讓我喜出望外,截止本文發稿,掘金上原文喜歡數為259,Github上專案的Star數為151。很慚愧,就做了這麼一點微小的工作。

基於XDanmuku的Android效能優化實戰

不過,好景不長,在釋出不久後Github上tz-xiaomage提交了一個題為體驗不好,滑動很卡的Issue.當時我並沒有很重視,以為是我程式中執行緒睡眠時間有點長導致的。然後amszsthl也在該Issue下評論

彈幕滾動的時候一卡一卡的。

這是我才開始認真思考,這不是偶然事件,應該是程式出問題了。

現在開始查詢卡頓原因,以優化優化效能。

首先設定測試條件,之前我的測試條件是點選按鈕,每點選一次就生成一個彈幕,可能是沒有測試時間不夠長,沒有達到效能瓶頸,所以顯示挺正常的,現在將增加更為嚴格的測試條件:每次點選按鈕生成10條彈幕。

基於XDanmuku的Android效能優化實戰

1. 未做任何優化之前

在未做任何優化時,每點選按鈕一次,就生成10個彈幕,點了生成新的彈幕按鈕大概10次左右,介面直接卡死。

基於XDanmuku的Android效能優化實戰

開啟Android Monitor視窗,切換到Monitors選項卡,檢視Memory(AS預設顯示的第一個為CPU,Memory在CPU上面,所以要滑動下滾輪才能看到)。記憶體直接飆升到12.62M,而且還在逐漸增加。

基於XDanmuku的Android效能優化實戰

2. 減少執行緒數

我之前的思路是這樣的,根據彈幕的模型構造不同View,並對每一個View開啟一個執行緒控制它的座標向左移動。細心的讀者可能會發現:

Q: 為什麼不直接使用Android 動畫來實現View的移動呢?

A: Android中的動畫本質上移動的不是原來的View,而是對View的影像進行移動,所以View的觸控事件都在原來的位置,這樣就無法實現彈幕點選事件了。

每一個View都開啟一個單獨的執行緒控制其移動,實在是太佔用記憶體了,想想我連續點選10次按鈕,生成100個彈幕,相當於一瞬間有100個執行緒啟動,並且每個執行緒都在間隔10ms輪詢控制各自的座標。

優化建議:使用一個執行緒控制所有的View的移動,由執行緒每個4ms發出一個Message,Handler接收到Message後對當前ViewGroup的所有chlid進行移動。在Handler中對view進行檢測,如果view的右邊界已經超出了螢幕範圍,則把view從這個ViewGroup中移除。

Handler handler = new Handler() {
    @Override
    public void handleMessage(Message msg) {
        super.handleMessage(msg);
        if (msg.what == 1) {
            for(int i=0;i<DanmuContainerView.this.getChildCount();i++){
                View view = DanmuContainerView.this.getChildAt(i);
                if(view.getX()+view.getWidth() >= 0)
                    view.offsetLeftAndRight((int)(0 - speed));
                else{
                    //新增到快取中
                    ...
                    DanmuContainerView.this.removeView(view);
                }
            }
        }
    }
};複製程式碼

3. 增加快取功能

在掘金上原文下與kaient的交流討論中,得知快取功能十分必要。

kaient :
我自己寫的彈幕方法是:定義一個 View 或者 surfacview 做容器,彈幕就是 bitmap,這個 Bitmap 做成快取,當劃過螢幕後就放到快取裡,給下一個彈幕用。開三個執行緒,一個子執行緒負責從伺服器取彈幕資訊,一個子執行緒負責把彈幕資訊轉換成 Bitmap,一個子執行緒負責通知繪畫 (只要是為了控制卡頓問題,參照了 B 站的開源彈幕)。缺點就是:每個 bitmap 的大小都是一樣,高度隨便設,寬度根據最長的彈幕長度來定 (產品說最長的彈幕是 1.5 屏,超過就省略號,所有我就設成 1.5 屏)。上面這個方案目前測試全屏 80 條彈幕同時顯示基本不卡。

我想問彈幕控制元件增加快取功能。我參照ListViewBaseAdapter的快取複用技術,去掉了V0.1版本的DanmuConverter,增加XAdapter作為彈幕介面卡,並且彈幕的Entity必須繼承ModelModel中有一個inttype表示彈幕的型別區分,程式碼如下:

public class Model {
    int type ;

    public int getType() {
        return type;
    }

    public void setType(int type) {
        this.type = type;
    }
}複製程式碼

XAdapter程式碼如下:

public abstract class XAdapter<M>{
    private HashMap<Integer,Stack<View>> cacheViews ;

    public XAdapter()
    {
        cacheViews = new HashMap<>();
        int typeArray[] = getViewTypeArray();
        for(int i=0;i<typeArray.length;i++){
            Stack<View> stack = new Stack<>();
            cacheViews.put(typeArray[i],stack);
        }
    }

    public abstract View getView(M danmuEntity, View convertView);

    public abstract int[] getViewTypeArray();
    public abstract int getSingleLineHeight();

    synchronized public void addToCacheViews(int type,View view) {
        if(cacheViews.containsKey(type)){
            cacheViews.get(type).push(view);
        }
        else{
            throw new Error("you are trying to add undefined type view to cacheViews,please define the type in the XAdapter!");
        }
    }

    synchronized public View removeFromCacheViews(int type) {
        if(cacheViews.get(type).size()>0)
            return cacheViews.get(type).pop();
        else
            return null;
    }

    //縮小快取陣列的長度,以減少記憶體佔用
    synchronized public void shrinkCacheSize() {
        int typeArray[] = getViewTypeArray();
        for(int i=0;i<typeArray.length;i++){
            int type = typeArray[i];
            Stack<View> typeStack = cacheViews.get(type);
            int length = typeStack.size();
            while(typeStack.size() > ((int)(length/2.0+0.5))){
                typeStack.pop();
            }
            cacheViews.put(type,typeStack);
        }
    }

    public int getCacheSize()
    {
        int totalSize = 0;
        int typeArray[] = getViewTypeArray();
        Stack typeStack = null;
        for(int i=0;i<typeArray.length;i++){
            int type = typeArray[i];
            typeStack = cacheViews.get(type);
            totalSize += typeStack.size();
        }
        return totalSize;
    }
}複製程式碼

好啦,關鍵就在這裡啦:cacheViews是一個按照型別分類的HashMap,鍵的型別為int型,也就是Model中的type,值的型別為Stack,是一個包含View的棧。

先看構造方法XAdapter(),在這裡我初始化了cacheViews,並且根據int typeArray[] = getViewTypeArray();獲取所有的彈幕型別的type值組成的陣列,getViewTypeArray()是一個抽象方法,需要使用者自行返回type值組成的陣列。然後把每個彈幕型別對於的棧初始化,防止獲取到null.

public abstract View getView(M danmuEntity, View convertView);則是模仿AdaptergetView()方法,它的功能是傳入彈幕的Model,將Model上資料繫結到View上,並且返回View,是抽象方法,需要使用者實現。

public abstract int getSingleLineHeight();則是一個讓使用者確定每一行航道的高度的抽象函式,如果使用者知道具體的值,可以直接返回具體值,否則建議使用者對不同的View進行測量,取測量高度的最大值。

synchronized public void addToCacheViews(int type,View view)的作用是向cacheViews中新增快取View物件。type代表彈幕的型別,使用HaskMapget()方法獲取該型別的所有彈幕的棧,並使用push()新增.

synchronized public View removeFromCacheViews(int type)的作用是當使用者使用了快取陣列中的View時,將此View從cacheViews中移除。

synchronized public void shrinkCacheSize()的作用是減小快取陣列的長度,因為快取陣列的長度不會減少,只有removeFromCacheViews表面會減少快取陣列長度,實際上都這個從removeFromCacheViews中返回的View移動到螢幕外後又會自動新增到快取陣列中,所以需要新增一個策略在不需要大量彈幕時減少快取陣列的長度,這個方法就是將快取陣列的長度減到一半的,什麼時候減少快取陣列長度我們在後面談。

public int getCacheSize()的作用統計cacheViews中快取的View的總個數。

使用者自定義DanmuAdapter,繼承XAdapter,並實現其中的虛擬函式。

public class DanmuAdapter extends XAdapter<DanmuEntity> {

    final int ICON_RESOURCES[] = {R.drawable.icon1, R.drawable.icon2, R.drawable.icon3, R.drawable.icon4, R.drawable.icon5};
    Random random;

    private Context context;
    DanmuAdapter(Context c){
        super();
        context = c;
        random = new Random();
    }

    @Override
    public View getView(DanmuEntity danmuEntity, View convertView) {

        ViewHolder1 holder1 = null;
        ViewHolder2 holder2 = null;

        if(convertView == null){
            switch (danmuEntity.getType()) {
                case 0:
                    convertView = LayoutInflater.from(context).inflate(R.layout.item_danmu, null);
                    holder1 = new ViewHolder1();
                    holder1.content = (TextView) convertView.findViewById(R.id.content);
                    holder1.image = (ImageView) convertView.findViewById(R.id.image);
                    convertView.setTag(holder1);
                    break;
                case 1:
                    convertView = LayoutInflater.from(context).inflate(R.layout.item_super_danmu, null);
                    holder2 = new ViewHolder2();
                    holder2.content = (TextView) convertView.findViewById(R.id.content);
                    holder2.time = (TextView) convertView.findViewById(R.id.time);
                    convertView.setTag(holder2);
                    break;
            }
        }
        else{
            switch (danmuEntity.getType()) {
                case 0:
                    holder1 = (ViewHolder1)convertView.getTag();
                    break;
                case 1:
                    holder2 = (ViewHolder2)convertView.getTag();
                    break;
            }
        }

        switch (danmuEntity.getType()) {
            case 0:
                Glide.with(context).load(ICON_RESOURCES[random.nextInt(5)]).into(holder1.image);
                holder1.content.setText(danmuEntity.content);
                holder1.content.setTextColor(Color.rgb(random.nextInt(256), random.nextInt(256), random.nextInt(256)));
                break;
            case 1:
                holder2.content.setText(danmuEntity.content);
                holder2.time.setText(danmuEntity.getTime());
                break;
        }

        return convertView;
    }

    @Override
    public int[] getViewTypeArray() {
        int type[] = {0,1};
        return type;
    }

    @Override
    public int getSingleLineHeight() {
        //將所有型別彈幕的佈局拿出來,找到高度最大值,作為彈道高度
        View view = LayoutInflater.from(context).inflate(R.layout.item_danmu, null);
        //指定行高
        view.measure(0, 0);

        View view2 = LayoutInflater.from(context).inflate(R.layout.item_super_danmu, null);
        //指定行高
        view2.measure(0, 0);

        return Math.max(view.getMeasuredHeight(),view2.getMeasuredHeight());
    }


    class ViewHolder1{
        public TextView content;
        public ImageView image;
    }

    class ViewHolder2{
        public TextView content;
        public TextView time;
    }


}複製程式碼

可以看到getView()中的具體程式碼是不是似曾相識?沒錯,之前常寫的BaseAdapter裡,幾乎一模一樣,所以我也不花時間介紹這個方法了。getSingleLineHeight就是測量航道的高度的方法,可以看到我計算了兩個佈局的高度,並且取其中的較大值作為航道高度。getViewTypeArray()則是很直接的返回你的彈幕的所有型別組成的陣列。

下面到了關鍵了,如何去在我自定義的這個ViewGroup中使用這個DanmuAdapter呢?

public void setAdapter(XAdapter danmuAdapter) {
    xAdapter = danmuAdapter;
    singleLineHeight = danmuAdapter.getSingleLineHeight();
    new Thread(new MyRunnable()).start();
}複製程式碼

首先得設定setAdapter,並獲取航道高度,並開啟View移動的執行緒。

再新增彈幕的方法addDanmu()中:

public void addDanmu(final Model model){
    if (xAdapter == null) {
        throw new Error("XAdapter(an interface need to be implemented) can`t be null,you should call setAdapter firstly");
    }

    View danmuView = null;
    if(xAdapter.getCacheSize() >= 1){
        danmuView = xAdapter.getView(model,xAdapter.removeFromCacheViews(model.getType()));
        if(danmuView == null)
            addTypeView(model,danmuView,false);
        else
            addTypeView(model,danmuView,true);
    }
    else {
        danmuView = xAdapter.getView(model,null);
        addTypeView(model,danmuView,false);
    }

    //新增監聽
    danmuView.setOnClickListener(new OnClickListener() {
        @Override
        public void onClick(View v) {
            if(onItemClickListener != null)
                onItemClickListener.onItemClick(model);
        }
    });
}複製程式碼

這裡的邏輯就是,如果xAdapter的快取棧中有View那麼就直接從xAdapter中使用xAdapter.removeFromCacheViews(model.getType())獲取,當然可能沒有這個type型別的彈幕快取View,如果沒有,就返回null.如果快取陣列中沒有View了,那麼就使用danmuView = xAdapter.getView(model,null);讓程式根據layout佈局檔案再生成一個View。

addTypeView的定義如下:

public void addTypeView(Model model,View child,boolean isReused) {
    super.addView(child);

    child.measure(0, 0);
    //把寬高拿到,寬高都是包含ItemDecorate的尺寸
    int width = child.getMeasuredWidth();
    int height = child.getMeasuredHeight();
    //獲取最佳行數
    int bestLine = getBestLine();
    child.layout(WIDTH, singleLineHeight * bestLine, WIDTH + width, singleLineHeight * bestLine + height);

    InnerEntity innerEntity = null;
    innerEntity = (InnerEntity) child.getTag(R.id.tag_inner_entity);
    if(!isReused || innerEntity==null){
        innerEntity = new InnerEntity();
    }
    innerEntity.model = model;
    innerEntity.bestLine = bestLine;
    child.setTag(R.id.tag_inner_entity,innerEntity);

    spanList.set(bestLine, child);

}複製程式碼

首先使用super.addView(child)新增child,然後設定child的位置。然後將InnerEntity型別的變數繫結到View上面,InnerEntity型別:

class InnerEntity{
    public int bestLine;
    public Model model;
}複製程式碼

包含該View的所處行數和View中繫結的Model資料。考慮到使用者可能會在DanmuAdapter中對Viewtag進行設定,所以不能直接使用setTag(Object object)方法繼續繫結InnerEntity型別的變數了,這裡可以使用setTag(int id,Object object)方法,首先在string.xml檔案中定義一個id:<item type="id" name="tag_inner_entity"></item>,然後使用child.setTag(R.id.tag_inner_entity,innerEntity);則避免了和setTag(Object object)的衝突。

啟動的執行緒會自動的每隔4ms遍歷一次,執行以下內容:

private class MyRunnable implements Runnable {
    @Override
    public void run() {
        int count = 0;
        Message msg = null;
        while(true){
            if(count < 7500){
                count ++;
            }
            else{
                count = 0;
                if(DanmuContainerView.this.getChildCount() < xAdapter.getCacheSize() / 2){
                    xAdapter.shrinkCacheSize();
                    System.gc();
                }
            }
            if(DanmuContainerView.this.getChildCount() >= 0){
                msg = new Message();
                msg.what = 1; //移動view
                handler.sendMessage(msg);
            }

            try {
                Thread.sleep(4);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

    }
}複製程式碼

count為計數器,每隔4ms計數一次,7500次後正好為30s,也就是30s檢測一次彈幕,如果當前彈幕量小於快取View數量的一半,就呼叫shrinkCacheSize()xAdapter中的快取陣列長度減少一半。

4. Bitmap的回收

開啟Android Monitors視窗,檢視Memory,執行一段時間程式後,點選Initiate GC,手動回收可回收的記憶體垃圾,剩下的就是不可回收的記憶體了,點選Dump Java Heap按鈕,等待一會會自動開啟當前記憶體使用狀態。我只關注Shallow Size,按照從大到小的順序可以看到,byte[]佔用了7,879,324個位元組的記憶體,然後點開byte[]檢視Instance,同樣按照從到小的順序,Shallow Size的前幾名都是Bitmap,因此可能是Bitmap的記憶體回收沒有做處理,的確,我在寫測試案例時沒有主要對bitmap的複用和回收,所以產生大量的記憶體洩露,簡單起見,我引入Glide圖片載入框架,使用Glide載入圖片。

5.總結

以上工作做完了,狂點生成彈幕按鈕,記憶體也不見飆升,基本維持在4-5M左右。可見,優化效果明顯,由之前的幾十M記憶體優化到4-5M。

基於XDanmuku的Android效能優化實戰

XDanmuku的第二個版本也就出來了。XDanmuku的V1.1版本,歡迎大家Star和提交Issues。

XDanmuku的V1.1版本 專案地址:XDanmuku

不知不覺,這篇文章寫了三個多小時了,要是這篇文章對你有一點啟發或幫助,您可以去我的部落格打賞和關注我。

基於XDanmuku的Android效能優化實戰

致謝

感謝以下使用者的建議和反饋:

相關文章