高仿Android网易云音乐OkHttp+Retrofit+RxJava+Glide+MVC+MVVM

Posted 沈页

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了高仿Android网易云音乐OkHttp+Retrofit+RxJava+Glide+MVC+MVVM相关的知识,希望对你有一定的参考价值。

简介

这是一个使用Java(以后还会推出Kotlin版本)语言,从0开发一个android平台,接近企业级的项目(我的云音乐),包含了基础内容,高级内容,项目封装,项目重构等知识;主要是使用系统功能,流行的第三方框架,第三方服务,完成接近企业级商业级项目。

功能点

隐私协议对话框 启动界面和动态处理权限 引导界面和广告 轮播图和侧滑菜单 首页复杂列表和列表排序 音乐播放和音乐列表管理 全局音乐控制条 桌面歌词和自定义样式 全局媒体控制中心 评论和回复评论 评论富文本点击 评论提醒人和话题 朋友圈动态列表和发布 高德地图定位和路径规划 阿里云OSS上传 视频播放和控制 QQ/微信登录和分享 商城/购物车\\微信\\支付宝支付 文本和图片聊天 消息离线推送 自动和手动检查更新 内存泄漏和优化 …

开发环境概述

2022年5月开发完成的,所以全部都是最新的,平均每3年会重新制作,现在已经是第三版了。

JDK17
Android 12/13
最低兼容版本:Android 6.0
Android Studio 2021.1

编译和运行

用最新AS打开MyCloudMusicAndroidJava目录,然后等待完全编译成功,因为是企业级项目,所以第三方依赖很多,同时代码量也很多,所以必须要确认完全编译成功,才能运行。

项目目录结构

├── MyCloudMusicAndroidJava
│   ├── LRecyclerview //第三方Recyclerview框架
│   ├── LetterIndexView //类似微信通讯录字母索引
│   ├── app //云音乐项目
│   ├── build.gradle
│   ├── common.gradle //通用项目配置文件
│   ├── config //配置目录,例如签名
│   ├── glidepalette //Glide画板,用来从网络图片提取颜色
│   ├── gradle
│   ├── gradle.properties
│   ├── gradlew
│   ├── gradlew.bat
│   ├── keystore.properties
│   ├── local.properties
│   ├── settings.gradle
│   ├── super-j //公用Java语言扩展
│   ├── super-player-tencent //腾讯开源的超级播放器
│   ├── super-speech-baidu //百度语音识别

依赖框架

内容太多,只列出部分。

//分页组件版本
//这里可以查看最新版本:https://developer.android.google.cn/jetpack/androidx/releases/paging
def paging_version = "3.1.1"

//添加所有libs目录里面的jar,aar
implementation fileTree(dir: 'libs', include: ['*.jar','*.aar'])

//官方兼容组件,像AppCompatActivity就是该依赖里面的
implementation 'androidx.appcompat:appcompat:1.4.1'

//Material Design组件,像FloatingActionButton就是该依赖里面的
implementation 'com.google.android.material:material:1.4.0'

//官方提供的约束布局,像ConstraintLayout就是该依赖里面的
implementation 'androidx.constraintlayout:constraintlayout:2.1.0'

//UI框架,主要是用他的工具类,也可以单独拷贝出来
//https://qmuiteam.com/android/get-started
implementation 'com.qmuiteam:qmui:2.0.1'

//动态处理权限
//https://github.com/permissions-dispatcher/PermissionsDispatcher
implementation "com.github.permissions-dispatcher:permissionsdispatcher:4.8.0"
annotationProcessor "com.github.permissions-dispatcher:permissionsdispatcher-processor:4.8.0"

//api:依赖会传递到其他应用本模块的项目
implementation project(path: ':super-j')
...

//使用gson解析json
//https://github.com/google/gson
implementation 'com.google.code.gson:gson:2.9.0'

//自动释放RxJava相关资源
//https://github.com/uber/AutoDispose
implementation "com.uber.autodispose2:autodispose-androidx-lifecycle:2.1.1"

//banner轮播图框架
//https://github.com/youth5201314/banner
implementation 'io.github.youth5201314:banner:2.2.2'

//图片加载框架,还引用他目的是,coil有些功能不好实现
//https://github.com/bumptech/glide
implementation 'com.github.bumptech.glide:glide:+'
annotationProcessor 'com.github.bumptech.glide:compiler:+'

implementation 'androidx.recyclerview:recyclerview:1.2.1'

//给控件添加未读消息数红点
//https://github.com/bingoogolapple/BGABadgeView-Android
implementation 'com.github.bingoogolapple.BGABadgeView-Android:api:1.2.0'
annotationProcessor 'com.github.bingoogolapple.BGABadgeView-Android:compiler:1.2.0'

//webview进度条
//https://github.com/youlookwhat/WebProgress
implementation 'com.github.youlookwhat:WebProgress:1.2.0'

//日志框架
//https://github.com/JakeWharton/timber
implementation 'com.jakewharton.timber:timber:5.0.1'

implementation "androidx.media:media:+"

//和Glide配合处理图片
//可以实现很多效果
//模糊;圆角;圆
//我们这里是用它实现模糊效果
//https://github.com/wasabeef/glide-transformations
implementation 'jp.wasabeef:glide-transformations:+'

//圆形图片控件
//https://github.com/hdodenhof/CircleImageView
implementation 'de.hdodenhof:circleimageview:+'

//下载框架
//https://github.com/ixuea/android-downloader
implementation 'com.ixuea:android-downloader:3.0.0'

//阿里云oss
//官方文档:https://help.aliyun.com/document_detail/32043.html
//sdk地址:https://github.com/aliyun/aliyun-oss-android-sdk
implementation 'com.aliyun.dpa:oss-android-sdk:+'

//高德地图,这里引用的是3d
//https://lbs.amap.com/api/android-sdk/guide/create-project/android-studio-create-project#gradle_sdk
implementation 'com.amap.api:3dmap:+'

//定位功能
implementation 'com.amap.api:location:+'

//百度语音相关技术,目前主要用在收货地址编辑界面,语音输入收货地址
//https://ai.baidu.com/ai-doc/SPEECH/Pkgt4wwdx#%E9%9B%86%E6%88%90%E6%8C%87%E5%8D%97
implementation project(path: ':super-speech-baidu')

//TextView显示富文本,目前主要用在商品详情界面,显示富文本商品描述
//https://github.com/wangchenyan/html-text
implementation 'com.github.wangchenyan:html-text:+'

//Hutool是一个小而全的Java工具类库
// 通过静态方法封装,降低相关API的学习成本
// 提高工作效率,使Java拥有函数式语言般的优雅
//https://github.com/looly/hutool
implementation 'cn.hutool:hutool-all:5.7.14'

//支付宝支付
//https://opendocs.alipay.com/open/204/105296
implementation 'com.alipay.sdk:alipaysdk-android:+@aar'

//融云IM
//https://docs.rongcloud.cn/v4/5X/views/im/ui/guide/quick/include/android.html
implementation 'cn.rongcloud.sdk:im_lib:+'

//微信支付
//官方sdk下载文档:https://developers.weixin.qq.com/doc/oplatform/Downloads/Android_Resource.html
//官方集成文档:https://pay.weixin.qq.com/wiki/doc/api/app/app.php?chapter=8_5
implementation 'com.tencent.mm.opensdk:wechat-sdk-android:+'

//内存泄漏检测工具
//https://github.com/square/leakcanary
//只有调试模式下才添加该依赖
debugImplementation 'com.squareup.leakcanary:leakcanary-android:+'

testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'

用户协议对话框

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Z7ywIETc-1657286593196)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/67786b295c154ad0abc9d4611ba69f74~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image)]

使用自定义DialogFragment实现,内容是放到字符串文件中的,其中的链接是HTML标签,设置后就可以点击了,然后修改默认对话框宽度,因为默认的有点窄。

public class TermServiceDialogFragment extends BaseViewModelDialogFragment<FragmentDialogTermServiceBinding> 

    ...

    @Override
    protected void initViews() 
        super.initViews();
        //点击弹窗外边不能关闭
        setCancelable(false);

        SuperTextUtil.setLinkColor(binding.content, getActivity().getColor(R.color.link));
    

    @Override
    protected void initListeners() 
        super.initListeners();
        binding.primary.setOnClickListener(view -> 
            dismiss();
            onAgreementClickListener.onClick(view);
        );

        binding.disagree.setOnClickListener(view -> 
            dismiss();
            SuperProcessUtil.killApp();
        );
    

    @Override
    public void onResume() 
        super.onResume();
        //修改宽度,默认比AlertDialog.Builder显示对话框宽度窄,看着不好看
        //参考:https://stackoverflow.com/questions/12478520/how-to-set-dialogfragments-width-and-height
        ViewGroup.LayoutParams params = getDialog().getWindow().getAttributes();

        params.width = (int) (ScreenUtil.getScreenWith(getContext()) * 0.9);
        params.height = ViewGroup.LayoutParams.WRAP_CONTENT;
        getDialog().getWindow().setAttributes((android.view.WindowManager.LayoutParams) params);
    


动态权限

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CDyE5IeO-1657286593205)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e7e3674075bd44ec9414469c4c8aa3fe~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image)]

高版本必须要动态处理权限,这里在启动界面请求了一些权限,但推荐在用到的时候才获取,写法差不多,这里使用第三方框架实现,当然也可以直接使用系统API实现。

/**
 * 权限授权了就会调用该方法
 * 请求相机权限目的是扫描二维码,拍照
 */
@NeedsPermission(
        Manifest.permission.CAMERA,
        Manifest.permission.READ_EXTERNAL_STORAGE,
        Manifest.permission.WRITE_EXTERNAL_STORAGE,
        Manifest.permission.ACCESS_COARSE_LOCATION,
        Manifest.permission.ACCESS_FINE_LOCATION
)
void onPermissionGranted() 
    //如果有权限就进入下一步
    prepareNext();


/**
 * 显示权限授权对话框
 * 目的是提示用户
 */
@OnShowRationale(
        Manifest.permission.CAMERA,
        Manifest.permission.READ_EXTERNAL_STORAGE,
        Manifest.permission.WRITE_EXTERNAL_STORAGE,
        Manifest.permission.ACCESS_COARSE_LOCATION,
        Manifest.permission.ACCESS_FINE_LOCATION
)
void showRequestPermission(PermissionRequest request) 
    new AlertDialog.Builder(getHostActivity())
            .setMessage(R.string.permission_hint)
            .setPositiveButton(R.string.allow, (dialog, which) -> request.proceed())
            .setNegativeButton(R.string.deny, (dialog, which) -> request.cancel()).show();


/**
 * 拒绝了权限调用
 */
@OnPermissionDenied(
        Manifest.permission.CAMERA,
        Manifest.permission.READ_EXTERNAL_STORAGE,
        Manifest.permission.WRITE_EXTERNAL_STORAGE,
        Manifest.permission.ACCESS_COARSE_LOCATION,
        Manifest.permission.ACCESS_FINE_LOCATION
)
void showDenied() 
    //退出应用
    finish();


/**
 * 再次获取权限的提示
 */
@OnNeverAskAgain(
        Manifest.permission.CAMERA,
        Manifest.permission.READ_EXTERNAL_STORAGE,
        Manifest.permission.WRITE_EXTERNAL_STORAGE,
        Manifest.permission.ACCESS_COARSE_LOCATION,
        Manifest.permission.ACCESS_FINE_LOCATION
)
void showNeverAsk() 
    //继续请求权限
    checkPermission();


/**
 * 授权后回调
 *
 * @param requestCode
 * @param permissions
 * @param grantResults
 */
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) 
    super.onRequestPermissionsResult(requestCode, permissions, grantResults);
    //将授权结果传递到框架
    SplashActivityPermissionsDispatcher.onRequestPermissionsResult(this, requestCode, grantResults);


引导界面

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-N8360HrL-1657286593206)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/7a74304a8fe442ff9171bd51742449a5~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image)] 引导界面比较简单,就是多个图片可以左右滚动,整体使用ViewPager+Fragment实现,也可以使用ViewPager2,后面有讲解。

/**
 * 引导界面适配器
 */
public class GuideAdapter extends BaseFragmentStatePagerAdapter<Integer> 

    /***
     *  @param context 上下文
     * @param fm Fragment管理器
     */
    public GuideAdapter(Context context, @NonNull FragmentManager fm) 
        super(context, fm);
    

    /**
     * 返回当前位置Fragment
     *
     * @param position
     * @return
     */
    @NonNull
    @Override
    public Fragment getItem(int position) 
        return GuideFragment.newInstance(getData(position));
    


/**
 * 引导界面Fragment
 */
public class GuideFragment extends BaseViewModelFragment<FragmentGuideBinding> 
    ...

    @Override
    protected void initDatum() 
        super.initDatum();
        int data = getArguments().getInt(Constant.ID);
        binding.icon.setImageResource(data);
    


广告界面

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3QvDjl18-1657286593208)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4f9a5637953c4a01af7966bcb4b7f9d0tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image)][外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QSDItqz6-1657286593210)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/44a7a58ffa4644b7a19ef64fd2eee217tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image)]

实现图片广告和视频广告,广告数据是在首页是缓存到本地,目的是在启动界面加载更快,因为真实项目中,大部分项目启动页面广告时间一共就5秒,如果太长了用户体验不好,如果是从网络请求,那么网络可能就耗时2秒左右,所以导致就美哟多少时间显示广告了。

下载广告

private void downloadAd(Ad data) 
    if (SuperNetworkUtil.isWifiConnected(getHostActivity())) 
        //wifi才下载
        sp.setSplashAd(data);

        //判断文件是否存在,如果存在就不下载
        File targetFile = FileUtil.adFile(getHostActivity(), data.getIcon());
        if (targetFile.exists()) 
            return;
        

        new Thread(
                new Runnable() 
                    @Override
                    public void run() 

                        try 
                            //FutureTarget会阻塞
                            //所以需要在子线程调用
                            FutureTarget<File> target = Glide.with(getHostActivity().getApplicationContext())
                                    .asFile()
                                    .load(ResourceUtil.resourceUri(data.getIcon()))
                                    .submit();

                            //获取下载的文件
                            File file = target.get();

                            //将文件拷贝到我们需要的位置
                            FileUtils.moveFile(file, targetFile);

                         catch (Exception e) 
                            e.printStackTrace();
                        
                    
                
        ).start();
    


显示广告

/**
 * 显示视频广告
 *
 * @param data
 */
private void showVideoAd(File data) 
    SuperViewUtil.show(binding.video);
    SuperViewUtil.show(binding.preload);

    //在要用到的时候在初始化,更节省资源,当然播放器控件也可以在这里动态创建
    //设置播放监听器

    //创建 player 对象
    player = new TXVodPlayer(getHostActivity());

    //静音,当然也可以在界面上添加静音切换按钮
    player.setMute(true);

    //关键 player 对象与界面 view
    player.setPlayerView(binding.video);

    //设置播放监听器
    player.setVodListener(this);

    //铺满
    binding.video.setRenderMode(TXLiveConstants.RENDER_MODE_FULL_FILL_SCREEN);

    //开启硬件加速
    player.enableHardwareDecode(true);

    player.startPlay(data.getAbsolutePath());


显示图片就是显示本地图片了,没什么难点,就不贴代码了。

首页/歌单详情/黑胶唱片界面

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2kAzlyMd-1657286593212)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/6d3e0f5810244a18a57f0461b7fb3b0a~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image)]

首页没有顶部是轮播图,然后是可以左右的菜单,接下来是热门歌单,推荐单曲,最后是首页排序模块;整体上使用RecycerView实现,轮播图:

Banner bannerView = holder.getView(R.id.banner);

BannerImageAdapter<Ad> bannerImageAdapter = new BannerImageAdapter<Ad>(data.getData()) 

    @Override
    public void onBindView(BannerImageHolder holder, Ad data, int position, int size) 
        ImageUtil.show(getContext(), (ImageView) holder.itemView, data.getIcon());
    
;

bannerView.setAdapter(bannerImageAdapter);

bannerView.setOnBannerListener(onBannerListener);

bannerView.setBannerRound(DensityUtil.dip2px(getContext(), 10));

//添加生命周期观察者
bannerView.addBannerLifecycleObserver(fragment);

bannerView.setIndicator(new CircleIndicator(getContext()));

推荐歌单

//设置标题,将标题放到每个具体的item上,好处是方便整体排序
holder.setText(R.id.title, R.string.recommend_sheet);

//显示更多容器
holder.setVisible(R.id.more, true);
holder.getView(R.id.more).setOnClickListener(v -> 

);

RecyclerView listView = holder.getView(R.id.list);
if (listView.getAdapter() == null) 
    //设置显示3列
    GridLayoutManager layoutManager = new GridLayoutManager(listView.getContext(), 3);
    listView.setLayoutManager(layoutManager);

    sheetAdapter = new SheetAdapter(R.layout.item_sheet);

    //item点击
    sheetAdapter.setOnItemClickListener(new OnItemClickListener() 
        @Override
        public void onItemClick(@NonNull BaseQuickAdapter<?, ?> adapter, @NonNull View view, int position) 
            if (discoveryAdapterListener != null) 
                discoveryAdapterListener.onSheetClick((Sheet) adapter.getItem(position));
            
        
    );
    listView.setAdapter(sheetAdapter);

    GridDividerItemDecoration itemDecoration = new GridDividerItemDecoration(getContext(), (int) DensityUtil.dip2px(getContext(), 5F));
    listView.addItemDecoration(itemDecoration);


sheetAdapter.setNewInstance(data.getData());

歌单详情

顶部是歌单信息,通过header实现,底部是列表,显示歌单内容的音乐,点击音乐进入黑胶唱片播放界面。

//添加头部
adapter.addHeaderView(createHeaderView());

/**
 * 显示数据的方法
 *
 * @param holder
 * @param data
 */
@Override
protected void convert(@NonNull BaseViewHolder holder, Song data) 
    //显示位置
    holder.setText(R.id.index, String.valueOf(holder.getLayoutPosition() + offset));

    //显示标题
    holder.setText(R.id.title, data.getTitle());

    //显示信息
    holder.setText(R.id.info, data.getSinger().getNickname());

    if (offset != 0) 
        holder.setImageResource(R.id.more, R.drawable.close);

        holder.getView(R.id.more)
                .setOnClickListener(new View.OnClickListener() 
                    @Override
                    public void onClick(View v) 
                        SuperDialog.newInstance(fragmentManager)
                                .setTitleRes(R.string.confirm_delete)
                                .setOnClickListener(new View.OnClickListener() 
                                    @Override
                                    public void onClick(View v) 
                                        //查询下载任务
                                        DownloadInfo downloadInfo = AppContext.getInstance().getDownloadManager().getDownloadById(data.getId());

                                        if (downloadInfo != null) 
                                            //从下载框架删除
                                            AppContext.getInstance().getDownloadManager().remove(downloadInfo);
                                         else 
                                            AppContext.getInstance().getOrm().deleteSong(data);
                                        

                                        //从适配器中删除
                                        removeAt(holder.getAdapterPosition());

                                    
                                ).show();
                    
                );
     else 
        //是否下载
        DownloadInfo downloadInfo = AppContext.getInstance().getDownloadManager().getDownloadById(data.getId());
        if (downloadInfo != null && downloadInfo.getStatus() == DownloadInfo.STATUS_COMPLETED) 
            //下载完成了

            //显示下载完成了图标
            holder.setGone(R.id.download, false);
         else 
            holder.setGone(R.id.download, true);
        
    

    //处理编辑状态
    if (isEditing()) 
        holder.setVisible(R.id.index, false);
        holder.setVisible(R.id.check, true);
        holder.setVisible(R.id.more, false);

        if (isSelected(holder.getLayoutPosition())) 
            holder.setImageResource(R.id.check, R.drawable.ic_checkbox_selected);
         else 
            holder.setImageResource(R.id.check, R.drawable.ic_checkbox);
        
     else 
        holder.setVisible(R.id.index, true);
        holder.setVisible(R.id.check, false);
        holder.setVisible(R.id.more, true);
    



黑胶唱片

上面是黑胶唱片,和网易云音乐差不多,随着音乐滚动或暂停,顶部是控制相关,音乐播放逻辑是封装到MusicPlayerManager中:

/**
 * 播放管理器默认实现
 */
public class MusicPlayerManagerImpl implements MusicPlayerManager, MediaPlayer.OnCompletionListener, AudioManager.OnAudioFocusChangeListener 
    ...
    
    /**
     * 获取播放管理器
     * getInstance:方法名可以随便取
     * 只是在Java这边大部分项目都取这个名字
     *
     * @return
     */
    public synchronized static MusicPlayerManager getInstance(Context context) 
        if (instance == null) 
            instance = new MusicPlayerManagerImpl(context);
        
        return instance;
    

    @Override
    public void play(String uri, Song data) 
        //保存信息
        this.uri = uri;
        this.data = data;

        //释放播放器
        player.reset();

        //获取音频焦点
        if (!requestAudioFocus()) 
            return;
        

        playNow();
    

    private void playNow() 
        isPrepare = true;

        try 
            if (uri.startsWith("content://")) 
                //内容提供者格式

                //本地音乐
                //uri示例:content://media/external/audio/media/23
                player.setDataSource(context, Uri.parse(uri));
             else 
                //设置数据源
                player.setDataSource(uri);
            

            //同步准备
            //真实项目中可能会使用异步
            //因为如果网络不好
            //同步可能会卡住
            player.prepare();
//            player.prepareAsync();

            //开始播放器
            player.start();

            //回调监听器
            publishPlayingStatus();

            //启动播放进度通知
            startPublishProgress();

            prepareLyric(data);
         catch (IOException e) 
            //TODO 播放错误处理
        

    


    @Override
    public void pause() 
        if (isPlaying()) 
            //如果在播放就暂停
            player.pause();

            ListUtil.eachListener(listeners, musicPlayerListener -> musicPlayerListener.onPaused(data));

            stopPublishProgress();
        
    

    @Override
    public void resume() 
        if (!isPlaying()) 
            //获取音频焦点
            if (!requestAudioFocus()) 
                return;
            

            resumeNow();
        
    

    private void resumeNow() 
        //如果没有播放就播放
        player.start();

        //回调监听器
        publishPlayingStatus();

        //启动进度通知
        startPublishProgress();
    

    @Override
    public void addMusicPlayerListener(MusicPlayerListener listener) 
        if (!listeners.contains(listener)) 
            listeners.add(listener);
        

        //启动进度通知
        startPublishProgress();
    

    @Override
    public void removeMusicPlayerListener(MusicPlayerListener listener) 
        listeners.remove(listener);
    

    @Override
    public void seekTo(int progress) 
        player.seekTo(progress);
    

    /**
     * 发布播放中状态
     */
    private void publishPlayingStatus() 
//        for (MusicPlayerListener listener : listeners) 
//            listener.onPlaying(data);
//        

        //使用重构后的方法
        ListUtil.eachListener(listeners, musicPlayerListener -> musicPlayerListener.onPlaying(data));
    

    /**
     * 播放完毕了回调
     *
     * @param mp
     */
    @Override
    public void onCompletion(MediaPlayer mp) 
        isPrepare = false;

        //回调监听器
        ListUtil.eachListener(listeners, listener -> listener.onCompletion(mp));
    

    @Override
    public void setLooping(boolean looping) 
        player.setLooping(looping);
    

    /**
     * 音频焦点改变了回调
     *
     * @param focusChange
     */
    @Override
    public void onAudioFocusChange(int focusChange) 
        Timber.d("onAudioFocusChange %s", focusChange);

        switch (focusChange) 
            case AudioManager.AUDIOFOCUS_GAIN:
                //获取到焦点了
                if (resumeOnFocusGain) 
                    if (isPrepare) 
                        resumeNow();
                     else 
                        playNow();
                    

                    resumeOnFocusGain = false;
                
                break;
            case AudioManager.AUDIOFOCUS_LOSS:
                //永久失去焦点,例如:其他应用请求时,也是播放音乐
                if (isPlaying()) 
                    pause();
                
                break;
            case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
            case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
                //暂时性失去焦点,例如:通话了,或者呼叫了语音助手等请求
                if (isPlaying()) 
                    resumeOnFocusGain = true;
                    pause();
                
                break;
        
    


音乐列表逻辑封装到MusicListManager:

public class MusicListManagerImpl implements MusicListManager, MusicPlayerListener 

    @Override
    public void setDatum(List<Song> datum) 
        //将原来数据playList标志设置为false
        DataUtil.changePlayListFlag(this.datum, false);

        //保存到数据库
        saveAll();

        //清空原来的数据
        this.datum.clear();

        //添加新的数据
        this.datum.addAll(datum);

        //更改播放列表标志
        DataUtil.changePlayListFlag(this.datum, true);

        //保存到数据库
        saveAll();

        sendPlayListChangedEvent(0);
    

    /**
     * 保存播放列表
     */
    private void saveAll() 
        getOrm().saveAll(datum);
    

    private LiteORMUtil getOrm() 
        return LiteORMUtil.getInstance(this.context);
    

    @Override
    public void play(Song data) 
        //当前音乐黑胶唱片滚动
        data.setRotate(true);

        //标记已经播放了
        isPlay = true;

        //保存数据
        this.data = data;

        if (StringUtils.isNotBlank(data.getPath())) 
            //本地音乐
            //不拼接地址
            musicPlayerManager.play(data.getPath(), data);
         else 
            //判断是否有下载对象
            DownloadInfo downloadInfo = AppContext.getInstance().getDownloadManager().getDownloadById(data.getId());
            if (downloadInfo != null && downloadInfo.getStatus() == DownloadInfo.STATUS_COMPLETED) 
                //下载完成了

                //播放本地音乐
                musicPlayerManager.play(downloadInfo.getPath(), data);
                Timber.d("play offline %s %s %s", data.getTitle(), downloadInfo.getPath(), data.getUri());
             else 
                //播放在线音乐
                String path = ResourceUtil.resourceUri(data.getUri());

                musicPlayerManager.play(path, data);

                Timber.d("play online %s %s", data.getTitle(), path);
            
        

        //设置最后播放音乐的Id
        sp.setLastPlaySongId(data.getId());
    

    @Override
    public void pause() 
        musicPlayerManager.pause();
    

    @Override
    public Song next() 
        if (datum.size() == 0) 
            //如果没有音乐了
            //直接返回null
            return null;
        

        //音乐索引
        int index = 0;

        //判断循环模式
        switch (model) 
            case MODEL_LOOP_RANDOM:
                //随机循环

                //在0~datum.size()中
                //不包含datum.size()
                index = new Random().nextInt(datum.size());
                break;
            default:
                //找到当前音乐索引
                index = datum.indexOf(data);

                if (index != -1) 
                    //找到了

                    //如果当前播放是列表最后一个
                    if (index == datum.size() - 1) 
                        //最后一首音乐

                        //那就从0开始播放
                        index = 0;
                     else 
                        index++;
                    
                 else 
                    //抛出异常
                    //因为正常情况下是能找到的
                    throw new IllegalArgumentException("Cant'found current song");
                
                break;
        

        return datum.get(index);
    

    @Override
    public void delete(int position) 
        //获取要删除的音乐
        Song song = datum.get(position);

        if (song.getId().equals(data.getId())) 
            //删除的音乐就是当前播放的音乐

            //应该停止当前播放
            pause();

            //并播放下一首音乐
            Song next = next();

            if (next.getId().equals(data.getId())) 
                //找到了自己
                //没有歌曲可以播放了
                data = null;
                //TODO Bug 随机循环的情况下有可能获取到自己
             else 
                play(next);
            
        

        //直接删除
        datum.remove(song);

        //从数据库中删除
        getOrm().deleteSong(song);

        sendPlayListChangedEvent(position);
    

    private void sendPlayListChangedEvent(int position) 
        EventBus.getDefault().post(new MusicPlayListChangedEvent(position));
    

    /**
     * 播放完毕了回调
     *
     * @param mp
     */
    @Override
    public void onCompletion(MediaPlayer mp) 
        if (model == MODEL_LOOP_ONE) 
            //如果是单曲循环
            //就不会处理了
            //因为我们使用了MediaPlayer的循环模式

            //如果使用的第三方框架
            //如果没有循环模式
            //那就要在这里继续播放当前音乐
         else 
            Song data = next();
            if (data != null) 
                play(data);
            
        
    

   ...


外界统一使用播放列表管理器播放音乐,上一曲下一曲:

//播放按钮点击
binding.play.setOnClickListener(v -> 
    playOrPause();
);

//下一曲按钮点击
binding.next.setOnClickListener(v -> 
    getMusicListManager().play(getMusicListManager().next());
);

//播放列表按钮点击
binding.listButton.setOnClickListener(v -> 
    MusicPlayListDialogFragment.show(getSupportFragmentManager());
);

媒体控制器/桌面歌词/桌面Widget

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vLf3q1Fi-1657286593213)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/bb9df0e9692341b0a9b24ebf732feb50~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image)] 歌词实现了LRC,KSC两种歌词,封装到LyricListView,单个歌词行封装到LyricView中,外界直接使用LyricListView就行:

private void showLyricData() 
    binding.lyricList.setData(getMusicListManager().getData().getParsedLyric());


桌面歌词使用两个LyricView显示两行歌词,桌面歌词使用的是全局悬浮窗API,所以要先判断是否有权限,没有需要先获取权限,然后才能显示,封装到GlobalLyricManagerImpl中:

/**
 * 全局(桌面)歌词管理器实现
 */
public class GlobalLyricManagerImpl implements GlobalLyricManager, MusicPlayerListener, GlobalLyricView.OnGlobalLyricDragListener, GlobalLyricView.GlobalLyricListener 
    public GlobalLyricManagerImpl(Context context) 
        this.context = context.getApplicationContext();

        //初始化偏好设置工具类
        sp = PreferenceUtil.getInstance(this.context);

        //初始化音乐播放管理器
        musicPlayerManager = MusicPlayerService.getMusicPlayerManager(this.context);

        //添加播放监听器
        musicPlayerManager.addMusicPlayerListener(this);

        //初始化窗口管理器
        initWindowManager();

        //从偏好设置中获取是否要显示全局歌词
        if (sp.isShowGlobalLyric()) 
            //创建全局歌词View
            initGlobalLyricView();

            //如果原来锁定了歌词
            if (sp.isGlobalLyricLock()) 
                //锁定歌词
                lock();
            
        
    

    public synchronized static GlobalLyricManagerImpl getInstance(Context context) 
        if (instance == null) 
            instance = new GlobalLyricManagerImpl(context);
        
        return instance;
    

    /**
     * 锁定全局歌词
     */
    private void lock() 
        //保存全局歌词锁定状态
        sp.setGlobalLyricLock(true);

        //设置全局歌词控件状态
        setGlobalLyricStatus();

        //显示简单模式
        globalLyricView.simpleStyle();

        //更新布局
        updateView();

        //显示解锁全局歌词通知
        NotificationUtil.showUnlockGlobalLyricNotification(context);

        //注册接收解锁全局歌词广告接收器
        registerUnlockGlobalLyricReceiver();
    

    /**
     * 注册接收解锁全局歌词广告接收器
     */
    private void registerUnlockGlobalLyricReceiver() 
        if (unlockGlobalLyricBroadcastReceiver == null) 
            //创建广播接受者
            unlockGlobalLyricBroadcastReceiver = new BroadcastReceiver() 

                @Override
                public void onReceive(Context context, Intent intent) 
                    if (Constant.ACTION_UNLOCK_LYRIC.equals(intent.getAction())) 
                        //歌词解锁事件
                        unlock();
                    
                
            ;

            IntentFilter intentFilter = new IntentFilter();

            //只监听歌词解锁事件
            intentFilter.addAction(Constant.ACTION_UNLOCK_LYRIC);

            //注册
            context.registerReceiver(unlockGlobalLyricBroadcastReceiver, intentFilter);
        
    

    /**
     * 解锁歌词
     */
    private void unlock() 
        //设置没有锁定歌词
        sp.setGlobalLyricLock(false);

        //设置歌词状态
        setGlobalLyricStatus();

        //解锁后显示标准样式
        globalLyricView.normalStyle();

        //更新view
        updateView();

        //清除歌词解锁通知
        NotificationUtil.clearUnlockGlobalLyricNotification(context);

        //解除接收全局歌词事件广播接受者
        unregisterUnlockGlobalLyricReceiver();
    

    /**
     * 解除接收全局歌词事件广播接受者
     */
    private void unregisterUnlockGlobalLyricReceiver() 
        if (unlockGlobalLyricBroadcastReceiver != null) 
            context.unregisterReceiver(unlockGlobalLyricBroadcastReceiver);
            unlockGlobalLyricBroadcastReceiver = null;
        
    

    @Override
    public void show() 
        //检查全局悬浮窗权限
        if (!Settings.canDrawOverlays(context)) 
            Intent intent = new Intent(context, SplashActivity.class);
            intent.setAction(Constant.ACTION_LYRIC);
            intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            context.startActivity(intent);
            return;
        

        //初始化全局歌词控件
        initGlobalLyricView();

        //设置显示了全局歌词
        sp.setShowGlobalLyric(true);

        WidgetUtil.onGlobalLyricShowStatusChanged(context, isShowing());
    

    private boolean hasGlobalLyricView() 
        return globalLyricView != null;
    

    /**
     * 全局歌词拖拽回调
     *
     * @param y y轴方向上移动的距离
     */
    @Override
    public void onGlobalLyricDrag(int y) 
        layoutParams.y = y - SizeUtil.getStatusBarHeight(context);

        //更新view
        updateView();

        //保存歌词y坐标
        sp.setGlobalLyricViewY(layoutParams.y);
    

    
    ...


显示和隐藏只需要调用该管理器的相关方法就行了。

媒体控制器

使用了可以通过系统媒体控制器,通知栏,锁屏界面,耳机,蓝牙耳机等设备控制媒体播放暂停,只需要把媒体信息更新到系统:

MusicPlayerService

/**
 * 更新媒体信息
 *
 * @param data
 * @param icon
 */
public void updateMetaData(Song data, Bitmap icon) 
    MediaMetadataCompat.Builder metaData = new MediaMetadataCompat.Builder()
            //标题
            .putString(MediaMetadataCompat.METADATA_KEY_TITLE, data.getTitle())

            //艺术家,也就是歌手
            .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, data.getSinger().getNickname())

            //专辑
            .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "专辑")

            //专辑艺术家
            .putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, "专辑艺术家")

            //时长
            .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, data.getDuration())

            //封面
            .putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, icon);

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) 
        //播放列表长度
        metaData.putLong(MediaMetadataCompat.METADATA_KEY_NUM_TRACKS, musicListManager.getDatum().size());
    

    mediaSession.setMetadata(metaData.build());


接收媒体控制

/**
 * 媒体回调
 */
private MediaSessionCompat.Callback callback = new MediaSessionCompat.Callback() 
    @Override
    public void onPlay() 
        musicListManager.resume();
    

    @Override
    public void onPause() 
        musicListManager.pause();
    

    @Override
    public void onSkipToNext() 
        musicListManager.play(musicListManager.next());
    

    @Override
    public void onSkipToPrevious() 
        musicListManager.play(musicListManager.previous());
    

    @Override
    public void onSeekTo(long pos) 
        musicListManager.seekTo((int) pos);
    
;

桌面Widget

创建布局,然后注册,最后就是更新信息:

public class MusicWidget extends AppWidgetProvider 
    /**
     * 添加,重新运行应用,周期时间,都会调用
     *
     * @param context
     * @param appWidgetManager
     * @param appWidgetIds
     */
    @Override
    public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) 
        super.onUpdate(context, appWidgetManager, appWidgetIds);

        //尝试启动service
        ServiceUtil.startService(context.getApplicationContext(), MusicPlayerService.class);

        //获取播放列表管理器
        MusicListManager musicListManager = MusicPlayerService.getListManager(context.getApplicationContext());

        //获取当前播放的音乐
        final Song data = musicListManager.getData();

        final int N = appWidgetIds.length;
        // 循环处理每一个,因为桌面上可能添加多个
        for (int i = 0; i < N; i++) 
            int appWidgetId = appWidgetIds[i];

            // 创建远程控件,所有对view的操作都必须通过该view提供的方法
            RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.music_widget);

            //因为这是在桌面的控件里面显示我们的控件,所以不能直接通过setOnClickListener设置监听器
            //这里发送的动作在MusicReceiver处理
            PendingIntent iconPendingIntent = IntentUtil.createMainActivityPendingIntent(context, Constant.ACTION_MUSIC_PLAYER_PAGE);

            //这里直接启动service,也可以用广播接收
            PendingIntent previousPendingIntent = IntentUtil.createMusicPlayerServicePendingIntent(context, Constant.ACTION_PREVIOUS);
            PendingIntent playPendingIntent = IntentUtil.createMusicPlayerServicePendingIntent(context, Constant.ACTION_PLAY);
            PendingIntent nextPendingIntent = IntentUtil.createMusicPlayerServicePendingIntent(context, Constant.ACTION_NEXT);
            PendingIntent lyricPendingIntent = IntentUtil.createMusicPlayerServicePendingIntent(context, Constant.ACTION_LYRIC);

            //设置点击事件
            views.setOnClickPendingIntent(R.id.icon, iconPendingIntent);
            views.setOnClickPendingIntent(R.id.previous, previousPendingIntent);
            views.setOnClickPendingIntent(R.id.play, playPendingIntent);
            views.setOnClickPendingIntent(R.id.next, nextPendingIntent);
            views.setOnClickPendingIntent(R.id.lyric, lyricPendingIntent);

            if (data == null) 
                //当前没有播放音乐
                appWidgetManager.updateAppWidget(appWidgetId, views);
             else 
                //有播放音乐
                views.setTextViewText(R.id.title, String.format("%s - %s", data.getTitle(), data.getSinger().getNickname()));
                views.setProgressBar(R.id.progress, (int) data.getDuration(), (int) data.getProgress(), false);

                //显示图标
                RequestOptions options = new RequestOptions();
                options.centerCrop();
                Glide.with(context)
                        .asBitmap()
                        .load(ResourceUtil.resourceUri(data.getIcon()))
                        .apply(options)
                        .into(new CustomTarget<Bitmap>() 

                            @Override
                            public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition<? super Bitmap> transition) 
                                //显示封面
                                views.setImageViewBitmap(R.id.icon, resource);
                                appWidgetManager.updateAppWidget(appWidgetId, views);
                            

                            @Override
                            public void onLoadCleared(@Nullable Drawable placeholder) 
                                //显示默认图片
                                views.setImageViewBitmap(R.id.icon, BitmapFactory.decodeResource(context.getResources(), R.drawable.placeholder));
                                appWidgetManager.updateAppWidget(appWidgetId, views);
                            
                        );
            
        
    


登录/注册/验证码登录

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DNHll9VY-1657286593214)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/fb26c4ca37a446a8a318d7f2d73da827~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image)]

登录注册没有多大难度,用户名和密码登录,就是把信息传递到服务端,可以加密后在传输,服务端判断登录成功,返回一个标记,客户端保存,其他需要的登录的接口带上;验证码登录就是用验证码代替密码,发送验证码都是服务端发送,客户端只需要调用接口。

评论

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HzIXFd1c-1657286593215)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ff59e47014b74dcd9a623430b0d4bec7~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image)] 评论列表包括下拉刷新,上拉加载更多,点赞,发布评论,回复评论,Emoji,话题和提醒人点击,选择好友,选择话题等。

下拉刷新和下拉加载更多

核心逻辑就只需要更改page就行了

//下拉刷新监听器
binding.refresh.setOnRefreshListener(new OnRefreshListener() 
    @Override
    public void onRefresh(RefreshLayout refreshlayout) 
        loadData();
    
);

//上拉加载更多
binding.refresh.setOnLoadMoreListener(new OnLoadMoreListener() 
    @Override
    public void onLoadMore(RefreshLayout refreshlayout) 
        loadMore();
    
);

@Override
protected void loadData(boolean isPlaceholder) 
    super.loadData(isPlaceholder);
    isRefresh = true;
    pageMeta = null;

    loadMore();


提醒人和话题点击

通过正则表达式,找到特殊文本,然后使用富文本实现点击。

holder.setText(R.id.content, processContent(data.getContent()));

/**
 * 处理文本点击事件
 * 这部分可以用监听器回调到Activity中处理
 *
 * @param content
 * @return
 */
private SpannableString processContent(String content) 
    //设置点击事件
    SpannableString result = RichUtil.processContent(getContext(), content,
            new RichUtil.OnTagClickListener() 
                @Override
                public void onTagClick(String data, RichUtil.MatchResult matchResult) 
                    String clickText = RichUtil.removePlaceholderString(data);
                    Timber.d("processContent mention click %s", clickText);
                    UserDetailActivity.startWithNickname(getContext(), clickText);
                
            ,
            (data, matchResult) -> 
                String clickText = RichUtil.removePlaceholderString(data);
                Timber.d("processContent hash tag %s", clickText);
            );

    //返回结果
    return result;


选择好友

对数据分组,然后显示右侧索引,选择了通过EventBus发送到评论界面。

adapter.setOnItemClickListener(new OnItemClickListener() 
        @Override
        public void onItemClick(@NonNull BaseQuickAdapter<?, ?> adapter, @NonNull View view, int position) 
            Object data = adapter.getItem(position);
            if (data instanceof User) 
                if (Constant.STYLE_FRIEND_SELECT == style) 
                    EventBus.getDefault().post(new SelectedFriendEvent((User) data));

                    //关闭界面
                    finish();
                 else 
                    startActivityExtraId(UserDetailActivity.class, ((User) data).getId());
                
            
        
    );


视频和播放

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zOelvRNG-1657286593215)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3431cf6036204094b35805fb61ac2a5a~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image)]

真实项目中视频播放大部分都是用第三方服务,例如:阿里云视频服务,腾讯视频服务,因为他们提供一条龙服务,包括审核,转码,CDN,安全,播放器等,这里用不到这么多功能,所以使用了第三方播放器播放普通mp4,这使用饺子播放器框架。

GSYVideoOptionBuilder videoOption = new GSYVideoOptionBuilder();
videoOption
//                .setThumbImageView(imageView)
        //小屏时不触摸滑动
        .setIsTouchWiget(false)
        //音频焦点冲突时是否释放
        .setReleaseWhenLossAudio(true)
        .setRotateViewAuto(false)
        .setLockLand(false)
        .setAutoFullWithSize(true)
        .setSeekOnStart(seek)
        .setNeedLockFull(true)
        .setUrl(ResourceUtil.resourceUri(data.getUri()))
        .setCacheWithPlay(false)

        //全屏切换时不使用动画
        .setShowFullAnimation(false)
        .setVideoTitle(data.getTitle())

        //设置右下角 显示切换到全屏 的按键资源
        .setEnlargeImageRes(R.drawable.full_screen)

        //设置右下角 显示退出全屏 的按键资源
        .setShrinkImageRes(R.drawable.normal_screen)
        .setVideoAllCallBack(new GSYSampleCallBack() 
            @Override
            public void onPrepared(String url, Object... objects) 
                super.onPrepared(url, objects);
                //开始播放了才能旋转和全屏
                orientationUtils.setEnable(true);
                isPlay = true;
            

            @Override
            public void onQuitFullscreen(String url, Object... objects) 
                super.onQuitFullscreen(url, objects);
                if (orientationUtils != null) 
                    orientationUtils.backToProtVideo();
                
            
        ).setLockClickListener(new LockClickListener() 
    @Override
    public void onClick(View view, boolean lock) 
        if (orientationUtils != null) 
            //配合下方的onConfigurationChanged
            orientationUtils.setEnable(!lock);
        
    
).build(binding.player);

//开始播放
binding.player.startPlayLogic();

用户详情/更改资料

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hNPYPfiz-1657286593216)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/8f43938e43e946d48784946b046f82f8~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image)]

用户详情顶部显示用户信息,好友数量,下面分别显示创建的歌单,收藏的歌单,发布的动态,类似微信朋友圈,右上角可以更改用户资料;整体采用CoordinatorLayout+TabLayout+ViewPager+Fragment实现。

public Fragment getItem(int position) 
    switch (position) 
        case 0:
            return UserDetailSheetFragment.newInstance(userId);
        case 1:
            return FeedFragment.newInstance(userId);
        default:
            return UserDetailAboutFragment.newInstance(userId);
    


/**
 * 返回标题
 *
 * @param position
 * @return
 */
@Nullable
@Override
public CharSequence getPageTitle(int position) 
    //获取字符串id
    int resourceId = titleIds[position];

    //获取字符串
    return context.getResources().getString(resourceId);


发布动态/选择位置/路径规划

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-f1Nhw8Cc-1657286593217)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e8ff77a27cfd4f23be5f3e9433a159e5~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image)] 发布效果和微信朋友圈类似,可以选择图片,和地理位置;地理位置使用高德地图实现选择,路径规划是调用系统中安装的地图,类似微信。

选择位置

/**
 * 搜索该位置的poi,方便用户选择,也方便其他人找
 * Point Of Interest,兴趣点)
 */
private void searchPOI(LatLng data, String keyword) 
    try 
        Timber.d("searchPOI %s %s", data, keyword);
        binding.progress.setVisibility(View.VISIBLE);
        adapter.setNewInstance(new ArrayList<>());

        // 第一个参数表示一个Latlng,第二参数表示范围多少米,第三个参数表示是火系坐标系还是GPS原生坐标系
//        val query = RegeocodeQuery(
//            LatLonPoint(data.latitude, data.longitude)
//            , 1000F, GeocodeSearch.AMAP
//        )
//
//        geocoderSearch.getFromLocationAsyn(query)

        //keyWord表示搜索字符串,
        //第二个参数表示POI搜索类型,二者选填其一,选用POI搜索类型时建议填写类型代码,码表可以参考下方(而非文字)
        //cityCode表示POI搜索区域,可以是城市编码也可以是城市名称,也可以传空字符串,空字符串代表全国在全国范围内进行搜索
        PoiSearch.Query query = new PoiSearch.Query(keyword, "");

        query.setPageSize(10); // 设置每页最多返回多少条poiitem

        query.setPageNum(0); //设置查询页码

        PoiSearch poiSearch = new PoiSearch(this, query);
        poiSearch.setOnPoiSearchListener(this);

        //设置周边搜索的中心点以及半径
        if (data != null) 
            poiSearch.setBound(new PoiSearch.SearchBound(
                    new LatLonPoint(
                            data.latitude,
                            data.longitude
                    ), 1000
            ));
        

        poiSearch.searchPOIAsyn();
     catch (Exception e) 
        e.printStackTrace();
    


高德地图路径规划

/**
 * 使用高德地图路径规划
 *
 * @param context
 * @param slat    起点纬度
 * @param slon    起点经度
 * @param sname   起点名称 可不填(0,0,null)
 * @param dlat    终点纬度
 * @param dlon    终点经度
 * @param dname   终点名称 必填
 *                官方文档:https://lbs.amap.com/api/amap-mobile/guide/android/route
 */
public static void openAmapRoute(
        Context context,
        double slat,
        double slon,
        String sname,
        double dlat,
        double dlon,
        String dname
) 
    StringBuilder builder = new StringBuilder("amapuri://route/plan?");
    //第三方调用应用名称
    builder.append("sourceApplication=");
    builder.append(context.getString(R.string.app_name));

    //开始信息
    if (slat != 0.0) 
        builder.append("&sname=").append(sname);
        builder.append("&slat=").append(slat);
        builder.append("&slon=").append(slon);
    

    //结束信息
    builder.append("&dlat=").append(dlat)
            .append("&dlon=").append(dlon)
            .append("&dname=").append(dname)
            .append("&dev=0")
            .append("&t=0");

    startActivity(context, Constant.PACKAGE_MAP_AMAP, builder.toString());


聊天/离线推送

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-seQyWWvr-1657286593218)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/a675d138aee34137aef6addccd5b2012~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image)] 大部分真实项目中聊天都会选择第三方商业级付费聊天服务,常用的有腾讯云聊天,融云聊天,网易云聊天等,这里选择融云聊天服务,使用步骤是先在服务端生成聊天Token,这里是登录后返回,然后客户端登录聊天服务器,然后设置消息监听,发送消息等。

登录聊天服务器

/**
 * 连接聊天服务器
 *
 * @param data
 */
private void connectChat(Session data) 
    RongIMClient.connect(data.getChatToken(), new RongIMClient.ConnectCallback() 
        /**
         * 成功回调
         * @param userId 当前用户 ID
         */
        @Override
        public void onSuccess(String userId) 
            Timber.d("connect chat success %s", userId);
        

        /**
         * 错误回调
         * @param errorCode 错误码
         */
        @Override
        public void onError(RongIMClient.ConnectionErrorCode errorCode) 
            Timber.e("connect chat error %s", errorCode);

            if (errorCode.equals(RongIMClient.ConnectionErrorCode.RC_CONN_TOKEN_INCORRECT)) 
                //从 APP 服务获取新 token,并重连
             else 
                //无法连接 IM 服务器,请根据相应的错误码作出对应处理
            

            //因为我们这个应用,不是类似微信那样纯聊天应用,所以聊天服务器连接失败,也让进入应用
            //真实项目中按照需求实现就行了
            SuperToast.show(R.string.error_message_login);
        

        /**
         * 数据库回调.
         * @param databaseOpenStatus 数据库打开状态. DATABASE_OPEN_SUCCESS 数据库打开成功; DATABASE_OPEN_ERROR 数据库打开失败
         */
        @Override
        public void onDatabaseOpened(RongIMClient.DatabaseOpenStatus databaseOpenStatus) 

        
    );



设置消息监听

chatClient.addOnReceiveMessageListener(new OnReceiveMessageWrapperListener() 
    @Override
    public void onReceivedMessage(Message message, ReceivedProfile profile) 
        //该方法的调用不再主线程
        Timber.e("chat onReceived %s", message);

        if (EventBus.getDefault().hasSubscriberForEvent(NewMessageEvent.class)) 
            //如果有监听该事件,表示在聊天界面,或者会话界面
            EventBus.getDefault().post(new NewMessageEvent(message));
         else 
            handler.obtainMessage(0, message).sendToTarget();
        

        //发送消息未读数改变了通知
        EventBus.getDefault().post(new MessageUnreadCountChangedEvent());
    
);

发送文本消息

发送图片等其他消息也是差不多。

private void sendTextMessage() 
    String content = binding.input.getText().toString().trim();
    if (StringUtils.isEmpty(content)) 
        SuperToast.show(R.string.hint_enter_message);
        return;
    

    TextMessage textMessage = TextMessage.obtain(content);
    RongIMClient.getInstance().sendMessage(Conversation.ConversationType.PRIVATE, targetId, textMessage, null, MessageUtil.createPushData(MessageUtil.getContent(textMessage), sp.getUserId()), new IRongCallback.ISendMessageCallback() 
        @Override
        public void onAttached(Message message) 
            // 消息成功存到本地数据库的回调
            Timber.d("sendTextMessage onAttached %s", message);
        

        @Override
        public void onSuccess(Message message) 
            // 消息发送成功的回调
            Timber.d("sendTextMessage success %s", message);

            //清空输入框
            clearInput();

            addMessage(message);
        

        @Override
        public void onError(Message message, RongIMClient.ErrorCode errorCode) 
            // 消息发送失败的回调
            Timber.e("sendTextMessage onError %s %s", message, errorCode);
        
    );



离线推送

先开启SDK离线推送,还要分别去厂商那边申请推送配置,这里只实现了小米推送,其他的华为推送,OPPO推送等差不多;然后把推送,或者点击都统一代理到主界面,然后再处理。

private void postRun(Intent intent) 
    String action = intent.getAction();
    if (Constant.ACTION_CHAT.equals(action)) 
        //本地显示的消息通知点击

        //要跳转到聊天界面
        String id = intent.getStringExtra(Constant.ID);
        startActivityExtraId(ChatActivity.class, id);
     else if (Constant.ACTION_PUSH.equals(action)) 
        //聊天通知点击
        String id = intent.getStringExtra(Constant.PUSH);
        startActivityExtraId(ChatActivity.class, id);
    


商城/订单/支付/购物车

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cexKv1v2-1657286593219)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0c74e7a8902143d3a89975191f596f34~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image)] [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yHBS8J0O-1657286593219)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9f71f43db8694e07a0d78c2d786177c4~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image)]

学到这里,大家不能说熟悉,那么看到上面的界面,那么大体要能实现出来。

商品详情富文本

<

以上是关于高仿Android网易云音乐OkHttp+Retrofit+RxJava+Glide+MVC+MVVM的主要内容,如果未能解决你的问题,请参考以下文章

0.Android高仿网易云音乐OkHttp+Retrofit+RxJava+Glide+MVC+MVVM

0.Android高仿网易云音乐OkHttp+Retrofit+RxJava+Glide+MVC+MVVM

第二版高仿Android网易云音乐企业级项目实战课程介绍

新鲜出炉高仿网易云音乐 APP

新鲜出炉高仿网易云音乐 APP

Android项目实战之高仿网易云音乐创建项目和配置