大家好,我是考100分的小小码 ,祝大家学习进步,加薪顺利呀。今天说一说Android技术栈(四)Android Jetpack MVVM 完全实践,希望您对编程的造诣更进一步.
1 MVVM总览
本文包含Android
中MVVM
体系中的很多部分,主要对ViewModel
+DataBinding
+RxJava
+LiveData
+Lifecycle
等笔者所使用的技术体系进行解析.
本文字数较多,内容较为完整并且后续还会追加更新,阅读本篇文章需要较长时间,建议读者分段阅读.
所有文字均为个人学习总结和理解,仅供参考,如有纰漏还请指出,笔者不胜感激.
1.1 配置环境
- 笔者的
Android Studio
版本=3.2
Jetpack
最低兼容到Android
=2.1
,API
=7
1.2 为什么要选择MVVM?
要回答这个问题首先就要介绍MVC
与MVP
这两种模式,从MVC
到MVVM
其实大家想的都是怎么把Model
和View
尽可能的拆开(熟悉三者定义的朋友可以跳过该节).
1.2.1 MVC
MVC
(Model
–View
–Controller
)即传统Android
开发中最常用的模式:
- 通常使用
Activity
/Fragment
作为Controller
层, - 以
android.view.View
的子类以xml
构建文件构建起的布局
作为View
层 - 以
SQLite
数据库,网络请求作为Model
层.
但由于Activity
/Fragment
的功能过于强大
并且实际上包含了部分View
层功能,导致最后Activity
/Fragment
既承担了View
的责任,又承担了Controller
的责任.所以一般较复杂的页面,Activity
/Fragment
很容易堆积代码,最终导致Controller
混杂了View
层和业务逻辑(也就是你们所知道的一个Activity
三千行)
在MVC
中View
层与Model
几乎几乎完全没有隔离,View
层可以直接操作Model
层,Model
层的回调
里也可能会直接给View
赋值.Controller
的概念被弱化,最后只剩下MV
没有C
了.
这也将导致但你想把某个界面上的元素进行更新时,他会牵扯到一堆跟Model
层相关的代码,这个问题在你变更Model
层的时候同样也会出现,这个问题其实是没有很好的将逻辑分层导致的.
1.2.2 MVP
MVP
(Model
–View
–Presenter
)架构设计,是当下最流行的开发模式,目前主要以Google
推出的TodoMVP
为主,MVP
不是一种框架,它实际上更类似一种分层思想
,一种接口约定
,具体体现在下面:
- 定义
IView
接口,并且在接口中约定View
层的各种操作,使用android.view.View
的子类以xml
构建文件构建起的布局
和Activity
/Fragment
作为布局控制器,实现IView
这个View
层的接口,View
层的实际实现类保留一个IPresenter
接口的实例. - 定义
IPresenter
接口,并且在接口中约定Presenter
层的各种操作.可以使用一个与View
无关的类实现它,一般是XxxPresenterImpl
.通常情况下Presenter
层会包含Model
层的引用和一个IView
接口的引用,但不应该直接或者间接引用View
层android.view.View
的子类,甚至是操作的参数中也最好不要有android.view.View
的子类传进来,因为它应该只负责业务逻辑和数据的处理并通过统一的接口IView
传递到View
层. - 不需要为
Model
层定义一个IModel
的接口,这一层是改造最小的.以前该怎么来现在也差不多该怎么来.但是现在Presenter
把它和View
隔开了,Presenter
就可以作为一段独立的逻辑被复用.
MVP
模式解决了MVC
中存在的分层问题,Presenter
层被突出强调,实际上也就是真正意义上实现了的MVC
但是MVP
中其实仍然存在一些问题,比如当业务逻辑变得复杂以后,IPresenter
和IView
层的操作数量可能将会成对的爆炸式增长,新增一个业务逻辑,可能要在两边增加数个通信接口,这种感觉很蠢.
并且,我们要知道一个Presenter
是要带一个IView
的,当一个Presenter
需要被复用时,对应的View
就要去实现所有这些操作,但往往一些操作不是必须实现的,这样会留下一堆TODO
,很难看.
1.2.3 MVVM
MVVM
(Model
–View
–ViewModel
)由MVP
模式演变而来,它由View
层,DataBinding
,ViewModel
层,Model
层构成,是MVP
的升级版并由Google
的Jetpack
工具包提供框架支持:
View
层包含布局,以及布局生命周期控制器(Activity
/Fragment
)DataBinding
用来实现View
层与ViewModel
数据的双向绑定(但实际上在Android Jetpack
中DataBinding
只存在于布局和布局生命周期控制器之间,当数据变化绑定到布局生命周期控制器时再转发给ViewModel
,布局控制器可以持有DataBinding
但ViewModel
不应该持有DataBinding
)ViewModel
与Presenter
大致相同,都是负责处理数据和实现业务逻辑,但是ViewModel
层不应该直接或者间接地持有View
层的任何引用,因为一个ViewModel
不应该直达自己具体是和哪一个View
进行交互的.ViewModel
主要的工作就是将Model
提供来的数据直接翻译成View
层能够直接使用的数据,并将这些数据暴露出去,同时ViewModel
也可以发布事件,供View
层订阅.Model
层与MVP
中一致.
MVVM
的核心思想是观察者模式,它通过事件
和转移View
层数据持有权
来实现View
层与ViewModel
层的解耦.
在MVVM
中View
不是数据的实际持有者,它只负责数据如何呈现以及点击事件的传递,不做的数据处理工作,而数据的处理者和持有者变成ViewModel
,它通过接收View
层传递过来的时间改变自身状态,发出事件或者改变自己持有的数据触发View
的更新.
MVVM
解决了MVP
中的存在的一些问题,比如它无需定义接口,ViewModel
与View
层彻底无关更好复用,并且有Google
的Android Jetpack
作为强力后援.
但是MVVM
也有自己的缺点,那就是使用MVVM
的情况下ViewModel
与View
层的通信变得更加困难了,所以在一些极其简单
的页面中请酌情
使用,否则就会有一种脱裤子放屁的感觉,在使用MVP
这个道理也依然适用.
2 DataBinding
2.1 坑
要用一个框架那么就要先说它的坑
点.那就是不建议在使用DataBinding
的模块同时使用apply plugin: 'kotlin-kapt'
.
因为现在kapt
还有很多Bug
,使用kapt
时,在Windows
下DataBinding
格式下的xml
中如果包含有中文,会报UTF-8
相关的错误.
笔者一开始猜想这是由于JVM
启动参数没有设置成-Dfile.encoding=UTF-8
导致的,在gradle.properties
中改过了,无果,Stack Overflow
搜过了,没找到,如果有大佬知道怎么解决,还请指点一二
如果你在模块中同时使用kotlin
和DataBinding
是可以的,但是请一定不要使用kapt
,除非JB
那帮大佬搞定这些奇怪的问题.
这就意味这你所有的kotlin
代码都不能依赖注解处理器来为你的代码提供附加功能,但是你可以把这些代码换成等价的Java
实现,它们可以工作得很好.
2.2 DataBinding的兼容性
先说一点,DataBinding
风格的xml
会有”奇怪“的东西入侵Android
原生的xml
格式,这种格式LayoutInfalter
是无法理解,但是,当你对这些奇怪的xml
使用LayoutInfalter#inflate
时亦不会报错,并且布局也正常加载了,这是为什么呢?
这是因为在打包时,Gradle
通过APT
把你的DataBinding
风格的xml
全部翻译了一遍,让LayoutInfalter
能读懂他们,正是因为这个兼容的实现,而使得我们可以在使用和不使用DataBinding
间自由的切换.
2.3 DataBinding风格的XML
要想使用DataBinding
,先在模块的build.gradle
中添加
android{
//省略...
dataBinding {
enabled = true
}
}
来启用DataBinding
支持.
DataBinding
不需要额外的类库支持,它被附加在你的android
插件中,它的版本号与你的android
插件版本一致.
classpath 'com.android.tools.build:gradle:3.3.2'
在DataBinding
风格的xml
中,最外层必须是layout
标签,并且不支持merge
标签,编写xml
就像下面这样
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="text"
type="String"/>
<variable
name="action"
type="android.view.View.OnClickListener"/>
</data>
<TextView
android:onClick="@{action}"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<layout/>
2.3.1 变量领域
data
标签包裹的是变量领域,在这里你可以使用variable
定义这个布局所要绑定的变量类型,使用name
来指定变量名,然后用type
来指定其类型.
如果一些类型比较长,而且由需要经常使用你可以像Java
一样使用import
导入他们(java.lang.*
会被默认导入),然后就不用写出完全限定名了,就像这样
<import
type="android.view.View"
alias="Action"/>
<variable
name="action"
type="Action"/>
有必要时(比如名字冲突),你还可以用Action
为一个类型指定一个别名,这样你就能在下文中使用这个别名.
2.3.2 转义字符
熟悉xml
的同学可能都知道<
和>
在xml
中是非法字符,那么要使用泛型的时候,我们就需要使用xml
中的转义字符<
和>
来进行转义
//↓错误,编译时会报错×
<variable
name="list"
type="java.util.List<String>"/>
//↓正确,可以通过编译√
<variable
name="list"
type="java.util.List<String>"/>
data
标签结束后就是原本的布局编写的位置了,这部分基本和以前差不多,只是加入了DataBinding
表达式
<data>
//......
<data/>
<TextView
android:onClick="@{action}"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
2.3.3 DataBinding表达式
以@{}
包裹的位置被称为DataBinding
表达式,DataBinding
表达式几乎支持Java
所有的运算符,并且增加了一些额外的操作,这允许我们在xml
中有一定的Java
编程体验,学过Java web
的同学可能会觉得它很像JSP
:
- 不需要
xml
转义的二元运算+
,-
,/
,*
,%
,||
,|
,^
,==
- 需要
xml
转义的二元运算&&
,>>
>>>
,<<
,>
,<
,>=
,<=
,与泛型一样运算符>=
,>
,<
,<=
等,也是需要转义的,&
需要用&
转义,这确实有些蹩脚,但这是xml
的局限性,我们无法避免,所以在DataBinding
风格的xml
中应该尽可能的少用这些符号. lambda
表达式@{()->persenter.doSomething()}
- 三元运算
?:
null
合并运算符??
,若左边不为空则选择左边,否则选择右边
android:text="@{nullableString??`This a string`}"
- 自动导入的
context
变量,你可以在xml
中的任意表达式使用context
这个变量,该Context
是从该布局的根View
的getContext
获取的,如果你设置了自己的context
变量,那么将会覆盖掉它 - 若表达式中有字符串文本
xml
需要特殊处理
用单引号包围外围,表达式使用双引号
android:text='@{"This a string"}'
或者使用`包围字符串,对,就Esc下面那个键的符号
android:text="@{`This a string`}"
- 判断类型
instanceof
- 括号
()
- 空值
null
- 方法调用,字段访问,以及
Getter
和Setter
的简写,比如User#getName
和User#setName
现在都可以直接写成@{user.name}
,这种表达式也是最简单的表达式,属于直接赋值表达式 - 默认值
default
,在xml
中
`android:text="@{file.name, default=`no name`}"`
- 下标
[]
,不只是数组,List
,SparseArray
,Map
现在都可以使用该运算符 - 使用
@
读取资源文件,如下,但是不支持读取mipmap
下的文件
android:text="@{@string/text}"
//或者把它作为表达式的一部分
android:padding="@{large? @dimen/large : @dimen/small}"
有一些资源需要显示引用
类型 | 正常情况 | DataBinding表达式引用 |
---|---|---|
String[] | @array | @stringArray |
int[] | @array | @intArray |
TypedArray | @array | @typedArray |
ColorStateList | @animator | @stateListAnimator |
StateListAnimator | @color | @colorStateList |
还有一些操作是DataBinding
表达式中没有的,我们无法使用它们:
- 没有
this
- 没有
super
- 不能创建对象
new
- 不能使用泛型方法的显示调用
Collections.<String>emptyList()
编写简单的DataBinding
表达式,就像下面这样
<data>
<improt type="android.view.View"/>
<variable
name="isShow"
type="Boolean"/>
<data/>
<TextView
android:visibility="@{isShow?View.VISIBLE:View.GONE}"
android:text="@{@string/text}"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
应该避免出现较为复杂的DataBinding
表达式,以全部都是直接赋值表达式为佳,数据的处理应该交给布局控制器或者ViewModel
来做,布局应该只负责渲染数据.
2.3.4 使用在Java中生成的ViewDataBinding
使用DataBinding
后Android Studio
会为每个xml
布局生成一个继承自ViewDataBinding
的子类型,来帮助我们将xml
文件中定义的绑定关系映射到Java
中.
比如,如果你有一个R.layout.fragment_main
的布局文件,那么他就会为你在当前包下生成一个,FragmentMainBinding
的ViewDataBinding
.
在Java
实化DataBinding
风格xml
布局与传统方式有所不同.
- 在
Actvity
中
private ActivityHostBinding mBinding;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
mBinding = DataBindingUtil.setContentView(this, R.layout.activity_host);
}
- 在自定义
View
和Fragment
中
private FragmentMainBinding mBinding;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater,
@Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
mBinding = DataBindingUtil.inflate(inflater,
R.layout.fragment_main,
container,
false);
return mBinding.getRoot();
}
- 在已经使用普通
LayoutInfalter
实例化的View
上(xml
必须是DataBinding
风格的,普通LayoutInflater
实例化布局时不会触发任何绑定机制,DataBindingUtil#bind
才会发生绑定)
View view = LayoutInflater.from(context).inflate(R.layout.item_view,null,false);
ItemViewBinding binding = DataBindingUtil.bind(view);
你在xml
设置的变量他会在这个类中为你生成对应的Getter
和Setter
.你可以调用它们给界面赋值,比如之前的我们定义的action
.
//这里的代码是Java8的lambda
mBinding.setAction(v->{
//TODO
})
2.3.5 使用BR文件
它还会为你生成一个类似R
的BR
文件,里面包含了你在DataBinding
风格xml
中定义的所有变量名的引用(由于使用的是APT
生成,有时候需要Rebuild Project
才能刷新),比如我们之前的action
,它会为我们生成BR.action
,我们可以这么使用它
mBinding.setVariable(BR.action,new View.OnClickListener(){
@Override
void onClick(View v){
//TODO
}
})
2.3.6 传递复杂对象
在之前给xml
中的变量中赋值时,我们用的都是一些类似String
的简单对象,其实我们也可以定义一些复杂的对象,一次性传递到xml
布局中
//java
public class File
{
public File(String name,
String size,
String path)
{
this.name = name;
this.size = size;
this.path = path;
}
public final String name;
public final String size;
public final String path;
}
//xml
<data>
<variable
name="file"
type="org.kexie.android.sample.bean.File"/>
<data/>
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<TextView
android:text="@{file.name}"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<TextView
android:text="@{file.size}"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<TextView
android:text="@{file.path}"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<LinearLayout/>
个人认为绑定到xml
中的数据最好是不可变的,所以上面的字段中我使用了final
,但这不是必须的,根据你自己的需求来进行定制
2.3.7 绑定并非立即发生
这里有一点值得注意的是,你给ViewDataBinding
的赋值并不是马上生效的,而是在当前方法执行完毕回到事件循环后,并保证在下一帧渲染之前得到执行,如果需要立即执行,请调用ViewDataBinding#executePendingBindings
2.3.8 使用android:id
如果你使用了android:id
,那么这个View
就也可以当成一个变量在下文的DataBinding
表达式中使用,就像写Java
.它还会帮你View
绑定到ViewDataBinding
中,你可以这么使用它们
//xml
<TextView
android:id="@+id/my_text"
android:layout_width="match_parent"
android:layout_height="wrap_context"/>
<TextView
android:id="@+id/my_text2"
android:text="@{my_text.getText()}"
android:layout_width="match_parent"
android:layout_height="wrap_context"/>
//在java中my_text被去掉下划线,更符合java的命名习惯
mBinding.myText.setText("This is a new text");
用过ButterKnife的同学可能都知道,ButterKnife
出过一次与gradle
版本不兼容的事故,但是DataBinding
是与gradle
打包在一起发布的,一般不会出现这种问题,如果你不想用ButterKnife
但有不想让DataBinding
的风格的写法入侵你的xml
太狠的话,只使用android:id
将会是一个不错的选择.
2.4 正向绑定
某些第三方View
是肯定没有适配DataBinding
的,业界虽然一直说MVVM
好,但现在MVP
的开发方式毕竟还是主流,虽然这种情况我们可以用android:id
,然后在Activity
/Fragment
中解决,但有时候我们想直接在xml
中配置,以消除一些样板代码,这时候就需要自定义正向绑定.
2.4.1 自定义正向绑定适配器
我们可以使用@BindingAdapter
自定义在xml
中可使用的View
属性,名字空间是不需要的,加了反而还会给你警告.
@Target(ElementType.METHOD)
public @interface BindingAdapter {
/**
* 与此绑定适配器关联的属性。
*/
String[] value();
/**
* 是否必须为每个属性分配绑定表达式,或者是否可以不分配某些属性。
* 如果为false,则当至少一个关联属性具有绑定表达式时,将调用BindingaAapter。
*/
boolean requireAll() default true;
}
//@BindingAdapter需要一个静态方法,该方法的第一个参数是与该适配器兼容的View类型
//从第二个参数开始,依次是你自定义的属性传进来的值.
//使用requireAll来指定这些属性是全部需要,还是只要一个就可以
//如果requireAll = false,触发适配器绑定时,没有被设置的属性将获得该类型的默认值
//框架优先使用自定义的适配器处理绑定
@BindingAdapter(value = {"load_async", "error_handler"},requireAll = true)
public static void loadImage(ImageView view, String url, String error) {
Glide.with(view)
.load(url)
.error(Glide.with(view).load(error))
.into(view);
}
//在xml中使用它(下面那两个网址都不是实际存在的)
<ImageView
load_async="@{`http://android.kexie.org/image.png`}"
error_handler="@{`http://android.kexie.org/error.png`}"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
2.4.2 第三方View适配
DataBinding
风格的xml
还能在一定程度上适配第三方View
//如果你的自定义View中有这么一个Setter↓
public class RoundCornerImageView extends AppCompatImageView{
//......
public void setRadiusDp(float dp){
//TODO
}
}
//那么你可以在xml中使用radiusDp来使用它
<org.kexie.android.ftper.widget.RoundCornerImageView
radiusDp="@{100}"
android:id="@+id/progress"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:scaleType="centerCrop"
android:src="@drawable/progress"/>
//它会自己为你去找名称为setRadiusDp并且能接受100为参数的方法.
2.4.3 xml中的属性重定向
使用@BindingMethod
来将xml
属性重定向:
@Target(ElementType.ANNOTATION_TYPE)
public @interface BindingMethod {
//需要重定向的View类型
Class type();
//需要重定向的属性名
String attribute();
//需要重定向到的方法名
String method();
}
//这是DataBinding源码中,DataBinding对于系统自带的TextView编写的适配器
//这是androidx.databinding.adapters.TextViewBindingAdapter的源码
@BindingMethods({
@BindingMethod(type = TextView.class, attribute = "android:autoLink", method = "setAutoLinkMask"),
@BindingMethod(type = TextView.class, attribute = "android:drawablePadding", method = "setCompoundDrawablePadding"),
@BindingMethod(type = TextView.class, attribute = "android:editorExtras", method = "setInputExtras"),
//......
})
public class TextViewBindingAdapter {
//......
}
//这样就可以建立起xml中属性与View中Setter的联系
2.4.4 添加转换层
使用@BindingConversion
为添加转换层
@BindingConversion
public static ColorDrawable toDrawable(int color) {
return new ColorDrawable(color);
}
//可以把color整形转换为android:src可接受的ColorDrawable类型
//但是转换只适用于直接的赋值
//如果你写了复杂的表达式,比如使用了?:这种三元运算符
//那就照顾不到你了
2.5 反向绑定
有正向绑定就一定有反向绑定,正向绑定和反向绑定一起构成了双向绑定.
在我们之前编写的DataBinding
表达式中,比如TextView
中android:text
之类的属性我们都是直接赋值一个String
过去的,这就是正向绑定,我们给View
的值能够直接反应到View
上,而反向绑定就是View
值的变化和也能反应给我们.
2.5.1 使用双向绑定
所有使用之前所有使用@{}
包裹的都是正向绑定,而双向绑定是@={}
,并且只支持变量,字段,Setter
(比如User#setName
,就写@={user.name}
)的直接编写并且不支持复杂表达式
2.5.2 兼容LiveData与ObservableField
实际上,android:text
不只能接受String
,当使用双向绑定时,它也能接受MutableLiveData<String>
和ObservableField<String>
作为赋值对象,这种赋值会将TextView
的android:text
的变化绑定到LiveData(实际上是MutableLiveData)
或者是ObservableField
上,以便我们在View
的控制层(Activity
/Fragment
)更好地观察他们的变化.
当然除了ObservableField
在androidx.databinding
包下还有不装箱的ObservableInt
,ObservableFloat
等等.
但是为了支持LiveData
我们必须开启第二版的DataBinding APT
.
在你的gradle.properties
添加
android.databinding.enableV2=true
现在我们可以通过LiveData(实际上是MutableLiveData)
将android:text
的变化绑定到Activity
/Fragment
//xml
<data>
<variable
name="liveText"
type="MutableLiveData<String>">
<data/>
<TextView
android:text="@={text}"
android:layout_width="match_parent"
android:layout_height="wrap_context"/>
//然后在Activity/Fragment中
MutableLiveData<String> liveText = new MutableLiveData<String>();
mBinding.setLiveText(liveText);
liveText.observe(this,text->{
//TODO 观察View层变化
});
2.5.3 自定义反向绑定适配器
下面我们回到androidx.databinding.adapters.TextViewBindingAdapter
的源码,继续对自定义反向绑定适配器进行分析.
//我们可以看到源码中使用了@InverseBindingAdapter自定义了一个反向绑定器
//指定了其属性以及相关联的事件
@InverseBindingAdapter(attribute = "android:text", event = "android:textAttrChanged")
public static String getTextString(TextView view) {
return view.getText().toString();
}
//并为这个事件添加了一个可接受InverseBindingListener的属性
//为了说明方便,下面的代码已简化,源码并非如此,但主要逻辑相同
@BindingAdapter(value = {"android:textAttrChanged"})
public static void setTextWatcher(TextView view , InverseBindingListener textAttrChanged){
view.addTextChangedListener(new TextWatcher(){
//......
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
textAttrChanged.onChange();
}
});
}
//至此android:text的反向绑定完成
//当你使用@={}时实际上是用android:textAttrChanged属性向TextView设置了TextWatcher
//传入的InverseBindingListener是反向绑定监听器
//当调用InverseBindingListener的onChange时
//会调用@BindingAdapter所注解的方法将获得数据并写回到变量中.
2.6 配合DataBinding打造通用RecyclerView.Adapter
下面进行一个小小的实战吧,我们可以站在巨人的肩膀上造轮子.
//导入万能适配器作为基类,可以大大丰富我们通用适配器的功能
implementation 'com.github.CymChad:BaseRecyclerViewAdapterHelper:2.9.46'
由于基类很强大所以代码不多:
//X是泛型,可以是你在item中所使用的java bean
public class GenericQuickAdapter<X>
extends BaseQuickAdapter<X, GenericQuickAdapter.GenericViewHolder> {
//BR中的变量名
protected final int mName;
//layoutResId是DataBinding风格的xml
public GenericQuickAdapter(int layoutResId, int name) {
super(layoutResId);
mName = name;
openLoadAnimation();
}
@Override
protected void convert(GenericViewHolder helper, X item) {
//触发DataBinding
helper.getBinding().setVariable(mName, item);
}
public static class GenericViewHolder extends BaseViewHolder {
private ViewDataBinding mBinding;
public GenericViewHolder(View view) {
super(view);
//绑定View获得ViewDataBinding
mBinding = DataBindingUtil.bind(view);
}
@SuppressWarnings("unchecked")
public <T extends ViewDataBinding> T getBinding() {
return (T) mBinding;
}
}
}
//实例化
GenericQuickAdapter<File> adapter = new GenericQuickAdapter<>(R.layout.item_file,BR.file);
//在xml中使用起来就像这样
<layout>
<data>
<variable
name="file"
type="org.kexie.android.sample.bean.File"/>
<data/>
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
<TextView
android:text="@{file.name}"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<TextView
android:text="@{file.size}"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<TextView
android:text="@{file.path}"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<LinearLayout/>
<layout/>
3 Lifecycle
在Android
中,组件的管理组件的生命周期一直是一个比较麻烦的东西,而自Google
推出Android Jetpack
组件包以来,这个问题得到的比较妥善的解决,Lifecycle
组件后来也成为Android Jetpack
的核心。
3.1 导入
以AndroidX
为例,要使用Lifecycle
组件,先在模块的build.gradle
文件中添加依赖:
api 'androidx.lifecycle:lifecycle-extensions:2.1.0-alpha02'
由于Lifecycle
组件由多个包构成,使用api
导入时即可将其依赖的包全部导入该模块,包括common
,livedata
,process
,runtime
,viewmodel
,service
等。
如果要使用Lifecycle
中的注解,你还需要添加如下注解处理器,以便在编译时,完成对相应注解的处理。
annotationProcessor 'androidx.lifecycle:lifecycle-compiler:2.0.0'
对于一个App
来说,使用Lifecycle
组件是没有任何侵入性的,因为他已经天然的融合到Google
的appcompat
库中了,而如今无论是什么应用程序都几乎离不开appcompat
,可以说集成Lifecycle
只是启用了之前没用过的功能罢了。
3.2 LifecycleOwner
LifecycleOwner
是Lifecycle
组件包中的一个接口,所有需要管理生命周期的类型都必须实现这个接口。
public interface LifecycleOwner
{
/**
* Returns the Lifecycle of the provider.
*
* @return The lifecycle of the provider.
*/
@NonNull
Lifecycle getLifecycle();
}
但其实很多时候我们根本无需关心LifecycleOwner
的存在。在Android
中, Fragment
、Activity
、Service
都是具有生命周期的组件,但是Google
已经让他们都实现了LifecycleOwner
这个接口,分别是androdx.fragment.app.Fragment
、AppCompatActivity
、androidx.lifecycle.LifecycleService
.
在项目中,只要继承这些类型,可以轻松的通过LifecycleOwner#getLifecycle()
获取到Lifecycle
实例.这是一种解耦实现,LifecycleOwner
不包含任何有关生命周期管理的逻辑,实际的逻辑都在Lifecycle
实例中,我们可以通过传递Lifecycle
实例而非LifecycleOwner
来防止内存泄漏.
而Lifecycle
这个类的只有这三个方法:
@MainThread
public abstract void removeObserver(@NonNull LifecycleObserver observer);
@MainThread
@NonNull
public abstract State getCurrentState();
@MainThread
public abstract void addObserver(@NonNull LifecycleObserver observer);
getCurrentState()
可以返回当前该LifecycleOwner
的生命周期状态,该状态与LifecycleOwner
上的某些回调事件相关,只会出现以下几种状态,在Java
中以一个枚举类抽象出来定义在Lifecycle
类中。
public enum State
{
DESTROYED,
INITIALIZED,
CREATED,
STARTED,
RESUMED;
}
-
DESTROYED
,在组件的onDestroy
调用前,会变成该状态,变成此状态后将不会再出现任何状态改变,也不会发送任何生命周期事件 -
INITIALIZED
,构造函数执行完成后但onCreate
未执行时为此状态,是最开始时的状态 -
CREATED
,在onCreate
调用之后,以及onStop
调用前会变成此状态 -
STARTED
,在onStart
调用之后,以及onPause
调用前会变成此状态 -
RESUMED
,再onResume
调用之后会变成此状态
addObserver
,此方法可以给LifecycleOwner
添加一个观察者,来接收LifecycleOwner
上的回调事件。回调事件也是一个枚举,定义在Lifecycle
类中:
public enum Event
{
/**
* Constant for onCreate event of the {@link LifecycleOwner}.
*/
ON_CREATE,
/**
* Constant for onStart event of the {@link LifecycleOwner}.
*/
ON_START,
/**
* Constant for onResume event of the {@link LifecycleOwner}.
*/
ON_RESUME,
/**
* Constant for onPause event of the {@link LifecycleOwner}.
*/
ON_PAUSE,
/**
* Constant for onStop event of the {@link LifecycleOwner}.
*/
ON_STOP,
/**
* Constant for onDestroy event of the {@link LifecycleOwner}.
*/
ON_DESTROY,
/**
* An {@link Event Event} constant that can be used to match all events.
*/
ON_ANY
}
每种事件都对应着Fragment
/Activity
中的事件。
3.3 LifecycleObserver
LifecycleObserver
是生命周期的观察者,可能是这个包中我们最常用的接口了.
查看源码得知,他就是一个空接口,不包含任何实现,但是若我们想使用,还是得继承此接口。
public interface LifecycleObserver { }
继承LifecycleObserver
后使用@OnLifecycleEvent
注解(这时之前申明得注解处理器派上了用场),并设置需要监听的生命周期回调事件。
@OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
public void test()
{
///TODO...
}
然后在Activity
/Fragment
中:
getLifecycle().addObserver(yourLifecycleObserver);
即可在运行时收到相应的的回调事件,但是注意添加@OnLifecycleEvent
注解的方法应该是包内访问权限或是public
的,否则可能在编译时会报错,或者收不到回调。
若想在运行时移除LifecycleObserver
,同样也还有Lifecycle#removeObserver
方法。
4 LiveData
LiveData
是对Android
组件生命周期感知的粘性事件
,也就是说,在LiveData
持有数据时,你去订阅它就能收到他最后一次接收到的数据.在实战中,我们能用到的LiveData
一般是它的两个子类MutableLiveData
和MediatorLiveData
.
4.1 LiveData基本使用
我们可以通过LiveData#observe
来观察它所持有的值的变化,还可以通过LiveData#getValue
来直接获取内部保存的值(非线程安全)
//LiveData 一般是用来给ViewModel保存数据的
public class MyViewModel extends ViewModel{
private MutableLiveData<Boolean> mIsLoading = new MutableLiveData<>();
LiveData<Boolean> isLoading(){
return mIsLoading;
}
}
//Activity/Fragment观察ViewModel
mViewModel.isLoading().observe(this, isLoading -> {
//TODO 发生在主线程,触发相关处理逻辑
});
//LiveData是依赖Lifecycle实现的
//传入的this是LifecycleOwner
//LiveData只会通知激活态的(STARTED和RESUMED)的LifecycleOwner
//并且在Activity/Fragment被重建也能重新接收到LiveData保存的数据
//在组件DESTROYED时,LiveData会把它移出观察者列表
//当然你也可以不关联LifecycleOwner,让订阅一直保持.
//需要这样时需要使用observeForever
mViewModel.isLoading().observeForever(isLoading -> {
//TODO
});
//这个订阅永远不会被取消
//除非你显示调用LiveData#removeObserver
4.2 MutableLiveData
顾名思义就是可变的LiveData
,基类LiveData
默认是不可变的,MutableLiveData
开放了能够改变其内部所持有数据的接口.
public class MutableLiveData<T> extends LiveData<T> {
/**
* Creates a MutableLiveData initialized with the given {@code value}.
*
* @param value initial value
*/
public MutableLiveData(T value) {
super(value);
}
/**
* Creates a MutableLiveData with no value assigned to it.
*/
public MutableLiveData() {
super();
}
@Override
public void postValue(T value) {
super.postValue(value);
}
@Override
public void setValue(T value) {
super.setValue(value);
}
}
分别是postValue
和setValue
,其中setValue
内部检查线程是否为主线程,不允许在子线程中使用,用了就报错.postValue
会将值通过主线程的Handler
转发到主线程上.
LiveData
可以有初始值,也可以没有,如果在没有初始值的情况下被订阅,则订阅者不会收到任何的值.
4.3 MediatorLiveData
MediatorLiveData
继承自MutableLiveData
,它主要用来实现多个LiveData
数据源的合并.
public class MediatorLiveData<T> extends MutableLiveData<T> {
private SafeIterableMap<LiveData<?>, Source<?>> mSources = new SafeIterableMap<>();
@MainThread
public <S> void addSource(@NonNull LiveData<S> source, @NonNull Observer<? super S> onChanged) {
Source<S> e = new Source<>(source, onChanged);
Source<?> existing = mSources.putIfAbsent(source, e);
if (existing != null && existing.mObserver != onChanged) {
throw new IllegalArgumentException(
"This source was already added with the different observer");
}
if (existing != null) {
return;
}
if (hasActiveObservers()) {
e.plug();
}
}
@MainThread
public <S> void removeSource(@NonNull LiveData<S> toRemote) {
Source<?> source = mSources.remove(toRemote);
if (source != null) {
source.unplug();
}
}
@CallSuper
@Override
protected void onActive() {
for (Map.Entry<LiveData<?>, Source<?>> source : mSources) {
source.getValue().plug();
}
}
@CallSuper
@Override
protected void onInactive() {
for (Map.Entry<LiveData<?>, Source<?>> source : mSources) {
source.getValue().unplug();
}
}
private static class Source<V> implements Observer<V> {
final LiveData<V> mLiveData;
final Observer<? super V> mObserver;
int mVersion = START_VERSION;
Source(LiveData<V> liveData, final Observer<? super V> observer) {
mLiveData = liveData;
mObserver = observer;
}
void plug() {
mLiveData.observeForever(this);
}
void unplug() {
mLiveData.removeObserver(this);
}
@Override
public void onChanged(@Nullable V v) {
if (mVersion != mLiveData.getVersion()) {
mVersion = mLiveData.getVersion();
mObserver.onChanged(v);
}
}
}
}
它比MutableLiveData
多了两个方法addSource
和removeSource
,通过这两个方法我们可以将其他LiveData
合并到此LiveData
上,当其他LiveData
发生改变时,此LiveData
就能收到通知.
@MainThread
public <S> void addSource(@NonNull LiveData<S> source, @NonNull Observer<? super S> onChanged)
@MainThread
public <S> void removeSource(@NonNull LiveData<S> toRemote)
通过查看源码,我们可以知道在有观察者时LiveData#onActive
会被回调,MediatorLiveData
会在内部迭代,用observeForever
订阅所有被合并进来的LiveData
,这样就能接收所有LiveData
的变化,在没有观察者时LiveData#onInactive
会被回调,此时执行反操作removeObserver
.
4.4 变换
使用androidx.lifecycle.Transformations
这个工具类可以将持有一种类型的LiveData
转换为另一种LiveData
.他有类似于RxJava
的使用方式.
LiveData<Boolean> boolLiveData = getBoolLiveData();
LiveData<String> stringLiveData = Transformations.map(boolLiveData,bool->Boolean.toString(bool));
上面只是一个演示,实际上可以执行更为复杂的逻辑,并且这种转换是惰性的,在没有激活态观察者时,这种转换不会发生.
5 ViewModel
5.1 自定义ViewModel
ViewModel
其实没什么可说的,其源码主要的部分其实就只有这些
public abstract class ViewModel {
protected void onCleared() {
}
}
简直一目了然,我们可以在ViewModel
上使用LiveData
作为字段保存数据,并编写业务逻辑
(数据处理逻辑).就像这样
public class MyViewModel extends ViewModel
{
public MutableLiveData<String> username = new MutableLiveData<>();
public MutableLiveData<String> password = new MutableLiveData<>();
public MutableLiveData<String> text = new MutableLiveData<>();
public void action1(){
//TODO
}
public void initName(){
username.setValue("Luke Luo");
}
//......
@Override
protected void onCleared() {
//TODO 清理资源
}
}
onCleared
会在组件销毁的时候回调,我们可以重写这个方法在ViewModel
销毁时添加一些自定义清理逻辑.
ViewModel
还有一个子类AndroidViewModel
也是一目了然,只是保存了Application
实例而已.
public class AndroidViewModel extends ViewModel {
@SuppressLint("StaticFieldLeak")
private Application mApplication;
public AndroidViewModel(@NonNull Application application) {
mApplication = application;
}
/**
* Return the application.
*/
@SuppressWarnings("TypeParameterUnusedInFormals")
@NonNull
public <T extends Application> T getApplication() {
//noinspection unchecked
return (T) mApplication;
}
}
5.2 自定义ViewModel构造方式
我们可以通过ViewModelProviders
来获取ViewModel
,这样获取的ViewModel
会绑定组件的生命周期(即在销毁时自动调用onCleared
)
mViewModel = ViewModelProviders.of(this).get(CustomViewModel.class);
在Android
的Lifecycle
实现中框架向Activity
中添加了一个继承了系统Fragment
的ReportFragment
来汇报组件的生命周期,如果你使用的是appcompat
的Fragment
,那么它对你就是不可见的,所以一定要避免使用系统的Fragment
(在API28
中已被标记为弃用).
ViewModel
通过Lifecycle
来管理自身释放,在组件的ON_DESTROY
事件来到时,它的onCleared()
也会被调用.
如果你想有自定义构造函数参数的ViewModel
那你就得继承ViewModelProvider.AndroidViewModelFactory
了
//自定义构造函数的ViewModel
public class NaviViewModel extends AndroidViewModel
{
private AMapNavi mNavi;
public NaviViewModel(AMapNavi navi,Application application)
{
super(application);
mNavi = navi;
}
//......
}
//继承并重写create
public final class NaviViewModelFactory
extends ViewModelProvider.AndroidViewModelFactory
{
private final AMapNavi navi;
private final Application application;
public NaviViewModelFactory(@NonNull Context context, AMapNavi navi)
{
super((Application) context.getApplicationContext());
this.application = (Application) context.getApplicationContext();
this.navi = navi;
}
@NonNull
@Override
public <T extends ViewModel> T create(@NonNull Class<T> modelClass)
{
try
{
Constructor<T> constructor = modelClass
.getConstructor(Application.class, AMapNavi.class);
return constructor.newInstance(application, navi);
} catch (Exception e)
{
return super.create(modelClass);
}
}
}
//使用
NaviViewModelFactory factory = new NaviViewModelFactory(context, navi);
mViewModel = ViewModelProviders.of(this, factory).get(NaviViewModel.class);
说白了就是反射调用构造函数创建,也是一目了然.
6 RxJava
本篇文章只是针对响应式编程在MVVM
体系下的应用,不对RxJava
展开深度讨论,但是后面还会专门出一篇文章讨论RxJava
的有关知识.
RxJava
在MVVM
中主要用于发布事件,下面是需要注意的一些点.
6.1 使用AutoDispose
RxJava
是响应式编程这种思想在JVM
这个平台上的实现,所以它一开始并没有为Android
平台的特点而做出优化.
就像上面所介绍过的一样,Android
的组件是有明确的生命周期的,如果在组件销毁后,RxJava
仍有后台线程
在运行且你的Observer
引用了你的Activity
,就会造成内存泄漏.
但其实RxJava
是提供了释放机制的,那就是Disposeable
,只不过这个实现这个机制的逻辑需要我们手动在Activity#onDestroy
中进行硬编码,这会带来大量的样板代码.
为了解决这一局面,在Android Jetpack
还没有诞生的时候,有大神开发了RxLifecycle,但是这个框架需要强制继承基类,对于一些现有项目的改造来说,其实是不太友好的,个人感觉并没有从根本上解决问题.
Android Jetpack
诞生后AutoDispose给了我们另外一条出路.它使用RxJava2
中的as
运算符,将订阅者
转换成能够自动释放
的订阅者对象
.
在你的build.gradle
中添加依赖:
implementation 'io.reactivex.rxjava2:rxjava:2.2.6'
implementation 'io.reactivex.rxjava2:rxandroid:2.1.0'
implementation 'com.uber.autodispose:autodispose:1.1.0'
implementation 'com.uber.autodispose:autodispose-android-archcomponents:1.1.0'
一个简单的示例:
Observable.just(new Object())
//使用AutoDispose#autoDisposable
//并使用AndroidLifecycleScopeProvider#form
//指定LifecycleOwner和需要在哪一个事件进行销毁
//关键↓是这行
.as(AutoDispose.autoDisposable(AndroidLifecycleScopeProvider.from(activity, Lifecycle.Event.ON_DESTROY)))
.subscribe();
上面代码的时间订阅将会在组件的Lifecycle.Event.ON_DESTROY
事件来到时被释放,当然你也可以指定其他事件时释放.
6.2 防止多重点击
首先你可以使用JW大神
的RxBinding来实现这一需求,但是今天我们不讨论RxBinding
,因为网上的讨论RxBinding
的文章已经太多了,随便抓一篇出来都已经非常优秀.
今天我们模仿RxBinding
实现一个简单的,轻量化的,基于Java动态代理
的,并且兼容所有第三方View
所自定义Listener
接口的防止多重点击机制.
二话不说先上代码:
import androidx.collection.ArrayMap;
import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleOwner;
import com.uber.autodispose.AutoDispose;
import com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvider;
import io.reactivex.subjects.PublishSubject;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static io.reactivex.android.schedulers.AndroidSchedulers.mainThread;
public final class RxOnClick<X>
{
//默认最低的可取的时间
private static final int MINI_TIME = 200;
private final Class<X> mInterface;
private X mInner;
private LifecycleOwner mOwner;
private int mTime;
private Lifecycle.Event mEvent;
private RxOnClick(Class<X> type)
{
mInterface = type;
}
//从一个创建接口类型创建
public static <X> RxOnClick<X> create(Class<X> type)
{
return new RxOnClick<>(type);
}
//实际处理事件的Listener
public RxOnClick<X> inner(X inner)
{
mInner = inner;
return this;
}
//依附于的组件也就是LifecycleOwner
public RxOnClick<X> owner(LifecycleOwner owner)
{
mOwner = owner;
return this;
}
//只去time毫秒内的第一个结果作为有效结果
public RxOnClick<X> throttleFirst(int time)
{
mTime = time;
return this;
}
//在哪一个事件进行释放
public RxOnClick<X> releaseOn(Lifecycle.Event event)
{
mEvent = event;
return this;
}
//创建代理类实例
@SuppressWarnings("unchecked")
public X build()
{
//检查参数
if (mInterface == null || !mInterface.isInterface())
{
throw new IllegalArgumentException();
}
if (mTime < MINI_TIME)
{
mTime = MINI_TIME;
}
if (mEvent == null)
{
mEvent = Lifecycle.Event.ON_DESTROY;
}
if (mOwner == null || mInner == null)
{
throw new IllegalStateException();
}
//用反射遍历获取所有方法
Map<Method, PublishSubject<Object[]>> subjectMap = new ArrayMap<>();
for (Method method : mInterface.getDeclaredMethods())
{
PublishSubject<Object[]> subject = PublishSubject.create();
subject.throttleFirst(mTime, TimeUnit.MILLISECONDS)
.observeOn(mainThread())
.as(AutoDispose.autoDisposable(AndroidLifecycleScopeProvider.from(mOwner, mEvent)))
.subscribe(args -> method.invoke(mInner, args));
subjectMap.put(method, subject);
}
//使用动态代理代理代理该接口并使用PublishSubject进行转发
return (X) Proxy.newProxyInstance(mInterface.getClassLoader(),
new Class[]{mInterface},
(proxy, method, args) -> {
//Object类的方法直接调用
if (Object.class.equals(method.getDeclaringClass()))
{
return method.invoke(proxy, args);
}
//否则转换为Rx事件流
PublishSubject<Object[]> subject = subjectMap.get(method);
if (subject != null)
{
subject.onNext(args);
}
return null;
});
}
}
上面类在设计上采用了Builder
模式,所以它实际是一个Builder
.
其核心原理就是使用Java的动态代理
机制创建Listener
的代理类,代理类不处理事件,而是将事件通过PublishSubject
(释放订阅后接收到的事件)转换为RxJava
事件流推送到真正处理事件的Listener
上.
这样我们就可以在这个事件流上对事件做手脚了,并且这样还能兼容RxBinding
所不能兼容的第三方自定义View
.
比如上面就加入了xxx毫秒内只取第一次点击和绑定组件的生命周期,用起来的时候就像是下面,依然非常简洁并且非常的有用:
View.OnClickListener listener = RxOnClick
.create(View.OnClickListener.class)
.owner(this)
.inner(v -> {
//TODO
})
.build();
7 使用MVVM改造Android现有体系
笔者就Android
现有体系
下的各种类库
和框架
,通过自己实践的得出的经验将其进行如下归类,观点仅供参考,在实践中应该视项目特点进行适当进行改造.
7.1 View层
现有体系下的内容:
Activity/Fragment
(布局生命周期与逻辑控制器)android.view.View
及其子类
设计原则:
View
层不应该承担处理数据的责任,它应该只负责数据如何显示.- 它不应该直接持有
Model
层的任何引用,也不应该直接持有Model
层的数据. View
层正常的行为应该是观察某个ViewModel
,间接获取该ViewModel
从Model
层中获取并处理过能在View
层上直接显示的数据,数据由ViewModel
保存,这样可以保证在Activity
重建时页面上有关的数据不会丢失而且也不会造成View
层与Model
层的耦合.
7.2 DataBinding
现有体系下的内容:
Jetpack DataBinding
函数库View
的Adapter
- ……
设计原则:
- 理想状态下,
DataBinding
与View
构建的关系应该是数据驱动的,即只要数据不改变View
层实现的变更不会导致逻辑的重新编写(如把TextView
改成EditText
也不需要修改一行代码). - 虽然
DataBinding
函数库已经完成了大多数DataBinding
应该做的事,但是不要为了数据驱动而排斥使用android:id
来获取View
并对View
直接赋值,虽然这不够数据驱动,但是适当使用是可以的,毕竟Android
的View
层目前还没有办法做到完全的数据驱动(主要是第三方库的兼容问题). Adapter
应该属于DataBinding
的一种,与DataBinding
函数库中生成的DataBinding
相同,它也是使用数据来触发View
层的改变.所以尽可能不要把它写到ViewModel
中,但这不是必须的,做在对List
操作要求比较高的情况下可以写到ViewModel
中,但要保证一个原则——ViewModel
应该只负责提供数据,而不应该知道这些数据要与何种View
进行交互.
7.3 事件传递
现有体系下的内容:
EventBus
事件总线RxJava
事件流
设计原则:
Jetpack
中实现的LiveData
能够很好的作为数据持有者,并且是生命周期感知的,但是有些时候我们需要向View
层发送一些单次的数据,这时LiveData
并不能够很好地工作.Rxjava
和EventBus
是更好的选择.
7.4 ViewModel层
现有体系下的内容:
Jetpack ViewModel
Jetpack LiveData
- 用于将
Model
数据转换成View
能直接显示的数据的工具类 - ……
设计原则:
ViewModel
通常应该使用LiveData
持有View
层数据的实际控制权ViewModel
可以包含操作,但是ViewModel
不应该直接或者间接地引用View
,即使是方法中的参数也最好不要,因为ViewModel
不应该知道自己到底是与哪一个View
进行交互.ViewModel
与Model
的关系应该是——将Model
层产生的数据翻译
成View
层能够直接消化吸收的数据。ViewModel
可以向View
层发送事件,然后View
可以订阅这些事件以收到ViewModel
层的通知.
7.5 Model层
现有体系下的内容:
- 部分与
Activity
无关的系统服务 Room
(SQLite
数据库)Retrofit
(网络数据)SharedPreferences
- ……
设计原则:
- 涉及
Activity
请一定不要包含进来,如WindowManager
,它们属于View
层. Model
层主要是原始数据的来源,由于存储格式/传输格式
与显示格式
存在的巨大差异,View
层往往并不能很好的直接消化这些数据,这时就需要一个中间人
作为翻译
,由此抽象出了ViewModel
.
8 实战
我编写了一个简单的FTP
客户端作为本次MVVM
博文的演示Demo
,该项目简单实践了QMUI
+MVVM
+DataBinding
+RxJava
+LiveData
+Room
的技术栈并由kotlin
和Java
混编写成,支持断点续传,代码质量比较一般,有爱自取.
9 参考资料以及扩展阅读
10 结语
有些日子没更新文章了,最近发生了一些事让笔者彻底从无限的狂热中冷静下来,开始耐心做事.
本篇文章多达10000+
字,感谢您在百忙之中抽空观看.所有内容均为个人学习总结与理解,仅供参考.
如果喜欢
我的文章
别忘了给我点个赞,拜托了这对我来说真的很重要.
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
转载请注明出处: https://daima100.com/12986.html