基于 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