在Android中,任何一个控件都是可以滚动的,因为在View类当中有scrollTo()和scrollBy()这两个方法,如下图所示:

这两个方法的主要作用是将View/ViewGroup移至指定的坐标中,并且将偏移量保存起来。另外:

mScrollX代表X轴方向的偏移坐标

mScrollY代表Y轴方向的偏移坐标

这两个方法都是用于对View进行滚动的,那么它们之间有什么区别呢?简单点讲,scrollBy()方法是让View相对于当前的位置滚动某段距离,而scrollTo()方法则是让View相对于初始的位置滚动某段距离。

关于偏移量的设置我们可以参看下源码:

public class View {

....

protected int mScrollX; //该视图内容相当于视图起始坐标的偏移量,X轴方向

protected int mScrollY; //该视图内容相当于视图起始坐标的偏移量,Y轴方向

//返回值

public final int getScrollX() {

return mScrollX;

}

public final int getScrollY() {

return mScrollY;

}

public void scrollTo(int x, int y) {

//偏移位置发生了改变

if (mScrollX != x || mScrollY != y) {

int oldX = mScrollX;

int oldY = mScrollY;

mScrollX = x; //赋新值,保存当前便宜量

mScrollY = y;

//回调onScrollChanged方法

onScrollChanged(mScrollX, mScrollY, oldX, oldY);

if (!awakenScrollBars()) {

invalidate(); //一般都引起重绘

}

}

}

// 看出区别了吧 。 mScrollX 与 mScrollY 代表我们当前偏移的位置 , 在当前位置继续偏移(x ,y)个单位

public void scrollBy(int x, int y) {

scrollTo(mScrollX + x, mScrollY + y);

}

//...

}

于是,在任何时刻我们都可以获取该View/ViewGroup的偏移位置了,即调用getScrollX()方法和getScrollY()方法。

下面我们写个例子看下它们的区别吧:

xmlns:tools="http://schemas.android.com/tools"

android:id="@+id/layout"

android:layout_width="match_parent"

android:layout_height="match_parent"

android:orientation="vertical">

android:id="@+id/scroll_to_btn"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:text="scrollTo"/>

android:id="@+id/scroll_by_btn"

android:layout_width="wrap_content"

android:layout_height="wrap_content"

android:layout_marginTop="10dp"

android:text="scrollBy"/>

外层使用了一个LinearLayout,在里面包含了两个按钮,一个用于触发scrollTo逻辑,一个用于触发scrollBy逻辑。

public class MainActivity extends AppCompatActivity {

private LinearLayout layout;

private Button scrollToBtn;

private Button scrollByBtn;

@Override

protected void onCreate(Bundle savedInstanceState) {

super.onCreate(savedInstanceState);

setContentView(R.layout.activity_main);

layout = (LinearLayout) findViewById(R.id.layout);

scrollToBtn = (Button) findViewById(R.id.scroll_to_btn);

scrollByBtn = (Button) findViewById(R.id.scroll_by_btn);

scrollToBtn.setOnClickListener(new View.OnClickListener() {

@Override

public void onClick(View v) {

layout.scrollTo(getResources().getDimensionPixelOffset(R.dimen.horizontal_scroll),

getResources().getDimensionPixelOffset(R.dimen.horizontal_scroll));

}

});

scrollByBtn.setOnClickListener(new View.OnClickListener() {

@Override

public void onClick(View v) {

layout.scrollBy(getResources().getDimensionPixelOffset(R.dimen.horizontal_scroll),

getResources().getDimensionPixelOffset(R.dimen.horizontal_scroll));

}

});

}

}

-20dp

-30dp

当点击了scrollTo按钮时,我们调用了LinearLayout的scrollTo()方法,当点击了scrollBy按钮时,调用了LinearLayout的scrollBy()方法。那有的朋友可能会问了,为什么都是调用的LinearLayout中的scroll方法?这里一定要注意,不管是scrollTo()还是scrollBy()方法,滚动的都是该View内部的内容,而LinearLayout中的内容就是我们的两个Button,如果你直接调用button的scroll方法的话,那结果一定不是你想看到的。

另外还有一点需要注意,就是两个scroll方法中传入的参数,第一个参数x表示相对于当前位置横向移动的距离,正值向左移动,负值向右移动。第二个参数y表示相对于当前位置纵向移动的距离,正值向上移动,负值向下移动。

运行一下程序:

当我们点击scrollTo按钮时,两个按钮会一起向右下方滚动,之后再点击scrollTo按钮就没有任何作用了,界面不会再继续滚动,只有点击scrollBy按钮界面才会继续滚动,并且不停点击scrollBy按钮界面会一起滚动下去。

Scroller类

从上面例子运行结果可以看出,利用scrollTo()/scrollBy()方法把一个View偏移至指定坐标(x,y)处,整个过程是直接跳跃的,没有对这个偏移过程有任何控制,对用户而言不太友好。于是,基于这种偏移控制,Scroller类被设计出来了,该类的主要作用是为偏移过程制定一定的控制流程,从而使偏移更流畅,更完美。

我们分析下源码里去看看Scroller类的相关方法,其源代码(部分)如下: 路径位于 \frameworks\base\core\Java\android\widget\Scroller.java

public class Scroller {

private int mStartX; //起始坐标点 , X轴方向

private int mStartY; //起始坐标点 , Y轴方向

private int mCurrX; //当前坐标点 X轴, 即调用startScroll函数后,经过一定时间所达到的值

private int mCurrY; //当前坐标点 Y轴, 即调用startScroll函数后,经过一定时间所达到的值

private float mDeltaX; //应该继续滑动的距离, X轴方向

private float mDeltaY; //应该继续滑动的距离, Y轴方向

private boolean mFinished; //是否已经完成本次滑动操作, 如果完成则为 true

//构造函数

public Scroller(Context context) {

this(context, null);

}

public final boolean isFinished() {

return mFinished;

}

//强制结束本次滑屏操作

public final void forceFinished(boolean finished) {

mFinished = finished;

}

public final int getCurrX() {

return mCurrX;

}

/* Call this when you want to know the new location. If it returns true,

* the animation is not yet finished. loc will be altered to provide the

* new location. */

//根据当前已经消逝的时间计算当前的坐标点,保存在mCurrX和mCurrY值中

public boolean computeScrollOffset() {

if (mFinished) { //已经完成了本次动画控制,直接返回为false

return false;

}

int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);

if (timePassed < mDuration) {

switch (mMode) {

case SCROLL_MODE:

float x = (float)timePassed * mDurationReciprocal;

...

mCurrX = mStartX + Math.round(x * mDeltaX);

mCurrY = mStartY + Math.round(x * mDeltaY);

break;

...

}

else {

mCurrX = mFinalX;

mCurrY = mFinalY;

mFinished = true;

}

return true;

}

//开始一个动画控制,由(startX , startY)在duration时间内前进(dx,dy)个单位,即到达坐标为(startX+dx , startY+dy)出

public void startScroll(int startX, int startY, int dx, int dy, int duration) {

mFinished = false;

mDuration = duration;

mStartTime = AnimationUtils.currentAnimationTimeMillis();

mStartX = startX; mStartY = startY;

mFinalX = startX + dx; mFinalY = startY + dy;

mDeltaX = dx; mDeltaY = dy;

...

}

}

其中比较重要的两个方法为:

public boolean computeScrollOffset()函数功能说明:根据当前已经消逝的时间计算当前的坐标点,保存在mCurrX和mCurrY值中。

public void startScroll(int startX, int startY, int dx, int dy, int duration)函数功能说明:开始一个动画控制,由(startX , startY)在duration时间内前进(dx,dy)个单位,到达坐标为(startX+dx , startY+dy)处。

computeScroll()方法介绍:为了易于控制滑屏控制,Android框架提供了 computeScroll()方法去控制这个流程。在绘制View时,会在draw()过程调用该方法。因此, 再配合使用Scroller实例,我们就可以获得当前应该的偏移坐标,手动使View/ViewGroup偏移至该处。

computeScroll()方法原型如下,该方法位于ViewGroup.java类中

/**

* Called by a parent to request that a child update its values for mScrollX and mScrollY if necessary. This will typically be done if the child is animating a scroll using a {@link android.widget.Scroller Scroller}

* object.

* 由父视图调用用来请求子视图根据偏移值 mScrollX,mScrollY重新绘制 */

public void computeScroll() { //空方法 ,自定义ViewGroup必须实现方法体

}

为了实现偏移控制,一般自定义View/ViewGroup都需要重载该方法 。其调用过程位于View绘制流程draw()过程中,如下:

@Override

protected void dispatchDraw(Canvas canvas){

...

for (int i = 0; i < count; i++) {

final View child = children[getChildDrawingOrder(count, i)];

if ((child.mViewFlags & VISIBILITY_MASK) == VISIBLE || child.getAnimation() != null) {

more |= drawChild(canvas, child, drawingTime);

}

}

}

protected boolean drawChild(Canvas canvas, View child, long drawingTime) {

...

child.computeScroll();

...

}

实例演示

ViewPager相信每个人都再熟悉不过了,因此它实在是太常用了,我们可以借助ViewPager来轻松完成页面之间的滑动切换效果,但是如果问到它是如何实现的话,我感觉大部分人还是比较陌生的。其实说到ViewPager最基本的实现原理主要就是两部分内容,一个是事件分发,一个是Scroller。对于事件分发,不了解的同学可以参考我这篇博客Android事件的分发、拦截和执行。

接下来我将结合事件分发和Scroller来实现一个简易版的ViewPager。首先自定义一个ViewGroup,不了解的可以参考Android自定义ViewGroup(一)之CustomGridLayout这篇文章。平滑偏移的主要做法如下:

第一、调用Scroller实例去产生一个偏移控制(对应于startScroll()方法)

第二、手动调用invalid()方法去重新绘制,剩下的就是在computeScroll()里根据当前已经逝去的时间,获取当前应该偏移的坐标(由Scroller实例对应的computeScrollOffset()计算而得)

第三、当前应该偏移的坐标,调用scrollBy()方法去缓慢移动至该坐标处。

新建一个ScrollerLayout并让它继承自ViewGroup来作为我们的简易ViewPager布局,代码如下所示:

public class ScrollerLayout extends ViewGroup {

private Scroller mScroller; //用于完成滚动操作的实例

private VelocityTracker mVelocityTracker = null ; //处理触摸的速率

public static int SNAP_VELOCITY = 600 ; //最小的滑动速率

private int mTouchSlop = 0 ; //最小滑动距离,超过了,才认为开始滑动

private float mLastionMotionX = 0 ; //上次触发ACTION_MOVE事件时的屏幕坐标

private int curScreen = 0 ; //当前屏幕

private int leftBorder; //界面可滚动的左边界

private int rightBorder; //界面可滚动的右边界

//两种状态: 是否处于滑屏状态

private static final int TOUCH_STATE_REST = 0; //什么都没做的状态

private static final int TOUCH_STATE_SCROLLING = 1; //开始滑屏的状态

private int mTouchState = TOUCH_STATE_REST; //默认是什么都没做的状态

public ScrollerLayout(Context context, AttributeSet attrs) {

super(context, attrs);

// 创建Scroller的实例

mScroller = new Scroller(context);

//初始化一个最小滑动距离

mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();

}

@Override

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

super.onMeasure(widthMeasureSpec, heightMeasureSpec);

int childCount = getChildCount();

for (int i = 0; i < childCount; i++) {

View childView = getChildAt(i);

// 为ScrollerLayout中的每一个子控件测量大小

measureChild(childView, widthMeasureSpec, heightMeasureSpec);

}

}

@Override

protected void onLayout(boolean changed, int l, int t, int r, int b) {

if (changed) {

int childCount = getChildCount();

for (int i = 0; i < childCount; i++) {

View childView = getChildAt(i);

// 为ScrollerLayout中的每一个子控件在水平方向上进行布局

childView.layout(i * childView.getMeasuredWidth(), 0, (i + 1) * childView.getMeasuredWidth(), childView.getMeasuredHeight());

}

}

// 初始化左右边界值

leftBorder = getChildAt(0).getLeft();

rightBorder = getChildAt(getChildCount() - 1).getRight();

}

@Override

public boolean onInterceptTouchEvent(MotionEvent ev) {

final int action = ev.getAction();

//表示已经开始滑动了,不需要走该Action_MOVE方法了(第一次时可能调用)。

//该方法主要用于用户快速松开手指,又快速按下的行为。此时认为是处于滑屏状态的。

if ((action == MotionEvent.ACTION_MOVE) && (mTouchState != TOUCH_STATE_REST)) {

return true;

}

final float x = ev.getX();

switch (action) {

case MotionEvent.ACTION_MOVE:

final int xDiff = (int) Math.abs(mLastionMotionX - x);

//超过了最小滑动距离,就可以认为开始滑动了

if (xDiff > mTouchSlop) {

mTouchState = TOUCH_STATE_SCROLLING;

}

break;

case MotionEvent.ACTION_DOWN:

mLastionMotionX = x;

mTouchState = mScroller.isFinished() ? TOUCH_STATE_REST : TOUCH_STATE_SCROLLING;

break;

case MotionEvent.ACTION_CANCEL:

case MotionEvent.ACTION_UP:

mTouchState = TOUCH_STATE_REST;

break;

}

return mTouchState != TOUCH_STATE_REST;

}

public boolean onTouchEvent(MotionEvent event){

super.onTouchEvent(event);

//获得VelocityTracker对象,并且添加滑动对象

if (mVelocityTracker == null) {

mVelocityTracker = VelocityTracker.obtain();

}

mVelocityTracker.addMovement(event);

//触摸点

float x = event.getX();

switch(event.getAction()){

case MotionEvent.ACTION_DOWN:

//如果屏幕的动画还没结束,你就按下了,我们就结束上一次动画,即开始这次新ACTION_DOWN的动画

if(mScroller != null){

if(!mScroller.isFinished()){

mScroller.abortAnimation();

}

}

mLastionMotionX = x ; //记住开始落下的屏幕点

break ;

case MotionEvent.ACTION_MOVE:

int detaX = (int)(mLastionMotionX - x ); //每次滑动屏幕,屏幕应该移动的距离

if (getScrollX() + detaX < leftBorder) { //防止用户拖出边界这里还专门做了边界保护,当拖出边界时就调用scrollTo()方法来回到边界位置

scrollTo(leftBorder, 0);

return true;

} else if (getScrollX() + getWidth() + detaX > rightBorder) {

scrollTo(rightBorder - getWidth(), 0);

return true;

}

scrollBy(detaX, 0);//开始缓慢滑屏咯。 detaX > 0 向右滑动 , detaX < 0 向左滑动

mLastionMotionX = x ;

break ;

case MotionEvent.ACTION_UP:

final VelocityTracker velocityTracker = mVelocityTracker ;

velocityTracker.computeCurrentVelocity(1000);

//计算速率

int velocityX = (int) velocityTracker.getXVelocity() ;

//滑动速率达到了一个标准(快速向右滑屏,返回上一个屏幕) 马上进行切屏处理

if (velocityX > SNAP_VELOCITY && curScreen > 0) {

// Fling enough to move left

snapToScreen(curScreen - 1);

}

//快速向左滑屏,返回下一个屏幕

else if(velocityX < -SNAP_VELOCITY && curScreen < (getChildCount()-1)){

snapToScreen(curScreen + 1);

}

//以上为快速移动的 ,强制切换屏幕

else{

//我们是缓慢移动的,因此先判断是保留在本屏幕还是到下一屏幕

snapToDestination();

}

//回收VelocityTracker对象

if (mVelocityTracker != null) {

mVelocityTracker.recycle();

mVelocityTracker = null;

}

//修正mTouchState值

mTouchState = TOUCH_STATE_REST ;

break;

case MotionEvent.ACTION_CANCEL:

mTouchState = TOUCH_STATE_REST ;

break;

}

return true ;

}

//我们是缓慢移动的,因此需要根据偏移值判断目标屏是哪个

private void snapToDestination(){

//判断是否超过下一屏的中间位置,如果达到就抵达下一屏,否则保持在原屏幕

//公式意思是:假设当前滑屏偏移值即 scrollCurX 加上每个屏幕一半的宽度,除以每个屏幕的宽度就是我们目标屏所在位置了。

int destScreen = (getScrollX() + getWidth() / 2 ) / getWidth() ;

snapToScreen(destScreen);

}

//真正的实现跳转屏幕的方法

private void snapToScreen(int whichScreen){

//简单的移到目标屏幕,可能是当前屏或者下一屏幕,直接跳转过去,不太友好,为了友好性,我们在增加一个动画效果

curScreen = whichScreen ;

//防止屏幕越界,即超过屏幕数

if(curScreen > getChildCount() - 1)

curScreen = getChildCount() - 1 ;

//为了达到下一屏幕或者当前屏幕,我们需要继续滑动的距离.根据dx值,可能向左滑动,也可能向右滑动

int dx = curScreen * getWidth() - getScrollX() ;

mScroller.startScroll(getScrollX(), 0, dx, 0, Math.abs(dx) * 2);

//由于触摸事件不会重新绘制View,所以此时需要手动刷新View 否则没效果

invalidate();

}

@Override

public void computeScroll() {

//重写computeScroll()方法,并在其内部完成平滑滚动的逻辑

if (mScroller.computeScrollOffset()) {

scrollTo(mScroller.getCurrX(), mScroller.getCurrY());

invalidate();

}

}

}

代码比较长,但思路比较清晰。

(1)首先在ScrollerLayout的构造函数里面我们创建Scroller的实例,由于Scroller的实例只需创建一次,因此我们把它放到构造函数里面执行。另外在构建函数中我们还初始化的TouchSlop的值,这个值在后面将用于判断当前用户的操作是否是拖动。

(2)接着重写onMeasure()方法和onLayout()方法,在onMeasure()方法中测量ScrollerLayout里的每一个子控件的大小,在onLayout()方法中为ScrollerLayout里的每一个子控件在水平方向上进行布局,布局类似于方向为horizontal的LinearLayout。

(3) 接着重写onInterceptTouchEvent()方法, 在这个方法中我们记录了用户手指按下时的X坐标位置,以及用户手指在屏幕上拖动时的X坐标位置,当两者之间的距离大于TouchSlop值时,就认为用户正在拖动布局,置状态为TOUCH_STATE_SCROLLING,当用户手指抬起,重置状态为TOUCH_STATE_REST。这里当状态值为TOUCH_STATE_SCROLLING时返回true,将事件在这里拦截掉,阻止事件传递到子控件当中。

(4)那么当我们把事件拦截掉之后,就会将事件交给ScrollerLayout的onTouchEvent()方法来处理。

如果当前事件是ACTION_MOVE,说明用户正在拖动布局,那么我们就应该对布局内容进行滚动从而影响拖动事件,实现的方式就是使用我们刚刚所学的scrollBy()方法,用户拖动了多少这里就scrollBy多少。另外为了防止用户拖出边界这里还专门做了边界保护,当拖出边界时就调用scrollTo()方法来回到边界位置。

如果当前事件是ACTION_UP时,说明用户手指抬起来了,但是目前很有可能用户只是将布局拖动到了中间,我们不可能让布局就这么停留在中间的位置,因此接下来就需要借助Scroller来完成后续的滚动操作。首先计算滚动速率,判断当前动作是scroll还是fling。如果是fling,再根据fling的方向跳转到上一页或者下一页,调用函数snapToScreen。如果是scroll,就调用函数snapToDestination,函数中首先根据当前的滚动位置来计算布局应该继续滚动到哪一页,滚动到哪一页同样调用snapToScreen。再来看看snapToScreen写法吧,其实是调用startScroll()方法来滚动数据,紧接着调用invalidate()方法来刷新界面。

(5)重写computeScroll()方法,并在其内部完成平滑滚动的逻辑 。在整个后续的平滑滚动过程中,computeScroll()方法是会一直被调用的,因此我们需要不断调用Scroller的computeScrollOffset()方法来进行判断滚动操作是否已经完成了,如果还没完成的话,那就继续调用scrollTo()方法,并把Scroller的curX和curY坐标传入,然后刷新界面从而完成平滑滚动的操作。

现在ScrollerLayout已经准备好了,接下来我们修改activity_main.xml布局中的内容,如下所示:

android:layout_width="match_parent"

android:layout_height="match_parent">

android:layout_width="match_parent"

android:layout_height="200dp"

android:background="@drawable/crazy_1" />

android:layout_width="match_parent"

android:layout_height="200dp"

android:background="@drawable/crazy_2" />

android:layout_width="match_parent"

android:layout_height="200dp"

android:background="@drawable/crazy_3" />

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持脚本之家。

Android scroller控件,Android Scroller完全解析相关推荐

  1. android toolbar控件,Android Toolbar控件

    1. Toolbar类 Toolbar是替代ActionBar的产物,低版本可以使用v7兼容包,使用Theme.AppCompat主题,并添加配置. false true 2. Toolbar配置 主 ...

  2. android ratingbar不可点击,Android评分控件RatingBar使用实例解析

    无论游戏,应用,网站,都少不了评分控件.在Android SDK 中提供了 RatingBar控件来实现相应的工作. 标签有几个常用评分相关属性 android:numStars,指定评分五角星数. ...

  3. android裁剪控件,Android 仿抖音音频裁剪控件

    效果图 QQ图片20201126164657.jpg 功能要求:绘制音频效果,音乐播放后进度滚动,控件可拖动,拖动后获取新的起始时间 (后期会加入根据音乐各个时段分贝大小来动态显示音律线的长短) 控件 ...

  4. android 电量控件,Android实现显示电量的控件代码

    下面介绍了Android实现显示电量的控件代码,具体代码如下: 1.目录结构,本人是使用安卓死丢丢. 2.运行界面,输入框中输入数值,点击刷新,会再电池中显示出相应的电量 3.绘制自定义电池控件,首先 ...

  5. android+ebook控件,Android 自定义控件 eBook 翻书效果

    Book.java文件: package com.book; import Android.app.Activity; import android.os.Bundle; import android ...

  6. android md 控件,Android基本UI控件.md

    # Android基本UI控件 ## *TextView 文本框* ### TextView常用用法 | 主要方法 | 功能描述 | | :----------: | :--------------- ...

  7. android 绘制控件,Android自定义控件绘制基本图形基础入门

    本文讲述绘制Android自定义各种图形效果,为自定义控件的入门篇 相关视频链接: 绘制点–这个控件只需要在布局中引用或者代码中new 即可,下面几个绘制只展示onDraw方法 package com ...

  8. android电子书控件,Android控件大全.pdf

    Android控件大全 Android 中常见控件的介绍和使用 1 TextView 文本框 1.1 TextView 类的结构 TextView 是用于显示字符串的组件,对于用户来说就是屏幕中一块用 ...

  9. android datepicker控件,android之datepicker控件的用法

    如下所示: android:orientation="vertical" android:layout_width="wrap_content" android ...

最新文章

  1. 解析PHP实现多进程并行执行脚本
  2. 【ArcGIS风暴】实验:公路建设成本的计算
  3. C/C++头文件与变量的声明和定义
  4. iOS之instancetype
  5. 每周荐书:Web扫描、HTML 5、Python(评论送书)
  6. Spring Boot的MyBatis注解:@MapperScan和@Mapper
  7. 关于连接参数-Ttext
  8. Julia : 关于类型的匹配和规范建议
  9. pycharm下防止鼠标拖拽代码字符
  10. 要看cpu的性能好坏主要看什么
  11. 考上一级建造师很牛吗?让我从一个屌丝技术员到项目经理
  12. 图形学笔记(四)——Harris 角点检测器延申
  13. Android 运行时权限
  14. 学习笔记21.07.09:绘制色块图
  15. 大文件MD5计算 C语言 (从OpenSSL库中分离算法:三)
  16. CSS实现鼠标放上图片放大
  17. 数据中台 第8章 数据资产管理
  18. java.lang.ClassNotFoundException: org.apache.commons.dbcp.BasicDataSource解决方法
  19. 设计一可控同步四进制可逆计数器, 其由输入X1,X2控制, 用D触发器和74153及必要的门电路实现
  20. jdbc shadring 扩容_shadring-jdbc解决查询数据库分库分表的问题

热门文章

  1. PLG日志平台搭建: Promtail + Loki + Grafana 全步骤
  2. ZT 悟空、悟能、悟净、这三个法号有什么特殊的含义吗?
  3. 证明碰撞集(HITTING SET)问题是NP完全问题
  4. ②五元物联网芯片点亮led 庆科EMW3060 , 运行alios- blink程序
  5. win10企业版无法访问共享文件夹
  6. 会php学java入门要多久_php自学需要多久
  7. 如何使用Dart的Stream(一)
  8. 什么是SEM竞价推广,竞价排名有何特征?
  9. springboot 第十九节 starter and muti_datasource 多数据源
  10. 【思维导图】canny滤波 原理步骤细致剖析