2022
我们一起努力

高防美国云服务器(高仿Android网易云音乐OkHttp+Retrofit+RxJava+Glide+MVC+MVVM)

效果

0.系列文章目录

因为目录比较多,每次更新这里比较麻烦,所以推荐点击到主页,然后查看Android云音乐专栏。

1.项目简介

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

2.项目功能点

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

3.开发环境概述

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

JDK17Android 12/13最低兼容版本:Android 6.0Android 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/pagingdef paging_version = "3.1.1"//添加所有libs目录里面的jar,aarimplementation 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-startedimplementation 'com.qmuiteam:qmui:2.0.1'//动态处理权限//https://github.com/permissions-dispatcher/PermissionsDispatcherimplementation "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/gsonimplementation 'com.google.code.gson:gson:2.9.0'//自动释放RxJava相关资源//https://github.com/uber/AutoDisposeimplementation "com.uber.autodispose2:autodispose-androidx-lifecycle:2.1.1"//banner轮播图框架//https://github.com/youth5201314/bannerimplementation 'io.github.youth5201314:banner:2.2.2'//图片加载框架,还引用他目的是,coil有些功能不好实现//https://github.com/bumptech/glideimplementation 'com.github.bumptech.glide:glide:+'annotationProcessor 'com.github.bumptech.glide:compiler:+'implementation 'androidx.recyclerview:recyclerview:1.2.1'//给控件添加未读消息数红点//https://github.com/bingoogolapple/BGABadgeView-Androidimplementation 'com.github.bingoogolapple.BGABadgeView-Android:api:1.2.0'annotationProcessor 'com.github.bingoogolapple.BGABadgeView-Android:compiler:1.2.0'//webview进度条//https://github.com/youlookwhat/WebProgressimplementation 'com.github.youlookwhat:WebProgress:1.2.0'//日志框架//https://github.com/JakeWharton/timberimplementation 'com.jakewharton.timber:timber:5.0.1'implementation "androidx.media:media:+"//和Glide配合处理图片//可以实现很多效果//模糊;圆角;圆//我们这里是用它实现模糊效果//https://github.com/wasabeef/glide-transformationsimplementation 'jp.wasabeef:glide-transformations:+'//圆形图片控件//https://github.com/hdodenhof/CircleImageViewimplementation 'de.hdodenhof:circleimageview:+'//下载框架//https://github.com/ixuea/android-downloaderimplementation 'com.ixuea:android-downloader:3.0.0'//阿里云oss//官方文档:https://help.aliyun.com/document_detail/32043.html//sdk地址:https://github.com/aliyun/aliyun-oss-android-sdkimplementation 'com.aliyun.dpa:oss-android-sdk:+'//高德地图,这里引用的是3d//https://lbs.amap.com/api/android-sdk/guide/create-project/android-studio-create-project#gradle_sdkimplementation '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%97implementation project(path: ':super-speech-baidu')//TextView显示富文本,目前主要用在商品详情界面,显示富文本商品描述//https://github.com/wangchenyan/html-textimplementation 'com.github.wangchenyan:html-text:+'//Hutool是一个小而全的Java工具类库// 通过静态方法封装,降低相关API的学习成本// 提高工作效率,使Java拥有函数式语言般的优雅//https://github.com/looly/hutoolimplementation 'cn.hutool:hutool-all:5.7.14'//支付宝支付//https://opendocs.alipay.com/open/204/105296implementation 'com.alipay.sdk:alipaysdk-android:+@aar'//融云IM//https://docs.rongcloud.cn/v4/5X/views/im/ui/guide/quick/include/android.htmlimplementation '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_5implementation '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标签,设置后就可以点击了,然后修改默认对话框宽度,因为默认的有点窄。

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

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 */@Overridepublic void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); //将授权结果传递到框架 SplashActivityPermissionsDispatcher.onRequestPermissionsResult(this, requestCode, grantResults);}

9.引导界面

引导界面比较简单,就是多个图片可以左右滚动,整体使用ViewPager+Fragment实现,也可以使用ViewPager2,后面有讲解。


10.广告界面

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

高防美国云服务器(高仿Android网易云音乐OkHttp+Retrofit+RxJava+Glide+MVC+MVVM)

用户详情顶部显示用户信息,好友数量,下面分别显示创建的歌单,收藏的歌单,发布的动态,类似微信朋友圈,右上角可以更改用户资料;整体采用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@Overridepublic CharSequence getPageTitle(int position) { //获取字符串id int resourceId = titleIds[position]; //获取字符串 return context.getResources().getString(resourceId);}

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

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

17.1选择位置


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.项目总结

总体来说项目功能还是很全的,还有一些小功能,例如:快捷方式等就不在贴代码了,但肯定没发和原版比,相信大家只要做过程序员就能理解,毕竟原版是一个商业级项目,几十个人天天开发和维护,而且持续了几年了;不过恕我直言,现在的常见的音乐软件都太复杂了,各种功能,不过都要恰饭,好像又能理解了。

赞(0)
文章名称:《高防美国云服务器(高仿Android网易云音乐OkHttp+Retrofit+RxJava+Glide+MVC+MVVM)》
文章链接:https://www.fzvps.com/70628.html
本站文章来源于互联网,如有侵权,请联系管理删除,本站资源仅供个人学习交流,请于下载后24小时内删除,不允许用于商业用途,否则法律问题自行承担。
图片版权归属各自创作者所有,图片水印出于防止被无耻之徒盗取劳动成果的目的。

评论 抢沙发

评论前必须登录!