基于 XDanmuku 的 Android 性能優化實戰

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

不過,好景不長,在發布不久后Github上 tz-xiaomage 提交了一個題為 體驗不好,滑動很卡 的Issue.當時我并沒有很重視,以為是我程序中線程睡眠時間有點長導致的。然后amszsthl也在該Issue下評論

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

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

現在開始查找卡頓原因,以優化優化性能。

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

1. 未做任何優化之前

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

打開Android Monitor窗口,切換到Monitors選項卡,查看Memory(AS默認顯示的第一個為CPU,Memory在CPU上面,所以要滑動下滾輪才能看到)。內存直接飆升到12.62M,而且還在逐漸增加。

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 條彈幕同時顯示基本不卡。

我想問彈幕控件增加緩存功能。我參照 ListView 的 BaseAdapter 的緩存復用技術,去掉了V0.1版本的 DanmuConverter ,增加 XAdapter 作為彈幕適配器,并且彈幕的Entity必須繼承 Model 。 Model 中有一個 int 型 type 表示彈幕的類型區分,代碼如下:

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); 則是模仿 Adapter 的 getView() 方法,它的功能是傳入彈幕的Model,將Model上數據綁定到View上,并且返回View,是抽象方法,需要用戶實現。

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

synchronized public void addToCacheViews(int type,View view) 的作用是向 cacheViews 中添加緩存View對象。 type 代表彈幕的類型,使用 HaskMap 的 get() 方法獲取該類型的所有彈幕的棧,并使用 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 中對 View 的 tag 進行設置,所以不能直接使用 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的第二個版本也就出來了。XDanmuku的V1.1版本,歡迎大家Star和提交Issues。

XDanmuku的V1.1版本 項目地址: XDanmuku

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

 

 

 

來自:https://juejin.im/post/58f4de53da2f60005d3fe0e7

 

 本文由用戶 youyo2013 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!