listview源碼學習

分類:技術 時間:2016-10-25

前言

本文從源碼角度出發學習listview,主要分析首次RecycleBin的組成,layout的過程,滑動過程,item的點擊實現,如何支持Header,notifyDataSetChanged原理。

問題

用了好幾年的listview,有幾個問題卻一直不清楚

1、如何讓一個itemview不被回收,比如我的listview里有個viewpager比較復雜,不想讓他被回收又重新創建。

2、head和foot的原理是怎么樣的,會被回收嗎?

3、scrap里的view會被進一步回收掉嗎?

基礎知識

讀了3遍郭神的 http://blog.csdn.net/sinyu890807/article/details/44996879 真是受益匪淺,原來listview是這么實現的。

listview的實現方法跟scrollview完全不同,scrollview是內部實例化了所有的view,在滾動的時候只是改變可見的部分,scrollview的高度可能是幾千幾萬。如果item數很多的話,必然會oom。

而listview是首先畫出listview的殼,然后去adapter里取數據,取到數據inflate為view,填到listview里面去,填滿了就好了。即使adapter里有1萬個數據,第一次layout的時候取的也是很少的數據(看當前屏幕需要,假設10個)。然后在上滑的過程中,首先用offsetTopAndBottom對所有child進行移動,此時頂部view就會滑出部分,那么底部會出現gap,再去adapter里面撈數據,填到底部;然后頂部的view逐漸的被完全移出屏幕,先detach,然后把這個view丟到scrap里面去,繼續滑動底部又出現了gap,就去scrap里面拿現成的view。如此往復循環,這就是listview的原理。

和scrollview對比,listview的滑動過程中伴隨著view的detach,attach,但是這些都不是耗時的東西,時間上沒什么損失,但是空間上減少了大量的內存開銷。先分析下layout過程和滑動過程。

listview內的緩存主要就是scrap,離屏就會進入scrap。scrap在layout的時候會進行裁剪,去調尾部的一些view,但是實際上這種情況發生的不多,后邊會詳細說。

layout過程

我測試了下layout的次數,郭神文章說的是2次,我這里會有3次。

第一次layout

onLayout -gt; layoutChildren -gt; fillFromTop-gt; fillDown-gt; while() makeAndAddView

makeAndAddView -gt;  
                    1、obtainView  -gt; getView -gt; inflate       
                    2、setupChild  -gt; addViewInLayout
                                   -gt;child.measure
                                   -gt;child.layout

第二次layout

onLayout -gt; layoutChildren -gt; 
1、fillActiveViews   
2、detachAllViewsFromParent  
3、fillSpecific-gt; fillDown-gt;while() makeAndAddView

        makeAndAddView -gt; getActiveView
                          -gt;setupChild -gt; attachViewToParent

第三次layout

onLayout -gt; layoutChildren -gt; 
1、fillActiveViews   
2、detachAllViewsFromParent  
3、fillSpecific-gt; fillDown-gt;while() makeAndAddView

        makeAndAddView -gt; getActiveView
                          -gt;setupChild -gt; attachViewToParent    
                                       -gt;child.measure
                                       -gt;child.layout

可以看到后2次基本差不多,區別在于在setupChild內是否要執行child的measure和layout。為什么第3次layout會調用measure和layout,而第二次不會呢?看下邊的代碼,差別就在于第三次child.isLayoutRequested()變為了true。

//setupChild
final boolean needToMeasure = !recycled || updateChildSelected || child.isLayoutRequested();

我在重新捋一下流程

1、ViewRootImpl#dispatchResized被調用,發出MSG_RESIZED_REPORT消息

2、第一次ListView:layoutChildren

3、第二次ListView:layoutChildren

4、收到發出MSG_RESIZED_REPORT消息,ViewRootImpl#forceLayout

5、第三次ListView:layoutChildren

所以第三次ListView:layoutChildren的時候會觸發child.measure和child.layout。奇怪的是,每次都是第2次layout之后收到MSG_RESIZED_REPORT消息

滑動

對移除屏幕的view addScrapView、detachViewsFromParent

對屏幕內的view offsetChildrenTopAndBottom

對屏幕內空白的地方 fillGap -gt; fillDown-gt;while() makeAndAddView

makeAndAddView -gt; obtainView、setupChild

obtainView-》getScrapView-》adapter.getView(convertview....)

void fillGap(boolean down) {  
    final int count = getChildCount();  
    if (down) {  
        final int startOffset = count gt; 0 ? getChildAt(count - 1).getBottom()   mDividerHeight :  
                getListPaddingTop();  
        //手指向上滑動,所以需要填充底部        
        fillDown(mFirstPosition   count, startOffset);  
        correctTooHigh(getChildCount());  
    } else {  
        final int startOffset = count gt; 0 ? getChildAt(0).getTop() - mDividerHeight :  
                getHeight() - getListPaddingBottom();  
        fillUp(mFirstPosition - 1, startOffset);  
        correctTooLow(getChildCount());  
    }  
}

滑動過程中不會調用onMeasure或者onLayout

RecycleBin基本成員與方法

view的回收復用主要就依靠RecycleBin,所以重點分析下RecycleBin

mScrapViews

RecycleBin內有個垃圾箱,mScrapViews用來存放移除屏幕的view。

private ArrayListlt;Viewgt;[] mScrapViews;
 private ArrayListlt;Viewgt; mCurrentScrap = mScrapViews[0];;

為什么是個數組呢?數組的每一項都是個ArrayListlt;Viewgt;,代表著某個type的垃圾view集合.如果只有一種type,那么垃圾都存在mScrapViews[0]內,mCurrentScrap = scrapViews[0];如果只有一個類型,我們直接操作mCurrentScrap即可

addScrapView

addScrapView就是把一個view加入到垃圾箱內,一般在view離開屏幕的時候調用。如果數據未變,adapter有stable IDs,有暫態,那就不會被收到垃圾箱里,會存著備用。。如果是header、footer那么就放入mSkippedScrap內,不放入mScrapViews。如果是暫態而且有有stable IDs,就丟到mTransientStateViewsById里面去。如果不需要stable IDs,數據未變可以丟到mTransientStateViews

/**
         * Puts a view into the list of scrap views.
         * lt;pgt;
         * If the list data hasn't changed or the adapter has stable IDs, views
         * with transient state will be preserved for later retrieval.
         *
         * @param scrap The view to add
         * @param position The view's position within its parent
         */
        void addScrapView(View scrap, int position) {
            final AbsListView.LayoutParams lp = (AbsListView.LayoutParams) scrap.getLayoutParams();
            if (lp == null) {
                // Can't recycle, but we don't know anything about the view.
                // Ignore it completely.
                return;
            }

            lp.scrappedFromPosition = position;

            // Remove but don't scrap header or footer views, or views that
            // should otherwise not be recycled.
            final int viewType = lp.viewType;
            if (!shouldRecycleViewType(viewType)) {
                // Can't recycle. If it's not a header or footer, which have
                // special handling and should be ignored, then skip the scrap
                // heap and we'll fully detach the view later.
                if (viewType != ITEM_VIEW_TYPE_HEADER_OR_FOOTER) {
                    getSkippedScrap().add(scrap);
                }
                return;
            }

            scrap.dispatchStartTemporaryDetach();

            // The the accessibility state of the view may change while temporary
            // detached and we do not allow detached views to fire accessibility
            // events. So we are announcing that the subtree changed giving a chance
            // to clients holding on to a view in this subtree to refresh it.
            notifyViewAccessibilityStateChangedIfNeeded(
                    AccessibilityEvent.CONTENT_CHANGE_TYPE_SUBTREE);

            // Don't scrap views that have transient state.
            final boolean scrapHasTransientState = scrap.hasTransientState();
            if (scrapHasTransientState) {
                if (mAdapter != null  mAdapterHasStableIds) {
                    // If the adapter has stable IDs, we can reuse the view for
                    // the same data.
                    //是暫態view,并且需要stable ID就丟到mTransientStateViewsById里面去
                    if (mTransientStateViewsById == null) {
                        mTransientStateViewsById = new LongSparseArraylt;gt;();
                    }
                    mTransientStateViewsById.put(lp.itemId, scrap);
                } else if (!mDataChanged) {
                    // If the data hasn't changed, we can reuse the views at
                    // their old positions.
                    if (mTransientStateViews == null) {
                        mTransientStateViews = new SparseArraylt;gt;();
                    }
                    //數據未變可以丟到mTransientStateViews
                    mTransientStateViews.put(position, scrap);
                } else {
                    // Otherwise, we'll have to remove the view and start over.
                    getSkippedScrap().add(scrap);
                }
            } else {
                if (mViewTypeCount == 1) {
                    mCurrentScrap.add(scrap);
                } else {
                    mScrapViews[viewType].add(scrap);
                }

                if (mRecyclerListener != null) {
                    mRecyclerListener.onMovedToScrapHeap(scrap);
                }
            }
        }

retrieveFromScrap

從scrap里取view,核心代碼如下,如果是固定id的,那就根據adapter的id來找,否則就根據scrappedFromPosition 來找,比如第7個item被回收到scrap里了,記下這個view的scrappedFromPosition為7, 那下次滑回第7個item,就盡量給scrappedFromPosition為7的view給他,簡單的說就是從哪里回收來的,還回哪里去。如果根據scrappedFromPosition找不到,那就直接取scrap的最后一個

if (mAdapterHasStableIds) {
                        final long id = mAdapter.getItemId(position);
                        if (id == params.itemId) {
                            return scrapViews.remove(i);
                        }
                    } else if (params.scrappedFromPosition == position) {
                        final View scrap = scrapViews.remove(i);
                        clearAccessibilityFromScrap(scrap);
                        return scrap;
                    }
        final View scrap = scrapViews.remove(size - 1);            
        return scrap;

activeViews

這個有什么意義,沒看懂。根據上面的分析,在第二次layout的過程中,首先會把當前屏幕的itemview給detach掉,扔到activeViews內,然后又把他們抓出來,給attach上,此時activeViews必定為空,如果不為空,把殘余的view丟到mScrapViews內(scrapActiveViews) 我實在不明白這么搞有什么意義。

shouldRecycleViewType

根據type類型來確定這個view是否能回收,type類型一般可以在adapter里指定,但是系統默認提供了2個類型,一個是ITEM_VIEW_TYPE_IGNORE=-1,一個是ITEM_VIEW_TYPE_HEADER_OR_FOOTER=-2。第二個很明顯就是listview的頭和尾。第一個是什么呢?如果我們希望某個view不被回收的話,可以設置ITEM_VIEW_TYPE_IGNORE,這樣就可以了。(recyclerView有類似的嗎?)

public boolean shouldRecycleViewType(int viewType) {
            return viewType gt;= 0;
        }

mRecyclerListener

當發生View回收時,mRecyclerListener若有注冊,則會通知給注冊者.RecyclerListener接口只有一個函數onMovedToScrapHeap,指明某個view被回收到了scrap heap.可以在這個接口回調里進行昂貴資源的回收(比如bitmap)。可以直接用listview來注冊監聽者.

listview.setRecyclerListener(new AbsListView.RecyclerListener() {
            @Override
            public void onMovedToScrapHeap(View view) {
            }
        });

點擊item

點擊一個item,是和第二次layout類似的,會調用layoutChildren,然后把界面上的view抓起來丟到activeViews內,然后又重新填充,setupChild內不會調用measure和layout

onTouchUp -gt; layoutChildren -gt; 
1、fillActiveViews   
2、detachAllViewsFromParent  
3、fillSpecific-gt; fillDown-gt;while() makeAndAddView
        makeAndAddView -gt; getActiveView
                          -gt;setupChild -gt; attachViewToParent

跟第二次layout的區別就是沒有調用child的onMeasure和onLayout,關鍵代碼如下,這里needToMeasure為false

final boolean needToMeasure = !recycled || updateChildSelected || child.isLayoutRequested();

Header

我們可以輕易的給使用addHeaderView一個listview加上header。

我知道 addHeaderView必須在setAdapter之前,可以add多個head

那么問題來了,為什么addHeaderView必須在setAdapter之前?

看setAdapter的部分代碼可以明白,如果之前設置了header,那mAdapter將會被包裝起來HeaderViewListAdapter

//setAdapter
        if (mHeaderViewInfos.size() gt; 0|| mFooterViewInfos.size() gt; 0) {
            mAdapter = new HeaderViewListAdapter(mHeaderViewInfos, mFooterViewInfos, adapter);
        } else {
            mAdapter = adapter;
        }

再看看HeaderViewListAdapter,看下邊的代碼可以看到實際上HeaderViewListAdapter實現了Adapter的各種接口,比如getCount,getItem,getItemViewType,getView,這就是把原來的adapter進行包裝,然后實現對應接口,把Header作為一種特殊類型AdapterView.ITEM_VIEW_TYPE_HEADER_OR_FOOTER,在ListView看來,他就是一個普通的adapter。

//HeaderViewListAdapter
public class HeaderViewListAdapter implements WrapperListAdapter, Filterable {

    public int getCount() {
        if (mAdapter != null) {
            return getFootersCount()   getHeadersCount()   mAdapter.getCount();
        } else {
            return getFootersCount()   getHeadersCount();
        }
    }

    public Object getItem(int position) {
        // Header (negative positions will throw an IndexOutOfBoundsException)
        int numHeaders = getHeadersCount();
        if (position lt; numHeaders) {
            return mHeaderViewInfos.get(position).data;
        }

        // Adapter
        final int adjPosition = position - numHeaders;
        int adapterCount = 0;
        if (mAdapter != null) {
            adapterCount = mAdapter.getCount();
            if (adjPosition lt; adapterCount) {
                return mAdapter.getItem(adjPosition);
            }
        }

        // Footer (off-limits positions will throw an IndexOutOfBoundsException)
        return mFooterViewInfos.get(adjPosition - adapterCount).data;
    }
    public View getView(int position, View convertView, ViewGroup parent) {
        // Header (negative positions will throw an IndexOutOfBoundsException)
        int numHeaders = getHeadersCount();
        if (position lt; numHeaders) {
            return mHeaderViewInfos.get(position).view;
        }

        // Adapter
        final int adjPosition = position - numHeaders;
        int adapterCount = 0;
        if (mAdapter != null) {
            adapterCount = mAdapter.getCount();
            if (adjPosition lt; adapterCount) {
                return mAdapter.getView(adjPosition, convertView, parent);
            }
        }

        // Footer (off-limits positions will throw an IndexOutOfBoundsException)
        return mFooterViewInfos.get(adjPosition - adapterCount).view;
    }

    public int getItemViewType(int position) {
        int numHeaders = getHeadersCount();
        if (mAdapter != null  position gt;= numHeaders) {
            int adjPosition = position - numHeaders;
            int adapterCount = mAdapter.getCount();
            if (adjPosition lt; adapterCount) {
                return mAdapter.getItemViewType(adjPosition);
            }
        }

        return AdapterView.ITEM_VIEW_TYPE_HEADER_OR_FOOTER;
    }
}

我們在分析下header是否會被多次創建,是否會被丟到scrap里去

首先看,滑動的時候會不會回收header,這里明顯可以看到position的限制,header和footer是不會被回收的。既然不會回收,那下次再滑到header的時候還是找adapter要,看上邊的adapte的getView代碼,mHeaderViewInfos.get(position).view,只是從mHeaderViewInfos.get內取,所以header是不會被回收的,永遠存在mHeaderViewInfos里面。這里可以得到啟發,Recyclerview是不支持header,footer的,那我們是不是可以針對Recyclerview來一次類似的包裝,讓他支持header,footer

//trackMotionScroll
                    if (position gt;= headerViewsCount  position lt; footerViewsStart) {
                        // The view will be rebound to new data, clear any
                        // system-managed transient state.
                        child.clearAccessibilityFocus();
                        mRecycler.addScrapView(child, position);
                    }

notifyDataSetChanged

之前一直沒有說過,數據發生變化的情況會怎么樣,我們都知道,數據發生變化調用adpater的notifyDataSetChanged就會刷新界面。這里面的原理是什么? 這里面有個觀察者模式,BaseAdapter內有個mDataSetObservable,AbsListView在onAttachedToWindow的時候會注冊觀察者,代碼如下,這樣就注冊了一個觀察者mDataSetObserver

//AbsListView#onAttachedToWindow
   if (mAdapter != null  mDataSetObserver == null) {
            mDataSetObserver = new AdapterDataSetObserver();
            mAdapter.registerDataSetObserver(mDataSetObserver);

            // Data may have changed while we were detached. Refresh.
            mDataChanged = true;
            mOldItemCount = mItemCount;
            mItemCount = mAdapter.getCount();
        }

notifyDataSetChanged會調用mDataSetObserver.onChanged,里面更新了mItemCount,然后調用了rememberSyncState和requestLayout。

@Override
        public void onChanged() {
            mDataChanged = true;
            mOldItemCount = mItemCount;
            mItemCount = getAdapter().getCount();

            // Detect the case where a cursor that was previously invalidated has
            // been repopulated with new data.
            if (AdapterView.this.getAdapter().hasStableIds()  mInstanceState != null
                     mOldItemCount == 0  mItemCount gt; 0) {
                AdapterView.this.onRestoreInstanceState(mInstanceState);
                mInstanceState = null;
            } else {
                rememberSyncState();
            }
            checkFocus();
            requestLayout();
        }

這里rememberSyncState比較陌生,實際上他做的事情也很少,主要就2行代碼,設置mSyncMode和mSyncPosition。重新布局的時候默認有個原則,之前誰在第一個,那么這次誰還是在第一個.

mSyncPosition = mFirstPosition;
      mSyncMode = SYNC_FIRST_POSITION;

然后我們看又一次layout的過程

首先,handleDataChanged會定下mSyncPosition,然后把

mLayoutMode = LAYOUT_SYNC;,這個mLayoutMode后邊會用到

第二步,因為dataChanged所以這里直接把所有的界面上的view丟到scrap里,不像以前放在activeViews里

第三步,detachAllViewsFromParent

第四步,fillSpecific是因為mLayoutMode是LAYOUT_SYNC所以直接調用fillSpecific。

里面的getView是adapter的getView一般在這里設置實際view的內容(比如文本圖片)。所以view一般都會設置為PFLAG_FORCE_LAYOUT,所以會重新measure、layout。(這里可以再思考下,其實大部分情況下,重用view,并不用重新measure,而layout的時候只要把item往listview的框里丟就可以了,item內部也不需要layout,這樣應該能夠提供效率,但是看了代碼后發現setupChild內是根據needToMeasure來決定是否measure、layout的,不能分別對待,哎。)

onLayout -gt; layoutChildren -gt; 
1、handleDataChanged:定個mSyncPosition、mLayoutMode = LAYOUT_SYNC;
2、for() addScrapView
3、detachAllViewsFromParent  
4、fillSpecific-gt; fillDown-gt;while() makeAndAddView

        makeAndAddView -gt; obtainView-gt;getView
                          -gt;setupChild -gt; attachViewToParent    
                                       -gt;child.measure
                                       -gt;child.layout

這次layout跟之前的區別主要是第二步和第四步。

listview動畫錯亂

listview的item如果在執行動畫的同時,listview在滑動,我們知道listview滑動過程中,是會重用view的,所以可能本來針對position 為1的動畫,跑到position為11的地方去了,所以我們得禁止這個view進入scrap,如何禁止?

setHasTransientState(true),讓view進入暫態

setHasTransientState是API16引入的函數,在View里,下邊是對他的介紹,主要是用于動畫開始和結束,在開始的時候setHasTransientState(true),結束的時候setHasTransientState(false),在這之間就是暫態的。

常見用法如下,在動畫開始的時候進入暫態,動畫結束退出暫態。我們再對比listview的代碼可以發現,進入暫態的view不會進入scrap,而是進入mTransientStateViewsById這個LongSparseArray內,這樣就不會被重用而導致動畫錯亂了。

//Listview 的 OnItemClickListener 的內容
//本范例點了 item 后會淡出并刪除該 item
public void onItemClick(AdapterView

  parent, final View view, int position, long id) {
    final String item = (String) parent.getItemAtPosition(position);
    ObjectAnimator anim = ObjectAnimator.ofFloat(view, View.ALPHA, 0);
    anim.setDuration(500);
    view.setHasTransientState(true); //設為 true 宣告 item 要被追蹤
    anim.addListener(new AnimatorListenerAdapter() {
        @Override
        public void onAnimationEnd(Animator animation) {
            myListview.remove(item);
            adapter.notifyDataSetChanged(); //重新整理 listview
            view.setAlpha(1);
            view.setHasTransientState(false); //完成后設定回 false
        }
    });
    anim.start();
}

不可否認這是一種解決方法,但并不是好的解決方法,因為item都滑出去了,還在搞動畫,沒啥意義,真正好的方法是什么,如下所示,在onMovedToScrapHeap里面停止動畫,這才是最合適的。

listView.setRecyclerListener(new RecyclerListener() {
        @Override
        public void onMovedToScrapHeap(View view) {
            // Stop animation on this view
        }
});

hasStableIds

adapter有個接口叫hasStableIds,這個有什么意義,我查了資料和代碼。發現hasStableIds一般情況下都是false,只有2個情況是true。

什么樣的adapter是stable的,個人以為是里面的數據的id不變化,數據可以變化,但是id不能變化。

首先,如果要用到listview的選中功能時,只有hasStableIds返回true,才能通過getCheckedItemIds方法才能正常獲取用戶選中的選項的id(當然adapter內必須復寫getItemId)。

還有個地方就是CursorAdapter,因為cursor是sql查詢的結果,所以說是stable的無可厚非。CursorAdapter里面的hasStableIds就是返回true的。

總的來說hasStableIds沒啥用,我也沒看到改為true能優化什么。

問題

addViewInLayout和attachViewToParent有什么區別呢

addViewInLayout和attachViewToParent兩者接收的參數是一樣的,主要功能也相似,也就是往ViewGroup的view數組里添加View, 但是調用addViewInLayout會使被添加的View在界面上添加時會有動畫效果呈現。兩者的使用場景差別也很明顯了:一般來說某一個View第一次添加進ViewGroup時比較適合調用addViewInLayout,而以后同一個View再次被添加時則適合使用attachViewToParent。因為一般情況想我們會希望進入的動畫效果執行一次就夠了,而不需要多次執行。

具體可參考 http://www.itdadao.com/articles/c15a444236p0.html

scap有數量限制嗎

在滑動過程中scrap是沒限制的,但是在layout的過程中調用scrapActiveViews-gt;pruneScrapViews,在這里會把mScrapViews內的每組緩存,都限制在mActiveViews.length大小。

scrap里的view會被去掉嗎

為什么要考慮這個問題呢?因為有的view創建成本很高,我們不希望重復創建,什么情況下會重復創建呢?那就是view離屏,進scrap,scrap裁剪,被裁剪的view就沒有了,下次必須重新inflate出來。

我看了下要想remove scrap里的view,只有pruneScrapViews和clear方法,clear在設置setAdapter(ListAdapter)和onDetachedFromWindow()時會被調用。而pruneScrapViews是在layout過程中被調的。所以主要看pruneScrapViews。這里可以看到其實處理scrap長度的方法是比較粗暴的,查一下mActiveViews.length,任何一個scrap堆都不準超過這個長度,否則直接截尾。于是我又看了下mActiveViews,每次顯示在界面上的view都會丟到mActiveViews里,又發現 mActiveViews只會變長不會變短 。這就有意思了,比如當前頁面有5個item,那mActiveViews.length就是5,待會當前頁面有10個item了,那mActiveViews.length就是10,再過一塊又只有3個item了,那mActiveViews.length還是10(只增不減)。要注意一點只有在layout的時候才會更新mActiveViews.length,如果只是滑來滑去是不會觸發layout的。所以如果,mActiveViews.length值比較小,而scrap的item又很多的話,會進入到L10,進行裁剪(一般會發生在header高度比較大的情況下),這種裁剪方式其實是比較奇怪的,憑什么根據mActiveViews.length來裁剪。

所以 scrap里的view是有可能被丟棄的,但是如果某個scap堆里只有一個view,那放心,他絕不會被丟棄。

另外如果我們希望緩存的view數量多一些的話,我們可以在view比較多的時候掉一遍requestLayout,這樣讓他更新mActiveViews.length

final int maxViews = mActiveViews.length;
            final int viewTypeCount = mViewTypeCount;
            final ArrayListlt;Viewgt;[] scrapViews = mScrapViews;
            for (int i = 0; i lt; viewTypeCount;   i) {
                final ArrayListlt;Viewgt; scrapPile = scrapViews[i];
                int size = scrapPile.size();
                final int extras = size - maxViews;
                size--;
                for (int j = 0; j lt; extras; j  ) {
                    removeDetachedView(scrapPile.remove(size--), false);
                }
            }

其他

1、layoutChildren必定調用invalidate

2、initAbsListView內設置ListView本身可以點擊即可以消耗父View分發的事件: setClickable(true);

3、我們常常用的convertView實際上來自scrapView

ref


Tags: ListView

文章來源:http://www.jianshu.com/p/62bf84c183ac


ads
ads

相關文章
ads

相關文章

ad