輕鬆又酷炫地實現彈幕效果——手把手教學
前言
現在越來越多的視訊網站或者客戶端支援彈幕功能,彈幕功能似乎也成了很多人的愛好,發彈幕,看彈幕成了大家吐槽、搞笑、發表看法的一種方式。
而國內彈幕的鼻祖應該就算A站和B站了。
彈幕(barrage),中文流行詞語,原意指用大量或少量火炮提供密集炮擊。而彈幕,顧名思義是指子彈多而形成的幕布,大量吐槽評論從螢幕飄過時效果看上去像是飛行射擊遊戲裡的彈幕。
最近一直在寫視訊播放器,那彈幕怎麼能少得了呢!所以把自己開發彈幕功能的思路寫出來與大家分享。
依舊還是先上效果圖:
大體思路
我們的目標是將各式各樣的itemView展示到播放器上方,並且使之滾動起來,itemView支援自定義,這樣看起來和ListView的功能很相像
所以,我採用介面卡模式,仿ListView的Adapter來實現彈幕功能。
想到這裡,很多人就會覺得這不典型的橫向瀑布流嘛,用RecyclerView或者flexbox很輕鬆就實現了。
但我想自己從設計模式、實現原理來考慮、設計,從而也可以更深刻地理解介面卡模式和ListView的原理,如果您想使用RecyclerView來實現,可以自己試試。
關鍵:
- 使用介面卡模式將各式各樣的itemView進行適配、處理、展示
- 使用hadler定時傳送訊息使itemView滾動
- itemView最佳位置的計算
- 滾動區域的設定
接下來就一起來實現:
1、實體類
實體類當然不能少了:
/**
* Description: 彈幕實體類
* Created by jia on 2017/9/25.
* 人之所以能,是相信能
*/
public class DanmuModel {
private int type;
public int getType() {
return type;
}
public void setType(int type) {
this.type = type;
}
}
其中的type是model實體類的型別,因為彈幕的itemView中會有多種型別,對應不同type的實體類。
使用時可以自己定義實體類,繼承自DanmuModel ,也可以不繼承,只要能區分不同型別就可以:因為自己稍後的adapter中沒有像ListView的Adapter一樣定義了獲取item型別的方法,所以就在getView方法中依據type選擇不同的itemView即可。
2、BaseAdapter
首先Adapter定義為抽象類,且設定泛型M,M就是對應的實體類。
在Adapter中定義三個抽象方法:
- getViewTypeArray :獲取itemView的型別type組成的陣列
- getSingleLineHeight :獲取單行itemView的高度
- getView :獲取itemView,功能類似於ListView的Adapter中getView方法
public abstract class DanmuAdapter<M> {
/**
* 獲取型別陣列
*
* @return
*/
public abstract int[] getViewTypeArray();
/**
* 獲取單行彈幕 高度
*
* @return
*/
public abstract int getSingleLineHeight();
/**
* 獲取itemView
*
* @param entry
* @param convertView
* @return
*/
public abstract View getView(M entry, View convertView);
}
這樣介面卡抽象類就定義好了嘛?不是的!
在顯示彈幕的時候會,會建立大量的View物件,如果不做處理,很容易造成記憶體溢位,所以我們要進行快取優化:
A、首先建立了map集合
// 使用HashMap,以型別和對應view的棧為key-value儲存,實現快取
private HashMap<Integer, Stack<View>> cacheViews;
以view的型別為key,對應的view存入棧中,以棧為value。
B、構造中
public DanmuAdapter() {
cacheViews = new HashMap<>();
typeArray = getViewTypeArray();
for (int i = 0; i < typeArray.length; i++) {
Stack<View> stack = new Stack<>();
cacheViews.put(typeArray[i], stack);
}
}
獲取itemView型別陣列,迴圈建立對應type的棧。
C、itemView加入快取
/**
* 將彈幕itemView加入快取(壓棧)
*
* @param type
* @param view
*/
synchronized public void addViewToCache(int type, View view) {
if (cacheViews.containsKey(type)) {
cacheViews.get(type).push(view);
} else {
throw new Error("your cache has not this type");
}
}
D、將itemView移出快取
/**
* 將itemView移出快取(彈棧)
*
* @param type
* @return
*/
synchronized public View removeViewFromCache(int type) {
if (cacheViews.containsKey(type) && cacheViews.get(type).size() > 0)
return cacheViews.get(type).pop();
else
return null;
}
E、減小快取大小
/**
* 減小快取大小
*/
public void shrinkCacheSize() {
int[] typeArray = getViewTypeArray();
for (int i = 0; i < typeArray.length; i++) {
if (cacheViews.containsKey(typeArray[i])) {
Stack<View> typeStack = cacheViews.get(typeArray[i]);
int length = typeStack.size();
// 迴圈彈棧,直到大小變為原來一半
while (typeStack.size() > (int) (length / 2.0 + 0.5)) {
typeStack.pop();
}
cacheViews.put(typeArray[i], typeStack);
}
}
}
F、獲取快取大小
/**
* 獲取快取大小
*
* @return
*/
public int getCacheSize() {
int size = 0;
int[] types = getViewTypeArray();
for (int i = 0; i < types.length; i++) {
size = size + cacheViews.get(types[i]).size();
}
return size;
}
ok,到這裡BaseAdapter就封裝完成了
3、DanmuView
繼承自ViewGroup,重寫其三個構造方法是必然的。不再累贅
A、變數、以及get/set方法
// 移動速度
public static final int LOWER_SPEED = 1;
public static final int NORMAL_SPEED = 4;
public static final int HIGH_SPEED = 8;
// 出現位置
public final static int GRAVITY_TOP = 1; //001
public final static int GRAVITY_CENTER = 2; //010
public final static int GRAVITY_BOTTOM = 4; //100
public final static int GRAVITY_FULL = 7; //111
private int gravity = GRAVITY_FULL;
private int speed = 4;
private int spanCount = 6;
private int WIDTH, HEIGHT;
private int singltLineHeight;
private DanmuAdapter adapter;
public List<View> spanList;
private OnItemClickListener onItemClickListener;
首先要有這樣一個思路,在介面卡中抽取出方法,返回itemView的高度,在彈幕View中根據彈幕繪製區域高度,除以itemView的高度,算出合理的彈幕行數(這裡大家也理解了為什麼在寫介面卡的時候要定義getSingleLineHeight()方法了)。
B、再次封裝實體類
這裡只是簡單得將傳進來的實體類DanmuModel與計算出的對應的最佳行數進行封裝。
class InnerEntity {
public int bestLine;
public DanmuModel model;
}
C、設定Adapter
public void setAdapter(DanmuAdapter adapter) {
this.adapter = adapter;
singltLineHeight = adapter.getSingleLineHeight();
// 開執行緒使彈幕滾動起來,稍後會介紹
}
D、計算最佳位置
關鍵的來了,先上程式碼
/**
* 計算最佳位置
*
* @return
*/
private int getBestLine() {
// 轉換為2進位制
int gewei = gravity % 2;
int temp = gravity / 2;
int shiwei = temp % 2;
temp = temp / 2;
int baiwei = temp % 2;
// 將所有的行分為三份,前兩份行數相同,將第一份的行數四捨五入
int firstLine = (int) (spanCount / 3.0 + 0.5);
List<Integer> legalLines = new ArrayList<>();
if (gewei == 1) {
for (int i = 0; i < firstLine; i++) {
legalLines.add(i);
}
}
if (shiwei == 1) {
for (int i = firstLine; i < firstLine * 2; i++) {
legalLines.add(i);
}
}
if (baiwei == 1) {
for (int i = firstLine * 2; i < spanCount; i++) {
legalLines.add(i);
}
}
int bestLine = 0;
// 如果有空行,將空行返回
for (int i = 0; i < spanCount; i++) {
if (spanList.get(i) == null) {
bestLine = i;
if (legalLines.contains(bestLine))
return bestLine;
}
}
float minSpace = Integer.MAX_VALUE;
// 沒有空行,就找最大空間的
for (int i = spanCount - 1; i >= 0; i--) {
if (legalLines.contains(i)) {
if (spanList.get(i).getX() + spanList.get(i).getWidth() <= minSpace) {
minSpace = spanList.get(i).getX() + spanList.get(i).getWidth();
bestLine = i;
}
}
}
return bestLine;
}
不知是否有注意到,在定義顯示位置的常亮的時候,只用了1,2,4,7,因為它們轉化為二進位制數為001,010,100,111,這裡用了一個巧妙的思路,三位數代表螢幕三個位置,0表示不顯示彈幕,1表示顯示彈幕(有沒有豁然開朗)
大家可以參照程式碼來看,計算最佳位置的思路是這樣的:
- 將設定的位置轉為二進位制數,判斷顯示位置
- 將所有的行分為三份,前兩份行數相同,將第一份的行數四捨五入,將所有要顯示彈幕的行數放入一集合中
- 由上至下迴圈判斷是否有空行,有空行則直接返回,此行就是這個itemView的最佳位置
- 沒有空行的話,由下至上尋找最大空間返回,就是該itemView的最佳位置
E、根據型別設定View
/**
* 新增view
*/
public void addTypeView(DanmuModel model, View child, boolean isReused) {
super.addView(child);
child.measure(0, 0);
//把寬高拿到,寬高都是包含ItemDecorate的尺寸
int width = child.getMeasuredWidth();
int height = child.getMeasuredHeight();
//獲取最佳行數
int bestLine = getBestLine();
// 設定子view位置
child.layout(WIDTH, singltLineHeight * bestLine, WIDTH + width, singltLineHeight * 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);
}
這裡就不多說了,將itemView的model與最佳位置對應起來並設定位置;
然後將spanList(itemView集合)對應view設定進去。
一定要注意:super.addView(child); child.measure(0, 0); 這兩句話不能少!
F、新增彈幕
/**
* 新增彈幕view
*
* @param model
*/
public void addDanmu(final DanmuModel model) {
if (adapter == null) {
throw new Error("DanmuAdapter(an interface need to be implemented) can't be null,you should call setAdapter firstly");
}
View dmView = null;
if (adapter.getCacheSize() >= 1) {
dmView = adapter.getView(model, adapter.removeViewFromCache(model.getType()));
if (dmView == null)
addTypeView(model, dmView, false);
else
addTypeView(model, dmView, true);
} else {
dmView = adapter.getView(model, null);
addTypeView(model, dmView, false);
}
//新增監聽
dmView.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
if(onItemClickListener != null)
onItemClickListener.onItemClick(model);
}
});
}
此方法則是暴露外部的設定彈幕view的方法,這裡注意一下,itemView有快取就複用,沒快取就不復用,就ok了。
G、子執行緒計算時間,傳送訊息,handler處理view平移
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(DanmuView.this.getChildCount() < adapter.getCacheSize() / 2){
adapter.shrinkCacheSize();
System.gc();
}
}
if(DanmuView.this.getChildCount() >= 0){
msg = new Message();
msg.what = 1; //移動view
handler.sendMessage(msg);
}
try {
Thread.sleep(16);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
這裡注意:
Adapter快取過大要及時清理;
每隔16毫秒讓itemView位置重新整理一次,這樣視覺效果好一些;
在setAdapter中開啟執行緒 new Thread(new MyRunnable()).start();
Handler handler = new Handler() {
@Override
public void handleMessage(Message msg) {
super.handleMessage(msg);
if (msg.what == 1) {
for(int i=0;i<DanmuView.this.getChildCount();i++){
View view = DanmuView.this.getChildAt(i);
if(view.getX()+view.getWidth() >= 0)
// 向左滑動
view.offsetLeftAndRight(0 - speed);
else{
//新增到快取中
int type = ((InnerEntity)view.getTag(R.id.tag_inner_entity)).model.getType();
adapter.addViewToCache(type,view);
DanmuView.this.removeView(view);
}
}
}
}
};
使用舉例:
1、實體類
/**
* Description:彈幕實體類
* Created by jia on 2017/9/25.
* 人之所以能,是相信能
*/
public class MyDanmuModel extends DanmuModel {
public String content;
public int textColor;
public String time;
public String getTime() {
return time;
}
public void setTime(String time) {
this.time = time;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public int getTextColor() {
return textColor;
}
public void setTextColor(int textColor) {
this.textColor = textColor;
}
}
2、介面卡
/**
* Description: 彈幕介面卡
* Created by jia on 2017/9/25.
* 人之所以能,是相信能
*/
public class MyDanmuAdapter extends DanmuAdapter<MyDanmuModel> {
private Context context;
public MyDanmuAdapter(Context c){
super();
context = c;
}
@Override
public int[] getViewTypeArray() {
int type[] = {0};
return type;
}
@Override
public int getSingleLineHeight() {
View view = LayoutInflater.from(context).inflate(R.layout.item_danmu, null);
//指定行高
view.measure(0, 0);
return view.getMeasuredHeight();
}
@Override
public View getView(MyDanmuModel entry, View convertView) {
ViewHolder vh=null;
if(convertView==null){
convertView= LayoutInflater.from(context).inflate(R.layout.item_danmu,null);
vh=new ViewHolder();
vh.tv=convertView.findViewById(R.id.tv_danmu);
convertView.setTag(vh);
}else{
vh= (ViewHolder) convertView.getTag();
}
vh.tv.setText(entry.getContent());
vh.tv.setTextColor(entry.getTextColor());
return convertView;
}
class ViewHolder{
TextView tv;
}
}
有木有很像ListView的Adapter!
相信大家一看就能明白,就不再多說。
3、配置基本資訊
jsplayer_danmu.setDanMuAdapter(new MyDanmuAdapter(this));
jsplayer_danmu.setDanMuGravity(3);
jsplayer_danmu.setDanMuSpeed(DanmuView.NORMAL_SPEED);
4、建立實體類並設定給DanmuView
MyDanmuModel danmuEntity = new MyDanmuModel();
danmuEntity.setType(0);
danmuEntity.setContent(DANMU[random.nextInt(8)]);
danmuEntity.setTextColor(COLOR[random.nextInt(4)]);
jsplayer_danmu.addDanmu(danmuEntity);
更多精彩內容,請關注我的微信公眾號——Android機動車