前言

本章继续,运用前面讲过的 Jetpack 组件结合 MVVM + Kotlin 在完成一个音乐播放器;
数据填充
我们接下来进行 Base 类和 Activity 的数据填充;
SharedViewModel 功能填充
kotlin
class SharedViewModel : ViewModel() {
// 添加监听(可以弹上来的监听)
val timeToAddSlideListener = MutableLiveData<Boolean>()
// 可以控制播放详情 点击/back 掉下来
// 播放详情中左手边滑动图标(点击的时候),与 MainActivity back 是会 set(true)
val closeSlidePanelIfExpanded = MutableLiveData<Boolean>()
// 活动关闭的一些记录(播放条缩小一条与扩大展开)通知给控制者的(扩展的)
val activityCanBeClosedDirectly = MutableLiveData<Boolean>()
// openMenu 打开菜单的时候会 set触发---> 改变 openDrawer.setValue(aBoolean); 的值
val openOrCloseDrawer = MutableLiveData<Boolean>()
// 开启和关闭卡片相关的状态,如果发生改变会和 allowDrawerOpen 挂钩
val enableSwipeDrawer = MutableLiveData<Boolean>()
}
MainActivity 功能填充
我们首先来初始化 MainActivityViewModel 和 ActivityMainBinding
kotlin
class MainActivity : BaseActivity() {
var mainBinding: ActivityMainBinding? = null // 当前 MainActivity 的布局
var mainActivityViewModel: MainActivityViewModel? = null // 当前 Activity 关联的 ViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 绑定 DataBinding 与 ViewModel 结合
mainActivityViewModel = getActivityViewModelProvider(this).get(MainActivityViewModel::class.java)
mainBinding = DataBindingUtil.setContentView(this, R.layout.activity_main)
mainBinding?.lifecycleOwner = this
mainBinding?.setVm(mainActivityViewModel)
//
setOberve()
}
}
接下来,我们来给 SharedViewModel 中定义的各个状态设置 observe
kotlin
private fun setObserve() {
// 共享 (观察) 活动关闭的一些记录(播放条 缩小一条 与 扩大展开)
mSharedViewModel.activityCanBeClosedDirectly.observe(this, {
// ... 业务逻辑的
})
// 监听(发送 打开菜单的指令 1)
mSharedViewModel.openOrCloseDrawer.observe(this, { aBoolean ->
mainActivityViewModel?.allowDrawerOpen.value = aBoolean // 触发,就会改变 --> 观察(打开菜单)
})
// 监听(发送 打开菜单的指令 2)
mSharedViewModel.enableSwipeDrawer.observe(this, { aBoolean ->
mainActivityViewModel?.allowDrawerOpen.value = aBoolean // 触发抽屉控件关联的值
})
}
接下来,我们来给 MainActivityViewModel 中定义变量
kotlin
class MainActivityViewModel : ViewModel() {
// 首页需要记录抽屉布局的情况
@JvmField
val openDrawer = MutableLiveData<Boolean>()
// 响应的效果,都让抽屉控件干了
@JvmField // @JvmField消除了变量的getter方法
val allowDrawerOpen = MutableLiveData<Boolean>()
// 构造代码块
init {
allowDrawerOpen.value = true
}
}
接下来,我们需要在 Activity 可见的时候,触发 timeToAddSlideListener 的状态变化,这里我们复现下 onWindowFoucsChanged
方法
kotlin
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
// 此字段只要发生了改变,就会添加监听(可以弹上来的监听)
mSharedViewModel.timeToAddSlideListener.value = true // 触发改变
}
BindingAdapter
上一章中,我们有在 activity_main.xml 中设置了一些并不属于 xml 范围内的属性,例如:

这是 DataBinding 的能力,我们来进行 DrawerLayout 的 DataBinding 填充
kotlin
object DrawerBindingAdapter {
// 『打开抽屉』与『关闭抽屉』
@JvmStatic // 代表是静态函数
@BindingAdapter(value = ["isOpenDrawer"], requireAll = false)
fun openDrawer(drawerLayout: DrawerLayout, isOpenDrawer: Boolean) {
if (isOpenDrawer && !drawerLayout.isDrawerOpen(GravityCompat.START)) {
drawerLayout.openDrawer(GravityCompat.START)
} else {
drawerLayout.closeDrawer(GravityCompat.START)
}
}
// 允许抽屉的 打开 与 关闭
@JvmStatic // 代表是静态函数
@BindingAdapter(value = ["allowDrawerOpen"], requireAll = false)
fun allowDrawerOpen(drawerLayout: DrawerLayout, allowDrawerOpen: Boolean) {
drawerLayout.setDrawerLockMode(if (allowDrawerOpen) DrawerLayout.LOCK_MODE_UNLOCKED else DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
}
}
PS:这里需要注意,因为是 Kotlin,需要我们引入 apply plugin: 'kotlin-kapt' 才能编译通过;
MainFragment
我们先来填充 xml 中的布局:
ini
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<!-- 点击事件 -->
<variable
name="click"
type="com.xiangxue.puremusic.ui.page.MainFragment.ClickProxy" />
<variable
name="vm"
type="com.xiangxue.puremusic.bridge.state.MainViewModel" />
</data>
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:overScrollMode="never"
android:background="@color/black">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appbar_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fitsSystemWindows="true"
android:theme="@style/AppTheme">
<!-- 折叠工具栏布局 -->
<com.google.android.material.appbar.CollapsingToolbarLayout
android:id="@+id/collapse_layout"
android:layout_width="match_parent"
android:layout_height="275dp"
android:fitsSystemWindows="true"
app:contentScrim="?attr/colorPrimary"
app:layout_scrollFlags="scroll|exitUntilCollapsed">
<!-- 背景 图片
android:src="@drawable/bg_home"
-->
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/iv_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
android:scaleType="centerCrop"
android:src="@drawable/bg_home"
app:layout_collapseMode="parallax" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/toolbar"
drawable_radius="@{8}"
drawable_solidColor="@{0x88ffffff}"
drawable_strokeColor="@{0x33666666}"
drawable_strokeWidth="@{1}"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginStart="12dp"
android:layout_marginTop="37dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="12dp">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/iv_menu"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="12dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:onClick="@{()->click.openMenu()}"
android:scaleType="centerInside"
android:src="@drawable/ic_menu_black_48dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/iv_icon"
onClickWithDebouncing="@{()->click.search()}"
android:layout_width="24dp"
android:layout_height="24dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:scaleType="centerInside"
android:src="@drawable/ic_music_note_black_48dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toLeftOf="@+id/tv_app"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/tv_app"
onClickWithDebouncing="@{()->click.search()}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="@color/my_c2"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/iv_search"
onClickWithDebouncing="@{()->click.search()}"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="12dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:scaleType="centerInside"
android:src="@drawable/ic_search_black_48dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.appbar.CollapsingToolbarLayout>
<!-- MainFragment 初始化页面的标记,初始化选项卡和页面 -->
<com.google.android.material.tabs.TabLayout
android:id="@+id/tab_layout"
initTabAndPage="@{vm.initTabAndPage}"
android:layout_width="match_parent"
android:layout_height="48dp"
android:fitsSystemWindows="true"
app:tabBackground="@color/my_c1"
app:tabIndicatorColor="@color/gray"
app:tabIndicatorHeight="4dp"
app:tabSelectedTextColor="@color/gray"
app:tabTextColor="@color/light_gray">
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/recently" />
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/best_practice" />
</com.google.android.material.tabs.TabLayout>
</com.google.android.material.appbar.AppBarLayout>
<!-- 下面展示 横向切换:
1."最近播放区域" 其实就是 音乐列表 系列Item
2."其他信息区域" 其实就是 WebView 展示网页信息而已
-->
<androidx.viewpager.widget.ViewPager
android:id="@+id/view_pager"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:overScrollMode="never"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<!-- 1."我的歌曲区域" 其实就是『 音乐列表 』系列Item
RecyclerView 的每一个 Item 布局 == adapter_play_item.xml
-->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/adapter_play_item"
android:visibility="visible"
/>
<!-- 2."其他信息区域" 其实就是 WebView 展示网页信息而已 -->
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<WebView
android:id="@+id/web_view"
pageAssetPath="@{vm.pageAssetPath}"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:visibility="visible"
android:background="#B4D9DD"/>
</androidx.core.widget.NestedScrollView>
</androidx.viewpager.widget.ViewPager>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>
接下来,我们在 MainFragment 中初始化 DataBinding 和 ViewModel
kotlin
class MainFragment : BaseFragment(){
// 首页 DataBinding
private var mainBinding: FragmentMainBinding? = null
// 首页 Fragment的ViewModel
private var mainViewModel : MainViewModel? = null
// 音乐资源相关的 VM Request ViewModel
private var musicRequestViewModel: MusicRequestViewModel? = null
// 适配器
private var adapter: SimpleBaseBindingAdapter<TestAlbum.TestMusic?, AdapterPlayItemBinding?>? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 初始化 ViewModel
mainViewModel = getFragmentViewModelProvider(this).get(MainViewModel::class.java)
musicRequestViewModel = getFragmentViewModelProvider(this).get(MusicRequestViewModel::class.java)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val view: View = inflater.inflate(R.layout.fragment_main, container, false)
// 绑定DataBinding 与 ViewModel 关联
mainBinding = FragmentMainBinding.bind(view)
// 设置点击事件,布局就可以直接绑定
mainBinding?.click = ClickProxy()
// 设置 VM,就可以实时数据变化
mainBinding?.setVm(mainViewModel)
return view
}
}
这里用到了 ClickProxy 这个是一个点击代理类,我们可以把它定义在 MainFramgent 中最为一个内部类来处理;
kotlin
// 处理所有点击事件
inner class ClickProxy {
// 当在首页点击 "菜单" 的时候,直接导航到 ---> 菜单的Fragment界面
// 间接通过共享 VM 触发到 openDrawer 触发到 @BindingAdapter
fun openMenu() {
sharedViewModel.openOrCloseDrawer.value = true
}
// 当在首页点击 "搜索图标" 的时候,直接导航到 ---> 搜索的Fragment界面
fun search() = nav().navigate(R.id.action_mainFragment_to_searchFragment)
}
接下来,我们来填充 MainViewModel 支持 initTabAndPage
less
class MainViewModel : ViewModel() {
// MainFragment 初始化页面的标记 初始化选项卡和页面
@JvmField
val initTabAndPage = ObservableBoolean()
// MainFragment "其他信息" 里面的 WebView需要加载的网页链接路径
@JvmField
val pageAssetPath = ObservableField<String>()
}
PS:ViewModel 中什么时候使用 LiveData 什么时候使用 ObservableField,
LiveData 应用于更新并不频繁的场景,而 ObservableField 使用于较为频繁的场景来防止抖动;
我们给 TabLayout 和 WebView 设置了 BindingAdapter,那么我们来完善这个 BindingAdapter

initTabAndPage
kotlin
object TabPageBindingAdapter {
// MainFragment 初始化页面的标记,初始化选项卡和页面
@JvmStatic
@BindingAdapter(value = ["initTabAndPage"], requireAll = false)
fun initTabAndPage(tabLayout: TabLayout, initTabAndPage: Boolean) {
if (initTabAndPage) {
val count = tabLayout.tabCount
val title = arrayOfNulls<String>(count)
for (i in 0 until count) {
val tab = tabLayout.getTabAt(i)
if (tab != null && tab.text != null) {
title[i] = tab.text.toString()
}
}
val viewPager: ViewPager = tabLayout.rootView.findViewById(R.id.view_pager)
if (viewPager != null) {
viewPager.adapter = CommonViewPagerAdapter(count, false, title)
tabLayout.setupWithViewPager(viewPager)
}
}
}
// 在选项卡上添加选定的侦听器(备用的功能)
@BindingAdapter(value = ["tabSelectedListener"], requireAll = false)
fun tabSelectedListener(tabLayout: TabLayout, listener: OnTabSelectedListener?) {
tabLayout.addOnTabSelectedListener(listener)
}
}

pageAssetPath
kotlin
object WebViewBindingAdapter {
/**
* 加载WebView,固定加载 Assets 目录下的资源
*/
@JvmStatic
@SuppressLint("SetJavaScriptEnabled")
@BindingAdapter(value = ["pageAssetPath"], requireAll = false)
fun loadAssetsPage(webView: WebView, assetPath: String) {
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView,
request: WebResourceRequest
): Boolean {
val uri = request.url
val intent = Intent(Intent.ACTION_VIEW, uri)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
Utils.getApp().startActivity(intent)
return true
}
}
webView.scrollBarStyle = View.SCROLLBARS_INSIDE_OVERLAY
val webSettings = webView.settings
webSettings.javaScriptEnabled = true
webSettings.defaultTextEncodingName = "UTF-8"
webSettings.setSupportZoom(true)
webSettings.builtInZoomControls = true
webSettings.displayZoomControls = false
webSettings.useWideViewPort = true
webSettings.loadWithOverviewMode = true
val url = "file:///android_asset/$assetPath"
webView.loadUrl(url)
}
}
接下来,我们来填充 MainFragment 音乐列表界面,按照多状态管理原则,这个 Fragment 除了 MainViewModel 之后,还需要一个请求音乐数据列表的 MusicRequestViewModel;
kotlin
class MusicRequestViewModel : ViewModel() {
var freeMusicesLiveData : MutableLiveData<TestAlbum>? = null
get() {
if (field == null) {
field = MutableLiveData()
}
return field
}
private set
fun requestFreeMusics() {
HttpRequestManager.getFreeMusic(freeMusicesLiveData)
}
}
接下来,我们进行适配器的填充,让数据在 View 上渲染出来;
kotlin
class MainFragment : BaseFragment() {
// 我们操作布局,不去传统方式操作,全部使用 Databind
private var mainBinding: FragmentMainBinding? = null
// 首页Fragment的 ViewModel
private var mainViewModel : MainViewModel? = null
// 音乐资源相关的 ViewModel 用来做网络请求,获取音乐数据
private var musicRequestViewModel: MusicRequestViewModel? = null
// 适配器
private var adapter: SimpleBaseBindingAdapter<TestAlbum.TestMusic?, AdapterPlayItemBinding?>? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// MainFragment初始化页面的标记,初始化选项卡和页面
mainViewModel?.initTabAndPage.set(true)
// 加载 WebView
mainViewModel?.pageAssetPath.set("JetPack 之 WorkManager.html")
// 展示数据,适配器里面的的数据 展示出来
// 设置设配器(item 的布局和适配器的绑定)
adapter = object : SimpleBaseBindingAdapter<TestAlbum.TestMusic?, AdapterPlayItemBinding?>(context, R.layout.adapter_play_item) {
override fun onSimpleBindItem(
binding: AdapterPlayItemBinding?,
item: TestAlbum.TestMusic?,
holder: RecyclerView.ViewHolder?) {
// 标题
binding?.tvTitle?.text = item?.title
// 名字
binding?.tvArtist?.text = item?.artist?.name
// 左右的图片
Glide.with(binding?.ivCover?.context)
.load(item?.coverImg).into(binding.ivCover)
// 点击Item
binding.root.setOnClickListener { v ->
Toast.makeText(mContext, "播放音乐", Toast.LENGTH_SHORT).show()
}
}
}
mainBinding?.rv.adapter = adapter
// 请求数据
musicRequestViewModel?.requestFreeMusics()
// observe
// 音乐资源的 VM
musicRequestViewModel?.freeMusicesLiveData?.observe(viewLifecycleOwner, { musicAlbum: TestAlbum? ->
if (musicAlbum != null && musicAlbum.musics != null) {
// 这里特殊:直接更新UI
// 数据加入适配器
adapter?.list = musicAlbum.musics
adapter?.notifyDataSetChanged()
}
})
}
}
接下来,我们来看下适配器是如何实现的 SimpleBaseBindingAdapter 可以看到这个适配器是一个抽象类,实现了 onSimpleBindItem
方法;
scala
public abstract class SimpleBaseBindingAdapter<M, B extends ViewDataBinding> extends BaseBindingAdapter {
private final int layout;
public SimpleBaseBindingAdapter(Context context, int layout) {
super(context);
this.layout = layout;
}
@Override
protected @LayoutRes int getLayoutResId(int viewType) {
return this.layout;
}
protected abstract void onSimpleBindItem(B binding, M item, RecyclerView.ViewHolder holder);
@Override
protected void onBindItem(ViewDataBinding binding, Object item, RecyclerView.ViewHolder holder) {
//noinspection unchecked
onSimpleBindItem((B) binding, (M) item, holder);
}
}
AdapterPlayItemBinding 则是 xml 生成的 DataBinding adapter_play_item.xml;
ini
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<!-- 为了以后扩展 -->
<data>
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/root_view"
android:layout_width="match_parent"
android:layout_height="72dp"
android:orientation="vertical"
tools:background="@color/light_gray">
<!-- 正方形的图片 -->
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/iv_cover"
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_marginStart="12dp"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/bg_home" />
<!-- 歌曲名字信息,与,歌曲描述信息等 -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toRightOf="@+id/iv_cover"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/tv_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:textSize="18sp"
android:textColor="@color/white"
tools:text="title" />
<TextView
android:id="@+id/tv_artist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="4dp"
android:textSize="14sp"
android:textColor="@color/white"
tools:text="summary" />
</LinearLayout>
<!-- 右的图标,播放的状态图标哦 -->
<net.steamcrafted.materialiconlib.MaterialIconView
android:id="@+id/iv_play_status"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginEnd="12dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:scaleType="center"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:materialIcon="music_note"
app:materialIconColor="@color/gray"
app:materialIconSize="28dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
接下来,我们来完善下『网络请求』模块 HttpRequestManager
kotlin
object HttpRequestManager : ILoadRequest, IRemoteRequest {
// 仓库:liveData: MutableLiveData<TestAlbum>? 已经和 Request VM 的 LiveData是 同一份了
override fun getFreeMusic(liveData: MutableLiveData<TestAlbum>?) {
val gson = Gson()
val type = object : TypeToken<TestAlbum?>(){}.type
// 这里模拟网络请求了,加载了本地的一个 json 文件,后面有条件的可以修改成实际的网络请求;
val testAlbum =
gson.fromJson<TestAlbum>(Utils.getApp().getString(R.string.free_music_json), type)
liveData?.value = testAlbum
}
override fun getLibraryInfo(liveData: MutableLiveData<List<LibraryInfo>>?) {
val gson = Gson()
val type = object : TypeToken<List<LibraryInfo?>?>() {}.type
// 这里模拟网络请求了,加载了本地的一个 json 文件,后面有条件的可以修改成实际的网络请求;
val list =
gson.fromJson<List<LibraryInfo?>>(Utils.getApp().getString(R.string.library_json), type)
liveData?.value = list as List<LibraryInfo>?
}
}
我们还差一项需要进行完善,点击事件的 BindingAdapter

less
object CommonBindingAdapter {
@JvmStatic
@BindingAdapter(value = ["onClickWithDebouncing"], requireAll = false)
fun onClickWithDebouncing(view: View?, clickListener: View.OnClickListener?) {
ClickUtils.applySingleDebouncing(view, clickListener)
}
}
到这,首页的音乐列表,我们就填充完了;
欢迎三连
来都来了,点个关注,点个赞吧,你的支持是我最大的动力~