Android:會呼吸的懸浮氣泡
寫在前面
這個標題看起來玄乎玄乎的,其實一張圖就明白了:
懸浮氣泡演示圖
最早看到這個效果是 MIUI6 系統升級界面,有很多五顏六色的氣泡懸浮著,覺得很好看。可惜現在找不到動態圖了。雖然 MIUI8 更新界面也有類似的氣泡,不過是靜態的,不咋好看。
MIUI8
再次見到這個效果是在 Pure 天氣這款軟件中,可惜開發者不開源。不過萬能的 Github 上有類似的實現,于是果斷把自定義 View 部分抽出來學習學習。
Pure
懷著敬意放上原項目地址,很好看的一款天氣 APP:
還是那句話,學習自定義 View 沒有什么捷徑,就是看源碼、模仿、動手。
具體實現
先思考
在看源碼之前,我自己想了一下該怎樣去實現,思路如下:
- 自定義一個圓形 View ,支持大小、顏色、位置等屬性
- 浮動利用最簡單的平移動畫來實現
- 平移的范圍通過自定義圓心的移動范圍來確定
- 最后給動畫一個循環就行了
雖然看起來比較簡單,但是實現起來還是遇到不少坑。首先畫圓一點問題都沒有,問題出在動畫上。動畫看起來很遲鈍,根本就不是呼吸效果,像哮喘一樣。
所以不能用動畫,就想到了不斷重繪。于是仍然給圓心設置一個小圓,讓圓心在小圓上移動,在這個過程中不斷重繪,結果直接 Crash 了,看了看 Log ,發現是線程阻塞了,但是這里并沒有開啟子線程啊,一看,我去,主線程。
那這條路行不通,又想到用貝塞爾去做,結果突然想起來之前繪制阻塞了主線程,那開子線程繪制不就完了, Android View 里面能開子線程繪制的不就是 SurfaceView 。于是看了看作者源碼,果然是自定義 SurfaceView 。
早已看穿一切
關于 SurfaceView 我只在以前學習的視頻案例、撕MM衣服案例、還有手寫板案例中遇到過,學的不是很深,加上本文它不是重點,所以就不詳細說了,如果不了解這個或者想深入了解一下的話,可以點擊文末的相關鏈接,這里只簡單提一下比較重要的一點,也就是 SurfaceView 跟 View 的主要區別:
SurfaceView在一個新起的單獨線程中重新繪制畫面,而 View 必須在 UI 線程中更新畫面。
這就決定了 SurfaceView 的一些特定使用場景:
-
需要界面迅速更新;
-
對幀率要求較高的情況;
-
渲染 UI 需要較長的時間。
所以綜合來看, SurfaceView 無疑是實現這類效果的最佳選擇。
再分析
廢話不多說,來分析一下思路。
1、首先光從界面上能看到就是圓,且是能浮動的圓,所以不管能不能動,先得把圓畫出來。要是我的話,我直接就拿著 Paint 在 Canvas 上開畫了。在源碼中開發者單獨抽取了繪制圓的類,但這個類的作用不僅僅是繪制圓,后面我們再說。
2、其次就是自定義 SurfaceView ,我們需要把畫出來的圓放到 SurfaceView 中。而自定義 SurfaceView 需要實現 SurfaceHolder.Callback 接口,就是一些回調方法。同時需要開子線程去不斷刷新界面,因為這些圓是需要動起來的.
3、另外重要的一點就是, SurfaceView 在渲染過程中需要消耗大量資源,比如內存啊、 CPU 啊之類的,所以最好提供一個生命周期相關的方法,讓它和 Activity 的生命周期保持一致,盡量保證及時回收資源,減少消耗。
4、最后需要提一點的是, SurfaceView 本身并不需要繪制內容,或者說在這里它的主要作用就是刷新界面就行了。就好像在放視頻的時候,只需要刷新視頻頁面就行,它并不參與視頻具體內容的繪制。
所以這樣來說的話,我們最好定義一個繪制過程的中間者,主要作用就是把繪制出來的圓放在 SurfaceView 上,同時也能做一些其他的工作,比如繪制背景、設置尺寸等。這樣做的好處就是能讓 SurfaceView 專心的做一件事:不斷刷新,這就夠了。
OK,總結一下我們到底需要哪些東西:
-
專門繪制圓的類
-
刷新過程中的子線程
-
實現 SurfaceHolder.Callback 接口方法
-
提供生命周期相關方法
-
一個繪制過程的中間對象
多提一句,最后的繪制中間者也可以不定義,全部封裝到自定義 SurfaceView 中,但是從我實踐來看,我最后不得不單獨抽取出來,因為 SurfaceView 類看起來太亂了,這也是源碼中的實現方式。
后動手
Talk is cheap,Show me the code .
1、畫圓
既然要畫圓,我們肯定要設置一些圓的基本屬性:
-
圓心坐標
-
圓的半徑
-
圓的顏色
由于需要圓動起來,也就是說它會偏移,所以要確定一個范圍。范圍確定了,就需要指定它該怎么變化,因為我們要求它緩慢而順暢的呼吸,不能瞬間大喘氣,也就是它不能瞬間移動偏移量那么多,所以最好指定它每一步變化多少,那就需要下面這兩樣東西:
-
圓心偏移范圍
-
每一幀的變化量
額外的,因為移動是每次都需要變的,下一次變化時不能重新開始,所以我們要記錄當前已經偏移的距離,然后根據一個標志位不斷呼氣...吐氣...呼氣...吐氣,所以需要:
-
當前幀變化量
-
標志位
好了,看構造函數吧:
/**
- @author Mixiaoxiao
- @revision xiarui 16/09/27
- @description 圓形浮動氣泡
*/
class CircleBubble {
private final float cx, cy; //圓心坐標
private final float dx, dy; //圓心偏移距離
private final float radius; //半徑
private final int color; //畫筆顏色
private final float variationOfFrame; //設置每幀變化量
private boolean isGrowing = true; //根據此標志位判斷左右移動
private float curVariationOfFrame = 0f; //當前幀變化量
CircleBubble(float cx, float cy, float dx, float dy, float radius, float variationOfFrame, int color) {
this.cx = cx;
this.cy = cy;
this.dx = dx;
this.dy = dy;
this.radius = radius;
this.variationOfFrame = variationOfFrame;
this.color = color;
}
//...畫圓方法先省略
}</code></pre>
好了,構造好了圓就要開始繪制圓了。之前說到,這個類的作用不僅僅是繪制圓,還要不斷更新圓的位置,也就是不斷重繪圓。更直接地說,我們需要繪制出不斷偏移的每一幀的圓。
步驟如下:
-
確定當前幀偏移位置
-
根據當前幀偏移位置計算圓心坐標
-
設置圓的顏色透明度等屬性
-
真正的開始繪制圓
代碼如下,結合上面的步驟和代碼中的注釋應該很容易看懂:
/**
- 更新位置并重新繪制
*
- @param canvas 畫布
- @param paint 畫筆
- @param alpha 透明值
*/
void updateAndDraw(Canvas canvas, Paint paint, float alpha) {
/**
- 每次繪制時都根據標志位(isGrowing)和每幀變化量(variationOfFrame)進行更新
- 說白了其實就是每幀都會變化一段距離 連在一起就產生動畫效果
*/
if (isGrowing) {
curVariationOfFrame += variationOfFrame;
if (curVariationOfFrame > 1f) {
curVariationOfFrame = 1f;
isGrowing = false;
}
} else {
curVariationOfFrame -= variationOfFrame;
if (curVariationOfFrame < 0f) {
curVariationOfFrame = 0f;
isGrowing = true;
}
}
//根據當前幀變化量計算圓心偏移后的位置
float curCX = cx + dx curVariationOfFrame;
float curCY = cy + dy curVariationOfFrame;
//設置畫筆顏色
int curColor = convertAlphaColor(alpha * (Color.alpha(color) / 255f), color);
paint.setColor(curColor);
//這里才真正的開始畫圓形氣泡
canvas.drawCircle(curCX, curCY, radius, paint);
}</code></pre>
其中的 convertAlphaColor() 方法是個工具方法,作用就是轉化一下顏色,不必深究:
/**
- 轉成透明顏色
*
- @param percent 百分比
- @param originalColor 初始顏色
- @return 帶有透明效果的顏色
/
private static int convertAlphaColor(float percent, final int originalColor) {
int newAlpha = (int) (percent 255) & 0xFF;
return (newAlpha << 24) | (originalColor & 0xFFFFFF);
}</code></pre>
到此,畫每一幀圓的工作我們就完成了。
2、繪制中間者對象
現在來說這個特殊的中間者對象,前文說了,單獨抽取這個類不是必須的。但最好抽取一下,讓 SurfaceView 專心做自己的事情。在這個中間者對象中我們做兩件事情:
-
繪制背景
-
繪制懸浮氣泡
先來看繪制背景。為什么需要繪制背景呢,因為 SurfaceView 本身其實是個黑色,從我們日常看視頻的軟件中也能發現,視頻播放時周圍都是黑色的。有人問為什么不能直接在布局中設置呢?當然可以直接設置啊,不過要記得添加一句 setZOrderOnTop(true) ,不然會把之后繪制的懸浮氣泡遮擋住。
在這里就來繪制一下吧,因為源碼中給出了一個漸變色的繪制,我覺得挺好玩,學一學。直接看代碼吧,都是模板代碼,沒啥好解釋的,簡單的 get/set 再畫一下就好了:
/**
- @author Mixiaoxiao
- @revision xiarui 16/09/27
@description 繪制圓形浮動氣泡及設定漸變背景的繪制對象
*/
public class BubbleDrawer {
/===== 圖形相關 =====/
private GradientDrawable mGradientBg; //漸變背景
private int[] mGradientColors; //漸變顏色數組
/**
- 設置漸變背景色
*
@param gradientColors 漸變色數組 必須 >= 2 不然沒法漸變
*/
public void setBackgroundGradient(int[] gradientColors) {
this.mGradientColors = gradientColors;
}
/**
- 獲取漸變色數組
*
@return 漸變色數組
*/
private int[] getBackgroundGradient() {
return mGradientColors;
}
/**
- 繪制漸變背景色
*
- @param canvas 畫布
@param alpha 透明值
*/
private void drawGradientBackground(Canvas canvas, float alpha) {
if (mGradientBg == null) {
//設置漸變模式和顏色
mGradientBg = new GradientDrawable(GradientDrawable.Orientation.TOP_BOTTOM, getBackgroundGradient());
//規定背景寬高 一般都為整屏
mGradientBg.setBounds(0, 0, mWidth, mHeight);
}
//然后開始畫
mGradientBg.setAlpha(Math.round(alpha * 255f));
mGradientBg.draw(canvas);
}
//...暫時省略圓的繪制方法
}</code></pre>
上面代碼就一點需要注意,漸變最少需要兩種顏色,不然沒法漸變,這個很好理解吧,不再多解釋了。現在我們來畫氣泡,步驟如下:
- 設置一下圓的范圍,一般都為全屏
- 根據圓的構造方法添加多個圓
- 繪制添加的這些圓
直接來看代碼,其實也很簡單:
/===== 圖形相關 =====/
private Paint mPaint; //抗鋸齒畫筆
private int mWidth, mHeight; //上下文對象
private ArrayList<CircleBubble> mBubbles; //存放氣泡的集合
/**
- 構造函數
*
- @param context 上下文對象 可能會用到
*/
public BubbleDrawer(Context context) {
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mBubbles = new ArrayList<>();
}
/**
- 設置顯示懸浮氣泡的范圍
- @param width 寬度
- @param height 高度
*/
void setViewSize(int width, int height) {
if (this.mWidth != width && this.mHeight != height) {
this.mWidth = width;
this.mHeight = height;
if (this.mGradientBg != null) {
mGradientBg.setBounds(0, 0, width, height);
}
}
//設置一些默認的氣泡
initDefaultBubble(width);
}
/**
- 初始化默認的氣泡
*
- @param width 寬度
*/
private void initDefaultBubble(int width) {
if (mBubbles.size() == 0) {
mBubbles.add(new CircleBubble(0.20f * width, -0.30f * width, 0.06f * width, 0.022f * width, 0.56f * width,
0.0150f, 0x56ffc7c7));
mBubbles.add(new CircleBubble(0.58f * width, -0.15f * width, -0.15f * width, 0.032f * width, 0.6f * width,
0.00600f, 0x45fffc9e));
//...
}
}
/**
- 用畫筆在畫布上畫氣泡
*
- @param canvas 畫布
- @param alpha 透明值
*/
private void drawCircleBubble(Canvas canvas, float alpha) {
//循環遍歷所有設置的圓形氣泡
for (CircleBubble bubble : this.mBubbles) {
bubble.updateAndDraw(canvas, mPaint, alpha);
}
}</code></pre>
從代碼中看出,已經將所有添加的圓放到集合里,然后遍歷集合去畫,這就不用添加一個畫一個了,只需統一添加再統一繪制即可。
既然背景繪制好了,氣泡也繪制好了,那就到了最后一步,需要提供方法讓 SurfaceView 去添加背景和氣泡:
/**
- 畫背景 畫所有的氣泡
*
- @param canvas 畫布
- @param alpha 透明值
*/
void drawBgAndBubble(Canvas canvas, float alpha) {
drawGradientBackground(canvas, alpha);
drawCircleBubble(canvas, alpha);
}</code></pre>
到此,這個繪制中間者對象就完成了。
3、自定義 SurfaceView
終于到了重要的 SurfaceView 部分了,這部分不太好描述,因為最好的解釋方式就是看代碼。
首先自定義 FloatBubbleView 繼承于 SurfaceView ,看一下簡單的變量定義、構造方法:
/**
- @author Mixiaoxiao
- @revision xiarui 16/09/27
- @description 用圓形浮動氣泡填充的View
@remark 因為氣泡需要不斷繪制 所以防止阻塞UI線程 需要繼承 SurfaceView 開啟線程更新 并實現回調類
*/
public class FloatBubbleView extends SurfaceView implements SurfaceHolder.Callback {
private DrawThread mDrawThread; //繪制線程
private BubbleDrawer mPreDrawer; //上一次繪制對象
private BubbleDrawer mCurDrawer; //當前繪制對象
private float curDrawerAlpha = 0f; //當前透明度 (范圍為0f~1f,因為 CircleBubble 中 convertAlphaColor 方法已經處理過了)
private int mWidth, mHeight; //當前屏幕寬高
public FloatBubbleView(Context context) {
super(context);
initThreadAndHolder(context);
}
//...省略其他構造方法
/**
- 初始化繪制線程和 SurfaceHolder
*
@param context 上下文對象 可能會用到
*/
private void initThreadAndHolder(Context context) {
mDrawThread = new DrawThread();
SurfaceHolder surfaceHolder = getHolder();
surfaceHolder.addCallback(this); //添加回調
surfaceHolder.setFormat(PixelFormat.RGBA_8888); //漸變效果 就是顯示SurfaceView的時候從暗到明
mDrawThread.start(); //開啟繪制線程
}
/**
- 當view的大小發生變化時觸發
*
- @param w 當前寬度
- @param h 當前高度
- @param oldw 變化前寬度
@param oldh 變化前高度
*/
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mWidth = w;
mHeight = h;
}
//...省略其他方法
}</code></pre>
這里其他的內容都比較好理解,重點提兩個變量:
private BubbleDrawer mPreDrawer; //上一次繪制對象
private BubbleDrawer mCurDrawer; //當前繪制對象
這是什么意思呢,開始我也不太理解,那換個思路,大家還記得 ListView 中的 ViewHolder 么,這個 ViewHolder 其實就是用來復用的。那 SurfaceView 中也有個 SurfaceHolder ,作用可以看做是相同的,就是用來不斷復用不斷刷新界面的。
那這里的這兩個變量是干什么的呢?就是相當于 當前刷新的中間者對象 和 上一次刷新的中間者對象 。
那獲得這兩個對象有什么用呢?注意看,還有個 curDrawerAlpha 變量,顧名思義,當前的透明度。
三者結合在一起,再加上一個這樣的小循環:
if (curDrawerAlpha < 1f) {
curDrawerAlpha += 0.5f;
if (curDrawerAlpha > 1f) {
curDrawerAlpha = 1f;
mPreDrawer = null;
}
}
那這又有什么作用呢,別急,先看下面兩張對比圖,分別設置 curDrawerAlpha += 0.2f 和 curDrawerAlpha += 0.8f :
模擬器太卡,將就著看

0.2f
再看 0.8f ,從暗到明顯然快了點:

0.8f
現在知道作用了么,就是實現界面從暗到明的效果。那為什么需要這樣的效果呢,我嘗試過去掉這個,發現繪制的時候會偶爾出現閃黑屏的現象,黑色剛好是 SurfaceView 的本身顏色,加上這個效果就不會出現了。
好,接下來看重中之重的繪制線程方法,為了方便我單獨抽取了線程類,并將 run 方法按照不同的功能分成好幾個方法,注釋寫的很清晰:
/**
繪制線程 必須開啟子線程繪制 防止出現阻塞主線程的情況
*/
private class DrawThread extends Thread {
SurfaceHolder mSurface;
boolean mRunning, mActive, mQuit; //三種狀態
Canvas mCanvas;
@Override
public void run() {
//一直循環 不斷繪制
while (true) {
synchronized (this) {
//根據返回值 判斷是否直接返回 不進行繪制
if (!processDrawThreadState()) {
return;
}
//動畫開始時間
final long startTime = AnimationUtils.currentAnimationTimeMillis();
//處理畫布并進行繪制
processDrawCanvas(mCanvas);
//繪制時間
final long drawTime = AnimationUtils.currentAnimationTimeMillis() - startTime;
//處理一下線程需要的睡眠時間
processDrawThreadSleep(drawTime);
}
}
}
/**
- 處理繪制線程的狀態問題
*
@return true:不結束繼續繪制 false:結束且不繪制
*/
private boolean processDrawThreadState() {
//處理沒有運行 或者 Holder 為 null 的情況
while (mSurface == null || !mRunning) {
if (mActive) {
mActive = false;
notify(); //喚醒
}
if (mQuit)
return false;
try {
wait(); //等待
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//其他情況肯定是活動狀態
if (!mActive) {
mActive = true;
notify(); //喚醒
}
return true;
}
/**
- 處理畫布與繪制過程 要注意一定要保證是同步鎖中才能執行 否則會出現
*
@param mCanvas 畫布
*/
private void processDrawCanvas(Canvas mCanvas) {
try {
mCanvas = mSurface.lockCanvas(); //加鎖畫布
if (mCanvas != null) { //防空保護
//清屏操作
mCanvas.drawColor(Color.TRANSPARENT, Mode.CLEAR);
drawSurface(mCanvas); //真正開始畫 SurfaceView 的地方
}
}catch (Exception ignored){
}finally {
if(mCanvas != null){
mSurface.unlockCanvasAndPost(mCanvas); //釋放canvas鎖,并顯示視圖
}
}
}
/**
- 真正的繪制 SurfaceView
*
@param canvas 畫布
*/
private void drawSurface(Canvas canvas) {
//防空保護
if (mWidth == 0 || mHeight == 0) {
return;
}
//如果前一次繪制對象不為空 且 當前繪制者有透明效果的話 繪制前一次的對象即可
if (mPreDrawer != null && curDrawerAlpha < 1f) {
mPreDrawer.setViewSize(mWidth, mHeight);
mPreDrawer.drawBgAndBubble(canvas, 1f - curDrawerAlpha);
}
//直到當前繪制完全不透明時將上一次繪制的置空
if (curDrawerAlpha < 1f) {
curDrawerAlpha += 0.5f;
if (curDrawerAlpha > 1f) {
curDrawerAlpha = 1f;
mPreDrawer = null;
}
}
//如果當前有繪制對象 直接繪制即可 先設置繪制寬高再繪制氣泡
if (mCurDrawer != null) {
mCurDrawer.setViewSize(mWidth, mHeight);
mCurDrawer.drawBgAndBubble(canvas, curDrawerAlpha);
}
}
/**
- 處理線程需要的睡眠時間
- View通過刷新來重繪視圖,在一些需要頻繁刷新或執行大量邏輯操作時,超過16ms就會導致明顯卡頓
*
@param drawTime 繪制時間
*/
private void processDrawThreadSleep(long drawTime) {
//需要睡眠時間
final long needSleepTime = 16 - drawTime;
if (needSleepTime > 0) {
try {
Thread.sleep(needSleepTime);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}</code></pre>
知道看這種代碼很枯燥,但不能急。首先這里有三種狀態:正在繪制、活動、退出。其中活動是一種中間狀態,指既沒有活動又沒有被銷毀。在回調類中需要根據這種狀態進行繪制線程的控制。
那就來看回調方法:
/========== Surface 回調方法 需要加同步鎖 防止阻塞 START==========/
@Override
public void surfaceCreated(SurfaceHolder holder) {
synchronized (mDrawThread) {
mDrawThread.mSurface = holder;
mDrawThread.notify(); //喚醒
}
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
synchronized (mDrawThread) {
mDrawThread.mSurface = holder;
mDrawThread.notify(); //喚醒
while (mDrawThread.mActive) {
try {
mDrawThread.wait(); //等待
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
holder.removeCallback(this);
}
/========== Surface 回調方法 需要加同步鎖 防止阻塞 END==========/</code></pre>
可以看到,在銷毀的時候繪制線程是在等待狀態。
然后就是一些生命周期相關方法了,也很簡單,就是設置相關狀態:
/========== 處理與 Activity 生命周期相關方法 需要加同步鎖 防止阻塞 START==========/
public void onDrawResume() {
synchronized (mDrawThread) {
mDrawThread.mRunning = true; //運行狀態
mDrawThread.notify(); //喚醒線程
}
}
public void onDrawPause() {
synchronized (mDrawThread) {
mDrawThread.mRunning = false; //不運行狀態
mDrawThread.notify(); //喚醒線程
}
}
public void onDrawDestroy() {
synchronized (mDrawThread) {
mDrawThread.mQuit = true; //退出狀態
mDrawThread.notify(); //喚醒線程
}
}
/========== 處理與 Activity 生命周期相關方法 需要加同步鎖 防止阻塞 END==========/</code></pre>
最后就是提供方法,給這個自定義的 SurfaceView 設置中間繪制者對象了:
/**
- 設置繪制者
*
- @param bubbleDrawer 氣泡繪制
*/
public void setDrawer(BubbleDrawer bubbleDrawer) {
//防空保護
if (bubbleDrawer == null) {
return;
}
curDrawerAlpha = 0f; //完全透明
//如果當前有正在繪制的對象 直接設置為前一次繪制對象
if (this.mCurDrawer != null) {
this.mPreDrawer = mCurDrawer;
}
//當前繪制對象 為設置的對象
this.mCurDrawer = bubbleDrawer;
}</code></pre>
到此,自定義 FloatBubbleView 就完成了,代碼很長,建議直接看文末的源碼。
看結果
好了, 現在只要在 Activity 中這樣:
/**
- 初始化Data
*/
private void initData() {
//設置氣泡繪制者
BubbleDrawer bubbleDrawer = new BubbleDrawer(this);
//設置漸變背景 如果不需要漸變 設置相同顏色即可
bubbleDrawer.setBackgroundGradient(new int[]{0xffffffff, 0xffffffff});
//給SurfaceView設置一個繪制者
mDWView.setDrawer(bubbleDrawer);
}</code></pre>
這樣就大功告成了!效果圖再貼一下吧,顏色大小位置都可以定義:

懸浮氣泡演示圖
后話
雖然效果實現了,但是我并沒有將設置氣泡的方法暴露出來,只寫死在 BubbleDrawer 中:
if (mBubbles.size() == 0) {
mBubbles.add(new CircleBubble(0.20f * width, -0.30f * width, 0.06f * width, 0.022f * width, 0.56f * width,0.0150f, 0x56ffc7c7));
//...
}
開始我確實抽取了方法,提供給 Activity ,結果發現 Activity 中的代碼太難看。另一方面因為 SurfaceView 消耗資源太多,我們應該不會在主要界面大量使用它,所以我覺得寫死就夠了,必要的時候動一動寫死的數據就行了。
還有一點就是,雖然效果很好看,但是確實消耗資源很大,有時候會很卡,不知道還有沒有可以優化的地方,建議只在簡單的頁面,比如關于軟件的頁面用這樣的效果,其他的主頁面還是算了吧。
參考資料
Android SurfaceView入門學習 - 英勇青銅5
來自:http://www.jianshu.com/p/5a672bac5ba9