前言
手把手讲解系列文章,是我写给各位看官,也是写给我自己的。
文章可能过分详细,但是这是为了帮助到尽量多的人,毕竟工作5,6年,不能老吸血,也到了回馈开源的时候.
这个系列的文章:
1、用通俗易懂的讲解方式,讲解一门技术的实用价值
2、详细书写源码的追踪,源码截图,绘制类的结构图,尽量详细地解释原理的探索过程
3、提供Github 的 可运行的Demo工程,但是我所提供代码,更多是提供思路,抛砖引玉,请酌情cv
4、集合整理原理探索过程中的一些坑,或者demo的运行过程中的注意事项
5、用gif图,最直观地展示demo运行效果如果觉得细节太细,直接跳过看结论即可。
本人能力有限,如若发现描述不当之处,欢迎留言批评指正。
学到老活到老,路漫漫其修远兮。与众君共勉 !
正文大纲
先给出github
Demo: https://github.com/18598925736/ViewPagerKeng
一、
ViewPager
+Fragment
"诡异"的缓存特性二、"诡异"特性带来的后果
三、必要的基础知识
四、
ViewPager
的缓存相关源码索引五、
ViewPager+Fragment
懒加载机制的设计思路六、案例演示
七、总结 程序开发者的修炼之路
八、鸣谢
正文
一 、ViewPager+Fragment"诡异"的缓存特性
- 缓存机制,最少缓存一个
Fragment
- 在只要是存在于缓存中的
Fragment
,无论它当前是不是可见,都会无脑跟随他们所在的Activity
,执行生命周期函数- 当存在这样的结构:
ViewPager+Fragment(ViewPager+Fragment
)嵌套,内部的Fragment
也会遵循第2
点
二 、"诡异"特性带来的后果
无论可见或者不可见,只要是存在于
ViewPager
这个容器之内的Fragment
,都会统一执行生命周期函数,这意味着什么?意味着,
Fragment
的生命周期函数已经不再可信。并且 如果你在某个生命周期函数内写了 耗时操作,或者 消耗CPU,内存,网络请求的 代码,也会在UI
根本不在视野内的情况下执行,而且执行地毫无意义。还有可能导致当前Fragment UI
的卡顿试想如果
ViewPager
有10个fragment
,但是一次只显示了一个,你离开了当前Activity
之后回来,Activity
执行onResume
,然后10个Fragment
一起执行onStart
-onResume
, 而恰好你在onResume
里面写了耗时操作,想想那酸爽
, 关键是其他9个不可见
,系统资源完全浪费.可是,你耗时操作的调用,不写在 生命周期函数里面,还能写在哪里??
看来ViewPager + Fragment
的默认机制,很有可能造成app
里的一个大坑,那怎么办?
只能自己设计一套懒加载机制
所谓懒加载,就是:
保证在任何情况下,只有可见的那一个
Fragment
才会执行 耗时操作,网络请求和UI操作其余不可见的,一律不执行任何操作,并且已有的操作(比如
定时器
,已经发出的网络请求
)都要手动终止
三 、必要的基础知识
Activity
和 Fragment
的生命周期函数图,我就不贴出来了,大家可以百度。以下几点,是本人写demo
做试验所得。
1) 关于Fragment
在结合ViewPager
的情况(包括内嵌ViewPager+Fragment
)下的生命周期函数 执行流程的几个重要结论:
Fragment
会听从ViewPager
的缓存机制,只要ViewPager
觉得一个Fragment
应该被初始化(无论是不是可见),然后缓存起来,那么Fragment
就会执行完整的生命周期 ,onCreate-onCreateView-onViewCreated-onStart-onResume
至于具体初始化几个,这个 与
ViewPager
的setOffscreenPageLimit()
缓存设置有关当所在的
Activity
发生跳转,其中的已经被实例化的Fragment
都会执行:onPause-onStop
当回到所在
Activity
时,onStart-onResume
2) 滑动ViewPager
,所有的Fragment
都只会有一次onCreate
,然而,View会经历销毁/重建
onCreateView-onViewCreated-onStart-onResume -----> onPause-onStop-onDestroyView
3)Fragment
其实还有两个与 它生命周期无关的 函数,这两个函数 可以控制 Fragment
的当前可见状态值 ,但是, 他只是一个特征值而已,并不能证明它就是可见的或者不可见, 他们分别是:
setUserVisibleHint () 看过注释,大概意思是 给系统设置一个关于当前
fragment
的UI
是否显示给用户的暗示,这个方法可能被fragment
的生命周期的外部调用,所以不能确保它在生命周期的哪个阶段被调用onHiddenChanged() 这个一般用于
fragment
被FragmentManager
hide
或者show
的时候
这两个函数,不能确保在生命周期的哪个阶段调用,但是可以根据他们,来配合生命周期函数,判定当前fragment
是不是可见.
四 、ViewPager 相关源码索引
终于要进入源码了。
之前提到过,ViewPager
是自带缓存机制的,并且默认缓存数量是1个。
那么有几个问题:
- 为什么至少缓存一个?
- 缓存的是什么?
- 存到哪里去了?
- 存起来之后用来干什么了?
进入ViewPager.java (SDK 27)源代码一起来探索一下:
由setOffscreenPageLimit()
作为入口(为什么是它? 因为它是设置缓存数量的函数)
private static final int DEFAULT_OFFSCREEN_PAGES = 1;//默认缓存页数
public void setOffscreenPageLimit(int limit) {
if (limit < DEFAULT_OFFSCREEN_PAGES) {//如果入参是0
Log.w(TAG, "Requested offscreen page limit " + limit + " too small; defaulting to " +
DEFAULT_OFFSCREEN_PAGES);
limit = DEFAULT_OFFSCREEN_PAGES;// 强制设定为1
}
if (limit != mOffscreenPageLimit) {//如果缓存页数和当前值不一致
mOffscreenPageLimit = limit;//那么更新当前值
populate();//并且执行缓存方法
}
}
上述注释很明确,如果入参是0
,那么强行改为1
,也就是说ViewPager
不允许无缓存
的情况。而且,在缓存数量更新的时候,要执行缓存方法polulate()
那么polulate()
都做了什么?
我们知道,
ViewPager
要搭配PagerAdapter
使用. 缓存一定和这个adapter有关,进入源码 , 跟随adapter
,
void populate(int newCurrentItem) {
ItemInfo oldCurInfo = null;
int focusDirection = View.FOCUS_FORWARD;
if (mCurItem != newCurrentItem) {
focusDirection = mCurItem < newCurrentItem ? View.FOCUS_RIGHT : View.FOCUS_LEFT;
oldCurInfo = infoForPosition(mCurItem);
mCurItem = newCurrentItem;
}
if (mAdapter == null) { //adapter是空,就不进行后面的操作了
sortChildDrawingOrder();
return;
}
// Bail now if we are waiting to populate. This is to hold off
// on creating views from the time the user releases their finger to
// fling to a new position until we have finished the scroll to
// that position, avoiding glitches from happening at that point.
if (mPopulatePending) {
if (DEBUG) Log.i(TAG, "populate is pending, skipping for now...");
sortChildDrawingOrder();
return;
}
// Also, don't populate until we are attached to a window. This is to
// avoid trying to populate before we have restored our view hierarchy
// state and conflicting with what is restored.
if (getWindowToken() == null) {
return;
}
mAdapter.startUpdate(this);//当一个改变发生在已经显示出来的页面时执行(貌似和缓存无关)
final int pageLimit = mOffscreenPageLimit;//当前缓存数
final int startPos = Math.max(0, mCurItem - pageLimit);//用当前页编号和缓存数的计算缓存开始的位置
final int N = mAdapter.getCount();//获得 adapter的子内容数量
final int endPos = Math.min(N-1, mCurItem + pageLimit);//用当前页编号和缓存数的计算缓存结束的位置
if (N != mExpectedAdapterCount) {//如果adapter的内容数量和期望值不同,就抛出异常
String resName;
try {
resName = getResources().getResourceName(getId());
} catch (Resources.NotFoundException e) {
resName = Integer.toHexString(getId());
}
throw new IllegalStateException("The application's PagerAdapter changed the adapter's" +
" contents without calling PagerAdapter#notifyDataSetChanged!" +
" Expected adapter item count: " + mExpectedAdapterCount + ", found: " + N +
" Pager id: " + resName +
" Pager class: " + getClass() +
" Problematic adapter: " + mAdapter.getClass());
}
// Locate the currently focused item or add it if needed.
// 本地化当前聚焦的item 或者 有必要的话将它加到list中去
int curIndex = -1;
ItemInfo curItem = null;//你就是当前正在显示的item
for (curIndex = 0; curIndex < mItems.size(); curIndex++) {
//这里在遍历一个mItems,它是ArrayList<ItemInfo> mItems
final ItemInfo ii = mItems.get(curIndex);// 获得当前位置item
if (ii.position >= mCurItem) {//
if (ii.position == mCurItem) curItem = ii;
//讲道理,上面两行代码我没看懂,先判定>= ,又判定==,最后把当前缓存的这个,赋值给curItem
break;
}
}
if (curItem == null && N > 0) {// 如果curItem经历了上面的遍历,还是null,并且adapter的内容数量大于0
curItem = addNewItem(mCurItem, curIndex);//那么就构建一个ItemInfo对象,并且赋值给curItem
}
// Fill 3x the available width or up to the number of offscreen
// pages requested to either side, whichever is larger.
// If we have no current item we have no work to do.
// 这段注释的意思是,填充三倍可见宽度 或者一直到 屏幕外页面的数量需要的宽度,无论它多大.
// 如果我们没有当前item,就不用做任何事情
if (curItem != null) {
float extraWidthLeft = 0.f;
int itemIndex = curIndex - 1;//??
ItemInfo ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;//itemIndex已经是0了,所以,这里就是获取当前位置的item
final int clientWidth = getPaddedWidth();
final float leftWidthNeeded = clientWidth <= 0 ? 0 :
2.f - curItem.widthFactor + (float) getPaddingLeft() / (float) clientWidth;
for (int pos = mCurItem - 1; pos >= 0; pos--) {//从当前显示的item倒过来往前遍历
if (extraWidthLeft >= leftWidthNeeded && pos < startPos) {
if (ii == null) {
break;
}
if (pos == ii.position && !ii.scrolling) {//如果遍历到了缓存item所在的位置,并且缓存的这个item没有处于滑动状态
mItems.remove(itemIndex);//就从缓存list中移除它
mAdapter.destroyItem(this, pos, ii.object);//并且 adapter执行destroyItem销毁item
if (DEBUG) {
Log.i(TAG, "populate() - destroyItem() with pos: " + pos +
" view: " + ii.object);
}
itemIndex--;
curIndex--;
ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;
}
} else if (ii != null && pos == ii.position) {//如果遍历到了 当前显示的item
extraWidthLeft += ii.widthFactor;
itemIndex--;
ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;
} else {//其他情况
ii = addNewItem(pos, itemIndex + 1);// 加入缓存
extraWidthLeft += ii.widthFactor;
curIndex++;
ii = itemIndex >= 0 ? mItems.get(itemIndex) : null;
}
}
float extraWidthRight = curItem.widthFactor;
itemIndex = curIndex + 1;
if (extraWidthRight < 2.f) {
ii = itemIndex < mItems.size() ? mItems.get(itemIndex) : null;
final float rightWidthNeeded = clientWidth <= 0 ? 0 :
(float) getPaddingRight() / (float) clientWidth + 2.f;
for (int pos = mCurItem + 1; pos < N; pos++) {//再遍历一次,从当前位置往后遍历
if (extraWidthRight >= rightWidthNeeded && pos > endPos) {
if (ii == null) {
break;
}
if (pos == ii.position && !ii.scrolling) {//
mItems.remove(itemIndex);//从缓存中移除掉当前item,因为正在显示中,所以无需缓存
mAdapter.destroyItem(this, pos, ii.object);
if (DEBUG) {
Log.i(TAG, "populate() - destroyItem() with pos: " + pos +
" view: " + ii.object);
}
ii = itemIndex < mItems.size() ? mItems.get(itemIndex) : null;
}
} else if (ii != null && pos == ii.position) {
extraWidthRight += ii.widthFactor;
itemIndex++;
ii = itemIndex < mItems.size() ? mItems.get(itemIndex) : null;
} else {
ii = addNewItem(pos, itemIndex);
itemIndex++;
extraWidthRight += ii.widthFactor;
ii = itemIndex < mItems.size() ? mItems.get(itemIndex) : null;
}
}
}
calculatePageOffsets(curItem, curIndex, oldCurInfo);//// Fix up offsets for later layout. 为了后面的布局,修复偏移量,没有涉及到缓存list的add和remove
}
if (DEBUG) {
Log.i(TAG, "Current page list:");
for (int i=0; i<mItems.size(); i++) {
Log.i(TAG, "#" + i + ": page " + mItems.get(i).position);
}
}
上面的代码中提到了 addNewItem()
,它就是往缓存ArrayList<ItemInfo> mItems
里面增加ItemInfo
的 核心方法
ItemInfo addNewItem(int position, int index) {
ItemInfo ii = new ItemInfo();
ii.position = position;
ii.object = mAdapter.instantiateItem(this, position);
ii.widthFactor = mAdapter.getPageWidth(position);
if (index < 0 || index >= mItems.size()) {
mItems.add(ii);
} else {
mItems.add(index, ii);
}
return ii;
}
看了这个方法,得出结论:
为什么至少缓存一个?
答:当我们传参为0,它会强制设定为1.
if (limit < DEFAULT_OFFSCREEN_PAGES) {//如果入参是0 limit = DEFAULT_OFFSCREEN_PAGES;// 强制设定为1 }
缓存的是什么?
答:缓存的是
ItemInfo
对象, 而ItemInfo
是adapter.instantiateItem
的一个封装,除了mAdapter.instantiateItem
返回的这个object之外,它还封装了position
,还有mAdapter.getPageWidth
页面宽度.static class ItemInfo { Object object; boolean scrolling; float widthFactor; /** Logical position of the item within the pager adapter. */ int position; /** Offset between the starting edges of the item and its container. */ float offset; }
存到哪里去了?
答:存到了一个List,叫做
ArrayList<ItemInfo> mItems
private final ArrayList<ItemInfo> mItems = new ArrayList<ItemInfo>();
存起来之后用来干什么了?
答: 追踪
mItems.get
方法,发现,源码中有30处出现,一共有 12 个方法使用了它:setAdapter setCurrentItemInternal dataSetChanged populate calculatePageOffsets infoForChild infoForPosition completeScroll performDrag infoForFirstVisiblePage determineTargetPage onDraw
可以发现 缓存,贯穿了几乎
ViewPager
的所有行为,包括适配器,数据变更,修正页面偏移量,计算滑动,拖拽,绘制等, 但是我想进一步探索的时候,发现这些方法,大部分没有注释,时间原因,只好作罢。
从上面的结论,我们总结一下,ViewPager
缓存的,是PageAdapter
mAdapter
里面 instantiateItem
创建出来的object
, 我们今天重点应该关注的是和Fragment
有关的FragmentPagerAdapter
,所以,我们看看它的instantiateItem
到底返回了一个神马东西:
public Object instantiateItem(ViewGroup container, int position) {
//...省略非重点代码
return fragment;
}
看到了吧? return fragment
!
所以,得出最终结论,
ViewPager + Fragment
(其实还应该加上FragmentPagerAdapter
), 缓存的是Fragment
对象,该对象被封装成了ItemInfo
被add
到了ArrayList<ItemInfo>
mItems
里面。当我们滑动ViewPager的时候,如果进入的是 已经缓存的Fragment
,就不会去走Fragment
的重新创建生命周期(onCreate-onCreateView-onViewCreated-onStart-onResume
),直接使用缓存中的Fragment
,调用它的onStart-onResume
.
五 、ViewPager+Fragment懒加载机制的设计思路
既然ViewPager
已经这么明确了,他就是要缓存至少一个Fragment
,我们不太好去直接改源码,那怎么去 实现 懒加载呢?没办法了,曲线救国吧,改不了 ViewPager
,那就 在Fragment
的生命周期上做文章。
Fragment
的生命周期也不太好去动刀,但是我们可以增加另外的方法,来结合生命周期,以及 上文提及的无关生命周期的两个函数 setUserVisibleHint
/ onHiddenChanged
.
原本的Fragment
生命周期函数不可信了,那么首先我们继承Fragment
,增加3个我们自己的方法, 以及3个bool
标志位
public abstract class BaseLazyLoadingFragment extends Fragment {
//省略无关代码...
private boolean isViewCreated = false;//View是否已经被创建出来
private boolean isFirstVisible = true;//当前Fragment是否是首次可见
private boolean currentVisibleState = false;//当前真正的可见状态
/**
* 当第一次可见的时候(此方法,在View的一次生命周期中只执行一次)
* 如果Fragment经历了onDestroyView,那么整个方法会再次执行
* 重写此方法时,对Fragment全局变量进行 初始化
* 具体的参照github demo
*/
protected void onFragmentFirstVisible() {
Log.d(getCustomMethodTag(), "第一次可见,进行当前Fragment初始化操作");
}
/**
* 当fragment变成可见的时候(可能会多次)
*/
protected void onFragmentResume() {
Log.d(getCustomMethodTag(), "onFragmentResume 执行网络请求以及,UI操作");
}
/**
* 当fragment变成不可见的时候(可能会多次)
*/
protected void onFragmentPause() {
Log.d(getCustomMethodTag(), "onFragmentPause 中断网络请求,UI操作");
}
}
我们设计了3个自定义的方法,那么这3个方法在什么情况下执行呢?
再定义一个 visible
状态分发的方法:
void dispatchVisibleState(boolean isVisible) {
//为了兼容内嵌ViewPager的情况,分发时,还要判断父Fragment是不是可见
if (isVisible && isParentInvisible()) {//如果当前可见,但是父容器不可见,那么也不必分发
return;
}
if (isVisible == currentVisibleState) return;//如果目标值,和当前值相同,那就别费劲了
currentVisibleState = isVisible;//更新状态值
if (isVisible) {//如果可见
//那就区分是第一次可见,还是非第一次可见
if (isFirstVisible) {
isFirstVisible = false;
onFragmentFirstVisible();
}
onFragmentResume();
dispatchChildVisibilityState(true);
} else {
onFragmentPause();
dispatchChildVisibilityState(false);
}
}
接着在Fragment
原有的生命周期函数内( 主要是OnCreateView
,onResume
,onPause
),调用此方法
public abstract class BaseLazyLoadingFragment extends Fragment {
//省略无关代码...
@Override
public void onAttach(Context context) {
super.onAttach(context);
Log.d(getLifeCycleTag(), "onAttach");
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
Log.d(getLifeCycleTag(), "onCreate");
super.onCreate(savedInstanceState);
}
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
mRoot = inflater.inflate(getLayoutId(), container, false);
Log.d(getLifeCycleTag(), "onCreateView");
initView(mRoot);
//在View创建完毕之后,isViewCreate 要变为true
isViewCreated = true;
if (!isHidden() && getUserVisibleHint())
dispatchVisibleState(true);
return mRoot;
}
@Override
public void onDestroyView() {//相对应的,当View被销毁的时候,isViewCreated要变为false
super.onDestroyView();
Log.d(getLifeCycleTag(), "onDestroyView");
reset();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
Log.d(getLifeCycleTag(), "onViewCreated");
}
@Override
public void onStart() {
super.onStart();
Log.d(getLifeCycleTag(), "onStart");
}
@Override
public void onResume() {
super.onResume();
Log.d(getLifeCycleTag(), "onResume");
if (!isFirstVisible) {
if (!isHidden() && !currentVisibleState && getUserVisibleHint())
dispatchVisibleState(true);
}
}
@Override
public void onPause() {
super.onPause();
Log.d(getLifeCycleTag(), "onPause");
if (currentVisibleState && getUserVisibleHint()) {
dispatchVisibleState(false);
}
}
@Override
public void onStop() {
super.onStop();
Log.d(getLifeCycleTag(), "onStop");
}
@Override
public void onDestroy() {
super.onDestroy();
Log.d(getLifeCycleTag(), "onDestroy");
}
@Override
public void onDetach() {
super.onDetach();
Log.d(getLifeCycleTag(), "onDetach");
}
//省略无关代码...
}
然而,能够表示当前Fragment
是否可见的,并不止有生命周期函数,还有2个和生命周期无关的函数(setUserVisibleHint
,onHiddenChanged
),在其中也要调用dispatchVisibleState
/**
* 此方法和生命周期无关,由外部调用,只是作为一个可见不可见的参考
*/
@Override
public void setUserVisibleHint(boolean isVisibleToUser) {
super.setUserVisibleHint(isVisibleToUser);
Log.d(getLogTag(), "setUserVisibleHint:" + isVisibleToUser);
//因为只有在Fragment的View已经被创建的前提下,UI处理才有意义,所以
if (isViewCreated) {
//为了逻辑严谨,必须当目前状态值和目标相异的时候,才去执行UI可见分发
if (currentVisibleState && !isVisibleToUser) {
dispatchVisibleState(false);
} else if (!currentVisibleState && isVisibleToUser) {
dispatchVisibleState(true);
}
}
}
/**
* 在Fragment被hide/show的时候被调用
* @param hidden
*/
@Override
public void onHiddenChanged(boolean hidden) {
super.onHiddenChanged(hidden);
if (hidden) {
dispatchVisibleState(false);
} else {
dispatchVisibleState(true);
}
}
最后,考虑到ViewPager+Fragment(ViewPager+Fragment)
嵌套的问题:
设计一个 dispatchChildVisibilityState
方法,来控制 内嵌的Fragment
的可见状态 :
private void dispatchChildVisibilityState(boolean isVisible) {
FragmentManager fragmentManager = getChildFragmentManager();
List<Fragment> list = fragmentManager.getFragments();
if (list != null) {
for (Fragment fg : list) {//遍历子
if (fg instanceof BaseLazyLoadingFragment
&& !fg.isHidden() && fg.getUserVisibleHint()) {
((BaseLazyLoadingFragment) fg).dispatchVisibleState(isVisible);
}
}
}
}
以及isParentInvisible
方法来判定内嵌Fragment
的父Fragment
是否可见:
private boolean isParentInvisible() {
Fragment parent = getParentFragment();
Log.d(getLogTag(), "getParentFragment:" + parent + "");
if (parent instanceof BaseLazyLoadingFragment) {
BaseLazyLoadingFragment lz = (BaseLazyLoadingFragment) parent;
return !lz.currentVisibleState;
}
return false;// 默认可见
}
六 、案例演示(观察日志)
请下载源码,运行
demo
中的module
:lazyloadingfragments
最简单的情况,单个ViewPager内滑动时
生命周期函数
刚启动app:
然后从0滑动到1:
然而,观察一下我们自定义的3个方法:
从0滑动到1:
再多滑动几次,可以发现:
只有当前可见的那一个Fragment,才执行了
onFragmentVisible
和onFragmentFirstVisible
,并且onFragmentFirstVisible
只有在onDestroyView
执行之后,才会再次执行
ViewPager所在Activity 发生跳转,又跳回来
生命周期函数:
自定义的方法:
可以确定:
Activity跳转,只有当前可见的Fragment才会有方法执行,不可见的那些Fragment没有任何多余的操作
ViewPager嵌套 + 所在Activity发生跳转,又跳回来
tips: 验证这种情况,必须:
走起:
生命周期函数:
这里看得很清楚,为什么说生命周期函数不可信了吧?
然而,看看我们自定义的方法:
其实还有另外的操作,就是内外
ViewPager
联动滑动,这里我就不贴图了,结果都是一样,凡是不可见的Fragment
,无论内外,都没有多余的操作。可见:我们的懒加载机制 完全解决了 各种情况下的生命周期不可靠的问题
其实最后还有一种操作 就是 FragmentManager
的show
hide
,结果也是一样,没有多余的操作,时间原因,不写在demo
里面了.
七 、总结 程序开发者的修炼之路
这几年几乎年年都在传,互联网不景气,然而就算如此,我们已经入行了,就必须往前。那么如何进步,如何进阶。编程开发的学习资料网上几乎铺天盖地到处都是,其中不乏一些 精致优秀的博客文章视频,那么拿到这些学习资源之后怎么办,没有捷径。笔者从高手那里取得这份Demo源码之后,从头到尾,反复研读,阅读源码,反复验证,然后自己写一遍,忙里偷闲,历时一周,总算总结出一篇能读的技术文章,希望能对看到此文有缘人有些许帮助,而且 总结文章,也是对我自身的一种提升。
Demo github地址已经给出,Demo的讲解,上文应该已经很详细了。再有问题,或者发现文章中的错误,欢迎联系本人。
各位开发者, fighting~
欢迎转载,但是请务必注明出处.
八 、鸣谢
感谢 享学课堂
avlin
老师 提供的demo 以及视频