Android Scene-ViewModel-Model 搭建轻量级架构

前言

说起现有的Android项目架构,常见的不外乎MVCMVP、以及后来的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

Scene

GroupScene

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示例.gif

可以看到,类似的直播间页面一般会有很多部分组成,包括顶部信息、麦位信息、聊天面板、底部操作栏这些,我们可以将其根据页面的位置,划分为几个模块,分别由各自的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也会较多,增加了更多的内存开销。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342