前言
说起现有的Android项目架构,常见的不外乎MVC、MVP、以及后来的MVVM,从最初始的MVC(Model-View-Controller),Activity或者Fragment承担了Controller的角色,且视图与业务逻辑耦合比较严重。到之后的MVP,在MVC的基础上,抽取出一层Presenter,Activity和Fragment等视图控制器只负责视图层的展示逻辑,不涉及具体的业务逻辑,将业务逻辑转移到Presenter层中去操作,Presenter有点类似于一个调度员的角色,负责从Model里面拿数据并通知View层展示,从而将View与Model分隔开。MVP从一定程度上解决了MVC的痛点,使得View层变得非常干净,但是在业务逻辑较多时Presenter层还是会比较厚,且页面数据源不好控制。
从而到了本文所要探讨的重点——MVVM,MVVM与MVP、MVC的最大区别,就是在于它的数据双向绑定,ViewModel的数据源一旦改变,会通知所有观察该数据源的UI即时更新,它不再需要向MVP那样子View层需要多次调用Presenter去触发逻辑,只需要一开始的绑定,之后就可以交由ViewModel去主动更新数据源,ViewModel只负责业务逻辑及调整唯一数据源,其他的不管。这样子设计的一个好处是,View层和Model层完全隔离开,且多个View可以复用同个ViewModel,它们之间只有唯一一个可靠数据源,不再需要担心因多处更改数据导致的脏数据问题。
由于互动性比较强的页面,UI层级和结构相对来说会复杂一些,决定采用新的视图层架构方式——Scene,在解决视图和逻辑的耦合问题的基础上,让视图层代码结构和设计更为简洁和轻量。
设计思路
利用Scene搭建视图层
Scene 是字节跳动技术团队开源的一款 Android 页面导航和组合框架,用于实现 Single Activity Applications,有着灵活的栈管理,页面拆分,以及完整的各种动画支持。
GitHub传送门:https://github.com/bytedance/scene
以往我们一般会是基于Activity或者Fragment去搭建视图页面及导航,Activity自然是最重的,页面间的切换是相对来说比较耗时的,而且Activity需要基于AndroidMainfest配置,不好动态创建。另外我们经常会有应用内悬浮效果的场景,如果是基于Acitivty,由于window机制所限,还是需要申请悬浮窗的权限。Fragment的话需要处理好子页面的各种状态,稍有不慎就可能会遇到崩溃,异步回调需要对宿主Activity判空等问题。而Scene是基于View来实现的,非常轻量,页面之间的切换不会再有黑屏的问题,且它也有自己对应的LifeCycle。
Scene
GroupScene
具体的实现原理是在 View 上面包一层生命周期,通过一个叫 LifeCycleFragment 的原生 Fragment 分发生命周期事件给框架内部,再由父组件同步给子组件,详细可见源码,这里暂不详谈。
Scene的集成可见GitHub,由于是作为项目视图架构,先针对视图层抽取一些接口:
interface IUIHierarchy {
/**
* 设置布局之前回调
*/
fun onLayoutBefore()
/**
* 获取布局LayoutId
*/
fun onInflaterViewId(): Int
/**
* 查找View和给View进行相关设置等
*/
fun onBindView(view: View?)
/**
* 设置数据
*/
fun setData()
/**
* 获取所属Activity
*/
fun getFromActivity(): Activity?
/**
* 绑定ViewModel相关操作
*/
fun bindObserver()
/**
* 关闭页面
*/
fun finishPage(isAnim: Boolean)
}
针对资源获取也封装成接口,方便具体Scene获取Res资源:
interface IResourceHelper {
fun getStringResource(@StringRes resId: Int): String
fun getStringResource(@StringRes resId: Int , vararg formatArgs: Any): String
fun getColorResource(resId: Int): Int
fun getDrawableResource(resId: Int): Drawable?
}
单独抽取一个BaseScene及BaseSceneActivity进行基层的封装,实现刚才定义的接口,如下:
abstract class BaseScene : SwipeBackGroupScene(), IUIHierarchy, IResourceHelper {
override fun onCreateSwipeContentView(
inflater: LayoutInflater,
container: ViewGroup,
savedInstanceState: Bundle?
): ViewGroup {
return inflater.inflate(onInflaterViewId(), null, false) as ViewGroup
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
setSwipeEnabled(true)
onLayoutBefore()
onBindView(view)
bindObserver()
setData()
initScene()
}
override fun getFromActivity(): Activity? {
return activity
}
override fun onLayoutBefore() {}
override fun setData() {}
override fun finishPage(isAnim: Boolean) {
if (isAnim) {
val popOptions = PopOptions.Builder()
.setAnimation(
requireActivity(),
R.anim.base_slide_in_right,
R.anim.base_slide_normal
)
.build()
requireNavigationScene().pop(popOptions)
} else {
requireNavigationScene().pop()
}
}
override fun getColorResource(resId: Int): Int {
getFromActivity()?.let {
return ContextCompat.getColor(it, resId)
}
return 0
}
override fun getDrawableResource(resId: Int): Drawable? {
getFromActivity()?.let {
return ContextCompat.getDrawable(it, resId)
}
return null
}
override fun getStringResource(resId: Int): String {
return getString(resId)
}
override fun getStringResource(resId: Int, vararg formatArgs: Any): String {
return getString(resId, formatArgs)
}
private fun initScene() {
navigationScene?.addOnBackPressedListener(this,
OnBackPressedListener {
onBackPress()
})
}
protected open fun onBackPress(): Boolean {
return false
}
}
另外再对Scene的跳转做一层封装,支持bundle以及自定义的跳转动画:
fun jumpPage(scene: Class<out Scene>) {
jumpPage(scene, null, true)
}
fun jumpPage(scene: Class<out Scene>, bundle: Bundle) {
jumpPage(scene, bundle, true)
}
fun jumpPage(scene: Class<out Scene>, bundle: Bundle?, isAnim: Boolean) {
val pushOption = PushOptions.Builder()
.setAnimation(
requireActivity(),
R.anim.base_slide_in_right,
R.anim.base_slide_normal
)
.build()
if (isAnim) {
requireNavigationScene().push(scene, bundle, pushOption)
} else {
requireNavigationScene().push(scene, bundle)
}
}
接着就是对入口Activity的封装:
abstract class BaseSceneActivity : AppCompatActivity() {
private var mDelegate: SceneDelegate? = null
override fun onCreate(@Nullable savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val bundle = intent?.extras
mDelegate = if (bundle == null) {
NavigationSceneUtility.setupWithActivity(this, getHomeSceneClass())
.supportRestore(supportRestore())
.build()
} else {
NavigationSceneUtility.setupWithActivity(this, getHomeSceneClass())
.rootSceneArguments(bundle)
.supportRestore(supportRestore())
.build()
}
}
override fun onBackPressed() {
if (!mDelegate!!.onBackPressed()) {
super.onBackPressed()
}
}
protected abstract fun getHomeSceneClass(): Class<out Scene?>
protected abstract fun supportRestore(): Boolean
}
getHomeScene
用于返回入口的Scene,之后的一系列Scene之间的跳转都是从这个入口Scene开始。
利用ViewModel搭建业务逻辑层
上面确定了视图层的结构,业务逻辑层采用Google官方的ViewModel结合LiveData作为基础,并且这里我将ViewModel划分为两个层次:
1. 负责UI逻辑的ViewModel,即关乎界面视图的一些逻辑所需要的LiveData模型,都放在这里面,只负责管理视图方面的逻辑控制。
2. 负责业务逻辑的ViewModel,即只存放除视图之外的业务逻辑的LiveData模型。
这样避免ViewModel过于冗余,职责也较为清晰一些。
先定义一个基础的BaseViewModel:
open class BaseViewModel(application: Application) : AndroidViewModel(application),
DefaultLifecycleObserver, IResourceHelper {
private var mApplication: Application = application
fun bindLifeCycle(lifecycle: Lifecycle) {
lifecycle.addObserver(this)
}
override fun getStringResource(resId: Int): String {
return mApplication.resources.getString(resId)
}
override fun getStringResource(resId: Int, vararg formatArgs: Any): String {
return mApplication.resources.getString(resId, formatArgs)
}
override fun getColorResource(resId: Int): Int {
return mApplication.resources.getColor(resId)
}
override fun getDrawableResource(resId: Int): Drawable {
return mApplication.resources.getDrawable(resId)
}
}
这里继承于AndroidViewModel,同样实现了IResourceHelper方便做一些资源操作,并且实现了DefaultLifecycleObserver接口,这是Android Lifecycle中LifecycleObserver的一种实现方式,可以将任意类作为一个LifeCycle的一个观察者对象,只要实现了该接口,并且重写它的生命周期方法例如onCreate、onPause、onDestroy等,然后通过调用其他Lifecycle对象的addObserver方法,即可与它们的生命周期绑定起来。
另外,Scene支持ViewModel的Kotlin快捷获取,例如可用如下方式获取ViewModel实例:
1. 获取与Activity绑定的ViewModel:
val mViewModel: MyViewModel by activityViewModels()
2. 获取与Scene绑定的ViewModel:
>val mViewModel: MyViewModel by viewModels()
更多详情可见Scene组件里的SceneViewModelExtensionsKt
Model层负责获取网络接口数据
Model层就没有什么好讲了,职责就是提供数据,主要就是结合RxJava请求网络数据,将Obserable返回给ViewModel层。
示例
下面以一个简略版的直播间布局演示具体业务场景的视图层代码结构,先看下效果:
可以看到,类似的直播间页面一般会有很多部分组成,包括顶部信息、麦位信息、聊天面板、底部操作栏这些,我们可以将其根据页面的位置,划分为几个模块,分别由各自的Scene去承接相关的视图,然后用一个主Scene作为容器的角色,进行添加移除显示隐藏等状态的控制。
根据层级定义好主布局文件:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#ffffff"
android:id="@+id/root_view"
xmlns:app="http://schemas.android.com/apk/res-auto">
<FrameLayout
android:id="@+id/top_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<FrameLayout
android:id="@+id/mic_container"
android:layout_width="match_parent"
android:layout_height="120dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/top_container"/>
<FrameLayout
android:id="@+id/chat_container"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/mic_container"
app:layout_constraintBottom_toTopOf="@+id/bottom_container"/>
<FrameLayout
android:id="@+id/bottom_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toTopOf="@+id/reward_container"/>
<FrameLayout
android:id="@+id/reward_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
然后在主控制器Scene里面将它们一一初始化添加进对应的布局里:
class HomeScene : BaseRoomScene() {
private val vRewardScene: RewardScene = RewardScene()
private val vMicScene: MicScene = MicScene()
private val vChatScene: ChatScene = ChatScene()
private val vTopBarScene: TopBarScene = TopBarScene()
private val vBottomScene: BottomBarScene = BottomBarScene()
override fun onInflaterViewId(): Int {
return R.layout.layout_home
}
override fun onBindView(view: View?) {
addChildScene(R.id.top_container, vTopBarScene, false)
addChildScene(R.id.reward_container, vRewardScene, true)
addChildScene(R.id.mic_container, vMicScene, false)
addChildScene(R.id.chat_container, vChatScene, false)
addChildScene(R.id.bottom_container, vBottomScene, false)
}
}
每个Scene的视图逻辑都由各自的Scene去承接,这里的HomeScene只负责协调各个Scene之间的一些交互逻辑,然后将这个HomeScene作为Activity的入口:
class RoomActivity : BaseSceneActivity() {
override fun supportRestore(): Boolean {
return false
}
override fun getHomeSceneClass(): Class<out Scene> {
return HomeScene::class.java
}
}
然后比如这里要实现点击底部操作栏的某个按钮,弹出礼物框,点击输入框,弹出输入法,隐藏礼物框,我们可以先在UIViewModel
里面定义两个变量,用于控制弹窗和输入法各自的展示隐藏:
class RoomUIStateViewModel(application: Application) : BaseViewModel(application){
private val mRewardPanelState: MutableLiveData<Boolean> = MutableLiveData()
private val mSoftKeyBoardState: MutableLiveData<Boolean> = MutableLiveData()
fun setShowRewardPanel(isShow: Boolean) {
mRewardPanelState.value = isShow
}
fun getShowRewardPanel(): MutableLiveData<Boolean> {
return mRewardPanelState
}
fun setSoftKeyBoardState(isShow: Boolean) {
mSoftKeyBoardState.value = isShow
}
fun getSoftKeyBoardState(): MutableLiveData<Boolean> {
return mSoftKeyBoardState
}
}
然后在HomeScene
中对RewardScene
的展示隐藏通过mRewardPanelState
这个LiveData进行监听:
val mUIStateViewModel: RoomUIStateViewModel by activityViewModels()
override fun bindObserver() {
mUIStateViewModel.getShowRewardPanel().observe(this, Observer { show ->
if (show) {
show(vRewardScene)
} else {
hide(vRewardScene)
}
})
}
监听的地方设置好了,触发的地方是底部操作栏,那么就把这部分视图的操作写在底部栏对应的Scene——BottomBarScene
里面:
class BottomBarScene : BaseRoomScene() {
private var vReward: ImageView? = null
private var vInput: EditText? = null
override fun onInflaterViewId(): Int {
return R.layout.layout_bottom_bar
}
@SuppressLint("ClickableViewAccessibility")
override fun onBindView(view: View?) {
vReward = findViewById(R.id.footer_more)
vInput = findViewById(R.id.footer_input)
vReward?.setOnClickListener {
mUIStateViewModel.setSoftKeyBoardState(false)
postDelayed(Runnable { mUIStateViewModel.setShowRewardPanel(true) },200)
}
vInput?.requestFocus()
vInput?.setOnTouchListener { _, event ->
mUIStateViewModel.setShowRewardPanel(false)
mUIStateViewModel.setSoftKeyBoardState(true)
false
}
}
override fun bindObserver() {
mUIStateViewModel.getSoftKeyBoardState().observe(this, Observer { show ->
if (show) {
vInput?.requestFocus()
SoftKeyBoardUtil.showKeyboard(vInput)
} else {
vInput?.clearFocus()
SoftKeyBoardUtil.hideKeyboard(vInput)
}
})
}
}
点击礼物按钮的时候,先调用ViewModel的setSoftKeyBoardState
将软键盘隐藏、接着调用setShowRewardPanel
将弹窗弹出,这样子设计的好处是职责分明,HomeScene那边只管监听软键盘和弹窗的弹出,而具体的触发则只会由各自的子Scene去触发,而且它们之间只通过唯一确定的ViewModel去通信,保证了数据的全局可靠性。
结语
总的来说就是Scene-ViewModel-Model的一个结构,利用Scene代替之前的多Fragment和Activity的模式,结合ViewModel+LiveData的观察者模式,让视图层与业务逻辑层剥离得更为干净,且很容易进行Scene之间的通信。但也有不足的地方,如果页面跳转较多,长期持有的ViewModel也会较多,增加了更多的内存开销。