安卓高手之路之图形系统(6)ListView继续

安卓高手之路之图形系统(6)ListView继续 - 修补C++ - ITeye技术网站

综述:

本篇首先介绍了ListView的实现细节。然后介绍了Gallery,ListView,ViewPager的效率对比分析。以及效率低下的原因。最后给出了一些解决方案。

1.在上一篇讨论了requestLayout的效率问题。对如何避免这个问题也进行了深入探讨。本篇就内存问题进行讨论。一般情况下,安卓的ListView实现方式上,就存在要移动childView位置的需求。

如果对childView进行了回收并且回收的childView必须仍然在原来的位置上,那么childView的位置可能要出现在两个位置上。这非常有挑战性,因为安卓的很多东西都是基于一个View一个位置的这样的思想。现在突然出现两个位置。那么也就是说,回收的View不能再呆在原来的位置了。必须被remove掉。remove掉之后呢,其他的View必须挤过去。总之所有的children都得改变位置。

          也就是说必须改变布局。但是改变布局是否就一定要reqestLayout,也是个问题。刚才说了不需要。只要调用一下onLayout就行了。onLayout调用的时候,必须知道childView的位置吧。、那还得改变child的位置,那又调用什么呢?

            那么,仅仅改变childView位置的函数是什么呢?这个函数就是offset系列函数。在Gallery那边是offsetChildrenLeftAndRight另外还有个setX和setY。这些都是改变位置的函数。

         ListView的实现时非常高效的。既保证了回收childiew,有能不进行layout。非常高效。

第一。View的回收机制。

第二。View不进行requestLayout

    

2.Gallery的实现方式

         Gallery在实现的时候没有采用回收机制。经过测试的Adapter.getView方法参数,View都是null。也就是说不对View进行复用。其实上Gallery中的View是越来越多。而且每一个View都不会进行回收。这跟一个

ScrollView+ViewPager的实现方式是一样的。唯一的区别是Gallery采用了Adapter机制,并且使用了ListView的滚动原理。但是Gallery没有对View进行回收,全部保存了起来。在内存不够用的时候,尽量不要使用这个。下面拿Gallery和ListView的回收机制进行了对比,

先看trackMotionScroll方法:

    Gallery:

Java代码  收藏代码
  1. /** 
  2.  * Tracks a motion scroll. In reality, this is used to do just about any 
  3.  * movement to items (touch scroll, arrow-key scroll, set an item as selected). 
  4.  *  
  5.  * @param deltaX Change in X from the previous event. 
  6.  */  
  7. void trackMotionScroll(int deltaX) {  
  8.   
  9.     if (getChildCount() == 0) {  
  10.         return;  
  11.     }  
  12.       
  13.     boolean toLeft = deltaX < 0;   
  14.       
  15.     int limitedDeltaX = getLimitedMotionScrollAmount(toLeft, deltaX);  
  16.     if (limitedDeltaX != deltaX) {  
  17.         // The above call returned a limited amount, so stop any scrolls/flings  
  18.         mFlingRunnable.endFling(false);  
  19.         onFinishedMovement();  
  20.     }  
  21.       
  22.     offsetChildrenLeftAndRight(limitedDeltaX);  
  23.       
  24.     detachOffScreenChildren(toLeft);  
  25.       
  26.     if (toLeft) {  
  27.         // If moved left, there will be empty space on the right  
  28.         fillToGalleryRight();  
  29.     } else {  
  30.         // Similarly, empty space on the left  
  31.         fillToGalleryLeft();  
  32.     }  
  33.       
  34.     // Clear unused views  
  35.     mRecycler.clear();  
  36.       
  37.     setSelectionToCenterChild();  
  38.   
  39.     onScrollChanged(0000); // dummy values, View's implementation does not use these.  
  40.   
  41.     invalidate();  
  42. }  
    /**
     * Tracks a motion scroll. In reality, this is used to do just about any
     * movement to items (touch scroll, arrow-key scroll, set an item as selected).
     *
     * @param deltaX Change in X from the previous event.
     */
    void trackMotionScroll(int deltaX) {

        if (getChildCount() == 0) {
            return;
        }

        boolean toLeft = deltaX < 0; 

        int limitedDeltaX = getLimitedMotionScrollAmount(toLeft, deltaX);
        if (limitedDeltaX != deltaX) {
            // The above call returned a limited amount, so stop any scrolls/flings
            mFlingRunnable.endFling(false);
            onFinishedMovement();
        }

        offsetChildrenLeftAndRight(limitedDeltaX);

        detachOffScreenChildren(toLeft);

        if (toLeft) {
            // If moved left, there will be empty space on the right
            fillToGalleryRight();
        } else {
            // Similarly, empty space on the left
            fillToGalleryLeft();
        }

        // Clear unused views
        mRecycler.clear();

        setSelectionToCenterChild();

        onScrollChanged(0, 0, 0, 0); // dummy values, View's implementation does not use these.

        invalidate();
    }

 ListView

  

Java代码  收藏代码
  1. /** 
  2.   * Track a motion scroll 
  3.   * 
  4.   * @param deltaY Amount to offset mMotionView. This is the accumulated delta since the motion 
  5.   *        began. Positive numbers mean the user's finger is moving down the screen. 
  6.   * @param incrementalDeltaY Change in deltaY from the previous event. 
  7.   * @return true if we're already at the beginning/end of the list and have nothing to do. 
  8.   */  
  9.  boolean trackMotionScroll(int deltaY, int incrementalDeltaY) {  
  10.      final int childCount = getChildCount();  
  11.      if (childCount == 0) {  
  12.          return true;  
  13.      }  
  14.   
  15.      final int firstTop = getChildAt(0).getTop();  
  16.      final int lastBottom = getChildAt(childCount - 1).getBottom();  
  17.   
  18.      final Rect listPadding = mListPadding;  
  19.   
  20.      // "effective padding" In this case is the amount of padding that affects  
  21.      // how much space should not be filled by items. If we don't clip to padding  
  22.      // there is no effective padding.  
  23.      int effectivePaddingTop = 0;  
  24.      int effectivePaddingBottom = 0;  
  25.      if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {  
  26.          effectivePaddingTop = listPadding.top;  
  27.          effectivePaddingBottom = listPadding.bottom;  
  28.      }  
  29.   
  30.       // FIXME account for grid vertical spacing too?  
  31.      final int spaceAbove = effectivePaddingTop - firstTop;  
  32.      final int end = getHeight() - effectivePaddingBottom;  
  33.      final int spaceBelow = lastBottom - end;  
  34.   
  35.      final int height = getHeight() - mPaddingBottom - mPaddingTop;  
  36.      if (deltaY < 0) {  
  37.          deltaY = Math.max(-(height - 1), deltaY);  
  38.      } else {  
  39.          deltaY = Math.min(height - 1, deltaY);  
  40.      }  
  41.   
  42.      if (incrementalDeltaY < 0) {  
  43.          incrementalDeltaY = Math.max(-(height - 1), incrementalDeltaY);  
  44.      } else {  
  45.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    = Math.min(height - 1, incrementalDeltaY);  
  46.      }  
  47.   
  48.      final int firstPosition = mFirstPosition;  
  49.   
  50.      // Update our guesses for where the first and last views are  
  51.      if (firstPosition == 0) {  
  52.          mFirstPositionDistanceGuess = firstTop - listPadding.top;  
  53.      } else {  
  54.          mFirstPositionDistanceGuess += incrementalDeltaY;  
  55.      }  
  56.      if (firstPosition + childCount == mItemCount) {  
  57.          mLastPositionDistanceGuess = lastBottom + listPadding.bottom;  
  58.      } else {  
  59.          mLastPositionDistanceGuess += incrementalDeltaY;  
  60.      }  
  61.   
  62.      final boolean cannotScrollDown = (firstPosition == 0 &&  
  63.              firstTop >= listPadding.top && incrementalDeltaY >= 0);  
  64.      final boolean cannotScrollUp = (firstPosition + childCount == mItemCount &&  
  65.              lastBottom <= getHeight() - listPadding.bottom && incrementalDeltaY <= 0);  
  66.   
  67.      if (cannotScrollDown || cannotScrollUp) {  
  68.          return incrementalDeltaY != 0;  
  69.      }  
  70.   
  71.      final boolean down = incrementalDeltaY < 0;  
  72.   
  73.      final boolean inTouchMode = isInTouchMode();  
  74.      if (inTouchMode) {  
  75.          hideSelector();  
  76.      }  
  77.   
  78.      final int headerViewsCount = getHeaderViewsCount();  
  79.      final int footerViewsStart = mItemCount - getFooterViewsCount();  
  80.   
  81.      int start = 0;  
  82.      int count = 0;  
  83.   
  84.      if (down) {  
  85.          int top = -incrementalDeltaY;  
  86.          if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {  
  87.              top += listPadding.top;  
  88.          }  
  89.          for (int i = 0; i < childCount; i++) {  
  90.              final View child = getChildAt(i);  
  91.              if (child.getBottom() >= top) {  
  92.                  break;  
  93.              } else {  
  94.                  count++;  
  95.                  int position = firstPosition + i;  
  96.                  if (position >= headerViewsCount && position < footerViewsStart) {  
  97.                      mRecycler.addScrapView(child, position);  
  98.   
  99.                      if (ViewDebug.TRACE_RECYCLER) {  
  100.                          ViewDebug.trace(child,  
  101.                                  ViewDebug.RecyclerTraceType.MOVE_TO_SCRAP_HEAP,  
  102.                                  firstPosition + i, -1);  
  103.                      }  
  104.                  }  
  105.              }  
  106.          }  
  107.      } else {  
  108.          int bottom = getHeight() - incrementalDeltaY;  
  109.          if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {  
  110.              bottom -= listPadding.bottom;  
  111.          }  
  112.          for (int i = childCount - 1; i >= 0; i--) {  
  113.              final View child = getChildAt(i);  
  114.              if (child.getTop() <= bottom) {  
  115.                  break;  
  116.              } else {  
  117.                  start = i;  
  118.                  count++;  
  119.                  int position = firstPosition + i;  
  120.                  if (position >= headerViewsCount && position < footerViewsStart) {  
  121.                      mRecycler.addScrapView(child, position);  
  122.   
  123.                      if (ViewDebug.TRACE_RECYCLER) {  
  124.                          ViewDebug.trace(child,  
  125.                                  ViewDebug.RecyclerTraceType.MOVE_TO_SCRAP_HEAP,  
  126.                                  firstPosition + i, -1);  
  127.                      }  
  128.                  }  
  129.              }  
  130.          }  
  131.      }  
  132.   
  133.      mMotionViewNewTop = mMotionViewOriginalTop + deltaY;  
  134.   
  135.      mBlockLayoutRequests = true;  
  136.   
  137.      if (count > 0) {  
  138.          detachViewsFromParent(start, count);  
  139.      }  
  140.      offsetChildrenTopAndBottom(incrementalDeltaY);  
  141.   
  142.      if (down) {  
  143.          mFirstPosition += count;  
  144.      }  
  145.   
  146.      invalidate();  
  147.   
  148.      final int absIncrementalDeltaY = Math.abs(incrementalDeltaY);  
  149.      if (spaceAbove < absIncrementalDeltaY || spaceBelow < absIncrementalDeltaY) {  
  150.          fillGap(down);  
  151.      }  
  152.   
  153.      if (!inTouchMode && mSelectedPosition != INVALID_POSITION) {  
  154.          final int childIndex = mSelectedPosition - mFirstPosition;  
  155.          if (childIndex >= 0 && childIndex < getChildCount()) {  
  156.              positionSelector(mSelectedPosition, getChildAt(childIndex));  
  157.          }  
  158.      } else if (mSelectorPosition != INVALID_POSITION) {  
  159.          final int childIndex = mSelectorPosition - mFirstPosition;  
  160.          if (childIndex >= 0 && childIndex < getChildCount()) {  
  161.              positionSelector(INVALID_POSITION, getChildAt(childIndex));  
  162.          }  
  163.      } else {  
  164.          mSelectorRect.setEmpty();  
  165.      }  
  166.   
  167.      mBlockLayoutRequests = false;  
  168.   
  169.      invokeOnItemScrollListener();  
  170.      awakenScrollBars();  
  171.   
  172.      return false;  
  173.  }  
   /**
     * Track a motion scroll
     *
     * @param deltaY Amount to offset mMotionView. This is the accumulated delta since the motion
     *        began. Positive numbers mean the user's finger is moving down the screen.
     * @param incrementalDeltaY Change in deltaY from the previous event.
     * @return true if we're already at the beginning/end of the list and have nothing to do.
     */
    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;

        // "effective padding" In this case is the amount of padding that affects
        // how much space should not be filled by items. If we don't clip to padding
        // there is no effective padding.
        int effectivePaddingTop = 0;
        int effectivePaddingBottom = 0;
        if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {
            effectivePaddingTop = listPadding.top;
            effectivePaddingBottom = listPadding.bottom;
        }

         // FIXME account for grid vertical spacing too?
        final int spaceAbove = effectivePaddingTop - firstTop;
        final int end = getHeight() - effectivePaddingBottom;
        final int spaceBelow = lastBottom - end;

        final int height = getHeight() - mPaddingBottom - mPaddingTop;
        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 {
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      = Math.min(height - 1, incrementalDeltaY);
        }

        final int firstPosition = mFirstPosition;

        // Update our guesses for where the first and last views are
        if (firstPosition == 0) {
            mFirstPositionDistanceGuess = firstTop - listPadding.top;
        } else {
            mFirstPositionDistanceGuess += incrementalDeltaY;
        }
        if (firstPosition + childCount == mItemCount) {
            mLastPositionDistanceGuess = lastBottom + listPadding.bottom;
        } else {
            mLastPositionDistanceGuess += incrementalDeltaY;
        }

        final boolean cannotScrollDown = (firstPosition == 0 &&
                firstTop >= listPadding.top && incrementalDeltaY >= 0);
        final boolean cannotScrollUp = (firstPosition + childCount == mItemCount &&
                lastBottom <= getHeight() - listPadding.bottom && incrementalDeltaY <= 0);

        if (cannotScrollDown || cannotScrollUp) {
            return incrementalDeltaY != 0;
        }

        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) {
            int top = -incrementalDeltaY;
            if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {
                top += listPadding.top;
            }
            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, position);

                        if (ViewDebug.TRACE_RECYCLER) {
                            ViewDebug.trace(child,
                                    ViewDebug.RecyclerTraceType.MOVE_TO_SCRAP_HEAP,
                                    firstPosition + i, -1);
                        }
                    }
                }
            }
        } else {
            int bottom = getHeight() - incrementalDeltaY;
            if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {
                bottom -= listPadding.bottom;
            }
            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, position);

                        if (ViewDebug.TRACE_RECYCLER) {
                            ViewDebug.trace(child,
                                    ViewDebug.RecyclerTraceType.MOVE_TO_SCRAP_HEAP,
                                    firstPosition + i, -1);
                        }
                    }
                }
            }
        }

        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(mSelectedPosition, getChildAt(childIndex));
            }
        } else if (mSelectorPosition != INVALID_POSITION) {
            final int childIndex = mSelectorPosition - mFirstPosition;
            if (childIndex >= 0 && childIndex < getChildCount()) {
                positionSelector(INVALID_POSITION, getChildAt(childIndex));
            }
        } else {
            mSelectorRect.setEmpty();
        }

        mBlockLayoutRequests = false;

        invokeOnItemScrollListener();
        awakenScrollBars();

        return false;
    }

 可以看到Gallery处理View的关键代码:

Java代码  收藏代码
  1. offsetChildrenLeftAndRight(limitedDeltaX);  
  2.   detachOffScreenChildren(toLeft);  
  3.  。。。。  
  4.   mRecycler.clear();  
      offsetChildrenLeftAndRight(limitedDeltaX);
        detachOffScreenChildren(toLeft);
       。。。。
        mRecycler.clear();

而ListView中为:

  

Java代码  收藏代码
  1. if (count > 0) {  
  2.     detachViewsFromParent(start, count);  
  3. }  
  4. offsetChildrenTopAndBottom(incrementalDeltaY);  
        if (count > 0) {
            detachViewsFromParent(start, count);
        }
        offsetChildrenTopAndBottom(incrementalDeltaY);

detachOffScreenChildren和detachViewsFromParent跟ListView功能一样。但是Gallery多出一个 mRecycler.clear()。等待重复利用的mRecycler被回收了,这就造成了再Gallery中无法复用之前的View的情况,由此可见Gallery在内存的使用上存在很大的设计缺陷。

再看Gallery与ListView的makeAndAddView方法

 Gallery

Java代码  收藏代码
  1. private View makeAndAddView(int position, int offset, int x, boolean fromLeft) {  
  2.   
  3.     View child;  
  4.     if (!mDataChanged) {  
  5.         child = mRecycler.get(position);  
  6.         if (child != null) {  
  7.             // Can reuse an existing view  
  8.             int childLeft = child.getLeft();  
  9.               
  10.             // Remember left and right edges of where views have been placed  
  11.             mRightMost = Math.max(mRightMost, childLeft   
  12.                     + child.getMeasuredWidth());  
  13.             mLeftMost = Math.min(mLeftMost, childLeft);  
  14.   
  15.             // Position the view  
  16.             setUpChild(child, offset, x, fromLeft);  
  17.   
  18.             return child;  
  19.         }  
  20.     }  
  21.   
  22.     // Nothing found in the recycler -- ask the adapter for a view  
  23.     child = mAdapter.getView(position, nullthis);  
  24.   
  25.     // Position the view  
  26.     setUpChild(child, offset, x, fromLeft);  
  27.   
  28.     return child;  
  29. }  
    private View makeAndAddView(int position, int offset, int x, boolean fromLeft) {

        View child;
        if (!mDataChanged) {
            child = mRecycler.get(position);
            if (child != null) {
                // Can reuse an existing view
                int childLeft = child.getLeft();

                // Remember left and right edges of where views have been placed
                mRightMost = Math.max(mRightMost, childLeft
                        + child.getMeasuredWidth());
                mLeftMost = Math.min(mLeftMost, childLeft);

                // Position the view
                setUpChild(child, offset, x, fromLeft);

                return child;
            }
        }

        // Nothing found in the recycler -- ask the adapter for a view
        child = mAdapter.getView(position, null, this);

        // Position the view
        setUpChild(child, offset, x, fromLeft);

        return child;
    }

 ListView:

   

Java代码  收藏代码
  1. private View makeAndAddView(int position, int y, boolean flow, int childrenLeft,  
  2.         boolean selected) {  
  3.     View child;  
  4.   
  5.   
  6.     if (!mDataChanged) {  
  7.         // Try to use an existing view for this position  
  8.         child = mRecycler.getActiveView(position);  
  9.         if (child != null) {  
  10.             if (ViewDebug.TRACE_RECYCLER) {  
  11.                 ViewDebug.trace(child, ViewDebug.RecyclerTraceType.RECYCLE_FROM_ACTIVE_HEAP,  
  12.                         position, getChildCount());  
  13.             }  
  14.   
  15.             // Found it -- we're using an existing child  
  16.             // This just needs to be positioned  
  17.             setupChild(child, position, y, flow, childrenLeft, selected, true);  
  18.   
  19.             return child;  
  20.         }  
  21.     }  
  22.   
  23.     // Make a new view for this position, or convert an unused view if possible  
  24.     child = obtainView(position, mIsScrap);  
  25.   
  26.     // This needs to be positioned and measured  
  27.     setupChild(child, position, y, flow, childrenLeft, selected, mIsScrap[0]);  
  28.   
  29.     return child;  
  30. }  
  31.                               
    private View makeAndAddView(int position, int y, boolean flow, int childrenLeft,
            boolean selected) {
        View child;

        if (!mDataChanged) {
            // Try to use an existing view for this position
            child = mRecycler.getActiveView(position);
            if (child != null) {
                if (ViewDebug.TRACE_RECYCLER) {
                    ViewDebug.trace(child, ViewDebug.RecyclerTraceType.RECYCLE_FROM_ACTIVE_HEAP,
                            position, getChildCount());
                }

                // 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;
    }

由此可见Gallery每次都将不可见的View进行了清理。对比ListView来说,在滚动过程中多了new一个View的开销。那么就存在一个避免此类问题的方法,自己对View建一个recyler从而弥补这个缺点。

另外安卓的ViewPager+ScrollView 采用了传统的整个容器进行滚动算法,而不是调整childView的位置,但是也存在卡顿问题。

ViewPager的用法见下文:

http://blog.csdn.net/wangjinyu501/article/details/816

ViewPager的卡顿见下文:

http://marspring.mobi/viewpager-majorization/

里面google提供了一个接口解决卡顿问题。至少目前看来,只是五十步笑百步。不能根本解决问题。默认当前正中位置前后缓存一个View。改后可以缓存多个View。当缓存的多个View被用完的时候,仍然是卡顿。

另外,

有人说用异步加载,异步加载解决的是数据加载问题,跟这个new View造成的卡顿问题是两码事儿。

如何解决?

自己做类似ListView的回收机制,对View进行复用,从而根本上解决这个问题。

另外,安卓没有采用享元模式。。。。很遗憾。。。也许google大牛们会想到,希望如此。希望google能体谅我们,再提供一个类似View的轻量级显示控件来直接支持享元模式。这在Brew平台是有的。。。。。。。。。。。。。。。。。。。。。。郁闷。

原文地址:https://www.cnblogs.com/seven1979/p/4369635.html