当前位置: 首页>后端>正文

Android极简MVVM,从一个基类库谈起

前言:

Hello啊各位老铁,今天带来一个老生常谈的技术,MVVM,这篇文章,主要详细介绍如何封装一个MVVM的基类库,以及MVVM架构模式在实际业务中的用法,最后会把实际的封装代码开源,并提供远程依赖,方便给到大家使用以及二次修改,尽量做到细致入微,浅显易懂,OK,废话不多赘述,我们进入正文。

这篇文章大概会按照以下几个模块进行阐述,此次封装,做到绝无第三方依赖,都是Android原生的代码封装,请放心使用,如果您想直接进行使用,请直接跳到第4步,集成使用即可,此次的封装,和目前主流的MVVM架构模式,会完美契合,让架构模式简单化,让业务代码清晰化,必须值得推荐使用。

一、MVVM简单概括

二、基于MVVM模式如何封装基类库

三、实战封装

四、封装后在业务中如何使用

五、开源以及Demo查看

温馨提示:内容稍多,请合理安排好时间,如果不想查阅具体封装过程,底部有开源地址,可以直接查看。

Android极简MVVM,从一个基类库谈起,第1张

一、MVVM简单概括

MVVM的开发模式,相对来说低耦合,业务之间逻辑显得也十分分明,Model层负责将请求的数据交给ViewModel层;ViewModel层负责将请求到的数据做业务逻辑处理,最后交给View层去展示,与View一一对应;View层只负责界面绘制刷新,不处理业务逻辑,非常适合进行独立模块开发。

Android极简MVVM,从一个基类库谈起,第2张

1、三层简单概括

  • 1、Model:数据层,包含数据实体和对数据实体的操作。
  • 2、View:视图层,对应于Activity,XML,View,负责数据显示以及用户交互。
  • 3、ViewModel:关联层,将Model和View进行绑定,Model或者View更改时,实时刷新对方。

2、需要注意:

  • 1、View只做和UI相关的工作,不涉及任何业务逻辑,不涉及操作数据,不处理数据,也就是UI和数据是严格分开的。
  • 2、ViewModel只做和业务逻辑相关的工作,不涉及任何和UI相关的操作,不持有控件引用,不更新UI。

二、基于MVVM模式如何封装基类库

MVVM我们已经清晰,然而针对现有的三层,我们如何进行拆解封装呢?面对这样的一个问题,我们也是需要从三层以及和实际的业务进行相结合,从实际业务中来,也要从实际业务中去,这是我们封装的一个潜在因素,一旦脱离了实际,封装的再优秀,也只是一个花瓶,中看不中用。

针对MVVM中的三层,其实,我们在封装中,也是基于这三层,View,ViewModel和Model。View中,在实际的开发中,一般针对Activity和Fragment进行系统的抽取封装,ViewModel一般会抽取一个父类,做一些公共的方法或属性配置,Model层一般封装的较少,根据实际业务,需要具体问题具体分析。

Activity和Fragment的封装思路,其实是一致的,需要以简单和复杂两种方向进行抽取,一种是简单的页面继承使用,一种是复杂的页面继承使用,这样区分的一个目的,就是,专职专用,避免大材小用,而具体的封装,除了使得代码简洁化,更重要的拓展化,方便子类的调用。

在具体封装的时候,与实际业务相结合,这个无比重要,比如实际的大部分页面,都带有一个标题栏,那么标题栏就可以直接封装父类里面,像子类拓展出,更改标题,右侧按钮,左侧按钮等功能属性;除了统一的标题栏,另外就是子类的视图了,关于子类的视图传递,这个是必须的,可以直接抽象出一个必须要实现的方法,其他的,比如状态栏的改变,缺省页的设置等等,也需要在父类中统一的给出。

复杂的页面是基于简单的页面而来的,这里的复杂,一般是包含很多逻辑的处理,那么,我们就可以增加ViewModel层和Model层了,目前基于DataBinding的实现方式,无论简单和复杂,都是必须需要考虑的,也就是说在父类中,我们就需要向子类提供出可以拿到的databinding和viewmodel,一般以泛型的方式引入,这样子类再继承的时候,就可以很方便的进行调用。

在复杂的页面,也就是包含ViewModel层Model层的时候,需要考虑绑定视图variable的传递,也就是当前的ViewModel和那个xml进行绑定,当然这是在需要的时候,必须要操作的,除了视图绑定,常见的,数据请求状态,比如请求成功,请求失败,缺省页显示和隐藏,Dialog的显示和隐藏,LiveData的数据回传等等,在复杂的页面中也是需要我们考虑的,除此之外,ViewModel中如何和View层的生命周期绑定,在实际的业务中也是不得不需要考虑的。

除了以上的常规考虑,在实际的业务中,比如事件消息传递,PagerAdapter使用,状态栏透明等很多和基类的相关的功能,我们其实也可以进行封装进去,便于子类的调用。

三、实战封装

通过第2条中的拆解和具体的封装思路,不妨我们进行实战一下,由于Activity和Fragment的封装思路以及相关属性和方法,大部分都是雷同的,所以目前只介绍Activity,更详细的封装,还请大家参考源码。

1、Activity的简单封装

简单封装,不携带ViewModel,只传递ViewDataBinding,子类必须重写的方法只有一个initData,其他均为选择性重写,如果相对逻辑比较简单的页面,可以继承此类。

一个很简单的普通封装,就是把共有的常见的,封装到父类里,便于子类的调用,具体什么方法,什么逻辑进行采取封装,需要我们根据具体业务或者公司的相关情况而定,以下是源码。

abstract class BaseActivity<VB : ViewDataBinding>(@LayoutRes layoutId: Int = 0) :
    AppCompatActivity(layoutId) {

    private var mActionBarView: ActionBarView= null
    private var mLayoutError: LinearLayout= null
    private var mLayoutId = layoutId
    lateinit var mBinding: VB

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        try {
            //默认状态栏为白底黑字
            darkMode(BaseConfig.statusBarDarkMode)
            statusBarColor(ContextCompat.getColor(this, BaseConfig.statusBarColor))
            setContentView(R.layout.activity_base)
            val baseChild = findViewById<LinearLayout>(R.id.layout_base_child)
            mLayoutError = findViewById(R.id.layout_empty_or_error)
            mActionBarView = findViewById(R.id.action_bar)
            if (mLayoutId == 0) {
                mLayoutId = getLayoutId()
            }

            if (savedInstanceState != null && getIntercept()) {
                noEmptyBundle()
                return
            }

            val childView = layoutInflater.inflate(mLayoutId, null)
            baseChild.addView(childView)
            mBinding = DataBindingUtil.bind(childView)!!

            initView()
            initData()
        } catch (e: Exception) {
            e.printStackTrace()
            noEmptyBundle()
        }
    }

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:获取视图id
     */
    open fun getLayoutId(): Int {
        return 0
    }

    open fun initView() {}

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:初始化数据
     */
    abstract fun initData()

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:动态改变状态栏颜色和标题
     */
    fun setDarkTitle(dark: Boolean, color: Int, title: String) {
        try {
            darkMode(dark)
            statusBarColor(ContextCompat.getColor(this, color))
            setBarTitle(title)

        } catch (e: Exception) {
            e.printStackTrace()
        }

    }

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:设置标题
     */
    fun setBarTitle(title: String) {
        mActionBarView!!.visibility = View.VISIBLE
        mActionBarView!!.setBarTitle(title)
    }

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:隐藏左侧按钮
     */

    fun hintLeftMenu() {
        mActionBarView!!.hintLeftBack()
    }

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:获取ActionBarView
     */
    fun getActionBarView(): ActionBarView {
        return mActionBarView!!
    }

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:隐藏标题栏
     */
    fun hintActionBar() {
        mActionBarView?.visibility = View.GONE
    }

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:Bundle为空进行拦截,解决改变权限后重回App崩溃问题
     */
    open fun getIntercept(): Boolean {
        return false
    }

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:Bundle为空时的逻辑处理,解决改变权限后重回App崩溃问题
     */
    open fun noEmptyBundle() {}

    override fun onDestroy() {
        super.onDestroy()
        try {
            LiveDataBus.removeObserve(this)
            LiveDataBus.removeStickyObserver(this)
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:透明状态栏
     */
    fun translucentWindow(dark: Boolean) {
        try {
            immersive(0, dark)
        } catch (e: Exception) {
            e.printStackTrace()
        }

    }

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:设置缺省页
     */
    fun setEmptyOrError(view: View) {
        mLayoutError?.visibility = View.VISIBLE
        mLayoutError?.removeAllViews()
        mLayoutError?.addView(view)
    }

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:隐藏
     */
    fun hintEmptyOrErrorView() {
        mLayoutError?.visibility = View.GONE
    }

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:获取错误或为空的view
     */
    fun getEmptyOrErrorView(): LinearLayout {
        return mLayoutError!!
    }
}

2、涉及的方法概述

方法名 参数 概述
getLayoutId 无参 子类传递的layout,用于加载视图,可以通过构造方法传递,也可以通过此方法传递。
initView 无参 初始化View,非必须重写
initData 无参 初始化数据
setDarkTitle dark: Boolean, color: Int, title: String,1、dark: Boolean,状态栏颜色,true就是黑色,false就是白色。2、color: Int,状态栏背景颜色,3、title: String,标题栏内容 设置标题,状态栏背景及颜色
setBarTitle title: String,标题栏内容 设置标题
hintLeftMenu 无参 隐藏左侧按钮
getActionBarView 无参 获取标题栏View,可以操作标题栏里的任何控件
hintActionBar 无参 隐藏标题栏
translucentWindow dark: Boolean,状态栏颜色,true就是黑色,false就是白色 透明状态栏
setEmptyOrError view: View,传递的缺省View视图 设置缺省视图
hintEmptyOrErrorView 无参 隐藏缺省视图
getEmptyOrErrorView 无参 获取缺省视图

简单的Activity没有什么好说的,都是中规中矩,具体的使用请大家看第四条,具体使用即可。

3、Activity的复杂封装

也谈不上复杂,只是在继承简单页面的基础之上多加了一个ViewModel,相对于比较复杂的页面,就可以继承此类,此类,拓展了ViewModel,可以在ViewModel里进行逻辑的书写,此类也是MVVM的标准执行,V继承于BaseVMActivity,VM继承于BaseViewModel,至于M,可以在VM中通过getRepository方法进行获取。

具体代码逻辑如下:

BaseVMActivity继承于BaseActivity。

abstract class BaseVMActivity<VB : ViewDataBinding, BM : BaseViewModel>(@LayoutRes layoutId: Int = 0) :
    BaseActivity<VB>(layoutId) {

    lateinit var mViewModel: BM

    override fun initData() {
        mViewModel = getViewModel()!!
        val variableId = getVariableId()
        if (variableId != -1) {
            mBinding.setVariable(getVariableId(), mViewModel)
            mBinding.executePendingBindings()
        }
        initVMData()
        observeLiveData()
        initState()
        lifecycle.addObserver(mViewModel)
    }

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:获取绑定的xml id
     */
    open fun getVariableId(): Int {
        return -1
    }

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:初始化状态
     */
    private fun initState() {
        mViewModel.mStateViewLiveData.observe(this, {
            when (it) {
                StateLayoutEnum.DIALOG_LOADING -> {
                    dialogLoading()
                }
                StateLayoutEnum.DIALOGD_DISMISS -> {
                    dialogDismiss()
                }
                StateLayoutEnum.DATA_ERROR -> {
                    dataError()
                }
                StateLayoutEnum.DATA_NULL -> {
                    dataEmpty()
                }
                StateLayoutEnum.NET_ERROR -> {
                    netError()
                }
                StateLayoutEnum.HIDE -> {
                    hide()
                }
            }
        })
    }

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:初始化数据
     */
    abstract fun initVMData()

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:LiveData的Observer
     */
    open fun observeLiveData() {

    }

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:dialog加载
     */
    open fun dialogLoading() {}

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:dialog隐藏
     */
    open fun dialogDismiss() {}

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:数据错误
     */
    open fun dataError() {}

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:数据为空
     */
    open fun dataEmpty() {}

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:网络错误或请求错误
     */
    open fun netError() {}

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:隐藏某些布局或者缺省页等
     */
    open fun hide() {}

    private fun getViewModel(): BM{
        //这里获得到的是类的泛型的类型
        val type = javaClass.genericSuperclass
        if (type != null && type is ParameterizedType) {
            val actualTypeArguments = type.actualTypeArguments
            val tClass = actualTypeArguments[1]
            return ViewModelProvider(
                this,
                ViewModelProvider.AndroidViewModelFactory.getInstance(application)
            )
                .get(tClass as Class<BM>)
        }
        return null
    }

override fun onDestroy() {
    super.onDestroy()
    try {
        lifecycle.removeObserver(mViewModel)
    } catch (e: Exception) {
        e.printStackTrace()
    }
}

}

4、封装涉及的方法概述

方法名 参数 概述
getVariableId 无参 获取绑定的xml variable,也就是当前的xml和哪个对象进行绑定,用于xml里直接数据绑定
initVMData 无参 初始化数据,必须要实现的方法
observeLiveData 无参 LiveData的Observer,UI层监听ViewModel层的数据改变
dialogLoading 无参 dialog加载
dialogDismiss 无参 dialog隐藏
dataError 无参 数据错误
dataEmpty 无参 数据为空
netError 无参 数据错误
hide 无参 隐藏缺省页等其他页面

5、BaseViewModel

BaseViewModel相对比较简单,只提供了一个可以获取Repository的方法,还有一个是刷新UI视图的一个LiveData,就是数据请求,Dialog加载,缺省页加载的状态。更改状态,只需要调用changeStateView方法即可,子类可以重写生命周期方法,便于生命周期的考虑。

open class BaseViewModel : ViewModel() , BaseObserver{
    /**
     * 控制状态视图的LiveData
     */
    val mStateViewLiveData = MutableLiveData<StateLayoutEnum>()

    /**
     * 更改状态视图的状态
     */
    public fun changeStateView(
        state: StateLayoutEnum
    ) {
        // 对参数进行校验
        when (state) {
            StateLayoutEnum.DIALOG_LOADING -> {
                mStateViewLiveData.postValue(StateLayoutEnum.DIALOG_LOADING)
            }
            StateLayoutEnum.DIALOGD_DISMISS -> {
                mStateViewLiveData.postValue(StateLayoutEnum.DIALOGD_DISMISS)
            }
            StateLayoutEnum.DATA_ERROR -> {
                mStateViewLiveData.postValue(StateLayoutEnum.DATA_ERROR)
            }
            StateLayoutEnum.DATA_NULL -> {
                mStateViewLiveData.postValue(StateLayoutEnum.DATA_NULL)
            }
            StateLayoutEnum.NET_ERROR -> {
                mStateViewLiveData.postValue(StateLayoutEnum.NET_ERROR)
            }
            StateLayoutEnum.HIDE -> {
                mStateViewLiveData.postValue(StateLayoutEnum.HIDE)
            }
        }

    }

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:获取Repository
     */
    inline fun <reified R> getRepository(): R{
        try {
            val clazz = R::class.java
            return clazz.newInstance()
        } catch (e: Exception) {
            e.printStackTrace()
        }
        return null
    }

/**
 * AUTHOR:AbnerMing
 * INTRODUCE:生命周期初始化
 */
override fun onCreate() {
}

/**
 * AUTHOR:AbnerMing
 * INTRODUCE:生命周期页面可见
 */
override fun onStart() {
}

/**
 * AUTHOR:AbnerMing
 * INTRODUCE:生命周期页面获取焦点
 */
override fun onResume() {
}

/**
 * AUTHOR:AbnerMing
 * INTRODUCE:生命周期页面失去焦点
 */
override fun onPause() {

}

/**
 * AUTHOR:AbnerMing
 * INTRODUCE:生命周期页面不可见
 */
override fun onStop() {

}

/**
 * AUTHOR:AbnerMing
 * INTRODUCE:生命周期页面销毁
 */
override fun onDestroy() {
}

}

复杂的Activity,大家可以发现,其实就是标准的MVVM形式封装,Fragment的封装也是基于此,搞清楚上述,基本上我们这个基类库就完成了大半,确实也没什么好说的,大家直接看使用吧。

四、封装后在业务中如何使用

通过以上的封装,我们在业务层所有的页面就可以继承父类进行使用,以达到代码的高度统一,使得架构模式简单化,让业务代码清晰化,目前的封装,大家可以直接封装成库或者打成aar给到其他开发者使用,目前我已经上传到远程,不想麻烦的老铁,可以直接按照下面的步骤进行使用。

  • 1、在你的根项目下的build.gradle文件下,引入maven。
allprojects {
    repositories {
      maven { url "https://gitee.com/AbnerAndroid/almighty/raw/master" }
    }
}
  • 2、在你需要使用的Module中build.gradle文件下,引入依赖。
dependencies {
  implementation 'com.vip:base:1.0.3'
}

通过以上的Maven仓库依赖,我们就可以愉快的进行使用了,下面针对各个封装的功能进行一个简单的演示,当然,大家可以直接看源码中的实例,那里相对比较全面。

1、普通的Activity的继承

如果,你的Activity页面逻辑比较简单,建议继承BaseActivity,此父类,没有与ViewModel相结合,只包含正常且简单的逻辑处理,目前必须重写的只有一个initData方法,其他方法,大家可以根据业务重写即可。

class MainActivity : BaseActivity<ActivityMainBinding>(R.layout.activity_main) {
    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:初始化数据
    */
    override fun initData() {
        setBarTitle("主页")
    }
}

2、ViewModel形式Activity的继承

View层,需要继承BaseVMActivity

class TestViewModelActivity : BaseVMActivity<ActivityViewModelBinding,
        TestViewModel>(R.layout.activity_view_model) {

    override fun initVMData() {
        setBarTitle("ViewModel方式使用")
    }

}

ViewModel层,需要继承BaseViewModel

实际的业务中,遇到网络请求,缺省页展示,Dialog显示隐藏,调用changeStateView方法,UI层只需要重写对应的方法即可。

class TestViewModel : BaseViewModel() {

      /**
     * AUTHOR:AbnerMing
     * INTRODUCE:获取需要的Repository
     */
    private val repository by lazy {
        getRepository<TestRepository>()
    }

}

Model层,一般根据实际需要,进行具体的封装使用。

class TestRepository {
}

3、DataBinding形式使用

View层,继承BaseVMActivity,返回当前视图的绑定variable

class DataBindActivity :
    BaseVMActivity<ActivityDataBindBinding,
            DataBindViewModel>(R.layout.activity_data_bind) {

    override fun initVMData() {
        setBarTitle("DataBinding使用")
    }

    override fun getVariableId(): Int {
        return BR.data
    }
}

ViewModel层继承BaseViewModel

class DataBindViewModel : BaseViewModel() {

    var oneWayContent = "单向绑定数据测试"

    var twoWayContent = "双向绑定数据测试"

    /**
     * AUTHOR:AbnerMing
     * INTRODUCE:获取双向绑定数据
     */
    var clickListener = View.OnClickListener {

        Toast.makeText(it.context, twoWayContent, Toast.LENGTH_SHORT).show()
    }
}

XML视图,直接绑定

<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>

        <variable
            name="data"
            type="com.abner.base.bind.DataBindViewModel" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:paddingLeft="@dimen/gwm_dp_20"
        android:paddingRight="@dimen/gwm_dp_20">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:layout_marginTop="@dimen/gwm_dp_20"
            android:text="@{data.oneWayContent}" />

        <EditText
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="@dimen/gwm_dp_20"
            android:hint="双向绑定"
            android:text="@={data.twoWayContent}" />

        <Button
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:layout_marginTop="@dimen/gwm_dp_20"
            android:onClick="@{data.clickListener}"
            android:text="获取双向绑定数据" />

    </LinearLayout>
</layout>

4、Fragment的简单使用

如果,你的Fragment页面逻辑比较简单,建议继承BaseFragment,此父类,没有与ViewModel相结合,只包含正常且简单的逻辑处理,目前必须重写的只有一个initData方法,其他方法,大家可以根据业务重写即可。

class TestPagerFragment : BaseFragment
    <FragmentTestPagerBinding>(R.layout.fragment_test_pager) {
    override fun initData() {
    }
}

5、ViewModel形式Fragment的继承

View层,需要继承BaseVMFragment

class TestViewModelPagerFragment :
    BaseVMFragment<FragmentTestPagerBinding,
            TestFragmentViewModel>(R.layout.fragment_test_pager) {

    override fun initVMData() {

    }

}

ViewModel层,需要继承BaseViewModel

class TestFragmentViewModel :BaseViewModel(){

        /**
     * AUTHOR:AbnerMing
     * INTRODUCE:获取需要的Repository
     */
    private val repository by lazy {
        getRepository<TestRepository>()
    }

}

Model层,一般根据实际需要,进行具体的封装使用。

class TestRepository {

}

6、Fragment的DataBinding形式使用和Activity类似,就不赘述了。

7、事件消息总线使用

  • 1、普通事件发送
    LiveDataBus.send("send", "我发送了一条普通消息")
  • 2、普通发送事件接收
LiveDataBus.observe(this, "send", Observer<String> {
            Toast.makeText(this, it, Toast.LENGTH_SHORT).show()
        })
  • 3、粘性事件发送
LiveDataBus.sendSticky("sendSticky", "我发送了一条粘性事件消息")
  • 4、粘性事件接收
  LiveDataBus.observeSticky(this, "sendSticky", Observer<String> {
            Toast.makeText(this, it, Toast.LENGTH_SHORT).show()
        })

更多的其他功能使用,大家直接看Github即可,上边有比较清晰的介绍。

Android极简MVVM,从一个基类库谈起,第3张

五、开源以及Demo查看

以上的封装,目前已经开源,大家可以下载查看源码,或者进行二次更改使用,地址是:
https://gitee.com/lyyon/VipBase

github.com/AbnerMing88…

相关Demo,大家可以down下项目,运行即可,这里简单贴张效果图:

Android极简MVVM,从一个基类库谈起,第4张

目前的封装,没有过多的冗余代码,完全可以满足实际的业务需要,大家可以按照这种模式试验一番,遇到问题,可以多多交流,毕竟,技术是开放的,交流中才能不断的进步,当然了,需要结合自己的实际业务进行使用,毕竟项目中不应存在多个架构模式,MVC也好,MVP,MVVM,MVI也罢,无论使用哪种,适合的才是最好的。

作者:_Jun
链接:https://www.jianshu.com/p/f7b718a4377f


https://www.xamrdz.com/backend/3xy1927505.html

相关文章: