通过前面两篇:《深入理解RecyclerView与LayoutManager(一):基本概念与原理》、《深入理解RecyclerView与LayoutManager(二):自定义LayoutManager注意事项》的学习,相信大家对RecyclerView以及LayoutManager都有了更深的认识。本篇将一步步的带着大家来实现一个案例效果,具体效果看这里。
虽然有了前两篇的基础,但是一到实战阶段还是有点束手无策,这就跟上学的时候一样,上课听得津津有味,一做题就错误百出,其实也正常,因为只有通过实践才能将学过的零散的知识点串联起来,形成体系,这样才能融会贯通。
废话不多说,开始进入实战。
为了实现这个效果,分三步走:
- 先搭建框架
- 再实现静态效果
- 最后处理滑动事件
自定义LayoutManager
绕不开添加、测量、布局三步。
第一步实现框架如下
class CoverFlowLayoutManager : LayoutManager() { private var pendingScrollPosition: Int = RecyclerView.NO_POSITION override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams { return RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) { if (pendingScrollPosition != RecyclerView.NO_POSITION) { if (state.itemCount == 0) { removeAndRecycleAllViews(recycler) return } } detachAndScrapAttachedViews(recycler) fill(recycler, state) } private fun fill(recycler: RecyclerView.Recycler, state: RecyclerView.State) { for (i in 0 until itemCount) { layoutChunk(xxx) } } private fun layoutChunk(item: View, l: Int, t: Int, r: Int, b: Int) { //TODO } override fun scrollToPosition(position: Int) { pendingScrollPosition = position } override fun onLayoutCompleted(state: RecyclerView.State) { pendingScrollPosition = RecyclerView.NO_POSITION } }
以上框架基本差不多摘自官方实现的LinearLayoutManager
,generateDefaultLayoutParams()
是必须实现的,一般返回WRAP_CONTENT
,由自己测量每个ItemView
的大小,onLayoutChildren()
也是必须要实现的,这里面是核心的流程,包括添加、布局、测量、回收。
拿到一个效果,首先要分析效果的组成,然后拆解成一个个小的静态效果,最后再串联起来,切记一口吃不了一个胖子。
基于以上效果,拆解过后的静态图如下:

如果不考虑滑动,让我们使用普通的ViewGroup
来实现上面效果,相信大家花点时间应该都能实现,无非就是在onMeasure()
里面测量子View
的大小,然后在onLayout()
里面布局子View
,关于自定义View
相关知识可以移步这里看我写的另一篇文章。那换成LayoutManager
其实也一样的,这些活放到onLayoutChildren()
中实现即可。
简单的分析下上图的效果,初始状态下第一个item处于正中间,且大小是正常大小,左边没有item,右边的item缩小显示,越往右item越小,item之间有固定大小的间隙。
第二步实现静态效果
class CoverFlowLayoutManager : LayoutManager() { private var pendingScrollPosition: Int = RecyclerView.NO_POSITION private var childWidth = 0 /** 一次完整的聚焦滑动所需要移动的距离 */ private var onceCompleteScrollLength = -1f /** 第一个item的偏移量 */ private var firstChildCompleteScrollLength = -1f /** 屏幕可见的第一个item的position */ private var firstVisibleItemPosition = 0 /** 屏幕可见的最后一个item的position */ private var lastVisibleItemPosition = 0 /** item之间的间距 */ private var normalViewGap = 0 override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams { return RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) { if (pendingScrollPosition != RecyclerView.NO_POSITION) { if (state.itemCount == 0) { removeAndRecycleAllViews(recycler) return } } onceCompleteScrollLength = -1f detachAndScrapAttachedViews(recycler) if (onceCompleteScrollLength == -1f) { //初始化一些常量 val itemView = recycler.getViewForPosition(0) measureChildWithMargins(itemView, 0, 0) childWidth = getDecoratedMeasurementHorizontal(itemView) } //修正第一个item的偏移量 firstChildCompleteScrollLength = width / 2f + childWidth / 2f fill(recycler, state) } private fun fill(recycler: RecyclerView.Recycler, state: RecyclerView.State) { var startX = getMinOffset() for (i in 0 until itemCount) { val item = recycler.getViewForPosition(i) //1.添加view addView(item) //2.测量view measureChildWithMargins(item, 0, 0) val l = startX.toInt() val t = paddingTop val r = l + getDecoratedMeasurementHorizontal(item) val b = t + getDecoratedMeasurementVertical(item) //3.布局view layoutChunk(item, l, t, r, b) startX += childWidth + normalViewGap //如果超出了边界就退出循环,不再添加view了 if (startX > width - paddingRight) { lastVisibleItemPosition = i break } } } private fun layoutChunk(item: View, l: Int, t: Int, r: Int, b: Int) { // 缩放子view val minScale = 0.6f val currentScale: Float val childCenterX = (r + l) / 2 val parentCenterX = width / 2 val isChildLayoutLeft = childCenterX <= parentCenterX if (isChildLayoutLeft) { //越往左边越小 val fractionScale = (parentCenterX - childCenterX) / (parentCenterX * 1.0f) currentScale = 1.0f - (1.0f - minScale) * fractionScale } else { //越往右边越小 val fractionScale = (childCenterX - parentCenterX) / (parentCenterX * 1.0f) currentScale = 1.0f - (1.0f - minScale) * fractionScale } item.scaleX = currentScale item.scaleY = currentScale item.setAlpha(currentScale) layoutDecoratedWithMargins(item, l, t, r, b) } /** * 获取某个childView在竖直方向所占的空间,将margin考虑进去 */ private fun getDecoratedMeasurementVertical(view: View): Int { val params = view.layoutParams as RecyclerView.LayoutParams return (getDecoratedMeasuredHeight(view) + params.topMargin + params.bottomMargin) } /** * 获取某个childView在水平方向所占的空间,将margin考虑进去 */ private fun getDecoratedMeasurementHorizontal(view: View): Int { val params = view.layoutParams as RecyclerView.LayoutParams return getDecoratedMeasuredWidth(view) + params.leftMargin + params.rightMargin } /** * 最小偏移量,中间的item距离最左边的距离 */ private fun getMinOffset(): Float { if (childWidth == 0) { return 0f } return (width - childWidth) / 2f } override fun scrollToPosition(position: Int) { pendingScrollPosition = position } override fun onLayoutCompleted(state: RecyclerView.State) { pendingScrollPosition = RecyclerView.NO_POSITION } }
上面代码的注释已经很详细了,测量和布局的思路跟自定义ViewGroup
一样,layoutChunk()
方法里面的逻辑需要自己对照上图思考下,尤其是缩放比例fractionScale
设计的很巧妙。效果图如下:

第三步处理滑动事件
RecyclerView
中的onInterceptTouchEvent()
、onTouchEvent()
已经帮我们处理触摸事件,比如事件的拦截处理、边界处理、惯性滚动处理等,我们只需要重写canScrollHorizontally()
或canScrollVertically()
,scrollHorizontallyBy()
或scrollVerticallyBy()
来提供如何滚动的具体实现即可。
例子中是左右滑动,所以需要实现
scrollHorizontallyBy(int dx, Recycler recycler, State state)
其中,手指从右向左滑动dx > 0
,手指从左向右滑动dx < 0
。
接下来还是分两步来实现:
-
- 先实现跟随手指左右滑动
- 再实现松开手指后的自动定位
(1)实现跟随手指左右滑动
修改后的代码如下所示:
class CoverFlowLayoutManager : LayoutManager() { private var pendingScrollPosition: Int = RecyclerView.NO_POSITION private var childWidth = 0 /** 一次完整的聚焦滑动所需要移动的距离 */ private var onceCompleteScrollLength = -1f /** 第一个item的偏移量 */ private var firstChildCompleteScrollLength = -1f /** 屏幕可见的第一个item的position */ private var firstVisibleItemPosition = 0 /** 屏幕可见的最后一个item的position */ private var lastVisibleItemPosition = 0 /** item之间的间距 */ private var normalViewGap = 0 /** 水平方向累计偏移量 */ private var horizontalOffset = 0f override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams { return RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) } override fun onLayoutChildren(recycler: RecyclerView.Recycler, state: RecyclerView.State) { if (pendingScrollPosition != RecyclerView.NO_POSITION) { if (state.itemCount == 0) { removeAndRecycleAllViews(recycler) return } } onceCompleteScrollLength = -1f detachAndScrapAttachedViews(recycler) if (onceCompleteScrollLength == -1f) { //初始化一些常量 val itemView = recycler.getViewForPosition(0) measureChildWithMargins(itemView, 0, 0) childWidth = getDecoratedMeasurementHorizontal(itemView) } // 修正第一个可见view firstVisibleItemPosition 已经滑动了多少个完整的 onceCompleteScrollLength 就代表滑动了多少个item firstChildCompleteScrollLength = width / 2f + childWidth / 2f //重新布局 fill(recycler, state, 0) } private fun fill(recycler: RecyclerView.Recycler, state: RecyclerView.State, dx: Int): Int { val resultDelta = fillHorizontal(recycler, state, dx) return resultDelta } private fun fillHorizontal(recycler: RecyclerView.Recycler, state: RecyclerView.State, dx: Int): Int { //边界检测 var tempDx = dx if (dx < 0) { //已到达左边界 if (horizontalOffset < 0) { horizontalOffset = 0f tempDx = 0 } } if (dx > 0) { if (horizontalOffset >= getMaxOffset()) { horizontalOffset = getMaxOffset() tempDx = 0 } } //分离全部的view,加入到临时缓存 detachAndScrapAttachedViews(recycler) firstVisibleItemPosition = 0 var startX = getMinOffset() onceCompleteScrollLength = firstChildCompleteScrollLength val fraction = (Math.abs(horizontalOffset) % onceCompleteScrollLength) / (onceCompleteScrollLength * 1.0f) // 临时将 lastVisibleItemPosition 赋值为getItemCount() - 1,放心,下面遍历时会判断view是否已溢出屏幕,并及时修正该值并结束布局 lastVisibleItemPosition = itemCount - 1 //每次布局之前都要对startX进行一次调整,初始的位置调整了,后面所有的view都是基于此位置进行布局的 val normalViewOffset = onceCompleteScrollLength * fraction startX -= normalViewOffset for (i in firstVisibleItemPosition until lastVisibleItemPosition) { val item = recycler.getViewForPosition(i) //1.添加view addView(item) //2.测量view measureChildWithMargins(item, 0, 0) val l = startX.toInt() val t = paddingTop val r = l + getDecoratedMeasurementHorizontal(item) val b = t + getDecoratedMeasurementVertical(item) //3.布局view layoutChunk(item, l, t, r, b) startX += childWidth + normalViewGap //如果超出了边界就退出循环,不再添加view了 if (startX > width - paddingRight) { lastVisibleItemPosition = i break } } return tempDx } private fun layoutChunk(item: View, l: Int, t: Int, r: Int, b: Int) { // 缩放子view val minScale = 0.6f val currentScale: Float //中间item中心点位置横坐标 val childCenterX = (r + l) / 2 val parentCenterX = width / 2 val isChildLayoutLeft = childCenterX <= parentCenterX if (isChildLayoutLeft) { //越往左边越小 val fractionScale = (parentCenterX - childCenterX) / (parentCenterX * 1.0f) currentScale = 1.0f - (1.0f - minScale) * fractionScale } else { //越往右边越小 val fractionScale = (childCenterX - parentCenterX) / (parentCenterX * 1.0f) currentScale = 1.0f - (1.0f - minScale) * fractionScale } item.scaleX = currentScale item.scaleY = currentScale item.setAlpha(currentScale) layoutDecoratedWithMargins(item, l, t, r, b) } /** * 获取某个childView在竖直方向所占的空间,将margin考虑进去 */ private fun getDecoratedMeasurementVertical(view: View): Int { val params = view.layoutParams as RecyclerView.LayoutParams return (getDecoratedMeasuredHeight(view) + params.topMargin + params.bottomMargin) } /** * 获取某个childView在水平方向所占的空间,将margin考虑进去 */ private fun getDecoratedMeasurementHorizontal(view: View): Int { val params = view.layoutParams as RecyclerView.LayoutParams return getDecoratedMeasuredWidth(view) + params.leftMargin + params.rightMargin } /** * 最小偏移量,中间的item距离最左边的距离 */ private fun getMinOffset(): Float { if (childWidth == 0) { return 0f } return (width - childWidth) / 2f } /** * 最大偏移量 */ private fun getMaxOffset(): Float { if (childWidth == 0 || itemCount == 0) return 0f return ((childWidth + normalViewGap) * (itemCount - 1)).toFloat() } override fun scrollToPosition(position: Int) { pendingScrollPosition = position } override fun onLayoutCompleted(state: RecyclerView.State) { pendingScrollPosition = RecyclerView.NO_POSITION } override fun canScrollHorizontally(): Boolean { return true } override fun scrollHorizontallyBy( dx: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State ): Int { // 手指从右向左滑动,dx > 0; 手指从左向右滑动,dx < 0; // 位移0、没有子View 当然不移动 if (dx == 0 || childCount == 0) { return 0 } val realDx = dx / 1.0f if (abs(realDx) < 0.00000001f) { return 0 } horizontalOffset += dx val tempDx = fill(recycler, state, dx) return tempDx } }
主要看fillHorizontal()
和scrollHorizontallyBy()
。
scrollHorizontallyBy()
中,做了些简单的边界值判断,然后将滑动的距离累加起来,最后调用fill()
不断地调整子View
的位置。
fillHorizontal()
中,首先处理边界问题,然后处理startX
变量,注意对startX
的理解非常重要,我刚开始看到这里的时候没有发挥想象,导致一直不理解。startX
初始值为getMinOffset()
,随着滑动的进行,需要在每次调整位置的时候都要基于已经滑动的距离做一次调整,所以以下代码是关键的需要理解的点:
//每次布局之前都要对startX进行一次调整,初始的位置调整了,后面所有的view都是基于此位置进行布局的 val normalViewOffset = onceCompleteScrollLength * fraction startX -= normalViewOffset
fraction
其实就是滑动的比例,比如需要滑动的距离是100
,每次滑动的距离是10
,那么计算出每次滑动的距离相对于需要滑动的距离的比例是0.1
。
startX -= normalViewOffset
,因为初始的时候第一个View
距离左边界距离是firstChildCompleteScrollLength
,随着手指不断的滑动,减掉已经滑动的距离normalViewOffset
就是第一个View
距离左边界实际的距离,后面的for
循环里面是基于此位置来布局所有可见的子View
。
还有一处核心的关键点就是在for
循环里面布局之前调用了detachAndScrapAttachedViews()
,如果不调用这个方法,就没法利用RecyclerView
的缓存机制了,不断的addView
,布局会错乱,滑动一会儿就OOM
了。如果不是很理解这个方法,就简单的认为在每次进行布局之前,都需要调用detachAndScrapAttachedViews
方法把屏幕中的items
都分离出来。你还可以这么理解,如果让你自己写一个自定义ViewGroup
,实现滚动效果,你也肯定会在每次布局之前都调用removeAllViews()
,然后再addView()
,所以detachAndScrapAttachedViews()
其实就相当于removeAllViews()
。但是detachAndScrapAttachedViews()
里面做了很多缓存有关的工作,这样滑动的效率会更高。
为了方便理解,我修改minScale = 1f
,这样更容易肉眼观察到滑动到边界的位置。
上面代码跑起来,当滑动到第一个View
不可见的时候会出现布局错乱的问题,具体效果看这里。分析原因是由于firstVisibleItemPosition
和startX
没有动态变化导致。
根据图1分析可知,以horizontalOffset >= firstChildCompleteScrollLength
作为分界线来动态计算firstVisibleItemPosition
和startX
。为了减少篇幅,只贴出修改的地方,修改代码如下:
private fun fillHorizontal(recycler: RecyclerView.Recycler, state: RecyclerView.State, dx: Int): Int { //边界检测 var tempDx = dx if (dx < 0) { //已到达左边界 if (horizontalOffset < 0) { horizontalOffset = 0f tempDx = 0 } } if (dx > 0) { if (horizontalOffset >= getMaxOffset()) { horizontalOffset = getMaxOffset() tempDx = 0 } } //分离全部的view,加入到临时缓存 detachAndScrapAttachedViews(recycler) var startX: Float val fraction: Float if (horizontalOffset >= firstChildCompleteScrollLength) { //当第一个item不可见的时候 startX = normalViewGap.toFloat() onceCompleteScrollLength = (childWidth + normalViewGap).toFloat() firstVisibleItemPosition = floor(Math.abs(horizontalOffset - firstChildCompleteScrollLength) / onceCompleteScrollLength).toInt() + 1 fraction = (Math.abs(horizontalOffset - firstChildCompleteScrollLength) % onceCompleteScrollLength) / (onceCompleteScrollLength * 1.0f) } else { //当第一个item可见的时候 startX = getMinOffset() firstVisibleItemPosition = 0 onceCompleteScrollLength = firstChildCompleteScrollLength fraction = (Math.abs(horizontalOffset) % onceCompleteScrollLength) / (onceCompleteScrollLength * 1.0f) } // 临时将 lastVisibleItemPosition 赋值为getItemCount() - 1,放心,下面遍历时会判断view是否已溢出屏幕,并及时修正该值并结束布局 lastVisibleItemPosition = itemCount - 1 //每次布局之前都要对startX进行一次调整,初始的位置调整了,后面所有的view都是基于此位置进行布局的 val normalViewOffset = onceCompleteScrollLength * fraction startX -= normalViewOffset for (i in firstVisibleItemPosition until lastVisibleItemPosition) { val item = recycler.getViewForPosition(i) //1.添加view addView(item) //2.测量view measureChildWithMargins(item, 0, 0) val l = startX.toInt() val t = paddingTop val r = l + getDecoratedMeasurementHorizontal(item) val b = t + getDecoratedMeasurementVertical(item) //3.布局view layoutChunk(item, l, t, r, b) startX += childWidth + normalViewGap //如果超出了边界就退出循环,不再添加view了 if (startX > width - paddingRight) { lastVisibleItemPosition = i break } } return tempDx }
主要修改了fillHorizontal()
中startX
、firstVisibleItemPosition
、onceCompleteScrollLength
以及fraction
的计算方式。这三个变量是滚动的核心变量,理解了这三个变量,那这个示例也就差不多搞懂了。
-
startX
:这个变量不用多说,每次滚动都要以这个变量作为锚点来布局所有可见的Item。firstVisibleItemPosition
:第一个可见的Item位置,决定for循环的起始位置,高效布局的关键。onceCompleteScrollLength
:原文的解释是“一次完整的聚焦滑动所需要移动的距离”。我觉得很有误导性,在下面的自动定位小结中这样理解是没问题的,但是在这里可以理解为将一个完整的Item从边缘位置滑动到不可见时所需要移动的距离。结合这张动图理解,当第1个Item从起始位置滑动到不可见时需要移动firstChildCompleteScrollLength = width / 2f + childWidth / 2f
,从第2个Item开始,每个Item从边缘位置滑动到不可见所需要移动的距离都是childWidth + normalViewGap
。fraction
:实时滑动距离相对于上面的onceCompleteScrollLength
的比例。
(2)实现松开手指后的自动定位
要实现松开手指后自动定位,需要重写onScrollStateChanged()
并监听RecyclerView.SCROLL_STATE_IDLE
状态。搭建框架如下:
//......此处省略其余代码 override fun onScrollStateChanged(state: Int) { super.onScrollStateChanged(state) when (state) { //当手指按下时,停止当前正在播放的动画 RecyclerView.SCROLL_STATE_DRAGGING -> cancelAnimator() //当列表滚动停止后,判断一下自动选中是否打开 RecyclerView.SCROLL_STATE_IDLE -> if (isAutoSelect) { //找到离目标落点最近的item索引 smoothScrollToPosition(findShouldSelectPosition(), null) } else -> {} } } //......此处省略其余代码
具体定位的位置,需要根据已经滑动的距离horizontalOffset
来计算,核心算法如下:
private fun findShouldSelectPosition(): Int { if (onceCompleteScrollLength == -1f || firstVisibleItemPosition == -1) { return -1 } val position = (Math.abs(horizontalOffset) / (childWidth + normalViewGap)) // 超过一半,应当选中下一项 if ((Math.abs(horizontalOffset) % (childWidth + normalViewGap)) >= (childWidth + normalViewGap) / 2.0f) { if (position + 1 <= itemCount - 1) { return (position + 1).toInt() } } return position.toInt() }
结合这张动图,我觉得不难理解。接下来就是处理自动滚动,需要用到属性动画。代码如下:
/** * 平滑滚动到某个位置 * * @param position 目标Item索引 */ fun smoothScrollToPosition(position: Int, listener: OnStackListener?) { if (position > -1 && position < itemCount) { startValueAnimator(position, listener) } } private fun startValueAnimator(position: Int, listener: OnStackListener?) { cancelAnimator() val distance = getScrollToPositionOffset(position) val minDuration = 100 val maxDuration = 300 val duration: Long val distanceFraction = ((abs(distance.toDouble()) / (childWidth + normalViewGap)).toFloat()) Log.i(TAG, "distance-->$distance, distanceFraction-->$distanceFraction") duration = if (distance <= (childWidth + normalViewGap)) { (minDuration + (maxDuration - minDuration) * distanceFraction).toLong() } else { (maxDuration * distanceFraction).toLong() } selectAnimator = ValueAnimator.ofFloat(0.0f, distance) selectAnimator?.setDuration(duration) selectAnimator?.interpolator = LinearInterpolator() val startedOffset = horizontalOffset selectAnimator?.addUpdateListener(AnimatorUpdateListener { animation -> val value = animation.animatedValue as Float horizontalOffset = startedOffset + value requestLayout() }) selectAnimator?.addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { super.onAnimationEnd(animation) listener?.onFocusAnimEnd() } }) selectAnimator?.start() } /** * 滑动到指定的位置需要移动的距离 * @param position * @return */ private fun getScrollToPositionOffset(position: Int): Float { return position * (childWidth + normalViewGap) - Math.abs(horizontalOffset) }
关键点:
-
- 滑动到指定的位置需要移动的距离
distance
。1次完整的滑动距离是childWidth + normalViewGap
,比如需要滑动到第2个位置时,完整的需要滑动的距离是2 * (childWidth + normalViewGap)
,减掉已经滑动的距离horizontalOffset
,那剩下的就是需要滑动的距离了。 - 根据上面计算出来的距离来动态计算滚动需要的时间
duration
。其实要是图简单,就固定一个时间就行,但这里的算法也比较精妙,距离短需要的时间就少,反之亦然。minDuration + (maxDuration - minDuration) * distanceFraction
,类似的这种算法在自己处理滚动、滑动、动画等场景中经常会用到,其中distanceFraction
就是实际滑动的距离相对于需要滑动的距离的比例,当实际滑动距离达到需要滑动的距离时,比例就是1,那么就取maxDuration
,总之维持在minDuration
到maxDuration
之间。
- 滑动到指定的位置需要移动的距离
最后使用ValueAnimator
来创建动画,动画onAnimationUpdate()
回调里面其实就是模拟手动滑动的过程,修改horizontalOffset
值,然后调用requestLayout()
来不断的重绘。回想手动滑动时也是在scrollHorizontallyBy()
中改变horizontalOffset
的值然后修改View
的位置并重新添加View
,但是代码中并没有调用requestLayout()
来重绘,这是因为我们已经在scrollHorizontallyBy()
中不断的调用了fill()
进而来重新布局。
完整的代码在这里。
以上就是全部的分析过程,一步步的从原理到实践,完成了一个看着简单但是实现起来并不简单的例子,相信看完这次的系列文章,大家都开始摩拳擦掌,想要一展身手了,加油吧。
本篇文章参考https://www.cnblogs.com/anyrtc/p/16373857.html,感谢作者这么优秀的文章,我这篇文章也只是班门弄斧罢了,站在了巨人的肩膀上补充了对原文的例子讲解。希望本篇文章能帮到大家,通过这次系列文章的编写,我对自定义LayoutManager也有了全新的和更加透彻的认识。
编写不易,转载请注明出处:https://www.longdw.com。