一個酷炫的音樂播放界面

iswy2776 7年前發布 | 17K 次閱讀 Bitmap 安卓開發 Android開發 移動開發

前言:

網易云音樂是一款非常優秀的音樂播放器,尤其是播放界面,使用唱盤機風格,顯得格外古典優雅。本文是AchillesL出于學習與挑戰的想法,思考播放界面背后的實現原理,并寫了一個小程序。

筆者盡可能地去模仿官方的視覺、交互效果,其中包括了唱盤與唱針切換時的細節處理、背景漸變等。本文將會分享一些視覺效果實現的方法以及設計思想,但難免有錯漏之處。若讀者發現有錯誤的地方或者更好的實現方法,請留言回復,希望與大家共同進步。效果如下圖所示:

1 本文內容

  • 項目結構介紹

  • 解決加載大圖OOM問題

  • 生成圓圖最簡單的方法

  • 使用LayerDrawable進行圖片合成

  • 實現背景毛玻璃效果

  • 結束語

2 項目結構介紹

項目結構介紹包括以下內容:

  • 主界面布局設計

  • 唱盤布局設計

  • 動態布局

  • 唱盤控件DiscView對外接口及方法

  • 音樂狀態控制時序圖

2.1主界面布局設計

主界面布局從上到下可以劃分幾大區域,如圖3-1所示:

圖 2-1 主界面布局

  • 標題欄 

    使用ToolBar實現,字體可能需要自定義。

  • 唱盤區域 

    唱盤區域包括唱盤、唱針、底盤、以及實現切換的ViewPager等控件,該布局比較復雜,本案例

    使用自定義控件實現唱盤區域

  • 時長顯示區域 

    使用RelativeLayout作為根布局,進度條使用SeekBar實現。

  • 播放控制區域 

    比較簡單,使用LinearLayout作為根布局。

    另外,主界面使用RelativeLayout作為根布局。

2.2 唱盤布局設計

唱盤區域由控件DiscView實現,以RelativeLayout為根布局,子控件包括:底盤、唱針、ViewPager等。其中,底盤和唱針均用ImageView實現,然后使用ViewPager加載ImageView實現唱片的切換。如圖3-2所示。

圖 2-2 唱盤區域布局

唱盤布局代碼如下所示:

<xml 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>

2.3 動態布局

到這里,讀者可能有些好奇,上述布局中并沒有指定控件的寬高、邊距等參數,那如何保證控件顯示在正確的位置?我們沒有網易云音樂的設計圖,因此不能得知官方的布局參數,那該怎么辦呢?其實有個笨方法,我們可以打開網易云音樂的播放界面并截圖,然后手動去量需要的高度、邊距等參數。

截圖量到控件的寬高、邊距等數值,除以截圖的寬或高,得到控件參數比例。使用時,我們根據手機的屏幕寬高,乘以對應的比例,就能得到該屏幕尺寸下的控件寬高、邊距。

當然,這種動態布局肯定會消耗更多性能,但不失為沒有辦法中的辦法。

相關控件參數比例,筆者統一放在DisplayUtil.java文件中,代碼如下:

public class DisplayUtil {
   /*手柄起始角度*/
   public static final float ROTATION_INIT_NEEDLE = -30;
   /*截圖屏幕寬高*/
   private static final float BASE_SCREEN_WIDTH = (float) 1080.0;
   private static final float BASE_SCREEN_HEIGHT = (float) 1920.0;
   /*唱針寬高、距離等比例*/
   public static final float SCALE_NEEDLE_WIDTH = (float) (276.0 / BASE_SCREEN_WIDTH);
   public static final float SCALE_NEEDLE_MARGIN_LEFT = (float) (500.0 / BASE_SCREEN_WIDTH);
   public static final float SCALE_NEEDLE_PIVOT_ = (float) (43.0 / BASE_SCREEN_WIDTH);
   public static final float SCALE_NEEDLE_PIVOT_Y = (float) (43.0 / BASE_SCREEN_WIDTH);
   public static final float SCALE_NEEDLE_HEIGHT = (float) (413.0 / BASE_SCREEN_HEIGHT);
   public static final float SCALE_NEEDLE_MARGIN_TOP = (float) (43.0 / BASE_SCREEN_HEIGHT);
   /*唱盤比例*/
   public static final float SCALE_DISC_SIZE = (float) (813.0 / BASE_SCREEN_WIDTH);
   public static final float SCALE_DISC_MARGIN_TOP = (float) (190 / BASE_SCREEN_HEIGHT);
   /*專輯圖片比例*/
   public static final float SCALE_MUSIC_PIC_SIZE = (float) (533.0 / BASE_SCREEN_WIDTH);
   /*設備屏幕寬度*/
   public static int getScreenWidth(Contet contet) {
       return contet.getResources().getDisplayMetrics().widthPiels;
   }
   /*設備屏幕高度*/
   public static int getScreenHeight(Contet contet) {
       return contet.getResources().getDisplayMetrics().heightPiels;
   }
}

例如需要設置唱盤底盤的頂部外邊距,我們先獲得該比例,然后乘上當前屏幕高度,得到具體數值,最后通過LayoutParams類進行動態設置。

int marginTop = (int) (DisplayUtil.SCALE_DISC_MARGIN_TOP * mScreenHeight);
RelativeLayout.LayoutParams layoutParams = (LayoutParams) mDiscBlackground.getLayoutParams();
layoutParams.setMargins(0, marginTop, 0, 0);

2.4 DiscView對外接口及方法

唱盤控件DiscView提供一個接口IPlayInfo,代碼如下:

public interface IPlayInfo {
   /*用于更新標題欄變化*/
   public void onMusicInfoChanged(String musicName, String musicAuthor);
   /*用于更新背景圖片*/
   public void onMusicPicChanged(int musicPicRes);
   /*用于更新音樂播放狀態*/
   public void onMusicChanged(MusicChangedStatus musicChangedStatus);
}

接口IPlayInfo中包含三個方法,分別用于更新標題欄(音樂名、作者名)、更新背景圖片以及控制音樂播放狀態(播放、暫停、上/下一首等)。

讀者可能有些疑問?

1. IPlayInfo接口的第一、二個方法屬于同一類型,為何要拆成兩個?

2. 為何通過回調來控制音樂播放?點擊主界面的控制按鈕時,直接控制音樂播放不也可以嗎?

這兩個問題,筆者也是經過多次考慮。

第一個問題,首先 網易云音樂交互上,更新標題欄和更新背景圖的時機不一樣 (ViewPager偏移頁面1/2時更新標題欄,而背景圖是ViewPager是停止滑動后才更新)。若兩個接口合并為一個,一來不利于解耦,二來可能造成開發者誤解,并且造成資源浪費。

第二個問題,筆者考慮到, 點擊主界面的控制按鈕,并不代表立刻需要發生音樂的狀態變更 (比如點擊播放按鈕,需要等唱針動畫結束后才能開始播放音樂)。因此,控制音樂的時機是依賴與DiscView的狀態。因此,我們 通過接口中的onMusicChanged方法在適合的時間先將音樂控制回調到Activity,再通過Activity發送指令,來達到切換音樂狀態的效果。

點擊主界面播放/暫停、上/下一首按鈕時,調用DiscView暴露的方法:

@Override
public void onClick(View v) {
   if (v == mIvPlayOrPause) {
       mDisc.playOrPause();
   } else if (v == mIvNet) {
       mDisc.net();
   } else if (v == mIvLast) {
       mDisc.last();
   }
}

當主界面收到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;
       }
   }
}

2.5 音樂狀態控制時序圖

圖 2-3 音樂狀態控制時序圖

音樂控制狀態時序如圖3-3所示,點擊Activity的按鈕時,先調用DiscView的相關方法,并在合適的時機(如動畫結束)再將狀態回調到Activity,并通過廣播發送指令到Service,實現音樂狀態切換,最后通過廣播更新UI狀態。

項目架構介紹到這里,接下來是部分視覺效果以及設計思路的介紹。

3 解決加載大圖OOM問題

加載大圖避免OOM(內存溢出),這是一個老生常談的話題,筆者以后會有 專門的文章來講述這方面的內容,這里先放出結論。

解決大圖加載一般有幾種方案:

1. 設置largeHeap為true。

2. 根據圖片類型選定解碼格式。

3. 根據原始圖片寬高及目標顯示寬高,設置圖片采樣率。

第一種方法,可以增加了堆內存空間,但這種方法僅僅延后了OOM發生的時機,治標不治本,不推薦使用該方法。

第二種方法, Android對圖片進行解碼時,默認是采用ARGB_8888格式,即每個像素占32位,如果圖片格式是jpg,那么用ARGB_8888來解析自然是浪費,因為jpg圖片沒有透明通道。一般我們采用RGB_565格式來對jpg圖片解碼,RGB_565即每個像素點占16位,因此解碼后圖片的內存占用僅僅是使用ARGB_8888解碼的一半 。

第三種方法,這也是網上最普遍方式,也是最有通用的,采樣率可以理解成:當采樣率為4,表示將4個點“合并”為一個點來讀出,縮小圖片尺寸的同時也減少了圖片占用空間,這樣解碼得到出來的圖片占用空間自然比原圖少。

以加載音樂專輯圖片的代碼為例:

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);
}

上面代碼中,我們先設置options.inJustDecodeBounds = true,這樣BitmapFactory.decodeResource的時候僅僅會加載圖片的一些信息,然后通過options.outWidth獲取到圖片的寬度,根據目標圖片尺寸算出采樣率。最后通過inPreferredConfig設置解碼格式,才正式加載圖片。

4 生成圓圖最簡單的方法

我們看到,網易云音樂唱盤背后有個底座,是個透明的圓形圖,如圖5-1所示。筆者找過所有網易云音樂的圖片資源,只發現了一張透明的方形圖,看來我們需要自己生成圓形圖片了。

圖 4-1 唱盤底座

生成圓圖有各種各樣的方式,比如自定義控件復寫onDraw方法、給圖片加上圓形蒙版等,網上都有很多資料,在此不再多說。

在此給大家分享一種筆者認為最簡單的方式:

RoundedBitmapDrawable是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;
}

我們將圖片資源文件轉為Bitmap對象,然后初始化RoundedBitmapDrawable對象,然后直接返回該對象就可以了。

5 使用LayerDrawable進行圖片合成

這一步,主要用于合成唱盤與專輯圖片,如圖6-1所示。筆者用 UI Automation 工具查看網易云音樂唱盤布局時,發現里面用了兩個ImageView,估計是一個用來顯示唱盤,一個用來顯示專輯圖片(并不確定)。 但如果可以將唱盤與專輯圖片合并成一張圖,那使用一個ImageView就夠了 。

圖 5-1

LayerDrawable介紹:

LayerDrawable也可包含一個Drawable數組,因此系統將會按這些Drawable對象的數組順序來繪制它們,索引最大的Drawable對象將會被繪制在最上面。 LayerDrawable有點類似PhotoShop圖層的概念。

思路:

1. 生成圓形的專輯圖。

2. 使用LayerDrawable加載唱盤及專輯圖片。

3. 調整專輯圖的邊距,讓它顯示在唱盤的正中間。

4. 在ImageView中顯示。

代碼:

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;
}

在上面代碼中,我們先生成了唱盤對象BitmapDrawable,然后通過RoundedBitmapDrawable生成圓形專輯圖片,然后存放到Drawable[]數組中,并用來初始化LayerDrawable對象。最后,我們用 setLayerInset方法 調整專輯圖片的四周邊距,讓它顯示在唱盤正中。

6 實現背景毛玻璃效果

顯而易見地,網易云音樂的背景圖是由專輯圖片加上毛玻璃效果而生成的,如圖7-1所示。

圖 6-1 毛玻璃效果

毛玻璃效果,我們可以 StackBlur模糊算法 來實現,這種算法應用非常廣泛,能得到非常良好的毛玻璃效果。在這里我們使用它的 java實現 。

用法如下:

public static Bitmap doBlur(Bitmap sentBitmap, int radius, boolean canReuseInBitmap)

第一個參數是需要模糊處理的Bitmap,第二個參數是模糊半徑(一般設置為8),第三個參數表示是否復用。

對圖片進行模糊化之前,我們先針對播放界面思考幾個問題:

1. 網易云音樂專輯圖均為方形,若將專輯圖全屏加載會造成圖片變形。

2. 直接對大圖模糊化很容易出現OOM,同時性能也有所損耗。

3. 可能有部分專輯圖片顏色過亮(如純白色),會影響按鈕的視覺效果。

第一點,比較容易解決,我們可以在原圖中部,切割一個與屏幕寬高比例對應的圖片即可。

第二點, 做圖片模糊化處理前,我們一般先對大圖進行縮小處理,再用算法進行模糊,這樣不容易出現OOM,對性能也沒影響。 

第三點,我們可以在圖片模糊化后的基礎上,加上灰色遮罩層,這樣就算是純白背景,也不會對主界面的控件造成視覺影響。

代碼如下所示:

private Drawable getForegroundDrawable(int musicPicRes) {
   /*得到屏幕的寬高比,以便按比例切割圖片一部分*/
   final float widthHeightSize = (float) (DisplayUtil.getScreenWidth(MainActivity.this)
           *1.0 / DisplayUtil.getScreenHeight(this) * 1.0);
   Bitmap bitmap = getForegroundBitmap(musicPicRes);
   int cropBitmapWidth = (int) (widthHeightSize * bitmap.getHeight());
   int cropBitmapWidth = (int) ((bitmap.getWidth() - cropBitmapWidth) / 2.0);
   /*切割部分圖片*/
   Bitmap cropBitmap = Bitmap.createBitmap(bitmap, cropBitmapWidth, 0, cropBitmapWidth,
           bitmap.getHeight());
   /*縮小圖片*/
   Bitmap scaleBitmap = Bitmap.createScaledBitmap(cropBitmap, bitmap.getWidth() / 50, bitmap
           .getHeight() / 50, false);
   /*模糊化*/
   final Bitmap blurBitmap = FastBlurUtil.doBlur(scaleBitmap, 8, true);
   final Drawable foregroundDrawable = new BitmapDrawable(blurBitmap);
   /*加入灰色遮罩層,避免圖片過亮影響其他控件*/
   foregroundDrawable.setColorFilter(Color.GRAY, PorterDuff.Mode.MULTIPLY);
   return foregroundDrawable;
}

考慮到這部分代碼可能會阻塞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();
   }
}

7 結束語

本案例還可以進行更多的優化,比如ViewPager無限切換、顯示歌詞等。但邊幅有限,本章的內容就先到此結束,希望能起到拋磚引玉的作用,更多的實現細節可以參考項目源碼。

第一時間獲得博客更新提醒,以及更多 android、小程序干貨,源碼分析,最新開源項目推薦 ,歡迎關注我的微信公眾號,掃一掃下方二維碼或者長按識別二維碼,即可關注。

 

 

來自:http://mp.weixin.qq.com/s?__biz=MzI2OTQxMTM4OQ==&mid=2247484451&idx=1&sn=29d7acdc90dd68b273ce58faecd54c42&chksm=eae1f171dd9678673eb8eb7534319c0e4b599ae07a26540ee4c05b655736778cf20b64aab404#rd

 

 本文由用戶 iswy2776 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!