基于 XDanmuku 的 Android 性能优化实战

更新时间:2017-04-19 09:59:00 点击次数:1748次

写在前面

4天前发布了 XDanmuku V1.0 版本——《可能是目前轻量级弹幕控件中功能强大的一款》,发布后大家的支持让笔者喜出望外。

不过,好景不长,在发布不久后Github上tz-xiaomage提交了一个题为体验不好,滑动很卡的Issue。当时我并没有很重视,以为是我程序中线程睡眠时间有点长导致的。然后amszsthl也在该Issue下评论

弹幕滚动的时候一卡一卡的。

这是我才开始认真思考,这不是偶然事件,应该是程序出问题了。

现在开始查找卡顿原因,以优化优化性能。

首先设置测试条件,之前我的测试条件是点击按钮,每点击一次就生成一个弹幕,可能是没有测试时间不够长,没有达到性能瓶颈,所以显示挺正常的,现在将增加更为严格的测试条件:每次点击按钮生成10条弹幕。

未做任何优化之前

在未做任何优化时,每点击按钮一次,就生成10个弹幕,点了生成新的弹幕按钮大概10次左右,界面直接卡死。

打开Android Monitor窗口,切换到Monitors选项卡,查看Memory(AS默认显示的个为CPU,Memory在CPU上面,所以要滑动下滚轮才能看到)。内存直接飙升到12.62M,而且还在逐渐增加。

减少线程数

我之前的思路是这样的,根据弹幕的模型构造不同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="" if(view.getx()+view.getwidth()="">= 0)
                    view.offsetLeftAndRight((int)(0 - speed)); else{ //添加到缓存中 ...
                    DanmuContainerView.this.removeView(view);
                }
            }
        }
    }
};

增加缓存功能

在《可能是目前轻量级弹幕控件中功能强大的一款》文章下与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中的缓存数组长度减少一半。

Bitmap的回收

打开Android Monitors窗口,查看Memory,运行一段时间程序后,点击Initiate GC,手动回收可回收的内存垃圾,剩下的就是不可回收的内存了,点击Dump Java Heap按钮,等待一会会自动打开当前内存使用状态。我只关注Shallow Size,按照从大到小的顺序可以看到,byte[]占用了7,879,324个字节的内存,然后点开byte[]查看Instance,同样按照从到小的顺序,Shallow Size的前几名都是Bitmap,因此可能是Bitmap的内存回收没有做处理,的确,我在写测试案例时没有主要对bitmap的复用和回收,所以产生大量的内存泄露,简单起见,我引入Glide图片加载框架,使用Glide加载图片。

总结

以上工作做完了,狂点生成弹幕按钮,内存也不见飙升,基本维持在4-5M左右。可见,优化效果明显,由之前的几十M内存优化到4-5M。

本站文章版权归原作者及原出处所有 。内容为作者个人观点, 并不代表本站赞同其观点和对其真实性负责,本站只提供参考并不构成任何投资及应用建议。本站是一个个人学习交流的平台,网站上部分文章为转载,并不用于任何商业目的,我们已经尽可能的对作者和来源进行了通告,但是能力有限或疏忽,造成漏登,请及时联系我们,我们将根据著作权人的要求,立即更正或者删除有关内容。本站拥有对此声明的最终解释权。

回到顶部
嘿,我来帮您!