可能是最全的kotlin协程+Jetpack入门教程
前言
最近一直在学习kotlin,为了能够尽快入门,所以我准备写一个简单的app练手,加上之前一直在研究Jetpack这套组件库,基于以上原因我打算用kotlin+jetpack搭建我的app框架,至于为啥用kotlin协程,之前在网上看了一篇博客Kotlin 协程 看这一篇就够了,既然是学习项目,所以把这个也加上,在查看了大量的文章,我发现大多数文章都是一个简单的例子,距离真正的项目,还有一定的差距,so这篇文章诞生了,希望能够给大家有所帮助。
一、协程是什么?
协程是什么很难用一句话简单的概括,我们先看一个例子,然后再来讲它到底是什么。
1.引入库
kotlin相关依赖库:
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.1"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.1"
2.官方例子
import kotlinx.coroutines.*
fun main() {
GlobalScope.launch { // 在后台启动一个新的协程并继续
delay(1000L) // 非阻塞的等待 1 秒钟(默认时间单位是毫秒)
println("World!") // 在延迟后打印输出
}
println("Hello,") // 协程已在等待时主线程还在继续
Thread.sleep(2000L) // 阻塞主线程 2 秒钟来保证 JVM 存活
}
运行结果如下:
简单说一下,上面这段代码,GlobalScope.launch开启了一个新的协程,大括号里面的代码就是协程体,结合我们的运行结果可以知道,delay不会阻塞当前主线程,这就是一个最简单的协程的用法,看到这里可能有点疑惑,我再举一个简单的例子来说明为什么要用协程。
我们平时在开发过程中,经常会有这样的需求,我们通过网络异步获取数据,然后显示在界面上:
/**
* 从网络拉取数据
*/
private fun fetchDataFromWork(url: String, callback: NetWorkListener) {
var data: String = ""
// 模拟耗时任务
delay(2000L)
data = url + "ddup"
callback.onSuccess(data)
}
/**
* 展示数据
*/
private fun showText(data: String) {
tv_my_content.text = data
}
调用如下:
Thread() {
fetchDataFromWork("www.ddup.com", object : NetWorkListener {
override fun onSuccess(data: String) {
// 切换到主线程显示结果
Handler().post {
showText(data)
}
}
override fun onFail(error: String) {
// 异常处理
}
})
}.start()
以上是我们用传统的方式实现的需求,我们再看看协程的方式,首先我们需要改造一下fetchDataFromWork()方法:
/**
* 从网络拉取数据
*/
private suspend fun fetchDataFromWork(url: String): String {
var data: String = ""
withContext(Dispatchers.IO) {
// 模拟耗时任务
delay(2000L)
data = url + "ddup"
}
return data;
}
suspend是kotlin的一个关键字,用于标记挂起函数,代表当前方法是一个耗时方法,withContext另外一种创建协程的方式,与此类似的还有async,一般配合await一起使用。我们再看下利用协程实现上述需求的调用方式:
GlobalScope.launch(Dispatchers.Main) {
val data: String = fetchDataFromWork("www.ddup.com")
showText(data)
}
对比以前的实现方式是不是要简单很多,以前的方式需要我们手动切换线程,而协程内部已经帮我们处理了,自动切换线程。这样看可能不明显,当我们的需求修改为,当我们第一条数据显示完成后再接着显示第二条数据:
传统的实现方式:
Thread() {
fetchDataFromWork("www.ddup.com", object : NetWorkListener {
override fun onSuccess(data: String) {
// 切换到主线程显示结果
Handler().post {
showText(data)
// 请求下一条数据
Thread() {
fetchDataFromWork2(
// 切换到主线程显示第二条数
)
}.start()
}
}
override fun onFail(error: String) {
// 切换到主线程显示异常
Handler().post {
showText(error)
}
}
})
}.start()
协程的方式:
GlobalScope.launch(Dispatchers.Main) {
val data: String = fetchDataFromWork("www.ddup.com")
showText(data)
val data2: String = fetchDataFromWork2("www.ddup.com")
showText(data2)
}
看到没有,协程只需要在后面添加请求第二条数据的方法,接着显示出来就行,相比之前的方式,不仅不会陷入嵌套回调地狱,而且让我写异步方法如同写同步方法一样。
回到一开始的协程是什么的问题,从上面的例子可以看出,协程本质上是一个轻量级的线程,协程内部帮我们自动切换线程,是用来帮我们简化异步执行代码,解决嵌套回调地狱的问题一种机制。
二、Jetpack是什么?
前面大概讲了下协程是什么,接下来说说Jetpack是什么,然后将协程和Jetpack结合起来使用。
1.Jetpack官方架构图
如上图所示,Jetpack是一套组件库的框架,帮助我们更快的开发app,减少模板代码,专注我们的业务开发。我们的app基于此Jetpack开发,activity/fragment主要用来显示UI的容器,ViewModel来管理我们的可观察的LiveData数据,通过数据仓库层Respository来管理远程服务端数据和本地可持续数据。
2.常用组件介绍
了解完大概的框架,我们来熟悉一下常见的组件。
DataBinding
DataBinding,使用过MVVM框架开发的人,应该知道是一个数据动态绑定框架,可双向绑定,看下最常见的用法。
使用之前先要将DataBinding启用,在app/build.gradle增加如下代码:
android {
...
dataBinding {
enabled = true
}
}
在使用到DataBinding布局中增加layout布局
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="android.view.View" />
<import type="com.gxp.ddup_douban.bean.TopicBean.IssueListBean.ItemListBean" />
<variable
name="videoDataBean"
type="ItemListBean" />
</data>
...
</layout>
其中data下面的type是我们引入的数据类型,控件可以通过@{}使用:
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="250dp">
<ImageView
android:id="@+id/iv_photo"
android:layout_width="match_parent"
android:layout_height="200dp"
android:layout_alignParentTop="true"
app:imgUrl="@{videoDataBean.data.cover.feed}" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="50dp"
android:layout_below="@id/iv_photo"
android:orientation="horizontal">
<ImageView
android:id="@+id/iv_user"
android:layout_width="50dp"
android:layout_height="50dp"
android:layout_gravity="center"
android:padding="10dp"
android:visibility="@{videoDataBean.data.author !=null ? View.VISIBLE : View.GONE}"
app:imgUrl="@{videoDataBean.data.author.icon}" />
<RelativeLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="15dp">
<TextView
android:id="@+id/tv_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:maxLines="1"
android:padding="5dp"
android:text="@{videoDataBean.data.title}"
android:textSize="15sp" />
<TextView
android:id="@+id/tv_detail"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:padding="5dp"
android:text='@{@string/topic_bean_publish+videoDataBean.data.category}' />
</RelativeLayout>
</LinearLayout>
</RelativeLayout>
其中app:imgUrl需要我们定义一个@BindingAdapter注解来实现url变化后,控件里面的图片跟着变化
companion object {
@BindingAdapter("app:imgUrl")
@JvmStatic
fun load(view: ImageView, url: String) {
Glide.with(view.context).load(url).into(view)
}
}
下面是如何设置数据
val mainBindingUtil =
DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main);
mainBindingUtil.lifecycleOwner = this;
mainBindingUtil.itembean = TopicBean.IssueListBean.ItemListBean()
以上是DataBinding基本用法,还有事件绑定、双向绑定这里就不介绍了。
Navigation
Navigation顾名思义导航,主要用来管理fragment,控制fragment之间跳转的框架。
首先引入依赖
implementation "androidx.navigation:navigation-fragment-ktx:2.2.0"
implementation "androidx.navigation:navigation-ui-ktx:2.2.0"
在res目录下创建navigation文件夹,新建nav_demo导航文件
编写fragment之间跳转的代码
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_demo"
app:startDestination="@id/homeFragment">
<fragment
android:id="@+id/homeFragment"
android:name="com.example.jetpacklearn.HomeFragment"
android:label="fragment_home"
tools:layout="@layout/fragment_home">
<action
android:id="@+id/action_homeFragment_to_secondFragment"
app:destination="@id/secondFragment"
app:enterAnim="@anim/in_from_right"
app:exitAnim="@anim/out_to_left" />
</fragment>
<fragment
android:id="@+id/secondFragment"
android:name="com.example.jetpacklearn.SecondFragment"
android:label="fragment_second"
tools:layout="@layout/fragment_second">
<action
android:id="@+id/action_secondFragment_to_thirdFragment"
app:destination="@id/thirdFragment"
app:enterAnim="@anim/in_from_right"
app:exitAnim="@anim/out_to_left" />
</fragment>
<fragment
android:id="@+id/thirdFragment"
android:name="com.example.jetpacklearn.ThirdFragment"
android:label="fragment_third"
app:enterAnim="@anim/in_from_right"
app:exitAnim="@anim/out_to_left"
tools:layout="@layout/fragment_third" />
</navigation>
其中app:startDestination="@id/homeFragment"代表根Fragment,<fragment标签里面的id,跳转到指定fragment需要用到,<action标签里面的id用于执行跳转动作,destination是跳转目标的fragment。
我们也可以用可视化UI来编辑
使用之前创建的navi_demo.xml导航文件
<fragment
android:id="@+id/demo_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navGraph="@navigation/nav_demo" />
app:navGraph="@navigation/nav_demo"引用我们之前创建fragment跳转依赖,我们也可以用代码来执行指定的跳转动作
// 这个id就是navigation里的action的id
Navigation.findNavController(view).navigate(R.id.action_secondFragment_to_thirdFragment)
// 回退到上一个页面
Navigation.findNavController(view).popBackStack()
以上就是Navigation基本用法,想了解更多可以看下这篇文章Android开发 navigation入门详解。
Lifecycle
Lifecycle生命周期感知组件,用来监听activity、fragment生命周期事件来执行相应的操作。我们平常在开发中,应该会经常遇到在activity、fragment onStart来执行开启的操作,onStop来执行对应的取消或关闭的操作。
我们先看看原来是怎么做的
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
myLocationListener = MyLocationListener(context = this,callback = object :Callback{
override fun updateUI() {
// 更新UI操作
}
})
class MyLocationListener : LocationListener {
constructor(context: Context, callback: Callback) {
// ...
}
override fun onStart() {
print("onStart")
}
override fun onStop() {
print("onStop")
}
}
override fun onStart() {
super.onStart()
// 开启定位服务
myLocationListener?.onStart()
}
override fun onStop() {
super.onStop()
// 结束定位服务
myLocationListener?.onStop()
}
interface LocationListener {
fun onStart()
fun onStop()
}
}
这是我们常见的一种写法,当我们的组件越来越多,在activity生命周期里面的方法会变得越来越多,会变得很臃肿、难以维护,于是Lifecycle应运而生,用来维护activity、fragment的生命周期,使得每个类都拥有生命周期。
下面改造一下,看下Lifecycle实现方式
class MyLocationListener : LifecycleObserver {
constructor(context: Context, callback: Callback) {
// ...
}
@OnLifecycleEvent(Lifecycle.Event.ON_START)
fun onStart(){
print("onStart")
}
@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
fun onStop(){
print("onStop")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
myLocationListener = MyLocationListener(context = this,callback = object :Callback{
override fun updateUI() {
// 更新UI操作
}
})
lifecycle.addObserver(myLocationListener!!)
}
override fun onDestroy() {
super.onDestroy()
myLocationListener?.let { lifecycle.removeObserver(it) }
}
我们只需要在需要用到activity、fragment生命周期的地方,实现LifecycleObserver接口,并在对应的event注解下实现相应的启动或停止,然后在activity、fragment生命周期下相应添加、移除观察对象,相比之前的方式是不是很简单,不需要我们自己在activity、fragment生命周期下添加对应的接口方法,只需要关注我们业务本身,而且在不需要的时候直接移除观察对象,做一些内存回收操作。
LiveData
LiveData可观察的数据类,相比其他的观察类来说,具有生命周期感知能力,而且仅更新处于活跃生命周期状态的应用组件观察者。
下面来说说它的用法,说LiveData之前,对比一下MutableLiveData,他们两个最大的区别:LiveData在实体类里可以通知指定某个字段的数据更新,而MutableLiveData则是完全是整个实体类或者数据类型变化后才通知.不会细节到某个字段。
来看代码
class DemoData : LiveData<DemoData?>() {
private var age = 0
private var name: String? = null
fun getAge(): Int {
return age
}
fun setAge(age: Int) {
this.age = age
postValue(this)
}
fun getName(): String? {
return name
}
fun setName(name: String?) {
this.name = name
postValue(this)
}
}
class DemoMutableData<T> : LiveData<T?>() {
override fun postValue(value: T?) {
super.postValue(value)
}
override fun setValue(value: T?) {
super.setValue(value)
}
}
既然是数据类,最重要是set、get两个方法,对应LiveData最重要的是通知数据更新即postValue和setValue,前者可以在子线程调用,后者只能在主线程调用。
我们下面看一下如何使用,我们先要创建一个ViewModel用来管理LiveData
class DemoViewModel : ViewModel() {
private val mDemoData = DemoData()
fun getDemoData(): DemoData {
return mDemoData
}
}
在activity、fragment调用
private var demoViewModel: DemoViewModel? = null
override fun initView() {
demoViewModel = ViewModelProvider(this).get(DemoViewModel::class.java)
demoViewModel!!.getDemoData().observe(this, Observer {
print("数据更新了:" + (it?.getName()));
})
tv_my_content.setOnClickListener {
demoViewModel!!.getDemoData().setName("ddup")
}
}
简单说一下上面的代码,先创建一个观察对象,如果数据更新的话会打印数据更新了: xxx,点击按钮更新数据,这里要说明一点的是ViewModelProvider当前如果是fragment的话,数据依赖当前fragment,如果是activity的话,可以在多个fragment里面使用,对应的生命周期依赖当前activity。MutableLiveData对应的使用在这里就不介绍了,感兴趣的可以看下这篇文章Android开发 LiveData与MutableLiveData详解,更详细的使用方法可以看官方LiveData介绍。
ViewModel
上面讲LiveData的使用已经提到了ViewModel的使用,它主要就是用来管理LiveData的,注重生命周期的方式来管理和存储数据,并且在屏幕旋转等配置更改后保存(原理是ViewModel在Activity重新创建时,会在onCreate时判断当前是否由屏幕旋转引起,如果屏幕引起的不在重新创建实例)。这里我就不在重复写它的使用方法了。
Paging
字面的意思是页面,我们平时平常最常见的列表页面,上拉加载、下拉刷新这类的控件,在官方没有推出Paging之前,我们都是基于Recyclerview、Listview之上封装实现的,Paging相比这些可以实现无限滚动的效果,类似一次完全加载完数据的效果。类似下面的效果(图片来自Android官方架构组件Paging:分页库的设计美学)
在介绍Paging的使用之前,我简单介绍一下Paging一些基本概念
DataSource
DataSource<Key,Value>数据源相关的类,其中Key对应加载数据的条件信息,Value对应返回结果, 针对不同场景,Paging提供了三种Datasource:
- PageKeyedDataSource<Key, Value> :适用于目标数据根据页信息请求数据的场景,即Key 字段是页相关的信息。比如请求的数据的参数中包含类似next/previous的信息。
- ItemKeyedDataSource<Key, Value> :适用于目标数据的加载依赖特定item的信息, 即Key字段包含的是Item中的信息,比如需要根据第N项的信息加载第N+1项的数据,传参中需要传入第N项的ID时,该场景多出现于论坛类应用评论信息的请求。
- PositionalDataSource:适用于目标数据总数固定,通过特定的位置加载数据,这里Key是Integer类型的位置信息,T即Value。 比如从数据库中的1200条开始加在20条数据。
PageList
将Datasource比作抽水泵,那PagedList就像是一个蓄水池,但不仅仅如此。PagedList是List的子类,支持所有List的操作, 除此之外它主要有五个成员:
- mMainThreadExecutor: 一个主线程的Excutor, 用于将结果post到主线程。
- mBackgroundThreadExecutor: 后台线程的Excutor。
- BoundaryCallback:加载Datasource中的数据加载到边界时的回调。
- Config: 配置PagedList从Datasource加载数据的方式, 其中包含以下属性:
. pageSize:设置每页加载的数量 prefetchDistance:预加载的数量
. initialLoadSizeHint:初始化数据时加载的数量
. enablePlaceholders:当item为null是否使用PlaceHolder展示 - PagedStorage: 用于存储加载到的数据,它是真正的蓄水池所在,它包含一个ArrayList<List> 对象mPages,按页存储数据。
PageListAdapter
PagedListAdapte是RecyclerView.Adapter的实现,用于展示PagedList的数据。它本身实现的更多是Adapter的功能,但是它有一个小伙伴PagedListAdapterHelper, PagedListAdapterHelper会负责监听PagedList的更新, Item数量的统计等功能。这样当PagedList中新一页的数据加载完成时, PagedAdapte就会发出加载完成的信号,通知RecyclerView刷新,这样就省略了每次loading后手动调一次notifyDataChanged().
在介绍了相关概念后,结合我们的需求来选择合适的DataSource,首先说下我们的需求,我在github下载了一个开眼视频的项目,首页列表Api,第一页数据和下一页数据使用不同的请求接口,下一页请求依赖上一页的请求url的日期,结合上面所说,我们选用PageKeyedDataSource自定义DataSource。
下面引入Paging相关依赖包(这里使用的Paging2版本,最新的是Paging3):
// paging相关依赖
implementation "androidx.paging:paging-runtime:2.1.2"
// 下拉刷新依赖
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
由于用到了下拉刷新功能,所以加入了SwipeRefreshLayout相关依赖。
1.创建数据源
class TopicRepository {
/**
* 获取首页数据
*/
suspend fun getTopicData(baseUrl: String): TopicBean? {
return RetrofitClient.getInstance(baseUrl).create(ApiService::class.java)
?.getTopicBean()
}
/**
* 获取首页之后的数据
*/
suspend fun getMoreTopicData(baseUrl: String, data: String): TopicBean? {
return RetrofitClient.getInstance(baseUrl).create(ApiService::class.java)
?.getMoreTopicBean(data, "2")
}
}
2.自定义DataSource
class TopicDataSource(
private val coroutineScope: CoroutineScope,
private val repository: TopicRepository
) : PageKeyedDataSource<String, TopicBean.IssueListBean.ItemListBean>() {
override fun loadInitial(
params: LoadInitialParams<String>,
callback: LoadInitialCallback<String, TopicBean.IssueListBean.ItemListBean>
) {
coroutineScope.launch {
try {
val response = repository.getTopicData(Constants.REQUEST_BASE_URL)
val (date, mList) = getVideoData(response)
callback.onResult(mList, null, date)
} catch (e: Exception) {
print(e.message)
}
}
}
override fun loadAfter(
params: LoadParams<String>,
callback: LoadCallback<String, TopicBean.IssueListBean.ItemListBean>
) {
coroutineScope.launch {
try {
val response = repository.getMoreTopicData(Constants.REQUEST_BASE_URL, params.key)
val (date, mList) = getVideoData(response)
callback.onResult(mList, date)
} catch (e: Exception) {
print(e.message)
}
}
}
...
}
主要看这loadInitial、loadAfter两个方法一个是刚开始加载第一个页面的请求,另一个是加载下一页的数据。
3.创建PageAdapter
class TopicAdapter :
PagedListAdapter<TopicBean.IssueListBean.ItemListBean, TopicViewHolder>(diffCallback) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TopicViewHolder {
return TopicViewHolder(
DataBindingUtil.inflate(
LayoutInflater.from(parent.context),
R.layout.item_topic,
parent,
false
)
)
}
override fun onBindViewHolder(holder: TopicViewHolder, position: Int) {
val binding: ViewDataBinding = holder.dataBinding
binding.setVariable(BR.videoDataBean, getItem(position))
binding.executePendingBindings()
}
companion object {
private val diffCallback =
object : DiffUtil.ItemCallback<TopicBean.IssueListBean.ItemListBean>() {
override fun areItemsTheSame(
oldItem: TopicBean.IssueListBean.ItemListBean,
newItem: TopicBean.IssueListBean.ItemListBean
): Boolean =
oldItem.data == newItem.data
override fun areContentsTheSame(
oldItem: TopicBean.IssueListBean.ItemListBean,
newItem: TopicBean.IssueListBean.ItemListBean
): Boolean =
oldItem.data?.id == newItem.data?.id
}
}
}
这里主要看一下diffcallback根据里面的值判断是否需要更新页面
4.使用
val topicAdapter = TopicAdapter()
rv_home.adapter = topicAdapter
rv_home.layoutManager = LinearLayoutManager(context)
homeViewModel = ViewModelProvider(this).get(TopicViewModel::class.java)
homeViewModel.topicPagedList.observe(
this, Observer {
if (mIsRefresh) {
mIsRefresh = false;
refresh_layout_home.isRefreshing = false;
}
topicAdapter.submitList(it)
Log.e(TAG, it.size.toString());
})
refresh_layout_home.setOnRefreshListener {
if (!mIsRefresh) {
mIsRefresh = true
homeViewModel.invalidateDataSource()
}
}
5.效果
三、结合使用
至此,kotlin协程+Jetpack相关基础知识介绍完毕,正如一开始所说的一样,本文不会简单写个demo,切合实际开发项目使用。
1.协程的进阶使用
前面简单的介绍了协程的基本使用,但在Jetpack项目中使用,实际用到的是viewModelScope,它是ViewModel扩展属性,在ViewModel onCleared 会自动取消协程任务,避免内存泄漏。
viewModelScope.launch {
try {
val apiService =
RetrofitClient.getInstance(context, baseUrl).create(ApiService::class.java)
if (apiService != null) {
val topicBean = apiService.getTopicBean();
topicLiveData.value = topicBean
}
} catch (e: Exception) {
}
}
我们可以看出上面出现异常情况,会直接捕获异常,不够优雅,也不便于定位问题,下面增加扩展方法封装。
fun ViewModel.launch(
block: suspend CoroutineScope.() -> Unit,
onError: (e: Throwable) -> Unit = {},
onComplete: () -> Unit = {}
) {
viewModelScope.launch(CoroutineExceptionHandler { _, e -> onError(e) }) {
try {
block.invoke(this)
} finally {
onComplete()
}
}
}
改造后的使用
launch(
{
topicLiveData.value = repository.getTopicData(Constants.REQUEST_BASE_URL)
},
{
Log.e(TAG, "getTopicBean error->" + it.message)
},
{
Log.e(TAG, "getTopicBean onComplete...")
}
)
2.工程架构图
看一个项目最重要的是参考架构图,这里我分享一下,我目前工程的架构图
3.项目地址
目前项目只搭了个架子,后续会完成整个项目,现有问题下拉刷新会闪烁,目前还没找到解决办法,知道的可以告知一下。
项目github地址
如果觉得有用的麻烦给个star,谢谢。
总结
这篇文章前前后后,花了几天时间才写完,在一开始计划写的时候,没觉得那么费时间,越写越发现,从输入到输出的过程中,相差还是蛮大的,在写demo的时候,特别Paging,看了很多篇博客,都没有太懂,最好在不断的尝试下才弄好,其实这里面每个知识点都可以写一篇文章,但是作为入门的项目,主要还是给大家一个初步的印象,一个实际且完整的例子,最终还是需要自己敲代码一步步实现,最好结合自己的项目来做。
第一次用markdown写博客,不知道效果咋样。
最好,创作不易,觉得不错的话,请点赞、评论鼓励,谢谢。