3

Jetpack新成员,Paging3从吐槽到真香

 3 years ago
source link: https://guolin.blog.csdn.net/article/details/114707250
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

本文同步发表于我的微信公众号,扫一扫文章底部的二维码或在微信搜索 郭霖 即可关注,每个工作日都有文章更新。

各位小伙伴们大家早上好。

随着Android 11的正式发布,Jetpack家族也引入了许多新的成员。我之前有承诺过,对于新引入的App Startup、Hilt、Paging 3,我会分别写一篇文章进行介绍。

现在,关于App Start和Hilt的文章我都已经写完了,请参考 Jetpack新成员,App Startup一篇就懂Jetpack新成员,一篇文章带你玩转Hilt和依赖注入

那么本篇文章,我们要学习的自然就是Paging 3了。

Paging 3简介

Paging是Google推出的一个应用于Android平台的分页加载库。

事实上,Paging并不是现在才刚刚推出的,而是之前就已经推出过两个版本了。

但Paging 3和前面两个版本的变化非常大,甚至可以说是完全不同的东西了。所以即使你之前没有学习过Paging的用法也没有关系,把Paging 3当成是一个全新的库去学习就可以了。

我相信一定会有很多朋友在学习Paging 3的时候会产生和我相同的想法:本身Android上的分页功能并不难实现,即使没有Paging库我们也完全做得出来,但为什么Paging 3要把一个本来还算简单的功能设计得如此复杂呢?

是的,Paging 3很复杂,至少在你还不了解它的情况下就是如此。我在第一次学习Paging 3的时候就直接被劝退了,心想着何必用这玩意委屈自己呢,自己写分页功能又不是做不出来。

后来本着拥抱新技术的态度,我又去学习了一次Paging 3,这次算是把它基本掌握了,并且还在我的新开源项目 Glance 当中应用了Paging 3的技术。

如果现在再让我来评价一下Paging 3,那么我大概是经历了一个由吐槽到真香的过程。理解了Paging 3之后,你会发现它提供了一套非常合理的分页架构,我们只需要按照它提供的架构去编写业务逻辑,就可以轻松实现分页功能。我希望大家在看完这篇文章之后,也能觉得Paging 3香起来。

不过,本篇文章我不能保证它的易懂性。虽然很多朋友都觉得我写的文章简单易懂,但Paging 3的复杂性在于它关联了太多其他的知识,如协程、Flow、MVVM、RecyclerView、DiffUtil等等,如果你不能将相关联的这些知识都有所了解,那么想要掌握Paging 3就会更有难度。

另外,由于Paging 3是Google基于Kotlin协程全新重写的一个库,所以它主要是应用于Kotlin语言(Java也能用,但是会更加复杂),并且以后这样的库会越来越多,比如Jetpack Compose等等。如果你对于Kotlin还不太了解的话,可以去参考我的新书《第一行代码 Android 第3版》

上手Paging 3

经过我自己的总结,我发现如果零散去介绍一些Paging 3的知识点是很难能掌握得了这个库的。最好的学习方式就是直接上手,用Paging 3去做一个项目,项目做完了,你也基本就掌握了。本篇文章中我们就会采用这种方式来学习。

另外,我相信大家之前应该都做过分页功能,正如我所说,这个功能并不难实现。但是现在,请你完全忘掉过去你所熟知的分页方案,因为它不仅对理解Paging 3没有帮助,反而在很大程度上会影响你对Paging 3的理解。

是的,不要想着去监听列表滑动事件,滑动到底部的时候发起一个网络请求加载下一页数据。Paging 3完全不是这么用的,如果你还保留着这种过去的实现思路,在学习Paging 3的时候会很受阻。

那么现在就让我们开始吧。

首先新建一个Android项目,这里我给它起名为Paging3Sample。

接下来,我们在build.gradle的dependencies当中添加必要的依赖库:

dependencies {
    ...
    implementation 'androidx.paging:paging-runtime:3.0.0-beta01'
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
    implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
}

注意虽然我刚才说,Paging 3是要和很多其他关联库结合到一起工作的,但是我们并不需要将这些关联库一一手动引入,引入了Paging 3之后,所有的关联库都会被自动下载下来。

另外这里还引入了Retrofit的库,因为待会我们会从网络上请求数据,并通过Paging 3进行分页展示。

那么在正式开始涉及Paging 3的用法之前,让我们先来把网络相关的代码搭建好,方便为Paging 3提供分页数据。

这里我准备采用GitHub的公开API来作为我们这个项目的数据源,请注意GitHub在国内虽然一般都是可以访问的,但有时接口并不稳定,如果你无法正常请求到数据的话,请自行科学上网。

我们可以尝试在浏览器中请求如下接口地址:

https://api.github.com/search/repositories?sort=stars&q=Android&per_page=5&page=1

这个接口表示,会返回GitHub上所有Android相关的开源库,以Star数量排序,每页返回5条数据,当前请求的是第一页。

服务器响应的数据如下,为了方便阅读,我对响应数据进行了简化:

{
  "items": [
    {
      "id": 31792824,
      "name": "flutter",
      "description": "Flutter makes it easy and fast to build beautiful apps for mobile and beyond.",
      "stargazers_count": 112819,
    },
    {
      "id": 14098069,
      "name": "free-programming-books-zh_CN",
      "description": ":books: 免费的计算机编程类中文书籍,欢迎投稿",
      "stargazers_count": 76056,
    },
    {
      "id": 111583593,
      "name": "scrcpy",
      "description": "Display and control your Android device",
      "stargazers_count": 44713,
    },
    {
      "id": 12256376,
      "name": "ionic-framework",
      "description": "A powerful cross-platform UI toolkit for building native-quality iOS, Android, and Progressive Web Apps with HTML, CSS, and JavaScript.",
      "stargazers_count": 43041,
    },
    {
      "id": 55076063,
      "name": "Awesome-Hacking",
      "description": "A collection of various awesome lists for hackers, pentesters and security researchers",
      "stargazers_count": 42876,
    }
  ]
}

简化后的数据格式还是非常好理解的,items数组中记录了第一页包含了哪些库,其中name表示该库的名字,description表示该库的描述,stargazers_count表示该库的Star数量。

那么下面我们就根据这个接口来编写网络相关的代码吧,由于这部分都是属于Retrofit的用法,我会介绍的比较简略。

首先根据服务器响应的Json格式定义对应的实体类,新建一个Repo类,代码如下所示:

data class Repo(
    @SerializedName("id") val id: Int,
    @SerializedName("name") val name: String,
    @SerializedName("description") val description: String?,
    @SerializedName("stargazers_count") val starCount: Int
)

然后定义一个RepoResponse类,以集合的形式包裹Repo类:

class RepoResponse(
    @SerializedName("items") val items: List<Repo> = emptyList()
)

接下来定义一个GitHubService用于提供网络请求接口,如下所示:

interface GitHubService {

    @GET("search/repositories?sort=stars&q=Android")
    suspend fun searchRepos(@Query("page") page: Int, @Query("per_page") perPage: Int): RepoResponse

    companion object {
        private const val BASE_URL = "https://api.github.com/"

        fun create(): GitHubService {
            return Retrofit.Builder()
                    .baseUrl(BASE_URL)
                    .addConverterFactory(GsonConverterFactory.create())
                    .build()
                    .create(GitHubService::class.java)
        }
    }

}

这些都是Retrofit的标准用法,现在当调用searchRepos()函数时,Retrofit就会自动帮我们向GitHub的服务器接口发起一条网络请求,并将响应的数据解析到RepoResponse对象当中。

好了,现在网络相关的代码都已经准备好了,下面我们就开始使用Paging 3来实现分页加载功能。

Paging 3有几个非常关键的核心组件,我们需要分别在这几个核心组件中按部就班地实现分页逻辑。

首先最重要的组件就是PagingSource,我们需要自定义一个子类去继承PagingSource,然后重写load()函数,并在这里提供对应当前页数的数据。

新建一个RepoPagingSource继承自PagingSource,代码如下所示:

class RepoPagingSource(private val gitHubService: GitHubService) : PagingSource<Int, Repo>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
        return try {
            val page = params.key ?: 1 // set page 1 as default
            val pageSize = params.loadSize
            val repoResponse = gitHubService.searchRepos(page, pageSize)
            val repoItems = repoResponse.items
            val prevKey = if (page > 1) page - 1 else null
            val nextKey = if (repoItems.isNotEmpty()) page + 1 else null
            LoadResult.Page(repoItems, prevKey, nextKey)
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, Repo>): Int? = null

}

这段代码并不长,但却需要好好解释一下。

在继承PagingSource时需要声明两个泛型类型,第一个类型表示页数的数据类型,我们没有特殊需求,所以直接用整型就可以了。第二个类型表示每一项数据(注意不是每一页)所对应的对象类型,这里使用刚才定义的Repo。

然后在load()函数当中,先通过params参数得到key,这个key就是代表着当前的页数。注意key是可能为null的,如果为null的话,我们就默认将当前页数设置为第一页。另外还可以通过params参数得到loadSize,表示每一页包含多少条数据,这个数据的大小我们可以在稍后设置。

接下来调用刚才在GitHubService中定义的searchRepos()接口,并把page和pageSize传入,从服务器获取当前页所对应的数据。

最后需要调用LoadResult.Page()函数,构建一个LoadResult对象并返回。注意LoadResult.Page()函数接收3个参数,第一个参数传入从响应数据解析出来的Repo列表即可,第二和第三个参数分别对应着上一页和下一页的页数。针对于上一页和下一页,我们还额外做了个判断,如果当前页已经是第一页或最后一页,那么它的上一页或下一页就为null。

这样load()函数的作用就已经解释完了,可能你会发现,上述代码还重写了一个getRefreshKey()函数。这个函数是Paging 3.0.0-beta01版本新增的,以前的alpha版中并没有。它是属于Paging 3比较高级的用法,我们本篇文章涉及不到,所以直接返回null就可以了。

PagingSource相关的逻辑编写完成之后,接下来需要创建一个Repository类。这是MVVM架构的一个重要组件,还不了解的朋友可以去参考《第一行代码 Android 第3版》第15章的内容。

object Repository {

    private const val PAGE_SIZE = 50

    private val gitHubService = GitHubService.create()

    fun getPagingData(): Flow<PagingData<Repo>> {
        return Pager(
            config = PagingConfig(PAGE_SIZE),
            pagingSourceFactory = { RepoPagingSource(gitHubService) }
        ).flow
    }

}

这段代码虽然很短,但是却不易理解,因为用到了协程的Flow。我无法在这里展开解释Flow是什么,你可以简单将它理解成协程中对标RxJava的一项技术。

当然这里也没有用到什么复杂的Flow技术,正如你所见,上面的代码很简短,相比于理解,这更多是一种固定的写法。

我们定义了一个getPagingData()函数,这个函数的返回值是Flow<PagingData<Repo>>,注意除了Repo部分是可以改的,其他部分都是固定的。

在getPagingData()函数当中,这里创建了一个Pager对象,并调用.flow将它转换成一个Flow对象。在创建Pager对象的时候,我们指定了PAGE_SIZE,也就是每页所包含的数据量。又指定了pagingSourceFactory,并将我们自定义的RepoPagingSource传入,这样Paging 3就会用它来作为用于分页的数据源了。

将Repository编写完成之后,我们还需要再定义一个ViewModel,因为Activity是不可以直接和Repository交互的,要借助ViewModel才可以。新建一个MainViewModel类,代码如下所示:

class MainViewModel : ViewModel() {

    fun getPagingData(): Flow<PagingData<Repo>> {
        return Repository.getPagingData().cachedIn(viewModelScope)
    }

}

代码很简单,就是调用了Repository中定义的getPagingData()函数而已。但是这里又额外调用了一个cachedIn()函数,这是用于将服务器返回的数据在viewModelScope这个作用域内进行缓存,假如手机横竖屏发生了旋转导致Activity重新创建,Paging 3就可以直接读取缓存中的数据,而不用重新发起网络请求了。

写到这里,我们的这个项目已经完成了一大半了,接下来开始进行界面展示相关的工作。

由于Paging 3是必须和RecyclerView结合使用的,下面我们定义一个RecyclerView的子项布局。新建repo_item.xml,代码如下所示:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="10dp"
    android:orientation="vertical">

    <TextView
        android:id="@+id/name_text"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center_vertical"
        android:maxLines="1"
        android:ellipsize="end"
        android:textColor="#5194fd"
        android:textSize="20sp"
        android:textStyle="bold" />

    <TextView
        android:id="@+id/description_text"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:maxLines="10"
        android:ellipsize="end" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:gravity="end"
        tools:ignore="UseCompoundDrawables">

        <ImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_vertical"
            android:layout_marginEnd="5dp"
            android:src="@drawable/ic_star"
            tools:ignore="ContentDescription" />

        <TextView
            android:id="@+id/star_count_text"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_vertical" />

    </LinearLayout>

</LinearLayout>

这个布局中使用到了一个图片资源,可以到本项目的源码中去获取,源码地址见文章最底部。

接下来定义RecyclerView的适配器,但是注意,这个适配器也比较特殊,必须继承自PagingDataAdapter,代码如下所示:

class RepoAdapter : PagingDataAdapter<Repo, RepoAdapter.ViewHolder>(COMPARATOR) {

    companion object {
        private val COMPARATOR = object : DiffUtil.ItemCallback<Repo>() {
            override fun areItemsTheSame(oldItem: Repo, newItem: Repo): Boolean {
                return oldItem.id == newItem.id
            }

            override fun areContentsTheSame(oldItem: Repo, newItem: Repo): Boolean {
                return oldItem == newItem
            }
        }
    }

    class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val name: TextView = itemView.findViewById(R.id.name_text)
        val description: TextView = itemView.findViewById(R.id.description_text)
        val starCount: TextView = itemView.findViewById(R.id.star_count_text)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.repo_item, parent, false)
        return ViewHolder(view)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val repo = getItem(position)
        if (repo != null) {
            holder.name.text = repo.name
            holder.description.text = repo.description
            holder.starCount.text = repo.starCount.toString()
        }
    }

}

相比于一个传统的RecyclerView Adapter,这里最特殊的地方就是要提供一个COMPARATOR。因为Paging 3在内部会使用DiffUtil来管理数据变化,所以这个COMPARATOR是必须的。如果你以前用过DiffUtil的话,对此应该不会陌生。

除此之外,我们并不需要传递数据源给到父类,因为数据源是由Paging 3在内部自己管理的。同时也不需要重写getItemCount()函数了,原因也是相同的,有多少条数据Paging 3自己就能够知道。

其他部分就和普通的RecyclerView Adapter没什么两样了,相信大家都能够看得明白。

接下来就差最后一步了,让我们把所有的一切都集成到Activity当中。

修改activity_main.xml布局,在里面定义一个RecyclerView和一个ProgressBar:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <ProgressBar
        android:id="@+id/progress_bar"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center" />

</FrameLayout>

然后修改MainActivity中的代码,如下所示:

class MainActivity : AppCompatActivity() {

    private val viewModel by lazy { ViewModelProvider(this).get(MainViewModel::class.java) }

    private val repoAdapter = RepoAdapter()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
        val progressBar = findViewById<ProgressBar>(R.id.progress_bar)
        recyclerView.layoutManager = LinearLayoutManager(this)
        recyclerView.adapter = repoAdapter
        lifecycleScope.launch {
            viewModel.getPagingData().collect { pagingData ->
                repoAdapter.submitData(pagingData)
            }
        }
        repoAdapter.addLoadStateListener {
            when (it.refresh) {
                is LoadState.NotLoading -> {
                    progressBar.visibility = View.INVISIBLE
                    recyclerView.visibility = View.VISIBLE
                }
                is LoadState.Loading -> {
                    progressBar.visibility = View.VISIBLE
                    recyclerView.visibility = View.INVISIBLE
                }
                is LoadState.Error -> {
                    val state = it.refresh as LoadState.Error
                    progressBar.visibility = View.INVISIBLE
                    Toast.makeText(this, "Load Error: ${state.error.message}", Toast.LENGTH_SHORT).show()
                }
            }
        }
    }

}

这里最重要的一段代码就是调用了RepoAdapter的submitData()函数。这个函数是触发Paging 3分页功能的核心,调用这个函数之后,Paging 3就开始工作了。

submitData()接收一个PagingData参数,这个参数我们需要调用ViewModel中返回的Flow对象的collect()函数才能获取到,collect()函数有点类似于Rxjava中的subscribe()函数,总之就是订阅了之后,消息就会源源不断往这里传。

不过由于collect()函数是一个挂起函数,只有在协程作用域中才能调用它,因此这里又调用了lifecycleScope.launch()函数来启动一个协程。

其他地方应该就没什么需要解释的了,都是一些传统RecyclerView的用法,相信大家都能看得懂。

好了,这样我们就把整个项目完成了,在正式运行项目之前,别忘了在你的AndroidManifest.xml文件中添加网络权限:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.paging3sample">

    <uses-permission android:name="android.permission.INTERNET" />
    ...

</manifest>

现在运行一下程序,效果如下图所示:

20210312210242811.gif

可以看到,GitHub上Android相关的开源库已经成功显示出来了。并且你可以不断往下滑,Paging 3会自动加载更多的数据,仿佛让你永远也滑不到头一样。

如次一来,使用Paging 3来进行分页加载的效果也就成功完成了。

总结一下,相比于传统的分页实现方案,Paging 3将一些琐碎的细节进行了隐藏,比如你不需要监听列表的滑动事件,也不需要知道知道何时应该加载下一页的数据,这些都被Paging 3封装掉了。我们只需要按照Paging 3搭建好的框架去编写逻辑实现,告诉Paging 3如何去加载数据,其他的事情Paging 3都会帮我们自动完成。

在底部显示加载状态

根据Paging 3的设计,其实我们理论上是不应该在底部看到加载状态的。因为Paging 3会在列表还远没有滑动到底部的时候就提前加载更多的数据(这是默认属性,可配置),从而产生一种好像永远滑不到头的感觉。

然而凡事总有意外,比如说当前的网速不太好,虽然Paging 3会提前加载下一页的数据,但是当滑动到列表底部的时候,服务器响应的数据可能还没有返回,这个时候就应该在底部显示一个正在加载的状态。

另外,如果网络条件非常糟糕,还可能会出现加载失败的情况,此时应该在列表底部显示一个重试按钮。

那么接下来我们就来实现这个功能,从而让项目变得更加完善。

创建一个footer_item.xml布局,用于显示加载进度条和重试按钮:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="10dp">

    <ProgressBar
        android:id="@+id/progress_bar"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center" />

    <Button
        android:id="@+id/retry_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:text="Retry" />

</FrameLayout>

然后创建一个FooterAdapter来作为RecyclerView的底部适配器,注意它必须继承自LoadStateAdapter,如下所示:

class FooterAdapter(val retry: () -> Unit) : LoadStateAdapter<FooterAdapter.ViewHolder>() {

    class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val progressBar: ProgressBar = itemView.findViewById(R.id.progress_bar)
        val retryButton: Button = itemView.findViewById(R.id.retry_button)
    }

    override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): ViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.footer_item, parent, false)
        val holder = ViewHolder(view)
        holder.retryButton.setOnClickListener {
            retry()
        }
        return holder
    }

    override fun onBindViewHolder(holder: ViewHolder, loadState: LoadState) {
        holder.progressBar.isVisible = loadState is LoadState.Loading
        holder.retryButton.isVisible = loadState is LoadState.Error
    }

}

这仍然是一个非常简单的Adapter,需要注意的地方大概只有两点。

第一点,我们使用Kotlin的高阶函数来给重试按钮注册点击事件,这样当点击重试按钮时,构造函数中传入的函数类型参数就会被回调,我们待会将在那里加入重试逻辑。

第二点,在onBindViewHolder()中会根据LoadState的状态来决定如何显示底部界面,如果是正在加载中那么就显示加载进度条,如果是加载失败那么就显示重试按钮。

最后,修改MainActivity中的代码,将FooterAdapter集成到RepoAdapter当中:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        recyclerView.adapter = repoAdapter.withLoadStateFooter(FooterAdapter { repoAdapter.retry() })
        ...
    }

}

代码非常简单,只需要改动一行,调用RepoAdapter的withLoadStateFooter()函数即可将FooterAdapter集成到RepoAdapter当中。

另外注意这里使用Lambda表达式来作为传递给FooterAdapter的函数类型参数,在Lambda表示式中,调用RepoAdapter的retry()函数即可重新加载。

这样我们就把底部显示加载状态的功能完成了,现在来测试一下吧,效果如下图所示。

20210312210330687.gif

可以看到,首先我在设备上开启了飞行模式,这样当滑动到列表底部时就会显示重试按钮。

然后把飞行模式关闭,并点击重试按钮,这样加载进度条就会显示出来,并且成功加载出新的数据了。

本文到这里就结束了。

不得不说,我在文章中讲解的这些知识点仍然只是Paging 3的基本用法,还有许多高级用法文中并没有涵盖。当然,这些基本用法也是最最常用的用法,所以如果你并不打算成为Paging 3大师,掌握文中的这些知识点就已经足够应对日常的开发工作了。

如果你还想要进一步进阶学习Paging 3,可以参考Google官方的Codelab项目,地址是:

https://developer.android.com/codelabs/android-paging

我们刚才一起编写的Paging3Sample项目其实就是从Google官方的Codelab项目演化而来的,我根据自己的理解重写了这个项目并进行了一定的简化。直接学习原版项目,你将能学到更多的知识。

最后,如果你需要获取Paging3Sample项目的源码,请访问以下地址:

https://github.com/guolindev/Paging3Sample

另外,如果想要学习Kotlin和最新的Android知识,可以参考我的新书 《第一行代码 第3版》点击此处查看详情

关注我的技术公众号,每个工作日都有优质技术文章推送。

微信扫一扫下方二维码即可关注:

20181224140138240.jpg


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK