這是一個使用Java(以後還會推出Kotlin版本)語言,從0開發一個Android平臺,接近企業級的項目(我的雲音樂),包含了基礎內容,高級內容,項目封裝,項目重構等知識;主要是使用系統功能,流行的第三方框架,第三方服務,完成接近企業級商業級項目。 ...
簡介
這是一個使用Java(以後還會推出Kotlin版本)語言,從0開發一個Android平臺,接近企業級的項目(我的雲音樂),包含了基礎內容,高級內容,項目封裝,項目重構等知識;主要是使用系統功能,流行的第三方框架,第三方服務,完成接近企業級商業級項目。
功能點
隱私協議對話框
啟動界面和動態處理許可權
引導界面和廣告
輪播圖和側滑菜單
首頁複雜列表和列表排序
音樂播放和音樂列表管理
全局音樂控制條
桌面歌詞和自定義樣式
全局媒體控制中心
評論和回覆評論
評論富文本點擊
評論提醒人和話題
朋友圈動態列表和發佈
高德地圖定位和路徑規劃
阿裡雲OSS上傳
視頻播放和控制
QQ/微信登錄和分享
商城/購物車\微信\支付寶支付
文本和圖片聊天
消息離線推送
自動和手動檢查更新
記憶體泄漏和優化
...
開發環境概述
2022年5月開發完成的,所以全部都是最新的,平均每3年會重新製作,現在已經是第三版了。
JDK17
Android 12/13
最低相容版本:Android 6.0
Android Studio 2021.1
編譯和運行
用最新AS打開MyCloudMusicAndroidJava目錄,然後等待完全編譯成功,因為是企業級項目,所以第三方依賴很多,同時代碼量也很多,所以必須要確認完全編譯成功,才能運行。
項目目錄結構
├── MyCloudMusicAndroidJava
│ ├── LRecyclerview //第三方Recyclerview框架
│ ├── LetterIndexView //類似微信通訊錄字母索引
│ ├── app //雲音樂項目
│ ├── build.gradle
│ ├── common.gradle //通用項目配置文件
│ ├── config //配置目錄,例如簽名
│ ├── glidepalette //Glide畫板,用來從網路圖片提取顏色
│ ├── gradle
│ ├── gradle.properties
│ ├── gradlew
│ ├── gradlew.bat
│ ├── keystore.properties
│ ├── local.properties
│ ├── settings.gradle
│ ├── super-j //公用Java語言擴展
│ ├── super-player-tencent //騰訊開源的超級播放器
│ ├── super-speech-baidu //百度語音識別
依賴框架
內容太多,只列出部分。
//分頁組件版本
//這裡可以查看最新版本:https://developer.android.google.cn/jetpack/androidx/releases/paging
def paging_version = "3.1.1"
//添加所有libs目錄裡面的jar,aar
implementation fileTree(dir: 'libs', include: ['*.jar','*.aar'])
//官方相容組件,像AppCompatActivity就是該依賴裡面的
implementation 'androidx.appcompat:appcompat:1.4.1'
//Material Design組件,像FloatingActionButton就是該依賴裡面的
implementation 'com.google.android.material:material:1.4.0'
//官方提供的約束佈局,像ConstraintLayout就是該依賴裡面的
implementation 'androidx.constraintlayout:constraintlayout:2.1.0'
//UI框架,主要是用他的工具類,也可以單獨拷貝出來
//https://qmuiteam.com/android/get-started
implementation 'com.qmuiteam:qmui:2.0.1'
//動態處理許可權
//https://github.com/permissions-dispatcher/PermissionsDispatcher
implementation "com.github.permissions-dispatcher:permissionsdispatcher:4.8.0"
annotationProcessor "com.github.permissions-dispatcher:permissionsdispatcher-processor:4.8.0"
//api:依賴會傳遞到其他應用本模塊的項目
implementation project(path: ':super-j')
...
//使用gson解析json
//https://github.com/google/gson
implementation 'com.google.code.gson:gson:2.9.0'
//自動釋放RxJava相關資源
//https://github.com/uber/AutoDispose
implementation "com.uber.autodispose2:autodispose-androidx-lifecycle:2.1.1"
//banner輪播圖框架
//https://github.com/youth5201314/banner
implementation 'io.github.youth5201314:banner:2.2.2'
//圖片載入框架,還引用他目的是,coil有些功能不好實現
//https://github.com/bumptech/glide
implementation 'com.github.bumptech.glide:glide:+'
annotationProcessor 'com.github.bumptech.glide:compiler:+'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
//給控制項添加未讀消息數紅點
//https://github.com/bingoogolapple/BGABadgeView-Android
implementation 'com.github.bingoogolapple.BGABadgeView-Android:api:1.2.0'
annotationProcessor 'com.github.bingoogolapple.BGABadgeView-Android:compiler:1.2.0'
//webview進度條
//https://github.com/youlookwhat/WebProgress
implementation 'com.github.youlookwhat:WebProgress:1.2.0'
//日誌框架
//https://github.com/JakeWharton/timber
implementation 'com.jakewharton.timber:timber:5.0.1'
implementation "androidx.media:media:+"
//和Glide配合處理圖片
//可以實現很多效果
//模糊;圓角;圓
//我們這裡是用它實現模糊效果
//https://github.com/wasabeef/glide-transformations
implementation 'jp.wasabeef:glide-transformations:+'
//圓形圖片控制項
//https://github.com/hdodenhof/CircleImageView
implementation 'de.hdodenhof:circleimageview:+'
//下載框架
//https://github.com/ixuea/android-downloader
implementation 'com.ixuea:android-downloader:3.0.0'
//阿裡雲oss
//官方文檔:https://help.aliyun.com/document_detail/32043.html
//sdk地址:https://github.com/aliyun/aliyun-oss-android-sdk
implementation 'com.aliyun.dpa:oss-android-sdk:+'
//高德地圖,這裡引用的是3d
//https://lbs.amap.com/api/android-sdk/guide/create-project/android-studio-create-project#gradle_sdk
implementation 'com.amap.api:3dmap:+'
//定位功能
implementation 'com.amap.api:location:+'
//百度語音相關技術,目前主要用在收貨地址編輯界面,語音輸入收貨地址
//https://ai.baidu.com/ai-doc/SPEECH/Pkgt4wwdx#%E9%9B%86%E6%88%90%E6%8C%87%E5%8D%97
implementation project(path: ':super-speech-baidu')
//TextView顯示富文本,目前主要用在商品詳情界面,顯示富文本商品描述
//https://github.com/wangchenyan/html-text
implementation 'com.github.wangchenyan:html-text:+'
//Hutool是一個小而全的Java工具類庫
// 通過靜態方法封裝,降低相關API的學習成本
// 提高工作效率,使Java擁有函數式語言般的優雅
//https://github.com/looly/hutool
implementation 'cn.hutool:hutool-all:5.7.14'
//支付寶支付
//https://opendocs.alipay.com/open/204/105296
implementation 'com.alipay.sdk:alipaysdk-android:+@aar'
//融雲IM
//https://docs.rongcloud.cn/v4/5X/views/im/ui/guide/quick/include/android.html
implementation 'cn.rongcloud.sdk:im_lib:+'
//微信支付
//官方sdk下載文檔:https://developers.weixin.qq.com/doc/oplatform/Downloads/Android_Resource.html
//官方集成文檔:https://pay.weixin.qq.com/wiki/doc/api/app/app.php?chapter=8_5
implementation 'com.tencent.mm.opensdk:wechat-sdk-android:+'
//記憶體泄漏檢測工具
//https://github.com/square/leakcanary
//只有調試模式下才添加該依賴
debugImplementation 'com.squareup.leakcanary:leakcanary-android:+'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
用戶協議對話框
使用自定義DialogFragment實現,內容是放到字元串文件中的,其中的鏈接是HTML標簽,設置後就可以點擊了,然後修改預設對話框寬度,因為預設的有點窄。
public class TermServiceDialogFragment extends BaseViewModelDialogFragment<FragmentDialogTermServiceBinding> {
...
@Override
protected void initViews() {
super.initViews();
//點擊彈窗外邊不能關閉
setCancelable(false);
SuperTextUtil.setLinkColor(binding.content, getActivity().getColor(R.color.link));
}
@Override
protected void initListeners() {
super.initListeners();
binding.primary.setOnClickListener(view -> {
dismiss();
onAgreementClickListener.onClick(view);
});
binding.disagree.setOnClickListener(view -> {
dismiss();
SuperProcessUtil.killApp();
});
}
@Override
public void onResume() {
super.onResume();
//修改寬度,預設比AlertDialog.Builder顯示對話框寬度窄,看著不好看
//參考:https://stackoverflow.com/questions/12478520/how-to-set-dialogfragments-width-and-height
ViewGroup.LayoutParams params = getDialog().getWindow().getAttributes();
params.width = (int) (ScreenUtil.getScreenWith(getContext()) * 0.9);
params.height = ViewGroup.LayoutParams.WRAP_CONTENT;
getDialog().getWindow().setAttributes((android.view.WindowManager.LayoutParams) params);
}
}
動態許可權
高版本必須要動態處理許可權,這裡在啟動界面請求了一些許可權,但推薦在用到的時候才獲取,寫法差不多,這裡使用第三方框架實現,當然也可以直接使用系統API實現。
/**
* 許可權授權了就會調用該方法
* 請求相機許可權目的是掃描二維碼,拍照
*/
@NeedsPermission({
Manifest.permission.CAMERA,
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION
})
void onPermissionGranted() {
//如果有許可權就進入下一步
prepareNext();
}
/**
* 顯示許可權授權對話框
* 目的是提示用戶
*/
@OnShowRationale({
Manifest.permission.CAMERA,
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION
})
void showRequestPermission(PermissionRequest request) {
new AlertDialog.Builder(getHostActivity())
.setMessage(R.string.permission_hint)
.setPositiveButton(R.string.allow, (dialog, which) -> request.proceed())
.setNegativeButton(R.string.deny, (dialog, which) -> request.cancel()).show();
}
/**
* 拒絕了許可權調用
*/
@OnPermissionDenied({
Manifest.permission.CAMERA,
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION
})
void showDenied() {
//退出應用
finish();
}
/**
* 再次獲取許可權的提示
*/
@OnNeverAskAgain({
Manifest.permission.CAMERA,
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION
})
void showNeverAsk() {
//繼續請求許可權
checkPermission();
}
/**
* 授權後回調
*
* @param requestCode
* @param permissions
* @param grantResults
*/
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
//將授權結果傳遞到框架
SplashActivityPermissionsDispatcher.onRequestPermissionsResult(this, requestCode, grantResults);
}
引導界面
引導界面比較簡單,就是多個圖片可以左右滾動,整體使用ViewPager+Fragment實現,也可以使用ViewPager2,後面有講解。
/**
* 引導界面適配器
*/
public class GuideAdapter extends BaseFragmentStatePagerAdapter<Integer> {
/***
* @param context 上下文
* @param fm Fragment管理器
*/
public GuideAdapter(Context context, @NonNull FragmentManager fm) {
super(context, fm);
}
/**
* 返回當前位置Fragment
*
* @param position
* @return
*/
@NonNull
@Override
public Fragment getItem(int position) {
return GuideFragment.newInstance(getData(position));
}
}
/**
* 引導界面Fragment
*/
public class GuideFragment extends BaseViewModelFragment<FragmentGuideBinding> {
...
@Override
protected void initDatum() {
super.initDatum();
int data = getArguments().getInt(Constant.ID);
binding.icon.setImageResource(data);
}
}
廣告界面
實現圖片廣告和視頻廣告,廣告數據是在首頁是緩存到本地,目的是在啟動界面載入更快,因為真實項目中,大部分項目啟動頁面廣告時間一共就5秒,如果太長了用戶體驗不好,如果是從網路請求,那麼網路可能就耗時2秒左右,所以導致就美喲多少時間顯示廣告了。
下載廣告
private void downloadAd(Ad data) {
if (SuperNetworkUtil.isWifiConnected(getHostActivity())) {
//wifi才下載
sp.setSplashAd(data);
//判斷文件是否存在,如果存在就不下載
File targetFile = FileUtil.adFile(getHostActivity(), data.getIcon());
if (targetFile.exists()) {
return;
}
new Thread(
new Runnable() {
@Override
public void run() {
try {
//FutureTarget會阻塞
//所以需要在子線程調用
FutureTarget<File> target = Glide.with(getHostActivity().getApplicationContext())
.asFile()
.load(ResourceUtil.resourceUri(data.getIcon()))
.submit();
//獲取下載的文件
File file = target.get();
//將文件拷貝到我們需要的位置
FileUtils.moveFile(file, targetFile);
} catch (Exception e) {
e.printStackTrace();
}
}
}
).start();
}
}
顯示廣告
/**
* 顯示視頻廣告
*
* @param data
*/
private void showVideoAd(File data) {
SuperViewUtil.show(binding.video);
SuperViewUtil.show(binding.preload);
//在要用到的時候在初始化,更節省資源,當然播放器控制項也可以在這裡動態創建
//設置播放監聽器
//創建 player 對象
player = new TXVodPlayer(getHostActivity());
//靜音,當然也可以在界面上添加靜音切換按鈕
player.setMute(true);
//關鍵 player 對象與界面 view
player.setPlayerView(binding.video);
//設置播放監聽器
player.setVodListener(this);
//鋪滿
binding.video.setRenderMode(TXLiveConstants.RENDER_MODE_FULL_FILL_SCREEN);
//開啟硬體加速
player.enableHardwareDecode(true);
player.startPlay(data.getAbsolutePath());
}
顯示圖片就是顯示本地圖片了,沒什麼難點,就不貼代碼了。
首頁/歌單詳情/黑膠唱片界面
首頁沒有頂部是輪播圖,然後是可以左右的菜單,接下來是熱門歌單,推薦單曲,最後是首頁排序模塊;整體上使用RecycerView實現,輪播圖:
Banner bannerView = holder.getView(R.id.banner);
BannerImageAdapter<Ad> bannerImageAdapter = new BannerImageAdapter<Ad>(data.getData()) {
@Override
public void onBindView(BannerImageHolder holder, Ad data, int position, int size) {
ImageUtil.show(getContext(), (ImageView) holder.itemView, data.getIcon());
}
};
bannerView.setAdapter(bannerImageAdapter);
bannerView.setOnBannerListener(onBannerListener);
bannerView.setBannerRound(DensityUtil.dip2px(getContext(), 10));
//添加生命周期觀察者
bannerView.addBannerLifecycleObserver(fragment);
bannerView.setIndicator(new CircleIndicator(getContext()));
推薦歌單
//設置標題,將標題放到每個具體的item上,好處是方便整體排序
holder.setText(R.id.title, R.string.recommend_sheet);
//顯示更多容器
holder.setVisible(R.id.more, true);
holder.getView(R.id.more).setOnClickListener(v -> {
});
RecyclerView listView = holder.getView(R.id.list);
if (listView.getAdapter() == null) {
//設置顯示3列
GridLayoutManager layoutManager = new GridLayoutManager(listView.getContext(), 3);
listView.setLayoutManager(layoutManager);
sheetAdapter = new SheetAdapter(R.layout.item_sheet);
//item點擊
sheetAdapter.setOnItemClickListener(new OnItemClickListener() {
@Override
public void onItemClick(@NonNull BaseQuickAdapter<?, ?> adapter, @NonNull View view, int position) {
if (discoveryAdapterListener != null) {
discoveryAdapterListener.onSheetClick((Sheet) adapter.getItem(position));
}
}
});
listView.setAdapter(sheetAdapter);
GridDividerItemDecoration itemDecoration = new GridDividerItemDecoration(getContext(), (int) DensityUtil.dip2px(getContext(), 5F));
listView.addItemDecoration(itemDecoration);
}
sheetAdapter.setNewInstance(data.getData());
歌單詳情
頂部是歌單信息,通過header實現,底部是列表,顯示歌單內容的音樂,點擊音樂進入黑膠唱片播放界面。
//添加頭部
adapter.addHeaderView(createHeaderView());
/**
* 顯示數據的方法
*
* @param holder
* @param data
*/
@Override
protected void convert(@NonNull BaseViewHolder holder, Song data) {
//顯示位置
holder.setText(R.id.index, String.valueOf(holder.getLayoutPosition() + offset));
//顯示標題
holder.setText(R.id.title, data.getTitle());
//顯示信息
holder.setText(R.id.info, data.getSinger().getNickname());
if (offset != 0) {
holder.setImageResource(R.id.more, R.drawable.close);
holder.getView(R.id.more)
.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
SuperDialog.newInstance(fragmentManager)
.setTitleRes(R.string.confirm_delete)
.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
//查詢下載任務
DownloadInfo downloadInfo = AppContext.getInstance().getDownloadManager().getDownloadById(data.getId());
if (downloadInfo != null) {
//從下載框架刪除
AppContext.getInstance().getDownloadManager().remove(downloadInfo);
} else {
AppContext.getInstance().getOrm().deleteSong(data);
}
//從適配器中刪除
removeAt(holder.getAdapterPosition());
}
}).show();
}
});
} else {
//是否下載
DownloadInfo downloadInfo = AppContext.getInstance().getDownloadManager().getDownloadById(data.getId());
if (downloadInfo != null && downloadInfo.getStatus() == DownloadInfo.STATUS_COMPLETED) {
//下載完成了
//顯示下載完成了圖標
holder.setGone(R.id.download, false);
} else {
holder.setGone(R.id.download, true);
}
}
//處理編輯狀態
if (isEditing()) {
holder.setVisible(R.id.index, false);
holder.setVisible(R.id.check, true);
holder.setVisible(R.id.more, false);
if (isSelected(holder.getLayoutPosition())) {
holder.setImageResource(R.id.check, R.drawable.ic_checkbox_selected);
} else {
holder.setImageResource(R.id.check, R.drawable.ic_checkbox);
}
} else {
holder.setVisible(R.id.index, true);
holder.setVisible(R.id.check, false);
holder.setVisible(R.id.more, true);
}
}
黑膠唱片
上面是黑膠唱片,和網易雲音樂差不多,隨著音樂滾動或暫停,頂部是控制相關,音樂播放邏輯是封裝到MusicPlayerManager中:
/**
* 播放管理器預設實現
*/
public class MusicPlayerManagerImpl implements MusicPlayerManager, MediaPlayer.OnCompletionListener, AudioManager.OnAudioFocusChangeListener {
...
/**
* 獲取播放管理器
* getInstance:方法名可以隨便取
* 只是在Java這邊大部分項目都取這個名字
*
* @return
*/
public synchronized static MusicPlayerManager getInstance(Context context) {
if (instance == null) {
instance = new MusicPlayerManagerImpl(context);
}
return instance;
}
@Override
public void play(String uri, Song data) {
//保存信息
this.uri = uri;
this.data = data;
//釋放播放器
player.reset();
//獲取音頻焦點
if (!requestAudioFocus()) {
return;
}
playNow();
}
private void playNow() {
isPrepare = true;
try {
if (uri.startsWith("content://")) {
//內容提供者格式
//本地音樂
//uri示例:content://media/external/audio/media/23
player.setDataSource(context, Uri.parse(uri));
} else {
//設置數據源
player.setDataSource(uri);
}
//同步準備
//真實項目中可能會使用非同步
//因為如果網路不好
//同步可能會卡住
player.prepare();
// player.prepareAsync();
//開始播放器
player.start();
//回調監聽器
publishPlayingStatus();
//啟動播放進度通知
startPublishProgress();
prepareLyric(data);
} catch (IOException e) {
//TODO 播放錯誤處理
}
}
@Override
public void pause() {
if (isPlaying()) {
//如果在播放就暫停
player.pause();
ListUtil.eachListener(listeners, musicPlayerListener -> musicPlayerListener.onPaused(data));
stopPublishProgress();
}
}
@Override
public void resume() {
if (!isPlaying()) {
//獲取音頻焦點
if (!requestAudioFocus()) {
return;
}
resumeNow();
}
}
private void resumeNow() {
//如果沒有播放就播放
player.start();
//回調監聽器
publishPlayingStatus();
//啟動進度通知
startPublishProgress();
}
@Override
public void addMusicPlayerListener(MusicPlayerListener listener) {
if (!listeners.contains(listener)) {
listeners.add(listener);
}
//啟動進度通知
startPublishProgress();
}
@Override
public void removeMusicPlayerListener(MusicPlayerListener listener) {
listeners.remove(listener);
}
@Override
public void seekTo(int progress) {
player.seekTo(progress);
}
/**
* 發佈播放中狀態
*/
private void publishPlayingStatus() {
// for (MusicPlayerListener listener : listeners) {
// listener.onPlaying(data);
// }
//使用重構後的方法
ListUtil.eachListener(listeners, musicPlayerListener -> musicPlayerListener.onPlaying(data));
}
/**
* 播放完畢了回調
*
* @param mp
*/
@Override
public void onCompletion(MediaPlayer mp) {
isPrepare = false;
//回調監聽器
ListUtil.eachListener(listeners, listener -> listener.onCompletion(mp));
}
@Override
public void setLooping(boolean looping) {
player.setLooping(looping);
}
/**
* 音頻焦點改變了回調
*
* @param focusChange
*/
@Override
public void onAudioFocusChange(int focusChange) {
Timber.d("onAudioFocusChange %s", focusChange);
switch (focusChange) {
case AudioManager.AUDIOFOCUS_GAIN:
//獲取到焦點了
if (resumeOnFocusGain) {
if (isPrepare) {
resumeNow();
} else {
playNow();
}
resumeOnFocusGain = false;
}
break;
case AudioManager.AUDIOFOCUS_LOSS:
//永久失去焦點,例如:其他應用請求時,也是播放音樂
if (isPlaying()) {
pause();
}
break;
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
//暫時性失去焦點,例如:通話了,或者呼叫了語音助手等請求
if (isPlaying()) {
resumeOnFocusGain = true;
pause();
}
break;
}
}
}
音樂列表邏輯封裝到MusicListManager:
public class MusicListManagerImpl implements MusicListManager, MusicPlayerListener {
@Override
public void setDatum(List<Song> datum) {
//將原來數據playList標誌設置為false
DataUtil.changePlayListFlag(this.datum, false);
//保存到資料庫
saveAll();
//清空原來的數據
this.datum.clear();
//添加新的數據
this.datum.addAll(datum);
//更改播放列表標誌
DataUtil.changePlayListFlag(this.datum, true);
//保存到資料庫
saveAll();
sendPlayListChangedEvent(0);
}
/**
* 保存播放列表
*/
private void saveAll() {
getOrm().saveAll(datum);
}
private LiteORMUtil getOrm() {
return LiteORMUtil.getInstance(this.context);
}
@Override
public void play(Song data) {
//當前音樂黑膠唱片滾動
data.setRotate(true);
//標記已經播放了
isPlay = true;
//保存數據
this.data = data;
if (StringUtils.isNotBlank(data.getPath())) {
//本地音樂
//不拼接地址
musicPlayerManager.play(data.getPath(), data);
} else {
//判斷是否有下載對象
DownloadInfo downloadInfo = AppContext.getInstance().getDownloadManager().getDownloadById(data.getId());
if (downloadInfo != null && downloadInfo.getStatus() == DownloadInfo.STATUS_COMPLETED) {
//下載完成了
//播放本地音樂
musicPlayerManager.play(downloadInfo.getPath(), data);
Timber.d("play offline %s %s %s", data.getTitle(), downloadInfo.getPath(), data.getUri());
} else {
//播放線上音樂
String path = ResourceUtil.resourceUri(data.getUri());
musicPlayerManager.play(path, data);
Timber.d("play online %s %s", data.getTitle(), path);
}
}
//設置最後播放音樂的Id
sp.setLastPlaySongId(data.getId());
}
@Override
public void pause() {
musicPlayerManager.pause();
}
@Override
public Song next() {
if (datum.size() == 0) {
//如果沒有音樂了
//直接返回null
return null;
}
//音樂索引
int index = 0;
//判斷迴圈模式
switch (model) {
case MODEL_LOOP_RANDOM:
//隨機迴圈
//在0~datum.size()中
//不包含datum.size()
index = new Random().nextInt(datum.size());
break;
default:
//找到當前音樂索引
index = datum.indexOf(data);
if (index != -1) {
//找到了
//如果當前播放是列表最後一個
if (index == datum.size() - 1) {
//最後一首音樂
//那就從0開始播放
index = 0;
} else {
index++;
}
} else {
//拋出異常
//因為正常情況下是能找到的
throw new IllegalArgumentException("Cant'found current song");
}
break;
}
return datum.get(index);
}
@Override
public void delete(int position) {
//獲取要刪除的音樂
Song song = datum.get(position);
if (song.getId().equals(data.getId())) {
//刪除的音樂就是當前播放的音樂
//應該停止當前播放
pause();
//並播放下一首音樂
Song next = next();
if (next.getId().equals(data.getId())) {
//找到了自己
//沒有歌曲可以播放了
data = null;
//TODO Bug 隨機迴圈的情況下有可能獲取到自己
} else {
play(next);
}
}
//直接刪除
datum.remove(song);
//從資料庫中刪除
getOrm().deleteSong(song);
sendPlayListChangedEvent(position);
}
private void sendPlayListChangedEvent(int position) {
EventBus.getDefault().post(new MusicPlayListChangedEvent(position));
}
/**
* 播放完畢了回調
*
* @param mp
*/
@Override
public void onCompletion(MediaPlayer mp) {
if (model == MODEL_LOOP_ONE) {
//如果是單曲迴圈
//就不會處理了
//因為我們使用了MediaPlayer的迴圈模式
//如果使用的第三方框架
//如果沒有迴圈模式
//那就要在這裡繼續播放當前音樂
} else {
Song data = next();
if (data != null) {
play(data);
}
}
}
...
}
外界統一使用播放列表管理器播放音樂,上一曲下一曲:
//播放按鈕點擊
binding.play.setOnClickListener(v -> {
playOrPause();
});
//下一曲按鈕點擊
binding.next.setOnClickListener(v -> {
getMusicListManager().play(getMusicListManager().next());
});
//播放列表按鈕點擊
binding.listButton.setOnClickListener(v -> {
MusicPlayListDialogFragment.show(getSupportFragmentManager());
});
媒體控制器/桌面歌詞/桌面Widget
歌詞實現了LRC,KSC兩種歌詞,封裝到LyricListView,單個歌詞行封裝到LyricView中,外界直接使用LyricListView就行:
private void showLyricData() {
binding.lyricList.setData(getMusicListManager().getData().getParsedLyric());
}
桌面歌詞使用兩個LyricView顯示兩行歌詞,桌面歌詞使用的是全局懸浮窗API,所以要先判斷是否有許可權,沒有需要先獲取許可權,然後才能顯示,封裝到GlobalLyricManagerImpl中:
/**
* 全局(桌面)歌詞管理器實現
*/
public class GlobalLyricManagerImpl implements GlobalLyricManager, MusicPlayerListener, GlobalLyricView.OnGlobalLyricDragListener, GlobalLyricView.GlobalLyricListener {
public GlobalLyricManagerImpl(Context context) {
this.context = context.getApplicationContext();
//初始化偏好設置工具類
sp = PreferenceUtil.getInstance(this.context);
//初始化音樂播放管理器
musicPlayerManager = MusicPlayerService.getMusicPlayerManager(this.context);
//添加播放監聽器
musicPlayerManager.addMusicPlayerListener(this);
//初始化視窗管理器
initWindowManager();
//從偏好設置中獲取是否要顯示全局歌詞
if (sp.isShowGlobalLyric()) {
//創建全局歌詞View
initGlobalLyricView();
//如果原來鎖定了歌詞
if (sp.isGlobalLyricLock()) {
//鎖定歌詞
lock();
}
}
}
public synchronized static GlobalLyricManagerImpl getInstance(Context context) {
if (instance == null) {
instance = new GlobalLyricManagerImpl(context);
}
return instance;
}
/**
* 鎖定全局歌詞
*/
private void lock() {
//保存全局歌詞鎖定狀態
sp.setGlobalLyricLock(true);
//設置全局歌詞控制項狀態
setGlobalLyricStatus();
//顯示簡單模式
globalLyricView.simpleStyle();
//更新佈局
updateView();
//顯示解鎖全局歌詞通知
NotificationUtil.showUnlockGlobalLyricNotification(context);
//註冊接收解鎖全局歌詞廣告接收器
registerUnlockGlobalLyricReceiver();
}
/**
* 註冊接收解鎖全局歌詞廣告接收器
*/
private void registerUnlockGlobalLyricReceiver() {
if (unlockGlobalLyricBroadcastReceiver == null) {
//創建廣播接受者
unlockGlobalLyricBroadcastReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (Constant.ACTION_UNLOCK_LYRIC.equals(intent.getAction())) {
//歌詞解鎖事件
unlock();
}
}
};
IntentFilter intentFilter = new IntentFilter();
//只監聽歌詞解鎖事件
intentFilter.addAction(Constant.ACTION_UNLOCK_LYRIC);
//註冊
context.registerReceiver(unlockGlobalLyricBroadcastReceiver, intentFilter);
}
}
/**
* 解鎖歌詞
*/
private void unlock() {
//設置沒有鎖定歌詞
sp.setGlobalLyricLock(false);
//設置歌詞狀態
setGlobalLyricStatus();
//解鎖後顯示標準樣式
globalLyricView.normalStyle();
//更新view
updateView();
//清除歌詞解鎖通知
NotificationUtil.clearUnlockGlobalLyricNotification(context);
//解除接收全局歌詞事件廣播接受者
unregisterUnlockGlobalLyricReceiver();
}
/**
* 解除接收全局歌詞事件廣播接受者
*/
private void unregisterUnlockGlobalLyricReceiver() {
if (unlockGlobalLyricBroadcastReceiver != null) {
context.unregisterReceiver(unlockGlobalLyricBroadcastReceiver);
unlockGlobalLyricBroadcastReceiver = null;
}
}
@Override
public void show() {
//檢查全局懸浮窗許可權
if (!Settings.canDrawOverlays(context)) {
Intent intent = new Intent(context, SplashActivity.class);
intent.setAction(Constant.ACTION_LYRIC);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
return;
}
//初始化全局歌詞控制項
initGlobalLyricView();
//設置顯示了全局歌詞
sp.setShowGlobalLyric(true);
WidgetUtil.onGlobalLyricShowStatusChanged(context, isShowing());
}
private boolean hasGlobalLyricView() {
return globalLyricView != null;
}
/**
* 全局歌詞拖拽回調
*
* @param y y軸方向上移動的距離
*/
@Override
public void onGlobalLyricDrag(int y) {
layoutParams.y = y - SizeUtil.getStatusBarHeight(context);
//更新view
updateView();
//保存歌詞y坐標
sp.setGlobalLyricViewY(layoutParams.y);
}
...
}
顯示和隱藏只需要調用該管理器的相關方法就行了。
媒體控制器
使用了可以通過系統媒體控制器,通知欄,鎖屏界面,耳機,藍牙耳機等設備控制媒體播放暫停,只需要把媒體信息更新到系統:
MusicPlayerService
/**
* 更新媒體信息
*
* @param data
* @param icon
*/
public void updateMetaData(Song data, Bitmap icon) {
MediaMetadataCompat.Builder metaData = new MediaMetadataCompat.Builder()
//標題
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, data.getTitle())
//藝術家,也就是歌手
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, data.getSinger().getNickname())
//專輯
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "專輯")
//專輯藝術家
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, "專輯藝術家")
//時長
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, data.getDuration())
//封面
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, icon);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
//播放列表長度
metaData.putLong(MediaMetadataCompat.METADATA_KEY_NUM_TRACKS, musicListManager.getDatum().size());
}
mediaSession.setMetadata(metaData.build());
}
接收媒體控制
/**
* 媒體回調
*/
private MediaSessionCompat.Callback callback = new MediaSessionCompat.Callback() {
@Override
public void onPlay() {
musicListManager.resume();
}
@Override
public void onPause() {
musicListManager.pause();
}
@Override
public void onSkipToNext() {
musicListManager.play(musicListManager.next());
}
@Override
public void onSkipToPrevious() {
musicListManager.play(musicListManager.previous());
}
@Override
public void onSeekTo(long pos) {
musicListManager.seekTo((int) pos);
}
};
桌面Widget
創建佈局,然後註冊,最後就是更新信息:
public class MusicWidget extends AppWidgetProvider {
/**
* 添加,重新運行應用,周期時間,都會調用
*
* @param context
* @param appWidgetManager
* @param appWidgetIds
*/
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
super.onUpdate(context, appWidgetManager, appWidgetIds);
//嘗試啟動service
ServiceUtil.startService(context.getApplicationContext(), MusicPlayerService.class);
//獲取播放列表管理器
MusicListManager musicListManager = MusicPlayerService.getListManager(context.getApplicationContext());
//獲取當前播放的音樂
final Song data = musicListManager.getData();
final int N = appWidgetIds.length;
// 迴圈處理每一個,因為桌面上可能添加多個
for (int i = 0; i < N; i++) {
int appWidgetId = appWidgetIds[i];
// 創建遠程式控制件,所有對view的操作都必須通過該view提供的方法
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.music_widget);
//因為這是在桌面的控制項裡面顯示我們的控制項,所以不能直接通過setOnClickListener設置監聽器
//這裡發送的動作在MusicReceiver處理
PendingIntent iconPendingIntent = IntentUtil.createMainActivityPendingIntent(context, Constant.ACTION_MUSIC_PLAYER_PAGE);
//這裡直接啟動service,也可以用廣播接收
PendingIntent previousPendingIntent = IntentUtil.createMusicPlayerServicePendingIntent(context, Constant.ACTION_PREVIOUS);
PendingIntent playPendingIntent = IntentUtil.createMusicPlayerServicePendingIntent(context, Constant.ACTION_PLAY);
PendingIntent nextPendingIntent = IntentUtil.createMusicPlayerServicePendingIntent(context, Constant.ACTION_NEXT);
PendingIntent lyricPendingIntent = IntentUtil.createMusicPlayerServicePendingIntent(context, Constant.ACTION_LYRIC);
//設置點擊事件
views.setOnClickPendingIntent(R.id.icon, iconPendingIntent);
views.setOnClickPendingIntent(R.id.previous, previousPendingIntent);
views.setOnClickPendingIntent(R.id.play, playPendingIntent);
views.setOnClickPendingIntent(R.id.next, nextPendingIntent);
views.setOnClickPendingIntent(R.id.lyric, lyricPendingIntent);
if (data == null) {
//當前沒有播放音樂
appWidgetManager.updateAppWidget(appWidgetId, views);
} else {
//有播放音樂
views.setTextViewText(R.id.title, String.format("%s - %s", data.getTitle(), data.getSinger().getNickname()));
views.setProgressBar(R.id.progress, (int) data.getDuration(), (int) data.getProgress(), false);
//顯示圖標
RequestOptions options = new RequestOptions();
options.centerCrop();
Glide.with(context)
.asBitmap()
.load(ResourceUtil.resourceUri(data.getIcon()))
.apply(options)
.into(new CustomTarget<Bitmap>() {
@Override
public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition<? super Bitmap> transition) {
//顯示封面
views.setImageViewBitmap(R.id.icon, resource);
appWidgetManager.updateAppWidget(appWidgetId, views);
}
@Override
public void onLoadCleared(@Nullable Drawable placeholder) {
//顯示預設圖片
views.setImageViewBitmap(R.id.icon, BitmapFactory.decodeResource(context.getResources(), R.drawable.placeholder));
appWidgetManager.updateAppWidget(appWidgetId, views);
}
});
}
}
}
}
登錄/註冊/驗證碼登錄
登錄註冊沒有多大難度,用戶名和密碼登錄,就是把信息傳遞到服務端,可以加密後在傳輸,服務端判斷登錄成功,返回一個標記,客戶端保存,其他需要的登錄的介面帶上;驗證碼登錄就是用驗證碼代替密碼,發送驗證碼都是服務端發送,客戶端只需要調用介面。
評論
評論列表包括下拉刷新,上拉載入更多,點贊,發佈評論,回覆評論,Emoji,話題和提醒人點擊,選擇好友,選擇話題等。
下拉刷新和下拉載入更多
核心邏輯就只需要更改page就行了
//下拉刷新監聽器
binding.refresh.setOnRefreshListener(new OnRefreshListener() {
@Override
public void onRefresh(RefreshLayout refreshlayout) {
loadData();
}
});
//上拉載入更多
binding.refresh.setOnLoadMoreListener(new OnLoadMoreListener() {
@Override
public void onLoadMore(RefreshLayout refreshlayout) {
loadMore();
}
});
@Override
protected void loadData(boolean isPlaceholder) {
super.loadData(isPlaceholder);
isRefresh = true;
pageMeta = null;
loadMore();
}
提醒人和話題點擊
通過正則表達式,找到特殊文本,然後使用富文本實現點擊。
holder.setText(R.id.content, processContent(data.getContent()));
/**
* 處理文本點擊事件
* 這部分可以用監聽器回調到Activity中處理
*
* @param content
* @return
*/
private SpannableString processContent(String content) {
//設置點擊事件
SpannableString result = RichUtil.processContent(getContext(), content,
new RichUtil.OnTagClickListener() {
@Override
public void onTagClick(String data, RichUtil.MatchResult matchResult) {
String clickText = RichUtil.removePlaceholderString(data);
Timber.d("processContent mention click %s", clickText);
UserDetailActivity.startWithNickname(getContext(), clickText);
}
},
(data, matchResult) -> {
String clickText = RichUtil.removePlaceholderString(data);
Timber.d("processContent hash tag %s", clickText);
});
//返回結果
return result;
}
選擇好友
對數據分組,然後顯示右側索引,選擇了通過EventBus發送到評論界面。
adapter.setOnItemClickListener(new OnItemClickListener() {
@Override
public void onItemClick(@NonNull BaseQuickAdapter<?, ?> adapter, @NonNull View view, int position) {
Object data = adapter.getItem(position);
if (data instanceof User) {
if (Constant.STYLE_FRIEND_SELECT == style) {
EventBus.getDefault().post(new SelectedFriendEvent((User) data));
//關閉界面
finish();
} else {
startActivityExtraId(UserDetailActivity.class, ((User) data).getId());
}
}
}
});
}
視頻和播放
真實項目中視頻播放大部分都是用第三方服務,例如:阿裡雲視頻服務,騰訊視頻服務,因為他們提供一條龍服務,包括審核,轉碼,CDN,安全,播放器等,這裡用不到這麼多功能,所以使用了第三方播放器播放普通mp4,這使用餃子播放器框架。
GSYVideoOptionBuilder videoOption = new GSYVideoOptionBuilder();
videoOption
// .setThumbImageView(imageView)
//小屏時不觸摸滑動
.setIsTouchWiget(false)
//音頻焦點衝突時是否釋放
.setReleaseWhenLossAudio(true)
.setRotateViewAuto(false)
.setLockLand(false)
.setAutoFullWithSize(true)
.setSeekOnStart(seek)
.setNeedLockFull(true)
.setUrl(ResourceUtil.resourceUri(data.getUri()))
.setCacheWithPlay(false)
//全屏切換時不使用動畫
.setShowFullAnimation(false)
.setVideoTitle(data.getTitle())
//設置右下角 顯示切換到全屏 的按鍵資源
.setEnlargeImageRes(R.drawable.full_screen)
//設置右下角 顯示退出全屏 的按鍵資源
.setShrinkImageRes(R.drawable.normal_screen)
.setVideoAllCallBack(new GSYSampleCallBack() {
@Override
public void onPrepared(String url, Object... objects) {
super.onPrepared(url, objects);
//開始播放了才能旋轉和全屏
orientationUtils.setEnable(true);
isPlay = true;
}
@Override
public void onQuitFullscreen(String url, Object... objects) {
super.onQuitFullscreen(url, objects);
if (orientationUtils != null) {
orientationUtils.backToProtVideo();
}
}
}).setLockClickListener(new LockClickListener() {
@Override
public void onClick(View view, boolean lock) {
if (orientationUtils != null) {
//配合下方的onConfigurationChanged
orientationUtils.setEnable(!lock);
}
}
}).build(binding.player);
//開始播放
binding.player.startPlayLogic();
用戶詳情/更改資料
用戶詳情頂部顯示用戶信息,好友數量,下麵分別顯示創建的歌單,收藏的歌單,發佈的動態,類似微信朋友圈,右上角可以更改用戶資料;整體採用CoordinatorLayout+TabLayout+ViewPager+Fragment實現。
public Fragment getItem(int position) {
switch (position) {
case 0:
return UserDetailSheetFragment.newInstance(userId);
case 1:
return FeedFragment.newInstance(userId);
default:
return UserDetailAboutFragment.newInstance(userId);
}
}
/**
* 返回標題
*
* @param position
* @return
*/
@Nullable
@Override
public CharSequence getPageTitle(int position) {
//獲取字元串id
int resourceId = titleIds[position];
//獲取字元串
return context.getResources().getString(resourceId);
}
發佈動態/選擇位置/路徑規劃
發佈效果和微信朋友圈類似,可以選擇圖片,和地理位置;地理位置使用高德地圖實現選擇,路徑規劃是調用系統中安裝的地圖,類似微信。
選擇位置
/**
* 搜索該位置的poi,方便用戶選擇,也方便其他人找
* Point Of Interest,興趣點)
*/
private void searchPOI(LatLng data, String keyword) {
try {
Timber.d("searchPOI %s %s", data, keyword);
binding.progress.setVisibility(View.VISIBLE);
adapter.setNewInstance(new ArrayList<>());
// 第一個參數表示一個Latlng,第二參數表示範圍多少米,第三個參數表示是火系坐標系還是GPS原生坐標系
// val query = RegeocodeQuery(
// LatLonPoint(data.latitude, data.longitude)
// , 1000F, GeocodeSearch.AMAP
// )
//
// geocoderSearch.getFromLocationAsyn(query)
//keyWord表示搜索字元串,
//第二個參數表示POI搜索類型,二者選填其一,選用POI搜索類型時建議填寫類型代碼,碼表可以參考下方(而非文字)
//cityCode表示POI搜索區域,可以是城市編碼也可以是城市名稱,也可以傳空字元串,空字元串代表全國在全國範圍內進行搜索
PoiSearch.Query query = new PoiSearch.Query(keyword, "");
query.setPageSize(10); // 設置每頁最多返回多少條poiitem
query.setPageNum(0); //設置查詢頁碼
PoiSearch poiSearch = new PoiSearch(this, query);
poiSearch.setOnPoiSearchListener(this);
//設置周邊搜索的中心點以及半徑
if (data != null) {
poiSearch.setBound(new PoiSearch.SearchBound(
new LatLonPoint(
data.latitude,
data.longitude
), 1000
));
}
poiSearch.searchPOIAsyn();
} catch (Exception e) {
e.printStackTrace();
}
}
高德地圖路徑規劃
/**
* 使用高德地圖路徑規劃
*
* @param context
* @param slat 起點緯度
* @param slon 起點經度
* @param sname 起點名稱 可不填(0,0,null)
* @param dlat 終點緯度
* @param dlon 終點經度
* @param dname 終點名稱 必填
* 官方文檔:https://lbs.amap.com/api/amap-mobile/guide/android/route
*/
public static void openAmapRoute(
Context context,
double slat,
double slon,
String sname,
double dlat,
double dlon,
String dname
) {
StringBuilder builder = new StringBuilder("amapuri://route/plan?");
//第三方調用應用名稱
builder.append("sourceApplication=");
builder.append(context.getString(R.string.app_name));
//開始信息
if (slat != 0.0) {
builder.append("&sname=").append(sname);
builder.append("&slat=").append(slat);
builder.append("&slon=").append(slon);
}
//結束信息
builder.append("&dlat=").append(dlat)
.append("&dlon=").append(dlon)
.append("&dname=").append(dname)
.append("&dev=0")
.append("&t=0");
startActivity(context, Constant.PACKAGE_MAP_AMAP, builder.toString());
}
聊天/離線推送
大部分真實項目中聊天都會選擇第三方商業級付費聊天服務,常用的有騰訊雲聊天,融雲聊天,網易雲聊天等,這裡選擇融雲聊天服務,使用步驟是先在服務端生成聊天Token,這裡是登錄後返回,然後客戶端登錄聊天伺服器,然後設置消息監聽,發送消息等。
登錄聊天伺服器
/**
* 連接聊天伺服器
*
* @param data
*/
private void connectChat(Session data) {
RongIMClient.connect(data.getChatToken(), new RongIMClient.ConnectCallback() {
/**
* 成功回調
* @param userId 當前用戶 ID
*/
@Override
public void onSuccess(String userId) {
Timber.d("connect chat success %s", userId);
}
/**
* 錯誤回調
* @param errorCode 錯誤碼
*/
@Override
public void onError(RongIMClient.ConnectionErrorCode errorCode) {
Timber.e("connect chat error %s", errorCode);
if (errorCode.equals(RongIMClient.ConnectionErrorCode.RC_CONN_TOKEN_INCORRECT)) {
//從 APP 服務獲取新 token,並重連
} else {
//無法連接 IM 伺服器,請根據相應的錯誤碼作出對應處理
}
//因為我們這個應用,不是類似微信那樣純聊天應用,所以聊天伺服器連接失敗,也讓進入應用
//真實項目中按照需求實現就行了
SuperToast.show(R.string.error_message_login);
}
/**
* 資料庫回調.
* @param databaseOpenStatus 資料庫打開狀態. DATABASE_OPEN_SUCCESS 資料庫打開成功; DATABASE_OPEN_ERROR 資料庫打開失敗
*/
@Override
public void onDatabaseOpened(RongIMClient.DatabaseOpenStatus databaseOpenStatus) {
}
});
}
設置消息監聽
chatClient.addOnReceiveMessageListener(new OnReceiveMessageWrapperListener() {
@Override
public void onReceivedMessage(Message message, ReceivedProfile profile) {
//該方法的調用不再主線程
Timber.e("chat onReceived %s", message);
if (EventBus.getDefault().hasSubscriberForEvent(NewMessageEvent.class)) {
//如果有監聽該事件,表示在聊天界面,或者會話界面
EventBus.getDefault().post(new NewMessageEvent(message));
} else {
handler.obtainMessage(0, message).sendToTarget();
}
//發送消息未讀數改變了通知
EventBus.getDefault().post(new MessageUnreadCountChangedEvent());
}
});
發送文本消息
發送圖片等其他消息也是差不多。
private void sendTextMessage() {
String content = binding.input.getText().toString().trim();
if (StringUtils.isEmpty(content)) {
SuperToast.show(R.string.hint_enter_message);
return;
}
TextMessage textMessage = TextMessage.obtain(content);
RongIMClient.getInstance().sendMessage(Conversation.ConversationType.PRIVATE, targetId, textMessage, null, MessageUtil.createPushData(MessageUtil.getContent(textMessage), sp.getUserId()), new IRongCallback.ISendMessageCallback() {
@Override
public void onAttached(Message message) {
// 消息成功存到本地資料庫的回調
Timber.d("sendTextMessage onAttached %s", message);
}
@Override
public void onSuccess(Message message) {
// 消息發送成功的回調
Timber.d("sendTextMessage success %s", message);
//清空輸入框
clearInput();
addMessage(message);
}
@Override
public void onError(Message message, RongIMClient.ErrorCode errorCode) {
// 消息發送失敗的回調
Timber.e("sendTextMessage onError %s %s", message, errorCode);
}
});
}
離線推送
先開啟SDK離線推送,還要分別去廠商那邊申請推送配置,這裡只實現了小米推送,其他的華為推送,OPPO推送等差不多;然後把推送,或者點擊都統一代理到主界面,然後再處理。
private void postRun(Intent intent) {
String action = intent.getAction();
if (Constant.ACTION_CHAT.equals(action)) {
//本地顯示的消息通知點擊
//要跳轉到聊天界面
String id = intent.getStringExtra(Constant.ID);
startActivityExtraId(ChatActivity.class, id);
} else if (Constant.ACTION_PUSH.equals(action)) {
//聊天通知點擊
String id = intent.getStringExtra(Constant.PUSH);
startActivityExtraId(ChatActivity.class, id);
}
}
商城/訂單/支付/購物車
學到這裡,大家不能說熟悉,那麼看到上面的界面,那麼大體要能實現出來。
商品詳情富文本
//詳情
HtmlText.from(data.getDetail())
.setImageLoader(new HtmlImageLoader() {
@Override
public void loadImage(String url, final Callback callback) {
Glide.with(getHostActivity())
.asBitmap()
.load(url)
.into(new CustomTarget<Bitmap>() {
@Override
public void onResourceReady(@NonNull Bitmap resource, @Nullable Transition<? super Bitmap> transition) {
callback.onLoadComplete(resource);
}
@Override
public void onLoadCleared(@Nullable Drawable placeholder) {
callback.onLoadFailed();
}
});
}
@Override
public Drawable getDefaultDrawable() {
return ContextCompat.getDrawable(getHostActivity(), R.drawable.placeholder);
}
@Override
public Drawable getErrorDrawable() {
return ContextCompat.getDrawable(getHostActivity(), R.drawable.placeholder_error);
}
@Override
public int getMaxWidth() {
return ScreenUtil.getScreenWith(getHostActivity());
}
@Override
public boolean fitWidth() {
return true;
}
})
.setOnTagClickListener(new OnTagClickListener() {
@Override
public void onImageClick(Context context, List<String> imageUrlList, int position) {
// image click
}
@Override
public void onLinkClick(Context context, String url) {
// link click
Timber.d("onLinkClick %s", url);
}
})
.into(binding.detail);
支付
客戶端先集成微信,支付寶SDK,然後請求服務端獲取支付信息,設置到SDK,最後就是處理支付結果。
/**
* 處理支付寶支付
*
* @param data
*/
private void processAlipay(String data) {
PayUtil.alipay(getHostActivity(), data);
}
/**
* 處理微信支付
*
* @param data
*/
private void processWechat(WechatPay data) {
//把服務端返回的參數
//設置到對應的欄位
PayReq request = new PayReq();
request.appId = data.getAppid();
request.partnerId = data.getPartnerid();
request.prepayId = data.getPrepayid();
request.nonceStr = data.getNoncestr();
request.timeStamp = data.getTimestamp();
request.packageValue = data.getPackageValue();
request.sign = data.getSign();
AppContext.getInstance().getWxapi().sendReq(request);
}
處理支付結果
/**
* 支付寶支付狀態改變了
*
* @param event
*/
@Subscribe(threadMode = ThreadMode.MAIN)
public void onAlipayStatusChanged(AlipayStatusChangedEvent event) {
String resultStatus = event.getData().getResultStatus();
if ("9000".equals(resultStatus)) {
//本地支付成功
//不能依賴本地支付結果
//一定要以服務端為準
showLoading(R.string.hint_pay_wait);
//延時3秒
//因為支付寶回調我們服務端可能有延遲
binding.primary.postDelayed(() -> {
checkPayStatus();
}, 3000);
} else if ("6001".equals(resultStatus)) {
//支付取消
SuperToast.show(R.string.error_pay_cancel);
} else {
//支付失敗
SuperToast.show(R.string.error_pay_failed);
}
}
語音識別輸入地址
這裡使用百度語音識別SDK,先集成,然後初始化,最後是監聽識別結果:
/**
* 百度語音識別事件監聽器
* <p>
* https://ai.baidu.com/ai-doc/SPEECH/4khq3iy52
*/
EventListener voiceRecognitionEventListener = new EventListener() {
/**
* 事件回