事件分发机制和滑动冲突

事件的种类 

要注意的是 Activity 和 View 是没有 onInterceptTouchEvent() 方法的。

事件分发流程

测试代码

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        Log.i("TAG","Activity**dispatchTouchEvent**"+Utils.getAction(ev));
        return super.dispatchTouchEvent(ev);

    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        Log.i("TAG","Activity**onTouchEvent**"+Utils.getAction(event));
        return super.onTouchEvent(event);

    }
}
class ViewGroup1 extends LinearLayout {
    public ViewGroup1(Context context) {
        this(context,null);
    }

    public ViewGroup1(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs,0);
    }

    public ViewGroup1(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        Log.i("TAG","ViewGroup1**dispatchTouchEvent**"+Utils.getAction(ev));
        return super.dispatchTouchEvent(ev);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        Log.i("TAG","ViewGroup1**onInterceptTouchEvent**"+Utils.getAction(ev));
        return super.onInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        Log.i("TAG","ViewGroup1**onTouchEvent**"+Utils.getAction(event));
        return super.onTouchEvent(event);
    }
}
class View1 extends View {

    public View1(Context context) {
        this(context,null);
    }

    public View1(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs,0);
    }

    public View1(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        Log.i("TAG","View1**dispatchTouchEvent**"+Utils.getAction(event));
        return super.dispatchTouchEvent(event);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        Log.i("TAG","View1**onTouchEvent**"+Utils.getAction(event));
        return super.onTouchEvent(event);
    }
}
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">
    <com.example.dispatcheventdemo.ViewGroup1
        android:layout_width="300dp"
        android:layout_height="300dp"
        android:background="#00ff00"
        >

        <com.example.dispatcheventdemo.View1
            android:layout_width="200dp"
            android:layout_height="200dp"
            android:background="#ff0000" />

    </com.example.dispatcheventdemo.ViewGroup1>

</RelativeLayout>

1.改变 Activity 的 dispathchTouchEvent() 方法的返回值

1.1返回 super.dispatchTouchEvent(ev)

可以看出后面的 MOVE 和 UP 事件,Activity 并没有分发下去,而是自己处理了。这里可以得出一个结论: 

如果一个 View 不消费 DOWN 事件,那么同一个事件序列剩下的事件将不会再交给它处理,而会交给它的父元素处理。

1.2 dispathchTouchEvent() 返回 true

如果直接返回 true,不会分发任何事件,可以在 Activity 处理事件。

1.3 dispathchTouchEvent() 返回 false

如果直接返回 false,不会分发任何事件,可以在 Activity 处理事件。 

2 改变 ViewGroup 的 dispathchTouchEvent() 方法的返回值

前面已经探究过 ViewGroup 返回默认值的情况了,现在只看看返回 true 和 false 的情况。

2.1返回 true

说明ViewGroup已经对事件进行了处理,停止分发

2.2返回false

从结果可以看出,如果 ViewGroup 不消费 DOWN 事件,事件序列接下来的事件都不会再交给它处理。

对于dispatchTouchEvent 返回 false 的含义应该是:事件停止往子View传递和分发同时开始往父控件回溯(父控件的onTouchEvent开始从下往上回传直到某个onTouchEvent return true),事件分发机制就像递归,return false 的意义就是递归停止然后开始回溯。

3.改变 ViewGroup 的 onInterceptTouchEvent() 方法的返回值

3.1 返回true

3.2 返回false

 验证一个结论,就是如果 View 不消费除了 DOWN 事件的其他事件,那么这些事件就会直接交给 Activity 的 onTouchEvent() 处理,而不会再交给它的父容器的 onTouchEvent()。

class View1 extends View {

    public View1(Context context) {
        this(context,null);
    }

    public View1(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs,0);
    }

    public View1(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        Log.i("TAG","View1**dispatchTouchEvent**"+Utils.getAction(event));
        return super.dispatchTouchEvent(event);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        Log.i("TAG","View1**onTouchEvent**"+Utils.getAction(event));
        if(event.getAction() != MotionEvent.ACTION_DOWN){
            return false;
        }
        return true;
    }
}

可以看到代码我只是修改了 onTouchEvent() 的代码,这里只消费 DOWN 事件,其余事件都不消费。 

从打印结果可以看出,除了 DOWN 事件外,其余事件就直接交给 Activity 处理,并没有再回调 ViewGroup 的 onTouchEvent()。

 从这里就可以看出,Acitity 如果要将事件分发出去,必须在 dispatchTouchEvent() 返回默认值。

 

 总结下:

  1. 对于 dispatchTouchEvent,onTouchEvent,return true是终结事件传递。return false 是回溯到父View的onTouchEvent方法。
  2. ViewGroup 想把自己分发给自己的onTouchEvent,需要拦截器onInterceptTouchEvent方法return true 把事件拦截下来。
  3. ViewGroup 的拦截器onInterceptTouchEvent 默认是不拦截的,所以return super.onInterceptTouchEvent()=return false;
  4. View 没有拦截器,为了让View可以把事件分发给自己的onTouchEvent,View的dispatchTouchEvent默认实现(super)就是把事件分发给自己的onTouchEvent。

 

滑动冲突场景

场景1——外部滑动方向和内部滑动方向不一致;

主要是将ViewPager和Fragment配合使用所组成的页面滑动效果,主流应用几乎都会使用这个效果。

在这种效果中,可以通过左右滑动来切换页面,而每个页面内部往往又是一个ListView。本来这种情况下是有滑动冲突的,但是ViewPager内部处理了这种滑动冲突,因此采用ViewPager时我们无须关注这个问题。

如果我们采用的不是ViewPager而是ScrollView等,那就必须手动处理滑动冲突了,否则造成的后果就是内外两层只能有一层能够滑动,这是因为两者之间的滑动事件有冲突。

除了这种典型情况外,还存在其他情况,比如外部上下滑动、内部左右滑动等,但是它们属于同一类滑动冲突。

场景2——外部滑动方向和内部滑动方向一致;

再说场景2,这种情况就稍微复杂一些,当内外两层都在同一个方向可以滑动的时候,显然存在逻辑问题。

因为当手指开始滑动的时候,系统无法知道用户到底是想让哪一层滑动,所以当手指滑动的时候就会出现问题,要么只有一层能滑动,要么就是内外两层都滑动得很卡顿。

在实际的开发中,这种场景主要是指内外两层同时能上下滑动或者内外两层同时能左右滑动。

场景3——上面两种情况的嵌套。

最后说下场景3,场景3是场景1和场景2两种情况的嵌套,因此场景3的滑动冲突看起来就更加复杂了。比如在许多应用中会有这么一个效果:内层有一个场景1中的滑动效果,然后外层又有一个场景2中的滑动效果。

具体说就是,外部有一个SlideMenu效 果,然后内部有一个ViewPager,ViewPager的每一个页面中又是一个ListView。

虽然说场景3的滑动冲突看起来更复杂,但是它是几个单一的滑动冲突的叠加。因此只需要分别处理内层和中层、中层和外层之间的滑动冲突即可。

 

滑动冲突的处理

对于场景1,它的处理规则是:当用户左右滑动时,需要让外部的View拦截点击事件,当用户上下滑动时,需要让内部View拦截点击事件。

具体来说是:根据滑动是水平滑动还是竖直滑动来判断到底由谁来拦截事件。

如图3-5所示,根据滑动过程中两个点之间的坐标就可以得出到底是水平滑动还是竖直滑动。

如何根据坐标来得到滑动的方向呢?

这里我们可以通过水平和竖直方向的距离差来判断,比如竖直方向滑动的距离大就判断为竖直滑动,否则判断为水平滑动。

根据这个规则就可以进行下一步的解决方法制定了。

滑动冲突的解决方式

1. 外部拦截法

所谓外部拦截法是指点击事情都先经过父容器的拦截处理,如果父容器需要此事件就拦截,如果不需要此事件就不拦截,这样就可以解决滑动冲突的问题,这种方法比较符合点击事件的分发机制。

外部拦截法需要重写父容器的onInterceptTouchEvent方法,在内部做相应的拦截即可,这种方法的伪代码如下所示。

    public boolean onInterceptTouchEvent(MotionEvent event) {
        boolean intercepted = false;
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                intercepted = false;
                break;
            }
            case MotionEvent.ACTION_MOVE: {
                if (父容器需要当前点击事件) {
                    intercepted = true;
                } else {
                    intercepted = false;
                }
                break;
            }
            case MotionEvent.ACTION_UP: {
                intercepted = false;
                break;
            }
            default:
                break;
        }
        mLastXIntercept = x;
        mLastYIntercept = y;
        return intercepted;
    }

上述代码是外部拦截法的典型逻辑,针对不同的滑动冲突,只需要修改父容器需要当前点击事件这个条件即可,其他均不需做修改并且也不能修改。

这里对上述代码再描述一下,在onInterceptTouchEvent方法中,

首先是ACTION_DOWN这个事件,父容器必须返回false,即不拦截ACTION_DOWN事件,这是因为一旦父容器拦截了ACTION_DOWN,那么后续的ACTION_MOVE和ACTION_UP事件都会直接交由父容器处理,这个时候事件没法再传递给子元素了。

其次是ACTION_MOVE事件,这个事件可以根据需要来决定是否拦截,如果父容器需要拦截就返回true,否则返回false;

最后是ACTION_UP事件,这里必须要返回false,因为ACTION_UP事件本身没有太多意义。

考虑一种情况,假设事件交由子元素处理,如果父容器在ACTION_UP时返回了true,就会导致子元素无法接收到ACTION_UP事件,这个时候子元素中的onClick事件就无法触发,但是父容器比较特殊,一旦它开始拦截任何一个事件,那么后续的事件都会交给它来处理,而ACTION_UP作为最后一个事件也必定可以传递给父容器,即便父容器的onInterceptTouchEvent方法在ACTION_UP时返回了false。

2. 内部拦截法

内部拦截法是指父容器不拦截任何事件,所有的事件都传递给子元素。如果子元素需要此事件就直接消耗掉, 否则就交由父容器进行处理。

这种方法和Android 中的事件分发机制不一致, 需要配合requestDisallowInterceptTouchEvent方法才能正常工作,工作中内部拦截比较有用。

它的伪代码如下,我们需要重写子元素的dispatchTouchEvent方法:

    public boolean dispatchTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                parent.requestDisallowInterceptTouchEvent(true);
                break;
            }
            case MotionEvent.ACTION_MOVE: {
                int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                if (父容器需要此类点击事件)){
                    parent.requestDisallowInterceptTouchEvent(false);
                }
                break;
            }
            case MotionEvent.ACTION_UP: {
                break;
            }
            default:
                break;
        }
        mLastX = x;
        mLastY = y;
        return super.dispatchTouchEvent(event);
    }

上述代码是内部拦截法的典型代码,当面对不同的滑动策略时只需要修改里面的条件即可,其他不需要做改动而且也不能有改动。

case MotionEvent.ACTION_DOWN: {
parent.requestDisallowInterceptTouchEvent(true);
break;

ACTION_DOWN事件的时候请求父类不要拦截,所有的ACTION_DOWN不能拦截

case MotionEvent.ACTION_MOVE: {
int deltaX = x -mLastX;
int deltaY = y -mLastY;
if (父容器需要此类点击事件)) {
parent.requestDisallowInterceptTouchEvent(false);
}
break;

ACTION_MOVE事件的时候,父元素你可以可以拦截事件了。

为什么父容器不能拦截ACTION_DOWN 事件呢?

因为一旦父容器拦截ACTION_DOWN事件,那么所有的事件都无法传递到子元素中去,这样内部拦截就无法起作用了。

父元素所做的修改如下所示。

    public boolean onInterceptTouchEvent(MotionEvent event) {
        int action = event.getAction();
        if (action == MotionEvent.ACTION_DOWN) {
            return false;
        } else {
            return true;
        }
    }

下面通过一个实例来分别介绍这两种方法。我们来实现一个类似于ViewPager中嵌套ListView的效果。为了制造滑动冲突,我们写一个类似于ViewPager的控件即可,名字就叫HorizontalScrollViewEx。

为了实现ViewPager的效果,我们定义了一个类似于水平的LinearLayout的东西,只不过它可以水平滑动,初始化时我们在它的内部添加若干个ListView,这样一来,由于它内部的Listview可以竖直滑动。而它本身又可以水平滑动,因此一个典型的滑动冲突场景就现了,并且这种冲突属于类型1的冲突。

根据滑动策略,我们可以选择水平和竖直的滑动距离差来解决滑动冲突。首先来看一下Activity中的初始化代码,如下所示。

public class DemoActivity_1 extends Activity {
    private static final String TAG = "SecondActivity";
    private HorizontalScrollViewEx mListContainer;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.demo_1);
        Log.d(TAG, "onCreate");
        initView();
    }

    private void initView() {
        LayoutInflater inflater = getLayoutInflater();
        mListContainer = (HorizontalScrollViewEx) findViewById(R.id.
                container);
        final int screenWidth = MyUtils.getScreenMetrics(this).widthPixels;
        final int screenHeight = MyUtils.getScreenMetrics(this).height -
                Pixels;
        for (int i = 0; i < 3; i++) {
            ViewGroup layout = (ViewGroup) inflater.inflate(
                    R.layout.content_layout, mListContainer, false);
            layout.getLayoutParams().width = screenWidth;
            TextView textView = (TextView) layout.findViewById(R.id.title);
            textView.setText("page " + (i + 1));
            layout.setBackgroundColor(Color.rgb(255 / (i + 1), 255 / (i + 1), 0));
            createList(layout);
            mListContainer.addView(layout);
        }
    }

    private void createList(ViewGroup layout) {
        ListView listView = (ListView) layout.findViewById(R.id.list);
        ArrayList<String> datas = new ArrayList<String>();
        for (int i = 0; i < 50; i++) {
            datas.add("name " + i);
        }
        ArrayAdapter<String> adapter = new ArrayAdapter<String>(this,
                R.layout.content_list_item, R.id.name, datas);
        listView.setAdapter(adapter);
    }
}

上述初始化代码很简单, 就是创建了3 个ListView 并且把ListView 加入到我们自定义的HorizontalScrollViewEx中,这里HorizontalScrollViewEx是父容器,而ListView则是子元素,这里就不再多介绍了。

首先采用外部拦截法来解决这个问题,按照前面的分析,我们只需要修改父容器需要拦截事件的条件即可。

对于本例来说,父容器的拦截条件就是滑动过程中水平距离差比竖直距离差大,在这种情况下,父容器就拦截当前点击事件, 根据这一条件进行相应修改, 修改后的HorizontalScrollViewEx 的onInterceptTouchEvent方法如下所示。

    public boolean onInterceptTouchEvent(MotionEvent event) {
        boolean intercepted = false;
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                intercepted = false;
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                    intercepted = true;
                }
                break;
            }
            case MotionEvent.ACTION_MOVE: {
                int deltaX = x - mLastXIntercept;
                int deltaY = y - mLastYIntercept;
                if (Math.abs(deltaX) > Math.abs(deltaY)) {
                    intercepted = true;
                } else {
                    intercepted = false;
                }
                break;
            }
            case MotionEvent.ACTION_UP: {
                intercepted = false;
                break;
            }
            default:
                break;
        }
        Log.d(TAG, "intercepted=" + intercepted);
        mLastXIntercept = x;
        mLastYIntercept = y;
        return intercepted;
    }

从上面的代码来看,它和外部拦截法的伪代码的差别很小,只是把父容器的拦截条件换成了具体的逻辑。

在滑动过程中,当水平方向的距离大时就判断为水平滑动,为了能够水平滑动所以让父容器拦截事件;

而竖直距离大时父容器就不拦截事件,于是事件就传递给了ListView,所以ListView也能上下滑动,如此滑动冲突就解决了。

至于mScroller.abortAnimation()这一句话主要是为了优化滑动体验而加入的。

考虑一种情况,如果此时用户正在水平滑动,但是在水平滑动停止之前如果用户再迅速进行竖直滑动,就会导致界面在水平方向无法滑动到终点从而处于一种中间状态。

为了避免这种不好的体验,当水平方向正在滑动时,下一个序列的点击事件仍然交给父容器处理,这样水平方向就不会停留在中间状态了。
下面是HorizontalScrollViewEx的具体实现,只展示了和滑动冲突相关的代码:

  public class HorizontalScrollViewEx extends ViewGroup {
        private static final String TAG = "HorizontalScrollViewEx";
        private int mChildrenSize;
        private int mChildWidth;
        private int mChildIndex;
        // 分别记录上次滑动的坐标
        private int mLastX = 0;
        private int mLastY = 0;
        // 分别记录上次滑动的坐标(onInterceptTouchEvent)
        private int mLastXIntercept = 0;
        private int mLastYIntercept = 0;
        private Scroller mScroller;
        private VelocityTracker mVelocityTracker;
…

        private void init() {
            mScroller = new Scroller(getContext());
            mVelocityTracker = VelocityTracker.obtain();
        }

        @Override

        public boolean onInterceptTouchEvent(MotionEvent event) {
            boolean intercepted = false;
            int x = (int) event.getX();
            int y = (int) event.getY();
            switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN: {
                    intercepted = false;
                    if (!mScroller.isFinished()) {
                        mScroller.abortAnimation();
                        intercepted = true;
                    }
                    break;
                }
                case MotionEvent.ACTION_MOVE: {
                    int deltaX = x - mLastXIntercept;
                    int deltaY = y - mLastYIntercept;
                    if (Math.abs(deltaX) > Math.abs(deltaY)) {
                        intercepted = true;
                    } else {
                        intercepted = false;
                    }
                    break;
                }
                case MotionEvent.ACTION_UP: {
                    intercepted = false;
                    break;
                }
                default:
                    break;
            }
            Log.d(TAG, "intercepted=" + intercepted);
            mLastX = x;
            mLastY = y;
            mLastXIntercept = x;
            mLastYIntercept = y;
            return intercepted;
        }

        @Override
        public boolean onTouchEvent(MotionEvent event) {
            mVelocityTracker.addMovement(event);
            int x = (int) event.getX();
            int y = (int) event.getY();
            switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN: {
                    if (!mScroller.isFinished()) {
                        mScroller.abortAnimation();
                    }
                    break;
                }
                case MotionEvent.ACTION_MOVE: {
                    int deltaX = x - mLastX;
                    int deltaY = y - mLastY;
                    scrollBy(-deltaX, 0);
                    break;
                }
                case MotionEvent.ACTION_UP: {
                    int scrollX = getScrollX();
                    int scrollToChildIndex = scrollX / mChildWidth;
                    mVelocityTracker.computeCurrentVelocity(1000);
                    float xVelocity = mVelocityTracker.getXVelocity();
                    if (Math.abs(xVelocity) =>50){
                        mChildIndex = xVelocity > 0 ? mChildIndex - 1 : mChildIndex + 1;
                    } else{
                        mChildIndex = (scrollX + mChildWidth / 2) / mChildWidth;
                    }
                    mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize -
                            1));
                    int dx = mChildIndex * mChildWidth - scrollX;
                    smoothScrollBy(dx, 0);
                    mVelocityTracker.clear();
                    break;
                }
                default:
                    break;
            }
            mLastX = x;
            mLastY = y;
            return true;
        }

        private void smoothScrollBy(int dx, int dy) {
            mScroller.startScroll(getScrollX(), 0, dx, 0, 500);
            invalidate();
        }

        @Override
        public void computeScroll() {
            if (mScroller.computeScrollOffset()) {
                scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
                postInvalidate();
            }
        }
…
    }

如果采用内部拦截法也是可以的,按照前面对内部拦截法的分析,我们只需要修改ListView的dispatchTouchEvent方法中的父容器的拦截逻辑,同时让父容器拦截ACTION_MOVE和ACTION_UP事件即可。

为了重写ListView的dispatchTouchEvent方法,我们必须自定义一个ListView,称为ListViewEx,然后对内部拦截法的模板代码进行修改,根据需要,ListViewEx的实现如下所示。

public class ListViewEx extends ListView {
    private static final String TAG = "ListViewEx";
    private HorizontalScrollViewEx2 mHorizontalScrollViewEx2;
    // 分别记录上次滑动的坐标
    private int mLastX = 0;
    private int mLastY = 0;
…

    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(true);
                break;
            }
            case MotionEvent.ACTION_MOVE: {
                int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                if (Math.abs(deltaX) > Math.abs(deltaY)) {
                    mHorizontalScrollViewEx2.requestDisallowInterceptTouchEvent(false);
                }
                break;
            }
            case MotionEvent.ACTION_UP: {
                break;
            }
            default:
                break;
        }
        mLastX = x;
        mLastY = y;
        return super.dispatchTouchEvent(event);
    }
}

除了上面对ListView所做的修改,我们还需要修改HorizontalScrollViewEx的onInterceptTouchEvent方法,修改后的类暂且叫HorizontalScrollViewEx2,其onInterceptTouchEvent方法如下所示。

    public boolean onInterceptTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        int y = (int) event.getY();
        int action = event.getAction();
        if (action == MotionEvent.ACTION_DOWN) {
            mLastX = x;
            mLastY = y;
            if (!mScroller.isFinished()) {
                mScroller.abortAnimation();
                return true;
            }
            return false;
        } else {
            return true;
        }
    }

上面的代码就是内部拦截法的示例,其中mScroller.abortAnimation()这一句不是必须的,在当前这种情形下主要是为了优化滑动体验。

从实现上来看,内部拦截法的操作要稍微复杂一些,因此推荐采用外部拦截法来解决常见的滑动冲突。但是工作中常用的还是内部拦截法。
前面说过,只要我们根据场景1的情况来得出通用的解决方案,那么对于场景2和场景3来说我们只需要修改相关滑动规则的逻辑即可。

猜你喜欢

转载自blog.csdn.net/jingerlovexiaojie/article/details/108827505