Repository: KunMinX/Jetpack-MVVM-Best-Practice Branch: master Commit: 543eb8659089 Files: 125 Total size: 403.4 KB Directory structure: gitextract_yu3lvodp/ ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ └── ci.yml ├── .gitignore ├── README.md ├── app/ │ ├── build.gradle │ ├── libs/ │ │ ├── materialiconlib-1.1.5.aar │ │ └── videocache-2.7.1.aar │ ├── proguard-rules.pro │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── com/ │ │ └── kunminx/ │ │ └── puremusic/ │ │ └── ExampleInstrumentedTest.java │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── assets/ │ │ │ └── summary.html │ │ ├── java/ │ │ │ └── com/ │ │ │ └── kunminx/ │ │ │ └── puremusic/ │ │ │ ├── MainActivity.java │ │ │ ├── data/ │ │ │ │ ├── api/ │ │ │ │ │ ├── APIs.java │ │ │ │ │ └── AccountService.java │ │ │ │ ├── bean/ │ │ │ │ │ ├── DownloadState.java │ │ │ │ │ ├── LibraryInfo.java │ │ │ │ │ ├── TestAlbum.java │ │ │ │ │ └── User.java │ │ │ │ ├── config/ │ │ │ │ │ ├── Configs.java │ │ │ │ │ └── Const.java │ │ │ │ └── repository/ │ │ │ │ └── DataRepository.java │ │ │ ├── domain/ │ │ │ │ ├── event/ │ │ │ │ │ ├── DownloadEvent.java │ │ │ │ │ └── Messages.java │ │ │ │ ├── message/ │ │ │ │ │ ├── DrawerCoordinateManager.java │ │ │ │ │ ├── PageMessenger.java │ │ │ │ │ ├── PlayerReceiver.java │ │ │ │ │ └── SharedViewModel.java │ │ │ │ ├── proxy/ │ │ │ │ │ └── PlayerManager.java │ │ │ │ ├── request/ │ │ │ │ │ ├── AccountRequester.java │ │ │ │ │ ├── DownloadRequester.java │ │ │ │ │ ├── InfoRequester.java │ │ │ │ │ └── MusicRequester.java │ │ │ │ └── usecase/ │ │ │ │ ├── CanBeStoppedUseCase.java │ │ │ │ └── DownloadUseCase.java │ │ │ └── ui/ │ │ │ ├── bind/ │ │ │ │ ├── CommonBindingAdapter.java │ │ │ │ ├── DrawerBindingAdapter.java │ │ │ │ ├── IconBindingAdapter.java │ │ │ │ ├── TabPageBindingAdapter.java │ │ │ │ └── WebViewBindingAdapter.java │ │ │ ├── page/ │ │ │ │ ├── DrawerFragment.java │ │ │ │ ├── LoginFragment.java │ │ │ │ ├── MainFragment.java │ │ │ │ ├── PlayerFragment.java │ │ │ │ ├── SearchFragment.java │ │ │ │ ├── adapter/ │ │ │ │ │ ├── DiffUtils.java │ │ │ │ │ ├── DrawerAdapter.java │ │ │ │ │ └── PlaylistAdapter.java │ │ │ │ └── helper/ │ │ │ │ └── DefaultInterface.java │ │ │ ├── view/ │ │ │ │ ├── PlayPauseDrawable.java │ │ │ │ ├── PlayPauseView.java │ │ │ │ └── PlayerSlideListener.java │ │ │ └── widget/ │ │ │ └── PlayerService.java │ │ └── res/ │ │ ├── anim/ │ │ │ ├── h_fragment_enter.xml │ │ │ ├── h_fragment_exit.xml │ │ │ ├── h_fragment_pop_enter.xml │ │ │ └── h_fragment_pop_exit.xml │ │ ├── drawable/ │ │ │ ├── bar_selector_white.xml │ │ │ ├── ic_menu_black_48dp.xml │ │ │ ├── ic_music_note_black_48dp.xml │ │ │ ├── ic_search_black_48dp.xml │ │ │ ├── loading_animation.xml │ │ │ └── progressbar_color.xml │ │ ├── layout/ │ │ │ ├── activity_main.xml │ │ │ ├── adapter_library.xml │ │ │ ├── adapter_play_item.xml │ │ │ ├── fragment_drawer.xml │ │ │ ├── fragment_login.xml │ │ │ ├── fragment_main.xml │ │ │ ├── fragment_player.xml │ │ │ ├── fragment_search.xml │ │ │ ├── notify_player_big.xml │ │ │ └── notify_player_small.xml │ │ ├── layout-land/ │ │ │ ├── activity_main.xml │ │ │ ├── fragment_main.xml │ │ │ └── fragment_player.xml │ │ ├── navigation/ │ │ │ ├── nav_drawer.xml │ │ │ ├── nav_main.xml │ │ │ └── nav_slide.xml │ │ ├── values/ │ │ │ ├── attrs.xml │ │ │ ├── colors.xml │ │ │ ├── dimen.xml │ │ │ ├── strings.xml │ │ │ └── styles.xml │ │ └── xml/ │ │ └── network_security_config.xml │ └── test/ │ └── java/ │ └── com/ │ └── kunminx/ │ └── puremusic/ │ └── ExampleUnitTest.java ├── architecture/ │ ├── build.gradle │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── com/ │ │ └── kunminx/ │ │ └── architecture/ │ │ └── ExampleInstrumentedTest.java │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── kunminx/ │ │ │ └── architecture/ │ │ │ ├── data/ │ │ │ │ └── response/ │ │ │ │ ├── DataResult.java │ │ │ │ ├── ResponseStatus.java │ │ │ │ ├── ResultSource.java │ │ │ │ └── manager/ │ │ │ │ ├── NetworkStateManager.java │ │ │ │ └── NetworkStateReceive.java │ │ │ ├── domain/ │ │ │ │ ├── request/ │ │ │ │ │ ├── AsyncTask.java │ │ │ │ │ └── Requester.java │ │ │ │ └── usecase/ │ │ │ │ ├── UseCase.java │ │ │ │ ├── UseCaseHandler.java │ │ │ │ ├── UseCaseScheduler.java │ │ │ │ └── UseCaseThreadPoolScheduler.java │ │ │ ├── ui/ │ │ │ │ ├── adapter/ │ │ │ │ │ └── CommonViewPagerAdapter.java │ │ │ │ ├── bind/ │ │ │ │ │ └── DrawablesBindingAdapter.java │ │ │ │ └── page/ │ │ │ │ ├── BaseActivity.java │ │ │ │ ├── BaseFragment.java │ │ │ │ └── StateHolder.java │ │ │ └── utils/ │ │ │ ├── AdaptScreenUtils.java │ │ │ ├── BarUtils.java │ │ │ ├── ClickUtils.java │ │ │ ├── DisplayUtils.java │ │ │ ├── ImageUtils.java │ │ │ ├── NetworkUtils.java │ │ │ ├── Res.java │ │ │ ├── ScreenUtils.java │ │ │ ├── ToastUtils.java │ │ │ └── Utils.java │ │ └── res/ │ │ ├── values/ │ │ │ └── strings.xml │ │ └── xml/ │ │ └── file_paths.xml │ └── test/ │ └── java/ │ └── com/ │ └── kunminx/ │ └── architecture/ │ └── ExampleUnitTest.java ├── build.gradle ├── gradle/ │ └── wrapper/ │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat └── settings.gradle ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ [*] charset = utf-8 ij_java_use_single_class_imports = true indent_size = 4 indent_style = space insert_final_newline = true trim_trailing_whitespace = true [*.yml] indent_size = 2 ================================================ FILE: .gitattributes ================================================ * text=auto eol=lf *.bat text eol=crlf *.jar binary ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: gradle directory: "/" schedule: interval: daily time: "21:00" open-pull-requests-limit: 10 target-branch: master ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: [ push, pull_request ] jobs: build: name: Build runs-on: ubuntu-latest strategy: matrix: os: [ubuntu-18.04, macOS-latest, windows-2016] java: [11, 11.0.3] steps: - uses: actions/checkout@v1 - name: set up JDK 11 uses: actions/setup-java@v2 with: distribution: 'zulu' java-version: ${{ matrix.java }} - name: Make gradlew executable run: chmod +x ./gradlew - name: Build run: ./gradlew --parallel app:assembleRelease ================================================ FILE: .gitignore ================================================ .classpath .DS_Store .externalNativeBuild .project .gradle .mtj.tmp .vscode .settings .cxx /.idea local.properties maven-repository mvn-clone build captures gen out target *.class *.txt *.ear *.iml *.jar *.keystore *.log *.nar *.rar *.tar.gz *.war *.zip *.apk ================================================ FILE: README.md ================================================ ![](https://images.xiaozhuanlan.com/photo/2021/b106fd65d34a4a724244e7c5b42a2372.jpg) [《重学安卓》](https://xiaozhuanlan.com/kunminx)付费读者加微信进群:myatejx > [免费试读](https://juejin.cn/post/7106042518457810952),**[专栏目录](https://www.yuque.com/kunminx/fpmbc5/ghlwb5)**,**[更新动态](https://www.yuque.com/kunminx/fpmbc5/in59vu)**,[优惠政策](https://www.yuque.com/kunminx/fpmbc5/of601a)   # 版权声明 我们就本项目 "被卖课" 一事,在掘金发表一期专访 [《开源项目被人拿去做课程卖了 1000 多万是什么体验》](https://juejin.im/post/5ecb4950518825431a669897) 本项目系我为方便开发者们 **无痛理解 Google 开源 Jetpack MVVM 中每个架构组件的 存在缘由、职责边界**,而 **精心设计的高频应用场景**, 与此同时,本项目是作为 [《重学安卓》](https://xiaozhuanlan.com/topic/6017825943)专栏 Jetpack MVVM 系列文章 “配套项目” 而存在,**文章内容和项目代码设计均涉及本人对 Jetpack MVVM 独家理解,本人对此享有著作权**。 任何组织或个人,未经与作者本人沟通,不得将本项目代码设计和本人对 Jetpack MVVM 独家理解用于 "**打包贩卖、引流、出书 和 卖课**" 等商业用途。   # 架构图一览 ![](https://images.xiaozhuanlan.com/photo/2023/b10d6c52e0cdb4197725059399fad12f.jpg)   # 前言 上周我在各大 “技术社区” 发表了一篇 [《Jetpack MVVM 精讲》](https://juejin.im/post/5dafc49b6fb9a04e17209922),原以为在 “知识网红” 唱衰 Android 的 2019 会无人问津,没想到文章一经发布,从 “国内知名公司” 架构师、技术经理,到 “世界级公司” Android 开发都在看。 且从读者反馈来看,近期大部分 Android 开发已跳出舒适圈,开始尝试认识和应用 Jetpack MVVM 到实际项目中。 只可惜,关于 Jetpack MVVM,网上多是 **东拼西凑、人云亦云、通篇贴代码** 文章,这不仅不能提供 “完整视角” 帮助读者 首先明确背景状况,更是给还没入门 Jetpack 读者 **徒添困扰**、起 **劝退** 作用。 好消息是,这一期,我们带着 **精心打磨 Jetpack MVVM 最佳实践案例** 来了!     | 爱不释手交互设计 | 连贯用户体验 | 可信源统一分发 | | :----------------------------------------------------------: | :----------------------------------------------------------: | :----------------------------------------------------------: | | ![](https://upload-images.jianshu.io/upload_images/57036-0a5cdc68f003211a.gif) | ![](https://upload-images.jianshu.io/upload_images/57036-2b21db531e51ff03.gif) | ![](https://upload-images.jianshu.io/upload_images/57036-9a541148ce5bed2e.gif) | | 横竖屏布局无缝切换 | | :----------------------------------------------------: | | ![](https://i.loli.net/2021/08/25/X9rado7AfnCEgv3.gif) |     # 项目简介 本人拥有 3 年 “移动端业务架构” 践行和设计经验,领导或参与团队 “重构” 中大型项目多达十数个,对 Jetpack MVVM 架构在 “确立规范化、标准化开发模式 以 **减少不可预期错误**” 所作的努力,有深入理解。 在这个案例中,我将为你展示,Jetpack MVVM 是如何 **以简驭繁** 地将原本十分容易出错、一出错就会耽搁半天的开发工作,通过寥寥几行代码 轻而易举完成。 > 👆👆👆 划重点!   该项目中, > 我们为 **横、竖屏** 场景安排两套 **截然不同布局**,且在 [生命周期](https://xiaozhuanlan.com/topic/0213584967)、[重建机制](https://xiaozhuanlan.com/topic/7692814530)、[状态管理](https://xiaozhuanlan.com/topic/7692814530)、[DataBinding](https://xiaozhuanlan.com/topic/9816742350)、[ViewModel](https://xiaozhuanlan.com/topic/6257931840)、[LiveData](https://xiaozhuanlan.com/topic/0168753249) 、[Navigation](https://xiaozhuanlan.com/topic/5860149732) 等知识点帮助下,通过寥寥几行代码,轻松做到 **在横竖屏两种布局间 无缝切换,且不产生任何 预期外错误**。 > 我们在多个 Fragment 页面 分别安排 **播放状态指示器**(包括 播放暂停按钮状态、播放列表当前索引指示 等),并向你展示 “如何” 及 “为何” 通过 [LiveData](https://xiaozhuanlan.com/topic/0168753249) **配合** 可信源 [ViewModel](https://xiaozhuanlan.com/topic/6257931840) 或单例,实现 **全应用范围内 “可追溯事件” 统一分发**。 > 我们在 Fragment 和 Activity 之间分别安排 跨页面通信,从而向你展示 如何基于 **迪米特原则**(也称 最少知道原则)、通过 UnPeekLiveData 和 应用级 SharedViewModel 实现 **生命周期安全、确保消息同步可靠一致的 页面通信**。 > 我们在 `ui.page` 、`domain.request` 、`data.repository` 等目录下,分别安排 视图控制器、[ViewModel](https://xiaozhuanlan.com/topic/6257931840) 、Dispatcher 、DataRepository 等 内容,从而向你展示,**单向依赖** 架构设计,是如何通过分层 数据请求和响应,**规避 “内存泄漏”** 等问题。 > 本项目代码一律采用 经过 ISO 认证 标准化工业级语言 Java 来编写。且在上述类中,我们大都 **提供丰富注释**,助你理解 “骨架代码” 为何要如此设计、如此设计能 **在软件工程背景下** 避免哪些不可预期错误。     除了 **在 "以简驭繁" 代码中 掌握 MVVM 最佳实践**,你还可从该项目中获得内容包括: 1. 整洁代码风格 和 标准资源命名规范。 2. 对 “视图控制器” 知识点的 深入理解 和 正确使用。 3. AndroidX 和 Material Design 2 全面使用。 4. ConstraintLayout 约束布局最佳实践。 5. **优秀的 用户体验 和 交互设计**。 6. 绝不使用 Dagger,绝不使用奇技淫巧、编写艰深晦涩代码。 7. The one more thing is: 即日起,可在 "应用商店" 下载体验! [![google-play1.png](https://upload-images.jianshu.io/upload_images/57036-f9dbd7810d38ae95.png)](https://www.coolapk.com/apk/247826) [![coolapk1.png](https://upload-images.jianshu.io/upload_images/57036-6cf24d0c9efe8362.png)](https://www.coolapk.com/apk/247826)     # Thanks to [AndroidX](https://developer.android.google.cn/jetpack/androidx) [Jetpack](https://developer.android.google.cn/jetpack/) [material-components-android](https://github.com/material-components/material-components-android) [轻听](https://play.google.com/store/apps/details?id=com.tencent.qqmusiclocalplayer) [AndroidSlidingUpPanel](https://github.com/umano/AndroidSlidingUpPanel) 项目中使用 图片素材 来自 [UnSplash](https://unsplash.com/) 提供 **免费授权图片**。 项目中使用 音频素材 来自 [BenSound](https://www.bensound.com/) 提供 **免费授权音乐**。     # Who is using 根据小伙伴们 “开源库使用情况” 匿名调查问卷参与,截至 2022年5月28日,我们了解到 包括 “腾讯音乐、网易、BMW、TCL” 在内诸多知名厂商软件,都参考过我们开源的此架构模式,或正在使用我们维护的 [UnPeek-LiveData](https://github.com/KunMinX/UnPeek-LiveData) 等框架。 目前已将统计数据更新到 相关开源库 ReadMe 中,错过本次问卷调查的小伙伴也不用担心,我们继续对此保持开放,不定期将小伙伴们登记的公司和产品更新到表格, 以便吸纳更多小伙伴参与对 “架构组件” 的使用和反馈,集众人所长,让组件得以不断演化和升级。 https://wj.qq.com/s2/8362688/124a/ | 集团 / 公司 / 品牌 / 团队 | 产品 | | ----------------------------------------------------- | -------------- | | 腾讯音乐 | QQ 音乐 | | 网易 | 网易云音乐 | | TCL | 内置文件管理器 | | 贵州广电网络 | 乐播播 | | 上海亿保健康管理有限公司 | 安诺保 | | | 小辣椒 | | ezen | Egshig音乐 | | BMW | Speech | | 上海互教信息有限公司 | 知心慧学教师 | | 美术宝 | 弹唱宝 | | | 网安 | | 字节跳动直播 | 直播 SDK | | 一加手机 | OPNote |     # My Pages Email:[kunminx@gmail.com](mailto:kunminx@gmail.com) Juejin:[KunMinX 在掘金](https://juejin.im/user/58ab0de9ac502e006975d757/posts) [《重学安卓》 专栏](https://xiaozhuanlan.com/kunminx) 付费读者加微信进群:myatejx [![重学安卓小专栏](https://images.xiaozhuanlan.com/photo/2021/d493a54a32e38e7fbcfa68d424ebfd1e.png)](https://xiaozhuanlan.com/kunminx)     # License ``` Copyright 2019-present KunMinX Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` ================================================ FILE: app/build.gradle ================================================ apply plugin: "com.android.application" android { namespace "com.kunminx.puremusic" compileSdk appTargetSdk defaultConfig { applicationId "com.kunminx.puremusic" minSdk appMinSdk targetSdk appTargetSdk versionCode appVersionCode versionName appVersionName testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { debug { applicationIdSuffix ".debug" manifestPlaceholders = [ APP_NAME: "@string/app_name_debug", ] } release { manifestPlaceholders = [ APP_NAME: "@string/app_name", ] minifyEnabled true shrinkResources true proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" } } lintOptions { checkReleaseBuilds false abortOnError false } buildFeatures { dataBinding true } } dependencies { implementation fileTree(dir: "libs", include: ["*.jar", "*.aar"]) implementation project(":architecture") testImplementation "junit:junit:4.13.2" androidTestImplementation "androidx.test.ext:junit:1.1.5" androidTestImplementation "androidx.test.espresso:espresso-core:3.5.1" implementation "org.slf4j:slf4j-android:1.7.36" implementation "com.sothree.slidinguppanel:library:3.4.0" implementation 'com.github.KunMinX:Jetpack-MusicPlayer:5.2.0' implementation 'com.github.KunMinX.KeyValueX:keyvalue:3.7.0-beta' annotationProcessor 'com.github.KunMinX.KeyValueX:keyvalue-compiler:3.7.0-beta' } ================================================ FILE: app/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile -keep class com.kunminx.puremusic.data.bean.** { *; } -keep class com.kunminx.puremusic.data.config.* -keep interface com.kunminx.puremusic.data.config.* -keep class com.kunminx.player.bean.** { *; } -keep class * implements android.os.Parcelable { public static final android.os.Parcelable$Creator *; } -keepnames class * implements java.io.Serializable -keepclassmembers class * implements java.io.Serializable { static final long serialVersionUID; private static final java.io.ObjectStreamField[] serialPersistentFields; !static !transient ; !private ; !private ; private void writeObject(java.io.ObjectOutputStream); private void readObject(java.io.ObjectInputStream); java.lang.Object writeReplace(); java.lang.Object readResolve(); } # webview -keepclassmembers class fqcn.of.javascript.interface.for.Webview { public *; } -keepclassmembers class * extends android.webkit.WebViewClient { public void *(android.webkit.WebView, java.lang.String, android.graphics.Bitmap); public boolean *(android.webkit.WebView, java.lang.String); } -keepclassmembers class * extends android.webkit.WebViewClient { public void *(android.webkit.WebView, jav.lang.String); } # AndroidX -keep class com.google.android.material.** {*;} -keep class androidx.** {*;} -keep public class * extends androidx.** -keep interface androidx.** {*;} -dontwarn com.google.android.material.** -dontnote com.google.android.material.** -dontwarn androidx.** # OkHttp -dontwarn okhttp3.** -keep class okhttp3.**{*;} -dontwarn okio.** -keep class okio.**{*;} # glide -keep public class * implements com.bumptech.glide.module.GlideModule -keep public class * extends com.bumptech.glide.module.AppGlideModule -keep public enum com.bumptech.glide.load.ImageHeaderParser$** { **[] $VALUES; public *; } # RxJava -keep class rx.schedulers.Schedulers { public static ; } -keep class rx.schedulers.ImmediateScheduler { public ; } -keep class rx.schedulers.TestScheduler { public ; } -keep class rx.schedulers.Schedulers { public static ** test(); } -keepclassmembers class rx.internal.util.unsafe.*ArrayQueue*Field* { long producerIndex; long consumerIndex; } -keepclassmembers class rx.internal.util.unsafe.BaseLinkedQueueProducerNodeRef { long producerNode; long consumerNode; } -dontwarn sun.misc.Unsafe # Gson -keepattributes Signature -keepattributes *Annotation* -keep class sun.misc.Unsafe { *; } -keep class * implements com.google.gson.TypeAdapterFactory -keep class * implements com.google.gson.JsonSerializer -keep class * implements com.google.gson.JsonDeserializer ================================================ FILE: app/src/androidTest/java/com/kunminx/puremusic/ExampleInstrumentedTest.java ================================================ package com.kunminx.puremusic; import static org.junit.Assert.assertEquals; import android.content.Context; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.platform.app.InstrumentationRegistry; import org.junit.Test; import org.junit.runner.RunWith; /** * Instrumented test, which will execute on an Android device. * * @see Testing documentation */ @RunWith(AndroidJUnit4.class) public class ExampleInstrumentedTest { @Test public void useAppContext() { // Context of the app under test. Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); assertEquals("com.kunminx.puremusic", appContext.getPackageName()); } } ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/assets/summary.html ================================================ 前言

《重学安卓》付费读者加微信进群:myatejx

 

前言

很高兴见到你!

上周我在 各大技术社区 发表了一篇 《Jetpack MVVM 精讲》,原以为在 知识网红 唱衰安卓 的 2019 会无人问津,没想到文章一经发布,从 国内知名公司 的架构师、技术经理,到 世界级公司 的 Android 开发 都在看。

reader_say.png

并且从读者的反馈来看,近期大部分安卓开发 已跳出舒适圈,开始尝试认识和应用 Jetpack MVVM 到实际的项目开发中。

只可惜,关于 Jetpack MVVM,网上多是 东拼西凑、人云亦云、通篇贴代码 的文章,这不仅不能提供完整的视角 来帮助读者 首先明确背景状况,更是给还没入门 Jetpack 的读者 徒添困扰、起到 劝退 的作用。

好消息是,这一期,我们带着 精心打磨的 Jetpack MVVM 最佳实践案例 来了!  

是让人 爱不释手 的 交互设计! 是 连贯 的 用户体验 可信源 的 统一分发
1231111323.gif 222.gif 333.gif

 

横竖屏布局 的 无缝切换
444.gif

 

项目简介

本人拥有 3 年的 移动端架构 践行和设计经验,领导或参与团队重构的 中大型项目 多达十数个,对 Jetpack MVVM 架构在 确立规范化、标准化 开发模式 以 减少不可预期的错误 所作的努力,有着深入的理解。

在这个案例中,我将为你展示,Jetpack MVVM 是如何 以简驭繁 地 将原本十分容易出错、一出错就会耽搁半天时间的开发工作,通过 寥寥的几行代码 轻而易举地完成。

👆👆👆 划重点!

 

在这个项目中,

我们为 横、竖屏 的情况 分别安排了两套 截然不同的布局,并且在 生命周期重建机制状态管理DataBindingViewModelLiveDataNavigation 等知识点的帮助下,通过寥寥几行代码,轻松做到 在横竖屏两种布局间 无缝地切换,并且不产生任何 预期外的错误

 

我们在多个 Fragment 页面 分别安排了 播放状态 指示器(包括 播放暂停按钮状态、播放列表当前索引指示 等),并向你展示了 如何 以及为何 通过 LiveData 配合 作为可信源 的 ViewModel 或单例,来实现 全应用范围内 可追溯事件 的统一分发

 

我们在 Fragment 和 Activity 之间分别安排了 跨页面通信,从而向你展示 如何基于 迪米特原则(也称 最少知道原则)、通过 UnPeekLiveData 和 应用级 SharedViewModel 来实现 生命周期安全的、确保消息同步一致性和可靠性的 页面通信(事件回调)。

 

我们在 ui.pagedata.repositorybridge.request 等目录下,分别安排了 视图控制器、ViewModel 、DataRepository 等 内容,从而向你展示,单向依赖 的架构设计,是如何通过分层的 数据请求和响应,来 规避 内存泄漏 等问题。

 

本项目的代码一律采用 经过 ISO 认证的 标准化工业级语言 Java 来编写。并且,在上述目录 所包含的 类中,我们大都 提供了丰富的注释,来帮助你理解 骨架代码 为何要如此设计、如此设计能够 在软件工程的背景下 避免哪些不可预期的错误。

   

除了 在 以简驭繁 的代码中 掌握 MVVM 最佳实践,你还可以 从这个开源项目中 获得的内容 包括:

  1. 整洁的代码风格 和 标准的资源命名规范。
  2. 对 视图控制器 知识点的 深入理解 和 正确使用。
  3. AndroidX 和 Material Design 2 的全面使用。
  4. ConstraintLayout 约束布局的最佳实践。
  5. 优秀的 用户体验 和 交互设计
  6. 绝不使用 Dagger,绝不使用奇技淫巧、编写艰深晦涩的代码。
  7. The one more thing is:

即日起,可在 应用商店 下载体验!

 

google-play1.png coolapk1.png

   

Thanks to

AndroidX

Jetpack

material-components-android

轻听

AndroidSlidingUpPanel

项目中使用的 图片素材 来自 UnSplash 提供的 无版权免费图片

项目中使用的 音频素材 来自 BenSound 提供的 无版权免费音乐

   

My Pages

Email:kunminx@gmail.com

Home:KunMinX 的个人博客

Juejin:KunMinX 在掘金

《重学安卓》 专栏

付费读者加微信进群:myatejx

重学安卓小专栏

License

 

================================================ FILE: app/src/main/java/com/kunminx/puremusic/MainActivity.java ================================================ /* * Copyright 2018-present KunMinX * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kunminx.puremusic; import android.os.Bundle; import android.view.View; import androidx.drawerlayout.widget.DrawerLayout; import androidx.navigation.NavController; import androidx.navigation.Navigation; import com.kunminx.architecture.ui.page.BaseActivity; import com.kunminx.architecture.ui.page.DataBindingConfig; import com.kunminx.architecture.ui.page.StateHolder; import com.kunminx.architecture.ui.state.State; import com.kunminx.puremusic.domain.event.Messages; import com.kunminx.puremusic.domain.message.DrawerCoordinateManager; import com.kunminx.puremusic.domain.message.PageMessenger; import com.kunminx.puremusic.domain.proxy.PlayerManager; /** * Create by KunMinX at 19/10/16 */ public class MainActivity extends BaseActivity { //TODO tip 1:基于 "单一职责原则",应将 ViewModel 划分为 state-ViewModel 和 result-ViewModel, // state-ViewModel 职责仅限于托管、保存和恢复本页面 state,作用域仅限于本页面, // result-ViewModel 职责仅限于 "消息分发" 场景承担 "可信源",作用域依 "数据请求" 或 "跨页通信" 消息分发范围而定 // 如这么说无体会,详见 https://xiaozhuanlan.com/topic/8204519736 private MainActivityStates mStates; private PageMessenger mMessenger; private boolean mIsListened = false; @Override protected void initViewModel() { mStates = getActivityScopeViewModel(MainActivityStates.class); mMessenger = getApplicationScopeViewModel(PageMessenger.class); } @Override protected DataBindingConfig getDataBindingConfig() { //TODO tip 2: DataBinding 严格模式: // 将 DataBinding 实例限制于 base 页面中,默认不向子类暴露, // 通过这方式,彻底解决 View 实例 Null 安全一致性问题, // 如此,View 实例 Null 安全性将和基于函数式编程思想的 Jetpack Compose 持平。 // 而 DataBindingConfig 就是在这样背景下,用于为 base 页面 DataBinding 提供绑定项。 // 如这么说无体会,详见 https://xiaozhuanlan.com/topic/9816742350 和 https://xiaozhuanlan.com/topic/2356748910 return new DataBindingConfig(R.layout.activity_main, BR.vm, mStates) .addBindingParam(BR.listener, new ListenerHandler()); } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); PlayerManager.getInstance().init(this); //TODO tip 6: 从 PublishSubject 接收回推的数据,并在回调中响应数据的变化, // 也即通过 BehaviorSubject(例如 ObservableField)通知控件属性重新渲染,并为其兜住最后一次状态, //如这么说无体会,详见 https://xiaozhuanlan.com/topic/6741932805 mMessenger.output(this, messages -> { switch (messages.eventId) { case Messages.EVENT_CLOSE_ACTIVITY_IF_ALLOWED: NavController nav = Navigation.findNavController(this, R.id.main_fragment_host); if (nav.getCurrentDestination() != null && nav.getCurrentDestination().getId() != R.id.mainFragment) { nav.navigateUp(); } else if (Boolean.TRUE.equals(mStates.isDrawerOpened.get())) { //TODO 同 tip 3 mStates.openDrawer.set(false); } else { super.onBackPressed(); } break; case Messages.EVENT_OPEN_DRAWER: //TODO yes:同 tip 2: // 此处将 drawer 的 open 和 close 都放在 drawerBindingAdapter 中操作, // 规避 View 实例 Null 安全一致性问题,因为横屏布局无 drawerLayout。 // 此处如果用手动判空,很容易因疏忽而造成空引用。 //TODO 此外,此处为 drawerLayout 绑定状态 "openDrawer",使用 "去防抖" ObservableField 子类, // 主要考虑到 ObservableField 具有 "防抖" 特性,不适合该场景。 //如这么说无体会,详见 https://xiaozhuanlan.com/topic/9816742350 mStates.openDrawer.set(true); //TODO do not:(容易因疏忽埋下 View 实例 Null 安全一致性隐患) /*if (mBinding.dl != null) { if (aBoolean && !mBinding.dl.isDrawerOpen(GravityCompat.START)) { mBinding.dl.openDrawer(GravityCompat.START); } else { mBinding.dl.closeDrawer(GravityCompat.START); } }*/ break; } }); DrawerCoordinateManager.getInstance().isEnableSwipeDrawer().observe(this, aBoolean -> { //TODO yes: 同 tip 2 mStates.allowDrawerOpen.set(aBoolean); // TODO do not:(容易因疏忽埋下 View 实例 Null 安全一致性隐患) /*if (mBinding.dl != null) { mBinding.dl.setDrawerLockMode(aBoolean ? DrawerLayout.LOCK_MODE_UNLOCKED : DrawerLayout.LOCK_MODE_LOCKED_CLOSED); }*/ }); } @Override public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); if (!mIsListened) { // TODO tip 3:此处演示向 "可信源" 发送请求,以便实现 "生命周期安全、消息分发可靠一致" 的通知。 // 如这么说无体会,详见 https://xiaozhuanlan.com/topic/0168753249 // -------- // 与此同时,此处传达的另一思想是 "最少知道原则", // Activity 内部事情在 Activity 内部消化,不要试图在 fragment 中调用和操纵 Activity 内部东西。 // 因为 Activity 端的处理后续可能会改变,且可受用于更多 fragment,而不单单是本 fragment。 mMessenger.input(new Messages(Messages.EVENT_ADD_SLIDE_LISTENER)); mIsListened = true; } } @Override public void onBackPressed() { // TODO 同 tip 3 mMessenger.input(new Messages(Messages.EVENT_CLOSE_SLIDE_PANEL_IF_EXPANDED)); } public class ListenerHandler extends DrawerLayout.SimpleDrawerListener { @Override public void onDrawerOpened(View drawerView) { super.onDrawerOpened(drawerView); mStates.isDrawerOpened.set(true); } @Override public void onDrawerClosed(View drawerView) { super.onDrawerClosed(drawerView); mStates.isDrawerOpened.set(false); mStates.openDrawer.set(false); } } //TODO tip 5:基于单一职责原则,抽取 Jetpack ViewModel "状态保存和恢复" 的能力作为 StateHolder, // 并使用 ObservableField 的改良版子类 State 来承担 BehaviorSubject,用作所绑定控件的 "可信数据源", // 从而在收到来自 PublishSubject 的结果回推后,响应结果数据的变化,也即通知控件属性重新渲染,并为其兜住最后一次状态, //如这么说无体会,详见 https://xiaozhuanlan.com/topic/6741932805 public static class MainActivityStates extends StateHolder { public final State isDrawerOpened = new State<>(false); public final State openDrawer = new State<>(false); public final State allowDrawerOpen = new State<>(true); } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/data/api/APIs.java ================================================ package com.kunminx.puremusic.data.api; /** * Create by KunMinX at 2021/6/3 */ public class APIs { public final static String BASE_URL = "https://test.com/"; } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/data/api/AccountService.java ================================================ package com.kunminx.puremusic.data.api; import retrofit2.Call; import retrofit2.http.Field; import retrofit2.http.FormUrlEncoded; import retrofit2.http.POST; /** * Create by KunMinX at 2021/6/3 */ public interface AccountService { @POST("xxx/login") @FormUrlEncoded Call login( @Field("username") String username, @Field("password") String password ); } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/data/bean/DownloadState.java ================================================ package com.kunminx.puremusic.data.bean; /** * Create by KunMinX at 2022/7/15 *

* bean,原始数据,只读, * Java 我们通过移除 setter * kotlin 直接将字段设为 val 即可 */ public class DownloadState { public final boolean isForgive; public final int progress; public DownloadState() { this.isForgive = false; this.progress = 0; } public DownloadState(boolean isForgive, int progress) { this.isForgive = isForgive; this.progress = progress; } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/data/bean/LibraryInfo.java ================================================ /* * Copyright 2018-present KunMinX * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kunminx.puremusic.data.bean; /** * Create by KunMinX at 19/11/2 *

* bean,原始数据,只读, * Java 我们通过移除 setter * kotlin 直接将字段设为 val 即可 */ public class LibraryInfo { private final String title; private final String summary; private final String url; public LibraryInfo(String title, String summary, String url) { this.title = title; this.summary = summary; this.url = url; } public String getTitle() { return title; } public String getSummary() { return summary; } public String getUrl() { return url; } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/data/bean/TestAlbum.java ================================================ /* * Copyright 2018-present KunMinX * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kunminx.puremusic.data.bean; import com.kunminx.player.bean.base.BaseAlbumItem; import com.kunminx.player.bean.base.BaseArtistItem; import com.kunminx.player.bean.base.BaseMusicItem; import java.util.List; /** * Create by KunMinX at 19/10/31 *

* bean,原始数据,只读 * Java 我们通过移除 setter * kotlin 直接将字段设为 val 即可 */ public class TestAlbum extends BaseAlbumItem { private String albumMid; public TestAlbum(String albumId, String title, String summary, TestArtist artist, String coverImg, List musics) { super(albumId, title, summary, artist, coverImg, musics); } public String getAlbumMid() { return albumMid; } public static class TestMusic extends BaseMusicItem { private String songMid; public TestMusic(String musicId, String coverImg, String url, String title, TestArtist artist) { super(musicId, coverImg, url, title, artist); } public String getSongMid() { return songMid; } } public static class TestArtist extends BaseArtistItem { private String birthday; public TestArtist(String name) { super(name); } public String getBirthday() { return birthday; } } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/data/bean/User.java ================================================ /* * Copyright 2018-present KunMinX * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kunminx.puremusic.data.bean; /** * Create by KunMinX at 20/04/26 *

* bean,原始数据,只读 * Java 我们通过移除 setter * kotlin 直接将字段设为 val 即可 */ public class User { private final String name; private final String password; public User(String name, String password) { this.name = name; this.password = password; } public String getName() { return name; } public String getPassword() { return password; } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/data/config/Configs.java ================================================ /* * Copyright 2018-present KunMinX * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kunminx.puremusic.data.config; import com.kunminx.architecture.data.config.keyvalue.KeyValueBoolean; import com.kunminx.architecture.data.config.keyvalue.KeyValueInteger; import com.kunminx.architecture.data.config.keyvalue.KeyValueSerializable; import com.kunminx.architecture.data.config.keyvalue.KeyValueString; import com.kunminx.keyvalue.annotation.KeyValueX; import com.kunminx.puremusic.data.bean.User; /** * TODO tip 1:消除 Android 项目 KeyValue 样板代码,让 key、value、get、put、init 缩减为一,不再 KV 爆炸。 * 如这么说无体会,详见 https://juejin.cn/post/7121955840319291428 *

* Create by KunMinX at 18/9/28 */ @KeyValueX public interface Configs { KeyValueString token(); KeyValueBoolean isLogin(); KeyValueInteger alive(); KeyValueSerializable user(); } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/data/config/Const.java ================================================ package com.kunminx.puremusic.data.config; import android.os.Environment; import com.kunminx.architecture.utils.Utils; import com.kunminx.puremusic.R; /** * Create by KunMinX at 2022/8/18 */ public class Const { public static final String COVER_PATH = Utils.getApp().getExternalFilesDir(Environment.DIRECTORY_PICTURES).getAbsolutePath(); public static final String COLUMN_LINK = Utils.getApp().getString(R.string.article_navigation); public static final String PROJECT_LINK = Utils.getApp().getString(R.string.github_project); } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/data/repository/DataRepository.java ================================================ /* * Copyright 2018-present KunMinX * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kunminx.puremusic.data.repository; import android.annotation.SuppressLint; import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; import com.kunminx.architecture.data.response.DataResult; import com.kunminx.architecture.data.response.ResponseStatus; import com.kunminx.architecture.data.response.ResultSource; import com.kunminx.architecture.domain.request.AsyncTask; import com.kunminx.architecture.utils.Utils; import com.kunminx.puremusic.R; import com.kunminx.puremusic.data.api.APIs; import com.kunminx.puremusic.data.api.AccountService; import com.kunminx.puremusic.data.bean.LibraryInfo; import com.kunminx.puremusic.data.bean.TestAlbum; import com.kunminx.puremusic.data.bean.User; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.lang.reflect.Type; import java.util.List; import java.util.concurrent.TimeUnit; import io.reactivex.Observable; import okhttp3.OkHttpClient; import okhttp3.logging.HttpLoggingInterceptor; import retrofit2.Call; import retrofit2.Response; import retrofit2.Retrofit; import retrofit2.converter.gson.GsonConverterFactory; /** * Create by KunMinX at 19/10/29 */ public class DataRepository { private static final DataRepository S_REQUEST_MANAGER = new DataRepository(); private DataRepository() { } public static DataRepository getInstance() { return S_REQUEST_MANAGER; } private final Retrofit retrofit; { HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); logging.setLevel(HttpLoggingInterceptor.Level.BODY); OkHttpClient client = new OkHttpClient.Builder() .connectTimeout(8, TimeUnit.SECONDS) .readTimeout(8, TimeUnit.SECONDS) .writeTimeout(8, TimeUnit.SECONDS) .addInterceptor(logging) .build(); retrofit = new Retrofit.Builder() .baseUrl(APIs.BASE_URL) .client(client) .addConverterFactory(GsonConverterFactory.create()) .build(); } //TODO tip: 通过 "响应式框架" 往领域层回推数据, // 与此相对应,kotlin 下使用 flow{ ... emit(...) }.flowOn(Dispatchers.xx) public Observable> getFreeMusic() { return AsyncTask.doIO(emitter -> { Gson gson = new Gson(); Type type = new TypeToken() { }.getType(); TestAlbum testAlbum = gson.fromJson(Utils.getApp().getString(R.string.free_music_json), type); emitter.onNext(new DataResult<>(testAlbum, new ResponseStatus())); }); } public Observable>> getLibraryInfo() { return AsyncTask.doIO(emitter -> { Gson gson = new Gson(); Type type = new TypeToken>() { }.getType(); List list = gson.fromJson(Utils.getApp().getString(R.string.library_json), type); emitter.onNext(new DataResult<>(list, new ResponseStatus())); }); } /** * TODO:模拟下载任务: */ @SuppressLint("CheckResult") public Observable downloadFile() { return AsyncTask.doIO(emitter -> { //在内存中模拟 "数据读写",假装是在 "文件 IO", byte[] bytes = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}; try (ByteArrayInputStream bis = new ByteArrayInputStream(bytes); ByteArrayOutputStream bos = new ByteArrayOutputStream()) { int b; while ((b = bis.read()) != -1) { Thread.sleep(500); emitter.onNext(b); } } catch (IOException | InterruptedException e) { e.printStackTrace(); } }); } /** * TODO 模拟登录的网络请求 * * @param user ui 层填写的用户信息 */ public Observable> login(User user) { // 使用 retrofit 或任意你喜欢的库实现网络请求。此处以 retrofit 写个简单例子, // 并且如使用 rxjava,还可额外依赖 RxJavaCallAdapterFactory 来简化编写,具体自行网上查阅,此处不做累述, return AsyncTask.doIO(emitter -> { Call call = retrofit.create(AccountService.class).login(user.getName(), user.getPassword()); Response response; try { response = call.execute(); ResponseStatus responseStatus = new ResponseStatus( String.valueOf(response.code()), response.isSuccessful(), ResultSource.NETWORK); emitter.onNext(new DataResult<>(response.body(), responseStatus)); } catch (IOException e) { emitter.onNext(new DataResult<>(null, new ResponseStatus(e.getMessage(), false, ResultSource.NETWORK))); } }); } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/domain/event/DownloadEvent.java ================================================ package com.kunminx.puremusic.domain.event; import com.kunminx.puremusic.data.bean.DownloadState; /** * Create by KunMinX at 2022/7/4 */ public class DownloadEvent { public final static int EVENT_DOWNLOAD = 1; public final static int EVENT_DOWNLOAD_GLOBAL = 2; public final int eventId; public final DownloadState downloadState; public DownloadEvent(int eventId) { this.eventId = eventId; this.downloadState = new DownloadState(); } public DownloadEvent(int eventId, DownloadState downloadState) { this.eventId = eventId; this.downloadState = downloadState; } public DownloadEvent copy(DownloadState downloadState) { return new DownloadEvent(this.eventId, downloadState); } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/domain/event/Messages.java ================================================ package com.kunminx.puremusic.domain.event; /** * Create by KunMinX at 2022/7/4 */ public class Messages { public final static int EVENT_CLOSE_SLIDE_PANEL_IF_EXPANDED = 1; public final static int EVENT_CLOSE_ACTIVITY_IF_ALLOWED = 2; public final static int EVENT_OPEN_DRAWER = 3; public final static int EVENT_ADD_SLIDE_LISTENER = 4; public final static int EVENT_LOGIN_SUCCESS = 5; public final int eventId; public Messages(int eventId) { this.eventId = eventId; } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/domain/message/DrawerCoordinateManager.java ================================================ /* * * * Copyright 2018-present KunMinX * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * * You may obtain a copy of the License at * * * * http://www.apache.org/licenses/LICENSE-2.0 * * * * Unless required by applicable law or agreed to in writing, software * * distributed under the License is distributed on an "AS IS" BASIS, * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * * See the License for the specific language governing permissions and * * limitations under the License. * */ package com.kunminx.puremusic.domain.message; import androidx.annotation.NonNull; import androidx.lifecycle.DefaultLifecycleObserver; import androidx.lifecycle.LifecycleOwner; import com.kunminx.architecture.domain.message.MutableResult; import com.kunminx.architecture.domain.message.Result; import java.util.ArrayList; import java.util.List; /** * TODO tip 1:通过 Lifecycle 来实现 "抽屉侧滑禁用与否的判断" 的一致, *

* 每个 "需要注册和监听生命周期来判断" 的视图控制器,无需在各自内部手动书写解绑等操作。 * 如这么说无体会,详见《为你还原一个真实的 Jetpack Lifecycle》 * https://xiaozhuanlan.com/topic/3684721950 *

* TODO tip 2:与此同时,作为用于 "跨页面通信" 单例,本类也承担 "可信源" 职责, * 所有对 Drawer 状态协调相关的请求都交由本单例处理,并统一分发给所有订阅者页面。 *

* 如这么说无体会,详见《吃透 LiveData 本质,享用可靠消息鉴权机制》解析。 * https://xiaozhuanlan.com/topic/6017825943 *

*

* Create by KunMinX at 19/11/3 */ public class DrawerCoordinateManager implements DefaultLifecycleObserver { private static final DrawerCoordinateManager S_HELPER = new DrawerCoordinateManager(); private DrawerCoordinateManager() { } public static DrawerCoordinateManager getInstance() { return S_HELPER; } private final List tagOfSecondaryPages = new ArrayList<>(); private boolean isNoneSecondaryPage() { return tagOfSecondaryPages.size() == 0; } private final MutableResult enableSwipeDrawer = new MutableResult<>(); public Result isEnableSwipeDrawer() { return enableSwipeDrawer; } public void requestToUpdateDrawerMode(boolean pageOpened, String pageName) { if (pageOpened) { tagOfSecondaryPages.add(pageName); } else { tagOfSecondaryPages.remove(pageName); } enableSwipeDrawer.setValue(isNoneSecondaryPage()); } //TODO tip 3:让 NetworkStateManager 可观察页面生命周期, // 从而在进入或离开目标页面时,自动在此登记和处理抽屉的禁用和解禁,避免一系列不可预期问题。 // 关于 Lifecycle 组件的存在意义,可详见《为你还原一个真实的 Jetpack Lifecycle》解析 // https://xiaozhuanlan.com/topic/3684721950 @Override public void onCreate(@NonNull LifecycleOwner owner) { tagOfSecondaryPages.add(owner.getClass().getSimpleName()); enableSwipeDrawer.setValue(isNoneSecondaryPage()); } @Override public void onDestroy(@NonNull LifecycleOwner owner) { tagOfSecondaryPages.remove(owner.getClass().getSimpleName()); enableSwipeDrawer.setValue(isNoneSecondaryPage()); } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/domain/message/PageMessenger.java ================================================ package com.kunminx.puremusic.domain.message; import com.kunminx.architecture.domain.dispatch.MviDispatcher; import com.kunminx.puremusic.domain.event.Messages; /** * TODO:Note 2022.07.04 * ` * PageMessenger 是一个领域层组件,可用于 "跨页面通信" 场景, * 比如跳转到 login 页面完成登录后,login 页面反过来通知其他页面刷新状态, *

* PageMessenger 基于 MVI-Dispatcher 实现可靠的消息回推, * 通过消息队列、引用计数等设计,确保 "消息都能被消费,且只消费一次", * 通过内聚设计,彻底杜绝 mutable 滥用等问题, *

* 鉴于本项目场景难发挥 MVI-Dispatcher 潜能,故目前仅以改造 DownloadRequester 和 SharedViewModel 为例, * 通过对比 SharedViewModel 和 PageMessenger 易得,后者可简洁优雅实现可靠一致的消息分发, *

*

* 具体可参见专为 MVI-Dispatcher 编写的领域层案例: *

* https://github.com/KunMinX/MVI-Dispatcher *

* Create by KunMinX at 2022/7/4 */ public class PageMessenger extends MviDispatcher { @Override protected void onHandle(Messages event) { sendResult(event); } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/domain/message/PlayerReceiver.java ================================================ /* * Copyright 2018-present KunMinX * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kunminx.puremusic.domain.message; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.view.KeyEvent; import com.kunminx.puremusic.domain.proxy.PlayerManager; import com.kunminx.puremusic.ui.widget.PlayerService; import java.util.Objects; public class PlayerReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { if (Objects.equals(intent.getAction(), Intent.ACTION_MEDIA_BUTTON)) { if (intent.getExtras() == null) { return; } KeyEvent keyEvent = (KeyEvent) intent.getExtras().get(Intent.EXTRA_KEY_EVENT); if (keyEvent == null) { return; } if (keyEvent.getAction() != KeyEvent.ACTION_DOWN) { return; } switch (keyEvent.getKeyCode()) { case KeyEvent.KEYCODE_HEADSETHOOK: case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: PlayerManager.getInstance().togglePlay(); break; case KeyEvent.KEYCODE_MEDIA_PLAY: PlayerManager.getInstance().playAudio(); break; case KeyEvent.KEYCODE_MEDIA_PAUSE: PlayerManager.getInstance().pauseAudio(); break; case KeyEvent.KEYCODE_MEDIA_STOP: PlayerManager.getInstance().clear(); break; case KeyEvent.KEYCODE_MEDIA_NEXT: PlayerManager.getInstance().playNext(); break; case KeyEvent.KEYCODE_MEDIA_PREVIOUS: PlayerManager.getInstance().playPrevious(); break; default: } } else { if (Objects.requireNonNull(intent.getAction()).equals(PlayerService.NOTIFY_PLAY)) { PlayerManager.getInstance().playAudio(); } else if (intent.getAction().equals(PlayerService.NOTIFY_PAUSE) || intent.getAction().equals(android.media.AudioManager.ACTION_AUDIO_BECOMING_NOISY)) { PlayerManager.getInstance().pauseAudio(); } else if (intent.getAction().equals(PlayerService.NOTIFY_NEXT)) { PlayerManager.getInstance().playNext(); } else if (intent.getAction().equals(PlayerService.NOTIFY_CLOSE)) { PlayerManager.getInstance().clear(); } else if (intent.getAction().equals(PlayerService.NOTIFY_PREVIOUS)) { PlayerManager.getInstance().playPrevious(); } } } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/domain/message/SharedViewModel.java ================================================ /* * Copyright 2018-present KunMinX * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kunminx.puremusic.domain.message; import androidx.lifecycle.ViewModel; import com.kunminx.architecture.domain.message.MutableResult; import com.kunminx.architecture.domain.message.Result; /** * TODO tip:本类专用于跨页面通信, * 本类已被 PageMessenger 类代替,具体可参见 PageMessenger 类说明 *

* Create by KunMinX at 19/10/16 */ @Deprecated public class SharedViewModel extends ViewModel { //TODO tip 2:此处演示 UnPeekLiveData 配合 SharedViewModel 实现 "生命周期安全、可靠一致" 消息分发。 //TODO tip 3:为便于理解,原 UnPeekLiveData 已改名为 MutableResult; // ProtectedUnPeekLiveData 改名 Result; private final MutableResult toCloseSlidePanelIfExpanded = new MutableResult<>(); private final MutableResult toCloseActivityIfAllowed = new MutableResult<>(); private final MutableResult toOpenOrCloseDrawer = new MutableResult<>(); //TODO tip 4:可通过构造器方式配置 MutableResult private final MutableResult toAddSlideListener = new MutableResult.Builder().setAllowNullValue(false).create(); public Result isToAddSlideListener() { return toAddSlideListener; } public Result isToCloseSlidePanelIfExpanded() { return toCloseSlidePanelIfExpanded; } public Result isToCloseActivityIfAllowed() { return toCloseActivityIfAllowed; } public Result isToOpenOrCloseDrawer() { return toOpenOrCloseDrawer; } public void requestToCloseActivityIfAllowed(boolean allow) { toCloseActivityIfAllowed.setValue(allow); } public void requestToOpenOrCloseDrawer(boolean open) { toOpenOrCloseDrawer.setValue(open); } public void requestToCloseSlidePanelIfExpanded(boolean close) { toCloseSlidePanelIfExpanded.setValue(close); } public void requestToAddSlideListener(boolean add) { toAddSlideListener.setValue(add); } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/domain/proxy/PlayerManager.java ================================================ /* * Copyright 2018-2019 KunMinX * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kunminx.puremusic.domain.proxy; import android.content.Context; import android.content.Intent; import androidx.lifecycle.LiveData; import com.danikula.videocache.HttpProxyCacheServer; import com.kunminx.player.contract.ICacheProxy; import com.kunminx.player.contract.IPlayController; import com.kunminx.player.contract.IServiceNotifier; import com.kunminx.player.domain.MusicDTO; import com.kunminx.player.domain.PlayerController; import com.kunminx.player.domain.PlayingInfoManager; import com.kunminx.puremusic.data.bean.TestAlbum; import com.kunminx.puremusic.ui.widget.PlayerService; import net.steamcrafted.materialiconlib.MaterialDrawableBuilder; import java.util.List; /** * Create by KunMinX at 19/10/31 */ public class PlayerManager implements IPlayController { private static final PlayerManager sManager = new PlayerManager(); private final PlayerController mController; private PlayerManager() { mController = new PlayerController<>(); } public static PlayerManager getInstance() { return sManager; } private boolean mIsInit; public void init(Context context) { if (!mIsInit) { init(context, null, null); mIsInit = true; } } @Override public void init(Context context, IServiceNotifier iServiceNotifier, ICacheProxy iCacheProxy) { Context context1 = context.getApplicationContext(); HttpProxyCacheServer proxy = new HttpProxyCacheServer.Builder(context1) .fileNameGenerator(url -> { String[] split = url.split("/"); return split[split.length - 1]; }) .maxCacheSize(2147483648L) .build(); mController.init(context1, startOrStop -> { Intent intent = new Intent(context1, PlayerService.class); if (startOrStop) context1.startService(intent); else context1.stopService(intent); }, proxy::getProxyUrl); } @Override public void loadAlbum(TestAlbum musicAlbum) { TestAlbum album = mController.getAlbum(); if (album == null || !album.albumId.equals(musicAlbum.albumId)) { mController.loadAlbum(musicAlbum); } } @Override public void loadAlbum(TestAlbum musicAlbum, int playIndex) { mController.loadAlbum(musicAlbum, playIndex); } @Override public void playAudio() { mController.playAudio(); } @Override public void playAudio(int albumIndex) { mController.playAudio(albumIndex); } @Override public void playNext() { mController.playNext(); } @Override public void playPrevious() { mController.playPrevious(); } @Override public void playAgain() { mController.playAgain(); } @Override public void pauseAudio() { mController.pauseAudio(); } @Override public void resumeAudio() { mController.resumeAudio(); } @Override public void clear() { mController.clear(); } @Override public void changeMode() { mController.changeMode(); } @Override public boolean isPlaying() { return mController.isPlaying(); } @Override public boolean isPaused() { return mController.isPaused(); } @Override public boolean isInit() { return mController.isInit(); } @Override public void setSeek(int progress) { mController.setSeek(progress); } @Override public String getTrackTime(int progress) { return mController.getTrackTime(progress); } @Override public LiveData> getUiStates() { return mController.getUiStates(); } @Override public TestAlbum getAlbum() { return mController.getAlbum(); } @Override public List getAlbumMusics() { return mController.getAlbumMusics(); } @Override public void setChangingPlayingMusic(boolean changingPlayingMusic) { mController.setChangingPlayingMusic(changingPlayingMusic); } @Override public int getAlbumIndex() { return mController.getAlbumIndex(); } @Override public Enum getRepeatMode() { return mController.getRepeatMode(); } @Override public void togglePlay() { mController.togglePlay(); } @Override public TestAlbum.TestMusic getCurrentPlayingMusic() { return mController.getCurrentPlayingMusic(); } public MaterialDrawableBuilder.IconValue getModeIcon(Enum mode) { if (mode == PlayingInfoManager.RepeatMode.LIST_CYCLE) { return MaterialDrawableBuilder.IconValue.REPEAT; } else if (mode == PlayingInfoManager.RepeatMode.SINGLE_CYCLE) { return MaterialDrawableBuilder.IconValue.REPEAT_ONCE; } else { return MaterialDrawableBuilder.IconValue.SHUFFLE; } } public MaterialDrawableBuilder.IconValue getModeIcon() { return getModeIcon(getRepeatMode()); } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/domain/request/AccountRequester.java ================================================ /* * Copyright 2018-present KunMinX * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kunminx.puremusic.domain.request; import androidx.annotation.NonNull; import androidx.lifecycle.DefaultLifecycleObserver; import androidx.lifecycle.LifecycleOwner; import com.kunminx.architecture.data.response.DataResult; import com.kunminx.architecture.data.response.ResponseStatus; import com.kunminx.architecture.data.response.ResultSource; import com.kunminx.architecture.domain.message.MutableResult; import com.kunminx.architecture.domain.message.Result; import com.kunminx.architecture.domain.request.Requester; import com.kunminx.puremusic.data.bean.User; import com.kunminx.puremusic.data.repository.DataRepository; import org.jetbrains.annotations.NotNull; import io.reactivex.Observer; import io.reactivex.disposables.Disposable; /** * 用户账户 Request *

* TODO tip 1:让 UI 和业务分离,让数据总是从生产者流向消费者 *

* UI逻辑和业务逻辑,本质区别在于,前者是数据的消费者,后者是数据的生产者, * "领域层组件" 作为数据的生产者,职责应仅限于 "请求调度 和 结果分发", *

* 换言之,"领域层组件" 中应当只关注数据的生成,而不关注数据的使用, * 改变 UI 状态的逻辑代码,只应在表现层页面中编写、在 Observer 回调中响应数据的变化, * 将来升级到 Jetpack Compose 更是如此, *

* Activity { * onCreate(){ * vm.livedata.observe { result-> * panel.visible(result.show ? VISIBLE : GONE) * tvTitle.setText(result.title) * tvContent.setText(result.content) * } * } *

* 如这么说无体会,详见《Jetpack MVVM 分层设计》解析 * https://xiaozhuanlan.com/topic/6741932805 *

*

* Create by KunMinX at 20/04/26 */ public class AccountRequester extends Requester implements DefaultLifecycleObserver { //TODO tip 3:👆👆👆 让 accountRequest 可观察页面生命周期, // 从而在页面即将退出、且登录请求由于网络延迟尚未完成时, // 及时通知数据层取消本次请求,以避免资源浪费和一系列不可预期问题。 private final MutableResult> tokenResult = new MutableResult<>(); //TODO tip 4:应顺应 "响应式编程",做好 "单向数据流" 开发, // MutableResult 应仅限 "鉴权中心" 内部使用,且只暴露 immutable Result 给 UI 层, // 通过 "读写分离" 实现数据从 "领域层" 到 "表现层" 的单向流动, //如这么说无体会,详见《吃透 LiveData 本质,享用可靠消息鉴权机制》解析。 //https://xiaozhuanlan.com/topic/6017825943 public Result> getTokenResult() { return tokenResult; } //TODO tip 5:模拟可取消的登录请求: // // 配合可观察页面生命周期的 accountRequest, // 从而在页面即将退出、且登录请求由于网络延迟尚未完成时, // 及时通知数据层取消本次请求,以避免资源浪费和一系列不可预期的问题。 private Disposable mDisposable; //TODO tip 6: requester 作为数据的生产者,职责应仅限于 "请求调度 和 结果分发", // // 换言之,此处只关注数据的生成和回推,不关注数据的使用, // 改变 UI 状态的逻辑代码,只应在表现层页面中编写,例如 Jetpack Compose 的使用, public void requestLogin(User user) { DataRepository.getInstance().login(user).subscribe(new Observer>() { @Override public void onSubscribe(Disposable d) { mDisposable = d; } @Override public void onNext(DataResult dataResult) { tokenResult.postValue(dataResult); } @Override public void onError(Throwable e) { tokenResult.postValue(new DataResult<>(null, new ResponseStatus(e.getMessage(), false, ResultSource.NETWORK))); } @Override public void onComplete() { mDisposable = null; } }); } public void cancelLogin() { if (mDisposable != null && !mDisposable.isDisposed()) { mDisposable.dispose(); mDisposable = null; } } //TODO tip 7:让 accountRequest 可观察页面生命周期, // 从而在页面即将退出、且登录请求由于网络延迟尚未完成时, // 及时通知数据层取消本次请求,以避免资源浪费和一系列不可预期问题。 // 关于 Lifecycle 组件的存在意义,详见《为你还原一个真实的 Jetpack Lifecycle》解析 // https://xiaozhuanlan.com/topic/3684721950 @Override public void onStop(@NonNull @NotNull LifecycleOwner owner) { cancelLogin(); } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/domain/request/DownloadRequester.java ================================================ package com.kunminx.puremusic.domain.request; import androidx.annotation.NonNull; import androidx.lifecycle.LifecycleOwner; import com.kunminx.architecture.domain.dispatch.MviDispatcher; import com.kunminx.architecture.domain.request.AsyncTask; import com.kunminx.puremusic.data.bean.DownloadState; import com.kunminx.puremusic.data.repository.DataRepository; import com.kunminx.puremusic.domain.event.DownloadEvent; import io.reactivex.disposables.Disposable; /** * 数据下载 Request *

* TODO tip 1:让 UI 和业务分离,让数据总是从生产者流向消费者 *

* UI逻辑和业务逻辑,本质区别在于,前者是数据的消费者,后者是数据的生产者, * "领域层组件" 作为数据的生产者,职责应仅限于 "请求调度 和 结果分发", *

* 换言之,"领域层组件" 中应当只关注数据的生成,而不关注数据的使用, * 改变 UI 状态的逻辑代码,只应在表现层页面中编写、在 Observer 回调中响应数据的变化, * 将来升级到 Jetpack Compose 更是如此, *

* Activity { * onCreate(){ * vm.livedata.observe { result-> * panel.visible(result.show ? VISIBLE : GONE) * tvTitle.setText(result.title) * tvContent.setText(result.content) * } * } *

* 如这么说无体会,详见《Jetpack MVVM 分层设计》解析 * https://xiaozhuanlan.com/topic/6741932805 *

*

* Create by KunMinX at 20/03/18 */ public class DownloadRequester extends MviDispatcher { private Disposable mDisposable; //TODO Tip 2:基于 "单一职责原则",宜将 Jetpack ViewModel 框架划分为 state-ViewModel 和 result-ViewModel, // result-ViewModel 作为领域层组件,仅提取和继承 Jetpack ViewModel 框架中 "作用域管理" 的能力, // 使业务实例能根据需要,被单个页面独享,或多个页面共享,例如: // // mDownloadRequester = getFragmentScopeViewModel(DownloadRequester.class); // mGlobalDownloadRequester = getActivityScopeViewModel(DownloadRequester.class); // // 在本案例中,fragment 级作用域的 mDownloadRequester 只走 DownloadEvent.EVENT_DOWNLOAD 业务, // Activity 级作用域的 mGlobalDownloadRequester 只走 DownloadEvent.EVENT_DOWNLOAD_GLOBAL 业务, // 二者都为 SearchFragment 所持有,用于对比不同作用域的效果, @Override protected void onHandle(DownloadEvent event) { DataRepository repo = DataRepository.getInstance(); switch (event.eventId) { case DownloadEvent.EVENT_DOWNLOAD: repo.downloadFile().subscribe(new AsyncTask.Observer() { @Override public void onSubscribe(Disposable d) { mDisposable = d; } @Override public void onNext(Integer integer) { sendResult(event.copy(new DownloadState(true, integer))); } }); break; case DownloadEvent.EVENT_DOWNLOAD_GLOBAL: repo.downloadFile().subscribe((AsyncTask.Observer) integer -> { sendResult(event.copy(new DownloadState(true, integer))); }); break; } } @Override public void onStop(@NonNull LifecycleOwner owner) { super.onStop(owner); if (mDisposable != null && !mDisposable.isDisposed()) { mDisposable.dispose(); mDisposable = null; } } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/domain/request/InfoRequester.java ================================================ /* * Copyright 2018-present KunMinX * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kunminx.puremusic.domain.request; import android.annotation.SuppressLint; import com.kunminx.architecture.data.response.DataResult; import com.kunminx.architecture.domain.message.MutableResult; import com.kunminx.architecture.domain.message.Result; import com.kunminx.architecture.domain.request.Requester; import com.kunminx.puremusic.data.bean.LibraryInfo; import com.kunminx.puremusic.data.repository.DataRepository; import java.util.List; /** * 信息列表 Request *

* TODO tip 1:让 UI 和业务分离,让数据总是从生产者流向消费者 *

* UI逻辑和业务逻辑,本质区别在于,前者是数据的消费者,后者是数据的生产者, * "领域层组件" 作为数据的生产者,职责应仅限于 "请求调度 和 结果分发", *

* 换言之,"领域层组件" 中应当只关注数据的生成,而不关注数据的使用, * 改变 UI 状态的逻辑代码,只应在表现层页面中编写、在 Observer 回调中响应数据的变化, * 将来升级到 Jetpack Compose 更是如此, *

* Activity { * onCreate(){ * vm.livedata.observe { result-> * panel.visible(result.show ? VISIBLE : GONE) * tvTitle.setText(result.title) * tvContent.setText(result.content) * } * } *

* TODO tip 2:Requester 通常按业务划分 * 一个项目中通常可存在多个 Requester 类, * 每个页面可根据业务需要,持有多个不同 Requester 实例, * 通过 PublishSubject 回推一次性消息,并在表现层 Observer 中分流, * 对于 Event,直接执行,对于 State,使用 BehaviorSubject 通知 View 渲染和兜着状态, *

* Activity { * onCreate(){ * request.observe {result -> * is Event ? -> execute one time * is State ? -> BehaviorSubject setValue and notify * } * } *

* 如这么说无体会,详见《Jetpack MVVM 分层设计解析》解析 * https://xiaozhuanlan.com/topic/6741932805 *

*

* Create by KunMinX at 19/11/2 */ public class InfoRequester extends Requester { private final MutableResult>> mLibraryResult = new MutableResult<>(); //TODO tip 4:应顺应 "响应式编程",做好 "单向数据流" 开发, // MutableResult 应仅限 "鉴权中心" 内部使用,且只暴露 immutable Result 给 UI 层, // 通过 "读写分离" 实现数据从 "领域层" 到 "表现层" 的单向流动, //如这么说无体会,详见《吃透 LiveData 本质,享用可靠消息鉴权机制》解析。 //https://xiaozhuanlan.com/topic/6017825943 public Result>> getLibraryResult() { return mLibraryResult; } //TODO tip 5: requester 作为数据的生产者,职责应仅限于 "请求调度 和 结果分发", // // 换言之,此处只关注数据的生成和回推,不关注数据的使用, // 改变 UI 状态的逻辑代码,只应在表现层页面中编写,例如 Jetpack Compose 的使用, @SuppressLint("CheckResult") public void requestLibraryInfo() { if (mLibraryResult.getValue() == null) DataRepository.getInstance().getLibraryInfo().subscribe(mLibraryResult::setValue); } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/domain/request/MusicRequester.java ================================================ /* * Copyright 2018-present KunMinX * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kunminx.puremusic.domain.request; import android.annotation.SuppressLint; import com.kunminx.architecture.data.response.DataResult; import com.kunminx.architecture.domain.message.MutableResult; import com.kunminx.architecture.domain.message.Result; import com.kunminx.architecture.domain.request.Requester; import com.kunminx.puremusic.data.bean.TestAlbum; import com.kunminx.puremusic.data.repository.DataRepository; /** * 音乐资源 Request *

* TODO tip 1:让 UI 和业务分离,让数据总是从生产者流向消费者 *

* UI逻辑和业务逻辑,本质区别在于,前者是数据的消费者,后者是数据的生产者, * "领域层组件" 作为数据的生产者,职责应仅限于 "请求调度 和 结果分发", *

* 换言之,"领域层组件" 中应当只关注数据的生成,而不关注数据的使用, * 改变 UI 状态的逻辑代码,只应在表现层页面中编写、在 Observer 回调中响应数据的变化, * 将来升级到 Jetpack Compose 更是如此, *

* Activity { * onCreate(){ * vm.livedata.observe { result-> * panel.visible(result.show ? VISIBLE : GONE) * tvTitle.setText(result.title) * tvContent.setText(result.content) * } * } *

* TODO tip 2:Requester 通常按业务划分 * 一个项目中通常可存在多个 Requester 类, * 每个页面可根据业务需要,持有多个不同 Requester 实例, * 通过 PublishSubject 回推一次性消息,并在表现层 Observer 中分流, * 对于 Event,直接执行,对于 State,使用 BehaviorSubject 通知 View 渲染和兜着状态, *

* Activity { * onCreate(){ * request.observe {result -> * is Event ? -> execute one time * is State ? -> BehaviorSubject setValue and notify * } * } *

* 如这么说无体会,详见《Jetpack MVVM 分层设计解析》解析 * https://xiaozhuanlan.com/topic/6741932805 *

*

* Create by KunMinX at 19/10/29 */ public class MusicRequester extends Requester { private final MutableResult> mFreeMusicsResult = new MutableResult<>(); //TODO tip 4:应顺应 "响应式编程",做好 "单向数据流" 开发, // MutableResult 应仅限 "鉴权中心" 内部使用,且只暴露 immutable Result 给 UI 层, // 通过 "读写分离" 实现数据从 "领域层" 到 "表现层" 的单向流动, //如这么说无体会,详见《吃透 LiveData 本质,享用可靠消息鉴权机制》解析。 //https://xiaozhuanlan.com/topic/6017825943 public Result> getFreeMusicsResult() { return mFreeMusicsResult; } //TODO tip 5: requester 作为数据的生产者,职责应仅限于 "请求调度 和 结果分发", // // 换言之,此处只关注数据的生成和回推,不关注数据的使用, // 改变 UI 状态的逻辑代码,只应在表现层页面中编写,例如 Jetpack Compose 的使用, @SuppressLint("CheckResult") public void requestFreeMusics() { DataRepository.getInstance().getFreeMusic().subscribe(mFreeMusicsResult::setValue); } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/domain/usecase/CanBeStoppedUseCase.java ================================================ /* * Copyright 2018-present KunMinX * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kunminx.puremusic.domain.usecase; import androidx.annotation.NonNull; import androidx.lifecycle.DefaultLifecycleObserver; import androidx.lifecycle.LifecycleOwner; import com.kunminx.architecture.data.response.DataResult; import com.kunminx.architecture.domain.usecase.UseCase; import com.kunminx.puremusic.data.bean.DownloadState; /** * UseCase 示例,实现 LifeCycle 接口,单独服务于 有 “叫停” 需求 的业务 *

* TODO tip: * 同样是“下载”,我不是在数据层分别写两个方法, * 而是遵循开闭原则,在 ViewModel 和 数据层之间,插入一个 UseCase,来专门负责可叫停的情况, * 除了开闭原则,使用 UseCase 还有个考虑就是避免内存泄漏, * 具体缘由可详见 https://xiaozhuanlan.com/topic/6257931840 评论区 15 楼 * 以及《这是一份 “架构模式” 自驾攻略》的解析 * https://xiaozhuanlan.com/topic/8204519736 *

*

* 现已更换为在 MVI-Dispatcher 中处理,具体可参见 DownloadRequest 实现 *

*

* Create by KunMinX at 19/11/25 */ @Deprecated public class CanBeStoppedUseCase extends UseCase implements DefaultLifecycleObserver { // private final DownloadState downloadState = new DownloadState(); //TODO tip:让 CanBeStoppedUseCase 可观察页面生命周期, // 从而在页面即将退出、且下载请求尚未完成时, // 及时通知数据层取消本次请求,以避免资源浪费和一系列不可预期的问题。 // 关于 Lifecycle 组件的存在意义,可详见《为你还原一个真实的 Jetpack Lifecycle》篇的解析 // https://xiaozhuanlan.com/topic/3684721950 @Override public void onStop(@NonNull LifecycleOwner owner) { if (getRequestValues() != null) { // downloadState.isForgive = true; // downloadState.file = null; // downloadState.progress = 0; // getUseCaseCallback().onError(); } } @Override protected void executeUseCase(RequestValues requestValues) { //访问数据层资源,在 UseCase 中处理带叫停性质的业务 // DataRepository.getInstance().downloadFile(downloadState, dataResult -> { // getUseCaseCallback().onSuccess(new ResponseValue(dataResult)); // }); } public static final class RequestValues implements UseCase.RequestValues { } public static final class ResponseValue implements UseCase.ResponseValue { private final DataResult mDataResult; public ResponseValue(DataResult dataResult) { mDataResult = dataResult; } public DataResult getDataResult() { return mDataResult; } } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/domain/usecase/DownloadUseCase.java ================================================ package com.kunminx.puremusic.domain.usecase; import com.kunminx.architecture.domain.usecase.UseCase; import com.kunminx.puremusic.data.config.Const; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.URL; /** * Create by KunMinX at 20/03/16 */ public class DownloadUseCase extends UseCase { @Override protected void executeUseCase(RequestValues requestValues) { try { URL url = new URL(requestValues.url); InputStream is = url.openStream(); File file = new File(Const.COVER_PATH, requestValues.path); OutputStream os = new FileOutputStream(file); byte[] buffer = new byte[1024]; int len = 0; while ((len = is.read(buffer)) > 0) { os.write(buffer, 0, len); } is.close(); os.close(); getUseCaseCallback().onSuccess(new ResponseValue(file)); } catch (IOException e) { e.printStackTrace(); } } public static final class RequestValues implements UseCase.RequestValues { private String url; private String path; public RequestValues(String url, String path) { this.url = url; this.path = path; } public String getUrl() { return url; } public void setUrl(String url) { this.url = url; } public String getPath() { return path; } public void setPath(String path) { this.path = path; } } public static final class ResponseValue implements UseCase.ResponseValue { private File mFile; public ResponseValue(File file) { mFile = file; } public File getFile() { return mFile; } public void setFile(File file) { mFile = file; } } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/ui/bind/CommonBindingAdapter.java ================================================ /* * Copyright 2018-present KunMinX * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kunminx.puremusic.ui.bind; import android.graphics.drawable.Drawable; import android.util.Pair; import android.view.View; import android.widget.ImageView; import android.widget.TextView; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.databinding.BindingAdapter; import com.bumptech.glide.Glide; import com.kunminx.architecture.utils.ClickUtils; /** * Create by KunMinX at 19/9/18 */ public class CommonBindingAdapter { @BindingAdapter(value = {"imageUrl", "placeHolder"}, requireAll = false) public static void imageUrl(ImageView view, String url, Drawable placeHolder) { Glide.with(view.getContext()).load(url).placeholder(placeHolder).into(view); } @BindingAdapter(value = {"visible"}, requireAll = false) public static void visible(View view, boolean visible) { if (visible && view.getVisibility() == View.GONE) { view.setVisibility(View.VISIBLE); } else if (!visible && view.getVisibility() == View.VISIBLE) { view.setVisibility(View.GONE); } } @BindingAdapter(value = {"invisible"}, requireAll = false) public static void invisible(View view, boolean visible) { if (visible && view.getVisibility() == View.INVISIBLE) { view.setVisibility(View.VISIBLE); } else if (!visible && view.getVisibility() == View.VISIBLE) { view.setVisibility(View.INVISIBLE); } } @BindingAdapter(value = {"size"}, requireAll = false) public static void size(View view, Pair size) { CoordinatorLayout.LayoutParams params = (CoordinatorLayout.LayoutParams) view.getLayoutParams(); params.width = size.first; params.height = size.second; view.setLayoutParams(params); } @BindingAdapter(value = {"transX"}, requireAll = false) public static void translationX(View view, float translationX) { view.setTranslationX(translationX); } @BindingAdapter(value = {"transY"}, requireAll = false) public static void translationY(View view, float translationY) { view.setTranslationY(translationY); } @BindingAdapter(value = {"x"}, requireAll = false) public static void x(View view, float x) { view.setX(x); } @BindingAdapter(value = {"y"}, requireAll = false) public static void y(View view, float y) { view.setY(y); } @BindingAdapter(value = {"alpha"}, requireAll = false) public static void alpha(View view, float alpha) { view.setAlpha(alpha); } @BindingAdapter(value = {"textColor"}, requireAll = false) public static void setTextColor(TextView textView, int textColorRes) { textView.setTextColor(textView.getContext().getColor(textColorRes)); } @BindingAdapter(value = {"selected"}, requireAll = false) public static void selected(View view, boolean select) { view.setSelected(select); } @BindingAdapter(value = {"onClickWithDebouncing"}, requireAll = false) public static void onClickWithDebouncing(View view, View.OnClickListener clickListener) { ClickUtils.applySingleDebouncing(view, clickListener); } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/ui/bind/DrawerBindingAdapter.java ================================================ package com.kunminx.puremusic.ui.bind; import androidx.core.view.GravityCompat; import androidx.databinding.BindingAdapter; import androidx.drawerlayout.widget.DrawerLayout; /** * Create by KunMinX at 2020/3/13 */ public class DrawerBindingAdapter { @BindingAdapter(value = {"isOpenDrawer"}, requireAll = false) public static void openDrawer(DrawerLayout drawerLayout, boolean isOpenDrawer) { if (isOpenDrawer && !drawerLayout.isDrawerOpen(GravityCompat.START)) { drawerLayout.openDrawer(GravityCompat.START); } else { drawerLayout.closeDrawer(GravityCompat.START); } } @BindingAdapter(value = {"allowDrawerOpen"}, requireAll = false) public static void allowDrawerOpen(DrawerLayout drawerLayout, boolean allowDrawerOpen) { drawerLayout.setDrawerLockMode(allowDrawerOpen ? DrawerLayout.LOCK_MODE_UNLOCKED : DrawerLayout.LOCK_MODE_LOCKED_CLOSED); } @BindingAdapter(value = {"bindDrawerListener"}, requireAll = false) public static void listenDrawerState(DrawerLayout drawerLayout, DrawerLayout.SimpleDrawerListener listener) { drawerLayout.addDrawerListener(listener); } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/ui/bind/IconBindingAdapter.java ================================================ package com.kunminx.puremusic.ui.bind; import androidx.databinding.BindingAdapter; import com.kunminx.puremusic.ui.view.PlayPauseView; import net.steamcrafted.materialiconlib.MaterialDrawableBuilder; import net.steamcrafted.materialiconlib.MaterialIconView; /** * Create by KunMinX at 2020/3/13 */ public class IconBindingAdapter { @BindingAdapter(value = {"isPlaying"}, requireAll = false) public static void isPlaying(PlayPauseView pauseView, boolean isPlaying) { if (isPlaying) { pauseView.play(); } else { pauseView.pause(); } } @BindingAdapter(value = {"mdIcon"}, requireAll = false) public static void setIcon(MaterialIconView view, MaterialDrawableBuilder.IconValue iconValue) { view.setIcon(iconValue); } @BindingAdapter(value = {"circleAlpha"}, requireAll = false) public static void circleAlpha(PlayPauseView pauseView, int circleAlpha) { pauseView.setCircleAlpha(circleAlpha); } @BindingAdapter(value = {"drawableColor"}, requireAll = false) public static void drawableColor(PlayPauseView pauseView, int drawableColor) { pauseView.setDrawableColor(drawableColor); } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/ui/bind/TabPageBindingAdapter.java ================================================ package com.kunminx.puremusic.ui.bind; import androidx.databinding.BindingAdapter; import androidx.viewpager.widget.ViewPager; import com.google.android.material.tabs.TabLayout; import com.kunminx.architecture.ui.adapter.CommonViewPagerAdapter; import com.kunminx.puremusic.R; /** * Create by KunMinX at 2020/3/13 */ public class TabPageBindingAdapter { @BindingAdapter(value = {"initTabAndPage"}, requireAll = false) public static void initTabAndPage(ViewPager viewPager, boolean initTabAndPage) { TabLayout tabLayout = (viewPager.getRootView()).findViewById(R.id.tab_layout); int count = tabLayout.getTabCount(); String[] title = new String[count]; for (int i = 0; i < count; i++) { TabLayout.Tab tab = tabLayout.getTabAt(i); if (tab != null && tab.getText() != null) { title[i] = tab.getText().toString(); } } viewPager.setAdapter(new CommonViewPagerAdapter(false, title)); tabLayout.setupWithViewPager(viewPager); } @BindingAdapter(value = {"tabSelectedListener"}, requireAll = false) public static void tabSelectedListener(TabLayout tabLayout, TabLayout.OnTabSelectedListener listener) { tabLayout.addOnTabSelectedListener(listener); } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/ui/bind/WebViewBindingAdapter.java ================================================ package com.kunminx.puremusic.ui.bind; import android.annotation.SuppressLint; import android.content.Intent; import android.net.Uri; import android.view.View; import android.webkit.WebResourceRequest; import android.webkit.WebSettings; import android.webkit.WebView; import android.webkit.WebViewClient; import androidx.databinding.BindingAdapter; import com.kunminx.architecture.utils.Utils; /** * Create by KunMinX at 2020/3/13 */ public class WebViewBindingAdapter { @SuppressLint("SetJavaScriptEnabled") @BindingAdapter(value = {"pageAssetPath"}, requireAll = false) public static void loadAssetsPage(WebView webView, String assetPath) { webView.setWebViewClient(new WebViewClient() { @Override public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { Uri uri = request.getUrl(); Intent intent = new Intent(Intent.ACTION_VIEW, uri); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); Utils.getApp().startActivity(intent); return true; } }); webView.setScrollBarStyle(View.SCROLLBARS_INSIDE_OVERLAY); WebSettings webSettings = webView.getSettings(); webSettings.setJavaScriptEnabled(true); webSettings.setDefaultTextEncodingName("UTF-8"); webSettings.setSupportZoom(true); webSettings.setBuiltInZoomControls(true); webSettings.setDisplayZoomControls(false); webSettings.setUseWideViewPort(true); webSettings.setLoadWithOverviewMode(true); String url = "file:///android_asset/" + assetPath; webView.loadUrl(url); } @SuppressLint("SetJavaScriptEnabled") @BindingAdapter(value = {"loadPage"}, requireAll = false) public static void loadPage(WebView webView, String loadPage) { webView.setWebViewClient(new WebViewClient()); webView.setScrollBarStyle(View.SCROLLBARS_INSIDE_OVERLAY); WebSettings webSettings = webView.getSettings(); webSettings.setJavaScriptEnabled(true); webSettings.setDefaultTextEncodingName("UTF-8"); webSettings.setSupportZoom(true); webSettings.setBuiltInZoomControls(true); webSettings.setDisplayZoomControls(false); webSettings.setUseWideViewPort(true); webSettings.setLoadWithOverviewMode(true); webView.loadUrl(loadPage); } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/ui/page/DrawerFragment.java ================================================ /* * Copyright 2018-present KunMinX * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kunminx.puremusic.ui.page; import android.os.Bundle; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.kunminx.architecture.ui.page.BaseFragment; import com.kunminx.architecture.ui.page.DataBindingConfig; import com.kunminx.architecture.ui.page.StateHolder; import com.kunminx.architecture.ui.state.State; import com.kunminx.puremusic.BR; import com.kunminx.puremusic.R; import com.kunminx.puremusic.data.bean.LibraryInfo; import com.kunminx.puremusic.data.config.Const; import com.kunminx.puremusic.domain.request.InfoRequester; import com.kunminx.puremusic.ui.page.adapter.DrawerAdapter; import java.util.ArrayList; import java.util.List; /** * Create by KunMinX at 19/10/29 */ public class DrawerFragment extends BaseFragment { //TODO tip 1:基于 "单一职责原则",应将 ViewModel 划分为 state-ViewModel 和 result-ViewModel, // state-ViewModel 职责仅限于托管、保存和恢复本页面 state,作用域仅限于本页面, // result-ViewModel 职责仅限于 "消息分发" 场景承担 "可信源",作用域依 "数据请求" 或 "跨页通信" 消息分发范围而定 // 如这么说无体会,详见 https://xiaozhuanlan.com/topic/8204519736 private DrawerStates mStates; private InfoRequester mInfoRequester; @Override protected void initViewModel() { mStates = getFragmentScopeViewModel(DrawerStates.class); mInfoRequester = getFragmentScopeViewModel(InfoRequester.class); } @Override protected DataBindingConfig getDataBindingConfig() { //TODO tip 2: DataBinding 严格模式: // 将 DataBinding 实例限制于 base 页面中,默认不向子类暴露, // 通过这方式,彻底解决 View 实例 Null 安全一致性问题, // 如此,View 实例 Null 安全性将和基于函数式编程思想的 Jetpack Compose 持平。 // 而 DataBindingConfig 就是在这样背景下,用于为 base 页面 DataBinding 提供绑定项。 // 如这么说无体会,详见 https://xiaozhuanlan.com/topic/9816742350 和 https://xiaozhuanlan.com/topic/2356748910 return new DataBindingConfig(R.layout.fragment_drawer, BR.vm, mStates) .addBindingParam(BR.click, new ClickProxy()) .addBindingParam(BR.adapter, new DrawerAdapter(getContext())); } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); //TODO tip 3: 从 PublishSubject 接收回推的数据,并在回调中响应数据的变化, // 也即通过 BehaviorSubject(例如 ObservableField)通知控件属性重新渲染,并为其兜住最后一次状态, //如这么说无体会,详见 https://xiaozhuanlan.com/topic/6741932805 mInfoRequester.getLibraryResult().observe(getViewLifecycleOwner(), dataResult -> { if (!dataResult.getResponseStatus().isSuccess()) return; if (dataResult.getResult() != null) mStates.list.set(dataResult.getResult()); }); mInfoRequester.requestLibraryInfo(); } public class ClickProxy { public void logoClick() { openUrlInBrowser(Const.PROJECT_LINK); } } //TODO tip 5:基于单一职责原则,抽取 Jetpack ViewModel "状态保存和恢复" 的能力作为 StateHolder, // 并使用 ObservableField 的改良版子类 State 来承担 BehaviorSubject,用作所绑定控件的 "可信数据源", // 从而在收到来自 PublishSubject 的结果回推后,响应结果数据的变化,也即通知控件属性重新渲染,并为其兜住最后一次状态, //如这么说无体会,详见 https://xiaozhuanlan.com/topic/6741932805 public static class DrawerStates extends StateHolder { public final State> list = new State<>(new ArrayList<>()); } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/ui/page/LoginFragment.java ================================================ /* * Copyright 2018-present KunMinX * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kunminx.puremusic.ui.page; import android.os.Bundle; import android.text.TextUtils; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.kunminx.architecture.data.config.utils.KeyValueProvider; import com.kunminx.architecture.ui.page.BaseFragment; import com.kunminx.architecture.ui.page.DataBindingConfig; import com.kunminx.architecture.ui.page.StateHolder; import com.kunminx.architecture.ui.state.State; import com.kunminx.architecture.utils.ToastUtils; import com.kunminx.puremusic.BR; import com.kunminx.puremusic.R; import com.kunminx.puremusic.data.bean.User; import com.kunminx.puremusic.data.config.Configs; import com.kunminx.puremusic.domain.event.Messages; import com.kunminx.puremusic.domain.message.DrawerCoordinateManager; import com.kunminx.puremusic.domain.message.PageMessenger; import com.kunminx.puremusic.domain.request.AccountRequester; /** * Create by KunMinX at 20/04/26 */ public class LoginFragment extends BaseFragment { //TODO tip 1:基于 "单一职责原则",应将 ViewModel 划分为 state-ViewModel 和 result-ViewModel, // state-ViewModel 职责仅限于托管、保存和恢复本页面 state,作用域仅限于本页面, // result-ViewModel 职责仅限于 "消息分发" 场景承担 "可信源",作用域依 "数据请求" 或 "跨页通信" 消息分发范围而定 // 如这么说无体会,详见 https://xiaozhuanlan.com/topic/8204519736 private LoginStates mStates; private AccountRequester mAccountRequester; private PageMessenger mMessenger; private final Configs mConfigs = KeyValueProvider.get(Configs.class); @Override protected void initViewModel() { mStates = getFragmentScopeViewModel(LoginStates.class); mMessenger = getApplicationScopeViewModel(PageMessenger.class); mAccountRequester = getFragmentScopeViewModel(AccountRequester.class); } @Override protected DataBindingConfig getDataBindingConfig() { //TODO tip 2: DataBinding 严格模式: // 将 DataBinding 实例限制于 base 页面中,默认不向子类暴露, // 通过这方式,彻底解决 View 实例 Null 安全一致性问题, // 如此,View 实例 Null 安全性将和基于函数式编程思想的 Jetpack Compose 持平。 // 而 DataBindingConfig 就是在这样背景下,用于为 base 页面 DataBinding 提供绑定项。 // 如这么说无体会,详见 https://xiaozhuanlan.com/topic/9816742350 和 https://xiaozhuanlan.com/topic/2356748910 return new DataBindingConfig(R.layout.fragment_login, BR.vm, mStates) .addBindingParam(BR.click, new ClickProxy()); } @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); getLifecycle().addObserver(DrawerCoordinateManager.getInstance()); //TODO tip 3:让 accountRequest 可观察页面生命周期, // 从而在页面即将退出、且登录请求由于网络延迟尚未完成时, // 及时通知数据层取消本次请求,以避免资源浪费和一系列不可预期问题。 getLifecycle().addObserver(mAccountRequester); } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); //TODO tip 4: 从可信源 Requester 通过 immutable Result 获取请求结果的只读数据,set 给 mutable State, //而非 Result、State 不分,直接在页面 set Result, //如这么说无体会,详见《吃透 LiveData 本质,享用可靠消息鉴权机制》解析。 //https://xiaozhuanlan.com/topic/6017825943 mAccountRequester.getTokenResult().observe(getViewLifecycleOwner(), dataResult -> { if (!dataResult.getResponseStatus().isSuccess()) { mStates.loadingVisible.set(false); ToastUtils.showLongToast(getString(R.string.network_state_retry)); return; } String s = dataResult.getResult(); if (TextUtils.isEmpty(s)) return; //TODO tip:成功获取 token 后,可通过 KeyValueX 框架存储配置, // 以及通过作用域为 Application 的 PageMessenger 框架通知其他页面刷新状态, // 具体详见 Configs 类和 PageMessenger 类说明 mConfigs.token().set(s); mStates.loadingVisible.set(false); mMessenger.input(new Messages(Messages.EVENT_LOGIN_SUCCESS)); nav().navigateUp(); }); } public class ClickProxy { public void back() { nav().navigateUp(); } public void login() { //TODO tip 5:通过双向绑定,使能通过 state-ViewModel 中与 xml 控件发生绑定的"可观察数据" 拿到控件数据, // 避免直接接触控件实例而埋下 Null 安全一致性隐患。 //如这么说无体会,详见 https://xiaozhuanlan.com/topic/9816742350 if (TextUtils.isEmpty(mStates.name.get()) || TextUtils.isEmpty(mStates.password.get())) { ToastUtils.showLongToast(getString(R.string.username_or_pwd_incomplete)); return; } User user = new User(mStates.name.get(), mStates.password.get()); mAccountRequester.requestLogin(user); mStates.loadingVisible.set(true); } } //TODO tip 6:基于单一职责原则,抽取 Jetpack ViewModel "状态保存和恢复" 的能力作为 StateHolder, // 并使用 ObservableField 的改良版子类 State 来承担 BehaviorSubject,用作所绑定控件的 "可信数据源", // 从而在收到来自 PublishSubject 的结果回推后,响应结果数据的变化,也即通知控件属性重新渲染,并为其兜住最后一次状态, //如这么说无体会,详见 https://xiaozhuanlan.com/topic/6741932805 public static class LoginStates extends StateHolder { public final State name = new State<>(""); public final State password = new State<>(""); public final State loadingVisible = new State<>(false); } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/ui/page/MainFragment.java ================================================ /* * Copyright 2018-present KunMinX * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kunminx.puremusic.ui.page; import android.annotation.SuppressLint; import android.os.Bundle; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.kunminx.architecture.ui.page.BaseFragment; import com.kunminx.architecture.ui.page.DataBindingConfig; import com.kunminx.architecture.ui.page.StateHolder; import com.kunminx.architecture.ui.state.State; import com.kunminx.puremusic.BR; import com.kunminx.puremusic.R; import com.kunminx.puremusic.data.bean.TestAlbum; import com.kunminx.puremusic.domain.event.Messages; import com.kunminx.puremusic.domain.message.PageMessenger; import com.kunminx.puremusic.domain.proxy.PlayerManager; import com.kunminx.puremusic.domain.request.MusicRequester; import com.kunminx.puremusic.ui.page.adapter.PlaylistAdapter; import java.util.ArrayList; import java.util.List; /** * Create by KunMinX at 19/10/29 */ public class MainFragment extends BaseFragment { //TODO tip 1:基于 "单一职责原则",应将 ViewModel 划分为 state-ViewModel 和 result-ViewModel, // state-ViewModel 职责仅限于托管、保存和恢复本页面 state,作用域仅限于本页面, // result-ViewModel 职责仅限于 "消息分发" 场景承担 "可信源",作用域依 "数据请求" 或 "跨页通信" 消息分发范围而定 // 如这么说无体会,详见 https://xiaozhuanlan.com/topic/8204519736 private MainStates mStates; private PageMessenger mMessenger; private MusicRequester mMusicRequester; private PlaylistAdapter mAdapter; @Override protected void initViewModel() { mStates = getFragmentScopeViewModel(MainStates.class); mMessenger = getApplicationScopeViewModel(PageMessenger.class); mMusicRequester = getFragmentScopeViewModel(MusicRequester.class); } @Override protected DataBindingConfig getDataBindingConfig() { mAdapter = new PlaylistAdapter(getContext()); mAdapter.setOnItemClickListener((viewId, item, position) -> { PlayerManager.getInstance().playAudio(position); }); //TODO tip 2: DataBinding 严格模式: // 将 DataBinding 实例限制于 base 页面中,默认不向子类暴露, // 通过这方式,彻底解决 View 实例 Null 安全一致性问题, // 如此,View 实例 Null 安全性将和基于函数式编程思想的 Jetpack Compose 持平。 // 而 DataBindingConfig 就是在这样背景下,用于为 base 页面 DataBinding 提供绑定项。 // 如这么说无体会,详见 https://xiaozhuanlan.com/topic/9816742350 和 https://xiaozhuanlan.com/topic/2356748910 return new DataBindingConfig(R.layout.fragment_main, BR.vm, mStates) .addBindingParam(BR.click, new ClickProxy()) .addBindingParam(BR.adapter, mAdapter); } @SuppressLint("NotifyDataSetChanged") @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); // TODO tip 3:所有播放状态的改变,皆来自 "可信源" PlayerManager 统一分发, // 确保 "消息分发可靠一致",避免不可预期推送和错误。 // 如这么说无体会,详见 https://xiaozhuanlan.com/topic/6017825943 & https://juejin.cn/post/7117498113983512589 PlayerManager.getInstance().getUiStates().observe(getViewLifecycleOwner(), uiStates -> { mStates.musicId.set(uiStates.getMusicId(), changed -> mAdapter.notifyDataSetChanged()); }); //TODO tip 4: // getViewLifeCycleOwner 是 2020 年新增特性, // 主要为了解决 getView() 生命长度 比 fragment 短(仅存活于 onCreateView 之后和 onDestroyView 之前), // 导致某些时候 fragment 其他成员还活着,但 getView() 为 null 的 生命周期安全问题, // 也即,在 fragment 场景下,请使用 getViewLifeCycleOwner 作为 liveData 观察者。 // Activity 则不用改变。 mMusicRequester.getFreeMusicsResult().observe(getViewLifecycleOwner(), dataResult -> { if (!dataResult.getResponseStatus().isSuccess()) return; TestAlbum musicAlbum = dataResult.getResult(); // TODO tip 5:未作 UnPeek 处理的 LiveData,在视图控制器重建时会自动倒灌数据 // 请记得这一点,因为如果没有妥善处理,这里就可能出现预期外错误(例如收到旧数据推送), // 所以,再一次,请记得它在重建时一定会倒灌。 // 如这么说无体会,详见 https://xiaozhuanlan.com/topic/6719328450 if (musicAlbum != null && musicAlbum.musics != null) { mStates.list.set(musicAlbum.musics); PlayerManager.getInstance().loadAlbum(musicAlbum); } }); mMessenger.output(this, messages -> { switch (messages.eventId) { case Messages.EVENT_LOGIN_SUCCESS: //TODO tip: //loginFragment 登录成功后的后续处理,例如刷新页面状态等 break; } }); if (PlayerManager.getInstance().getAlbum() == null) mMusicRequester.requestFreeMusics(); } // TODO tip 7:此处通过 DataBinding 规避 setOnClickListener 时存在的 View 实例 Null 安全一致性问题, // 也即,有视图就绑定,无就无绑定,总之 不会因不一致性造成 View 实例 Null 安全问题。 // 如这么说无体会,详见 https://xiaozhuanlan.com/topic/9816742350 public class ClickProxy { public void openMenu() { // TODO tip 8:此处演示向 "可信源" 发送请求,以便实现 "生命周期安全、消息分发可靠一致" 的通知。 // 如这么说无体会,详见 https://xiaozhuanlan.com/topic/6017825943 & https://juejin.cn/post/7117498113983512589 // -------- // 与此同时,此处传达的另一思想是 "最少知道原则", // Activity 内部事情在 Activity 内部消化,不要试图在 fragment 中调用和操纵 Activity 内部东西。 // 因为 Activity 端的处理后续可能会改变,且可受用于更多 fragment,而不单单是本 fragment。 mMessenger.input(new Messages(Messages.EVENT_OPEN_DRAWER)); } public void login() { nav().navigate(R.id.action_mainFragment_to_loginFragment); } public void search() { nav().navigate(R.id.action_mainFragment_to_searchFragment); } } //TODO tip 9:每个页面都需单独准备一个 state-ViewModel,托管与 "控件属性" 发生绑定的 State, // 此外,state-ViewModel 职责仅限于状态托管和保存恢复,不建议在此处理 UI 逻辑, // UI 逻辑和业务逻辑,本质区别在于,前者是数据的消费者,后者是数据的生产者, // 数据总是来自领域层业务逻辑的处理,并单向回推至 UI 层,在 UI 层中响应数据的变化(也即处理 UI 逻辑), // 换言之,UI 逻辑只适合在 Activity/Fragment 等视图控制器中编写,将来升级到 Jetpack Compose 更是如此。 //如这么说无体会,详见 https://xiaozhuanlan.com/topic/6741932805 public static class MainStates extends StateHolder { //TODO tip 10:此处我们使用 "去除防抖特性" 的 ObservableField 子类 State,用以代替 MutableLiveData, //如这么说无体会,详见 https://xiaozhuanlan.com/topic/9816742350 public final State musicId = new State<>("", true); public final State initTabAndPage = new State<>(true); public final State pageAssetPath = new State<>("summary.html"); public final State> list = new State<>(new ArrayList<>()); } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/ui/page/PlayerFragment.java ================================================ /* * Copyright 2018-present KunMinX * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kunminx.puremusic.ui.page; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.view.View; import android.widget.SeekBar; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.kunminx.architecture.ui.page.BaseFragment; import com.kunminx.architecture.ui.page.DataBindingConfig; import com.kunminx.architecture.ui.page.StateHolder; import com.kunminx.architecture.ui.state.State; import com.kunminx.architecture.utils.Res; import com.kunminx.architecture.utils.ToastUtils; import com.kunminx.architecture.utils.Utils; import com.kunminx.player.domain.PlayingInfoManager; import com.kunminx.puremusic.BR; import com.kunminx.puremusic.R; import com.kunminx.puremusic.databinding.FragmentPlayerBinding; import com.kunminx.puremusic.domain.event.Messages; import com.kunminx.puremusic.domain.message.DrawerCoordinateManager; import com.kunminx.puremusic.domain.message.PageMessenger; import com.kunminx.puremusic.domain.proxy.PlayerManager; import com.kunminx.puremusic.ui.page.helper.DefaultInterface; import com.kunminx.puremusic.ui.view.PlayerSlideListener; import com.sothree.slidinguppanel.SlidingUpPanelLayout; import net.steamcrafted.materialiconlib.MaterialDrawableBuilder; /** * Create by KunMinX at 19/10/29 */ public class PlayerFragment extends BaseFragment { //TODO tip 1:基于 "单一职责原则",应将 ViewModel 划分为 state-ViewModel 和 result-ViewModel, // state-ViewModel 职责仅限于托管、保存和恢复本页面 state,作用域仅限于本页面, // result-ViewModel 职责仅限于 "消息分发" 场景承担 "可信源",作用域依 "数据请求" 或 "跨页通信" 消息分发范围而定 // 如这么说无体会,详见 https://xiaozhuanlan.com/topic/8204519736 private PlayerStates mStates; private PlayerSlideListener.SlideAnimatorStates mAnimatorStates; private PageMessenger mMessenger; private PlayerSlideListener mListener; @Override protected void initViewModel() { mStates = getFragmentScopeViewModel(PlayerStates.class); mAnimatorStates = getFragmentScopeViewModel(PlayerSlideListener.SlideAnimatorStates.class); mMessenger = getApplicationScopeViewModel(PageMessenger.class); } @Override protected DataBindingConfig getDataBindingConfig() { //TODO tip 2: DataBinding 严格模式: // 将 DataBinding 实例限制于 base 页面中,默认不向子类暴露, // 通过这方式,彻底解决 View 实例 Null 安全一致性问题, // 如此,View 实例 Null 安全性将和基于函数式编程思想的 Jetpack Compose 持平。 // 而 DataBindingConfig 就是在这样背景下,用于为 base 页面 DataBinding 提供绑定项。 // 如这么说无体会,详见 https://xiaozhuanlan.com/topic/9816742350 和 https://xiaozhuanlan.com/topic/2356748910 return new DataBindingConfig(R.layout.fragment_player, BR.vm, mStates) .addBindingParam(BR.panelVm, mAnimatorStates) .addBindingParam(BR.click, new ClickProxy()) .addBindingParam(BR.listener, new ListenerHandler()); } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); //TODO tip 3: 此处演示使用 "可信源" MVI-Dispatcher input-output 接口完成消息收发 //如这么说无体会,详见《领域层设计》篇拆解 https://juejin.cn/post/7117498113983512589 mMessenger.output(this, messages -> { switch (messages.eventId) { case Messages.EVENT_ADD_SLIDE_LISTENER: if (view.getParent().getParent() instanceof SlidingUpPanelLayout) { SlidingUpPanelLayout sliding = (SlidingUpPanelLayout) view.getParent().getParent(); //TODO tip 4: 警惕使用。非必要情况下,尽可能不在子类中拿到 binding 实例乃至获取 view 实例。使用即埋下隐患。 // 目前方案是于 debug 模式,对获取实例情况给予提示。 // 如这么说无体会,详见 https://xiaozhuanlan.com/topic/9816742350 和 https://xiaozhuanlan.com/topic/2356748910 mListener = new PlayerSlideListener((FragmentPlayerBinding) getBinding(), mAnimatorStates, sliding); sliding.addPanelSlideListener(mListener); sliding.addPanelSlideListener(new DefaultInterface.PanelSlideListener() { @Override public void onPanelStateChanged( View view, SlidingUpPanelLayout.PanelState panelState, SlidingUpPanelLayout.PanelState panelState1) { DrawerCoordinateManager.getInstance().requestToUpdateDrawerMode( panelState1 == SlidingUpPanelLayout.PanelState.EXPANDED, this.getClass().getSimpleName() ); } }); } break; case Messages.EVENT_CLOSE_SLIDE_PANEL_IF_EXPANDED: // 按下返回键,如果此时 slide 面板是展开的,那么只对面板进行 slide down if (view.getParent().getParent() instanceof SlidingUpPanelLayout) { SlidingUpPanelLayout sliding = (SlidingUpPanelLayout) view.getParent().getParent(); if (sliding.getPanelState() == SlidingUpPanelLayout.PanelState.EXPANDED) { sliding.setPanelState(SlidingUpPanelLayout.PanelState.COLLAPSED); } else { // TODO tip 5:此处演示向 "可信源" 发送请求,以便实现 "生命周期安全、消息分发可靠一致" 的通知。 // 如这么说无体会,详见 https://xiaozhuanlan.com/topic/0168753249 // -------- // 与此同时,此处传达的另一思想是 "最少知道原则", // Activity 内部事情在 Activity 内部消化,不要试图在 fragment 中调用和操纵 Activity 内部东西。 // 因为 Activity 端的处理后续可能会改变,且可受用于更多 fragment,而不单单是本 fragment。 // TODO: yes: mMessenger.input(new Messages(Messages.EVENT_CLOSE_ACTIVITY_IF_ALLOWED)); // TODO: do not: // mActivity.finish(); } } else { mMessenger.input(new Messages(Messages.EVENT_CLOSE_ACTIVITY_IF_ALLOWED)); } break; } }); // TODO tip 6:所有播放状态的改变,皆来自 getUiStates() 统一分发, // 确保 "消息分发可靠一致",避免不可预期推送和错误, // 细节 1: uiStates 回调只读,此处只可通过 getter 获取只读数据,避免数据被篡改, // 细节 2: uiStates 每次都是整个推送,progress 等属性会造成 uiStates 的高频回推, // 故此宜对低频变化属性做防抖处理,仅当属性值变化时,通知相关控件完成一次重绘, PlayerManager.getInstance().getUiStates().observe(getViewLifecycleOwner(), uiStates -> { mStates.musicId.set(uiStates.getMusicId(), changed -> { mStates.title.set(uiStates.getTitle()); mStates.artist.set(uiStates.getSummary()); mStates.coverImg.set(uiStates.getImg()); if (mListener != null) view.post(mListener::calculateTitleAndArtist); mStates.maxSeekDuration.set(uiStates.getDuration()); }); mStates.currentSeekPosition.set(uiStates.getProgress()); mStates.isPlaying.set(!uiStates.isPaused()); mStates.repeatMode.set(uiStates.getRepeatMode(), changed -> { mStates.playModeIcon.set(PlayerManager.getInstance().getModeIcon(uiStates.getRepeatMode())); }); }); } // TODO tip 7:此处通过 DataBinding 规避 setOnClickListener 时存在的 View 实例 Null 安全一致性问题, // 也即,有视图就绑定,无就无绑定,总之 不会因不一致性造成 View 实例 Null 安全问题。 // 如这么说无体会,详见 https://xiaozhuanlan.com/topic/9816742350 public class ClickProxy { public void playMode() { PlayerManager.getInstance().changeMode(); } public void previous() { PlayerManager.getInstance().playPrevious(); } public void togglePlay() { PlayerManager.getInstance().togglePlay(); } public void next() { PlayerManager.getInstance().playNext(); } public void showPlayList() { ToastUtils.showShortToast(getString(R.string.unfinished)); } //TODO tip: 同 tip 3 public void slideDown() { mMessenger.input(new Messages(Messages.EVENT_CLOSE_SLIDE_PANEL_IF_EXPANDED)); } public void more() { } } public static class ListenerHandler implements DefaultInterface.OnSeekBarChangeListener { @Override public void onStopTrackingTouch(SeekBar seekBar) { PlayerManager.getInstance().setSeek(seekBar.getProgress()); } } //TODO tip 8:基于单一职责原则,抽取 Jetpack ViewModel "状态保存和恢复" 的能力作为 StateHolder, // 并使用 ObservableField 的改良版子类 State 来承担 BehaviorSubject,用作所绑定控件的 "可信数据源", // 从而在收到来自 PublishSubject 的结果回推后,响应结果数据的变化,也即通知控件属性重新渲染,并为其兜住最后一次状态, //如这么说无体会,详见 https://xiaozhuanlan.com/topic/6741932805 public static class PlayerStates extends StateHolder { public final State musicId = new State<>("", true); public final State> repeatMode = new State<>(PlayingInfoManager.RepeatMode.LIST_CYCLE, true); public final State title = new State<>(Utils.getApp().getString(R.string.app_name), true); public final State artist = new State<>(Utils.getApp().getString(R.string.app_name), true); public final State coverImg = new State<>("", true); public final State placeHolder = new State<>(Res.getDrawable(R.drawable.bg_album_default), true); public final State maxSeekDuration = new State<>(0, true); public final State currentSeekPosition = new State<>(0, true); public final State isPlaying = new State<>(false, true); public final State playModeIcon = new State<>(PlayerManager.getInstance().getModeIcon(), true); } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/ui/page/SearchFragment.java ================================================ /* * Copyright 2018-present KunMinX * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kunminx.puremusic.ui.page; import android.os.Bundle; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.kunminx.architecture.ui.page.BaseFragment; import com.kunminx.architecture.ui.page.DataBindingConfig; import com.kunminx.architecture.ui.page.StateHolder; import com.kunminx.architecture.ui.state.State; import com.kunminx.puremusic.BR; import com.kunminx.puremusic.R; import com.kunminx.puremusic.data.bean.DownloadState; import com.kunminx.puremusic.data.config.Const; import com.kunminx.puremusic.domain.event.DownloadEvent; import com.kunminx.puremusic.domain.message.DrawerCoordinateManager; import com.kunminx.puremusic.domain.request.DownloadRequester; /** * Create by KunMinX at 19/10/29 */ public class SearchFragment extends BaseFragment { //TODO tip 1:基于 "单一职责原则",应将 ViewModel 划分为 state-ViewModel 和 result-ViewModel, // state-ViewModel 职责仅限于托管、保存和恢复本页面 state,作用域仅限于本页面, // result-ViewModel 职责仅限于 "消息分发" 场景承担 "可信源",作用域依 "数据请求" 或 "跨页通信" 消息分发范围而定 // 如这么说无体会,详见 https://xiaozhuanlan.com/topic/8204519736 private SearchStates mStates; private DownloadRequester mDownloadRequester; private DownloadRequester mGlobalDownloadRequester; @Override protected void initViewModel() { mStates = getFragmentScopeViewModel(SearchStates.class); mDownloadRequester = getFragmentScopeViewModel(DownloadRequester.class); mGlobalDownloadRequester = getActivityScopeViewModel(DownloadRequester.class); } @Override protected DataBindingConfig getDataBindingConfig() { //TODO tip 2: DataBinding 严格模式: // 将 DataBinding 实例限制于 base 页面中,默认不向子类暴露, // 通过这方式,彻底解决 View 实例 Null 安全一致性问题, // 如此,View 实例 Null 安全性将和基于函数式编程思想的 Jetpack Compose 持平。 // 而 DataBindingConfig 就是在这样背景下,用于为 base 页面 DataBinding 提供绑定项。 // 如这么说无体会,详见 https://xiaozhuanlan.com/topic/9816742350 和 https://xiaozhuanlan.com/topic/2356748910 return new DataBindingConfig(R.layout.fragment_search, BR.vm, mStates) .addBindingParam(BR.click, new ClickProxy()); } @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); getLifecycle().addObserver(DrawerCoordinateManager.getInstance()); //TODO tip 3:绑定跟随视图控制器生命周期、可叫停、单独放在 UseCase 中处理的业务 getLifecycle().addObserver(mDownloadRequester); } @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); //TODO tip 8: 此处演示使用 MVI-Dispatcher input-output 接口完成数据请求响应 //如这么说无体会,详见《领域层设计》篇拆解 https://juejin.cn/post/7117498113983512589 mDownloadRequester.output(this, downloadEvent -> { if (downloadEvent.eventId == DownloadEvent.EVENT_DOWNLOAD) { DownloadState state = downloadEvent.downloadState; mStates.progress_cancelable.set(state.progress); mStates.enableDownload.set(state.progress == 100 || state.progress == 0); } }); //TODO tip 9: 此处演示 "同一 Result-ViewModel 类,在不同作用域下实例化,造成的不同结果" mGlobalDownloadRequester.output(this, downloadEvent -> { if (downloadEvent.eventId == DownloadEvent.EVENT_DOWNLOAD_GLOBAL) { DownloadState state = downloadEvent.downloadState; mStates.progress.set(state.progress); mStates.enableGlobalDownload.set(state.progress == 100 || state.progress == 0); } }); } // TODO tip 4:此处通过 DataBinding 规避 setOnClickListener 时存在的 View 实例 Null 安全一致性问题, // 也即,有视图就绑定,无就无绑定,总之 不会因不一致性造成 View 实例 Null 安全问题。 // 如这么说无体会,详见 https://xiaozhuanlan.com/topic/9816742350 public class ClickProxy { public void back() { nav().navigateUp(); } public void testNav() { openUrlInBrowser(Const.COLUMN_LINK); } public void subscribe() { openUrlInBrowser(Const.COLUMN_LINK); } //TODO tip: 同 tip 8 public void testDownload() { mGlobalDownloadRequester.input(new DownloadEvent(DownloadEvent.EVENT_DOWNLOAD_GLOBAL)); } //TODO tip 5: 在 UseCase 中 执行可跟随生命周期中止的下载任务 public void testLifecycleDownload() { mDownloadRequester.input(new DownloadEvent(DownloadEvent.EVENT_DOWNLOAD)); } } //TODO tip 6:基于单一职责原则,抽取 Jetpack ViewModel "状态保存和恢复" 的能力作为 StateHolder, // 并使用 ObservableField 的改良版子类 State 来承担 BehaviorSubject,用作所绑定控件的 "可信数据源", // 从而在收到来自 PublishSubject 的结果回推后,响应结果数据的变化,也即通知控件属性重新渲染,并为其兜住最后一次状态, //如这么说无体会,详见 https://xiaozhuanlan.com/topic/6741932805 public static class SearchStates extends StateHolder { public final State progress = new State<>(1); public final State progress_cancelable = new State<>(1); public final State enableDownload = new State<>(true); public final State enableGlobalDownload = new State<>(true); } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/ui/page/adapter/DiffUtils.java ================================================ /* * Copyright 2018-present KunMinX * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kunminx.puremusic.ui.page.adapter; import androidx.annotation.NonNull; import androidx.recyclerview.widget.DiffUtil; import com.kunminx.puremusic.data.bean.LibraryInfo; import com.kunminx.puremusic.data.bean.TestAlbum; /** * Create by KunMinX at 2020/7/19 */ public class DiffUtils { private DiffUtil.ItemCallback mLibraryInfoItemCallback; private DiffUtil.ItemCallback mTestMusicItemCallback; private DiffUtils() { } private static final DiffUtils S_DIFF_UTILS = new DiffUtils(); public static DiffUtils getInstance() { return S_DIFF_UTILS; } public DiffUtil.ItemCallback getLibraryInfoItemCallback() { if (mLibraryInfoItemCallback == null) { mLibraryInfoItemCallback = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull LibraryInfo oldItem, @NonNull LibraryInfo newItem) { return oldItem.equals(newItem); } @Override public boolean areContentsTheSame(@NonNull LibraryInfo oldItem, @NonNull LibraryInfo newItem) { return oldItem.getTitle().equals(newItem.getTitle()); } }; } return mLibraryInfoItemCallback; } public DiffUtil.ItemCallback getTestMusicItemCallback() { if (mTestMusicItemCallback == null) { mTestMusicItemCallback = new DiffUtil.ItemCallback() { @Override public boolean areItemsTheSame(@NonNull TestAlbum.TestMusic oldItem, @NonNull TestAlbum.TestMusic newItem) { return oldItem.equals(newItem); } @Override public boolean areContentsTheSame(@NonNull TestAlbum.TestMusic oldItem, @NonNull TestAlbum.TestMusic newItem) { return oldItem.musicId.equals(newItem.musicId); } }; } return mTestMusicItemCallback; } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/ui/page/adapter/DrawerAdapter.java ================================================ /* * Copyright 2018-present KunMinX * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kunminx.puremusic.ui.page.adapter; import android.content.Context; import android.content.Intent; import android.net.Uri; import androidx.recyclerview.widget.RecyclerView; import com.kunminx.binding_recyclerview.adapter.SimpleDataBindingAdapter; import com.kunminx.puremusic.R; import com.kunminx.puremusic.data.bean.LibraryInfo; import com.kunminx.puremusic.databinding.AdapterLibraryBinding; /** * Create by KunMinX at 20/4/19 */ public class DrawerAdapter extends SimpleDataBindingAdapter { public DrawerAdapter(Context context) { super(context, R.layout.adapter_library, DiffUtils.getInstance().getLibraryInfoItemCallback()); //TODO item click 回调可以在 adapter 中实现,也可以在外部实现 setOnItemClickListener((viewId, item, position) -> { Uri uri = Uri.parse(item.getUrl()); Intent intent = new Intent(Intent.ACTION_VIEW, uri); mContext.startActivity(intent); }); } @Override protected void onBindItem(AdapterLibraryBinding binding, LibraryInfo item, RecyclerView.ViewHolder holder) { binding.setInfo(item); } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/ui/page/adapter/PlaylistAdapter.java ================================================ /* * Copyright 2018-present KunMinX * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kunminx.puremusic.ui.page.adapter; import android.content.Context; import android.graphics.Color; import androidx.recyclerview.widget.RecyclerView; import com.kunminx.binding_recyclerview.adapter.SimpleDataBindingAdapter; import com.kunminx.puremusic.R; import com.kunminx.puremusic.data.bean.TestAlbum; import com.kunminx.puremusic.databinding.AdapterPlayItemBinding; import com.kunminx.puremusic.domain.proxy.PlayerManager; /** * Create by KunMinX at 20/4/19 */ public class PlaylistAdapter extends SimpleDataBindingAdapter { public PlaylistAdapter(Context context) { super(context, R.layout.adapter_play_item, DiffUtils.getInstance().getTestMusicItemCallback()); } @Override protected void onBindItem(AdapterPlayItemBinding binding, TestAlbum.TestMusic item, RecyclerView.ViewHolder holder) { binding.setAlbum(item); int currentIndex = PlayerManager.getInstance().getAlbumIndex(); binding.ivPlayStatus.setColor(currentIndex == holder.getAbsoluteAdapterPosition() ? binding.getRoot().getContext().getColor(R.color.gray) : Color.TRANSPARENT); } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/ui/page/helper/DefaultInterface.java ================================================ /* * * * Copyright 2018-present KunMinX * * * * Licensed under the Apache License, Version 2.0 (the "License"); * * you may not use this file except in compliance with the License. * * You may obtain a copy of the License at * * * * http://www.apache.org/licenses/LICENSE-2.0 * * * * Unless required by applicable law or agreed to in writing, software * * distributed under the License is distributed on an "AS IS" BASIS, * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * * See the License for the specific language governing permissions and * * limitations under the License. * */ package com.kunminx.puremusic.ui.page.helper; import android.view.View; import android.widget.SeekBar; import com.sothree.slidinguppanel.SlidingUpPanelLayout; /** * Create by KunMinX at 2020/12/3 */ public class DefaultInterface { public interface OnSeekBarChangeListener extends SeekBar.OnSeekBarChangeListener { @Override default void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { } @Override default void onStartTrackingTouch(SeekBar seekBar) { } @Override default void onStopTrackingTouch(SeekBar seekBar) { } } public interface PanelSlideListener extends SlidingUpPanelLayout.PanelSlideListener { @Override default void onPanelSlide(View panel, float slideOffset) { } @Override default void onPanelStateChanged(View panel, SlidingUpPanelLayout.PanelState previousState, SlidingUpPanelLayout.PanelState newState) { } } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/ui/view/PlayPauseDrawable.java ================================================ package com.kunminx.puremusic.ui.view; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.ObjectAnimator; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.ColorFilter; import android.graphics.Paint; import android.graphics.Path; import android.graphics.PixelFormat; import android.graphics.Rect; import android.graphics.RectF; import android.graphics.drawable.Drawable; import android.util.Property; import androidx.annotation.ColorInt; public class PlayPauseDrawable extends Drawable { private static final Property PROGRESS = new Property(Float.class, "progress") { @Override public Float get(PlayPauseDrawable d) { return d.getProgress(); } @Override public void set(PlayPauseDrawable d, Float value) { d.setProgress(value); } }; private final Path mLeftPauseBar = new Path(); private final Path mRightPauseBar = new Path(); private final Paint mPaint = new Paint(); private final RectF mBounds = new RectF(); private float mPauseBarWidth; private float mPauseBarHeight; private float mPauseBarDistance; private float mWidth; private float mHeight; private float mProgress; private boolean mIsPlay; public PlayPauseDrawable() { mPaint.setAntiAlias(true); mPaint.setStyle(Paint.Style.FILL); mPaint.setColor(Color.BLACK); } public PlayPauseDrawable(@ColorInt int color) { mPaint.setAntiAlias(true); mPaint.setStyle(Paint.Style.FILL); mPaint.setColor(color); } private static float interpolate(float a, float b, float t) { return a + (b - a) * t; } public void setIsPlay(boolean isPlay) { this.mIsPlay = isPlay; } @Override protected void onBoundsChange(Rect bounds) { super.onBoundsChange(bounds); mBounds.set(bounds); mWidth = mBounds.width(); mHeight = mBounds.height(); mPauseBarWidth = mWidth / 8; mPauseBarHeight = mHeight * 0.40f; mPauseBarDistance = mPauseBarWidth; } @Override public void draw(Canvas canvas) { mLeftPauseBar.rewind(); mRightPauseBar.rewind(); final float barDist = interpolate(mPauseBarDistance, 0, mProgress); final float barWidth = interpolate(mPauseBarWidth, mPauseBarHeight / 2f, mProgress); final float firstBarTopLeft = interpolate(0, barWidth, mProgress); final float secondBarTopRight = interpolate(2 * barWidth + barDist, barWidth + barDist, mProgress); mLeftPauseBar.moveTo(0, 0); mLeftPauseBar.lineTo(firstBarTopLeft, -mPauseBarHeight); mLeftPauseBar.lineTo(barWidth, -mPauseBarHeight); mLeftPauseBar.lineTo(barWidth, 0); mLeftPauseBar.close(); mRightPauseBar.moveTo(barWidth + barDist, 0); mRightPauseBar.lineTo(barWidth + barDist, -mPauseBarHeight); mRightPauseBar.lineTo(secondBarTopRight, -mPauseBarHeight); mRightPauseBar.lineTo(2 * barWidth + barDist, 0); mRightPauseBar.close(); canvas.save(); canvas.translate(interpolate(0, mPauseBarHeight / 8f, mProgress), 0); final float rotationProgress = mIsPlay ? 1 - mProgress : mProgress; final float startingRotation = mIsPlay ? 90 : 0; canvas.rotate(interpolate(startingRotation, startingRotation + 90, rotationProgress), mWidth / 2f, mHeight / 2f); canvas.translate(mWidth / 2f - ((2 * barWidth + barDist) / 2f), mHeight / 2f + (mPauseBarHeight / 2f)); canvas.drawPath(mLeftPauseBar, mPaint); canvas.drawPath(mRightPauseBar, mPaint); canvas.restore(); } public Animator getPausePlayAnimator() { final Animator anim = ObjectAnimator.ofFloat(this, PROGRESS, mIsPlay ? 1 : 0, mIsPlay ? 0 : 1); anim.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { mIsPlay = !mIsPlay; } }); return anim; } public boolean isPlay() { return mIsPlay; } private float getProgress() { return mProgress; } private void setProgress(float progress) { mProgress = progress; invalidateSelf(); } @Override public void setAlpha(int alpha) { mPaint.setAlpha(alpha); invalidateSelf(); } public void setDrawableColor(@ColorInt int color) { mPaint.setColor(color); invalidateSelf(); } @Override public void setColorFilter(ColorFilter cf) { mPaint.setColorFilter(cf); invalidateSelf(); } @Override public int getOpacity() { return PixelFormat.TRANSLUCENT; } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/ui/view/PlayPauseView.java ================================================ package com.kunminx.puremusic.ui.view; import android.animation.Animator; import android.animation.AnimatorSet; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Outline; import android.graphics.Paint; import android.graphics.drawable.Drawable; import android.util.AttributeSet; import android.view.View; import android.view.ViewOutlineProvider; import android.view.animation.DecelerateInterpolator; import android.widget.FrameLayout; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import com.kunminx.puremusic.R; public class PlayPauseView extends FrameLayout { private static final long PLAY_PAUSE_ANIMATION_DURATION = 200; public final boolean isDrawCircle; private final PlayPauseDrawable mDrawable; private final Paint mPaint = new Paint(); public int circleAlpha; private int mDrawableColor; private AnimatorSet mAnimatorSet; private int mBackgroundColor; private int mWidth; private int mHeight; private boolean mIsPlay; public PlayPauseView(Context context, AttributeSet attrs) { super(context, attrs); setWillNotDraw(false); TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.PlayPauseView); isDrawCircle = typedArray.getBoolean(R.styleable.PlayPauseView_isCircleDraw, true); circleAlpha = typedArray.getInt(R.styleable.PlayPauseView_circleAlpha, 255); mDrawableColor = typedArray.getInt(R.styleable.PlayPauseView_drawableColor, Color.WHITE); typedArray.recycle(); mPaint.setAntiAlias(true); mPaint.setStyle(Paint.Style.FILL); mPaint.setAlpha(circleAlpha); mPaint.setColor(mBackgroundColor); mDrawable = new PlayPauseDrawable(mDrawableColor); mDrawable.setCallback(this); } @Override protected void onSizeChanged(final int w, final int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); mDrawable.setBounds(0, 0, w, h); mWidth = w; mHeight = h; setOutlineProvider(new ViewOutlineProvider() { @Override public void getOutline(View view, Outline outline) { outline.setOval(0, 0, view.getWidth(), view.getHeight()); } }); setClipToOutline(true); } public void setCircleAlpha(int alpah) { circleAlpha = alpah; invalidate(); } private int getCircleColor() { return mBackgroundColor; } public void setCircleColor(@ColorInt int color) { mBackgroundColor = color; invalidate(); } public int getDrawableColor() { return mDrawableColor; } public void setDrawableColor(@ColorInt int color) { mDrawableColor = color; mDrawable.setDrawableColor(color); invalidate(); } @Override protected boolean verifyDrawable(@NonNull Drawable who) { return who == mDrawable || super.verifyDrawable(who); } @Override public boolean hasOverlappingRendering() { return false; } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); mPaint.setColor(mBackgroundColor); final float radius = Math.min(mWidth, mHeight) / 2f; if (isDrawCircle) { mPaint.setColor(mBackgroundColor); mPaint.setAlpha(circleAlpha); canvas.drawCircle(mWidth / 2f, mHeight / 2f, radius, mPaint); } mDrawable.draw(canvas); } public boolean isPlay() { return mIsPlay; } public void play() { if (mAnimatorSet != null) { mAnimatorSet.cancel(); } mAnimatorSet = new AnimatorSet(); mDrawable.setIsPlay(mIsPlay = true); final Animator pausePlayAnim = mDrawable.getPausePlayAnimator(); mAnimatorSet.setInterpolator(new DecelerateInterpolator()); mAnimatorSet.setDuration(PLAY_PAUSE_ANIMATION_DURATION); pausePlayAnim.start(); } public void pause() { if (mAnimatorSet != null) { mAnimatorSet.cancel(); } mAnimatorSet = new AnimatorSet(); mDrawable.setIsPlay(mIsPlay = false); final Animator pausePlayAnim = mDrawable.getPausePlayAnimator(); mAnimatorSet.setInterpolator(new DecelerateInterpolator()); mAnimatorSet.setDuration(PLAY_PAUSE_ANIMATION_DURATION); pausePlayAnim.start(); } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/ui/view/PlayerSlideListener.java ================================================ /* * Copyright 2018-present KunMinX * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kunminx.puremusic.ui.view; import android.animation.ArgbEvaluator; import android.animation.FloatEvaluator; import android.animation.IntEvaluator; import android.graphics.Color; import android.graphics.Rect; import android.util.Pair; import android.view.View; import android.widget.TextView; import com.kunminx.architecture.ui.page.StateHolder; import com.kunminx.architecture.ui.state.State; import com.kunminx.architecture.utils.DisplayUtils; import com.kunminx.architecture.utils.ScreenUtils; import com.kunminx.puremusic.databinding.FragmentPlayerBinding; import com.sothree.slidinguppanel.SlidingUpPanelLayout; /** * Create by KunMinX at 19/10/29 */ public class PlayerSlideListener implements SlidingUpPanelLayout.PanelSlideListener { private final FragmentPlayerBinding mBinding; private final SlidingUpPanelLayout mSlidingUpPanelLayout; private final SlideAnimatorStates mStates; private int mTitleEndTranslationX; private int mArtistEndTranslationX; private int mArtistNormalEndTranslationY; private int mContentNormalEndTranslationY; private final int mModeStartX; private final int mPreviousStartX; private final int mPlayPauseStartX; private final int mNextStartX; private final int mPlayQueueStartX; private final int mPlayPauseEndX; private final int mPreviousEndX; private final int mModeEndX; private final int mNextEndX; private final int mPlayQueueEndX; private final int mIconContainerStartY; private final int mIconContainerEndY; private final int SCREEN_WIDTH; private final int SCREEN_HEIGHT; private final IntEvaluator INT_EVALUATOR = new IntEvaluator(); private final FloatEvaluator FLOAT_EVALUATOR = new FloatEvaluator(); private final ArgbEvaluator COLOR_EVALUATOR = new ArgbEvaluator(); private final int NOW_PLAYING_CARD_COLOR; private final int PLAY_PAUSE_DRAWABLE_COLOR; private Status mStatus = Status.COLLAPSED; public enum Status { EXPANDED, COLLAPSED, } public PlayerSlideListener(FragmentPlayerBinding binding, SlideAnimatorStates states, SlidingUpPanelLayout slidingUpPanelLayout) { mBinding = binding; mStates = states; mSlidingUpPanelLayout = slidingUpPanelLayout; SCREEN_WIDTH = ScreenUtils.getScreenWidth(); SCREEN_HEIGHT = ScreenUtils.getScreenHeight(); PLAY_PAUSE_DRAWABLE_COLOR = Color.BLACK; NOW_PLAYING_CARD_COLOR = Color.WHITE; calculateTitleAndArtist(); mModeStartX = binding.mode != null ? binding.mode.getLeft() : 0; mPreviousStartX = binding.previous.getLeft(); mPlayPauseStartX = binding.playPause.getLeft(); mNextStartX = binding.next.getLeft(); mPlayQueueStartX = binding.icPlayList != null ? binding.icPlayList.getLeft() : 0; int size = DisplayUtils.dp2px(36); int gap = (SCREEN_WIDTH - 5 * (size)) / 6; mPlayPauseEndX = (SCREEN_WIDTH / 2) - (size / 2); mPreviousEndX = mPlayPauseEndX - gap - size; mModeEndX = mPreviousEndX - gap - size; mNextEndX = mPlayPauseEndX + gap + size; mPlayQueueEndX = mNextEndX + gap + size; mIconContainerStartY = binding.iconContainer.getTop(); int tempImgSize = DisplayUtils.dp2px(55); mStates.albumArtSize.set(new Pair<>(tempImgSize, tempImgSize)); mIconContainerEndY = SCREEN_HEIGHT - 3 * binding.iconContainer.getHeight() - binding.seekBottom.getHeight(); mStates.playPauseDrawableColor.set(PLAY_PAUSE_DRAWABLE_COLOR); mStates.playCircleAlpha.set(INT_EVALUATOR.evaluate(0, 0, 255)); mStates.nextX.set(mNextStartX); mStates.modeX.set(0); mStates.previousX.set(0); mStates.playPauseX.set(mPlayPauseStartX); mStates.iconContainerY.set(mIconContainerStartY); mBinding.executePendingBindings(); } @Override public void onPanelSlide(View panel, float slideOffset) { calculateTitleAndArtist(); int tempImgSize = INT_EVALUATOR.evaluate(slideOffset, DisplayUtils.dp2px(55), SCREEN_WIDTH); mStates.albumArtSize.set(new Pair<>(tempImgSize, tempImgSize)); mStates.titleTranslationX.set(FLOAT_EVALUATOR.evaluate(slideOffset, 0, mTitleEndTranslationX)); mStates.artistTranslationX.set(FLOAT_EVALUATOR.evaluate(slideOffset, 0, mArtistEndTranslationX)); mStates.artistTranslationY.set(FLOAT_EVALUATOR.evaluate(slideOffset, 0, mArtistNormalEndTranslationY)); mStates.summaryTranslationY.set(FLOAT_EVALUATOR.evaluate(slideOffset, 0, mContentNormalEndTranslationY)); mStates.playPauseX.set(INT_EVALUATOR.evaluate(slideOffset, mPlayPauseStartX, mPlayPauseEndX)); mStates.playCircleAlpha.set(INT_EVALUATOR.evaluate(slideOffset, 0, 255)); mStates.playPauseDrawableColor.set((int) COLOR_EVALUATOR.evaluate(slideOffset, PLAY_PAUSE_DRAWABLE_COLOR, NOW_PLAYING_CARD_COLOR)); mStates.previousX.set(INT_EVALUATOR.evaluate(slideOffset, mPreviousStartX, mPreviousEndX)); mStates.modeX.set(INT_EVALUATOR.evaluate(slideOffset, mModeStartX, mModeEndX)); mStates.nextX.set(INT_EVALUATOR.evaluate(slideOffset, mNextStartX, mNextEndX)); mStates.icPlayListX.set(INT_EVALUATOR.evaluate(slideOffset, mPlayQueueStartX, mPlayQueueEndX)); mStates.modeAlpha.set(FLOAT_EVALUATOR.evaluate(slideOffset, 0, 1)); mStates.previousAlpha.set(FLOAT_EVALUATOR.evaluate(slideOffset, 0, 1)); mStates.iconContainerY.set(INT_EVALUATOR.evaluate(slideOffset, mIconContainerStartY, mIconContainerEndY)); mBinding.executePendingBindings(); } @Override public void onPanelStateChanged(View panel, SlidingUpPanelLayout.PanelState previousState, SlidingUpPanelLayout.PanelState newState) { if (previousState == SlidingUpPanelLayout.PanelState.COLLAPSED) { mStates.songProgressNormalVisibility.set(false); mStates.modeVisibility.set(true); mStates.previousVisibility.set(true); } if (newState == SlidingUpPanelLayout.PanelState.EXPANDED) { mStatus = Status.EXPANDED; mStates.customToolbarVisibility.set(true); } else if (newState == SlidingUpPanelLayout.PanelState.COLLAPSED) { mStatus = Status.COLLAPSED; mStates.songProgressNormalVisibility.set(true); mStates.modeVisibility.set(false); mStates.previousVisibility.set(false); mBinding.topContainer.setOnClickListener(v -> { if (mSlidingUpPanelLayout.isTouchEnabled()) mSlidingUpPanelLayout.setPanelState(SlidingUpPanelLayout.PanelState.EXPANDED); }); } else if (newState == SlidingUpPanelLayout.PanelState.DRAGGING) { mStates.customToolbarVisibility.set(false); } } public void calculateTitleAndArtist() { int titleWidth = getTextWidth(mBinding.title != null ? mBinding.title : null); int artistWidth = getTextWidth(mBinding.artist != null ? mBinding.artist : null); mTitleEndTranslationX = (SCREEN_WIDTH / 2) - (titleWidth / 2) - DisplayUtils.dp2px(67); mArtistEndTranslationX = (SCREEN_WIDTH / 2) - (artistWidth / 2) - DisplayUtils.dp2px(67); mArtistNormalEndTranslationY = DisplayUtils.dp2px(12); mContentNormalEndTranslationY = SCREEN_WIDTH + DisplayUtils.dp2px(32); mStates.titleTranslationX.set(mStatus == Status.COLLAPSED ? 0f : mTitleEndTranslationX); mStates.artistTranslationX.set(mStatus == Status.COLLAPSED ? 0f : mArtistEndTranslationX); } private int getTextWidth(TextView textView) { if (textView == null) return 0; Rect artistBounds = new Rect(); textView.getPaint().getTextBounds(textView.getText().toString(), 0, textView.getText().length(), artistBounds); return artistBounds.width(); } /** * TODO tip:使用 ObservableField 绑定,尽可能减少 View 实例 Null 安全一致性问题 *

* 如这么说无体会,详见 https://xiaozhuanlan.com/topic/9816742350 和 https://xiaozhuanlan.com/topic/2356748910 */ public static class SlideAnimatorStates extends StateHolder { public final State titleTranslationX = new State<>(0f); public final State artistTranslationX = new State<>(0f); public final State artistTranslationY = new State<>(0f); public final State summaryTranslationY = new State<>(0f); public final State playPauseX = new State<>(0); public final State playCircleAlpha = new State<>(0); public final State playPauseDrawableColor = new State<>(0); public final State previousX = new State<>(0); public final State modeX = new State<>(0); public final State nextX = new State<>(0); public final State icPlayListX = new State<>(0); public final State modeAlpha = new State<>(0f); public final State previousAlpha = new State<>(0f); public final State iconContainerY = new State<>(0); public final State songProgressNormalVisibility = new State<>(false); public final State modeVisibility = new State<>(false); public final State previousVisibility = new State<>(false); public final State customToolbarVisibility = new State<>(false); public final State> albumArtSize = new State<>(new Pair<>(0, 0)); } } ================================================ FILE: app/src/main/java/com/kunminx/puremusic/ui/widget/PlayerService.java ================================================ /* * Copyright 2018-present KunMinX * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.kunminx.puremusic.ui.widget; import android.annotation.SuppressLint; import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationChannelGroup; import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; import android.content.Context; import android.content.Intent; import android.graphics.Bitmap; import android.os.Build; import android.os.IBinder; import android.view.View; import android.widget.RemoteViews; import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; import com.kunminx.architecture.domain.usecase.UseCaseHandler; import com.kunminx.architecture.utils.ImageUtils; import com.kunminx.puremusic.MainActivity; import com.kunminx.puremusic.R; import com.kunminx.puremusic.data.bean.TestAlbum; import com.kunminx.puremusic.data.config.Const; import com.kunminx.puremusic.domain.proxy.PlayerManager; import com.kunminx.puremusic.domain.usecase.DownloadUseCase; import java.io.File; /** * Create by KunMinX at 19/7/17 */ public class PlayerService extends Service { public static final String NOTIFY_PREVIOUS = "pure_music.kunminx.previous"; public static final String NOTIFY_CLOSE = "pure_music.kunminx.close"; public static final String NOTIFY_PAUSE = "pure_music.kunminx.pause"; public static final String NOTIFY_PLAY = "pure_music.kunminx.play"; public static final String NOTIFY_NEXT = "pure_music.kunminx.next"; private static final String GROUP_ID = "group_001"; private static final String CHANNEL_ID = "channel_001"; private DownloadUseCase mDownloadUseCase; @Override public int onStartCommand(Intent intent, int flags, int startId) { TestAlbum.TestMusic results = PlayerManager.getInstance().getCurrentPlayingMusic(); if (results == null) { stopSelf(); return START_NOT_STICKY; } createNotification(results); return START_NOT_STICKY; } private void createNotification(TestAlbum.TestMusic testMusic) { try { String title = testMusic.title; TestAlbum album = PlayerManager.getInstance().getAlbum(); String summary = album.summary; RemoteViews simpleContentView = new RemoteViews( getApplicationContext().getPackageName(), R.layout.notify_player_small); RemoteViews expandedView; expandedView = new RemoteViews( getApplicationContext().getPackageName(), R.layout.notify_player_big); Intent intent = new Intent(getApplicationContext(), MainActivity.class); intent.setAction("showPlayer"); PendingIntent contentIntent = PendingIntent.getActivity(this, 0, intent, Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE : 0); if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); NotificationChannelGroup playGroup = new NotificationChannelGroup(GROUP_ID, getString(R.string.play)); notificationManager.createNotificationChannelGroup(playGroup); NotificationChannel playChannel = new NotificationChannel(CHANNEL_ID, getString(R.string.notify_of_play), NotificationManager.IMPORTANCE_DEFAULT); playChannel.setGroup(GROUP_ID); notificationManager.createNotificationChannel(playChannel); } Notification notification = new NotificationCompat.Builder( getApplicationContext(), CHANNEL_ID) .setSmallIcon(R.drawable.ic_player) .setContentIntent(contentIntent) .setOnlyAlertOnce(true) .setContentTitle(title).build(); notification.contentView = simpleContentView; notification.bigContentView = expandedView; setListeners(simpleContentView); setListeners(expandedView); notification.contentView.setViewVisibility(R.id.player_progress_bar, View.GONE); notification.contentView.setViewVisibility(R.id.player_next, View.VISIBLE); notification.contentView.setViewVisibility(R.id.player_previous, View.VISIBLE); notification.bigContentView.setViewVisibility(R.id.player_next, View.VISIBLE); notification.bigContentView.setViewVisibility(R.id.player_previous, View.VISIBLE); notification.bigContentView.setViewVisibility(R.id.player_progress_bar, View.GONE); boolean isPaused = PlayerManager.getInstance().isPaused(); notification.contentView.setViewVisibility(R.id.player_pause, isPaused ? View.GONE : View.VISIBLE); notification.contentView.setViewVisibility(R.id.player_play, isPaused ? View.VISIBLE : View.GONE); notification.bigContentView.setViewVisibility(R.id.player_pause, isPaused ? View.GONE : View.VISIBLE); notification.bigContentView.setViewVisibility(R.id.player_play, isPaused ? View.VISIBLE : View.GONE); notification.contentView.setTextViewText(R.id.player_song_name, title); notification.contentView.setTextViewText(R.id.player_author_name, summary); notification.bigContentView.setTextViewText(R.id.player_song_name, title); notification.bigContentView.setTextViewText(R.id.player_author_name, summary); notification.flags |= Notification.FLAG_ONGOING_EVENT; String coverPath = Const.COVER_PATH + File.separator + testMusic.musicId + ".jpg"; Bitmap bitmap = ImageUtils.getBitmap(coverPath); if (bitmap != null) { notification.contentView.setImageViewBitmap(R.id.player_album_art, bitmap); notification.bigContentView.setImageViewBitmap(R.id.player_album_art, bitmap); } else { requestAlbumCover(testMusic.coverImg, testMusic.musicId); notification.contentView.setImageViewResource(R.id.player_album_art, R.drawable.bg_album_default); notification.bigContentView.setImageViewResource(R.id.player_album_art, R.drawable.bg_album_default); } startForeground(5, notification); } catch (Exception e) { e.printStackTrace(); } } @SuppressLint("UnspecifiedImmutableFlag") public void setListeners(RemoteViews view) { int flags = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE : PendingIntent.FLAG_UPDATE_CURRENT; try { PendingIntent pendingIntent = PendingIntent.getBroadcast(getApplicationContext(), 0, new Intent(NOTIFY_PREVIOUS).setPackage(getPackageName()), flags); view.setOnClickPendingIntent(R.id.player_previous, pendingIntent); pendingIntent = PendingIntent.getBroadcast(getApplicationContext(), 0, new Intent(NOTIFY_CLOSE).setPackage(getPackageName()), flags); view.setOnClickPendingIntent(R.id.player_close, pendingIntent); pendingIntent = PendingIntent.getBroadcast(getApplicationContext(), 0, new Intent(NOTIFY_PAUSE).setPackage(getPackageName()), flags); view.setOnClickPendingIntent(R.id.player_pause, pendingIntent); pendingIntent = PendingIntent.getBroadcast(getApplicationContext(), 0, new Intent(NOTIFY_NEXT).setPackage(getPackageName()), flags); view.setOnClickPendingIntent(R.id.player_next, pendingIntent); pendingIntent = PendingIntent.getBroadcast(getApplicationContext(), 0, new Intent(NOTIFY_PLAY).setPackage(getPackageName()), flags); view.setOnClickPendingIntent(R.id.player_play, pendingIntent); } catch (Exception e) { e.printStackTrace(); } } private void requestAlbumCover(String coverUrl, String musicId) { if (mDownloadUseCase == null) { mDownloadUseCase = new DownloadUseCase(); } UseCaseHandler.getInstance().execute(mDownloadUseCase, new DownloadUseCase.RequestValues(coverUrl, musicId + ".jpg"), response -> startService(new Intent(getApplicationContext(), PlayerService.class))); } @Override public void onDestroy() { super.onDestroy(); } @Nullable @Override public IBinder onBind(Intent intent) { return null; } } ================================================ FILE: app/src/main/res/anim/h_fragment_enter.xml ================================================ ================================================ FILE: app/src/main/res/anim/h_fragment_exit.xml ================================================ ================================================ FILE: app/src/main/res/anim/h_fragment_pop_enter.xml ================================================ ================================================ FILE: app/src/main/res/anim/h_fragment_pop_exit.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bar_selector_white.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_menu_black_48dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_music_note_black_48dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_search_black_48dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/loading_animation.xml ================================================ ================================================ FILE: app/src/main/res/drawable/progressbar_color.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_main.xml ================================================ ================================================ FILE: app/src/main/res/layout/adapter_library.xml ================================================ ================================================ FILE: app/src/main/res/layout/adapter_play_item.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_drawer.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_login.xml ================================================