Android RecyclerView 必知必會
導語
RecyclerView是Android 5.0提出的新UI控件,可以用來代替傳統的ListView。
Bugly之前也發過一篇相關文章,講解了 RecyclerView 與 ListView 在緩存機制上的一些區別:
今天精神哥來給大家詳細介紹關于 RecyclerView,你需要了解 的方方面面。
前言
下文中Demo的源代碼地址:
https://github.com/xiazdong/RecyclerViewDemo
(點擊文末閱讀原文,直接訪問該項目)
-
Demo1: RecyclerView添加HeaderView和FooterView,ItemDecoration范例。
-
Demo2: ListView實現局部刷新。
-
Demo3: RecyclerView實現拖拽、側滑刪除。
-
Demo4: RecyclerView閃屏問題。
-
Demo5: RecyclerView實現 setEmptyView() 。
-
Demo6: RecyclerView實現萬能適配器,瀑布流布局,嵌套滑動機制。
基本概念
RecyclerView是Android 5.0提出的新UI控件,位于support-v7包中,可以通過在build.gradle中添加 compile 'com.android.support:recyclerview-v7:24.2.1' 導入。
RecyclerView的官方定義如下:
A flexible view for providing a limited window into a large data set.
從定義可以看出,flexible(可擴展性)是RecyclerView的特點。不過我們發現和ListView有點像,本文后面會介紹RecyclerView和ListView的區別。
為什么會出現RecyclerView?
RecyclerView并不會完全替代ListView(這點從ListView沒有被標記為@Deprecated可以看出),兩者的使用場景不一樣。但是RecyclerView的出現會讓很多開源項目被廢棄,例如橫向滾動的ListView, 橫向滾動的GridView, 瀑布流控件,因為RecyclerView能夠實現所有這些功能。
比如有一個需求是屏幕豎著的時候的顯示形式是ListView,屏幕橫著的時候的顯示形式是2列的GridView,此時如果用RecyclerView,則通過設置LayoutManager一行代碼實現替換。
ListView vs RecyclerView
ListView相比RecyclerView,有一些優點:
-
addHeaderView() , addFooterView() 添加頭視圖和尾視圖。
-
通過”android:divider”設置自定義分割線。
-
setOnItemClickListener() 和 setOnItemLongClickListener() 設置點擊事件和長按事件。
這些功能在RecyclerView中都沒有直接的接口,要自己實現(雖然實現起來很簡單),因此如果只是實現簡單的顯示功能,ListView無疑更簡單。
RecyclerView相比ListView,有一些明顯的優點:
-
默認已經實現了View的復用,不需要類似 if(convertView == null) 的實現,而且回收機制更加完善。
-
默認支持局部刷新。
-
容易實現添加item、刪除item的動畫效果。
-
容易實現拖拽、側滑刪除等功能。
RecyclerView是一個插件式的實現,對各個功能進行解耦,從而擴展性比較好。
ListView實現局部刷新
我們都知道ListView通過 adapter.notifyDataSetChanged() 實現ListView的更新,這種更新方法的缺點是全局更新,即對每個Item View都進行重繪。但事實上很多時候,我們只是更新了其中一個Item的數據,其他Item其實可以不需要重繪。
這里給出ListView實現局部更新的方法:
可以看出,我們通過ListView的 getChildAt() 來獲得需要更新的View,然后通過 getTag() 獲得ViewHolder,從而實現更新。
標準用法
RecyclerView的標準實現步驟如下:
-
創建Adapter:創建一個繼承 RecyclerView.Adapter<VH> 的Adapter類(VH是ViewHolder的類名),記為NormalAdapter。
-
創建ViewHolder:在NormalAdapter中創建一個繼承 RecyclerView.ViewHolder 的靜態內部類,記為VH。ViewHolder的實現和ListView的ViewHolder實現幾乎一樣。
-
在NormalAdapter中實現:
-
VH onCreateViewHolder(ViewGroup parent, int viewType) : 映射Item Layout Id,創建VH并返回。
-
void onBindViewHolder(VH holder, int position) : 為holder設置指定數據。
-
int getItemCount() : 返回Item的個數。
可以看出,RecyclerView將ListView中 getView() 的功能拆分成了 onCreateViewHolder() 和 onBindViewHolder() 。
基本的Adapter實現如下:
創建完Adapter,接著對RecyclerView進行設置,一般來說,需要為RecyclerView進行四大設置,也就是后文說的四大組成:Adapter(必選),Layout Manager(必選),Item Decoration(可選,默認為空), Item Animator(可選,默認為DefaultItemAnimator)。
需要注意的是在 onCreateViewHolder() 中,映射Layout必須為
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_1, parent, false);
而不能是:
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_1, null);
如果要實現ListView的效果,只需要設置Adapter和Layout Manager,如下:
List<String> data = initData(); RecyclerView rv = (RecyclerView) findViewById(R.id.rv); rv.setLayoutManager(new LinearLayoutManager(this)); rv.setAdapter(new NormalAdapter(data));
ListView只提供了 notifyDataSetChanged() 更新整個視圖,這是很不合理的。RecyclerView提供了 notifyItemInserted() , notifyItemRemoved() , notifyItemChanged() 等API更新單個或某個范圍的Item視圖。
四大組成
RecyclerView的四大組成是:
-
Adapter:為Item提供數據。
-
Layout Manager:Item的布局。
-
Item Animator:添加、刪除Item動畫。
-
Item Decoration:Item之間的Divider。
Adapter
Adapter的使用方式前面已經介紹了,功能就是為RecyclerView提供數據,這里主要介紹萬能適配器的實現。其實萬能適配器的概念在ListView就已經存在了,即 base-adapter-helper 。
這里我們只針對RecyclerView,聊聊萬能適配器出現的原因。為了創建一個RecyclerView的Adapter,每次我們都需要去做重復勞動,包括重寫 onCreateViewHolder() , getItemCount() 、創建ViewHolder,并且實現過程大同小異,因此萬能適配器出現了,他能通過以下方式快捷地創建一個Adapter:
是不是很方便。當然復雜情況也可以輕松解決。
這里講解下萬能適配器的實現思路。
我們通過 public abstract class QuickAdapter<T> extends RecyclerView.Adapter<QuickAdapter.VH> 定義萬能適配器QuickAdapter類,T是列表數據中每個元素的類型,QuickAdapter.VH是QuickAdapter的ViewHolder實現類,稱為萬能ViewHolder。
首先介紹QuickAdapter.VH的實現:
其中的關鍵點在于通過 SparseArray<View> 存儲item view的控件, getView(int id) 的功能就是通過id獲得對應的View(首先在mViews中查詢是否存在,如果沒有,那么 findViewById() 并放入mViews中,避免下次再執行 findViewById() )。
QuickAdapter的實現如下:
其中:
-
getLayoutId(int viewType) 是根據viewType返回布局ID。
-
convert() 做具體的bind操作。
就這樣,萬能適配器實現完成了。
Item Decoration
RecyclerView通過 addItemDecoration() 方法添加item之間的分割線。Android并沒有提供實現好的Divider,因此任何分割線樣式都需要自己實現。
方法是:創建一個類并繼承RecyclerView.ItemDecoration,重寫以下兩個方法:
-
onDraw(): 繪制分割線。
-
getItemOffsets(): 設置分割線的寬、高。
Google在sample中給了一個參考的實現類:DividerItemDecoration,這里我們通過分析這個例子來看如何自定義Item Decoration。
首先看構造函數,構造函數中獲得系統屬性 android:listDivider ,該屬性是一個Drawable對象。
因此如果要設置,則需要在value/styles.xml中設置:
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar"> <item name="android:listDivider">@drawable/item_divider</item></style>
接著來看 getItemOffsets() 的實現:
這里只看 mOrientation == VERTICAL_LIST 的情況,outRect是當前item四周的間距,類似margin屬性,現在設置了該item下間距為 mDivider.getIntrinsicHeight() 。
那么 getItemOffsets() 是怎么被調用的呢?
RecyclerView繼承了ViewGroup,并重寫了 measureChild() ,該方法在 onMeasure() 中被調用,用來計算每個child的大小,計算每個child大小的時候就需要加上 getItemOffsets() 設置的外間距:
這里我們只考慮 mOrientation == VERTICAL_LIST 的情況,DividerItemDecoration的 onDraw() 實際上調用了 drawVertical() :
那么 onDraw() 是怎么被調用的呢?還有ItemDecoration還有一個方法 onDrawOver() ,該方法也可以被重寫,那么 onDraw() 和 onDrawOver() 之間有什么關系呢?
我們來看下面的代碼:
根據View的繪制流程,首先調用RecyclerView重寫的 draw() 方法,隨后 super.draw() 即調用View的 draw() ,該方法會先調用 onDraw() (這個方法在RecyclerView重寫了),再調用 dispatchDraw() 繪制children。因此:ItemDecoration的 onDraw() 在繪制Item之前調用,ItemDecoration的 onDrawOver() 在繪制Item之后調用。
當然,如果只需要實現Item之間相隔一定距離,那么只需要為Item的布局設置margin即可,沒必要自己實現ItemDecoration這么麻煩。
Layout Manager
LayoutManager負責RecyclerView的布局,其中包含了Item View的獲取與回收。這里我們簡單分析LinearLayoutManager的實現。
對于LinearLayoutManager來說,比較重要的幾個方法有:
-
onLayoutChildren() : 對RecyclerView進行布局的入口方法。
-
fill() : 負責填充RecyclerView。
-
scrollVerticallyBy() :根據手指的移動滑動一定距離,并調用 fill() 填充。
-
canScrollVertically() 或 canScrollHorizontally() : 判斷是否支持縱向滑動或橫向滑動。
onLayoutChildren() 的核心實現如下:
RecyclerView的回收機制有個重要的概念,即將回收站分為Scrap Heap和Recycle Pool,其中Scrap Heap的元素可以被直接復用,而不需要調用 onBindViewHolder() 。 detachAndScrapAttachedViews() 會根據情況,將原來的Item View放入Scrap Heap或Recycle Pool,從而在復用時提升效率。
fill() 是對剩余空間不斷地調用 layoutChunk() ,直到填充完為止。 layoutChunk() 的核心實現如下:
其中 next() 調用了 getViewForPosition(currentPosition) ,該方法是從RecyclerView的回收機制實現類Recycler中獲取合適的View,在后文的回收機制中會介紹該方法的具體實現。
如果要自定義LayoutManager,可以參考:
創建一個 RecyclerView LayoutManager – Part 1
https://github.com/hehonghui/android-tech-frontier/blob/master/issue-9/%E5%88%9B%E5%BB%BA-RecyclerView-LayoutManager-Part-1.md
創建一個 RecyclerView LayoutManager – Part 2
https://github.com/hehonghui/android-tech-frontier/blob/master/issue-13/%E5%88%9B%E5%BB%BA-RecyclerView-LayoutManager-Part-2.md
創建一個 RecyclerView LayoutManager – Part 3
https://github.com/hehonghui/android-tech-frontier/blob/master/issue-13/%E5%88%9B%E5%BB%BA-RecyclerView-LayoutManager-Part-3.md
Item Animator
RecyclerView能夠通過 mRecyclerView.setItemAnimator(ItemAnimator animator) 設置添加、刪除、移動、改變的動畫效果。RecyclerView提供了默認的ItemAnimator實現類:DefaultItemAnimator。這里我們通過分析DefaultItemAnimator的源碼來介紹如何自定義Item Animator。
DefaultItemAnimator繼承自SimpleItemAnimator,SimpleItemAnimator繼承自ItemAnimator。
首先我們介紹ItemAnimator類的幾個重要方法:
-
animateAppearance() : 當ViewHolder出現在屏幕上時被調用(可能是add或move)。
-
animateDisappearance() : 當ViewHolder消失在屏幕上時被調用(可能是remove或move)。
-
animatePersistence() : 在沒調用 notifyItemChanged() 和 notifyDataSetChanged() 的情況下布局發生改變時被調用。
-
animateChange() : 在顯式調用 notifyItemChanged() 或 notifyDataSetChanged() 時被調用。
-
runPendingAnimations(): RecyclerView動畫的執行方式并不是立即執行,而是每幀執行一次,比如兩幀之間添加了多個Item,則會將這些將要執行的動畫Pending住,保存在成員變量中,等到下一幀一起執行。該方法執行的前提是前面 animateXxx() 返回true。
-
isRunning(): 是否有動畫要執行或正在執行。
-
dispatchAnimationsFinished(): 當全部動畫執行完畢時被調用。
上面用斜體字標識的方法比較難懂,不過沒關系,因為Android提供了SimpleItemAnimator類(繼承自ItemAnimator),該類提供了一系列更易懂的API,在自定義Item Animator時只需要繼承SimpleItemAnimator即可:
-
animateAdd(ViewHolder holder): 當Item添加時被調用。
-
animateMove(ViewHolder holder, int fromX, int fromY, int toX, int toY): 當Item移動時被調用。
-
animateRemove(ViewHolder holder): 當Item刪除時被調用。
-
animateChange(ViewHolder oldHolder, ViewHolder newHolder, int fromLeft, int fromTop, int toLeft, int toTop): 當顯式調用 notifyItemChanged() 或 notifyDataSetChanged() 時被調用。
對于以上四個方法,注意兩點:
-
當Xxx動畫開始執行前(在 runPendingAnimations() 中)需要調用 dispatchXxxStarting(holder) ,執行完后需要調用 dispatchXxxFinished(holder) 。
-
這些方法的內部實際上并不是書寫執行動畫的代碼,而是將需要執行動畫的Item全部存入成員變量中,并且返回值為true,然后在 runPendingAnimations() 中一并執行。
DefaultItemAnimator類是RecyclerView提供的默認動畫類。我們通過閱讀該類源碼學習如何自定義Item Animator。我們先看DefaultItemAnimator的成員變量:
DefaultItemAnimator實現了SimpleItemAnimator的 animateAdd() 方法,該方法只是將該item添加到mPendingAdditions中,等到 runPendingAnimations() 中執行。
接著看 runPendingAnimations() 的實現,該方法是執行remove,move,change,add動畫,執行順序為:remove動畫最先執行,隨后move和change并行執行,最后是add動畫。為了簡化,我們將remove,move,change動畫執行過程省略,只看執行add動畫的過程,如下:
為了防止在執行add動畫時外面有新的add動畫添加到mPendingAdditions中,從而導致執行add動畫錯亂,這里將mPendingAdditions的內容移動到局部變量additions中,然后遍歷additions執行動畫。
在 runPendingAnimations() 中, animateAddImpl() 是執行add動畫的具體方法,其實就是將itemView的透明度從0變到1(在 animateAdd() 中已經將view的透明度變為0),實現如下:
從DefaultItemAnimator類的實現來看,發現自定義Item Animator好麻煩,需要繼承SimpleItemAnimator類,然后實現一堆方法。別急,recyclerview-animators解救你,原因如下:
首先,recyclerview-animators提供了一系列的Animator,比如FadeInAnimator,ScaleInAnimator。其次,如果該庫中沒有你滿意的動畫,該庫提供了BaseItemAnimator類,該類繼承自SimpleItemAnimator,進一步封裝了自定義Item Animator的代碼,使得自定義Item Animator更方便,你只需要關注動畫本身。如果要實現DefaultItemAnimator的代碼,只需要以下實現:
是不是比繼承SimpleItemAnimator方便多了。
對于RecyclerView的Item Animator,有一個常見的坑就是”閃屏問題”。這個問題的描述是:當Item視圖中有圖片和文字,當更新文字并調用 notifyItemChanged() 時,文字改變的同時圖片會閃一下。這個問題的原因是當調用 notifyItemChanged() 時,會調用DefaultItemAnimator的 animateChangeImpl() 執行change動畫,該動畫會使得Item的透明度從0變為1,從而造成閃屏。
解決辦法很簡單,在 rv.setAdapter() 之前調用 ((SimpleItemAnimator)rv.getItemAnimator()).setSupportsChangeAnimations(false) 禁用change動畫。
拓展RecyclerView
添加setOnItemClickListener接口
RecyclerView默認沒有像ListView一樣提供 setOnItemClickListener() 接口,而 RecyclerView無法添加onItemClickListener最佳的高效解決方案 (http://blog.csdn.net/liaoinstan/article/details/51200600)這篇文章給出了通過 recyclerView.addOnItemTouchListener(...) 添加點擊事件的方法,但我認為根本沒有必要費這么大勁對外暴露這個接口,因為我們完全可以把點擊事件的實現寫在Adapter的 onBindViewHolder() 中,不暴露出來。具體方法就是通過:
public void onBindViewHolder(VH holder, int position) { holder.itemView.setOnClickListener(...); }
添加HeaderView和FooterView
RecyclerView默認沒有提供類似 addHeaderView() 和 addFooterView() 的API,因此這里介紹如何優雅地實現這兩個接口。
如果你已經實現了一個Adapter,現在想為這個Adapter添加 addHeaderView() 和 addFooterView() 接口,則需要在Adapter中添加幾個Item Type,然后修改 getItemViewType() , onCreateViewHolder() , onBindViewHolder() , getItemCount() 等方法,并添加switch語句進行判斷。那么如何在不破壞原有Adapter實現的情況下完成呢?
這里引入裝飾器(Decorator)設計模式,該設計模式通過組合的方式,在不破話原有類代碼的情況下,對原有類的功能進行擴展。
這恰恰滿足了我們的需求。我們只需要通過以下方式為原有的Adapter(這里命名為NormalAdapter)添加 addHeaderView() 和 addFooterView() 接口:
是不是看起來特別優雅。具體實現思路其實很簡單,創建一個繼承 RecyclerView.Adapter<RecyclerView.ViewHolder> 的類,并重寫常見的方法,然后通過引入ITEM TYPE的方式實現:
添加setEmptyView
ListView提供了 setEmptyView() 設置Adapter數據為空時的View視圖。RecyclerView雖然沒提供直接的API,但是也可以很簡單地實現。
-
創建一個繼承RecyclerView的類,記為EmptyRecyclerView。
-
通過 getRootView().addView(emptyView) 將空數據時顯示的View添加到當前View的層次結構中。
-
通過AdapterDataObserver監聽RecyclerView的數據變化,如果adapter為空,那么隱藏RecyclerView,顯示EmptyView。
具體實現如下:
拖拽、側滑刪除
Android提供了ItemTouchHelper類,使得RecyclerView能夠輕易地實現滑動和拖拽,此處我們要實現上下拖拽和側滑刪除。首先創建一個繼承自 ItemTouchHelper.Callback 的類,并重寫以下方法:
-
getMovementFlags() : 設置支持的拖拽和滑動的方向,此處我們支持的拖拽方向為上下,滑動方向為從左到右和從右到左,內部通過 makeMovementFlags() 設置。
-
onMove() : 拖拽時回調。
-
onSwiped() : 滑動時回調。
-
onSelectedChanged() : 狀態變化時回調,一共有三個狀態,分別是ACTION_STATE_IDLE(空閑狀態),ACTION_STATE_SWIPE(滑動狀態),ACTION_STATE_DRAG(拖拽狀態)。此方法中可以做一些狀態變化時的處理,比如拖拽的時候修改背景色。
-
clearView() : 用戶交互結束時回調。此方法可以做一些狀態的清空,比如拖拽結束后還原背景色。
-
isLongPressDragEnabled() : 是否支持長按拖拽,默認為true。如果不想支持長按拖拽,則重寫并返回false。
具體實現如下:
然后通過以下代碼為RecyclerView設置該滑動、拖拽功能:
ItemTouchHelper helper = new ItemTouchHelper(new SimpleItemTouchCallback(adapter, data)); helper.attachToRecyclerView(recyclerview);
前面拖拽的觸發方式只有長按,如果想支持觸摸Item中的某個View實現拖拽,則核心方法為 helper.startDrag(holder) 。首先定義接口:
interface OnStartDragListener{ void startDrag(RecyclerView.ViewHolder holder); }
然后讓Activity實現該接口:
public MainActivity extends Activity implements OnStartDragListener{ ... public void startDrag(RecyclerView.ViewHolder holder) { mHelper.startDrag(holder); } }
如果要對ViewHolder的text對象支持觸摸拖拽,則在Adapter中的 onBindViewHolder() 中添加:
其中mListener是在創建Adapter時將實現OnStartDragListener接口的Activity對象作為參數傳進來。
回收機制
ListView回收機制
ListView為了保證Item View的復用,實現了一套回收機制,該回收機制的實現類是RecycleBin,他實現了兩級緩存:
-
View[] mActiveViews : 緩存屏幕上的View,在該緩存里的View不需要調用 getView() 。
-
ArrayList<View>[] mScrapViews; : 每個Item Type對應一個列表作為回收站,緩存由于滾動而消失的View,此處的View如果被復用,會以參數的形式傳給 getView() 。
接下來我們通過源碼分析ListView是如何與RecycleBin交互的。其實ListView和RecyclerView的layout過程大同小異,ListView的布局函數是 layoutChildren() ,實現如下:
其中 fillXxx() 實現了對Item View進行填充,該方法內部調用了 makeAndAddView() ,實現如下:
其中, getActiveView() 是從mActiveViews中獲取合適的View,如果獲取到了,則直接返回,而不調用 obtainView() ,這也印證了如果從mActiveViews獲取到了可復用的View,則不需要調用 getView() 。
obtainView() 是從mScrapViews中獲取合適的View,然后以參數形式傳給了 getView() ,實現如下:
接下去我們介紹 getScrapView(position) 的實現,該方法通過position得到Item Type,然后根據Item Type從mScrapViews獲取可復用的View,如果獲取不到,則返回null,具體實現如下:
RecyclerView回收機制
RecyclerView和ListView的回收機制非常相似,但是ListView是以View作為單位進行回收,RecyclerView是以ViewHolder作為單位進行回收。Recycler是RecyclerView回收機制的實現類,他實現了四級緩存:
-
mAttachedScrap: 緩存在屏幕上的ViewHolder。
-
mCachedViews: 緩存屏幕外的ViewHolder,默認為2個。ListView對于屏幕外的緩存都會調用 getView() 。
-
mViewCacheExtensions: 需要用戶定制,默認不實現。
-
mRecyclerPool: 緩存池,多個RecyclerView共用。
在上文Layout Manager中已經介紹了RecyclerView的layout過程,但是一筆帶過了 getViewForPosition() ,因此此處介紹該方法的實現。
從上述實現可以看出,依次從mAttachedScrap, mCachedViews, mViewCacheExtension, mRecyclerPool尋找可復用的ViewHolder,如果是從mAttachedScrap或mCachedViews中獲取的ViewHolder,則不會調用 onBindViewHolder() ,mAttachedScrap和mCachedViews也就是我們所說的Scrap Heap;而如果從mViewCacheExtension或mRecyclerPool中獲取的ViewHolder,則會調用 onBindViewHolder() 。
RecyclerView局部刷新的實現原理也是基于RecyclerView的回收機制,即能直接復用的ViewHolder就不調用 onBindViewHolder() 。
嵌套滑動機制
Android 5.0推出了嵌套滑動機制,在之前,一旦子View處理了觸摸事件,父View就沒有機會再處理這次的觸摸事件,而嵌套滑動機制解決了這個問題,能夠實現如下效果:
為了支持嵌套滑動,子View必須實現NestedScrollingChild接口,父View必須實現NestedScrollingParent接口,而RecyclerView實現了NestedScrollingChild接口,而CoordinatorLayout實現了NestedScrollingParent接口,上圖是實現CoordinatorLayout嵌套RecyclerView的效果。
為了實現上圖的效果,需要用到的組件有:
-
CoordinatorLayout: 布局根元素。
-
AppBarLayout: 包裹的內容作為應用的Bar。
-
CollapsingToolbarLayout: 實現可折疊的ToolBar。
-
ToolBar: 代替ActionBar。
實現中需要注意的點有:
-
我們為ToolBar的 app:layout_collapseMode 設置為pin,表示折疊之后固定在頂端,而為ImageView的 app:layout_collapseMode 設置為parallax,表示視差模式,即漸變的效果。
-
為了讓RecyclerView支持嵌套滑動,還需要為它設置 app:layout_behavior="@string/appbar_scrolling_view_behavior" 。
-
為CollapsingToolbarLayout設置 app:layout_scrollFlags="scroll|exitUntilCollapsed" ,其中scroll表示滾動出屏幕,exitUntilCollapsed表示退出后折疊。
具體實現參見Demo6。
回顧
回顧整篇文章,發現我們已經實現了RecyclerView的很多擴展功能,包括:打造萬能適配器、添加Item事件、添加頭視圖和尾視圖、設置空布局、側滑拖拽。BaseRecyclerViewAdapterHelper是一個比較火的RecyclerView擴展庫,仔細一看發現,這里面80%的功能在我們這篇文章中都實現了。
來自:http://mp.weixin.qq.com/s/CzrKotyupXbYY6EY2HP_dA
-