1. 程式人生 > >Android仿網易雲音樂播放介面

Android仿網易雲音樂播放介面

概述

網易雲音樂是一款非常優秀的音樂播放器,尤其是播放介面,使用唱盤機風格,顯得格外古典優雅。這裡拋磚引玉,原文地址:http://www.jianshu.com/p/cb54990219d9
首先來看一下網易的播放效果。
這裡寫圖片描述
要實現上面的功能,我們需要對介面進行一個拆分,拆分後大概包含如下結構:

  • 主介面佈局設計
  • 唱盤佈局設計
  • 動態佈局
  • 唱盤控制元件DiscView對外介面及方法
  • 音樂狀態控制時序圖

分析及實現

主介面佈局設計

主介面佈局從上到下可以劃分幾大區域,如圖:
這裡寫圖片描述
如圖,由上到下主要分為:標題欄區、唱盤區域、時長顯示區域、播放控制區域。
標題欄
使用ToolBar實現,字型可能需要自定義。
唱盤區域
唱盤區域包括唱盤、唱針、底盤、以及實現切換的ViewPager等控制元件,該佈局比較複雜,本案例使用自定義控制元件實現唱盤區域。
時長顯示區域


使用RelativeLayout作為根佈局,進度條使用SeekBar實現。
播放控制區域
比較簡單,使用LinearLayout作為根佈局。

唱盤佈局實現(難點)

唱盤區域由控制元件DiscView實現,以RelativeLayout為根佈局,子控制元件包括:底盤、唱針、ViewPager等。其中,底盤和唱針均用ImageView實現,然後使用ViewPager載入ImageView實現唱片的切換。如圖:
這裡寫圖片描述
唱片佈局如下:

<?ml version="1.0" encoding="utf-8"?>
<com.achillesl.neteasedisc.widget.DiscView
mlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content">
<!--底盤--> <ImageView android:id="@+id/ivDiscBlackgound" android:layout_width="wrap_content" android:layout_height="wrap_content"
android:layout_centerHorizontal="true" />
<!--ViewPager實現唱片切換--> <android.support.v4.view.ViewPager android:id="@+id/vpDiscContain" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerHorizontal="true" /> <!--唱針--> <ImageView android:id="@+id/ivNeedle" android:layout_width="wrap_content" android:layout_height="wrap_content" android:src="@drawable/ic_needle"/> </com.achillesl.neteasedisc.widget.DiscView>
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30

這裡面涉及到一個DiscView類,這個是一個複合類,我們來看一些主要的功能。
唱盤控制元件DiscView提供一個介面IPlayInfo,程式碼如下:

public interface IPlayInfo {
    /*用於更新標題欄變化*/
    void onMusicInfoChanged(String musicName, String musicAuthor);
    /*用於更新背景圖片*/
    void onMusicPicChanged(int musicPicRes);
    /*用於更新音樂播放狀態*/
    void onMusicChanged(MusicChangedStatus musicChangedStatus);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

這上面定義的三個函式作用: 分別用於更新標題欄(音樂名、作者名)、更新背景圖片以及控制音樂播放狀態(播放、暫停、上/下一首等)。
點選主介面播放/暫停、上/下一首按鈕時,呼叫DiscView暴露的方法:

@Override
public void onClick(View v) {
    if (v == mIvPlayOrPause) {
        mDisc.playOrPause();
    } else if (v == mIvNet) {
        mDisc.net();
    } else if (v == mIvLast) {
        mDisc.last();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

當主介面收到DiscView回撥時,呼叫相關方法控制音樂播放,這樣邏輯就會很清晰,各分職責:

public void onMusicChanged(MusicChangedStatus musicChangedStatus) {
    switch (musicChangedStatus) {
        case PLAY:{
            play();
            break;
        }
        case PAUSE:{
            pause();
            break;
        }
        case NET:{
            net();
            break;
        }
        case LAST:{
            last();
            break;
        }
        case STOP:{
            stop();
            break;
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

音樂狀態控制時序圖

這裡寫圖片描述
音樂控制狀態時序如圖3-3所示,點選Activity的按鈕時,先呼叫DiscView的相關方法,並在合適的時機(如動畫結束)再將狀態回撥到Activity,並通過廣播發送指令到Service,實現音樂狀態切換,最後通過廣播更新UI狀態。
這個狀態的切換隻有你仔細觀察就會明白它的流程了。專案架構介紹到這裡,接下來是部分視覺效果以及設計思路的介紹和專案的一些難點介紹。

解決載入大圖OOM問題

解決大圖載入一般有幾種方案:
1. 設定largeHeap為true。
2. 根據圖片型別選定解碼格式。
3. 根據原始圖片寬高及目標顯示寬高,設定圖片取樣率。

根據實際經驗我們一般採用後兩種,第一種雖然通過增加堆記憶體來延緩了oom的時機,但是治標不治本。這裡我們整理一個類。

private Bitmap getMusicPicBitmap(int musicPicSize, int musicPicRes) {
    BitmapFactory.Options options = new BitmapFactory.Options();
    options.inJustDecodeBounds = true;

    BitmapFactory.decodeResource(getResources(),musicPicRes,options);
    int imageWidth = options.outWidth;

    int sample = imageWidth / musicPicSize;
    int dstSample = 1;
    if (sample > dstSample) {
        dstSample = sample;
    }
    options.inJustDecodeBounds = false;
    //設定圖片取樣率
    options.inSampleSize = dstSample;
    //設定圖片解碼格式
    options.inPreferredConfig = Bitmap.Config.RGB_565;

    return Bitmap.createScaledBitmap(BitmapFactory.decodeResource(getResources(),
            musicPicRes, options), musicPicSize, musicPicSize, true);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

我相信有過幾年Java開發經驗或Android經驗的人都會知道這麼一個常識:首先設定options.inJustDecodeBounds = true,這樣BitmapFactory.decodeResource的時候僅僅會載入圖片的一些資訊,然後通過options.outWidth獲取到圖片的寬度,根據目標圖片尺寸算出取樣率。最後通過inPreferredConfig設定解碼格式,才正式載入圖片,這樣有效的避免了圖片的oom。

生成圓圖最簡單方式

以前我們使用圓圈一般會自定義一個View,然後實現onDraw(),不過Android在android.support.v4.graphics.drawable 裡面為我們實現了一個類RoundedBitmapDrawable。使用如下,我們可以對其做一個簡單的封裝:

private Drawable getDiscBlackgroundDrawable() {
    int discSize = (int) (mScreenWidth * DisplayUtil.SCALE_DISC_SIZE);
    Bitmap bitmapDisc = Bitmap.createScaledBitmap(BitmapFactory.decodeResource(getResources(), R
            .drawable.ic_disc_blackground), discSize, discSize, false);
    RoundedBitmapDrawable roundDiscDrawable = RoundedBitmapDrawableFactory.create
            (getResources(), bitmapDisc);
    return roundDiscDrawable;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

使用LayerDrawable進行圖片合成

LayerDrawable介紹
  LayerDrawable也可包含一個Drawable陣列,因此係統將會按這些Drawable物件的陣列順序來繪製它們,索引最大的Drawable物件將會被繪製在最上面。 LayerDrawable有點類似PhotoShop圖層的概念。
我們在分析唱片佈局的時候發現原View包含兩個ImageView,估計是一個用來顯示唱盤,一個用來顯示專輯圖片。
這裡寫圖片描述
使用LayerDrawable生成複合圖片程式碼:

private Drawable getDiscDrawable(int musicPicRes) {
    int discSize = (int) (mScreenWidth * DisplayUtil.SCALE_DISC_SIZE);
    int musicPicSize = (int) (mScreenWidth * DisplayUtil.SCALE_MUSIC_PIC_SIZE);

    Bitmap bitmapDisc = Bitmap.createScaledBitmap(BitmapFactory.decodeResource(getResources(), R
            .drawable.ic_disc), discSize, discSize, false);
    Bitmap bitmapMusicPic = getMusicPicBitmap(musicPicSize,musicPicRes);
    BitmapDrawable discDrawable = new BitmapDrawable(bitmapDisc);
    RoundedBitmapDrawable roundMusicDrawable = RoundedBitmapDrawableFactory.create
            (getResources(), bitmapMusicPic);

    //抗鋸齒
    discDrawable.setAntiAlias(true);
    roundMusicDrawable.setAntiAlias(true);

    Drawable[] drawables = new Drawable[2];
    drawables[0] = roundMusicDrawable;
    drawables[1] = discDrawable;

    LayerDrawable layerDrawable = new LayerDrawable(drawables);
    int musicPicMargin = (int) ((DisplayUtil.SCALE_DISC_SIZE - DisplayUtil
            .SCALE_MUSIC_PIC_SIZE) * mScreenWidth / 2);
    //調整專輯圖片的四周邊距
    layerDrawable.setLayerInset(0, musicPicMargin, musicPicMargin, musicPicMargin,
            musicPicMargin);

    return layerDrawable;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28

 在上面程式碼中,我們先生成了唱盤物件BitmapDrawable,然後通過RoundedBitmapDrawable生成圓形專輯圖片,然後存放到Drawable[]陣列中,並用來初始化LayerDrawable物件。最後,我們用setLayerInset方法調整專輯圖片的四周邊距,讓它顯示在唱盤正中。

實現背景毛玻璃效果

這個網上的資料很多,也有基於JNI實現的,這個使用JNI實現可以看一下我之前的部落格JNI實現毛玻璃效果,這裡為了方便大家使用,我就直接使用工具類的方式,關於模糊化的實現邏輯大家可以搜尋一下“BlurUtil”,考慮到這部分程式碼可能會阻塞UI執行緒,因此將其放著單獨執行緒中執行。

private void try2UpdateMusicPicBackground(final int musicPicRes) {
    if (mRootLayout.isNeed2UpdateBackground(musicPicRes)) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                final Drawable foregroundDrawable = getForegroundDrawable(musicPicRes);
                runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        mRootLayout.setForeground(foregroundDrawable);
                        mRootLayout.beginAnimation();
                    }
                });
            }
        }).start();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

使用LayerDrawable與屬性動畫,實現背景切換時漸變效果

仔細觀察網易雲音樂,發現切換歌曲時,背景圖也會隨著變化。其實這種也很好做,可以使用LayerDrawable加屬性動畫來實現。
 思路如下:
  1. 給LayerDrawable設定兩個圖層,第一圖層是前一個背景,第二圖層是準備顯示的背景。
  2. 先把準備顯示的背景透明度設為0,因此完全透明,此時只顯示前一個背景圖。
  3. 通過屬性動畫,動態將第二圖層的透明度從0調整至100,並不斷更新控制元件的背景。

public class BackgourndAnimationRelativeLayout etends RelativeLayout

//初始化LayerDrawable物件
private void initLayerDrawable() {
    Drawable backgroundDrawable = getContet().getDrawable(R.drawable.ic_blackground);
    Drawable[] drawables = new Drawable[2];

    /*初始化時先將前景與背景顏色設為一致*/
    drawables[INDE_BACKGROUND] = backgroundDrawable;
    drawables[INDE_FOREGROUND] = backgroundDrawable;

    layerDrawable = new LayerDrawable(drawables);
}

private void initObjectAnimator() {
    objectAnimator = ObjectAnimator.ofFloat(this, "number", 0f, 1.0f);
    objectAnimator.setDuration(DURATION_ANIMATION);
    objectAnimator.setInterpolator(new AccelerateInterpolator());
    objectAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            int foregroundAlpha = (int) ((float) animation.getAnimatedValue() * 255);
            /*動態設定Drawable的透明度,讓前景圖逐漸顯示*/
            layerDrawable.getDrawable(INDE_FOREGROUND).setAlpha(foregroundAlpha);
            BackgourndAnimationRelativeLayout.this.setBackground(layerDrawable);
        }
    });
    objectAnimator.addListener(new Animator.AnimatorListener() {
        @Override
        public void onAnimationStart(Animator animation) {
        }

        @Override
        public void onAnimationEnd(Animator animation) {
            /*動畫結束後,記得將原來的背景圖及時更新*/
            layerDrawable.setDrawable(INDE_BACKGROUND, layerDrawable.getDrawable(
                    INDE_FOREGROUND));
        }

        @Override
        public void onAnimationCancel(Animator animation) {

        }

        @Override
        public void onAnimationRepeat(Animator animation) {

        }
    });
}

//對外提供方法,用於播放漸變動畫
public void beginAnimation() {
    objectAnimator.start();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55

唱針變化邏輯

我們來看一下唱針的變化,為了真實的模擬真實的場景,唱針主要有以下狀態:

  • 初始狀態為暫停/停止時,點選播放按鈕,此時唱針移動到底部。
  • 初始狀態為播放時,點選暫停按鈕,此時唱針移到頂部。
  • 初始狀態為播放時,手指按住唱盤並稍微偏移,等唱針未移到頂部時,立刻鬆開手指,此時唱針回到頂部後立刻再回到唱盤位置。
  • 初始狀態為暫停/停止時,點選播放,此時唱針往下移動,當唱針還未移到底部,手指馬上按住唱盤並偏移,此時唱針立刻往頂部移動。
  • 初始狀態為播放/暫停/停止時,左右滑動唱片進行音樂切換,唱針動畫未結束時,立刻點選上/下一首按鈕,進行音樂切換,此時唱針狀態不能出現混亂。

初始狀態為暫停/停止時,點選播放按鈕,此時唱針移動到底部。
這裡寫圖片描述
初始狀態為播放時,點選暫停按鈕,此時唱針移到頂部。
這裡寫圖片描述
初始狀態為播放時,手指按住唱盤並稍微偏移,等唱針未移到頂部時,立刻鬆開手指,此時唱針回到頂部後立刻再回到唱盤位置。
這裡寫圖片描述
初始狀態為暫停/停止時,點選播放,此時唱針往下移動,當唱針還未移到底部,手指馬上按住唱盤並偏移,此時唱針立刻往頂部移動。
這裡寫連結內容
初始狀態為播放/暫停/停止時,左右滑動唱片進行音樂切換,唱針動畫未結束時,立刻點選上/下一首按鈕,進行音樂切換,此時唱針狀態不能出現混亂,反覆做了步驟1的動作。
這裡寫圖片描述
我們隊上面的圖片仔細分析,然後結合ViewPager的原理我們來看看。
這裡寫圖片描述
唱片(即ViewPager)的狀態可以通過PageChangeListener得到。唱針的狀態,筆者用列舉來表示,並且在動畫的開始、結束時對唱針狀態及時更新。那麼我們很容易就想到case或者列舉。

 private enum NeedleAnimatorStatus {
        /*移動時:從唱盤往遠處移動*/
        TO_FAR_END,
        /*移動時:從遠處往唱盤移動*/
        TO_NEAR_END,
        /*靜止時:離開唱盤*/
        IN_FAR_END,
        /*靜止時:貼近唱盤*/
        IN_NEAR_END
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

動畫開始時,更新唱針狀態:

@Override
public void onAnimationStart(Animator animator) {
    /**
     *根據動畫開始前NeedleAnimatorStatus的狀態,
     *即可得出動畫進行時NeedleAnimatorStatus的狀態
     **/
    if (needleAnimatorStatus == NeedleAnimatorStatus.IN_FAR_END) {
        needleAnimatorStatus = NeedleAnimatorStatus.TO_NEAR_END;
    } else if (needleAnimatorStatus == NeedleAnimatorStatus.IN_NEAR_END) {
        needleAnimatorStatus = NeedleAnimatorStatus.TO_FAR_END;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

動畫結束時,更新唱針狀態:

@Override
public void onAnimationEnd(Animator animator) {
    if (needleAnimatorStatus == NeedleAnimatorStatus.TO_NEAR_END) {
        needleAnimatorStatus = NeedleAnimatorStatus.IN_NEAR_END;
        int inde = mVpContain.getCurrentItem();
        playDiscAnimator(inde);
    } else if (needleAnimatorStatus == NeedleAnimatorStatus.TO_FAR_END) {
        needleAnimatorStatus = NeedleAnimatorStatus.IN_FAR_END;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

每種狀態都定義清楚,每個動畫負責的功能都拆分這樣寫起來就比較清楚了。
 比如需要播放動畫時,就包含兩個狀態: 
- 唱針動畫暫停中,唱針處於遠端。
- 唱針動畫播放中,唱針處於從近端往遠端移動

那麼我們呼叫程式碼的時候就這麼用:

/*播放動畫*/
private void playAnimator() {
    /*唱針處於遠端時,直接播放動畫*/
    if (needleAnimatorStatus == NeedleAnimatorStatus.IN_FAR_END) {
        mNeedleAnimator.start();
    } 
    /*唱針處於往遠端移動時,設定標記,等動畫結束後再播放動畫*/
    else if (needleAnimatorStatus == NeedleAnimatorStatus.TO_FAR_END) {
        mIsNeed2StartPlayAnimator = true;
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

至於其他的比較跨元件的介面更新,一般會使用廣播,大家也可以使用事件匯流排(EventBus).
附上原始碼,這裡可能需要大家自己編譯。
附:仿網易雲音樂介面原始碼