美文网首页第三方Android开发Android技术知识
深入源码Android ListView工作原理完全解析(下)

深入源码Android ListView工作原理完全解析(下)

作者: 木木玩Android | 来源:发表于2021-06-11 17:37 被阅读0次

    滑动加载更多数据

    经历了两次 Layout 过程,虽说我们已经可以在 ListView 中看到内容了,然而关于 ListView 最神奇的部分我们却还没有接触到,因为目前 ListView 中只是加载并显示了第一屏的数据而已。比如说我们的 Adapter 当中有 1000 条数据,但是第一屏只显示了 10 条,ListView 中也只有 10 个子 View 而已,那么剩下的 990 是怎样工作并显示到界面上的呢?这就要看一下 ListView 滑动部分的源码了,因为我们是通过手指滑动来显示更多数据的。

    由于滑动部分的机制是属于通用型的,即 ListView 和 GridView 都会使用同样的机制,因此这部分代码就肯定是写在 AbsListView 当中的了。那么监听触控事件是在 onTouchEvent() 方法当中进行的,我们就来看一下 AbsListView 中的这个方法:

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        if (!isEnabled()) {
            // A disabled view that is clickable still consumes the touch
            // events, it just doesn't respond to them.
            return isClickable() || isLongClickable();
        }
        final int action = ev.getAction();
        View v;
        int deltaY;
        if (mVelocityTracker == null) {
            mVelocityTracker = VelocityTracker.obtain();
        }
        mVelocityTracker.addMovement(ev);
        switch (action & MotionEvent.ACTION_MASK) {
        case MotionEvent.ACTION_DOWN: {
            mActivePointerId = ev.getPointerId(0);
            final int x = (int) ev.getX();
            final int y = (int) ev.getY();
            int motionPosition = pointToPosition(x, y);
            if (!mDataChanged) {
                if ((mTouchMode != TOUCH_MODE_FLING) && (motionPosition >= 0)
                        && (getAdapter().isEnabled(motionPosition))) {
                    // User clicked on an actual view (and was not stopping a
                    // fling). It might be a
                    // click or a scroll. Assume it is a click until proven
                    // otherwise
                    mTouchMode = TOUCH_MODE_DOWN;
                    // FIXME Debounce
                    if (mPendingCheckForTap == null) {
                        mPendingCheckForTap = new CheckForTap();
                    }
                    postDelayed(mPendingCheckForTap, ViewConfiguration.getTapTimeout());
                } else {
                    if (ev.getEdgeFlags() != 0 && motionPosition < 0) {
                        // If we couldn't find a view to click on, but the down
                        // event was touching
                        // the edge, we will bail out and try again. This allows
                        // the edge correcting
                        // code in ViewRoot to try to find a nearby view to
                        // select
                        return false;
                    }
    
                    if (mTouchMode == TOUCH_MODE_FLING) {
                        // Stopped a fling. It is a scroll.
                        createScrollingCache();
                        mTouchMode = TOUCH_MODE_SCROLL;
                        mMotionCorrection = 0;
                        motionPosition = findMotionRow(y);
                        reportScrollStateChange(OnScrollListener.SCROLL_STATE_TOUCH_SCROLL);
                    }
                }
            }
            if (motionPosition >= 0) {
                // Remember where the motion event started
                v = getChildAt(motionPosition - mFirstPosition);
                mMotionViewOriginalTop = v.getTop();
            }
            mMotionX = x;
            mMotionY = y;
            mMotionPosition = motionPosition;
            mLastY = Integer.MIN_VALUE;
            break;
        }
        case MotionEvent.ACTION_MOVE: {
            final int pointerIndex = ev.findPointerIndex(mActivePointerId);
            final int y = (int) ev.getY(pointerIndex);
            deltaY = y - mMotionY;
            switch (mTouchMode) {
            case TOUCH_MODE_DOWN:
            case TOUCH_MODE_TAP:
            case TOUCH_MODE_DONE_WAITING:
                // Check if we have moved far enough that it looks more like a
                // scroll than a tap
                startScrollIfNeeded(deltaY);
                break;
            case TOUCH_MODE_SCROLL:
                if (PROFILE_SCROLLING) {
                    if (!mScrollProfilingStarted) {
                        Debug.startMethodTracing("AbsListViewScroll");
                        mScrollProfilingStarted = true;
                    }
                }
                if (y != mLastY) {
                    deltaY -= mMotionCorrection;
                    int incrementalDeltaY = mLastY != Integer.MIN_VALUE ? y - mLastY : deltaY;
                    // No need to do all this work if we're not going to move
                    // anyway
                    boolean atEdge = false;
                    if (incrementalDeltaY != 0) {
                        atEdge = trackMotionScroll(deltaY, incrementalDeltaY);
                    }
                    // Check to see if we have bumped into the scroll limit
                    if (atEdge && getChildCount() > 0) {
                        // Treat this like we're starting a new scroll from the
                        // current
                        // position. This will let the user start scrolling back
                        // into
                        // content immediately rather than needing to scroll
                        // back to the
                        // point where they hit the limit first.
                        int motionPosition = findMotionRow(y);
                        if (motionPosition >= 0) {
                            final View motionView = getChildAt(motionPosition - mFirstPosition);
                            mMotionViewOriginalTop = motionView.getTop();
                        }
                        mMotionY = y;
                        mMotionPosition = motionPosition;
                        invalidate();
                    }
                    mLastY = y;
                }
                break;
            }
            break;
        }
        case MotionEvent.ACTION_UP: {
            switch (mTouchMode) {
            case TOUCH_MODE_DOWN:
            case TOUCH_MODE_TAP:
            case TOUCH_MODE_DONE_WAITING:
                final int motionPosition = mMotionPosition;
                final View child = getChildAt(motionPosition - mFirstPosition);
                if (child != null && !child.hasFocusable()) {
                    if (mTouchMode != TOUCH_MODE_DOWN) {
                        child.setPressed(false);
                    }
                    if (mPerformClick == null) {
                        mPerformClick = new PerformClick();
                    }
                    final AbsListView.PerformClick performClick = mPerformClick;
                    performClick.mChild = child;
                    performClick.mClickMotionPosition = motionPosition;
                    performClick.rememberWindowAttachCount();
                    mResurrectToPosition = motionPosition;
                    if (mTouchMode == TOUCH_MODE_DOWN || mTouchMode == TOUCH_MODE_TAP) {
                        final Handler handler = getHandler();
                        if (handler != null) {
                            handler.removeCallbacks(mTouchMode == TOUCH_MODE_DOWN ? mPendingCheckForTap
                                    : mPendingCheckForLongPress);
                        }
                        mLayoutMode = LAYOUT_NORMAL;
                        if (!mDataChanged && mAdapter.isEnabled(motionPosition)) {
                            mTouchMode = TOUCH_MODE_TAP;
                            setSelectedPositionInt(mMotionPosition);
                            layoutChildren();
                            child.setPressed(true);
                            positionSelector(child);
                            setPressed(true);
                            if (mSelector != null) {
                                Drawable d = mSelector.getCurrent();
                                if (d != null && d instanceof TransitionDrawable) {
                                    ((TransitionDrawable) d).resetTransition();
                                }
                            }
                            postDelayed(new Runnable() {
                                public void run() {
                                    child.setPressed(false);
                                    setPressed(false);
                                    if (!mDataChanged) {
                                        post(performClick);
                                    }
                                    mTouchMode = TOUCH_MODE_REST;
                                }
                            }, ViewConfiguration.getPressedStateDuration());
                        } else {
                            mTouchMode = TOUCH_MODE_REST;
                        }
                        return true;
                    } else if (!mDataChanged && mAdapter.isEnabled(motionPosition)) {
                        post(performClick);
                    }
                }
                mTouchMode = TOUCH_MODE_REST;
                break;
            case TOUCH_MODE_SCROLL:
                final int childCount = getChildCount();
                if (childCount > 0) {
                    if (mFirstPosition == 0
                            && getChildAt(0).getTop() >= mListPadding.top
                            && mFirstPosition + childCount < mItemCount
                            && getChildAt(childCount - 1).getBottom() <= getHeight()
                                    - mListPadding.bottom) {
                        mTouchMode = TOUCH_MODE_REST;
                        reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE);
                    } else {
                        final VelocityTracker velocityTracker = mVelocityTracker;
                        velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
                        final int initialVelocity = (int) velocityTracker
                                .getYVelocity(mActivePointerId);
                        if (Math.abs(initialVelocity) > mMinimumVelocity) {
                            if (mFlingRunnable == null) {
                                mFlingRunnable = new FlingRunnable();
                            }
                            reportScrollStateChange(OnScrollListener.SCROLL_STATE_FLING);
                            mFlingRunnable.start(-initialVelocity);
                        } else {
                            mTouchMode = TOUCH_MODE_REST;
                            reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE);
                        }
                    }
                } else {
                    mTouchMode = TOUCH_MODE_REST;
                    reportScrollStateChange(OnScrollListener.SCROLL_STATE_IDLE);
                }
                break;
            }
            setPressed(false);
            // Need to redraw since we probably aren't drawing the selector
            // anymore
            invalidate();
            final Handler handler = getHandler();
            if (handler != null) {
                handler.removeCallbacks(mPendingCheckForLongPress);
            }
            if (mVelocityTracker != null) {
                mVelocityTracker.recycle();
                mVelocityTracker = null;
            }
            mActivePointerId = INVALID_POINTER;
            if (PROFILE_SCROLLING) {
                if (mScrollProfilingStarted) {
                    Debug.stopMethodTracing();
                    mScrollProfilingStarted = false;
                }
            }
            break;
        }
        case MotionEvent.ACTION_CANCEL: {
            mTouchMode = TOUCH_MODE_REST;
            setPressed(false);
            View motionView = this.getChildAt(mMotionPosition - mFirstPosition);
            if (motionView != null) {
                motionView.setPressed(false);
            }
            clearScrollingCache();
            final Handler handler = getHandler();
            if (handler != null) {
                handler.removeCallbacks(mPendingCheckForLongPress);
            }
            if (mVelocityTracker != null) {
                mVelocityTracker.recycle();
                mVelocityTracker = null;
            }
            mActivePointerId = INVALID_POINTER;
            break;
        }
        case MotionEvent.ACTION_POINTER_UP: {
            onSecondaryPointerUp(ev);
            final int x = mMotionX;
            final int y = mMotionY;
            final int motionPosition = pointToPosition(x, y);
            if (motionPosition >= 0) {
                // Remember where the motion event started
                v = getChildAt(motionPosition - mFirstPosition);
                mMotionViewOriginalTop = v.getTop();
                mMotionPosition = motionPosition;
            }
            mLastY = y;
            break;
        }
        }
        return true;
    }
    复制代码
    

    这个方法中的代码就非常多了,因为它所处理的逻辑也非常多,要监听各种各样的触屏事件。但是我们目前所关心的就只有手指在屏幕上滑动这一个事件而已,对应的是 ACTION_MOVE 这个动作,那么我们就只看这部分代码就可以了。

    可以看到,ACTION_MOVE 这个 case 里面又嵌套了一个 switch 语句,是根据当前的 TouchMode 来选择的。那这里我可以直接告诉大家,当手指在屏幕上滑动时,TouchMode 是等于 TOUCH_MODE_SCROLL 这个值的,至于为什么那又要牵扯到另外的好几个方法,这里限于篇幅原因就不再展开讲解了,喜欢寻根究底的朋友们可以自己去源码里找一找原因。

    这样的话,代码就应该会走到第 78 行的这个 case 里面去了,在这个 case 当中并没有什么太多需要注意的东西,唯一一点非常重要的就是第 92 行调用的 trackMotionScroll() 方法,相当于我们手指只要在屏幕上稍微有一点点移动,这个方法就会被调用,而如果是正常在屏幕上滑动的话,那么这个方法就会被调用很多次。那么我们进入到这个方法中瞧一瞧,代码如下所示:

    boolean trackMotionScroll(int deltaY, int incrementalDeltaY) {
        final int childCount = getChildCount();
        if (childCount == 0) {
            return true;
        }
        final int firstTop = getChildAt(0).getTop();
        final int lastBottom = getChildAt(childCount - 1).getBottom();
        final Rect listPadding = mListPadding;
        final int spaceAbove = listPadding.top - firstTop;
        final int end = getHeight() - listPadding.bottom;
        final int spaceBelow = lastBottom - end;
        final int height = getHeight() - getPaddingBottom() - getPaddingTop();
        if (deltaY < 0) {
            deltaY = Math.max(-(height - 1), deltaY);
        } else {
            deltaY = Math.min(height - 1, deltaY);
        }
        if (incrementalDeltaY < 0) {
            incrementalDeltaY = Math.max(-(height - 1), incrementalDeltaY);
        } else {
            incrementalDeltaY = Math.min(height - 1, incrementalDeltaY);
        }
        final int firstPosition = mFirstPosition;
        if (firstPosition == 0 && firstTop >= listPadding.top && deltaY >= 0) {
            // Don't need to move views down if the top of the first position
            // is already visible
            return true;
        }
        if (firstPosition + childCount == mItemCount && lastBottom <= end && deltaY <= 0) {
            // Don't need to move views up if the bottom of the last position
            // is already visible
            return true;
        }
        final boolean down = incrementalDeltaY < 0;
        final boolean inTouchMode = isInTouchMode();
        if (inTouchMode) {
            hideSelector();
        }
        final int headerViewsCount = getHeaderViewsCount();
        final int footerViewsStart = mItemCount - getFooterViewsCount();
        int start = 0;
        int count = 0;
        if (down) {
            final int top = listPadding.top - incrementalDeltaY;
            for (int i = 0; i < childCount; i++) {
                final View child = getChildAt(i);
                if (child.getBottom() >= top) {
                    break;
                } else {
                    count++;
                    int position = firstPosition + i;
                    if (position >= headerViewsCount && position < footerViewsStart) {
                        mRecycler.addScrapView(child);
                    }
                }
            }
        } else {
            final int bottom = getHeight() - listPadding.bottom - incrementalDeltaY;
            for (int i = childCount - 1; i >= 0; i--) {
                final View child = getChildAt(i);
                if (child.getTop() <= bottom) {
                    break;
                } else {
                    start = i;
                    count++;
                    int position = firstPosition + i;
                    if (position >= headerViewsCount && position < footerViewsStart) {
                        mRecycler.addScrapView(child);
                    }
                }
            }
        }
        mMotionViewNewTop = mMotionViewOriginalTop + deltaY;
        mBlockLayoutRequests = true;
        if (count > 0) {
            detachViewsFromParent(start, count);
        }
        offsetChildrenTopAndBottom(incrementalDeltaY);
        if (down) {
            mFirstPosition += count;
        }
        invalidate();
        final int absIncrementalDeltaY = Math.abs(incrementalDeltaY);
        if (spaceAbove < absIncrementalDeltaY || spaceBelow < absIncrementalDeltaY) {
            fillGap(down);
        }
        if (!inTouchMode && mSelectedPosition != INVALID_POSITION) {
            final int childIndex = mSelectedPosition - mFirstPosition;
            if (childIndex >= 0 && childIndex < getChildCount()) {
                positionSelector(getChildAt(childIndex));
            }
        }
        mBlockLayoutRequests = false;
        invokeOnItemScrollListener();
        awakenScrollBars();
        return false;
    }
    复制代码
    

    这个方法接收两个参数,deltaY 表示从手指按下时的位置到当前手指位置的距离,incrementalDeltaY 则表示据上次触发 event 事件手指在 Y 方向上位置的改变量,那么其实我们就可以通过 incrementalDeltaY 的正负值情况来判断用户是向上还是向下滑动的了。如第 34 行代码所示,如果 incrementalDeltaY 小于 0,说明是向下滑动,否则就是向上滑动。

    下面将会进行一个边界值检测的过程,可以看到,从第 43 行开始,当 ListView 向下滑动的时候,就会进入一个 for 循环当中,从上往下依次获取子 View,第 47 行当中,如果该子 View 的 bottom 值已经小于 top 值了,就说明这个子 View 已经移出屏幕了,所以会调用 RecycleBin 的 addScrapView() 方法将这个 View 加入到废弃缓存当中,并将 count 计数器加 1,计数器用于记录有多少个子 View 被移出了屏幕。那么如果是 ListView 向上滑动的话,其实过程是基本相同的,只不过变成了从下往上依次获取子 View,然后判断该子 View 的 top 值是不是大于 bottom 值了,如果大于的话说明子 View 已经移出了屏幕,同样把它加入到废弃缓存中,并将计数器加 1。

    接下来在第 76 行,会根据当前计数器的值来进行一个 detach 操作,它的作用就是把所有移出屏幕的子 View 全部 detach 掉,在 ListView 的概念当中,所有看不到的 View 就没有必要为它进行保存,因为屏幕外还有成百上千条数据等着显示呢,一个好的回收策略才能保证 ListView 的高性能和高效率。紧接着在第 78 行调用了 offsetChildrenTopAndBottom() 方法,并将 incrementalDeltaY 作为参数传入,这个方法的作用是让 ListView 中所有的子 View 都按照传入的参数值进行相应的偏移,这样就实现了随着手指的拖动,ListView 的内容也会随着滚动的效果。

    然后在第 84 行会进行判断,如果 ListView 中最后一个 View 的底部已经移入了屏幕,或者 ListView 中第一个 View 的顶部移入了屏幕,就会调用 fillGap() 方法,那么因此我们就可以猜出 fillGap() 方法是用来加载屏幕外数据的,进入到这个方法中瞧一瞧,如下所示:

    /**
     * Fills the gap left open by a touch-scroll. During a touch scroll,
     * children that remain on screen are shifted and the other ones are
     * discarded. The role of this method is to fill the gap thus created by
     * performing a partial layout in the empty space.
     * 
     * @param down
     *            true if the scroll is going down, false if it is going up
     */
    abstract void fillGap(boolean down);
    复制代码
    

    OK,AbsListView 中的 fillGap() 是一个抽象方法,那么我们立刻就能够想到,它的具体实现肯定是在 ListView 中完成的了。回到 ListView 当中,fillGap() 方法的代码如下所示:

    void fillGap(boolean down) {
        final int count = getChildCount();
        if (down) {
            final int startOffset = count > 0 ? getChildAt(count - 1).getBottom() + mDividerHeight :
                    getListPaddingTop();
            fillDown(mFirstPosition + count, startOffset);
            correctTooHigh(getChildCount());
        } else {
            final int startOffset = count > 0 ? getChildAt(0).getTop() - mDividerHeight :
                    getHeight() - getListPaddingBottom();
            fillUp(mFirstPosition - 1, startOffset);
            correctTooLow(getChildCount());
        }
    }
    复制代码
    

    down 参数用于表示 ListView 是向下滑动还是向上滑动的,可以看到,如果是向下滑动的话就会调用 fillDown() 方法,而如果是向上滑动的话就会调用 fillUp() 方法。那么这两个方法我们都已经非常熟悉了,内部都是通过一个循环来去对 ListView 进行填充,所以这两个方法我们就不看了,但是填充 ListView 会通过调用 makeAndAddView() 方法来完成,又是 makeAndAddView() 方法,但这次的逻辑再次不同了,所以我们还是回到这个方法瞧一瞧:

    /**
     * Obtain the view and add it to our list of children. The view can be made
     * fresh, converted from an unused view, or used as is if it was in the
     * recycle bin.
     *
     * @param position Logical position in the list
     * @param y Top or bottom edge of the view to add
     * @param flow If flow is true, align top edge to y. If false, align bottom
     *        edge to y.
     * @param childrenLeft Left edge where children should be positioned
     * @param selected Is this position selected?
     * @return View that was added
     */
    private View makeAndAddView(int position, int y, boolean flow, int childrenLeft,
            boolean selected) {
        View child;
        if (!mDataChanged) {
            // Try to use an exsiting view for this position
            child = mRecycler.getActiveView(position);
            if (child != null) {
                // Found it -- we're using an existing child
                // This just needs to be positioned
                setupChild(child, position, y, flow, childrenLeft, selected, true);
                return child;
            }
        }
        // Make a new view for this position, or convert an unused view if possible
        child = obtainView(position, mIsScrap);
        // This needs to be positioned and measured
        setupChild(child, position, y, flow, childrenLeft, selected, mIsScrap[0]);
        return child;
    }
    复制代码
    

    不管怎么说,这里首先仍然是会尝试调用 RecycleBin 的 getActiveView() 方法来获取子布局,只不过肯定是获取不到的了,因为在第二次 Layout 过程中我们已经从 mActiveViews 中获取过了数据,而根据 RecycleBin 的机制,mActiveViews 是不能够重复利用的,因此这里返回的值肯定是 null。

    既然 getActiveView() 方法返回的值是 null,那么就还是会走到第 28 行的 obtainView() 方法当中,代码如下所示:

    /**
     * Get a view and have it show the data associated with the specified
     * position. This is called when we have already discovered that the view is
     * not available for reuse in the recycle bin. The only choices left are
     * converting an old view or making a new one.
     * 
     * @param position
     *            The position to display
     * @param isScrap
     *            Array of at least 1 boolean, the first entry will become true
     *            if the returned view was taken from the scrap heap, false if
     *            otherwise.
     * 
     * @return A view displaying the data associated with the specified position
     */
    View obtainView(int position, boolean[] isScrap) {
        isScrap[0] = false;
        View scrapView;
        scrapView = mRecycler.getScrapView(position);
        View child;
        if (scrapView != null) {
            child = mAdapter.getView(position, scrapView, this);
            if (child != scrapView) {
                mRecycler.addScrapView(scrapView);
                if (mCacheColorHint != 0) {
                    child.setDrawingCacheBackgroundColor(mCacheColorHint);
                }
            } else {
                isScrap[0] = true;
                dispatchFinishTemporaryDetach(child);
            }
        } else {
            child = mAdapter.getView(position, null, this);
            if (mCacheColorHint != 0) {
                child.setDrawingCacheBackgroundColor(mCacheColorHint);
            }
        }
        return child;
    }
    复制代码
    

    这里在第 19 行会调用 RecyleBin 的 getScrapView() 方法来尝试从废弃缓存中获取一个 View,那么废弃缓存有没有 View 呢?当然有,因为刚才在 trackMotionScroll() 方法中我们就已经看到了,一旦有任何子 View 被移出了屏幕,就会将它加入到废弃缓存中,而从 obtainView() 方法中的逻辑来看,一旦有新的数据需要显示到屏幕上,就会尝试从废弃缓存中获取 View。所以它们之间就形成了一个生产者和消费者的模式,那么 ListView 神奇的地方也就在这里体现出来了,不管你有任意多条数据需要显示,ListView 中的子 View 其实来来回回就那么几个,移出屏幕的子 View 会很快被移入屏幕的数据重新利用起来,因而不管我们加载多少数据都不会出现 OOM 的情况,甚至内存都不会有所增加。

    那么另外还有一点是需要大家留意的,这里获取到了一个 scrapView,然后我们在第 22 行将它作为第二个参数传入到了 Adapter 的 getView() 方法当中。那么第二个参数是什么意思呢?我们再次看一下一个简单的 getView() 方法示例:

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        Fruit fruit = getItem(position);
        View view;
        if (convertView == null) {
            view = LayoutInflater.from(getContext()).inflate(resourceId, null);
        } else {
            view = convertView;
        }
        ImageView fruitImage = (ImageView) view.findViewById(R.id.fruit_image);
        TextView fruitName = (TextView) view.findViewById(R.id.fruit_name);
        fruitImage.setImageResource(fruit.getImageId());
        fruitName.setText(fruit.getName());
        return view;
    }
    复制代码
    

    第二个参数就是我们最熟悉的 convertView 呀,难怪平时我们在写 getView() 方法是要判断一下 convertView 是不是等于 null,如果等于 null 才调用 inflate() 方法来加载布局,不等于 null 就可以直接利用 convertView,因为 convertView 就是我们之间利用过的 View,只不过被移出屏幕后进入到了废弃缓存中,现在又重新拿出来使用而已。然后我们只需要把 convertView 中的数据更新成当前位置上应该显示的数据,那么看起来就好像是全新加载出来的一个布局一样,这背后的道理你是不是已经完全搞明白了?

    之后的代码又都是我们熟悉的流程了,从缓存中拿到子 View 之后再调用 setupChild() 方法将它重新 attach 到 ListView 当中,因为缓存中的 View 也是之前从 ListView 中 detach 掉的,这部分代码就不再重复进行分析了。

    为了方便大家理解,这里我再附上一张图解说明:

    那么到目前为止,我们就把 ListView 的整个工作流程代码基本分析结束了,文章比较长,希望大家可以理解清楚。

    这边也整理了一份《Android相关源码精编解析》pdf学习笔记,有需要的朋友可以评论区留言或私信我获取!

    相关文章

      网友评论

        本文标题:深入源码Android ListView工作原理完全解析(下)

        本文链接:https://www.haomeiwen.com/subject/lyeveltx.html