雕蟲曉技(十) Android超簡單氣泡效果

GcsSloop發表於2018-09-09

《雕蟲曉技目錄》

【示例專案:BubbleSample】

最近有用到水下氣泡上升效果,因此在網上查了一下資料,結果還真找到了,就是這篇文章 [Android例項] 水下氣泡上升介面效果, 不過這篇文章所附帶的示例程式碼是有些問題的,例如View移除後,執行緒沒有正確關閉,鎖屏後再開啟螢幕,氣泡會擠成一團等問題,因此我在它的原理基礎上稍為進行了一些調整和修改,解決了這些問題,它可以實現下面這樣的效果:

雕蟲曉技(十) Android超簡單氣泡效果

0. 基本原理

氣泡效果的基本原理非常簡單,其實所謂的氣泡就是一個個的半透明圓而已,它的基本邏輯如下:

  1. 如果當前圓的數量沒有超過數量上限,則隨機生成半徑不同的圓。
  2. 設定這些圓的初始位置。
  3. 隨機設定垂直向上平移速度。
  4. 隨機設定水平平移速度。
  5. 不斷的重新整理圓的位置然後繪製。
  6. 將超出顯示區域的圓進行移除。
  7. 不斷重複。

原理可以說非常簡單,但是也有一些需要注意的地方,尤其是執行緒,最容易出問題。

在原始的 demo 中,直接把執行緒建立和計算邏輯直接放到了 onDraw 裡面,而且沒有關閉執行緒,這自然會導致很多問題的發生。沒有關閉執行緒會造成View的記憶體洩露,而把計算邏輯放在 onDraw 裡面則會加大繪製的負擔,拖慢重新整理速度,在機能較弱的情況下會導致明顯示卡頓的發生。而解決這些問題的最好辦法則是專事專辦,將合適的內容放在合適的位置,下面來看一下具體的程式碼實現。

1. 程式碼實現

1.1 定義氣泡

氣泡效果我們關心的屬性並不多,主要有這幾種:半徑、座標、上升速度、水平平移速度。由於我們只在 View 內部使用,因此直接建立一個內部類,然後在內部類中定義這些屬性。

private class Bubble {
    int radius;     // 氣泡半徑
    float speedY;   // 上升速度
    float speedX;   // 平移速度
    float x;        // 氣泡x座標
    float y;        // 氣泡y座標
}
複製程式碼

1.2 生命週期處理

由於需要用執行緒來進行計算和控制重新整理,就少不了開啟和關閉執行緒,這個自然要符合 View 的生命週期,因此我在 View 被新增到介面上時開啟了一個執行緒用於生成氣泡和重新整理氣泡位置,然後在 View 從介面上移除的時候關閉了這個執行緒。

@Override
protected void onAttachedToWindow() {
    super.onAttachedToWindow();
    startBubbleSync();
}

@Override
protected void onDetachedFromWindow() {
    super.onDetachedFromWindow();
    stopBubbleSync();
}
複製程式碼

1.3 開啟執行緒

開啟執行緒非常簡單,就是簡單的建立了一個執行緒,然後在裡面新增了一個 while 死迴圈,然後不停的執行 休眠、建立氣泡、重新整理氣泡位置、申請更新UI 等操作。

這裡沒有用變數來控制迴圈,而是監聽了中斷事件,在當攔截到 InterruptedException 的時候,使用 break 跳出了死迴圈,因此執行緒也就結束了,方法簡單粗暴。

// 開始氣泡執行緒
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();
}
複製程式碼

1.4 關閉執行緒

由於執行緒執行時監聽了 interrupt 中斷,這裡直接使用 interrupt 通知執行緒中斷就可以了。

// 停止氣泡執行緒
private void stopBubbleSync() {
    if (null == mBubbleThread) return;
    mBubbleThread.interrupt();
    mBubbleThread = null;
}
複製程式碼

1.5 建立氣泡

為了防止氣泡數量過多而佔用太多的效能,因此在建立氣泡之前需要先判斷當前已經有多少個氣泡,如果已經有足夠多的氣泡了,則不再建立新的氣泡。

同時,為了讓氣泡產生過程看起來更合理,在氣泡數量沒有達到上限之前,會隨機的建立氣泡,以防止氣泡扎堆出現,因此設立了一個隨機項,生成的隨機數大於 0.95 的時候才生成氣泡,讓氣泡生成過程慢一些。

建立氣泡的過程也很簡單,就是隨機的在設定範圍內生成一些屬性,然後放到 List 中而已。

PS:這裡使用了一些硬編碼和魔數,屬於不太好的習慣。不過由於應用場景固定,這些引數需要調整的概率比較小,影響也不大。

// 嘗試建立氣泡
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);
}
複製程式碼

1.6 重新整理氣泡位置

這裡主要做了兩項工作:

  1. 將超出顯示區域的氣泡進行移除。
  2. 計算新的氣泡顯示位置。

可以看到這裡沒有直接使用原始的List,而是複製了一個 List 進行遍歷,這樣做主要是為了規避 ConcurrentModificationException 異常,(對Vector、ArrayList在迭代的時候如果同時對其進行修改就會丟擲 java.util.ConcurrentModificationException 異常)。

對複製的 List 進行遍歷,然後對超出顯示區域的 Bubble 進行移除,對沒有超出顯示區域的 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);
        }
    }
}
複製程式碼

1.7 繪製氣泡

繪製氣泡同樣簡單,就是遍歷 List,然後畫圓就行了。

這裡同樣複製了一個新的 List 進行操作,不過這個與上面的原因不同,是為了防止多執行緒問題。由於在繪製的過程中,我們的計算執行緒可能會對原始 List 進行更新,可能導致異常的發生。為了避免這樣的問題,就複製了一個 List 出來用於遍歷繪製。

// 繪製氣泡
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);
    }
}
複製程式碼

2. 完整程式碼

完整的示例程式碼非常簡單,所以直接貼在了正文中,同時,你也可以從文末下載完整的專案程式碼。

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. 結語

由於本專案是一個示例性質的專案,因此設計的比較簡單,結構也是簡單粗暴,並沒有經過精心的雕琢,存在一些疏漏也說不定,如果大家覺得邏輯上存在問題或者有什麼疑惑,歡迎在下面(公眾號、小專欄)的評論區留言。

公眾號檢視到該文章的可以通過點選【閱讀原文】下載到所需的示例程式碼,非公眾號閱讀的可以從文末或者文初下載到示例專案。

【示例專案:BubbleSample】

《本系列其它文章》

相關文章