仿网易云音乐播放界面
wxh
8年前
<h2><strong>0 前言</strong></h2> <p>网易云音乐是一款非常优秀的音乐播放器,尤其是播放界面,使用唱盘机风格,显得格外古典优雅。笔者出于学习与挑战的想法,思考播放界面背后的实现原理,并写了一个小程序。</p> <p>笔者尽可能地去模仿官方的视觉、交互效果,其中包括了唱盘与唱针切换时的细节处理、背景渐变等。本文将会分享一些视觉效果实现的方法以及设计思想,但难免有错漏之处。若读者发现有错误的地方或者更好的实现方法,请留言回复,希望与大家共同进步。效果如下图所示:</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/300a3f210fdf1caa1a04e5d3e6438cd0.gif"></p> <h2><strong>1 本文内容</strong></h2> <ul> <li>项目结构介绍</li> <li>解决加载大图OOM问题</li> <li>生成圆图最简单的方法</li> <li>使用LayerDrawable进行图片合成</li> <li>实现背景毛玻璃效果</li> <li>使用LayerDrawable与属性动画,实现背景切换时渐变效果</li> <li><strong>遇到复杂的场景,应该如何编写代码</strong></li> <li>配合Service、本地广播进行音乐播放</li> </ul> <h2><strong>2 项目结构介绍</strong></h2> <p>项目结构介绍包括以下内容:</p> <ul> <li>主界面布局设计</li> <li>唱盘布局设计</li> <li>动态布局</li> <li>唱盘控件DiscView对外接口及方法</li> <li>音乐状态控制时序图</li> </ul> <h3><strong>2.1主界面布局设计</strong></h3> <p>主界面布局从上到下可以划分几大区域:</p> <ul> <li><strong>标题栏</strong><br> 使用ToolBar实现,字体可能需要自定义。</li> <li><strong>唱盘区域</strong><br> 唱盘区域包括唱盘、唱针、底盘、以及实现切换的ViewPager等控件,该布局比较复杂,本案例 <strong>使用自定义控件实现唱盘区域</strong> 。</li> <li><strong>时长显示区域</strong><br> 使用RelativeLayout作为根布局,进度条使用SeekBar实现。</li> <li> <p>播放控制区域</p> <p>比较简单,使用LinearLayout作为根布局。</p> <p>另外,主界面使用RelativeLayout作为根布局。</p> </li> </ul> <h3><strong>2.2 唱盘布局设计</strong></h3> <p>唱盘区域由控件DiscView实现,以RelativeLayout为根布局,子控件包括:底盘、唱针、ViewPager等。其中,底盘和唱针均用ImageView实现,然后使用ViewPager加载ImageView实现唱片的切换。</p> <p>唱盘布局代码如下所示:</p> <pre> <code class="language-java"><?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></code></pre> <h3><strong>2.3 动态布局</strong></h3> <p>到这里,读者可能有些好奇,上述布局中并没有指定控件的宽高、边距等参数,那如何保证控件显示在正确的位置?我们没有网易云音乐的设计图,因此不能得知官方的布局参数,那该怎么办呢?其实有个笨方法,我们可以打开网易云音乐的播放界面并截图,然后手动去量需要的高度、边距等参数。</p> <p>截图量到控件的宽高、边距等数值,除以截图的宽或高,得到控件参数比例。使用时,我们根据手机的屏幕宽高,乘以对应的比例,就能得到该屏幕尺寸下的控件宽高、边距。</p> <p>当然,这种动态布局肯定会消耗更多性能,但不失为没有办法中的办法。</p> <p>相关控件参数比例,笔者统一放在DisplayUtil.java文件中,代码如下:</p> <pre> <code class="language-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; } }</code></pre> <p>例如需要设置唱盘底盘的顶部外边距,我们先获得该比例,然后乘上当前屏幕高度,得到具体数值,最后通过LayoutParams类进行动态设置。</p> <pre> <code class="language-java">int marginTop = (int) (DisplayUtil.SCALE_DISC_MARGIN_TOP * mScreenHeight); RelativeLayout.LayoutParams layoutParams = (LayoutParams) mDiscBlackground.getLayoutParams(); layoutParams.setMargins(0, marginTop, 0, 0);</code></pre> <h3><strong>2.4 DiscView对外接口及方法</strong></h3> <p>唱盘控件DiscView提供一个接口IPlayInfo,代码如下:</p> <pre> <code class="language-java">public interface IPlayInfo { /*用于更新标题栏变化*/ public void onMusicInfoChanged(String musicName, String musicAuthor); /*用于更新背景图片*/ public void onMusicPicChanged(int musicPicRes); /*用于更新音乐播放状态*/ public void onMusicChanged(MusicChangedStatus musicChangedStatus); }</code></pre> <p>接口IPlayInfo中包含三个方法,分别用于更新标题栏(音乐名、作者名)、更新背景图片以及控制音乐播放状态(播放、暂停、上/下一首等)。</p> <p>读者可能有些疑问?</p> <p>1. IPlayInfo接口的第一、二个方法属于同一类型,为何要拆成两个?</p> <p>2. 为何通过回调来控制音乐播放?点击主界面的控制按钮时,直接控制音乐播放不也可以吗?</p> <p>这两个问题,笔者也是经过多次考虑。</p> <p>第一个问题,首先 网易云音乐交互上,更新标题栏和更新背景图的时机不一样 (ViewPager偏移页面1/2时更新标题栏,而背景图是ViewPager是停止滑动后才更新)。若两个接口合并为一个,一来不利于解耦,二来可能造成开发者误解,并且造成资源浪费。</p> <p>第二个问题,笔者考虑到, 点击主界面的控制按钮,并不代表立刻需要发生音乐的状态变更 (比如点击播放按钮,需要等唱针动画结束后才能开始播放音乐)。因此,控制音乐的时机是依赖与DiscView的状态。因此,我们 通过接口中的onMusicChanged方法在适合的时间先将音乐控制回调到Activity,再通过Activity发送指令,来达到切换音乐状态的效果。</p> <p>点击主界面播放/暂停、上/下一首按钮时,调用DiscView暴露的方法:</p> <pre> <code class="language-java">@Override public void onClick(View v) { if (v == mIvPlayOrPause) { mDisc.playOrPause(); } else if (v == mIvNet) { mDisc.net(); } else if (v == mIvLast) { mDisc.last(); } }</code></pre> <p>当主界面收到DiscView回调时,调用相关方法控制音乐播放:</p> <pre> <code class="language-java">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; } } }</code></pre> <h3><strong>2.5 音乐状态控制时序图</strong></h3> <p style="text-align:center"><img src="https://simg.open-open.com/show/45179b19933ac00ff21036879697eb18.png"></p> <p>音乐状态控制时序图</p> <p>如上时序图所示,点击Activity的按钮时,先调用DiscView的相关方法,并在合适的时机(如动画结束)再将状态回调到Activity,并通过广播发送指令到Service,实现音乐状态切换,最后通过广播更新UI状态。</p> <p>项目架构介绍到这里,接下来是部分视觉效果以及设计思路的介绍。</p> <h2><strong>3 加载大图避免OOM</strong></h2> <p>加载大图避免OOM(内存溢出),这是一个老生常谈的话题,笔者以后会有 专门的文章来讲述这方面的内容,这里先放出结论。</p> <p>解决大图加载一般有几种方案:</p> <p>1. 设置largeHeap为true。</p> <p>2. 根据图片类型选定解码格式。</p> <p>3. 根据原始图片宽高及目标显示宽高,设置图片采样率。</p> <p>第一种方法,可以增加了堆内存空间,但这种方法仅仅延后了OOM发生的时机,治标不治本,不推荐使用该方法。</p> <p>第二种方法, Android对图片进行解码时,默认是采用ARGB_8888格式,即每个像素占32位,如果图片格式是jpg,那么用ARGB_8888来解析自然是浪费,因为jpg图片没有透明通道。一般我们采用RGB_565格式来对jpg图片解码,RGB_565即每个像素点占16位,因此解码后图片的内存占用仅仅是使用ARGB_8888解码的一半 。</p> <p>第三种方法,这也是网上最普遍方式,也是最有通用的,采样率可以理解成:当采样率为4,表示将4个点“合并”为一个点来读出,缩小图片尺寸的同时也减少了图片占用空间,这样解码得到出来的图片占用空间自然比原图少。</p> <p>以加载音乐专辑图片的代码为例:</p> <pre> <code class="language-java">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); }</code></pre> <p>上面代码中,我们先设置options.inJustDecodeBounds = true,这样BitmapFactory.decodeResource的时候仅仅会加载图片的一些信息,然后通过options.outWidth获取到图片的宽度,根据目标图片尺寸算出采样率。最后通过inPreferredConfig设置解码格式,才正式加载图片。</p> <h2><strong>4 生成圆图最快捷的方式</strong></h2> <p>我们看到,网易云音乐唱盘背后有个底座,是个透明的圆形图,如图5-1所示。笔者找过所有网易云音乐的图片资源,只发现了一张透明的方形图,看来我们需要自己生成圆形图片了。</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/462783c90c7071b3381c369bf569cf1e.png"></p> <p>生成圆图有各种各样的方式,比如自定义控件复写onDraw方法、给图片加上圆形蒙版等,网上都有很多资料,在此不再多说。</p> <p>在此给大家分享一种笔者认为最简单的方式:</p> <p>RoundedBitmapDrawable是android.support.v4.graphics.drawable 里面的一个类,通过这个类可以很容易实现圆角和圆形图片。</p> <p>用法:</p> <p>使用RoundedBitmapDrawable生成圆形图,先要将初始图片调整为正方形,由于网易云音乐的这张图片本身就是方形,因此笔者将这一步省略。</p> <p>代码非常简单,代码如下:</p> <pre> <code class="language-java">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; }</code></pre> <p>我们将图片资源文件转为Bitmap对象,然后初始化RoundedBitmapDrawable对象,然后直接返回该对象就可以了。</p> <h2><strong>5 使用LayerDrawable进行图片合成</strong></h2> <p>这一步,主要用于合成唱盘与专辑图片,如图6-1所示。笔者用 <strong>UI Automation</strong> 工具查看网易云音乐唱盘布局时,发现里面用了两个ImageView,估计是一个用来显示唱盘,一个用来显示专辑图片(并不确定)。 但如果可以将唱盘与专辑图片合并成一张图,那使用一个ImageView就够了 。</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/b5b5d75159dfa0a558f99d138e3a30c6.png"></p> <p style="text-align:center">图 6-1</p> <p>LayerDrawable介绍:</p> <p>LayerDrawable也可包含一个Drawable数组,因此系统将会按这些Drawable对象的数组顺序来绘制它们,索引最大的Drawable对象将会被绘制在最上面。 LayerDrawable有点类似PhotoShop图层的概念。</p> <p>思路:</p> <p>1. 生成圆形的专辑图。</p> <p>2. 使用LayerDrawable加载唱盘及专辑图片。</p> <p>3. 调整专辑图的边距,让它显示在唱盘的正中间。</p> <p>4. 在ImageView中显示。</p> <p>代码:</p> <pre> <code class="language-java">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; }</code></pre> <p>在上面代码中,我们先生成了唱盘对象BitmapDrawable,然后通过RoundedBitmapDrawable生成圆形专辑图片,然后存放到Drawable[]数组中,并用来初始化LayerDrawable对象。最后,我们用 <strong>setLayerInset方法</strong> 调整专辑图片的四周边距,让它显示在唱盘正中。</p> <h2><strong>6 实现毛玻璃效果图</strong></h2> <p>显而易见地,网易云音乐的背景图是由专辑图片加上毛玻璃效果而生成的。毛玻璃效果,我们可以 StackBlur模糊算法 来实现,这种算法应用非常广泛,能得到非常良好的毛玻璃效果。在这里我们使用它的 java实现 。</p> <p>用法如下:</p> <pre> <code class="language-java">public static Bitmap doBlur(Bitmap sentBitmap, int radius, boolean canReuseInBitmap)</code></pre> <p>第一个参数是需要模糊处理的Bitmap,第二个参数是模糊半径(一般设置为8),第三个参数表示是否复用。</p> <p>对图片进行模糊化之前,我们先针对播放界面思考几个问题:</p> <p>1. 网易云音乐专辑图均为方形,若将专辑图全屏加载会造成图片变形。</p> <p>2. 直接对大图模糊化很容易出现OOM,同时性能也有所损耗。</p> <p>3. 可能有部分专辑图片颜色过亮(如纯白色),会影响按钮的视觉效果。</p> <p>第一点,比较容易解决,我们可以在原图中部,切割一个与屏幕宽高比例对应的图片即可。</p> <p>第二点, 做图片模糊化处理前,我们一般先对大图进行缩小处理,再用算法进行模糊,这样不容易出现OOM,对性能也没影响。</p> <p>第三点,我们可以在图片模糊化后的基础上,加上灰色遮罩层,这样就算是纯白背景,也不会对主界面的控件造成视觉影响。</p> <p>代码如下所示:</p> <pre> <code class="language-java">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; }</code></pre> <p>考虑到这部分代码可能会阻塞UI线程,因此笔者将其放着单独线程中执行。</p> <pre> <code class="language-java">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(); } }</code></pre> <h2><strong>7 使用LayerDrawable与属性动画,实现图片切换渐变效果</strong></h2> <p>仔细观察网易云音乐,发现切换歌曲时,背景图也会随着变化,如图8-1所示,变化时还带有一个渐变的效果。笔者曾经也是为这个效果想了很长时间,这效果究竟是怎么实现的?后来笔者想到了一个很简单的方法,可以用前面介绍的LayerDrawable加属性动画来实现。</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/e23de7277c5cf3e70f25d36895f5f4ee.gif"></p> <p style="text-align:center">图 8-1 背景图渐变效果</p> <p>思路如下:</p> <p>1. 给LayerDrawable设置两个图层,第一图层是前一个背景,第二图层是准备显示的背景。</p> <p>2. 先把准备显示的背景透明度设为0,因此完全透明,此时只显示前一个背景图。</p> <p>3. 通过属性动画,动态将第二图层的透明度从0调整至100,并不断更新控件的背景。</p> <p>有了思路,写代码就简单了。我们通过RelativeLayout来显示背景,考虑到需要对代码进行封装,我们自定义一个类BackgourndAnimationRelativeLayout继承RelativeLayout,并在该类中实现上述的思路,关键代码如下:</p> <pre> <code class="language-java">/** * 自定义一个控件,继承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(); }</code></pre> <h2><strong>8 遇到复杂的场景,如何编写代码</strong></h2> <p>这是个很有趣的问题,我们平时写代码也会遇到很多复杂的场景。就拿网易云音乐来说,我们可以仔细观察唱针的动画细节。</p> <p>唱针动画细节:</p> <ol> <li>暂停音乐时,点击播放按钮,此时唱针移动到底部。</li> <li>播放音乐时,手指按住唱盘并移动,此时唱针移到顶部。</li> <li>播放音乐时,手指按住唱盘并移动,等唱针未移到顶部时,立刻松开手指,此时唱针先到顶部再回到唱盘位置。</li> <li>暂停音乐时,点击播放,此时唱针往下移动,当唱针动画为结束时,马上手指按住唱盘并偏移,此时唱针立刻往顶部移动。</li> <li>左右滑动ViewPager进行音乐切换,唱针动画未结束时,立刻点击上下一首按钮,进行音乐切换,此时唱针状态不能错乱。</li> </ol> <p>第1、2个效果很容易实现,只要监听ViewPager的状态配合属性动画就可以了,但第3、4、5个效果(本质上5和3、4一致)实现起来就有难度了。当然我们可以简单地加boolean标记暴力解决,标记虽然可以用,但不能随意用,否则变量多起来代码可读性句变得非常差了。</p> <p>笔者在这谈一点自己的心得体会,遇到这种问题原则如下:</p> <p>冷静分析,简化并找到不同场景的被触发的状态。</p> <p>我们仔细分析上述的几种场景,无非和两个因素有关: <strong>ViewPager是否偏离</strong> 以及 <strong>唱针所处的位置</strong> 。因此,我们可以把状态分为两类,六种状态:</p> <p>1. ViewPager状态:包括正在偏移、偏移结束。</p> <p>2. 唱针的状态:处于远端、处于近端、正在从远端往近端移动、正在从近端往远端移动。</p> <p>ViewPager的状态可以通过PageChangeListener得到,而唱针的状态笔者通过枚举来表示,唱针的状态在动画的开始、结束时进行切换。</p> <p>唱针状态枚举:</p> <pre> <code class="language-java">private enum NeedleAnimatorStatus { /*移动时:从唱盘往远处移动*/ TO_FAR_END, /*移动时:从远处往唱盘移动*/ TO_NEAR_END, /*静止时:离开唱盘*/ IN_FAR_END, /*静止时:贴近唱盘*/ IN_NEAR_END }</code></pre> <p>动画开始时,更新唱针状态:</p> <pre> <code class="language-java">@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; } }</code></pre> <p>动画结束时,更新唱针状态:</p> <pre> <code class="language-java">@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; } }</code></pre> <p>每种状态都定义清楚,这样代码写起来就比较清晰,比如 <strong>播放动画时,包含两个状态</strong> :</p> <p>1. 唱针处于远端时,播放唱针动画。</p> <p>2. 唱针处于从近端往远端移动时,播放唱针动画(解决上述场景第3个问题)</p> <pre> <code class="language-java">/*播放动画*/ private void playAnimator() { /*唱针处于远端时,直接播放动画*/ if (needleAnimatorStatus == NeedleAnimatorStatus.IN_FAR_END) { mNeedleAnimator.start(); } /*唱针处于往远端移动时,设置标记,等动画结束后再播放动画*/ else if (needleAnimatorStatus == NeedleAnimatorStatus.TO_FAR_END) { mIsNeed2StartPlayAnimator = true; } }</code></pre> <p>再比如暂停播放时,包含两种状态:</p> <p>1. 唱针处于近端时,暂停动画。</p> <p>2. 唱针往近端移动时,暂停动画(解决上述场景第4个细节问题)</p> <pre> <code class="language-java">/*暂停动画*/ private void pauseAnimator() { /*播放时暂停动画*/ if (needleAnimatorStatus == NeedleAnimatorStatus.IN_NEAR_END) { int inde = mVpContain.getCurrentItem(); pauseDiscAnimatior(inde); } /*唱针往唱盘移动时暂停动画*/ else if (needleAnimatorStatus == NeedleAnimatorStatus.TO_NEAR_END) { mNeedleAnimator.reverse(); /** *若动画在没结束时执行reverse方法,则不会执行监听器的onStart方法,此时需要手动设置 **/ needleAnimatorStatus = NeedleAnimatorStatus.TO_FAR_END; } }</code></pre> <h2><strong>9 配合Service、本地广播进行音乐播放</strong></h2> <h3><strong>9.1 为什么选择Service?</strong></h3> <p>面试的时候,可能面试官会问:什么场景下,使用Service会比Activity更有优势?很多人回答,运行在不需要显示界面的场景时,但没有没有说出具体场景。</p> <p>播放音频,需要用到MediaPlayer类。笔者认为,对MediaPlayer的控制,放在Service处理再好不过了: Service不需要界面,但更重要的是,不需要处理屏幕旋转的逻辑 。如果把MediaPlayer放在Activity,屏幕旋转时,因为Activity会重建,估计还要保存ediaPlayer的各种状态,使用Service就没有这个顾虑了。</p> <p>笔者的案例不涉及到屏幕旋转处理,为了演示流程,还是使用Service处理音乐播放。</p> <h3><strong>9.2 为什么选择本地广播?</strong></h3> <p>Android的广播时全局的,一旦发出,会在系统内传播,如果有其他APP得知你的Action信息和权限,可能造成信息泄露,甚至可以发送广播来控制你的APP。而本地广播(LocalBroadcast)只在应用内部传递,则不会有这个顾虑。</p> <p>本地广播有以下几个特点:</p> <ul> <li><strong>广播在应用内部传播,不必担心信息泄露。</strong></li> <li><strong>别的应用无法发送广播来控制你的APP,更加安全。</strong></li> <li><strong>发送的广播不需要系统中转,效率更高。</strong></li> </ul> <p>本地广播的用法很简单,如下:</p> <p>注册:</p> <pre> <code class="language-java">LocalBroadcastManager.getInstance(this).registerReceiver(receiver, intentFilter);</code></pre> <p>取消注册:</p> <pre> <code class="language-java">LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver);</code></pre> <p>广播发送:</p> <pre> <code class="language-java">LocalBroadcastManager.getInstance(contet).sendBroadcast(new Intent(ACTION));</code></pre> <h3><strong>9.3 进度条的处理</strong></h3> <p>本案例中,我们使用Handler+SeekBar实现歌曲进度动态更新,通过Handler的sendEmptyMessageDelayed方法,每隔1秒发送一个事件。当接收到事件时,更新SeekBar的进度然后再次调用sendEmptyMessageDelayed方法,这样就可以实现进度的动态更新。</p> <p>关键代码如下所示:</p> <pre> <code class="language-java">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); }</code></pre> <p>当SeekBar滑动时,使用removeMessages方法移除Handler中的延时消息,暂停Handler对SeekBar的更新。当SeekBar滑动结束后,根据当前的进度值来更新音乐播放的位置。</p> <p>关键代码如下所示:</p> <pre> <code class="language-java">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(); } });</code></pre> <p>本章的内容到此结束,更多的实现细节可以参考项目源码。</p> <p> </p> <p>来自:http://www.jianshu.com/p/cb54990219d9</p> <p> </p>