Android RecyclerView四級(jí)緩存源碼層詳細(xì)分析
RecyclerView是一個(gè)非常重要的控件,是任何一個(gè)研發(fā)都需要掌握的,這個(gè)控件的設(shè)計(jì)也是非常優(yōu)秀的,值得我們?nèi)W(xué)習(xí)。RecyclerView的核心就是緩存機(jī)制,RecyclerView為了提升效率使用了4級(jí)緩存:
- mChangeScrap與 mAttachedScrap:用來(lái)緩存還在屏幕內(nèi)的 ViewHolder,是ViewHolder的ArrayList 集合。
- mCacheView:緩存將要隱藏ViewHolder 下次將要顯示的ViewHolder 先從這個(gè)緩存里邊獲取,也是ViewHolder的 ArrayList 集合。
- mViewChcheExtension:需要用戶自己實(shí)現(xiàn)的緩存,這一級(jí)系統(tǒng)會(huì)調(diào)用一個(gè)抽象方法,這個(gè)方法需要用戶自己實(shí)現(xiàn)。
- mRecyclerPool:緩存池 ,這個(gè)用戶根據(jù)不同的ViewType保存緩存池 ,這個(gè)緩存池是一個(gè)二維數(shù)組 外部是ScrapData 的SparseArray數(shù)組,內(nèi)部是ArrayList數(shù)組。
1.緩存的使用流程源碼分析-滑動(dòng)入口
當(dāng)用戶在滑動(dòng)Item的時(shí)候會(huì)進(jìn)行ViewHolder的復(fù)用,下面來(lái)看滑動(dòng)方法:RecyclerView的onTouchEvent方法case MotionEvent.ACTION_MOVE
@Override public boolean onTouchEvent(MotionEvent e) { if (mLayoutFrozen || mIgnoreMotionEventTillDown) { return false; } if (dispatchOnItemTouch(e)) { cancelTouch(); return true; } if (mLayout == null) { return false; } ... switch (action) { case MotionEvent.ACTION_DOWN: ... case MotionEvent.ACTION_MOVE: { ... if (mScrollState == SCROLL_STATE_DRAGGING) { mLastTouchX = x - mScrollOffset[0]; mLastTouchY = y - mScrollOffset[1]; //入口在這里 因?yàn)榛瑒?dòng)的時(shí)候會(huì)發(fā)生緩存操作 所以一個(gè)入口在這里 if (scrollByInternal( canScrollHorizontally ? dx : 0, canScrollVertically ? dy : 0, vtev)) { getParent().requestDisallowInterceptTouchEvent(true); } if (mGapWorker != null && (dx != 0 || dy != 0)) { mGapWorker.postFromTraversal(this, dx, dy); } } } break; case MotionEvent.ACTION_POINTER_UP: { onPointerUp(e); } break; case MotionEvent.ACTION_UP: ... vtev.recycle(); return true; }
scrollByInternal 方法就是使用緩存的入口方法
下面來(lái)看scrollByInternal方法
boolean scrollByInternal(int x, int y, MotionEvent ev) { int unconsumedX = 0, unconsumedY = 0; int consumedX = 0, consumedY = 0; consumePendingUpdateOperations(); if (mAdapter != null) { eatRequestLayout(); onEnterLayoutOrScroll(); Trace.beginSection(TRACE_SCROLL_TAG); if (x != 0) { consumedX = mLayout.scrollHorizontallyBy(x, mRecycler, mState); unconsumedX = x - consumedX; } if (y != 0) { consumedY = mLayout.scrollVerticallyBy(y, mRecycler, mState); unconsumedY = y - consumedY; } Trace.endSection(); repositionShadowingViews(); onExitLayoutOrScroll(); resumeRequestLayout(false); } ... return consumedX != 0 || consumedY != 0; }
這里區(qū)分橫向和縱向滑動(dòng):scrollHorizontallyBy與scrollVerticallyBy
下面分析縱向滑動(dòng)的情況scrollVerticallyBy(橫向類(lèi)似):
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) { if (mOrientation == HORIZONTAL) { return 0; } return scrollBy(dy, recycler, state); }
這里調(diào)用了scrollBy方法,繼續(xù)往下跟
int scrollBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) { if (getChildCount() == 0 || dy == 0) { return 0; } mLayoutState.mRecycle = true; ensureLayoutState(); final int layoutDirection = dy > 0 ? LayoutState.LAYOUT_END : LayoutState.LAYOUT_START; final int absDy = Math.abs(dy); updateLayoutState(layoutDirection, absDy, true, state); final int consumed = mLayoutState.mScrollingOffset + fill(recycler, mLayoutState, state, false); if (consumed < 0) { if (DEBUG) { Log.d(TAG, "Don't have any more elements to scroll"); } return 0; } final int scrolled = absDy > consumed ? layoutDirection * consumed : dy; mOrientationHelper.offsetChildren(-scrolled); if (DEBUG) { Log.d(TAG, "scroll req: " + dy + " scrolled: " + scrolled); } mLayoutState.mLastScrollDelta = scrolled; return scrolled; }
這里有個(gè)關(guān)鍵方法:fill,當(dāng)布局或者上下滾動(dòng)的時(shí)候會(huì)調(diào)用fill方法。
int fill(RecyclerView.Recycler recycler, LayoutState layoutState, RecyclerView.State state, boolean stopOnFocusable) { //布局或者上下滾動(dòng)的時(shí)候會(huì)調(diào)用 // max offset we should set is mFastScroll + available final int start = layoutState.mAvailable; if (layoutState.mScrollingOffset != LayoutState.SCROLLING_OFFSET_NaN) { // TODO ugly bug fix. should not happen if (layoutState.mAvailable < 0) { layoutState.mScrollingOffset += layoutState.mAvailable; } recycleByLayoutState(recycler, layoutState); //回收ViewHolder } int remainingSpace = layoutState.mAvailable + layoutState.mExtra; LayoutChunkResult layoutChunkResult = mLayoutChunkResult; while ((layoutState.mInfinite || remainingSpace > 0) && layoutState.hasMore(state)) { layoutChunkResult.resetInternal(); layoutChunk(recycler, state, layoutState, layoutChunkResult); //循環(huán)調(diào)用 這里是layout的核心 if (layoutChunkResult.mFinished) { break; } ... } if (DEBUG) { validateChildOrder(); } return start - layoutState.mAvailable; }
layoutChunk這個(gè)方法是使用緩存的入口,recycleByLayoutState這個(gè)是進(jìn)行ViewHolder緩存的入口。
下面來(lái)看layoutChunk:
void layoutChunk(RecyclerView.Recycler recycler, RecyclerView.State state, LayoutState layoutState, LayoutChunkResult result) { View view = layoutState.next(recycler); if (view == null) { if (DEBUG && layoutState.mScrapList == null) { throw new RuntimeException("received null view when unexpected"); } // if we are laying out views in scrap, this may return null which means there is // no more items to layout. result.mFinished = true; return; } ... result.mFocusable = view.isFocusable(); }
這個(gè)方法里邊調(diào)用了layoutState的next方法得到一個(gè)View,那么關(guān)鍵就是next方法了
View next(RecyclerView.Recycler recycler) { if (mScrapList != null) { return nextViewFromScrapList(); } final View view = recycler.getViewForPosition(mCurrentPosition); mCurrentPosition += mItemDirection; return view; } public View getViewForPosition(int position) { return getViewForPosition(position, false); } View getViewForPosition(int position, boolean dryRun) { return tryGetViewHolderForPositionByDeadline(position, dryRun, FOREVER_NS).itemView; }
這個(gè)方法又調(diào)用了recycler.getViewForPosition方法,最終調(diào)到了tryGetViewHolderForPositionByDeadline這個(gè)方法。
下面來(lái)分析tryGetViewHolderForPositionByDeadline這個(gè)方法,整個(gè)ViewHolder的復(fù)用流程都在這里,這里是最核心的位置:
ViewHolder tryGetViewHolderForPositionByDeadline(int position, boolean dryRun, long deadlineNs) { if (position < 0 || position >= mState.getItemCount()) { throw new IndexOutOfBoundsException("Invalid item position " + position + "(" + position + "). Item count:" + mState.getItemCount()); } boolean fromScrapOrHiddenOrCache = false; ViewHolder holder = null; // 0) If there is a changed scrap, try to find from there if (mState.isPreLayout()) { //通過(guò)位置從mChangeScrap緩存中獲取ViewHolder holder = getChangedScrapViewForPosition(position); fromScrapOrHiddenOrCache = holder != null; } // 1) Find by position from scrap/hidden list/cache if (holder == null) {//通過(guò)position的方式從mAttachScrap或者mCacheViews中獲取ViewHolder holder = getScrapOrHiddenOrCachedHolderForPosition(position, dryRun); ... } if (holder == null) { final int offsetPosition = mAdapterHelper.findPositionOffset(position); if (offsetPosition < 0 || offsetPosition >= mAdapter.getItemCount()) { throw new IndexOutOfBoundsException("Inconsistency detected. Invalid item " + "position " + position + "(offset:" + offsetPosition + ")." + "state:" + mState.getItemCount()); } final int type = mAdapter.getItemViewType(offsetPosition); // 2) Find from scrap/cache via stable ids, if exists if (mAdapter.hasStableIds()) { holder = getScrapOrCachedViewForId(mAdapter.getItemId(offsetPosition), type, dryRun); //通過(guò)id的方式從mAttachScrap或者mCacheViews中獲取ViewHolder if (holder != null) { // update position holder.mPosition = offsetPosition; fromScrapOrHiddenOrCache = true; } } if (holder == null && mViewCacheExtension != null) { //從用戶自定義緩存獲取ViewHolder // We are NOT sending the offsetPosition because LayoutManager does not // know it. final View view = mViewCacheExtension .getViewForPositionAndType(this, position, type); ... } if (holder == null) { // 從緩存池獲取ViewHolder if (DEBUG) { Log.d(TAG, "tryGetViewHolderForPositionByDeadline(" + position + ") fetching from shared pool"); } holder = getRecycledViewPool().getRecycledView(type); if (holder != null) { holder.resetInternal(); if (FORCE_INVALIDATE_DISPLAY_LIST) { invalidateDisplayListInt(holder); } } } if (holder == null) { long start = getNanoTime(); if (deadlineNs != FOREVER_NS && !mRecyclerPool.willCreateInTime(type, start, deadlineNs)) { // abort - we have a deadline we can't meet return null; } //如果還是獲取不到ViewHolder,那么就需要通過(guò)createViewHolder創(chuàng)建了 holder = mAdapter.createViewHolder(RecyclerView.this, type); if (ALLOW_THREAD_GAP_WORK) { // only bother finding nested RV if prefetching RecyclerView innerView = findNestedRecyclerView(holder.itemView); if (innerView != null) { holder.mNestedRecyclerView = new WeakReference<>(innerView); } } long end = getNanoTime(); mRecyclerPool.factorInCreateTime(type, end - start); if (DEBUG) { Log.d(TAG, "tryGetViewHolderForPositionByDeadline created new ViewHolder"); } } } ... boolean bound = false; if (mState.isPreLayout() && holder.isBound()) { // do not update unless we absolutely have to. holder.mPreLayoutPosition = position; } else if (!holder.isBound() || holder.needsUpdate() || holder.isInvalid()) { if (DEBUG && holder.isRemoved()) { throw new IllegalStateException("Removed holder should be bound and it should" + " come here only in pre-layout. Holder: " + holder); } final int offsetPosition = mAdapterHelper.findPositionOffset(position); //這里會(huì)調(diào)用到onBindViewHolder方法進(jìn)行數(shù)據(jù)的綁定 bound = tryBindViewHolderByDeadline(holder, offsetPosition, position, deadlineNs); } ... return holder; }
- getChangedScrapViewForPosition:通過(guò)位置從mChangeScrap緩存中獲取ViewHolder。
- getScrapOrHiddenOrCachedHolderForPosition:通過(guò)position的方式從mAttachScrap或者mCacheViews中獲取ViewHolder。
- getScrapOrCachedViewForId:通過(guò)id的方式從mAttachScrap或者mCacheViews中獲取ViewHolder
- mViewCacheExtension.getViewForPositionAndType:從用戶自定義緩存獲取ViewHolder(這里系統(tǒng)未做實(shí)現(xiàn),需要用戶自定義)
- getRecycledViewPool().getRecycledView(type):從緩存池獲取ViewHolder
- mAdapter.createViewHolder:如果從各個(gè)緩存中獲取不到ViewHolder,那么就需要通過(guò)createViewHolder創(chuàng)建了
- tryBindViewHolderByDeadline:這里會(huì)調(diào)用到onBindViewHolder方法進(jìn)行數(shù)據(jù)的綁定
以上就是整個(gè)ViewHolder獲取過(guò)程,首先從緩存池獲取,獲取不到才會(huì)創(chuàng)建,然后進(jìn)行數(shù)據(jù)綁定。
2.RecyclerView的緩存流程
在進(jìn)行l(wèi)ayout操作的時(shí)候就會(huì)進(jìn)行ViewHolder的緩存操作,將創(chuàng)建好的ViewHolder緩存到緩存池,以便直接使用,下面分析一下ViewHolder是如何緩存到緩存池中的。
@Override protected void onLayout(boolean changed, int l, int t, int r, int b) { Trace.beginSection(TRACE_ON_LAYOUT_TAG); dispatchLayout(); //這里是擺放的入口 Trace.endSection(); mFirstLayoutComplete = true; }
下面是dispatchLayout:
void dispatchLayout() { if (mAdapter == null) { Log.e(TAG, "No adapter attached; skipping layout"); // leave the state in START return; } if (mLayout == null) { Log.e(TAG, "No layout manager attached; skipping layout"); // leave the state in START return; } mState.mIsMeasuring = false; if (mState.mLayoutStep == State.STEP_START) { dispatchLayoutStep1(); mLayout.setExactMeasureSpecsFrom(this); dispatchLayoutStep2(); } else if (mAdapterHelper.hasUpdates() || mLayout.getWidth() != getWidth() || mLayout.getHeight() != getHeight()) { // First 2 steps are done in onMeasure but looks like we have to run again due to // changed size. mLayout.setExactMeasureSpecsFrom(this); dispatchLayoutStep2(); } else { // always make sure we sync them (to ensure mode is exact) mLayout.setExactMeasureSpecsFrom(this); } dispatchLayoutStep3(); }
下面來(lái)看dispatchLayoutStep2:
private void dispatchLayoutStep2() { eatRequestLayout(); onEnterLayoutOrScroll(); mState.assertLayoutStep(State.STEP_LAYOUT | State.STEP_ANIMATIONS); mAdapterHelper.consumeUpdatesInOnePass(); mState.mItemCount = mAdapter.getItemCount(); mState.mDeletedInvisibleItemCountSincePreviousLayout = 0; // Step 2: Run layout mState.mInPreLayout = false; mLayout.onLayoutChildren(mRecycler, mState); mState.mStructureChanged = false; mPendingSavedState = null; // onLayoutChildren may have caused client code to disable item animations; re-check mState.mRunSimpleAnimations = mState.mRunSimpleAnimations && mItemAnimator != null; mState.mLayoutStep = State.STEP_ANIMATIONS; onExitLayoutOrScroll(); resumeRequestLayout(false); }
這個(gè)方法中會(huì)調(diào)用onLayoutChildren方法,這個(gè)方法是緩存的核心所在。
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { ... onAnchorReady(recycler, state, mAnchorInfo, firstLayoutDirection); detachAndScrapAttachedViews(recycler); //分離并廢棄附加視圖 ... }
這個(gè)方法內(nèi)容較多,做了省略。detachAndScrapAttachedViews這個(gè)方法會(huì)將ViewHolder緩存到緩存池中。
public void detachAndScrapAttachedViews(Recycler recycler) { final int childCount = getChildCount(); for (int i = childCount - 1; i >= 0; i--) { final View v = getChildAt(i); scrapOrRecycleView(recycler, i, v); } }
調(diào)到了scrapOrRecycleView方法
private void scrapOrRecycleView(Recycler recycler, int index, View view) { final ViewHolder viewHolder = getChildViewHolderInt(view); if (viewHolder.shouldIgnore()) { if (DEBUG) { Log.d(TAG, "ignoring view " + viewHolder); } return; } if (viewHolder.isInvalid() && !viewHolder.isRemoved() && !mRecyclerView.mAdapter.hasStableIds()) { removeViewAt(index); recycler.recycleViewHolderInternal(viewHolder); //這是一個(gè)收集的情況 } else { detachViewAt(index); recycler.scrapView(view); //這是一個(gè)情況 mRecyclerView.mViewInfoStore.onViewDetached(viewHolder); } }
- recycleViewHolderInternal :這個(gè)方法主要是緩存到mCacheViews或者RecyclerViewPool中
- scrapView:這個(gè)情況會(huì)將ViewHolder緩存到mAttachScrap中或者mChangedScrap中
下面來(lái)分析recycleViewHolderInternal:
void recycleViewHolderInternal(ViewHolder holder) { //主要處理CacheViews 和RecyclerPool 的緩存 ... if (forceRecycle || holder.isRecyclable()) { if (mViewCacheMax > 0 && !holder.hasAnyOfTheFlags(ViewHolder.FLAG_INVALID | ViewHolder.FLAG_REMOVED | ViewHolder.FLAG_UPDATE | ViewHolder.FLAG_ADAPTER_POSITION_UNKNOWN)) { // Retire oldest cached view int cachedViewSize = mCachedViews.size(); if (cachedViewSize >= mViewCacheMax && cachedViewSize > 0) { recycleCachedViewAt(0); cachedViewSize--; } int targetCacheIndex = cachedViewSize; if (ALLOW_THREAD_GAP_WORK && cachedViewSize > 0 && !mPrefetchRegistry.lastPrefetchIncludedPosition(holder.mPosition)) { // when adding the view, skip past most recently prefetched views int cacheIndex = cachedViewSize - 1; while (cacheIndex >= 0) { int cachedPos = mCachedViews.get(cacheIndex).mPosition; if (!mPrefetchRegistry.lastPrefetchIncludedPosition(cachedPos)) { break; } cacheIndex--; } targetCacheIndex = cacheIndex + 1; } mCachedViews.add(targetCacheIndex, holder); //這里是加入到mCachedViews中 cached = true; } if (!cached) { //這里是加入到RecycledViewPool緩存池中 addViewHolderToRecycledViewPool(holder, true); recycled = true; } } ... }
- mCachedViews.add(targetCacheIndex, holder):將ViewHolder加入到mCachedViews中
- addViewHolderToRecycledViewPool:加入到RecycledViewPool緩存池中
下面是scrapView部分:
void scrapView(View view) { final ViewHolder holder = getChildViewHolderInt(view); if (holder.hasAnyOfTheFlags(ViewHolder.FLAG_REMOVED | ViewHolder.FLAG_INVALID) || !holder.isUpdated() || canReuseUpdatedViewHolder(holder)) { if (holder.isInvalid() && !holder.isRemoved() && !mAdapter.hasStableIds()) { throw new IllegalArgumentException("Called scrap view with an invalid view." + " Invalid views cannot be reused from scrap, they should rebound from" + " recycler pool."); } holder.setScrapContainer(this, false); mAttachedScrap.add(holder); } else { if (mChangedScrap == null) { mChangedScrap = new ArrayList<ViewHolder>(); } holder.setScrapContainer(this, true); mChangedScrap.add(holder); } }
根據(jù)不同的情況會(huì)將ViewHolder緩存到mAttachedScrap或者mChangedScrap中
3.RecyclerView緩存總結(jié)
RecyclerView 緩存的是ViewHolder
RecyclerView采用了四級(jí)緩存:緩存的分類(lèi)是根據(jù)功能區(qū)分
- mAttachedScrap : 緩存可見(jiàn)的ViewHolder 用于 執(zhí)行onLayout的時(shí)候 ArrayList 集合
- mCacheView:緩存將要隱藏ViewHolder 下次將要顯示的ViewHolder 先從這個(gè)緩存里邊獲取 ArrayList 集合
- mViewChcheExtension:需要用戶自己實(shí)現(xiàn)的緩存
- mRecyclerPool:緩存池,這個(gè)用戶根據(jù)不同的ViewType保存緩存池 , ScrapData包含一個(gè)ArrayList mScrap 是一個(gè)SparseArray數(shù)組,所以緩存池是一個(gè)二維數(shù)組。
ViewHolder的創(chuàng)建流程
- 先從mAttachedScrap 緩存 查找ViewHolder
- 然后從mCacheView 查找
- 然后從mViewCacheExtension
- 然后 從來(lái)mRecyclerPool查找
- 如果還是沒(méi)有 就需要調(diào)用onCreateViewHolder方法來(lái)新創(chuàng)建
到此這篇關(guān)于Android RecyclerView四級(jí)緩存源碼層詳細(xì)分析的文章就介紹到這了,更多相關(guān)Android RecyclerView內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
- Android實(shí)現(xiàn)RecyclerView嵌套流式布局的詳細(xì)過(guò)程
- Android RecyclerView實(shí)現(xiàn)吸頂動(dòng)態(tài)效果流程分析
- Android RecyclerView緩存復(fù)用原理解析
- Android RecyclerView使用入門(mén)介紹
- Android開(kāi)發(fā)RecyclerView單獨(dú)刷新使用技巧
- Android開(kāi)發(fā)RecyclerView實(shí)現(xiàn)折線圖效果
- Android?手寫(xiě)RecyclerView實(shí)現(xiàn)列表加載
- Android獲取RecyclerView滑動(dòng)距離方法詳細(xì)講解
相關(guān)文章
Android實(shí)現(xiàn)狀態(tài)欄白底黑字效果示例代碼
這篇文章主要介紹了Android實(shí)現(xiàn)狀態(tài)欄白底黑字效果的相關(guān)資料,實(shí)現(xiàn)后的效果非常適合日常開(kāi)發(fā)中使用,文中給出了詳細(xì)的示例代碼供大家參考學(xué)習(xí),需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧。2017-10-10Android編程實(shí)現(xiàn)在底端顯示選項(xiàng)卡的方法
這篇文章主要介紹了Android編程實(shí)現(xiàn)在底端顯示選項(xiàng)卡的方法,涉及Android界面線性布局、相對(duì)布局及選項(xiàng)卡設(shè)置相關(guān)操作技巧,需要的朋友可以參考下2017-02-02Android Intent啟動(dòng)別的應(yīng)用實(shí)現(xiàn)方法
我們知道Intent的應(yīng)用,可以啟動(dòng)別一個(gè)Activity,那么是否可以啟動(dòng)別外的一個(gè)應(yīng)用程序呢,答案是可以的2013-04-04Android讀取本地json文件的方法(解決顯示亂碼問(wèn)題)
這篇文章主要介紹了Android讀取本地json文件的方法,結(jié)合實(shí)例形式對(duì)比分析了解決顯示亂碼問(wèn)題的方法,需要的朋友可以參考下2016-06-06Android?NDK入門(mén)初識(shí)(組件結(jié)構(gòu)開(kāi)發(fā)流程)
這篇文章主要為大家介紹了Android?NDK入門(mén)之初識(shí)組件結(jié)構(gòu)開(kāi)發(fā)流程詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-08-08Android自定義手機(jī)界面狀態(tài)欄實(shí)例代碼
我們知道IOS上的應(yīng)用,狀態(tài)欄的顏色總能與應(yīng)用標(biāo)題欄顏色保持一致,用戶體驗(yàn)很不錯(cuò),那安卓是否可以呢?若是在安卓4.4之前,答案是否定的,但在4.4之后,谷歌允許開(kāi)發(fā)者自定義狀態(tài)欄背景顏色啦,這是個(gè)不錯(cuò)的體驗(yàn)2017-03-03Android實(shí)現(xiàn)QQ登錄界面遇到問(wèn)題及解決方法
本文給大家介紹android仿qq登錄界面的實(shí)現(xiàn)代碼,在實(shí)現(xiàn)此功能過(guò)程中遇到各種問(wèn)題,但是最終都順利解決,如果大家對(duì)android qq登錄界面實(shí)現(xiàn)方法感興趣的朋友一起學(xué)習(xí)吧2016-09-09開(kāi)源電商app常用標(biāo)簽"hot"之第三方開(kāi)源LabelView
這篇文章主要介紹了開(kāi)源電商app常用標(biāo)簽"hot"之第三方開(kāi)源LabelView,對(duì)開(kāi)源電商app相關(guān)資料感興趣的朋友一起學(xué)習(xí)吧2015-12-12