1

高仿Android网易云音乐OkHttp+Retrofit+RxJava+Glide+MVC+MVVM - 爱学啊

 1 year ago
source link: https://www.cnblogs.com/woblog/p/16435797.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
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'

用户协议对话框

f09afe7fd1d0021736b7f6183b4e6b64.jpeg#pic_center

使用自定义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);
    }
}
188102035476422c8ad318ef1abe683b.jpeg#pic_center

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

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

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

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

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

09780c564b9040d9a732ddb0f428b8ed.png

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

b38a820d154848b69cc71abf5b80a50b.png

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

登录/注册/验证码登录

505254a8ae6e48049a5fda5eb82cc8a0.png

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

39e99819d8b249118612a60e6b332a50.png

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

视频和播放

8bc5a42882eb4d1099489c5c71897226.png

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

用户详情/更改资料

ee65c56c2f19455288d9523ccfd5651c.png

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

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

2a050bd8226c4df79a1bf9cf364c17b2.png

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

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

聊天/离线推送

7f14768a6f174f0b8b8b5da66874d119.png

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

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

ee3103d8c0854d999cfe9bca839a7d6a.png
3aa5788ba2c74addb7a7c63b3433feb0.png

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

商品详情富文本

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

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK