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
先看構造方法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
不知不覺,這篇文章寫了三個多小時了,要是這篇文章對你有一點啟發或幫助,您可以去我的部落格打賞和關注我。
致謝
感謝以下使用者的建議和反饋: