Android 自定义音乐播放器实现

Android自定义音乐播放器

一:首先介绍用了哪些Android的知识点:

1 MediaPlayer工具来播放音乐

2 Handle。因为存在定时任务(歌词切换,动画,歌词进度条变换等)需要由Handle来处理Ui相关内容

3 动态权限申请(该应用程序读取本地歌曲,并且设置音质相关属性)且这两个权限在Android6.0后都需要动态申请

4 手势控制 (左划和右划需要满足一定条件后可以进行切歌)

5 Service服务 (启动Service 绑定Service 前台Service)

6 BroadcastReceiver 广播,Service与Activity,Activity与Activity存在动态交互,需要广播实现

7 基本的重写View能力和Intent交互数据的能力

8 Animation动画 图片旋转 歌词更新


二:实现过程(简要步骤,下面会详细讲解)

1 先编写MusicInfo工具类。因为我们是从手机内存中去读取音乐的相关信息,那么读出的数据该存储到MusicInfo工具类集合中。

2 先申请权限,然后再去手机内存将音乐及其相关的信息读出来 ,用一个ListView容器去装载所有的本地音乐

PS:到了这里基本的音乐信息列表已经有了   这也是我们的主界面(音乐列表界面) 即: 展示音乐/歌曲列表;

Android 自定义音乐播放器实现_第1张图片

3 这时候先不方去实现播放这类的功能,我们先去处理歌词

这里说明一下。一个歌词文件(.Lrc)里面内容格式如下

(张卫健--真英雄)

Android 自定义音乐播放器实现_第2张图片

可以发现他由时间戳和歌词内容两部分组成。有了这个信息后。编写LrcContent工具类,用于记录歌词内容和歌词时间。然后去手机里面寻找歌曲对应的歌词文件,将其编码,读出,装载为LrcContent集合。

4 编写Service类,Service主要用来处理:音乐播放,前台服务。在播放状态改变的时候与播放音乐的Activity进行通信。该Service由主界面启动,后面的Activity绑定即可

5 编写播放音乐的Activity类(MusicPlay)。当我们从主界面(音乐列表界面)点击了一首具体的音乐时,就会调转到该Activity,所有首先,主界面Activity需要传递一些信息给该Activity。

(1) MusicInfo工具类集合。即手机中所有的音乐信息

(2)当前点击的歌曲,传递位置(position)即可

好,现在我们播放音乐的Activity有了所有的音乐信息,还有当前需要播放的歌曲位置。因为需要前台服务,所以我们把音乐播放的控制权交给Service,我们去绑定服务,然后把所有的音乐信息,还有当前需要播放的歌曲位置都传递给Serivce,Service来控制播放音乐。

6 好了,现在我们的程序可以播放音乐了,我们再来一步步完善细节,歌词同步,该功能自定义View实现,最后显现在播放音乐的Activity中。注意Mediaplayer有一个重要属性:

mediaPlayer.getCurrentPosition()。该方法会返回当前播放时间,不过返回的时候时毫秒(重)。

自定义View(LrcView 显示歌词),该View中除了传统的自定义View需要的OnDraw之类的方法外,还需要获取第三步中的LrcContent集合,有了这个我们就有了所有的歌词内容和歌词相应的时间,那么同步如何实现呢?音乐最终该View要显示在播放音乐的Activity(MusicPlay)中,我们去MusicPlay的布局文件申明该View,然后在MusicPlay中编写一个定时器,可以设定每一秒启动一次,定时器发送消息,在Handle中接受消息,处理消息。Handle中我们需要:获取歌曲当前播放时间,根据当前播放时间去LrcContent集合中寻求匹配的歌词。用invalidate()方法,通知自定义View重绘,来同步更新歌词

7 我们的音乐播放器还差一个重要的东西,音乐控制器部分。这部分需要来控制播放上一首,下一首,播放/暂停,音乐进度拖动,音量设置。

该部分不难,所以再这里不详细讲。

三:效果图  因为完整录制的GIF太大,传不上来所以分批处理




四:代码精讲 代码量也不很大,但是全贴出来挨着讲又影响阅读。所以部分节选和重要知识点一并讲解。

PS:源码中含有大量System.out.println("XXXX");语句。个人比较偏爱的一种测试方式。。应该不干扰阅读,忘见谅

1 权限获取,我们要做的第一件事就是去内存读取音乐相关信息,那么我们就需要获得相关的权限,从Android6.0开始部分权限不仅需要在AndroidManifest.xml文件中声明,还需要在运行程序的时候动态获取.这里以读取存储权限为例:

首先在AndroidManifest.xml中定义

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

在Java业务代码中先判断是否已经有权限,有权限就不再申请,没有就申请权限

//首先检查自身是否已经拥有相关权限,拥有则不再重复申请
int check = checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) ;
    //没有相关权限
    if (check != PackageManager.PERMISSION_GRANTED)
    {
        //申请权限  STORGE_REQUEST = 1
        ActivityCompat.requestPermissions(this , new String[] {Manifest.permission.WRITE_EXTERNAL_STORAGE} ,STORGE_REQUEST);
    }else {
    //已有权限的情况下可以直接初始化程序
    init();
}

当我们申请权限后,去判断用户是否给与了相关权限,如果赋予了就可以做我们的事情了

/*
申请权限处理结果
 */
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
     super.onRequestPermissionsResult(requestCode, permissions, grantResults);
     switch (requestCode)
     {
         case STORGE_REQUEST :
             if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED)
             {
                 //完成程序的初始化
                 init();
                 System.out.println("程序申请权限成功,完成初始化") ;
             }
             else {
                 System.out.println("程序没有获得相关权限,请处理");
             }
             break ;
     }

}

关于权限这部分:有兴趣的可以看下参考下这篇博客

Android 6.0 运行时权限管理最佳实践

2 有了权限,我们就可以从手机中去读取音乐相关信息了,这里讲解下如何去读取信息

(1) 我们需要先创建一个存储音乐信息MusicInfo工具类  

注意:代码没贴完,还有属性的get和set方法没列出

public class MusicInfo implements Serializable {
    private  int _id = - 1;       //音乐标识码
    private  int duration = -1 ;   //音乐时常
    private String artist = null ;    //音乐作者
    private String musicName= null ; //音乐名字
    private String album = null ;   //音乐文件专辑
    private String title = null ;  //音乐文件标题
    private int size  ;   //音乐文件的大小  返回byte大小
    private String data ;  //获取文件的完整路径
    private String album_id ; //实际存储为音乐专辑团片
}   


(2)读取音乐信息。

Android中的音乐信息存储再手机数据库中,那么我们就需要去访问数据库。这里介绍下如何访问数据库

首先要知道基本数据库查询操作,返回Cursor对象

public final Cursor query(Uri uri , //查询路径

                            String[] projection , //查询指定的列

                            String selection, //查询条件

                            String[] selectionArgs, //查询参数

                           String sortOrder } //查询结果的排序方式

 那么就有了我们查询音乐的操作。

 
  
//申明ContentResolver对象,用于访问系统数据库
private ContentResolver contentResolver ;
//用于装载MusicInfo对象
private List musicInfos ;
//获取系统的ContentResolver
contentResolver = getContentResolver() ;

//从数据库中获取指定列的信息
mCursor = contentResolver.query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI ,
        new String[] {MediaStore.Audio.Media._ID ,
                MediaStore.Audio.Media.TITLE , MediaStore.Audio.Media.ALBUM ,
                MediaStore.Audio.Media.ARTIST , MediaStore.Audio.Media.DURATION ,
                MediaStore.Audio.Media.DISPLAY_NAME , MediaStore.Audio.Media.SIZE ,
                MediaStore.Audio.Media.DATA , MediaStore.Audio.Media.ALBUM_ID } , null ,null ,null) ;
 
  

我们已经查询出了所有的音乐信息,并且存储在mCursor对象中,现在我们需要把音乐信息装载到musicInfos工具类集合中。

但是注意,音乐专辑图片需要特殊处理:我们这是只查询出了音乐专辑id   MediaStore.Audio.Media.ALBUM_ID 我们需要根据音乐专辑id再去查询音乐专辑图片,用该图片来作为音乐图片。

/*
    获取本地音乐专辑的图片
 */
private String getAlbumArt(int album_id)
{
    String UriAlbum = "content://media/external/audio/albums" ;
    String projecttion[] =  new String[] {"album_art"} ;
    Cursor cursor = contentResolver.query(Uri.parse(UriAlbum + File.separator +Integer.toString(album_id)) ,
            projecttion , null , null , null);
    String album = null ;
    if (cursor.getCount() > 0 && cursor.getColumnCount() > 0)
    {
        cursor.moveToNext() ;
        album = cursor.getString(0) ;
    }
    //关闭资源数据
    cursor.close();
    return album ;
}

这时还可能出现一个问题,可能存在本地专辑音乐图片不存在,所以我们要进行判断,没有专辑图片就使用默认的图片。好了,现在我们就可以装载音乐信息了

musicInfos = new ArrayList<>() ;
for (int i = 0 ; i < mCursor.getCount() ; i++)
{
    Map map = new HashMap<>() ;
    MusicInfo musicInfo = new MusicInfo() ;

    //列表移动
    mCursor.moveToNext() ;

    //将数据装载到List    musicInfo.set_id(mCursor.getInt(0));
    musicInfo.setTitle(mCursor.getString(1));
    musicInfo.setAlbum(mCursor.getString(2));
    musicInfo.setArtist(mCursor.getString(3));
    musicInfo.setDuration(mCursor.getInt(4));
    musicInfo.setMusicName(mCursor.getString(5));
    musicInfo.setSize(mCursor.getInt(6));
    musicInfo.setData(mCursor.getString(7));
      //将数据装载到List>中
    //获取本地音乐专辑图片
    String MusicImage = getAlbumArt(mCursor.getInt(8)) ;
    //判断本地专辑的图片是否为空
    if (MusicImage == null)
    {
        //为空,用默认图片
        map.put("image" , String.valueOf(R.mipmap.timg)) ;
        musicInfo.setAlbum_id(String.valueOf(R.mipmap.timg));
    }else
    {
        //不为空,设定专辑图片为音乐显示的图片
        map.put("image" , MusicImage) ;
        musicInfo.setAlbum_id(MusicImage);
    }
   // musicInfo.setAlbum_id(mCursor.getInt(8));
    musicInfos.add(musicInfo) ;

3 MediaPlayer重要方法:MediaPlayer用来指定音乐文件和播放相关动作,所以我们需要掌握与音乐播放相关的方法。

方法: getCurrentPosition() 

解释:返回 Int, 得到当前播放位置  毫秒单位时间

方法: getDuration() 

解释:返回 Int,得到文件的时间 

方法:isLooping() 

解释:返回 boolean ,是否循环播放

方法:isPlaying() 

解释:返回 boolean,是否正在播放

方法:pause() 

解释:无返回值 ,暂停

方法:release() 

解释:无返回值,释放 MediaPlayer 对象

方法:reset() 

解释:无返回值,重置 MediaPlayer 对象

方法:seekTo(int msec) 

解释:无返回值,指定播放的位置(以毫秒为单位的时间) 

方法:setDataSource(String path) 

解释:无返回值,设置多媒体数据来源【根据 路径】

方法:setLooping(boolean looping) 

解释:无返回值,设置是否循环播放 

方法:start() 

解释:无返回值,开始播放

方法:stop() 

解释:无返回值,停止播放

方法:setVolume(float leftVolume, float rightVolume) 

解释:无返回值,设置音量 

事件:setOnCompletionListener(MediaPlayer.OnCompletionListener listener) 

解释:监听事件,网络流媒体播放结束监听

事件:setOnBufferingUpdateListener(MediaPlayer.OnBufferingUpdateListener listener) 

解释:监听事件,网络流媒体的缓冲监听

事件:setOnErrorListener(MediaPlayer.OnErrorListener listener) 
解释:监听事件,设置错误信息监听

4 歌词处理  说下思路把:首先根据歌曲去内存寻找对应的歌词文件,找到后解码为歌词内容和歌词时间,并且把歌词内容和个时间装载到歌词信息类集合中。然后提供一个外部访问的方法用于向外部输出歌词信息

// 注:LrcContent是歌词处理类 其中包含两个参数,歌词时间和歌词内容
public class LrcProcess {

    //所有需要处理的歌词对象
    private List lrcList;
    //一个歌词对象
    private LrcContent mLrcContent;

    /*
    构造函数完成对象的初始化
     */
    public LrcProcess() {
        lrcList = new ArrayList<>();
    }
     /*
    *从内存中读取歌词文件,并转换为String对象输出
    */
     public String readLrc(String path)
     {
         //用StringBuild来存储歌词内容
         StringBuilder sb = new StringBuilder() ;
         //获取歌词文件  因为传入的文件为MusicInfo类中的Data内容,其文件为mp3,需要更换为lrc文件
         File f = new File(path.replace(".mp3" , ".lrc")) ;
         try{
             //通过文件流对象来获取文件内容并且导入歌词内容对象集合中(lrcList)
             FileInputStream inputStream = new FileInputStream(f) ;
             InputStreamReader streamReader = new InputStreamReader(inputStream , "utf-8") ;
             BufferedReader bufferedReader = new BufferedReader(streamReader) ;
             String tempStr = "" ;
             while((tempStr = bufferedReader.readLine()) != null)
             {
                 //实现字符替换
                 tempStr = tempStr.replace("[" , "") ;
                 tempStr = tempStr.replace("]" , "@") ;

                 //根据@分号对文件分离
                 String[] splitData = tempStr.split("@") ;

               //  System.out.println("THE TEMP STR IS " + splitData[0]) ;

                 if (splitData.length > 1)
                 {
                     //新建歌词内容对象
                     mLrcContent = new LrcContent();
                     //设置歌词文本内容
                     mLrcContent.setLrcStr(splitData[1]);
                     //设置歌词时间
                     int lrcTime = timeToStr(splitData[0]) ;
                     mLrcContent.setLrcTime(lrcTime);
                     //添加到列表
                     lrcList.add(mLrcContent);
                   //  System.out.println("录入歌词成功") ;
                 }else {
                    // System.out.println("录入歌词失败") ;

                 }
             }
         }catch (FileNotFoundException e)
         {
             sb.append("木有歌词文件,赶紧去下载!...");
         } catch (UnsupportedEncodingException e) {
             e.printStackTrace();
             sb.append("木有读取到歌词哦!");
         } catch (IOException e) {
             e.printStackTrace();
             sb.append("木有读取到歌词哦!");
         }
         return sb.toString() ;

     }
     /*
     * 对歌词文件lrc中的时间内容进行转码
     * [00:02.32]陈奕迅   时间分别代表分,秒,毫秒
     * [00:03.43]好久不见
      */
     public int timeToStr(String timeStr)
     {
         timeStr = timeStr.replace(":" , ".") ;
         timeStr = timeStr.replace("." , "@") ;
         String []splitTime = timeStr.split("@") ;

         //分离出分, 秒, 毫秒 
         int minute = Integer.parseInt(splitTime[0]) ;
         int second = Integer.parseInt(splitTime[1]) ;
         int millisSecond = Integer.parseInt(splitTime[2]) ;
         int time = (minute * 60 + second) * 1000 + millisSecond ;
         return time ;
     }
     /*
     提供一个外界方法歌词对象集合的方法
      */
     public  List getLrcList()
     {
         return lrcList ;
     }


}
//实例化歌词处理对象   Activity中调用方法  
  //musicInfosList为封装好的所有音乐信息类,get(position)可以获取当前音乐信息,getData获取音乐路径     
LrcProcess  mLrcProcess = new LrcProcess() ;
mLrcProcess.readLrc(musicInfosList.get(position).getData()) ;

这里再补一个内容:通常经常下载的歌曲都是没有歌词文件的,所以我们需要自己去下载歌词文件,注意该程序中你需要把歌词文件和音乐文件放在一个地方,并且歌词文件和音乐文件前缀相同只是将.mp3改为了.lrc。我用的是网易云音乐,这里顺便推荐一个网易云下载歌词的网址,大神们各种推荐供你选择

知乎:网易云音乐怎么下载歌词?

5 歌曲播放界面

看图分析:

Android 自定义音乐播放器实现_第3张图片

(1)标题动画

Android 自定义音乐播放器实现_第4张图片

这两个TextView的布局代码就不再列出了。动画效果主要是继承了Animation类自定实现。

public class TextAnimation extends Animation {

    private float currentX ;  //指定X
    private float currentY ;  //指定Y
    //定义持续时间
    private int duration ;
    //设定Camera
    private Camera camera = new Camera() ;

    public TextAnimation(float x , float y ,int duration)
    {
        currentX = x ;
        currentY = y ;
        this.duration = duration ;
        System.out.println("THE X IS " + currentX + "\nTHE Y IS " + currentY) ;
    }

    @Override
    public void initialize(int width, int height, int parentWidth, int parentHeight) {
        super.initialize(width, height, parentWidth, parentHeight);

        //设置持续时间
        setDuration(duration);
        //设置动画结束后保留
        setFillAfter(true);
        //设置变换速度
        setInterpolator(new AccelerateDecelerateInterpolator());  //减速
        //setInterpolator(new AccelerateInterpolator());        //加速 默认情况
        //setInterpolator(new LinearInterpolator());          //匀速
    }

    @Override
    protected void applyTransformation(float interpolatedTime, Transformation t) {
        super.applyTransformation(interpolatedTime, t);
        /*
        保存
         */
        camera.save();
        //根据interpolatedTime来控制X Y Z上的偏移
        camera.translate(10.0f - 10.0f * interpolatedTime ,
                30.0f  - 30.0f * interpolatedTime ,
                80.0f - 80.0f * interpolatedTime);
        //根据interpolatedTime在X轴做角度变换
        camera.rotateX(360 * interpolatedTime);
        //根据interpolatedTime在Y轴做角度变换
        camera.rotateY(360 * interpolatedTime);
        //获取Transformation参数封装的matrix对象
        camera.getMatrix(t.getMatrix());
        t.getMatrix().preTranslate(-currentX / 4 , -currentY / 4) ;
        t.getMatrix().postTranslate(currentX , currentY) ;
        /*
        如果存在保存的状态,就恢复
         */
        camera.restore();
    }

}
 initialize(int width, int height, int parentWidth, int parentHeight)中,width和height代表指定播放动画的View空间宽高,parentWidth和parentHeight代表该View控件所在的父控件宽高。可以在该方法中获取View的宽和高,但是我在这里面主要是完成的初始化

applyTransformation()方法是动画具体的实现方法,在系统绘制动画时会反复调用这个方法,每调用一次applyTransformation()方法,其中的interpolatedTime参数都会改变一次,值从0到1递增,当interpolatedTime的值为1时则动画结束。Transformatio类是一个变换的矩阵,通过改变该矩阵就可以实现各种复杂的效果。

PS:更多的相关的动画知识可以阅读官方文档,或者其它博文,书籍,网络视频获取。

Activity调用该动画方法:

/*
获取并且设置音乐的标题和歌手。并添加动画来显示
 */
MusicArtist = (TextView) findViewById(R.id.MusicArtist) ;
MusicName = (TextView) findViewById(R.id.MusicName) ;
MusicName.setText(musicInfosList.get(position).getTitle());
MusicArtist.setText(musicInfosList.get(position).getArtist());
//动画效果
MusicArtist.setAnimation(new TextAnimation(0 , 0 , 2000));
MusicName.setAnimation(new TextAnimation(0 , 0 , 2000));
(2)音乐专辑图片处理



1,圆形图片

 需要用到Shader

 Shader的使用步骤:
1. 构建Shader对象
2. 通过Paint的setShader方法设置渲染对象
3.设置渲染对象
4.绘制时使用这个Paint对象

public class XCRoundImageView extends android.support.v7.widget.AppCompatImageView{
    private Paint mPaintBitmap = new Paint(Paint.ANTI_ALIAS_FLAG);
    private Bitmap mRawBitmap;
    private BitmapShader mShader;
    private Matrix mMatrix = new Matrix();

    public XCRoundImageView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        //获取原生Bitmap位图
        Bitmap rawBitmap = getBitmap(getDrawable());
        if (rawBitmap != null){
            //获取图片宽和高
            int viewWidth = getWidth();
            int viewHeight = getHeight();
            //由于要变换为圆形,在变换过程中取边长相对小的为基准
            int viewMinSize = Math.min(viewWidth, viewHeight);
            float dstWidth = viewMinSize;
            float dstHeight = viewMinSize;
            //如果是第一次绘制
            if (mShader == null || !rawBitmap.equals(mRawBitmap)){
                mRawBitmap = rawBitmap;
                /*
                BitmapShader是Shader的子类,可以通过Paint.setShader(Shader shader)进行设置、
                这里我们只关注BitmapShader,构造方法:
                mBitmapShader = new BitmapShader(bitmap, TileMode.CLAMP, TileMode.CLAMP);
                参数1:bitmap
                参数2,参数3:TileMode;
                TileMode的取值有三种:
                CLAMP 拉伸
                REPEAT 重复
                MIRROR 镜像
                如果大家给电脑屏幕设置屏保的时候,如果图片太小,可以选择重复、拉伸、镜像;
                重复:就是横向、纵向不断重复这个bitmap
                镜像:横向不断翻转重复,纵向不断翻转重复;
                拉伸:这个和电脑屏保的模式应该有些不同,这个拉伸的是图片最后的那一个像素;横向的最后一个横行像素,不断的重复,纵项的那一列像素,不断的重复;
                */
                mShader = new BitmapShader(mRawBitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
            }
            if (mShader != null){
                /*
                 void setLocalMatrix(Matrix localM);
                 设置shader的本地矩阵,如果localM为空将重置shader的本地矩阵。
                 */
                mMatrix.setScale(dstWidth / rawBitmap.getWidth(), dstHeight / rawBitmap.getHeight());

                mShader.setLocalMatrix(mMatrix);
            }
            mPaintBitmap.setShader(mShader);
            float radius = viewMinSize / 2.0f;
            canvas.drawCircle(radius, radius, radius, mPaintBitmap);
        } else {
            super.onDraw(canvas);
        }
    }

    private Bitmap getBitmap(Drawable drawable){
        if (drawable instanceof BitmapDrawable){
            return ((BitmapDrawable)drawable).getBitmap();
        } else if (drawable instanceof ColorDrawable){
            Rect rect = drawable.getBounds();
            int width = rect.right - rect.left;
            int height = rect.bottom - rect.top;
            int color = ((ColorDrawable)drawable).getColor();
            Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
            Canvas canvas = new Canvas(bitmap);
            canvas.drawARGB(Color.alpha(color), Color.red(color), Color.green(color), Color.blue(color));
            return bitmap;
        } else {
            return null;
        }
    }
}

PS:作者当时也是搬运的,只是补充了部分注释。内容可能有点复杂,需要多实践,建议参考资料文档

自定义控件之 圆形 / 圆角 ImageView

自定义控件三部曲之绘图篇(十八)——BitmapShader与望远镜效果

2,图片旋转

首先定义旋转动画 :image_rotate.xml




    

然后在Java业务代码中设定定时器来启动动画

/*
该Timer用于实现:音乐播放界面图片旋转动画
 */
MyHandle2 handle2 = new MyHandle2() ;
new Timer().schedule(new TimerTask() {
    @Override
    public void run() {
        handle2.sendEmptyMessage(0x112) ;
    }
}, 0 ,8000);

最后再Handle中接收消息,并且启动动画

public class MyHandle2 extends  Handler
{
    @Override
    public void handleMessage(Message msg) {
        if ((msg.what == 0x112))
        {
            //设置图片旋转
            MusicImage.setAnimation(AnimationUtils.loadAnimation(MusicPlay.this , R.anim.image_rotate));

        }
    }
}



五:下载地址github

https://github.com/547291213/MusicPlayer


六:参考资料

  •  Android音乐播放器(一):搜索手机存储的音乐
  •  Android 6.0 运行时权限管理最佳实践
  •  android 完美获取音乐文件中的专辑图片并显示
  •  Android开发笔记(一百二十六)自定义音乐播放器
  •  Android应用开发--MP3音乐播放器滚动歌词实现
  •  Android实现音乐示波器、均衡器、重低音和音场功能
  •  自定义控件之 圆形 / 圆角 ImageView 
  • 【Android】Service前台服务的使用
  •  官方开发文档

七:总结

第一次写这么长文的博客,也没想过邀功。不过真心希望您再阅读了这篇博文,并且有所建议和收获后留下您宝贵的评论。谢谢。

PS:转载请注明 https://blog.csdn.net/qq_29989087/article/details/80206290





你可能感兴趣的:(android)