阿里P7移動互聯網架構師進階視頻(每日更新中)免費學習請點擊:https://space.bilibili.com/474380680
最近有用到水下氣泡上升效果,因此在網上查了一下資料,結果還真找到了,就是這篇文章 [Android實例] 水下氣泡上升界面效果, 不過這篇文章所附帶的示例代碼是有些問題的,例如View移除后,線程沒有正確關閉,鎖屏后再打開屏幕,氣泡會擠成一團等問題,因此我在它的原理基礎上稍為進行了一些調整和修改,解決了這些問題,它可以實現下面這樣的效果:

0. 基本原理
氣泡效果的基本原理非常簡單,其實所謂的氣泡就是一個個的半透明圓而已,它的基本邏輯如下:
如果當前圓的數量沒有超過數量上限,則隨機生成半徑不同的圓。
設定這些圓的初始位置。
隨機設定垂直向上平移速度。
隨機設定水平平移速度。
不斷的刷新圓的位置然后繪制。
將超出顯示區域的圓進行移除。
不斷重復。
原理可以說非常簡單,但是也有一些需要注意的地方,尤其是線程,最容易出問題。
在原始的 demo 中,直接把線程創建和計算邏輯直接放到了 onDraw 里面,而且沒有關閉線程,這自然會導致很多問題的發生。沒有關閉線程會造成View的內存泄露,而把計算邏輯放在 onDraw 里面則會加大繪制的負擔,拖慢刷新速度,在機能較弱的情況下會導致明顯卡頓的發生。而解決這些問題的最好辦法則是專事專辦,將合適的內容放在合適的位置,下面來看一下具體的代碼實現。
1. 代碼實現
1.1 定義氣泡
氣泡效果我們關心的屬性並不多,主要有這幾種:半徑、坐標、上升速度、水平平移速度。由於我們只在 View 內部使用,因此直接創建一個內部類,然后在內部類中定義這些屬性。
1.2 生命周期處理
由於需要用線程來進行計算和控制刷新,就少不了開啟和關閉線程,這個自然要符合 View 的生命周期,因此我在 View 被添加到界面上時開啟了一個線程用於生成氣泡和刷新氣泡位置,然后在 View 從界面上移除的時候關閉了這個線程。
1.3 開啟線程
開啟線程非常簡單,就是簡單的創建了一個線程,然后在里面添加了一個 while 死循環,然后不停的執行 休眠、創建氣泡、刷新氣泡位置、申請更新UI 等操作。
這里沒有用變量來控制循環,而是監聽了中斷事件,在當攔截到 InterruptedException 的時候,使用 break 跳出了死循環,因此線程也就結束了,方法簡單粗暴。
1.4 關閉線程
由於線程運行時監聽了 interrupt 中斷,這里直接使用 interrupt 通知線程中斷就可以了。
1.5 創建氣泡
為了防止氣泡數量過多而占用太多的性能,因此在創建氣泡之前需要先判斷當前已經有多少個氣泡,如果已經有足夠多的氣泡了,則不再創建新的氣泡。
同時,為了讓氣泡產生過程看起來更合理,在氣泡數量沒有達到上限之前,會隨機的創建氣泡,以防止氣泡扎堆出現,因此設立了一個隨機項,生成的隨機數大於 0.95 的時候才生成氣泡,讓氣泡生成過程慢一些。
創建氣泡的過程也很簡單,就是隨機的在設定范圍內生成一些屬性,然后放到 List 中而已。
PS:這里使用了一些硬編碼和魔數,屬於不太好的習慣。不過由於應用場景固定,這些參數需要調整的概率比較小,影響也不大。
1.6 刷新氣泡位置
這里主要做了兩項工作:
將超出顯示區域的氣泡進行移除。
計算新的氣泡顯示位置。
可以看到這里沒有直接使用原始的List,而是復制了一個 List 進行遍歷,這樣做主要是為了規避 ConcurrentModificationException 異常,(對Vector、ArrayList在迭代的時候如果同時對其進行修改就會拋出 java.util.ConcurrentModificationException 異常)。
對復制的 List 進行遍歷,然后對超出顯示區域的 Bubble 進行移除,對沒有超出顯示區域的 Bubble 位置進行了刷新。可以看到,這里邏輯比較復雜,有各種加減計算,是為了解決氣泡飄到邊緣的問題,防止氣泡飄出水所在的范圍。
1.7 繪制氣泡
繪制氣泡同樣簡單,就是遍歷 List,然后畫圓就行了。
這里同樣復制了一個新的 List 進行操作,不過這個與上面的原因不同,是為了防止多線程問題。由於在繪制的過程中,我們的計算線程可能會對原始 List 進行更新,可能導致異常的發生。為了避免這樣的問題,就復制了一個 List 出來用於遍歷繪制。
- 完整代碼
完整的示例代碼非常簡單,所以直接貼在了正文中,同時,你也可以從文末下載完整的項目代碼。
public class BubbleView extends View {
private int mBubbleMaxRadius = 30; // 氣泡最大半徑 px
private int mBubbleMinRadius = 5; // 氣泡最小半徑 px
private int mBubbleMaxSize = 30; // 氣泡數量
private int mBubbleRefreshTime = 20; // 刷新間隔
private int mBubbleMaxSpeedY = 5; // 氣泡速度
private int mBubbleAlpha = 128; // 氣泡畫筆
private float mBottleWidth; // 瓶子寬度
private float mBottleHeight; // 瓶子高度
private float mBottleRadius; // 瓶子底部轉角半徑
private float mBottleBorder; // 瓶子邊緣寬度
private float mBottleCapRadius; // 瓶子頂部轉角半徑
private float mWaterHeight; // 水的高度
private RectF mContentRectF; // 實際可用內容區域
private RectF mWaterRectF; // 水占用的區域
private Path mBottlePath; // 外部瓶子
private Path mWaterPath; // 水
private Paint mBottlePaint; // 瓶子畫筆
private Paint mWaterPaint; // 水畫筆
private Paint mBubblePaint; // 氣泡畫筆
public BubbleView(Context context) {
this(context, null);
}
public BubbleView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public BubbleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mWaterRectF = new RectF();
mBottleWidth = dp2px(130);
mBottleHeight = dp2px(260);
mBottleBorder = dp2px(8);
mBottleRadius = dp2px(15);
mBottleCapRadius = dp2px(5);
mWaterHeight = dp2px(240);
mBottlePath = new Path();
mWaterPath = new Path();
mBottlePaint = new Paint();
mBottlePaint.setAntiAlias(true);
mBottlePaint.setStyle(Paint.Style.STROKE);
mBottlePaint.setStrokeCap(Paint.Cap.ROUND);
mBottlePaint.setColor(Color.WHITE);
mBottlePaint.setStrokeWidth(mBottleBorder);
mWaterPaint = new Paint();
mWaterPaint.setAntiAlias(true);
initBubble();
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mContentRectF = new RectF(getPaddingLeft(), getPaddingTop(), w - getPaddingRight(), h - getPaddingBottom());
float bl = mContentRectF.centerX() - mBottleWidth / 2;
float bt = mContentRectF.centerY() - mBottleHeight / 2;
float br = mContentRectF.centerX() + mBottleWidth / 2;
float bb = mContentRectF.centerY() + mBottleHeight / 2;
mBottlePath.reset();
mBottlePath.moveTo(bl - mBottleCapRadius, bt - mBottleCapRadius);
mBottlePath.quadTo(bl, bt - mBottleCapRadius, bl, bt);
mBottlePath.lineTo(bl, bb - mBottleRadius);
mBottlePath.quadTo(bl, bb, bl + mBottleRadius, bb);
mBottlePath.lineTo(br - mBottleRadius, bb);
mBottlePath.quadTo(br, bb, br, bb - mBottleRadius);
mBottlePath.lineTo(br, bt);
mBottlePath.quadTo(br, bt - mBottleCapRadius, br + mBottleCapRadius, bt - mBottleCapRadius);
mWaterPath.reset();
mWaterPath.moveTo(bl, bb - mWaterHeight);
mWaterPath.lineTo(bl, bb - mBottleRadius);
mWaterPath.quadTo(bl, bb, bl + mBottleRadius, bb);
mWaterPath.lineTo(br - mBottleRadius, bb);
mWaterPath.quadTo(br, bb, br, bb - mBottleRadius);
mWaterPath.lineTo(br, bb - mWaterHeight);
mWaterPath.close();
mWaterRectF.set(bl, bb - mWaterHeight, br, bb);
LinearGradient gradient = new LinearGradient(mWaterRectF.centerX(), mWaterRectF.top,
mWaterRectF.centerX(), mWaterRectF.bottom, 0xFF4286f4, 0xFF373B44, Shader.TileMode.CLAMP);
mWaterPaint.setShader(gradient);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawPath(mWaterPath, mWaterPaint);
canvas.drawPath(mBottlePath, mBottlePaint);
drawBubble(canvas);
}
//--- 氣泡效果 ---------------------------------------------------------------------------------
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
startBubbleSync();
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
stopBubbleSync();
}
private class Bubble {
int radius; // 氣泡半徑
float speedY; // 上升速度
float speedX; // 平移速度
float x; // 氣泡x坐標
float y; // 氣泡y坐標
}
private ArrayList<Bubble> mBubbles = new ArrayList<>();
private Random random = new Random();
private Thread mBubbleThread;
// 初始化氣泡
private void initBubble() {
mBubblePaint = new Paint();
mBubblePaint.setColor(Color.WHITE);
mBubblePaint.setAlpha(mBubbleAlpha);
}
// 開始氣泡線程
private void startBubbleSync() {
stopBubbleSync();
mBubbleThread = new Thread() {
public void run() {
while (true) {
try {
Thread.sleep(mBubbleRefreshTime);
tryCreateBubble();
refreshBubbles();
postInvalidate();
} catch (InterruptedException e) {
System.out.println("Bubble線程結束");
break;
}
}
}
};
mBubbleThread.start();
}
// 停止氣泡線程
private void stopBubbleSync() {
if (null == mBubbleThread) return;
mBubbleThread.interrupt();
mBubbleThread = null;
}
// 繪制氣泡
private void drawBubble(Canvas canvas) {
List<Bubble> list = new ArrayList<>(mBubbles);
for (Bubble bubble : list) {
if (null == bubble) continue;
canvas.drawCircle(bubble.x, bubble.y,
bubble.radius, mBubblePaint);
}
}
// 嘗試創建氣泡
private void tryCreateBubble() {
if (null == mContentRectF) return;
if (mBubbles.size() >= mBubbleMaxSize) {
return;
}
if (random.nextFloat() < 0.95) {
return;
}
Bubble bubble = new Bubble();
int radius = random.nextInt(mBubbleMaxRadius - mBubbleMinRadius);
radius += mBubbleMinRadius;
float speedY = random.nextFloat() * mBubbleMaxSpeedY;
while (speedY < 1) {
speedY = random.nextFloat() * mBubbleMaxSpeedY;
}
bubble.radius = radius;
bubble.speedY = speedY;
bubble.x = mWaterRectF.centerX();
bubble.y = mWaterRectF.bottom - radius - mBottleBorder / 2;
float speedX = random.nextFloat() - 0.5f;
while (speedX == 0) {
speedX = random.nextFloat() - 0.5f;
}
bubble.speedX = speedX * 2;
mBubbles.add(bubble);
}
// 刷新氣泡位置,對於超出區域的氣泡進行移除
private void refreshBubbles() {
List<Bubble> list = new ArrayList<>(mBubbles);
for (Bubble bubble : list) {
if (bubble.y - bubble.speedY <= mWaterRectF.top + bubble.radius) {
mBubbles.remove(bubble);
} else {
int i = mBubbles.indexOf(bubble);
if (bubble.x + bubble.speedX <= mWaterRectF.left + bubble.radius + mBottleBorder / 2) {
bubble.x = mWaterRectF.left + bubble.radius + mBottleBorder / 2;
} else if (bubble.x + bubble.speedX >= mWaterRectF.right - bubble.radius - mBottleBorder / 2) {
bubble.x = mWaterRectF.right - bubble.radius - mBottleBorder / 2;
} else {
bubble.x = bubble.x + bubble.speedX;
}
bubble.y = bubble.y - bubble.speedY;
mBubbles.set(i, bubble);
}
}
}
private float dp2px(float dpValue) {
return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dpValue, getResources().getDisplayMetrics());
}
}
3. 結語
由於本項目是一個示例性質的項目,因此設計的比較簡單,結構也是簡單粗暴,並沒有經過精心的雕琢,存在一些疏漏也說不定,如果大家覺得邏輯上存在問題或者有什么疑惑,歡迎在下面的評論區留言。
原文鏈接:https://www.gcssloop.com/gebug/bubble-sample
阿里P7移動互聯網架構師進階視頻(每日更新中)免費學習請點擊:https://space.bilibili.com/474380680