乐趣区

关于android:高仿Android网易云音乐OkHttpRetrofitRxJavaGlideMVCMVVM

1. 我的项目简介

这是一个应用 Java(当前还会推出 Kotlin 版本)语言,从 0 开发一个 Android 平台,靠近企业级的我的项目(我的云音乐),蕴含了根底内容,高级内容,我的项目封装,我的项目重构等常识;次要是应用零碎性能,风行的第三方框架,第三方服务,实现靠近企业级商业级我的项目。

2. 我的项目性能点

隐衷协定对话框
启动界面和动静解决权限
疏导界面和广告
轮播图和侧滑菜单
首页简单列表和列表排序
音乐播放和音乐列表治理
全局音乐管制条
桌面歌词和自定义款式
全局媒体控制中心
评论和回复评论
评论富文本点击
评论揭示人和话题
朋友圈动静列表和公布
高德地图定位和门路布局
阿里云 OSS 上传
视频播放和管制
QQ/ 微信登录和分享
商城 / 购物车 \ 微信 \ 支付宝领取
文本和图片聊天
音讯离线推送
主动和手动查看更新
内存透露和优化

3. 开发环境概述

2022 年 5 月开发实现的,所以全部都是最新的,均匀每 3 年会从新制作,当初曾经是第三版了。

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

4. 编译和运行

用最新 AS 关上 MyCloudMusicAndroidJava 目录,而后期待齐全编译胜利,因为是企业级我的项目,所以第三方依赖很多,同时代码量也很多,所以必须要确认齐全编译胜利,能力运行。

5. 我的项目目录构造

├── 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 // 百度语音辨认

6. 依赖框架

内容太多,只列出局部。

// 分页组件版本
// 这里能够查看最新版本: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'

7. 用户协定对话框

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

8. 动静权限

高版本必须要动静解决权限,这里在启动界面申请了一些权限,但举荐在用到的时候才获取,写法差不多,这里应用第三方框架实现,当然也能够间接应用零碎 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);
}

9. 疏导界面

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

10. 广告界面

image.png

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

10.1 下载广告

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

10.2 显示广告

/**
 * 显示视频广告
 *
 * @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());
}

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

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

首页没有顶部是轮播图,而后是能够左右的菜单,接下来是热门歌单,举荐单曲,最初是首页排序模块;整体上应用 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());

11.1 歌单详情

顶部是歌单信息,通过 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);
    }

}

11.2 黑胶唱片

下面是黑胶唱片,和网易云音乐差不多,随着音乐滚动或暂停,顶部是管制相干,音乐播放逻辑是封装到 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());
});

12. 媒体控制器 / 桌面歌词 / 桌面 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);
    }

    
    ...
}

显示和暗藏只须要调用该管理器的相干办法就行了。

12.1 媒体控制器

应用了能够通过零碎媒体控制器,告诉栏,锁屏界面,耳机,蓝牙耳机等设施管制媒体播放暂停,只须要把媒体信息更新到零碎:

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

12.2 接管媒体管制

/**
 * 媒体回调
 */
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);
    }
};

12.3 桌面 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);
                            }
                        });
            }
        }
    }
}

13. 登录 / 注册 / 验证码登录

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

14. 评论

评论列表包含下拉刷新,上拉加载更多,点赞,公布评论,回复评论,Emoji,话题和揭示人点击,抉择好友,抉择话题等。

14.1 下拉刷新和下拉加载更多

外围逻辑就只须要更改 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();}

14.2 揭示人和话题点击

通过正则表达式,找到非凡文本,而后应用富文本实现点击。

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

14.3 抉择好友

对数据分组,而后显示右侧索引,抉择了通过 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());
                }
            }
        }
    });
}

15. 视频和播放

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

16. 用户详情 / 更改材料

用户详情顶部显示用户信息,好友数量,上面别离显示创立的歌单,珍藏的歌单,公布的动静,相似微信朋友圈,右上角能够更改用户材料;整体采纳 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);
}

17. 公布动静 / 抉择地位 / 门路布局

公布成果和微信朋友圈相似,能够抉择图片,和地理位置;地理位置应用高德地图实现抉择,门路布局是调用零碎中装置的地图,相似微信。

17.1 抉择地位

/**
 * 搜寻该地位的 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();
    }
}

17.2 高德地图门路布局

/**
 * 应用高德地图门路布局
 *
 * @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());
}

18. 聊天 / 离线推送

大部分实在我的项目中聊天都会抉择第三方商业级付费聊天服务,罕用的有腾讯云聊天,融云聊天,网易云聊天等,这里抉择融云聊天服务,应用步骤是先在服务端生成聊天 Token,这里是登录后返回,而后客户端登录聊天服务器,而后设置音讯监听,发送音讯等。

18.1 登录聊天服务器

/**
 * 连贯聊天服务器
 *
 * @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) {}});

}

18.2 设置音讯监听

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

18.3 发送文本音讯

发送图片等其余音讯也是差不多。

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

}

19. 离线推送

先开启 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);
    }
}

20. 商城 / 订单 / 领取 / 购物车

学到这里,大家不能说相熟,那么看到下面的界面,那么大体要能实现进去。

20.1 商品详情富文本

// 详情
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);

20.2 支付宝 / 微信领取

客户端先集成微信,支付宝 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);
}

20.3 解决领取后果

/**
 * 支付宝领取状态扭转了
 *
 * @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);
    }
}

24. 语音辨认输出地址

这里应用百度语音辨认 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);
    }
};

25. 百度 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()));
        }
    });
}

26. 我的项目总结

总体来说我的项目性能还是很全的,还有一些小性能,例如:快捷方式等就不在贴代码了,但必定没发和原版比,置信大家只有做过程序员就能了解,毕竟原版是一个商业级我的项目,几十个人天天开发和保护,而且继续了几年了;不过恕我直言,当初的常见的音乐软件都太简单了,各种性能,不过都要恰饭,如同又能了解了😄。

退出移动版