基于 XDanmuku 的 Android 性能优化实战

youyo2013 4年前
   <p>V1.0版本于4天前首发与我的掘金专栏,发布后大家的支持让我喜出望外,截止本文发稿,掘金上原文喜欢数为259,Github上 项目 的Star数为151。很惭愧,就做了这么一点微小的工作。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/6ff319fe8dbf4f0a831c3b8fec7cdf49.png"></p>    <p>不过,好景不长,在发布不久后Github上 tz-xiaomage 提交了一个题为 <strong> 体验不好,滑动很卡 </strong> 的Issue.当时我并没有很重视,以为是我程序中线程睡眠时间有点长导致的。然后amszsthl也在该Issue下评论</p>    <p>弹幕滚动的时候一卡一卡的。</p>    <p>这是我才开始认真思考,这不是偶然事件,应该是程序出问题了。</p>    <p>现在开始查找卡顿原因,以优化优化性能。</p>    <p>首先设置测试条件,之前我的测试条件是点击按钮,每点击一次就生成一个弹幕,可能是没有测试时间不够长,没有达到性能瓶颈,所以显示挺正常的,现在将增加更为严格的测试条件:每次点击按钮生成10条弹幕。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/17c8a76601b94c5fe6d0db3884d56da0.gif"></p>    <h2>1. 未做任何优化之前</h2>    <p>在未做任何优化时,每点击按钮一次,就生成10个弹幕,点了生成新的弹幕按钮大概10次左右,界面直接卡死。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/313eb4d2c1b6f60abde992fbd41d4b5f.png"></p>    <p>打开Android Monitor窗口,切换到Monitors选项卡,查看Memory(AS默认显示的第一个为CPU,Memory在CPU上面,所以要滑动下滚轮才能看到)。内存直接飙升到12.62M,而且还在逐渐增加。</p>    <p><img src="https://simg.open-open.com/show/8330b462a099a567fea9374bbf82955e.png"></p>    <h2>2. 减少线程数</h2>    <p>我之前的思路是这样的,根据弹幕的模型构造不同View,并对每一个View开启一个线程控制它的坐标向左移动。细心的读者可能会发现:</p>    <p>Q: 为什么不直接使用Android 动画来实现View的移动呢?</p>    <p>A: Android中的动画本质上移动的不是原来的View,而是对View的影像进行移动,所以View的触摸事件都在原来的位置,这样就无法实现弹幕点击事件了。</p>    <p>每一个View都开启一个单独的线程控制其移动,实在是太占用内存了,想想我连续点击10次按钮,生成100个弹幕,相当于一瞬间有100个线程启动,并且每个线程都在间隔10ms轮询控制各自的坐标。</p>    <p>优化建议:使用一个线程控制所有的View的移动,由线程每个4ms发出一个Message,Handler接收到Message后对当前ViewGroup的所有chlid进行移动。在Handler中对view进行检测,如果view的右边界已经超出了屏幕范围,则把view从这个ViewGroup中移除。</p>    <pre>  <code class="language-java">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);                  }              }          }      }  };</code></pre>    <h2>3. 增加缓存功能</h2>    <p>在掘金上原文下与kaient的交流讨论中,得知缓存功能十分必要。</p>    <p>kaient :</p>    <p>我自己写的弹幕方法是:定义一个 View 或者 surfacview 做容器,弹幕就是 bitmap,这个 Bitmap 做成缓存,当划过屏幕后就放到缓存里,给下一个弹幕用。开三个线程,一个子线程负责从服务器取弹幕信息,一个子线程负责把弹幕信息转换成 Bitmap,一个子线程负责通知绘画 (只要是为了控制卡顿问题,参照了 B 站的开源弹幕)。缺点就是:每个 bitmap 的大小都是一样,高度随便设,宽度根据最长的弹幕长度来定 (产品说最长的弹幕是 1.5 屏,超过就省略号,所有我就设成 1.5 屏)。上面这个方案目前测试全屏 80 条弹幕同时显示基本不卡。</p>    <p>我想问弹幕控件增加缓存功能。我参照 ListView 的 BaseAdapter 的缓存复用技术,去掉了V0.1版本的 DanmuConverter ,增加 XAdapter 作为弹幕适配器,并且弹幕的Entity必须继承 Model 。 Model 中有一个 int 型 type 表示弹幕的类型区分,代码如下:</p>    <pre>  <code class="language-java">public class Model {      int type ;        public int getType() {          return type;      }        public void setType(int type) {          this.type = type;      }  }</code></pre>    <p>XAdapter代码如下:</p>    <pre>  <code class="language-java">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;      }  }</code></pre>    <p>好啦,关键就在这里啦: cacheViews 是一个按照类型分类的 HashMap ,键的类型为 int 型,也就是 Model 中的 type ,值的类型为Stack ,是一个包含View的栈。</p>    <p>先看构造方法 XAdapter() ,在这里我初始化了 cacheViews ,并且根据 int typeArray[] = getViewTypeArray(); 获取所有的弹幕类型的type值组成的数组, getViewTypeArray() 是一个抽象方法,需要用户自行返回type值组成的数组。然后把每个弹幕类型对于的栈初始化,防止获取到 null .</p>    <p>public abstract View getView(M danmuEntity, View convertView); 则是模仿 Adapter 的 getView() 方法,它的功能是传入弹幕的Model,将Model上数据绑定到View上,并且返回View,是抽象方法,需要用户实现。</p>    <p>public abstract int getSingleLineHeight(); 则是一个让用户确定每一行航道的高度的抽象函数,如果用户知道具体的值,可以直接返回具体值,否则建议用户对不同的View进行测量,取测量高度的最大值。</p>    <p>synchronized public void addToCacheViews(int type,View view) 的作用是向 cacheViews 中添加缓存View对象。 type 代表弹幕的类型,使用 HaskMap 的 get() 方法获取该类型的所有弹幕的栈,并使用 push() 添加.</p>    <p>synchronized public View removeFromCacheViews(int type) 的作用是当用户使用了缓存数组中的View时,将此View从 cacheViews 中移除。</p>    <p>synchronized public void shrinkCacheSize() 的作用是减小缓存数组的长度,因为缓存数组的长度不会减少,只有 removeFromCacheViews 表面会减少缓存数组长度,实际上都这个从 removeFromCacheViews 中返回的View移动到屏幕外后又会自动添加到缓存数组中,所以需要添加一个策略在不需要大量弹幕时减少缓存数组的长度,这个方法就是将缓存数组的长度减到一半的,什么时候减少缓存数组长度我们在后面谈。</p>    <p>public int getCacheSize() 的作用统计 cacheViews 中缓存的View的总个数。</p>    <p>用户自定义DanmuAdapter,继承XAdapter,并实现其中的虚函数。</p>    <pre>  <code class="language-java">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;      }      }</code></pre>    <p>可以看到 getView() 中的具体代码是不是似曾相识?没错,之前常写的 BaseAdapter 里,几乎一模一样,所以我也不花时间介绍这个方法了。 getSingleLineHeight 就是测量航道的高度的方法,可以看到我计算了两个布局的高度,并且取其中的较大值作为航道高度。 getViewTypeArray() 则是很直接的返回你的弹幕的所有类型组成的数组。</p>    <p>下面到了关键了,如何去在我自定义的这个 ViewGroup 中使用这个DanmuAdapter呢?</p>    <pre>  <code class="language-java">public void setAdapter(XAdapter danmuAdapter) {      xAdapter = danmuAdapter;      singleLineHeight = danmuAdapter.getSingleLineHeight();      new Thread(new MyRunnable()).start();  }</code></pre>    <p>首先得设置 setAdapter ,并获取航道高度,并开启View移动的线程。</p>    <p>再添加弹幕的方法 addDanmu() 中:</p>    <pre>  <code class="language-java">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);          }      });  }</code></pre>    <p>这里的逻辑就是,如果 xAdapter 的缓存栈中有 View 那么就直接从xAdapter中使用 xAdapter.removeFromCacheViews(model.getType()) 获取,当然可能没有这个 type 类型的弹幕缓存 View ,如果没有,就返回 null .如果缓存数组中没有View了,那么就使用 danmuView = xAdapter.getView(model,null); 让程序根据layout布局文件再生成一个View。</p>    <p>addTypeView 的定义如下:</p>    <pre>  <code class="language-java">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);    }</code></pre>    <p>首先使用 super.addView(child) 添加child,然后设置child的位置。然后将InnerEntity类型的变量绑定到View上面,InnerEntity类型:</p>    <pre>  <code class="language-java">class InnerEntity{      public int bestLine;      public Model model;  }</code></pre>    <p>包含该 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) 的冲突。</p>    <p>启动的线程会自动的每隔4ms遍历一次,执行以下内容:</p>    <pre>  <code class="language-java">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();              }          }        }  }</code></pre>    <p>count 为计数器,每隔4ms计数一次,7500次后正好为30s,也就是30s检测一次弹幕,如果当前弹幕量小于缓存 View 数量的一半,就调用 shrinkCacheSize() 将 xAdapter 中的缓存数组长度减少一半。</p>    <h2>4. Bitmap的回收</h2>    <p>打开Android Monitors窗口,查看Memory,运行一段时间程序后,点击Initiate GC,手动回收可回收的内存垃圾,剩下的就是不可回收的内存了,点击Dump Java Heap按钮,等待一会会自动打开当前内存使用状态。我只关注Shallow Size,按照从大到小的顺序可以看到,byte[]占用了7,879,324个字节的内存,然后点开byte[]查看Instance,同样按照从到小的顺序,Shallow Size的前几名都是Bitmap,因此可能是Bitmap的内存回收没有做处理,的确,我在写测试案例时没有主要对bitmap的复用和回收,所以产生大量的内存泄露,简单起见,我引入Glide图片加载框架,使用Glide加载图片。</p>    <h2>5.总结</h2>    <p>以上工作做完了,狂点生成弹幕按钮,内存也不见飙升,基本维持在4-5M左右。可见,优化效果明显,由之前的几十M内存优化到4-5M。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/a13e52b26f30147a574e6b4fed1bac69.png"></p>    <p>XDanmuku的第二个版本也就出来了。XDanmuku的V1.1版本,欢迎大家Star和提交Issues。</p>    <p>XDanmuku的V1.1版本 项目地址: <a href="/misc/goto?guid=4959747611197783654" rel="nofollow,noindex">XDanmuku</a></p>    <p>不知不觉,这篇文章写了三个多小时了,要是这篇文章对你有一点启发或帮助,您可以去我的博客打赏和关注我。</p>    <p> </p>    <h2> </h2>    <p> </p>    <p>来自:https://juejin.im/post/58f4de53da2f60005d3fe0e7</p>    <p> </p>