软件编程
位置:首页>> 软件编程>> Android编程>> XListView实现下拉刷新和上拉加载原理解析

XListView实现下拉刷新和上拉加载原理解析

作者:RuingMan  发布时间:2022-02-16 06:47:52 

标签:XListView,下拉刷新,上拉加载

XListview是一个非常受欢迎的下拉刷新控件,但是已经停止维护了。之前写过一篇XListview的使用介绍,用起来非常简单,这两天放假无聊,研究了下XListview的实现原理,学到了很多,今天分享给大家。

    提前声明,为了让代码更好的理解,我对代码进行了部分删减和重构,如果大家想看原版代码,请去github自行下载。

    Xlistview项目主要是三部分:XlistView,XListViewHeader,XListViewFooter,分别是XListView主体、header、footer的实现。下面我们分开来介绍。

    下面是修改之后的XListViewHeader代码


public class XListViewHeader extends LinearLayout {

private static final String HINT_NORMAL = "下拉刷新";
 private static final String HINT_READY = "松开刷新数据";
 private static final String HINT_LOADING = "正在加载...";

// 正常状态
 public final static int STATE_NORMAL = 0;
 // 准备刷新状态,也就是箭头方向发生改变之后的状态
 public final static int STATE_READY = 1;
 // 刷新状态,箭头变成了progressBar
 public final static int STATE_REFRESHING = 2;
 // 布局容器,也就是根布局
 private LinearLayout container;
 // 箭头图片
 private ImageView mArrowImageView;
 // 刷新状态显示
 private ProgressBar mProgressBar;
 // 说明文本
 private TextView mHintTextView;
 // 记录当前的状态
 private int mState;
 // 用于改变箭头的方向的动画
 private Animation mRotateUpAnim;
 private Animation mRotateDownAnim;
 // 动画持续时间
 private final int ROTATE_ANIM_DURATION = 180;

public XListViewHeader(Context context) {
   super(context);
   initView(context);
 }

public XListViewHeader(Context context, AttributeSet attrs) {
   super(context, attrs);
   initView(context);
 }

private void initView(Context context) {
   mState = STATE_NORMAL;
   // 初始情况下,设置下拉刷新view高度为0
   LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
       LayoutParams.MATCH_PARENT, 0);
   container = (LinearLayout) LayoutInflater.from(context).inflate(
       R.layout.xlistview_header, null);
   addView(container, lp);
   // 初始化控件
   mArrowImageView = (ImageView) findViewById(R.id.xlistview_header_arrow);
   mHintTextView = (TextView) findViewById(R.id.xlistview_header_hint_textview);
   mProgressBar = (ProgressBar) findViewById(R.id.xlistview_header_progressbar);
   // 初始化动画
   mRotateUpAnim = new RotateAnimation(0.0f, -180.0f,
       Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF,
       0.5f);
   mRotateUpAnim.setDuration(ROTATE_ANIM_DURATION);
   mRotateUpAnim.setFillAfter(true);
   mRotateDownAnim = new RotateAnimation(-180.0f, 0.0f,
       Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF,
       0.5f);
   mRotateDownAnim.setDuration(ROTATE_ANIM_DURATION);
   mRotateDownAnim.setFillAfter(true);
 }

// 设置header的状态
 public void setState(int state) {
   if (state == mState)
     return;

// 显示进度
   if (state == STATE_REFRESHING) {
     mArrowImageView.clearAnimation();
     mArrowImageView.setVisibility(View.INVISIBLE);
     mProgressBar.setVisibility(View.VISIBLE);
   } else {
     // 显示箭头
     mArrowImageView.setVisibility(View.VISIBLE);
     mProgressBar.setVisibility(View.INVISIBLE);
   }

switch (state) {
   case STATE_NORMAL:
     if (mState == STATE_READY) {
       mArrowImageView.startAnimation(mRotateDownAnim);
     }
     if (mState == STATE_REFRESHING) {
       mArrowImageView.clearAnimation();
     }
     mHintTextView.setText(HINT_NORMAL);
     break;
   case STATE_READY:
     if (mState != STATE_READY) {
       mArrowImageView.clearAnimation();
       mArrowImageView.startAnimation(mRotateUpAnim);
       mHintTextView.setText(HINT_READY);
     }
     break;
   case STATE_REFRESHING:
     mHintTextView.setText(HINT_LOADING);
     break;
   }

mState = state;
 }

public void setVisiableHeight(int height) {
   if (height < 0)
     height = 0;
   LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) container
       .getLayoutParams();
   lp.height = height;
   container.setLayoutParams(lp);
 }

public int getVisiableHeight() {
   return container.getHeight();
 }

public void show() {
   container.setVisibility(View.VISIBLE);
 }

public void hide() {
   container.setVisibility(View.INVISIBLE);
 }

}

    XListViewHeader继承自linearLayout,用来实现下拉刷新时的界面展示,可以分为三种状态:正常、准备刷新、正在加载。
    在Linearlayout布局里面,主要有指示箭头、说明文本、圆形加载条三个控件。在构造函数中,调用了initView()进行控件的初始化操作。在添加布局文件的时候,指定高度为0,这是为了隐藏header,然后初始化动画,是为了完成箭头的旋转动作。
    setState()是设置header的状态,因为header需要根据不同的状态,完成控件隐藏、显示、改变文字等操作,这个方法主要是在XListView里面调用。除此之外,还有setVisiableHeight()和getVisiableHeight(),这两个方法是为了设置和获取Header中根布局文件的高度属性,从而完成拉伸和收缩的效果,而show()和hide()则显然就是完成显示和隐藏的效果。
    下面是Header的布局文件


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
 xmlns:tools="http://schemas.android.com/tools"
 android:layout_width="match_parent"
 android:layout_height="wrap_content"
 android:gravity="bottom" >

<RelativeLayout
   android:id="@+id/xlistview_header_content"
   android:layout_width="match_parent"
   android:layout_height="60dp"
   tools:ignore="UselessParent" >

<TextView
     android:id="@+id/xlistview_header_hint_textview"
     android:layout_width="100dp"
     android:layout_height="wrap_content"
     android:layout_centerInParent="true"
     android:gravity="center"
     android:text="正在加载"
     android:textColor="@android:color/black"
     android:textSize="14sp" />

<ImageView
     android:id="@+id/xlistview_header_arrow"
     android:layout_width="30dp"
     android:layout_height="wrap_content"
     android:layout_centerVertical="true"
     android:layout_toLeftOf="@id/xlistview_header_hint_textview"
     android:src="@drawable/xlistview_arrow" />

<ProgressBar
     android:id="@+id/xlistview_header_progressbar"
     style="@style/progressbar_style"
     android:layout_width="30dp"
     android:layout_height="30dp"
     android:layout_centerVertical="true"
     android:layout_toLeftOf="@id/xlistview_header_hint_textview"
     android:visibility="invisible" />
 </RelativeLayout>

</LinearLayout>

    说完了Header,我们再看看Footer。Footer是为了完成加载更多功能时候的界面展示,基本思路和Header是一样的,下面是Footer的代码


public class XListViewFooter extends LinearLayout {

// 正常状态
 public final static int STATE_NORMAL = 0;
 // 准备状态
 public final static int STATE_READY = 1;
 // 加载状态
 public final static int STATE_LOADING = 2;

private View mContentView;
 private View mProgressBar;
 private TextView mHintView;

public XListViewFooter(Context context) {
   super(context);
   initView(context);
 }

public XListViewFooter(Context context, AttributeSet attrs) {
   super(context, attrs);
   initView(context);
 }

private void initView(Context context) {

LinearLayout moreView = (LinearLayout) LayoutInflater.from(context)
       .inflate(R.layout.xlistview_footer, null);
   addView(moreView);
   moreView.setLayoutParams(new LinearLayout.LayoutParams(
       LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT));

mContentView = moreView.findViewById(R.id.xlistview_footer_content);
   mProgressBar = moreView.findViewById(R.id.xlistview_footer_progressbar);
   mHintView = (TextView) moreView
       .findViewById(R.id.xlistview_footer_hint_textview);
 }

/**
  * 设置当前的状态
  *
  * @param state
  */
 public void setState(int state) {

mProgressBar.setVisibility(View.INVISIBLE);
   mHintView.setVisibility(View.INVISIBLE);

switch (state) {
   case STATE_READY:
     mHintView.setVisibility(View.VISIBLE);
     mHintView.setText(R.string.xlistview_footer_hint_ready);
     break;

case STATE_NORMAL:
     mHintView.setVisibility(View.VISIBLE);
     mHintView.setText(R.string.xlistview_footer_hint_normal);
     break;

case STATE_LOADING:
     mProgressBar.setVisibility(View.VISIBLE);
     break;

}

}

public void setBottomMargin(int height) {
   if (height > 0) {

LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) mContentView
         .getLayoutParams();
     lp.bottomMargin = height;
     mContentView.setLayoutParams(lp);
   }
 }

public int getBottomMargin() {
   LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) mContentView
       .getLayoutParams();
   return lp.bottomMargin;
 }

public void hide() {
   LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) mContentView
       .getLayoutParams();
   lp.height = 0;
   mContentView.setLayoutParams(lp);
 }

public void show() {
   LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) mContentView
       .getLayoutParams();
   lp.height = LayoutParams.WRAP_CONTENT;
   mContentView.setLayoutParams(lp);
 }

}

    从上面的代码里面,我们可以看出,footer和header的思路是一样的,只不过,footer的拉伸和显示效果不是通过高度来模拟的,而是通过设置BottomMargin来完成的。
    下面是Footer的布局文件 


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
 xmlns:tools="http://schemas.android.com/tools"
 android:layout_width="fill_parent"
 android:layout_height="wrap_content" >

<RelativeLayout
   android:id="@+id/xlistview_footer_content"
   android:layout_width="fill_parent"
   android:layout_height="wrap_content"
   android:padding="5dp"
   tools:ignore="UselessParent" >

<ProgressBar
     android:id="@+id/xlistview_footer_progressbar"
     style="@style/progressbar_style"
     android:layout_width="30dp"
     android:layout_height="30dp"
     android:layout_centerInParent="true"
     android:visibility="invisible" />

<TextView
     android:id="@+id/xlistview_footer_hint_textview"
     android:layout_width="wrap_content"
     android:layout_height="wrap_content"
     android:layout_centerInParent="true"
     android:text="@string/xlistview_footer_hint_normal"
     android:textColor="@android:color/black"
     android:textSize="14sp" />
 </RelativeLayout>

</LinearLayout>

    在了解了Header和footer之后,我们就要介绍最核心的XListView的代码实现了。
    在介绍代码实现之前,我先介绍一下XListView的实现原理。
    首先,一旦使用XListView,Footer和Header就已经添加到我们的ListView上面了,XListView就是通过继承ListView,然后处理了屏幕点击事件和控制滑动实现效果的。所以,如果我们的Adapter中getCount()返回的值是20,那么其实XListView里面是有20+2个item的,这个数量即使我们关闭了XListView的刷新和加载功能,也是不会变化的。Header和Footer通过addHeaderView和addFooterView添加上去之后,如果想实现下拉刷新和上拉加载功能,那么就必须有拉伸效果,所以就像上面的那样,Header是通过设置height,Footer是通过设置BottomMargin来模拟拉伸效果。那么回弹效果呢?仅仅通过设置高度或者是间隔是达不到模拟回弹效果的,因此,就需要用Scroller来实现模拟回弹效果。在说明原理之后,我们开始介绍XListView的核心实现原理。
    再次提示,下面的代码经过我重构了,只是为了看起来更好的理解。


public class XListView extends ListView {

private final static int SCROLLBACK_HEADER = 0;
 private final static int SCROLLBACK_FOOTER = 1;
 // 滑动时长
 private final static int SCROLL_DURATION = 400;
 // 加载更多的距离
 private final static int PULL_LOAD_MORE_DELTA = 100;
 // 滑动比例
 private final static float OFFSET_RADIO = 2f;
 // 记录按下点的y坐标
 private float lastY;
 // 用来回滚
 private Scroller scroller;
 private IXListViewListener mListViewListener;
 private XListViewHeader headerView;
 private RelativeLayout headerViewContent;
 // header的高度
 private int headerHeight;
 // 是否能够刷新
 private boolean enableRefresh = true;
 // 是否正在刷新
 private boolean isRefreashing = false;
 // footer
 private XListViewFooter footerView;
 // 是否可以加载更多
 private boolean enableLoadMore;
 // 是否正在加载
 private boolean isLoadingMore;
 // 是否footer准备状态
 private boolean isFooterAdd = false;
 // total list items, used to detect is at the bottom of listview.
 private int totalItemCount;
 // 记录是从header还是footer返回
 private int mScrollBack;

private static final String TAG = "XListView";

public XListView(Context context) {
   super(context);
   initView(context);
 }

public XListView(Context context, AttributeSet attrs) {
   super(context, attrs);
   initView(context);
 }

public XListView(Context context, AttributeSet attrs, int defStyle) {
   super(context, attrs, defStyle);
   initView(context);
 }

private void initView(Context context) {

scroller = new Scroller(context, new DecelerateInterpolator());

headerView = new XListViewHeader(context);
   footerView = new XListViewFooter(context);

headerViewContent = (RelativeLayout) headerView
       .findViewById(R.id.xlistview_header_content);
   headerView.getViewTreeObserver().addOnGlobalLayoutListener(
       new OnGlobalLayoutListener() {
         @SuppressWarnings("deprecation")
         @Override
         public void onGlobalLayout() {
           headerHeight = headerViewContent.getHeight();
           getViewTreeObserver()
               .removeGlobalOnLayoutListener(this);
         }
       });
   addHeaderView(headerView);

}

@Override
 public void setAdapter(ListAdapter adapter) {
   // 确保footer最后添加并且只添加一次
   if (isFooterAdd == false) {
     isFooterAdd = true;
     addFooterView(footerView);
   }
   super.setAdapter(adapter);

}

@Override
 public boolean onTouchEvent(MotionEvent ev) {

totalItemCount = getAdapter().getCount();
   switch (ev.getAction()) {
   case MotionEvent.ACTION_DOWN:
     // 记录按下的坐标
     lastY = ev.getRawY();
     break;
   case MotionEvent.ACTION_MOVE:
     // 计算移动距离
     float deltaY = ev.getRawY() - lastY;
     lastY = ev.getRawY();
     // 是第一项并且标题已经显示或者是在下拉
     if (getFirstVisiblePosition() == 0
         && (headerView.getVisiableHeight() > 0 || deltaY > 0)) {
       updateHeaderHeight(deltaY / OFFSET_RADIO);
     } else if (getLastVisiblePosition() == totalItemCount - 1
         && (footerView.getBottomMargin() > 0 || deltaY < 0)) {
       updateFooterHeight(-deltaY / OFFSET_RADIO);
     }
     break;

case MotionEvent.ACTION_UP:

if (getFirstVisiblePosition() == 0) {
       if (enableRefresh
           && headerView.getVisiableHeight() > headerHeight) {
         isRefreashing = true;
         headerView.setState(XListViewHeader.STATE_REFRESHING);
         if (mListViewListener != null) {
           mListViewListener.onRefresh();
         }
       }
       resetHeaderHeight();
     } else if (getLastVisiblePosition() == totalItemCount - 1) {
       if (enableLoadMore
           && footerView.getBottomMargin() > PULL_LOAD_MORE_DELTA) {
         startLoadMore();
       }
       resetFooterHeight();
     }
     break;
   }
   return super.onTouchEvent(ev);
 }

@Override
 public void computeScroll() {

// 松手之后调用
   if (scroller.computeScrollOffset()) {

if (mScrollBack == SCROLLBACK_HEADER) {
       headerView.setVisiableHeight(scroller.getCurrY());
     } else {
       footerView.setBottomMargin(scroller.getCurrY());
     }
     postInvalidate();
   }
   super.computeScroll();

}

public void setPullRefreshEnable(boolean enable) {
   enableRefresh = enable;

if (!enableRefresh) {
     headerView.hide();
   } else {
     headerView.show();
   }
 }

public void setPullLoadEnable(boolean enable) {
   enableLoadMore = enable;
   if (!enableLoadMore) {
     footerView.hide();
     footerView.setOnClickListener(null);
   } else {
     isLoadingMore = false;
     footerView.show();
     footerView.setState(XListViewFooter.STATE_NORMAL);
     footerView.setOnClickListener(new OnClickListener() {
       @Override
       public void onClick(View v) {
         startLoadMore();
       }
     });
   }
 }

public void stopRefresh() {
   if (isRefreashing == true) {
     isRefreashing = false;
     resetHeaderHeight();
   }
 }

public void stopLoadMore() {
   if (isLoadingMore == true) {
     isLoadingMore = false;
     footerView.setState(XListViewFooter.STATE_NORMAL);
   }
 }

private void updateHeaderHeight(float delta) {
   headerView.setVisiableHeight((int) delta
       + headerView.getVisiableHeight());
   // 未处于刷新状态,更新箭头
   if (enableRefresh && !isRefreashing) {
     if (headerView.getVisiableHeight() > headerHeight) {
       headerView.setState(XListViewHeader.STATE_READY);
     } else {
       headerView.setState(XListViewHeader.STATE_NORMAL);
     }
   }

}

private void resetHeaderHeight() {
   // 当前的可见高度
   int height = headerView.getVisiableHeight();
   // 如果正在刷新并且高度没有完全展示
   if ((isRefreashing && height <= headerHeight) || (height == 0)) {
     return;
   }
   // 默认会回滚到header的位置
   int finalHeight = 0;
   // 如果是正在刷新状态,则回滚到header的高度
   if (isRefreashing && height > headerHeight) {
     finalHeight = headerHeight;
   }
   mScrollBack = SCROLLBACK_HEADER;
   // 回滚到指定位置
   scroller.startScroll(0, height, 0, finalHeight - height,
       SCROLL_DURATION);
   // 触发computeScroll
   invalidate();
 }

private void updateFooterHeight(float delta) {
   int height = footerView.getBottomMargin() + (int) delta;
   if (enableLoadMore && !isLoadingMore) {
     if (height > PULL_LOAD_MORE_DELTA) {
       footerView.setState(XListViewFooter.STATE_READY);
     } else {
       footerView.setState(XListViewFooter.STATE_NORMAL);
     }
   }
   footerView.setBottomMargin(height);

}

private void resetFooterHeight() {
   int bottomMargin = footerView.getBottomMargin();
   if (bottomMargin > 0) {
     mScrollBack = SCROLLBACK_FOOTER;
     scroller.startScroll(0, bottomMargin, 0, -bottomMargin,
         SCROLL_DURATION);
     invalidate();
   }
 }

private void startLoadMore() {
   isLoadingMore = true;
   footerView.setState(XListViewFooter.STATE_LOADING);
   if (mListViewListener != null) {
     mListViewListener.onLoadMore();
   }
 }

public void setXListViewListener(IXListViewListener l) {
   mListViewListener = l;
 }

public interface IXListViewListener {

public void onRefresh();

public void onLoadMore();
 }
}

    在三个构造函数中,都调用initView进行了header和footer的初始化,并且定义了一个Scroller,并传入了一个减速的插值器,为了模仿回弹效果。在initView方法里面,因为header可能还没初始化完毕,所以通过GlobalLayoutlistener来获取了header的高度,然后addHeaderView添加到了listview上面。
    通过重写setAdapter方法,保证Footer最后天假,并且只添加一次。
    最重要的,要属onTouchEvent了。在方法开始之前,通过getAdapter().getCount()获取到了item的总数,便于计算位置。这个操作在源代码中是通过scrollerListener完成的,因为ScrollerListener在这里没大有用,所以我直接去掉了,然后把位置改到了这里。如果在setAdapter里面获取的话,只能获取到没有header和footer的item数量。
    在ACTION_DOWN里面,进行了lastY的初始化,lastY是为了判断移动方向的,因为在ACTION_MOVE里面,通过ev.getRawY()-lastY可以计算出手指的移动趋势,如果>0,那么就是向下滑动,反之向上。getRowY()是获取元Y坐标,意思就是和Window和View坐标没有关系的坐标,代表在屏幕上的绝对位置。然后在下面的代码里面,如果第一项可见并且header的可见高度>0或者是向下滑动,就说明用户在向下拉动或者是向上拉动header,也就是指示箭头显示的时候的状态,这时候调用了updateHeaderHeight,来更新header的高度,实现header可以跟随手指动作上下移动。这里有个OFFSET_RADIO,这个值是一个移动比例,就是说,你手指在Y方向上移动400px,如果比例是2,那么屏幕上的控件移动就是400px/2=200px,可以通过这个值来控制用户的滑动体验。下面的关于footer的判断与此类似,不再赘述。
   当用户移开手指之后,ACTION_UP方法就会被调用。在这里面,只对可见位置是0和item总数-1的位置进行了处理,其实正好对应header和footer。如果位置是0,并且可以刷新,然后当前的header可见高度>原始高度的话,就说明用户确实是要进行刷新操作,所以通过setState改变header的状态,如果有 * 的话,就调用onRefresh方法,然后调用resetHeaderHeight初始化header的状态,因为footer的操作如出一辙,所以不再赘述。但是在footer中有一个PULL_LOAD_MORE_DELTA,这个值是加载更多触发条件的临界值,只有footer的间隔超过这个值之后,才能够触发加载更多的功能,因此我们可以修改这个值来改变用户体验。
    说到现在,大家应该明白基本的原理了,其实XListView就是通过对用户手势的方向和距离的判断,来动态的改变Header和Footer实现的功能,所以如果我们也有类似的需求,就可以参照这种思路进行自定义。
    下面再说几个比较重要的方法。
    前面我们说道,在ACTION_MOVE里面,会不断的调用下面的updateXXXX方法,来动态的改变header和fooer的状态,


private void updateHeaderHeight(float delta) {
   headerView.setVisiableHeight((int) delta
       + headerView.getVisiableHeight());
   // 未处于刷新状态,更新箭头
   if (enableRefresh && !isRefreashing) {
     if (headerView.getVisiableHeight() > headerHeight) {
       headerView.setState(XListViewHeader.STATE_READY);
     } else {
       headerView.setState(XListViewHeader.STATE_NORMAL);
     }
   }

}

private void updateFooterHeight(float delta) {
   int height = footerView.getBottomMargin() + (int) delta;
   if (enableLoadMore && !isLoadingMore) {
     if (height > PULL_LOAD_MORE_DELTA) {
       footerView.setState(XListViewFooter.STATE_READY);
     } else {
       footerView.setState(XListViewFooter.STATE_NORMAL);
     }
   }
   footerView.setBottomMargin(height);

}

    在移开手指之后,会调用下面的resetXXX来初始化header和footer的状态


private void resetHeaderHeight() {
   // 当前的可见高度
   int height = headerView.getVisiableHeight();
   // 如果正在刷新并且高度没有完全展示
   if ((isRefreashing && height <= headerHeight) || (height == 0)) {
     return;
   }
   // 默认会回滚到header的位置
   int finalHeight = 0;
   // 如果是正在刷新状态,则回滚到header的高度
   if (isRefreashing && height > headerHeight) {
     finalHeight = headerHeight;
   }
   mScrollBack = SCROLLBACK_HEADER;
   // 回滚到指定位置
   scroller.startScroll(0, height, 0, finalHeight - height,
       SCROLL_DURATION);
   // 触发computeScroll
   invalidate();
 }

private void resetFooterHeight() {
   int bottomMargin = footerView.getBottomMargin();
   if (bottomMargin > 0) {
     mScrollBack = SCROLLBACK_FOOTER;
     scroller.startScroll(0, bottomMargin, 0, -bottomMargin,
         SCROLL_DURATION);
     invalidate();
   }
 }

    我们可以看到,滚动操作不是通过直接的设置高度来实现的,而是通过Scroller.startScroll()来实现的,通过调用此方法,computeScroll()就会被调用,然后在这个里面,根据mScrollBack区分是哪一个滚动,然后再通过设置高度和间隔,就可以完成收缩的效果了。
    至此,整个XListView的实现原理就完全的搞明白了,以后如果做滚动类的自定义控件,应该也有思路了。

来源:http://blog.csdn.net/RuingMan/article/details/51647697

0
投稿

猜你喜欢

手机版 软件编程 asp之家 www.aspxhome.com