老话题,ListView 源码解析

Android · coderen · Created at · Last by mm685265 Replied at · 1981 hits
96

源码解析的习惯也要开始养成呀,ListView 的一些工作流程当时看懂了,可过了一段时间再看彻底忘了,这次又花了时间顺了一遍,所以必须要记下来了,以便跟 RecyclerView 作对比,所以先看 ListView

继承结构

ListView 和 GridView 都继承自 AbListView ,AbListView 继承自 AdapterView,再往上就是 ViewGroup 和 View 了

在使用 ListView 时,我们需要 Adpater 来适配 ListView 的工作,这样用到了适配器模式,ListView 只负责显示数据和处理交互, 至于显示什么数据,这个工作就交给了 Adapter,这样在 ListView 需要数据时就跟 Adapter 来要,减轻了 ListView 的工作量,并为 ListView 可以展示不同的数据提供了实现。

我们学习过 ListView 的时候,都知道 ListView 不但以列表形式展示,并且 item 还能实现复用,这样即使显示上千条数据,内存中 ListView 的 item 的 View 对象也就那么几个,至于 ListView 的 item 复用是如何实现的,今天的解析就是为了搞清楚这个。

RecycleBin

首先我们看一个 AbListView 的内部类,RecycleBin ,这个类在 LisView 工作中起到了关键性作用。主要解析 RecycleBin 的 5 个方法

RecycleBin 中的代码很多,我只选了重要的 5 个方法,这五个方法都加了注释,可以先稍微了解一下,接下来分析 ListView 的时候再回头看会更加清晰。

class AbListView {

class RecycleBin {

/**
* 在 ListView 的 item 只有一种布局时,存储废弃 View 的集合
/
private ArrayList<View> mCurrentScrap;
/
*
* 在 ListViwe 有多种布局时,这个数组中的每一项都是一种布局的集合
*/
private ArrayList<View>[] mScrapViews;

/**
* 当前 ListView 中显示的 View
*/
private View[] mActiveViews = new View[0];

/**
* 将 ListView 中的 View 都放入 mActivieViews 数组中
* childCount 要存入的 View 的数量
* firstActivePosition 第一个可见的 View 的 position
*/
void fillActiveViews(int childCount, int firstActivePosition) {

if (mActiveViews.length < childCount) {
mActiveViews = new View[childCount];
}

mFirstActivePosition = firstActivePosition;

final View[] activeViews = mActiveViews;
for (int i = 0; i < childCount; i++) {// 遍历 AbListView 中的子 View ,把每一个都放入 mActiveViews 中
View child = getChildAt(i);
AbsListView.LayoutParams lp = (AbsListView.LayoutParams) child.getLayoutParams();

if (lp != null && lp.viewType != ITEM_VIEW_TYPE_HEADER_OR_FOOTER) {

activeViews[i] = child;
// Remember the position so that setupChild() doesn't reset state.
lp.scrappedFromPosition = firstActivePosition + i;
}
}
}

/**
* 与 fillActiveViews 相对,getActiveView 方法作用为从 mActiveViews 中取出相应位置的 View,并移除 mActiveViews 中 position 位置的 View
* 说明 mActiveViews 中每个位置的 View 只能使用一次,除非重新赋值
*/
View getActiveView(int position) {
int index = position - mFirstActivePosition;
final View[] activeViews = mActiveViews;
if (index >=0 && index < activeViews.length) {
final View match = activeViews[index];
activeViews[index] = null;
return match;
}
return null;
}

/**
* 根据 View 在 AbListView 中的 Type ,存储废弃的 View,例如滚动出屏幕的 View
*/
void addScrapView(View scrap, int position) {
...
final AbsListView.LayoutParams lp = (AbsListView.LayoutParams) scrap.getLayoutParams();
final int viewType = lp.viewType; // 获取 Type 的值
...
if (mViewTypeCount == 1) {
mCurrentScrap.add(scrap); // 如果只有一种则添加到 mCurrentScrap 中
} else {
mScrapViews[viewType].add(scrap); // 如果有多种,则放入对应的集合中
}
...
}

/**
* @return A view from the ScrapViews collection. 找到废弃 View 的数组中 itemId 跟 position 处 itemId 相同的 View 从数组中移除,并将 View 返回
*/
View getScrapView(int position) {
final int whichScrap = mAdapter.getItemViewType(position); // 需要获取的 Viwe 的 Type
if (whichScrap < 0) {
return null;
}
if (mViewTypeCount == 1) {
return retrieveFromScrap(mCurrentScrap, position); // 只有一种时,返回 mCurrentScrap 中对应位置 View
} else if (whichScrap < mScrapViews.length) {
return retrieveFromScrap(mScrapViews[whichScrap], position); // 多种情况是,返回对应种类集合中的 View
}
return null;
}

/**
* 设置 ListView 中 item 的种类,并初始化存储废弃 View 的集合
*/
public void setViewTypeCount(int viewTypeCount) {
if (viewTypeCount < 1) {
throw new IllegalArgumentException("Can't have a viewTypeCount < 1");
}
//noinspection unchecked
ArrayList<View>[] scrapViews = new ArrayList[viewTypeCount];
for (int i = 0; i < viewTypeCount; i++) {
scrapViews[i] = new ArrayList<View>();
}
mViewTypeCount = viewTypeCount;
mCurrentScrap = scrapViews[0];
mScrapViews = scrapViews;
}
}
}

ListView 的工作流程

ListView 在显示时,也是通过 onMeasure,onLayout,onDraw 的流程,onMeasure 过程很简单,因为 ListView 一般都是固定宽高或者宽高最大;onDraw 更简单,绘制的过程都是每个子 View 自己绘制自己,ListViwe 不用管绘制阶段;只有 onLayout 过程是有实际工作意义的,所以我们直接来看 onLayout 方法,onLayout 方法的实现是在 AbListView 中的,onLayout 方法中主要就是调用 layoutChildren 来布局子 View,这个方法就切换到 ListView 中了

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);

...

layoutChildren(); // 布局 子 view

...
}

@Override
protected void layoutChildren() {
...
final int childCount = getChildCount();
if (dataChanged) { // 判断 数据集 是否变化
for (int i = 0; i < childCount; i++) {
recycleBin.addScrapView(getChildAt(i), firstPosition+i);
}
} else { // ListView 刚进来时,进去当然是没变化的,所以执行这里,recycleBin 就是刚开始提到的 RecycleBin 的实例,fillActiveViews 将子 View 添加到 RcycleBin 的响应集合里,由于第一次进来是没有子 View 的,所以这行代码无效果
recycleBin.fillActiveViews(childCount, firstPosition);
}

...
switch (mLayoutMode) { // 布局模式,一般都是默认
default:
if (childCount == 0) { // 第一次 onLayout 时 子 View 个数为 0
if (!mStackFromBottom) { // 从上往下加载
final int position = lookForSelectablePosition(0, true);
setSelectedPositionInt(position);
sel = fillFromTop(childrenTop);
} else { // 从下往上加载
final int position = lookForSelectablePosition(mItemCount - 1, false);
setSelectedPositionInt(position);
sel = fillUp(mItemCount - 1, childrenBottom);
}
} else {
...
}
break;

}
}

我们来看 layoutChildre 方法,数据没有变化时会调用 recycleBin.fillActiveViews 的方法,这是子 View 的个数为 0 ,所以这行代码无效,接着到了根据布局模式填充数据的阶段,一般都是默认模式。接下来判断从上往下填充活着从下往上填充 ListView,这个是由 ListView 使用者设置的,默认从上往下。

// 从上往下填充
private View fillFromTop(int nextTop) {
// 检查第一个位置的有效性
mFirstPosition = Math.min(mFirstPosition, mSelectedPosition);
mFirstPosition = Math.min(mFirstPosition, mItemCount - 1);
if (mFirstPosition < 0) {
mFirstPosition = 0;
}
return fillDown(mFirstPosition, nextTop); // 填充数据
}

private View fillDown(int pos, int nextTop) {
View selectedView = null;

...

// 循环填充数据
while (nextTop < end && pos < mItemCount) { // 下一个 Viwe 的 top 位置还不到 listView 的底部,并且当前 position 小于数据的数量时继续填充
// is this the selected item?
boolean selected = pos == mSelectedPosition;

// 由 位置,需要添加的 Viwe 的顶部在 ListView 中的位置,padding 值的构造子 View 并添加到 ListView 中
View child = makeAndAddView(pos, nextTop, true, mListPadding.left, selected);

nextTop = child.getBottom() + mDividerHeight; // 计算下一个子 View 的顶部位置
if (selected) {
selectedView = child;
}
pos++;
}

setVisibleRangeHint(mFirstPosition, mFirstPosition + getChildCount() - 1);
return selectedView;
}

// 从下往上填充的情况
private View fillUp(int pos, int nextBottom) {
View selectedView = null;

int end = 0;
if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {
end = mListPadding.top;
}

// 循环填充
while (nextBottom > end && pos >= 0) { // 从下往上填充,判断的是下一个 View 的底部是否已经出了屏幕位置,如果出了屏幕则不用添加
// is this the selected item?
boolean selected = pos == mSelectedPosition;
View child = makeAndAddView(pos, nextBottom, false, mListPadding.left, selected); // 构造 View 并填充到 ListView
nextBottom = child.getTop() - mDividerHeight; // 下一个 Viwe 的底部位置
if (selected) {
selectedView = child;
}
pos--;
}

mFirstPosition = pos + 1;
setVisibleRangeHint(mFirstPosition, mFirstPosition + getChildCount() - 1);
return selectedView;
}

由代码我们看到不管是从下往上填充还是从上往下填充,都会调用一个 makeAndAddView 方法来构造 View 并将其填充到 ListView 中

// 构造 View 并填充 ListView
private View makeAndAddView(int position, int y, boolean flow, int childrenLeft,
boolean selected) {

if (!mDataChanged) { // 数据集没变化时
final View activeView = mRecycler.getActiveView(position); // 从 RecycleBin 中获取当前位置的 View ,由于第一次加载界面,该 View 为空
if (activeView != null) {
setupChild(activeView, position, y, flow, childrenLeft, selected, true);
return activeView;
}
}

final View child = obtainView(position, mIsScrap); // 在 RecycleBin 中没有需要的 View 时,调用 obtainView 方法来构造 View
// This needs to be positioned and measured.
setupChild(child, position, y, flow, childrenLeft, selected, mIsScrap[0]); // 将 View 填充到 ListView

return child;
}

第一次加载时在 RecycleBin 中不能获取到对象 view, 则通过 obtainView 方法来构造 Viwe,该方法在 AbListView 中.

View obtainView(int position, boolean[] outMetadata) {

...

final View scrapView = mRecycler.getScrapView(position); // 从 RecycleBin 中的废弃 View 中取出需要的 View ,由于是第一次加载,返回为空

final View child = mAdapter.getView(position, scrapView, this); // 由 Adapter 的 getView 方法来获取 View

if (scrapView != null) {
if (child != scrapView) {
mRecycler.addScrapView(scrapView, position);
} else if (child.isTemporarilyDetached()) {
...
}
}

setItemViewLayoutParams(child, position);

return child; // 返回 Viwe
}

obtainView 方法中会首先从 RecycleBin 中的废弃 View 的集合中来获取 View,第一次加载时是获取不到的,所以同 mAdapter.getView 方法来获取 View,getView 方法就是我们定义 Adapter 时重写的那个方法。在 scrapView 为空时,我们会通过 LayoutInflater 来加载一个 View 赋值给 child,最后将child 返回。makeAndAddView 方法中调用 obtainView 获取到 View 之后,在调用 setupChild 方法,其中调用 attachViewToParent 将 Viwe 填充到 ListView 中。加载时的第一次 onLayout 过程结束

由于 Android 界面加载机制,onLayout 方法会调用两次,第一次 onLayout 时我们上面已经分析过了,如果再加载一次,难道数据会添加两次? 我们接着回头看第二次 onLayout 的过程。其中 onLayout 还是会调用 layoutChildren 方法,不过执行的逻辑确跟第一次不同了

@Override
protected void layoutChildren() {
...
final int childCount = getChildCount();
if (dataChanged) { // 判断 数据集 是否变化
for (int i = 0; i < childCount; i++) {
recycleBin.addScrapView(getChildAt(i), firstPosition+i);
}
} else { // 这已经是第二季加载,ListView 中已经是有数据了,这时候将 ListView 中的所有 View 都添加到 recycleBind 中的存储 ListView 子 View 的集合中
recycleBin.fillActiveViews(childCount, firstPosition);
}

...

detachAllViewsFromParent(); // 移除 ListView 中所有的 Viwe

...
switch (mLayoutMode) { // 布局模式,一般都是默认
default:
if (childCount == 0) { // 第一次 onLayout 时 子 View 个数为 0 时的加载
...
} else { // 第二次加载时 childCount 已经不是 0
if (mSelectedPosition >= 0 && mSelectedPosition < mItemCount) {
sel = fillSpecific(mSelectedPosition,
oldSel == null ? childrenTop : oldSel.getTop());
} else if (mFirstPosition < mItemCount) {
sel = fillSpecific(mFirstPosition,
oldFirst == null ? childrenTop : oldFirst.getTop());
} else {
sel = fillSpecific(0, childrenTop);
}
}
break;

}
}

第二次 onLayout 时,layoutChildren 方法的执行已经跟第一次不一样,这是会将 ListView 中所有的 View 添加到 RecycleBin 中,然后移除 ListView 中所有的 View,再调用 fillSpecific 方法来填充 ListView 。

private View fillSpecific(int position, int top) {
boolean tempIsSelected = position == mSelectedPosition;
View temp = makeAndAddView(position, top, true, mListPadding.left, tempIsSelected);
// Possibly changed again in fillUp if we add rows above this one.
mFirstPosition = position;

View above;
View below;

final int dividerHeight = mDividerHeight;
if (!mStackFromBottom) { // 从上往下加载
above = fillUp(position - 1, temp.getTop() - dividerHeight);
// This will correct for the top of the first view not touching the top of the list
adjustViewsUpOrDown();
below = fillDown(position + 1, temp.getBottom() + dividerHeight);
int childCount = getChildCount();
if (childCount > 0) {
correctTooHigh(childCount);
}
} else {// 从下往加载
below = fillDown(position + 1, temp.getBottom() + dividerHeight);
// This will correct for the bottom of the last view not touching the bottom of the list
adjustViewsUpOrDown();
above = fillUp(position - 1, temp.getTop() - dividerHeight);
int childCount = getChildCount();
if (childCount > 0) {
correctTooLow(childCount);
}
}

...
}

fillSpecific 方法里面还是会根据 ListView 的填充模式从下往上或者从上往下来填充,不过由于之前已经加载过,被选中的 position 的值可能不为 0 ,这时候会从被选中的位置向两头加载。同样是调用 fillUp 和 fillDown 方法,这两个方法的执行跟第一次 onLayout 没什么区别,都是根据当前视图中最后一个 View 的位置和数据的数量来判断是否需要继续添加 View,不过其内部构造 View 时调用的 makeAndAddView 确有了一点不同。由于 从 RecycleBin 中取到了数据,就不需要调用 obtainView 方法来获取 View ,直接将 RecycleBin 中取到的 view 填充到 ListView 中,通过 setupChild 方法中的 attachViewToParent 方法完成填充。

private View makeAndAddView(int position, int y, boolean flow, int childrenLeft,
boolean selected) {
if (!mDataChanged) {

final View activeView = mRecycler.getActiveView(position); // 由于第二次 onLayout 开始就把 ListView 中原来的 view 添加到 RecycleBin 中了,所有 activeView 有值

if (activeView != null) {
// Found it. We're reusing an existing child, so it just needs
// to be positioned like a scrap view.
setupChild(activeView, position, y, flow, childrenLeft, selected, true); // 将从 RecycleBin 中取到的值填充到 ListView 中
return activeView; // 返回 RecycleBin 中得到的 View
}
}

// Make a new view for this position, or convert an unused view if
// possible.
final View child = obtainView(position, mIsScrap);

// This needs to be positioned and measured.
setupChild(child, position, y, flow, childrenLeft, selected, mIsScrap[0]);

return child;
}

这样第一次 onLayout 和第二次 onLayout 方法就调用完成了,ListView 显示到界面上时也就可以展示数据了。只有在第一次加载时才从 Adapter 中获取View,第二次加载时首先为 RecycleBin 添加数据,ListView 再需要数据时直接从 RecycleBin 中获取数据即可。

ListView 滑动过程中的视图填充

在 ListView 滑动过程中 ListView 中的视图是会随时变化的,所以我们必须要分析滑动过程中 ListView 的工作过程,滑动中处理主要在 onTouchEvent 方法中。由于滑动过程中的触摸事件是 EVENT_MOVE 所以我们只看移动过程中的代码,接着追踪到了 onTouchMove 方法中

@Override
public boolean onTouchEvent(MotionEvent ev) {

initVelocityTrackerIfNotExists();
final MotionEvent vtev = MotionEvent.obtain(ev);
final int actionMasked = ev.getActionMasked();
...
switch (actionMasked) {
...
case MotionEvent.ACTION_MOVE: {
onTouchMove(ev, vtev);
break;
}
...
}

if (mVelocityTracker != null) {
mVelocityTracker.addMovement(vtev);
}
vtev.recycle();
return true;
}

ListView 的滑动过程中,在 onTouchMove 中对应的情况为 TOUCH_MODE_SCROLL ,接着追踪到了 scrollFNeeded 方法中,该方法中通过调用 trackMotionScroll 方法来完成滑动过程中的填充任务,trackMotionScroll 方法的两个参数为 deltaY 为从 ACTION_DOWN 到当前位置移动的距离,incrementalDelaY 当前距离上一个事件时发送的距离变化

boolean trackMotionScroll(int deltaY, int incrementalDeltaY) {
...

// 如果 incrementalDeltaY 大于 0 ,说明手指下滑,屏幕中 ListView 向上滚动,向下拉状态,要显示的 View 从顶部出现

// 如果 incrementalDeltaY 小于 0 ,说明手指上滑,屏幕中 ListView 向下滚动,向上拉状态,要显示的 View 从底部出现

final boolean down = incrementalDeltaY < 0;

int start = 0;
int count = 0;

if (down) { // 上拉状态,View 从底部填充,同时 View 从顶部滑出 ListView 的区域
int top = -incrementalDeltaY; // 手指滑动的长度
if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {
top += listPadding.top; // 手指滑动的长度加上 ListViwe 的顶部的 padding 值
}
for (int i = 0; i < childCount; i++) {
final View child = getChildAt(i);
if (child.getBottom() >= top) { // View 的底部加上手指滑动的距离如果比 top 值大,说明该 View 还有部分或整体都在 ListView 中,不能回收
break;
} else { // 如果 View 的底部的值加滑动的距离比 top 值小,说明该 View 经过滑动之后会滑出 ListView 的区域,需要回收
count++; // 需要回收的数量加 1
int position = firstPosition + i;
if (position >= headerViewsCount && position < footerViewsStart) {
...
mRecycler.addScrapView(child, position); // 将需要回收的 view 放入 RecycleBin 中的回收集合中
}
}
}
} else { // 下拉状态,数据从顶部填充,同时 View 从底部移出
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) {
// The view will be rebound to new data, clear any
// system-managed transient state.
child.clearAccessibilityFocus();
mRecycler.addScrapView(child, position);
}
}
}
}

if (count > 0) {
detachViewsFromParent(start, count); // 需要移除的 view 移出 ListView
mRecycler.removeSkippedScrap();
}

offsetChildrenTopAndBottom(incrementalDeltaY); // 将还存在的 view 根据滑动距离做出偏移

// 判断原来最顶部的 View 的在 ListView 中的高度小于滑动的高度,或者最底部的 View 在 ListView 中的高度小于滑动的高度,这时候必然有 View 滑出屏幕
if (spaceAbove < absIncrementalDeltaY || spaceBelow < absIncrementalDeltaY) {
fillGap(down); // 有 View 滑出屏幕是需要新的 View 填充屏幕
}

}

trackMotionScroll 方法中,就根据滑动的距离,筛选出经过滑动之后移除屏幕的 View 并添加到 RecycleBin 中存储废弃 View 的集合中,接着会调用 detachViewFromParent 将需要移除的 view 移出 ListView,还会将仍在布局中的 View 根据滑动距离做出移动,最后如果有 View 移出屏幕,则通过 fillGap 方法充新的 View

void fillGap(boolean down) {
final int count = getChildCount();
if (down) { // 向上拉,从底部填充 view
int paddingTop = 0;
if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {
paddingTop = getListPaddingTop();
}
final int startOffset = count > 0 ? getChildAt(count - 1).getBottom() + mDividerHeight :
paddingTop;
fillDown(mFirstPosition + count, startOffset);
correctTooHigh(getChildCount());
} else { // 向下拉,从顶部填充 view
int paddingBottom = 0;
if ((mGroupFlags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK) {
paddingBottom = getListPaddingBottom();
}
final int startOffset = count > 0 ? getChildAt(0).getTop() - mDividerHeight :
getHeight() - paddingBottom;
fillUp(mFirstPosition - 1, startOffset);
correctTooLow(getChildCount());
}
}

又到了,fillDown 和 fillUp 方法,还记得吗,上面 onLayout 过程中填充 ListView 的方法,其中会调用 makeAndAddView 方法来获取 View

private View makeAndAddView(int position, int y, boolean flow, int childrenLeft,
boolean selected) {
if (!mDataChanged) {
// Try to use an existing view for this position.
final View activeView = mRecycler.getActiveView(position); // 由于在 ListView 的第二次 onLayout 过程中将 RecycleBin.getActiveView 中存储的 View 已经全部获取,所以这里会取得 null
if (activeView != null) {
// Found it. We're reusing an existing child, so it just needs
// to be positioned like a scrap view.
setupChild(activeView, position, y, flow, childrenLeft, selected, true);
return activeView;
}
}

// Make a new view for this position, or convert an unused view if
// possible.
final View child = obtainView(position, mIsScrap); // 从而接着从 obtainView 方法中获取 View

// This needs to be positioned and measured.
setupChild(child, position, y, flow, childrenLeft, selected, mIsScrap[0]);

return child;
}

这次 obtainView 中的执行跟第一次 onLayout 时就不一样了,从 RecycleBin 中的废弃 View 中取出需要的 View ,由于滑出屏幕的 View 已经添加到RecycleBin 中,所以这里不为 null,在 Adapter.getView 时就可以重用被回收的 View ,实现了 ListView 虽然有千百条数据,但是真正的 item View 确只有很少的几个。

View obtainView(int position, boolean[] outMetadata) {

...

final View scrapView = mRecycler.getScrapView(position); // 从 RecycleBin 中的废弃 View 中取出需要的 View ,由于滑出屏幕的 View 已经添加到了 RecycleBin 中,所以这里不为 null

final View child = mAdapter.getView(position, scrapView, this); // 由 Adapter 的 getView 方法来获取 View,将 scrapView 传入该方法,也就是 AdatperView 的 getView 的 convertView 是有值的,这时我们不会重新构造 View,而是复用 convertView ,这样就实现了 ListView 滑动是的 View 重用

if (scrapView != null) {
if (child != scrapView) {
mRecycler.addScrapView(scrapView, position);
} else if (child.isTemporarilyDetached()) {
...
}
}

setItemViewLayoutParams(child, position);

return child; // 返回 Viwe
}

到这里,整个 ListView 源码的分析就要结束了,主要的内容是两次 onLayout 过程中的处理,和 ListView 在滑动过程中 View 的回收和复用的过程。

在 ListView 滑动之后,还会调用 invalidate 方法来申请重新绘制,这个过程又会调用依次 onLayout 这时的 onLayout 执行过程就如果显示到屏幕上时第二次调用 onLayout 的过程,会将 View 添加到 RecycleBin ,移除所有 View ,再从 RecycleBin 中获取 View 添加到 ListView 中。整个过程执行结束。

共收到 3 条回复
30
d_clock · #1 ·

排版太乱了啊。。。。。。看得费劲,望调整。

96
mm0089 · #2 ·

臺灣約妹找妓女做愛打炮賴mm0089 堅持讓你花得值得 讓你一次就滿意
薇薇外送茶優質正妹網站:http://www.kiss69lg.com/forum.php?forumlist=1&mobile=2
堅持讓你花得值得 讓你一次就滿意
保證咩咩 熟練多變的技巧 讓你三度回味
堅持誠信經營 絕不會說的天花亂墬 浪費彼此寶貴的時間
精心安排 保證約好的時間 20分鐘內火速到達
絕無強迫消費‧購買點數‧匯款‧ATM轉帳‧都是見到本人 滿意在消費
優咩咩都是經過篩選 絕無地雷 請放心光臨體驗享受
㊣平價錢又安心 熟客 新客都享有優惠
薇薇茶坊營業時間:中午12點-凌晨四點

外送地區:臺北新北林口龜山新竹臺中彰化南投高雄臺南
妹妹類型:學生 OL 巨乳 蘿莉 人妻 技術茶 空姐小模 AV女優 婚紗助理等
服務內容:全套服務
約妹流程:賴上先預約然後準時給房號在房間等妹到
約會地點:你自己選擇的旅館or熟客可送住家
消費方式:看到妹喜歡在當場現金交易 不喜歡可換三次

北部:一節6000內立減500送1000優惠券
一節7000 -8000第二節半價 買三節送一節
一節9000-10000 立減1000-2000送2次半價
一節11000-15000 立減3000-5000送3次半價
一節15000-30000 立減5000-8000 送一年半價

中南部:一節4000-5000 二節半價
一節6000-7000 買兩節送一節
一節7000-10000 買兩節送兩節
一節11000-15000 立減3000-5000送2次半價
一節15000-30000立減5000-8000送一年半價
約小姐部落格看照網址:https://www.cssanyu.org/bbs2/forum.php?mod=viewthread&tid=333726&extra=
約小姐部落格看照https://www.photostore.me/mm0089/?list=images&sort=date_desc&page=4&seek=ursjV
雙北桃園林口龜山新竹看照約妹網址:http://www.kiss69lg.com/forum.php?mod=forumdisplay&fid=143
臺中彰化南投看照約妹網址:http://www.kiss69lg.com/forum.php?mod=forumdisplay&fid=145
高雄臺南看照約妹網址:http://www.kiss69lg.com/forum.php?mod=forumdisplay&fid=147
安全旅館便宜經濟實惠旅館推薦:http://www.kiss69lg.com/forum.php?mod=forumdisplay&fid=182
色情圖片露點照片網址:http://www.kiss69lg.com/forum.php?mod=forumdisplay&fid=177
成人小說網址http://www.kiss69lg.com/forum.php?mod=forumdisplay&fid=163
台中台北外送茶莊、茶坊推薦|優質台北、高雄外約茶妹任你選南投外送茶/全套叫小姐彰化外送茶
草屯外送茶大台中外送茶,外約美女,薇薇外送茶賴mm0089外約服務網,台中一夜情 .台灣出差旅館叫小姐line:mm0089
台北外送茶/台中外送茶/高雄外送茶/台南外送茶/新竹外送茶/彰化外送茶/南投外送茶/薇薇外送茶賴mm0089大台灣台北台中高雄台南新竹地區喝茶服務外送茶薇薇外送茶賴mm0089/鼓山區看照約妹薇薇外送茶賴mm0089前鎮區美腿茶/三民區火辣茶/新興區惹火嫵媚茶/左營區MT外送... 外送/台北清涼茶莊/更多優質妹妹/台北外送茶莊/高雄外送茶/新竹外送茶/洗澡/愛愛薇薇外送茶賴mm0089/口交旅館酒店外送小姐/上門服務/薇薇外送茶賴mm0089茶魚分享/大台北外送茶坊/大台中外送茶坊/高雄外送茶/援交妹網站台灣叫小姐俱樂部,薇薇外送茶賴mm0089台北叫小姐,薇薇外送茶賴mm0089西門町外送服務 板橋外送茶高雄約妹外約茶莊薇薇外送茶賴mm0089/夜市附近叫小姐,85大樓叫小姐 高雄叫小姐按摩外送茶西?町找小姐薇薇外送茶賴mm0089林森北找茶喝/台北?正妹/台?援交找薇薇外送茶賴mm0089台北叫小姐台中旅館叫小姐高雄旅遊找妹兼職美女茶外送看照約妹好茶外送到家西?町找小姐|林森北找茶喝/台北?正妹/台?援交找薇薇外送茶賴mm0089
#台灣汽車旅館找小姐 #商旅找小姐薇薇外送茶賴mm0089 #星級酒店找小姐
#旅館找小姐 #商旅找小姐薇薇外送茶賴mm0089 #星級酒店找小姐 #旅館找小姐
#飯店找小姐 #住家找小姐薇薇外送茶賴mm0089 #賓館找小姐 #台灣出差旅遊外約 #台灣出差旅遊找小姐
#酒店外約叫茶 #台灣汽車旅館外約叫茶 薇薇外送茶賴mm0089#商旅外約叫茶 #星級酒店外約叫茶 #旅館外約叫茶 #飯店外約叫茶
#住家外約叫茶 #賓館外約叫茶 #台灣出差旅遊外約叫茶 薇薇外送茶賴mm0089#台灣出差旅遊外約叫茶 +薇薇外送茶賴mm0089#
台中外送茶 #台北外送茶 #新竹外送茶薇薇外送茶賴mm0089 #高雄外送茶 #台南外送茶 #彰化外送茶 #南投外送茶 #台中外約
#台北外約 #高雄外約薇薇外送茶賴mm0089 #新竹外約 #台南外約 #彰化外約 #南投外約 #台灣外送茶 #外送茶 #情愛全台外送茶看照約妹叫小姐
#外送茶不戴套 +薇薇外送茶賴mm0089#板橋外約 #三重外約 #永和外約 #中和外約 #汐止外約 #新莊外約 #土城外約
#新店外約 #蘆洲外約 #五股外約 #泰山外約 #淡水外約薇薇外送茶賴mm0089 #八里外約 #林口外約 #龜山外約 #台中外約 #高雄外約
#台北外約薇薇外送茶賴mm0089 #本土外約 #外約台妹 #中正外約 #大同外約 #松山外約+薇薇外送茶賴mm0089 #板橋外送茶 #板橋外約
#大安外約 #萬華外約 #信義外約薇薇外送茶賴mm0089 #士林外約 #北投外約 #內湖外約 #南港外約 #文山外約 #新竹外約 #台南外約
#西屯外約 #南屯外約 薇薇外送茶賴mm0089#北屯外約 #逢甲外約 #大里外約 #大雅外約 #七其外約 #東海外約 #烏日外約 #太平外約
#豐原外約薇薇外送茶賴mm0089 #沙鹿外約 薇薇外送茶賴mm0089#逢甲茶莊 #逢甲全套
#薇薇外送茶賴mm0089逢甲外約 #逢甲外送茶 #逢甲叫小姐 #逢甲打砲 屏東汽車旅館叫小姐.薇薇外送茶賴mm0089屏東找茶,屏東外送舒壓按摩.屏東護膚全套外約.屏東找妹薇薇外送茶賴mm0089.屏東找小姐.屏東汽車旅館叫妹妹服務薇薇外送茶賴mm0089.屏東叫小姐.妹妹服務找歡樂.薇薇外送茶賴mm0089屏東優質外送茶莊.屏東出差旅遊約妹 .薇薇外送茶賴mm0089屏東正妹論壇.屏東約情人.薇薇外送茶賴mm0089屏東找女人兼職妹.推薦屏東茶莊##屏東外約妹妹價位#屏東找小姐.薇薇外送茶賴mm0089屏東全套外送.屏東辣妹薇薇外送茶賴mm0089.屏東找學生妹.人妻
#逢甲茶訊 #逢甲援交 #逢甲找女人薇薇外送茶賴mm0089 #逢甲魚訊 #逢甲炮神器 #逢甲紓壓 #逢甲性愛服務 #逢甲鐘點情人 #
太平全套 #薇薇外送茶賴mm0089大里全套 #沙鹿全套 #豐原全套 #大雅全套 #烏日全套薇薇外送茶賴mm0089 #台中車站應召 #台中南屯約妹
#台中西屯叫小姐 #台中逢甲外送 #台中勤美約妹薇薇外送茶賴mm0089 #台中車站叫小姐 #台中北屯應召 #台中南屯叫雞 #台中車站叫妹
#台中北區叫小姐薇薇外送茶賴mm0089 #台中應召 #台中車站叫雞 #台中車站茶莊 #台中西屯外送薇薇外送茶賴mm0089 #台中逢甲約砲 #台中北屯叫妹 #台中市區應召桃園半套店薇薇外送茶賴mm0089 ,#桃園半套價錢 #桃園找援,#桃園聯天室找援,#桃園西門找援桃園半套店薇薇外送茶賴mm0089 ,#桃園半套價錢 台中一夜情,台中??,台中全套基隆叫小姐電話 #基隆叫小姐 #基隆飯店叫小姐 #基隆旅館叫小姐薇薇外送茶賴mm0089 #基隆找女人 #基隆找妹 #基隆出差叫小姐 #基隆打炮薇薇外送茶賴mm0089 #基隆看照約妹#桃園茶莊心得,#桃園茶莊ptt,#桃園桑拿薇薇外送茶賴mm0089 ,#桃園桑拿浴,#桃園桑拿網
#桃園桑拿論壇薇薇外送茶賴mm0089 ,#桃園桑拿澳門,#桃園桑拿168,#桃園半套店薇薇外送茶賴mm0089 ,#桃園半套價錢
台北外送茶/高雄鐘點情人外約,台北旅館叫小姐,台北鐘點情人,台灣一夜情,高雄一夜情,台中一夜情,台北一夜情,台北美女外約,台中美女外約,高雄美女外約,高雄茶莊,台中茶莊,台北茶莊,台北叫小姐,台中叫小姐,高雄叫小姐,高雄外約/外約/援交妹,吃魚喝茶論壇,大家來找茶,PLUS,伊利,微克成人網/女優GoGoGo/淘A片/一刀未剪/免費成人影音薇薇外送茶賴mm0089 /交換網站/交友網站/性愛成人網/一葉晴成人貼片/成人極品情色站/癡漢線上免費A片/台灣明星淫片流出露比線上免費A片/伊莉成人論壇/◆免費線上A片◆/寶貝一夜情聊天室/免費A片頻道/無名成人網/成人網/台北情色聯盟/天天幹貼圖/洪爺色情網/台灣噴精成人網/十八小妹自拍美少女自拍貼圖老婆自拍貼圖/色色女孩情色總站/A圖情色交流/666人氣貼圖/插插穴排行/69Kiss電影排行/薇薇外送茶賴mm0089 彩虹頻道/后宮電影院/中文搜性網/酷站排行入口/上我人妻/成人龍虎豹/干爹情色排行/十七歲少女/台灣1歲/104寫真銀行/插插穴排行/熱酷美眉網/台灣性樂園/只有貼圖/波波美女網/交換連結eyny,玩美情人,男人幫,高雄女外約,高雄外送,高雄賓館叫小姐,高雄飯店叫小姐,高雄鐘點情人外約,台北旅館叫小姐,彰化外送茶,台灣兼職美女外送,薇薇外送茶賴mm0089 台北兼職美女外約,台中外送茶坊,高雄外送茶坊,台北外送茶坊陸妹價格,檳榔西施清涼秀,台功援學生兼差 msn,援交妹,24h 台北私兼,CLUB,台南指壓 3k,高雄酒店經紀,台中學生兼差msn,台北 夜店 舞廳 酒吧 制服便服,中年夫妻聯誼,高雄媛交,台南茶妹,台北指油壓留言板,情趣精品,大台南一夜情人外約\俱樂部,台北賓館叫小姐,高雄原味貼身衣物買賣,夢時代購物中心,台北推拿中醫,0204一夜崤◆隆f聊天室,熟女圖,討論區,台南喝茶的店哪好,網路購物,台中理容按摩,台南陪唱,台北下\午茶 blog,高雄 砲友,台中好茶討論區,高雄茶店news,高雄按摩個人工作室,旅遊,台南24h台南24h餐廳,台北茶訊交流msn,卡債,台中旅館外叫服務,台中 spa油壓男按摩小姐服務/漁會玩美情人遊戲成人論壇 台北吃魚喝茶留言板外送/台北一夜情重點情/台北旅館飯店找服務叫小姐/找女人全套服務加按摩指油壓成人夜遊魚訊交流論壇區/台北應召站/伊莉喝茶/第一手論壇/外約愛愛/外約電話/外約高檔茶到府服務/伊莉plus28/成人性愛慾茶園 性交易/正妹外送服務/找茶論壇薇薇外送茶賴mm0089 /找茶討論區/台灣外送GTO/台灣樂緣外送茶/兩性論妹板橋外送酒店 北投泡溫泉三溫暖趙小姐/援交妹網站論壇/FB交友網站/UT天室交友一夜情炮友/台北喝茶買三送一接多買多送純情動感兼職妹/華僑台北旅遊出差消伴遊找女人茶/薇薇外送茶賴mm0089 極品俱樂部嚴選絕色經典,第一手娛樂論壇/卡提諾/玩美情人/吃魚喝茶網 薇薇外送茶賴mm0089 伊漁網/Plus論壇/台灣樂緣/小女人論壇/台灣論壇/微風論壇/伊莉論壇/禁地論壇/維克斯論壇/捷克論/男人幫論壇/大眾論壇/竹北旅館飯店找女人按摩舒壓叫小姐3p服務 愛情公寓論壇 交友/ 愛情/戀愛/貓都論壇/賽斯論壇/104論壇/九州娛樂論壇/櫻雪論壇/2B級/台灣、送茶坊台北外送茶坊,台中外送茶,高雄外送茶,美女外約服務/台中/高雄/新竹/彰化/莊極品俱樂部嚴選絕色經典,成人性愛慾茶園,找茶討論區,催情藥,唯美貼圖,成人論壇,網絡報稅,線上遊戲,高檔平價好茶,淫照聊天是尋夢園美女外送,情趣用品八大行業指油壓全套,暑假打工,網站設計,中國合夥人,鋼鐵俠3,HTC,蝴蝶機,變裝遊戲,茶,motel,hotel,性感絲襪,A片下載,AV女優,第一手論壇,貓都,卡提諾,喝茶,完美情人,BJ論壇,小女人論壇,卡提諾論壇,台灣論壇三溫暖中陪酒ktv,台北全套護膚個人工作室,尋找台南援交auty 美容美體 SPA沙龍,台北外約茶棧,台中越南餐廳,台南應召站,台北外送 3k,高雄下午茶外,台中三溫暖全套,台南全套油壓泰國,台南半套店1600元,高雄推拿指壓,小姐,台北夜生活 pub,台北單身聯誼,兼差,台北交換伴侶,台北一夜情,高雄聊天網,高雄美女兼職,台南車站美食,8000mile,台北一夜情緣俱樂部,高雄應徵\酒店酒店上班,高雄成人視訊聊天室,台南兼職找利菁,高雄24h到府指油壓,女兼職,酒店兼職\,壽山,台南茶訊茶資薇薇外送茶賴mm0089 ,台南陪酒小姐,高雄絲襪美腿高跟鞋,夫妻聯誼部落格,薇薇外送茶賴mm0089 台南小野貓檳榔西施外送時被下藥拍照,自拍女老師,找台中援妹地點,台北大陸妹價格,檳榔西施清涼秀,台功援學生兼差 msn,援交妹,24h 台北私兼,CLUB,台南指壓 3k,高雄酒店經紀,台中學生兼差msn,台北 夜店 舞廳 酒吧 制服便服,中年夫妻聯誼,高雄媛交,台南茶妹,台北指油壓留言板,情趣精品,大台南一夜情人外約\俱樂部,台北賓館叫小姐,高雄原味貼身衣物買賣,夢時代購物中心,台北推拿中醫,0204一夜崤◆隆f聊天室,熟女圖,討論區,台南喝茶的店哪好,網福祿猴林千又 吳宗憲 2015 黑豹旗 王大陸 徐太宇 登革熱 波多野結衣 金鐘獎 蔡英文 洪秀柱 蛇精男 靈異 鬼故事 柯文哲 柯P 大家來說鬼 綜藝玩很大 時尚脈動 賈靜雯 陳佩琪 氣象 反課綱 2015星光大賞 宅男女神 愛爾麗 泛舟哥 張吉吟 ET看電影 八仙 塵爆 楊子晴 范冰冰 安心亞 陳泱瑾 Grace ISIS 林書豪

96
mm685265 · #3 ·

臺灣找小姐+賴:211861 大奶大粉嫩可看照.洗澡愛愛按摩口交全套服務
好吃的東西當然要一起分享~ 小弟昨天趁休假找茶姊約了個正妹 果然幫我安排的素質很讚 妹妹叫童童 目前還是個大學生 22歲 身材臉蛋都是我的菜 身高160 甜美可愛 美腿哦 罩杯Dcup 真材實料 吸起來彈性十足 妹妹雖然年紀小 但是性欲很強 喜歡在床上纏著我的腰扭屁股 真的是視覺上肉體上的100分滿足!!全程真的很主動 很會挑逗 皮膚也很棒很白嫩 全身鮑魚都可以隨意摸哦 全身都很敏感 妹妹也很緊 還會夾我的小弟 插起來水水很多 很有感覺 超級讚!!! 真的是個很淫蕩的小女生 讓你有回味無窮的感覺 喜歡的可以嘗試看看 加賴:211861 找童童可看照片 她家還要其他的姊妹 類型很多 加賴說是阿傑介紹 有好康喔!!!

需要 Sign In 后方可回复, 如果你还没有账号请点击这里 Sign Up