源码解析Android Jetpack组件之ViewModel的使用

 更新时间:2023年04月21日 15:27:30   作者:孙先森Blog  
Jetpack 是一个丰富的组件库,它的组件库按类别分为 4 类,分别是架构(Architecture)、界面(UI)、 行为(behavior)和基础(foundation)。本文将从源码和大家讲讲Jetpack组件中ViewModel的使用

前言

在之前 LiveData 源码浅析的博客中提到了 ViewModel 组件,当时对 ViewModel 的解释是 “生命周期比Activity” 更长的对象。本文就来了解下其实现原理。

依赖版本

// 注意这里的 appcompat、activity-ktx、fragment-ktx
// 高版本的自动引入了 viewmodel-savedstate 实战中很少用到的功能
// 篇幅原因 就不再本文中分析 viewmodel-savedstate 扩展组件了
implementation 'androidx.appcompat:appcompat:1.0.0'

def fragment_version = "1.1.0"
def activity_version = "1.0.0"
implementation "androidx.activity:activity-ktx:$activity_version"
implementation "androidx.fragment:fragment-ktx:$fragment_version"

def lifecycle_version = "2.5.1"

implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycle_version"

基础使用

定义

class MainViewModel: ViewModel(){ ... }
// or 
class MainViewModel(application: Application): AndroidViewModel(application){
    val data: String = ""
    
    fun requestData(){
        data = "xxx"
    }
}

在 MainVieModel 中可以定义 UI 界面中需要的数据(对象、LiveData、Flow 等等)和方法,在 Activity 真正销毁前 ViewModel 中的数据不会丢失。

Activity 中获取

val vm = ViewModelProvider(this).get(MainViewModel::class.java)
// or
// 引入 activity-ktx 库可以这样初始化 ViewModel
val vm by viewModels<MainViewModel>()

// 通过 vm 可以调用其中的方法、获取其中的数据
vm.requestData()
Log.e(TAG, vm.data)

Fragment 中获取

val vm = ViewModelProvider(this).get(MainViewModel::class.java)
// or 
// 获取和 Activity 共享的 ViewModel 也就是同一个 ViewModel 对象
val vm = ViewModelProvider(requireActivity()).get(MainViewModel::class.java)

引入 fragment-ktx 可以这样初始化

val vm = viewModels<MainViewModel>()
// or 效果同上
val vm = activityViewModels<MainViewModel>()

前置知识

ViewModel 的使用非常简单,也很容易理解,就是一个生命周期长于 Activity 的对象,区别在于不会造成内存泄漏。ViewModel 不是魔法,站在开发者的角度在 ViewModel 没有问世之前横竖屏切换需要保存状态数据的需求通常都是通过 onSaveInstanceState、onRestoreInstanceState 来实现。

onSaveInstanceState、onRestoreInstanceState

关于这两个方法这里就简单概述一下:onSaveInstanceState 用于在 Activity 横竖屏切换(意外销毁)前保存数据,而 onRestoreInstanceState 是用于 Activity 横竖屏切换(重建)后获取保存的数据;

onSaveInstanceState 调用流程

由于是在 Activity 销毁前触发,那么直接来 ActivityThread 中找到 performPauseActivity 方法:

ActivityThread.java

private Bundle performPauseActivity(ActivityClientRecord r, boolean finished, String reason, PendingTransactionActions pendingActions) {
    // ...
    if (shouldSaveState) {
        callActivityOnSaveInstanceState(r);
    }
    // ...
}

private void callActivityOnSaveInstanceState(ActivityClientRecord r) {
    // ...
    // 这里通过 ActivityClientRecord 获取到 activity
    // state 是 Bundle 对象,后面要保存的数据就放在 state 中
    mInstrumentation.callActivityOnSaveInstanceState(r.activity, r.state);
    // ...
}

这里有 ActivityThread 调用到了 Instrumentation 中,继续看源码:

Instrumentation.java

public void callActivityOnSaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {
    activity.performSaveInstanceState(outState);
}

根据传入的 activity 调用其 performSaveInstanceState 方法:

Activity.java

final void performSaveInstanceState(@NonNull Bundle outState) {
    onSaveInstanceState(outState);
}

总结一下,onSaveInstanceState 中我们将数据存储在 Bundle 对象中,而这个 Bundle 对象是存储在 ActivityClientRecord 中。

onRestoreInstanceState 调用流程

看完了 onSaveInstanceState 的调用流程,那么 onRestoreInstanceState 的流程就来简单说说,由于在 onStart 后发生回调,所以直接去看 ActivityThread 中的源码:

ActivityThread.java

public void handleStartActivity(ActivityClientRecord r, PendingTransactionActions pendingActions, ActivityOptions activityOptions) {
    // ...
    mInstrumentation.callActivityOnRestoreInstanceState(activity, r.state);
    // ...
}

可以看出这里从 ActivityClientRecord 中取出了 activity 和 state 进行传毒,后面就和 onSaveInstanceState 调用流程一样了,源码比较简单就不贴了。

onRetainCustomNonConfigurationInstance、getLastCustomNonConfigurationInstance

除了 onSaveInstanceState 和 onRestoreInstanceState,在 Activity 中还有一组方法可以实现类似的功能,就是 onRetainCustomNonConfigurationInstance 和 getLastCustomNonConfigurationInstance,前者即保存数据,后者即获取保存的数据;

简单使用

override fun onRetainCustomNonConfigurationInstance(): Any? {
    val data = SaveStateData()
    return data
}

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    // 获取保存的数据
    val data = getLastCustomNonConfigurationInstance() as SaveStateData
}

和 onSaveInstanceState 使用的区别在于 onSaveInstanceState 只能在其参数中的 Bundle 对象中写入数据,而 onRetainCustomNonConfigurationInstance 返回的类型是 Any(Java Object)不限制数据类型。老样子看一下这组方法的源码调用流程。

onRetainCustomNonConfigurationInstance

onRetainCustomNonConfigurationInstance 是在 ComponentActivity 中定义的,默认实现返回 null,其在 onRetainNonConfigurationInstance 方法中被调用:

ComponentActivity.java

public Object onRetainCustomNonConfigurationInstance() {
    // ComponentActivity 中默认返回 null
    return null;
}

public final Object onRetainNonConfigurationInstance() {
    // 保存在了 custom 变量中
    Object custom = onRetainCustomNonConfigurationInstance();
    // 这里已经出现 ViewModel 相关的源码了,这里先按下不表
    ViewModelStore viewModelStore = mViewModelStore;
    if (viewModelStore == null) {
        NonConfigurationInstances nc =
                (NonConfigurationInstances) getLastNonConfigurationInstance();
        if (nc != null) {
            viewModelStore = nc.viewModelStore;
        }
    }

    if (viewModelStore == null && custom == null) {
        return null;
    }
    // 新建 NonConfigurationInstances 对象
    NonConfigurationInstances nci = new NonConfigurationInstances();
    // custom 赋值给了 NonConfigurationInstances 对象
    nci.custom = custom;
    nci.viewModelStore = viewModelStore;
    return nci;
}

从 ComponentActivity 的这部分源码中可以看出保存的数据最终放在了 NonConfigurationInstances 对象的 custom 属性中;接着找 onRetainNonConfigurationInstance 的定义,在 Activity 中:

Activity.java

public Object onRetainNonConfigurationInstance() {
    // 默认返回 null
    return null;
}

NonConfigurationInstances retainNonConfigurationInstances() {
    // ComponentActivity 中返回的 NonConfigurationInstances 对象
    Object activity = onRetainNonConfigurationInstance();
    // ...
    // 注意 这里有新建另一个 NonConfigurationInstances 对象
    NonConfigurationInstances nci = new NonConfigurationInstances();
    // ComponentActivity 中返回的 NonConfigurationInstances 对象
    // 存储到了新的 NonConfigurationInstances 中的 activity 属性中
    nci.activity = activity;
    // ...
    return nci;
}

在 Activity 类中相当于做了一层套娃,又新建了一个 NonConfigurationInstances 对象,将 ComponentActivity 中返回的 NonConfigurationInstances 对象存了进去;

其实源码看到这里就可以了,不过本着刨根问底的原则,我们接着再看一下 NonConfigurationInstances 到底存在了哪里?在 ActivityThread.java 中找到了调用 retainNonConfigurationInstances 的地方:

ActivityThread.java

void performDestroyActivity(ActivityClientRecord r, boolean finishing, int configChanges, boolean getNonConfigInstance, String reason) {
    // ...
    // 这个 r 是参数中的 ActivityClientRecord
    r.lastNonConfigurationInstances = r.activity.retainNonConfigurationInstances();
}

和 onSaveInstanceState 一样存储在了 ActivityClientRecord 中,只不过换了一个属性罢了。

getLastCustomNonConfigurationInstance

看完了存储的流程,简单来看看取数据的流程。既然存的时候套娃了一下 NonConfigurationInstances,那取数据的时候肯定也需要套娃:

ComponentActivity.java

public Object getLastCustomNonConfigurationInstance() {
    // 通过 getLastNonConfigurationInstance 获取 NonConfigurationInstances
    NonConfigurationInstances nc = (NonConfigurationInstances)
            getLastNonConfigurationInstance();
    // 返回 custom
    return nc != null ? nc.custom : null;
}

那么在 Activity 中肯定还需要取一次 ActivityClientRecord 中的 NonConfigurationInstances:

Activity.java

NonConfigurationInstances mLastNonConfigurationInstances;

public Object getLastNonConfigurationInstance() {
    // 返回其 activity 字段
    return mLastNonConfigurationInstances != null
            ? mLastNonConfigurationInstances.activity : null;
}

// mLastNonConfigurationInstances 赋值在 attach 方法中
final void attach(Context context, /*参数太多 省略了*/ NonConfigurationInstances lastNonConfigurationInstances) {
    // ...
    mLastNonConfigurationInstances = lastNonConfigurationInstances;
    // ...
}

可以看出在 Activity attach 方法中就已经拿到了套娃后的 NonConfigurationInstances 对象,我们都知道 Activity attach 方法是在 ActivityThread 的 performLaunchActivity 中调用,看一下源码:

private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) {
    // ...
    // 参数太多 省略了
    // 可以看到是从 ActivityClientRecord 中取出传入的
    activity.attach(appContext, r.lastNonConfigurationInstancesn);
    // ...
}

小节总结

两种方式都是将数据保存到了 ActivityClientRecord 中,不同的是前者限制了 Bundle 类型,后者不限制类型(ViewModel 采用的就是后者这组方法实现),不过后者已经在源码中被标记了删除,并不影响使用,标记删除是为了让开发者们利用 ViewModel 来接管这种需求。下面我们就正式进入 ViewModel 源码。

源码分析

前置知识有点长,不过也几乎把 ViewModel 的原理说透了,ViewModel 的保存、恢复是利用了系统提供的方法,不过还有些细节还需要在源码中探索,比如:如何实现 Activity/Fragment 共享 ViewModel?接下来就来深入 ViewModel 源码。

创建

先来以 Activity 中创建 ViewModel 的这段代码入手:

val vm by viewModels<MainViewModel>()

查看 viewModels 源码:

// 这是一个 ComponentActivity 的扩展方法
@MainThread // 在主线程中使用
inline fun <reified VM : ViewModel> ComponentActivity.viewModels(
    // 从命名也可以看出是一个工厂模式,默认是 null
    noinline factoryProducer: (() -> Factory)? = null
): Lazy<VM> {
    // 默认 factoryProducer 为 null
    // 返回的是 AndroidViewModelFactory
    val factoryPromise = factoryProducer ?: {
        val application = application ?: throw IllegalArgumentException(
            "ViewModel can be accessed only when Activity is attached"
        )
        AndroidViewModelFactory.getInstance(application)
    }
    
    // 返回了一个 ViewModelLazy 对象,将 viewModelStore、factoryProducer 传入
    return ViewModelLazy(VM::class, { viewModelStore }, factoryPromise)
}

到这里先暂停看一下 AndroidViewModelFactory 是如何初始化的,以及 viewModelStore 是什么东东:

ViewModelProvider.kt

private var sInstance: AndroidViewModelFactory? = null

@JvmStatic
public fun getInstance(application: Application): AndroidViewModelFactory {
    if (sInstance == null) {
        sInstance = AndroidViewModelFactory(application)
    }
    return sInstance!!
}

是一个单例模式,直接对 AndroidViewModelFactory 进行实例化,再来看看 mViewModelStore

ComponentActivity.java

// 都是定义在 ComponentActivity 中的变量,默认 null
private ViewModelStore mViewModelStore;

public ViewModelStore getViewModelStore() {
    // ...
    if (mViewModelStore == null) { // 第一次启动 activity 为 null
        // 获取保存的数据
        NonConfigurationInstances nc = (NonConfigurationInstances) getLastNonConfigurationInstance();
         // 优先从保存的数据中获取
        if (nc != null) {
            mViewModelStore = nc.viewModelStore;
        }
        // 默认返回 ViewModelStore
        if (mViewModelStore == null) {
            mViewModelStore = new ViewModelStore();
        }
    }
    return mViewModelStore;
}

ViewModelStore 内部仅仅是管理一个 Map<String, ViewModel>,用于缓存、清理创建的 ViewModel。

回过头接着看扩展方法 viewModels 返回的 ViewModelLazy:

public class ViewModelLazy<VM : ViewModel> @JvmOverloads constructor(
    private val viewModelClass: KClass<VM>, // ViewModel 的 class
    private val storeProducer: () -> ViewModelStore, // 默认是 ViewModelStore
    private val factoryProducer: () -> ViewModelProvider.Factory, // 这里就是 mDefaultFactory
    private val extrasProducer: () -> CreationExtras = { CreationExtras.Empty } // 
) : Lazy<VM> { // 注意这里返回的 Lazy,延迟初始化
    private var cached: VM? = null

    override val value: VM
        get() { // 由于返回的是 Lazy,也就是当使用 ViewModel 时才会调用 get
            val viewModel = cached
            return if (viewModel == null) { // 第一次调用是 null,进入 if
                val factory = factoryProducer() // mDefaultFactory
                val store = storeProducer() // ViewModelStore
                ViewModelProvider( // 生成 ViewModelProvider 对象
                    store,
                    factory,
                    extrasProducer()
                ).get(viewModelClass.java).also { // 调用其 get 方法获取 ViewModel
                    cached = it  // 保存到 cached 变量
                } 
            } else {
                viewModel
            }
        }
    
    override fun isInitialized(): Boolean = cached != null
}

这里又出现了一个陌生的对象 CreationExtras,其内部也是一个 map,可以理解为一个键值对存储对象,只不过他的 Key 是一个特殊类型。

接着查看 ViewModelProvider 的 get 方法是如何创建 ViewModel 的:

// 存储ViewModel的key的前缀
internal const val DEFAULT_KEY = "androidx.lifecycle.ViewModelProvider.DefaultKey"

public open operator fun <T : ViewModel> get(modelClass: Class<T>): T {
    val canonicalName = modelClass.canonicalName
        ?: throw IllegalArgumentException("Local and anonymous classes can not be ViewModels")
    // 调用重载方法,拼接 key 传入
    // 当前key即为:androidx.lifecycle.ViewModelProvider.DefaultKey$com.xxx.MainViewModel
    return get("$DEFAULT_KEY:$canonicalName", modelClass)
}

@MainThread
public open operator fun <T : ViewModel> get(key: String, modelClass: Class<T>): T {
    val viewModel = store[key] // 优先从 ViewModelStroe 中获取缓存
    if (modelClass.isInstance(viewModel)) { // 如果类型相同 直接返回
        // 这里我们的 factory 是 AndroidViewModelFactory 所以不会走这行代码
        (factory as? OnRequeryFactory)?.onRequery(viewModel)
        return viewModel as T
    }
    // ...
    // 这里的 defaultCreationExtras 是上一步骤中的 CreationExtras,默认值为 CreationExtras.Empty
    // MutableCreationExtras 包装一层就是将 defaultCreationExtras 中所有的键值对都copy一份
    val extras = MutableCreationExtras(defaultCreationExtras)
    // 将当前 ViewModel 的 key 存储进去
    extras[VIEW_MODEL_KEY] = key
    
    return try {
        // 优先调用双参数方法
        factory.create(modelClass, extras)
    } catch (e: AbstractMethodError) {
        // 调用双参数方法发生异常再调用单参数方法
        factory.create(modelClass)
    }.also { 
        // 获取到 ViewModel 后存储到 viewModelStore 中
        // 再提一嘴 viewModelStore 是在 ComponentActivity 中定义 
        store.put(key, it) 
    }
}

终于到了创建 ViewModel 的部分了,直接去看 AndroidViewModelFactory 的 create 方法:

ViewModelProvider.kt

override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
    // application 不为 null 调用单参数方法
    // 在新建 AndroidViewModelFactory 已经传入了 application,一般情况不为 null
    return if (application != null) { 
        create(modelClass)
    } else { 
        // application 如果为 null,则会从传入的 extras 中尝试获取
        val application = extras[APPLICATION_KEY]
        if (application != null) {
            // 这个 create 也是双参数,但不是递归,第二个参数是 application,源码贴在下面
            create(modelClass, application)
        } else {
            // 如果 application 仍然为 null,且 ViewModel 类型为 AndroidViewModel 则抛异常
            if (AndroidViewModel::class.java.isAssignableFrom(modelClass)) {
                throw IllegalArgumentException(...)
            }
            // 类型不是 AndroidViewModel 则根据 class 创建
            // 注意这里调用的 super.create 是父类方法
            // 父类方法直接根据 modelClass.newInstance() 创建,就一行就不贴源码了
            super.create(modelClass)
        }
    }
}

override fun <T : ViewModel> create(modelClass: Class<T>): T {
    return if (application == null) { // application 为 null 直接抛异常
        throw UnsupportedOperationException(...)
    } else {
        // 调用下面的双参数方法
        create(modelClass, application)
    }
}

private fun <T : ViewModel> create(modelClass: Class<T>, app: Application): T {
    // 如果是 AndroidViewModel 类型则获取带 application 的构造参数创建
    return if (AndroidViewModel::class.java.isAssignableFrom(modelClass)) {
        modelClass.getConstructor(Application::class.java).newInstance(app)
    } else {
        // 直接调用父类 create 方法通过 modelClass.newInstance() 创建
        super.create(modelClass)
    }
}

至此 Activity 中的 ViewModel 创建过程源码就全部分析完了,总结一下:Activity 中的 ViewModel 创建都是通过单例工厂 AndroidViewModelFactory 的 create 方法中反射创建,在调用 create 创建前会生成字符串 key,创建完成后会将 key 和 vm 对象存储到 ViewModelStore 中,后续获取将优先从 ViewModelStore 缓存中获取。

ViewModelStore 是定义在 ComponentActivity 中的,ViewModel 生命周期 “长于” Activity 的原理跟这个 ViewModelStore 脱不了干系。

恢复

前面小节提过,ViewModel 的恢复利用的是 onRetainNonConfigurationInstance 方法,ViewModelStore 又是定义在 ComponentActivity 中,那么直接去看 ComponentActivity 这部分的源码:

ComponentActivity.java

public final Object onRetainNonConfigurationInstance() {
    // 留给开发者使用的字段
    Object custom = onRetainCustomNonConfigurationInstance();
    
    // 获取当前 Activity 的 mViewModelStore
    ViewModelStore viewModelStore = mViewModelStore;
    if (viewModelStore == null) { 
        // 如果为 null 则尝试获取上一次保存的数据
        NonConfigurationInstances nc =  (NonConfigurationInstances) getLastNonConfigurationInstance();
        if (nc != null) {
            // 获取上一次存储的 viewModelStore
            viewModelStore = nc.viewModelStore;
        }
    }
    // ...
    NonConfigurationInstances nci = new NonConfigurationInstances();
    nci.custom = custom; // 开发者用的字段
    nci.viewModelStore = viewModelStore; // 保存 viewModelStore 的字段
    return nci;
}

再来看一看 ViewModelStore 的获取方法:

public ViewModelStore getViewModelStore() {
    // ...
    if (mViewModelStore == null) {
        NonConfigurationInstances nc = (NonConfigurationInstances) getLastNonConfigurationInstance();
         // 优先从保存的数据中获取 viewModelStore
        if (nc != null) {
            mViewModelStore = nc.viewModelStore;
        }
        // 获取不到才会新建
        if (mViewModelStore == null) {
            mViewModelStore = new ViewModelStore();
        }
    }
    return mViewModelStore;
}

Activity 获取 mViewModelStore 时优先从 getLastNonConfigurationInstance 获取到 NonConfigurationInstances 对象,再从其中获取 viewModelStore,这样在当前 Activity 作用域中创建过的 ViewModel 都存储在 ViewModelStore 中,当需要再次使用时走 ViewModel 创建流程会直接从 ViewModelStore 中返回。

最后

再了解了 onRetainNonConfigurationInstance 这组方法之后再来探究 ViewModel 的恢复原理就很简单了,onRetainNonConfigurationInstance 也被标记为了删除,google 也希望开发者尽可能的使用 ViewModel 来保存数据(临时数据)。

onRetainNonConfigurationInstance 虽然被标记为删除,但仍然可以正常使用,相比于 onSaveInstanceState 没有了数据类型限制,但并不意味着我们可以随意存储,比较大的数据还是应该考虑持久化存储。

以上就是源码解析Android Jetpack组件之ViewModel的使用的详细内容,更多关于Android Jetpack ViewModel的资料请关注脚本之家其它相关文章!

相关文章

  • Android startActivityForResult的基本用法详解

    Android startActivityForResult的基本用法详解

    这篇文章主要介绍了Android startActivityForResult的基本用法详解,本篇文章通过简要的案例,讲解了该项技术的了解与使用,以下就是详细内容,需要的朋友可以参考下
    2021-08-08
  • 图解Eclipse在线安装ADT插件过程

    图解Eclipse在线安装ADT插件过程

    这篇文章主要以图解的方式为大家分享了Eclipse在线安装ADT插件过程,需要的朋友可以参考下
    2015-12-12
  • Android HelloChart开源库图表之折线图的实例代码

    Android HelloChart开源库图表之折线图的实例代码

    这篇文章主要介绍了Android HelloChart开源库图表之折线图的实例代码,具有很好的参考价值,希望对大家有所帮助,一起跟随小编过来看看吧
    2018-05-05
  • Android Bluetooth蓝牙技术初体验

    Android Bluetooth蓝牙技术初体验

    这篇文章主要介绍了Android Bluetooth蓝牙技术初体验的相关资料,需要的朋友可以参考下
    2016-02-02
  • Android使用surfaceView自定义抽奖大转盘

    Android使用surfaceView自定义抽奖大转盘

    这篇文章主要为大家详细介绍了Android使用surfaceView自定义抽奖大转盘,熟练掌握SurfaceVie实现抽奖大转盘,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2016-12-12
  • Android系统检测程序内存占用各种方法

    Android系统检测程序内存占用各种方法

    这篇文章主要介绍了Android系统检测程序内存占用各种方法,本文讲解了检查系统总内存、检查某个程序的各类型内存占用、检查程序状态、检查程序各部分的内存占用等内容,需要的朋友可以参考下
    2015-03-03
  • Android画中画窗口开启方法

    Android画中画窗口开启方法

    Android8.0 Oreo(API Level26)允许活动启动画中画Picture-in-picture(PIP)模式。PIP是一种特殊类型的多窗口模式,主要用于视频播放。PIP模式已经可用于Android TV,而Android8.0则让该功能可进一步用于其他Android设备
    2023-01-01
  • Android自定义水平进度条的圆角进度

    Android自定义水平进度条的圆角进度

    这篇文章主要为大家详细介绍了Android自定义水平进度条的圆角进度,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2016-08-08
  • 详解RecyclerView设置背景图片长宽一样(以GridLayoutManager为例)

    详解RecyclerView设置背景图片长宽一样(以GridLayoutManager为例)

    这篇文章主要介绍了详解RecyclerView设置背景图片长宽一样(以GridLayoutManager为例),小编觉得挺不错的,现在分享给大家,也给大家做个参考。一起跟随小编过来看看吧
    2017-12-12
  • Android使用ViewPager实现启动引导页

    Android使用ViewPager实现启动引导页

    这篇文章主要为大家详细介绍了Android使用ViewPager实现第一次启动引导页,文中示例代码介绍的非常详细,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2016-07-07

最新评论