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

2022-07-01 21:00:45

簡介

這是一個使用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() {
    /**
     * 事件回撥
     * @param name 回撥事件名稱
     * @param params 回撥引數
     * @param data 資料
     * @param offset 開始位置
     * @param length 長度
     */
    @Override
    public void onEvent(String name, String params, byte[] data, int offset, int length) {
        String result = "name: " + name;

        if (name.equals(SpeechConstant.CALLBACK_EVENT_ASR_READY)) {
            // 引擎就緒,可以說話,一般在收到此事件後通過UI通知使用者可以說話了
            setStopVoiceRecognition();
        } else if (name.equals(SpeechConstant.CALLBACK_EVENT_ASR_PARTIAL)) {
            // 一句話的臨時結果,最終結果及語意結果

            if (params == null || params.isEmpty()) {
                return;
            }

            // 識別相關的結果都在這裡
            try {
                JSONObject paramObject = new JSONObject(params);

                //獲取第一個結果
                JSONArray resultsRecognition = paramObject.getJSONArray("results_recognition");

                String voiceRecognitionResult = resultsRecognition.getString(0);

                //可以根據result_type是臨時結果,還是最終結果

                binding.input.setText(voiceRecognitionResult);
                result += voiceRecognitionResult;
            } catch (JSONException e) {
                e.printStackTrace();
            }
        } else if (name.equals(SpeechConstant.CALLBACK_EVENT_ASR_FINISH)) {
            //一句話識別結束(可能含有錯誤資訊) 。最終識別的文字結果在ASR_PARTIAL事件中

            if (params.contains("\"error\":0")) {

            } else if (params.contains("\"error\":7")) {
                SuperToast.show(R.string.voice_error_no_result);
            } else {
                //其他錯誤
                SuperToast.show(getString(R.string.voice_error, params));
            }
        } else if (name.equals(SpeechConstant.CALLBACK_EVENT_ASR_EXIT)) {
            //識別結束,資源釋放
            setStartVoiceRecognition();
        }

        Timber.d("baidu voice recognition onEvent %s", result);
    }
};

百度OCR

使用百度OCR從圖片中識別文字,主要是識別地址,類似順豐公眾號輸入地址時識別功能。

private void recognitionImage(String data) {
    GeneralBasicParams param = new GeneralBasicParams();
    param.setDetectDirection(true);
    param.setImageFile(new File(data));

    // 呼叫通用文字識別服務
    OCR.getInstance(getApplicationContext()).recognizeGeneralBasic(param, new OnResultListener<GeneralResult>() {

        /**
         * 成功
         * @param result
         */
        @Override
        public void onResult(GeneralResult result) {
            StringBuilder builder = new StringBuilder();
            for (WordSimple it : result.getWordList()) {
                builder.append(it.getWords());

                //每一項之間,新增空格,方便OCR失敗
                builder.append(" ");
            }

            binding.input.setText(builder.toString());
        }

        /**
         * 失敗
         * @param error
         */
        @Override
        public void onError(OCRError error) {
            SuperToast.show(getString(R.string.ocr_error, error.getMessage(), error.getErrorCode()));
        }
    });
}

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