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


    image.png

    简介

    这是一个使用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'
    
    折叠

    用户协议对话框

    使用自定义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);
        }
    }
    
    折叠

    动态权限

    高版本必须要动态处理权限,这里在启动界面请求了一些权限,但推荐在用到的时候才获取,写法差不多,这里使用第三方框架实现,当然也可以直接使用系统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);
    }
    
    折叠

    引导界面


    引导界面比较简单,就是多个图片可以左右滚动,整体使用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);
        }
    }
    

    广告界面

    实现图片广告和视频广告,广告数据是在首页是缓存到本地,目的是在启动界面加载更快,因为真实项目中,大部分项目启动页面广告时间一共就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());
    }
    
    折叠

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

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

    首页没有顶部是轮播图,然后是可以左右的菜单,接下来是热门歌单,推荐单曲,最后是首页排序模块;整体上使用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


    歌词实现了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);
                                }
                            });
                }
            }
        }
    }
    
    折叠

    登录/注册/验证码登录

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

    评论


    评论列表包括下拉刷新,上拉加载更多,点赞,发布评论,回复评论,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());
                    }
                }
            }
        });
    }
    

    视频和播放

    真实项目中视频播放大部分都是用第三方服务,例如:阿里云视频服务,腾讯视频服务,因为他们提供一条龙服务,包括审核,转码,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();
    
    折叠

    用户详情/更改资料

    用户详情顶部显示用户信息,好友数量,下面分别显示创建的歌单,收藏的歌单,发布的动态,类似微信朋友圈,右上角可以更改用户资料;整体采用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);
    }
    
    折叠

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


    发布效果和微信朋友圈类似,可以选择图片,和地理位置;地理位置使用高德地图实现选择,路径规划是调用系统中安装的地图,类似微信。

    选择位置

    /**
     * 搜索该位置的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());
    }
    
    折叠

    聊天/离线推送


    大部分真实项目中聊天都会选择第三方商业级付费聊天服务,常用的有腾讯云聊天,融云聊天,网易云聊天等,这里选择融云聊天服务,使用步骤是先在服务端生成聊天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);
        }
    }
    

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


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

    商品详情富文本

    //详情
    HtmlText.from(data.getDetail())
        .setImageLoader(new HtmlImageLoader() {
            @Override
            public void loadImage(String url, final Callback callback) {
                Glide.with(getHostActivity())
                        .asBitmap()
                        .load(url)
                        .into(new CustomTarget<Bitmap>() {
    
                            @Override
                            public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition<? super Bitmap> transition) {
                                callback.onLoadComplete(resource);
                            }
    
                            @Override
                            public void onLoadCleared(@Nullable Drawable placeholder) {
                                callback.onLoadFailed();
                            }
                        });
            }
    
            @Override
            public Drawable getDefaultDrawable() {
                return ContextCompat.getDrawable(getHostActivity(), R.drawable.placeholder);
            }
    
            @Override
            public Drawable getErrorDrawable() {
                return ContextCompat.getDrawable(getHostActivity(), R.drawable.placeholder_error);
            }
    
            @Override
            public int getMaxWidth() {
                return ScreenUtil.getScreenWith(getHostActivity());
            }
    
            @Override
            public boolean fitWidth() {
                return true;
            }
        })
        .setOnTagClickListener(new OnTagClickListener() {
            @Override
            public void onImageClick(Context context, List<String> imageUrlList, int position) {
                // image click
            }
    
            @Override
            public void onLinkClick(Context context, String url) {
                // link click
                Timber.d("onLinkClick %s", url);
            }
        })
        .into(binding.detail);
    
    折叠

    支付

    客户端先集成微信,支付宝SDK,然后请求服务端获取支付信息,设置到SDK,最后就是处理支付结果。

    /**
     * 处理支付宝支付
     *
     * @param data
     */
    private void processAlipay(String data) {
        PayUtil.alipay(getHostActivity(), data);
    }
    
    /**
     * 处理微信支付
     *
     * @param data
     */
    private void processWechat(WechatPay data) {
        //把服务端返回的参数
        //设置到对应的字段
        PayReq request = new PayReq();
    
        request.appId = data.getAppid();
        request.partnerId = data.getPartnerid();
        request.prepayId = data.getPrepayid();
        request.nonceStr = data.getNoncestr();
        request.timeStamp = data.getTimestamp();
        request.packageValue = data.getPackageValue();
        request.sign = data.getSign();
    
        AppContext.getInstance().getWxapi().sendReq(request);
    }
    
    折叠

    处理支付结果

    /**
     * 支付宝支付状态改变了
     *
     * @param event
     */
    @Subscribe(threadMode = ThreadMode.MAIN)
    public void onAlipayStatusChanged(AlipayStatusChangedEvent event) {
        String resultStatus = event.getData().getResultStatus();
    
        if ("9000".equals(resultStatus)) {
            //本地支付成功
    
            //不能依赖本地支付结果
            //一定要以服务端为准
            showLoading(R.string.hint_pay_wait);
    
            //延时3秒
            //因为支付宝回调我们服务端可能有延迟
            binding.primary.postDelayed(() -> {
                checkPayStatus();
            }, 3000);
    
        } else if ("6001".equals(resultStatus)) {
            //支付取消
            SuperToast.show(R.string.error_pay_cancel);
        } else {
            //支付失败
            SuperToast.show(R.string.error_pay_failed);
        }
    }
    
    折叠

    语音识别输入地址

    这里使用百度语音识别SDK,先集成,然后初始化,最后是监听识别结果:

    /**
     * 百度语音识别事件监听器
     * <p>
     * https://ai.baidu.com/ai-doc/SPEECH/4khq3iy52
     */
    EventListener voiceRecognitionEventListener = new EventListener() {
        /**
         * 事件回调
         * @param name 回调事件名称
         * @param params 回调参数
         * @param data 数据
         * @param offset 开始位置
         * @param length 长度
         */
        @Override
        public void onEvent(String name, String params, byte[] data, int offset, int length) {
            String result = "name: " + name;
    
            if (name.equals(SpeechConstant.CALLBACK_EVENT_ASR_READY)) {
                // 引擎就绪,可以说话,一般在收到此事件后通过UI通知用户可以说话了
                setStopVoiceRecognition();
            } else if (name.equals(SpeechConstant.CALLBACK_EVENT_ASR_PARTIAL)) {
                // 一句话的临时结果,最终结果及语义结果
    
                if (params == null || params.isEmpty()) {
                    return;
                }
    
                // 识别相关的结果都在这里
                try {
                    JSONObject paramObject = new JSONObject(params);
    
                    //获取第一个结果
                    JSONArray resultsRecognition = paramObject.getJSONArray("results_recognition");
    
                    String voiceRecognitionResult = resultsRecognition.getString(0);
    
                    //可以根据result_type是临时结果,还是最终结果
    
                    binding.input.setText(voiceRecognitionResult);
                    result += voiceRecognitionResult;
                } catch (JSONException e) {
                    e.printStackTrace();
                }
            } else if (name.equals(SpeechConstant.CALLBACK_EVENT_ASR_FINISH)) {
                //一句话识别结束(可能含有错误信息) 。最终识别的文字结果在ASR_PARTIAL事件中
    
                if (params.contains("\"error\":0")) {
    
                } else if (params.contains("\"error\":7")) {
                    SuperToast.show(R.string.voice_error_no_result);
                } else {
                    //其他错误
                    SuperToast.show(getString(R.string.voice_error, params));
                }
            } else if (name.equals(SpeechConstant.CALLBACK_EVENT_ASR_EXIT)) {
                //识别结束,资源释放
                setStartVoiceRecognition();
            }
    
            Timber.d("baidu voice recognition onEvent %s", result);
        }
    };
    
    折叠

    百度OCR

    使用百度OCR从图片中识别文本,主要是识别地址,类似顺丰公众号输入地址时识别功能。

    private void recognitionImage(String data) {
        GeneralBasicParams param = new GeneralBasicParams();
        param.setDetectDirection(true);
        param.setImageFile(new File(data));
    
        // 调用通用文字识别服务
        OCR.getInstance(getApplicationContext()).recognizeGeneralBasic(param, new OnResultListener<GeneralResult>() {
    
            /**
             * 成功
             * @param result
             */
            @Override
            public void onResult(GeneralResult result) {
                StringBuilder builder = new StringBuilder();
                for (WordSimple it : result.getWordList()) {
                    builder.append(it.getWords());
    
                    //每一项之间,添加空格,方便OCR失败
                    builder.append(" ");
                }
    
                binding.input.setText(builder.toString());
            }
    
            /**
             * 失败
             * @param error
             */
            @Override
            public void onError(OCRError error) {
                SuperToast.show(getString(R.string.ocr_error, error.getMessage(), error.getErrorCode()));
            }
        });
    }
    
    折叠

    还有一些功能,例如:快捷方式等就不在贴代码了。

  • 相关阅读:
    TikTok Shop新结算政策:卖家选择权加强,电商市场蓄势待发
    十一假期,分享几个好玩儿的GitHub项目
    java 字符串初始化=“” 和=null的区别
    FullCalendarDemo5 控件的实例讲解—拖拽实现值班排班(二)
    做外贸用以下邮箱比较好
    【c语言】编译链接--详解
    Nginx rtmp&&Centos FFmpeg安装配置
    JAVA班主任管理系统(源代码+论文)
    leetcode每天5题-Day20
    MySQL中什么情况下会出现索引失效?如何排查索引失效?
  • 原文地址:https://www.cnblogs.com/woblog/p/16435797.html