高仿Android網易雲音樂OkHttp+Retrofit+RxJava+Glide+MVC+MVVM

語言: CN / TW / HK

簡介

這是一個使用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:[email protected]'

//融雲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標籤,設定後就可以點選了,然後修改預設對話方塊寬度,因為預設的有點窄。

```java public class TermServiceDialogFragment extends BaseViewModelDialogFragment {

...

@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實現。

```java /* * 許可權授權了就會呼叫該方法 * 請求相機許可權目的是掃描二維碼,拍照 / @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,後面有講解。

```java /* * 引導介面介面卡 / public class GuideAdapter extends BaseFragmentStatePagerAdapter {

/***
 *  @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));
}

} ```

```java /* * 引導介面Fragment / public class GuideFragment extends BaseViewModelFragment { ...

@Override
protected void initDatum() {
    super.initDatum();
    int data = getArguments().getInt(Constant.ID);
    binding.icon.setImageResource(data);
}

} ```

廣告介面

實現圖片廣告和影片廣告,廣告資料是在首頁是快取到本地,目的是在啟動介面載入更快,因為真實專案中,大部分專案啟動頁面廣告時間一共就5秒,如果太長了使用者體驗不好,如果是從網路請求,那麼網路可能就耗時2秒左右,所以導致就美喲多少時間顯示廣告了。

下載廣告

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

} ```

顯示廣告

```java /* * 顯示影片廣告 * * @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實現,輪播圖:

```java Banner bannerView = holder.getView(R.id.banner);

BannerImageAdapter bannerImageAdapter = new BannerImageAdapter(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())); ```

推薦歌單

```java //設定標題,將標題放到每個具體的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實現,底部是列表,顯示歌單內容的音樂,點選音樂進入黑膠唱片播放介面。

java //新增頭部 adapter.addHeaderView(createHeaderView());

```java /* * 顯示資料的方法 * * @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中:

```java /* * 播放管理器預設實現 / 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:

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

... } ```

外界統一使用播放列表管理器播放音樂,上一曲下一曲:

```java //播放按鈕點選 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就行:

java private void showLyricData() { binding.lyricList.setData(getMusicListManager().getData().getParsedLyric()); }

桌面歌詞使用兩個LyricView顯示兩行歌詞,桌面歌詞使用的是全域性懸浮窗API,所以要先判斷是否有許可權,沒有需要先獲取許可權,然後才能顯示,封裝到GlobalLyricManagerImpl中:

```java /* * 全域性(桌面)歌詞管理器實現 / 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

```java /* * 更新媒體資訊 * * @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());

} ```

接收媒體控制

```java /* * 媒體回撥 / 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

建立佈局,然後註冊,最後就是更新資訊:

```java 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就行了

```java //下拉重新整理監聽器 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();

} ```

提醒人和話題點選

通過正則表示式,找到特殊文字,然後使用富文字實現點選。

```java 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傳送到評論介面。

```java 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,這使用餃子播放器框架。

```java 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實現。

```java 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);

} ```

釋出動態/選擇位置/路徑規劃

釋出效果和微信朋友圈類似,可以選擇圖片,和地理位置;地理位置使用高德地圖實現選擇,路徑規劃是呼叫系統中安裝的地圖,類似微信。

選擇位置

```java /* * 搜尋該位置的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();
}

} ```

高德地圖路徑規劃

```java /* * 使用高德地圖路徑規劃 * * @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,這裡是登入後返回,然後客戶端登入聊天伺服器,然後設定訊息監聽,傳送訊息等。

登入聊天伺服器

```java / * 連線聊天伺服器 * * @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) {

    }
});

} ```

設定訊息監聽

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

}); ```

傳送文字訊息

傳送圖片等其他訊息也是差不多。

```java 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推送等差不多;然後把推送,或者點選都統一代理到主介面,然後再處理。

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

} ```

商城/訂單/支付/購物車

學到這裡,大家不能說熟悉,那麼看到上面的介面,那麼大體要能實現出來。

商品詳情富文字

```java //詳情 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() {

                    @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,最後就是處理支付結果。 ```java /* * 處理支付寶支付 * * @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);

} ```

處理支付結果

```java /* * 支付寶支付狀態改變了 * * @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,先整合,然後初始化,最後是監聽識別結果: ```java / * 百度語音識別事件監聽器 *

* 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從圖片中識別文字,主要是識別地址,類似順豐公眾號輸入地址時識別功能。

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

} ```

還有一些功能,例如:快捷方式等就不在貼程式碼了。