ListView 源码解析
来源:互联网 发布:ubuntu 命令不提示 编辑:程序博客网 时间:2024/05/29 19:26
源码解析的习惯也要开始养成呀,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 中了
@Overrideprotected void onLayout(boolean changed, int l, int t, int r, int b) { super.onLayout(changed, l, t, r, b); ... layoutChildren(); // 布局 子 view ...}@Overrideprotected 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 并填充 ListViewprivate 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 方法,不过执行的逻辑确跟第一次不同了
@Overrideprotected 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 方法中
@Overridepublic 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 中。整个过程执行结束。
- android源码解析--ListView
- android源码解析--ListView
- ListView源码解析
- android listview源码解析
- listview源码解析
- Listview源码解析(一)
- ListView 源码解析
- ListView 源码解析
- ListView[2] 源码解析
- ListView源码解析
- ListView源码解析(二) Adapter
- android源码解析--ListView(上)
- 源码解析ListView中的RecycleBin机制
- 王学岗ListView和源码解析(一)
- 王学岗ListView和源码解析(二)
- 王学岗ListView和源码解析(三)
- ListView动画展开布局 ExpandableLayout源码解析
- ListView深入源码解析及其常见问题解决方案
- Python中函数的参数定义和可变参数
- Laravel核心之IOC和Facade 架构分析1
- 匿名内部类传参
- HTTP 错误 500.19
- 微信浏览器左上角返回按钮的监听
- ListView 源码解析
- android-async-http 源码分析
- py faster rcnn,UnicodeDecodeError: 'ascii' codec can't decode byte 0xe4 in position 10: ordinal not
- 开根号向下取整代码实现
- excel中求汉字的拼音
- 使用Xshell更新测试环境(Linux下Tomcat)
- RecyclerView 使用及源码解析
- 数据库删除虚拟机
- centos下搭建ftp服务器