仿网易云音乐播放界面

作者: AchillesL | 来源:发表于2016-11-24 12:41 被阅读14413次

    原创作者:AchillesL
    若转载文章,请在明显的位置标明文章出处

    0 前言

    网易云音乐是一款非常优秀的音乐播放器,尤其是播放界面,使用唱盘机风格,显得格外古典优雅。笔者出于学习与挑战的想法,思考播放界面背后的实现原理,并写了一个小程序。

    笔者尽可能地去模仿官方的视觉、交互效果,其中包括了唱盘与唱针切换时的细节处理、背景渐变等。本文将会分享一些视觉效果实现的方法以及设计思想,但难免有错漏之处。若读者发现有错误的地方或者更好的实现方法,请留言回复,希望与大家共同进步。效果如下图所示:

    效果图 使用系统浏览器查看效果更佳

    1 源码地址

    需要源码的读者,可以到github中自行下载:
      https://github.com/AchillesLzg/jianshu-neteasedisc

    2 本文内容

    • 项目结构介绍
    • 解决加载大图OOM问题
    • 生成圆图最简单的方法
    • 使用LayerDrawable进行图片合成
    • 实现背景毛玻璃效果
    • 使用LayerDrawable与属性动画,实现背景切换时渐变效果
    • 遇到复杂的场景,应该如何编写代码
    • 配合Service、本地广播进行音乐播放
    • 结束语

    3 项目结构介绍

    项目结构介绍包括以下内容:

    • 主界面布局设计
    • 唱盘布局设计
    • 动态布局
    • 唱盘控件DiscView对外接口及方法
    • 音乐状态控制时序图

    3.1主界面布局设计

    主界面布局从上到下可以划分几大区域,如图3-1所示:

    图 3-1 主界面布局
    • **标题栏 **
      使用ToolBar实现,字体可能需要自定义。

    • 唱盘区域 **
      唱盘区域包括唱盘、唱针、底盘、以及实现切换的ViewPager等控件,该布局比较复杂,本案例
      使用自定义控件实现唱盘区域**。

    • **时长显示区域 **
      使用RelativeLayout作为根布局,进度条使用SeekBar实现。

    • **播放控制区域 **
      比较简单,使用LinearLayout作为根布局。

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

    3.2 唱盘布局设计

    唱盘区域由控件DiscView实现,以RelativeLayout为根布局,子控件包括:底盘、唱针、ViewPager等。其中,底盘和唱针均用ImageView实现,然后使用ViewPager加载ImageView实现唱片的切换。如图3-2所示。

    图 3-2 唱盘区域布局

    唱盘布局代码如下所示:

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

    3.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);
    

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

    3.5 音乐状态控制时序图

    图 3-3 音乐状态控制时序图

    音乐控制状态时序如图3-3所示,点击Activity的按钮时,先调用DiscView的相关方法,并在合适的时机(如动画结束)再将状态回调到Activity,并通过广播发送指令到Service,实现音乐状态切换,最后通过广播更新UI状态。

    项目架构介绍到这里,接下来是部分视觉效果以及设计思路的介绍。

    4 解决加载大图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设置解码格式,才正式加载图片。

    5 生成圆图最简单的方法

    我们看到,网易云音乐唱盘背后有个底座,是个透明的圆形图,如图5-1所示。笔者找过所有网易云音乐的图片资源,只发现了一张透明的方形图,看来我们需要自己生成圆形图片了。

    图 5-1 唱盘底座

    生成圆图有各种各样的方式,比如自定义控件复写onDraw方法、给图片加上圆形蒙版等,网上都有很多资料,在此不再多说。

    在此给大家分享一种笔者认为最简单的方式:

    RoundedBitmapDrawable是android.support.v4.graphics.drawable 里面的一个类,通过这个类可以很容易实现圆角和圆形图片。

    更多介绍请见:
      http://www.cnblogs.com/liunanjava/p/5827919.html

    用法:
      使用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对象,然后直接返回该对象就可以了。

    6 使用LayerDrawable进行图片合成

    这一步,主要用于合成唱盘与专辑图片,如图6-1所示。笔者用UI Automation工具查看网易云音乐唱盘布局时,发现里面用了两个ImageView,估计是一个用来显示唱盘,一个用来显示专辑图片(并不确定)。但如果可以将唱盘与专辑图片合并成一张图,那使用一个ImageView就够了

    图 6-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方法调整专辑图片的四周边距,让它显示在唱盘正中。

    7 实现背景毛玻璃效果

    显而易见地,网易云音乐的背景图是由专辑图片加上毛玻璃效果而生成的,如图7-1所示。

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

    8 使用LayerDrawable与属性动画,实现背景切换时渐变效果

    仔细观察网易云音乐,发现切换歌曲时,背景图也会随着变化,如图8-1所示,变化时还带有一个渐变的效果。笔者曾经也是为这个效果想了很长时间,这效果究竟是怎么实现的?后来笔者想到了一个很简单的方法,可以用前面介绍的LayerDrawable加属性动画来实现。

    图 8-1 背景图渐变效果

    思路如下:
      1. 给LayerDrawable设置两个图层,第一图层是前一个背景,第二图层是准备显示的背景。
      2. 先把准备显示的背景透明度设为0,因此完全透明,此时只显示前一个背景图。
      3. 通过属性动画,动态将第二图层的透明度从0调整至100,并不断更新控件的背景。

    有了思路,写代码就简单了。我们通过RelativeLayout来显示背景,考虑到需要对代码进行封装,我们自定义一个类BackgourndAnimationRelativeLayout继承RelativeLayout,并在该类中实现上述的思路,关键代码如下:

    /**
     * 自定义一个控件,继承RelativeLayout
     **/
    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();
    }
    

    9 遇到复杂的场景,应该如何编写代码

    这是个很有趣的问题,我们平时写代码也会遇到很多复杂的场景。就拿网易云音乐来说,我们可以仔细观察唱针动画的细节,你会发现网易云音乐是多么优秀的应用。

    唱针动画细节:

    • 初始状态为暂停/停止时,点击播放按钮,此时唱针移动到底部。 图9-1所示。
    图 9-1
    • 初始状态为播放时,点击暂停按钮,此时唱针移到顶部。 如图9-2所示。
    图 9-2
    • 初始状态为播放时,手指按住唱盘并稍微偏移,等唱针未移到顶部时,立刻松开手指,此时唱针回到顶部后立刻再回到唱盘位置。如图9-3所示。
    图 9-3
    • 初始状态为暂停/停止时,点击播放,此时唱针往下移动,当唱针还未移到底部,手指马上按住唱盘并偏移,此时唱针立刻往顶部移动。如图9-4所示 。
    图 9-4
    • 初始状态为播放/暂停/停止时,左右滑动唱片进行音乐切换,唱针动画未结束时,立刻点击上/下一首按钮,进行音乐切换,此时唱针状态不能出现混乱。如图9-5所示。
    图 9-5

    第1、2个效果很容易实现,只要监听ViewPager的状态配合属性动画就可以了,但第3、4、5个效果(本质上5和3、4一致)实现起来就有难度了。当然我们可以简单地加boolean标记暴力解决,标记虽然可以用,但不能随意用,否则变量多起来代码可读性句变得非常差了

    笔者在这谈一点自己的心得体会,遇到这种问题,原则如下:

    冷静分析,简化并找到不同场景的被触发的状态。

    我们仔细分析上述的几种场景,无非和两个因素有关:唱片是否偏离以及动作触发时,唱针所处的位置。因此,我们可以把状态分为两类,六种状态:

    1. 唱片状态(两种):包括偏移中、偏移结束。 如图9-6所示。
      2. 唱针的状态(四种):处于远端(远离唱片)、处于近端(贴近唱片)、正在从远端往近端移动、正在从近端往远端移动。如图9-7所示。

    图 9-6 ViewPager的状态 图 9-7 唱针的状态

    其中,唱片(即ViewPager)的状态可以通过PageChangeListener得到。唱针的状态,笔者用枚举来表示,并且在动画的开始、结束时对唱针状态及时更新。

    唱针状态枚举:

        private enum NeedleAnimatorStatus {
            /*移动时:从唱盘往远处移动*/
            TO_FAR_END,
            /*移动时:从远处往唱盘移动*/
            TO_NEAR_END,
            /*静止时:离开唱盘*/
            IN_FAR_END,
            /*静止时:贴近唱盘*/
            IN_NEAR_END
        }
    

    动画开始时,更新唱针状态:

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

    动画结束时,更新唱针状态:

    @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的问题)

    /*播放动画*/
    private void playAnimator() {
        /*唱针处于远端时,直接播放动画*/
        if (needleAnimatorStatus == NeedleAnimatorStatus.IN_FAR_END) {
            mNeedleAnimator.start();
        } 
        /*唱针处于往远端移动时,设置标记,等动画结束后再播放动画*/
        else if (needleAnimatorStatus == NeedleAnimatorStatus.TO_FAR_END) {
            mIsNeed2StartPlayAnimator = true;
        }
    }
    

    再比如需要暂停动画时,也包含两种状态

    1. 唱针动画暂停中,唱针处于近端时。
      2. 唱针动画播放中,唱针往近端移动时(解决上述场景第4个细节问题)

    /*暂停动画*/
    private void pauseAnimator() {
        /*播放时暂停动画*/
        if (needleAnimatorStatus == NeedleAnimatorStatus.IN_NEAR_END) {
            int index = mVpContain.getCurrentItem();
            pauseDiscAnimatior(index);
        }
        /*唱针往唱盘移动时暂停动画*/
        else if (needleAnimatorStatus == NeedleAnimatorStatus.TO_NEAR_END) {
            mNeedleAnimator.reverse();
            /**
             * 若动画在没结束时执行reverse方法,则不会执行监听器的onStart方法,此时需要手动设置
             * */
            needleAnimatorStatus = NeedleAnimatorStatus.TO_FAR_END;
        }
        /**
         * 动画可能执行多次,只有音乐处于停止 / 暂停状态时,才执行暂停命令
         * */
        if (musicStatus == MusicStatus.STOP) {
            notifyMusicStatusChanged(MusicChangedStatus.STOP);
        } else if (musicStatus == MusicStatus.PAUSE) {
            notifyMusicStatusChanged(MusicChangedStatus.PAUSE);
        }
    }
    

    10 配合Service、本地广播进行音乐播放

    10.1 为什么选择Service?

    面试的时候,可能面试官会问:什么场景下,使用Service会比Activity更有优势?很多人回答,运行在不需要显示界面的场景时,但没有没有说出具体场景。

    播放音频,需要用到MediaPlayer类。笔者认为,对MediaPlayer的控制,放在Service处理再好不过了:Service不需要界面,但更重要的是,不需要处理屏幕旋转的逻辑。如果把MediaPlayer放在Activity,屏幕旋转时,因为Activity会重建,估计还要保存ediaPlayer的各种状态,使用Service就没有这个顾虑了。

    笔者的案例不涉及到屏幕旋转处理,为了演示流程,还是使用Service处理音乐播放。

    10.2 为什么选择本地广播?

    Android的广播时全局的,一旦发出,会在系统内传播,如果有其他APP得知你的Action信息和权限,可能造成信息泄露,甚至可以发送广播来控制你的APP。而本地广播(LocalBroadcast)只在应用内部传递,则不会有这个顾虑。

    本地广播有以下几个特点:

    • **广播在应用内部传播,不必担心信息泄露。 **
    • 别的应用无法发送广播来控制你的APP,更加安全。
    • 发送的广播不需要系统中转,效率更高。

    本地广播的用法很简单,如下:

    注册:

    LocalBroadcastManager.getInstance(this).registerReceiver(receiver, intentFilter);
    

    取消注册:

    LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver);
    

    广播发送:

    LocalBroadcastManager.getInstance(contet).sendBroadcast(new Intent(ACTION));
    

    10.3 进度条的处理

    本案例中,我们使用Handler+SeekBar实现歌曲进度动态更新,通过Handler的sendEmptyMessageDelayed方法,每隔1秒发送一个事件。当接收到事件时,更新SeekBar的进度然后再次调用sendEmptyMessageDelayed方法,这样就可以实现进度的动态更新。

    关键代码如下所示:

    private Handler mMusicHandler = new Handler(){
        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
            mSeekBar.setProgress(mSeekBar.getProgress() + 1000);
                    mTvMusicDuration.setTet(duration2Time(mSeekBar.getProgress()));
            startUpdateSeekBarProgress();
        }
    };
    
    private void startUpdateSeekBarProgress() {
        /*避免重复发送Message*/
        stopUpdateSeekBarProgree();
        mMusicHandler.sendEmptyMessageDelayed(0,1000);
    }
    

    当SeekBar滑动时,使用removeMessages方法移除Handler中的延时消息,暂停Handler对SeekBar的更新。当SeekBar滑动结束后,根据当前的进度值来更新音乐播放的位置。

    关键代码如下所示:

    mSeekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
    
        @Override
        public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
            mTvMusicDuration.setTet(duration2Time(progress));
        }
    
        @Override
        public void onStartTrackingTouch(SeekBar seekBar) {
            stopUpdateSeekBarProgree();
        }
    
        @Override
        public void onStopTrackingTouch(SeekBar seekBar) {
            seekTo(seekBar.getProgress());
            startUpdateSeekBarProgress();
        }
    });
    

    11 结束语

    本案例还可以进行更多的优化,比如ViewPager无限切换、显示歌词等。但边幅有限,本章的内容就先到此结束,希望能起到抛砖引玉的作用,更多的实现细节可以参考项目源码。

    相关文章

      网友评论

      • 爱吃板栗的小女孩:你好,图片想要用网络的应该怎么弄,您有方法吗?我把那替换成glide方式加载后,图片就是正方形了,应该怎么弄?在线等,谢谢
        爱吃板栗的小女孩:@AchillesL 处理那部分好难弄,整半天也没弄明白。。:sob:
        AchillesL:@爱吃板栗的小女孩 下载图片后应该有回调,手动处理成圆图。
      • 放开外星人:集成进去到自己的项目了,美观实用,可是有几个问题还没解决到。
        第一、OOM问题:
        笔者尽力绕过了几个容易导致OOM的坑,但是getDiscDrawable那里还是有错误:
        java.lang.OutOfMemoryError: Failed to allocate a 1336348 byte allocation with 51608 free bytes and 50KB until OOM
        at dalvik.system.VMRuntime.newNonMovableArray(Native Method)
        at android.graphics.Bitmap.nativeCreate(Native Method)
        at android.graphics.Bitmap.createBitmap(Bitmap.java:879)
        at android.graphics.Bitmap.createBitmap(Bitmap.java:856)
        at android.graphics.Bitmap.createBitmap(Bitmap.java:787)
        at android.graphics.Bitmap.createScaledBitmap(Bitmap.java:663)
        at tashkin.munberapp.MunberForum.classes.DiscView.getDiscDrawable(DiscView.java:330)
        at tashkin.munberapp.MunberForum.classes.DiscView.setMusicDataList(DiscView.java:390)
        at tashkin.munberapp.MunberForum.purchasedAlbum.MusicPlayerActivity.initView(MusicPlayerActivity.java:120)
        at tashkin.munberapp.MunberForum.purchasedAlbum.MusicPlayerActivity.onCreate(MusicPlayerActivity.java:68)

        正在尝试解决.但是毕竟不是自己的代码,有点费劲.
        第二,指定播放位置(可能是自己没读懂代码,这里有些许问题,还没找到具体是什么原因)
        因为歌曲的图片,名称等一系列信息是网络获取,加载完列表后点击跳转至指定歌曲的播放时出现ActionBar 上的歌曲名和作者名现实正确,但是播放url 不正确的异常。
        如果作者有幸看见此贴,望指导。谢谢。
      • 7f4653b15025:大神问下评论分享的图片生成是怎么做的?
      • Symbian米汤:大神,继续分享心得啊
      • 芸芸人海之中独独遇见你:前端小白一脸蒙蔽。。。
      • 庞哈哈哈12138:非常的棒,楼主写的很详细
      • 天门一只猿:打开就oom了。。。版本低于6.0,有个方法报错
      • a173037f9952:不知道下载后如何上传使用呢——我是小白
      • 有_风:自己导入到android studio好了, 显示显示:Error:(1, 0) Plugin with id 'com.android.application' not found.错误的朋友, 先新建一个项目, 然后进入文件夹删除原"src"文件夹, 把楼主的"src"复制替换即可, 注意要改一下最低系统版本为6.0 .

        天门一只猿:咳咳,正在找方法中。老铁你很给力
      • aad65ceb0ccf:有没有大神,我想拜个师,学习编程。😆😆😆
      • e6400fcb6186:好文章,必须赞一个!:smile:
      • ab57b7ac7eb0:如果是网络图片怎么搞啊
        ab57b7ac7eb0:@AchillesL 嗯呢,
        但是这个版本不兼容4.3及以下的
        AchillesL:@ab57b7ac7eb0 网络图片,一般是先显示默认图,然后网络异步下载后,再更新。
      • 2fbc612a5434:非常细致必须赞
      • 93a6588447dc:切换背景那方法很巧妙啊 赞个:+1:
      • 203cefe0b2b1:总结的很好啊。简直就是记一次网易云音乐,音乐播放页面的开发了。
        AchillesL:@耿世玉 哈哈,谢谢夸奖啦
      • Laddie:写的不错
      • 扯淡的橘子:厉害噜
      • MambasJi:楼主请问网易音乐的歌词切换是怎么实现的,用的是Fragment吗
        MambasJi:@AchillesL 好的,谢谢
        AchillesL:@Manbas_JI 应该是的,看别人APP是什么布局,用ui automator就可以了。
      • Lrxc:一波赞 :+1: 不知楼主用了几天写成的啊 参考下
        AchillesL: @萧萧冷者 好像写了三天
      • 一溪酒:好用心啊
        AchillesL: @cocakoala 谢谢支持😬
      • BryantHe:厉害:+1:
      • 93d875b8b69e:我下载不了
        93d875b8b69e: @AchillesL 现在可以打开了 昨天网络不好
        93d875b8b69e: @AchillesL 最上面那个链接
        AchillesL: @ZZZERUI 什么下载不了?😳
      • 发现有趣app:不错,
      • MeloDev:good job
      • 阿弥小布:好深奥
      • 04b5fead1fdc:这么好的文章没人看嘛

      本文标题:仿网易云音乐播放界面

      本文链接:https://www.haomeiwen.com/subject/rtlfpttx.html