如何应对Android面试官->事件分发冲突与解决方案大揭秘

前言

View的继承关系

Android 中的 View 的继承关系,View 用来处理事件,ViewGroup 用来分发事件;

事件的分发流程如下:

这个在上一章(嵌套滚动大揭秘)中有简单介绍;

View 的事件处理流程

onTouch 如何消费?onClick 如何消费?

我们可以先来看一个小 demo

typescript 复制代码
mTvJustWatchTitle.setOnClickListener(new View.OnClickListener() {    
    @Override    
    public void onClick(View v) {   
        //
     }
});

mTvJustWatchTitle.setOnTouchListener(new View.OnTouchListener() {    
    @Override    
    public boolean onTouch(View v, MotionEvent event) {        
        return false;    
    }
});

当我们的 onTouch 方法 return false 的时候,我们发现,onClick 方法执行了,当 return true 的时候,onClick 方法却没有执行,那么这是为什么呢? onClick 为什么会与 onTouch 发生了冲突呢?

什么是事件冲突?

事件只有一个,多个人想要处理,如果处理的对象不是我们想要的对象,那么就被称为事件冲突!

我们进入 View 的 dispatchTouchEvent 方法看下,事件是怎么消费的,谁的优先级较高

csharp 复制代码
public boolean dispatchTouchEvent(MotionEvent event) {
    // 省略部分代码
    ...
    // 
    if (onFilterTouchEventForSecurity(event)) {    
        if ((mViewFlags & ENABLED_MASK) == ENABLED && handleScrollBarDragging(event)) {        
            result = true;    
        }    
        //noinspection SimplifiableIfStatement 
        // 因为我们调用了 setOnTouchEventListener 并实现了这个方法,那么这个mListenerInfo就一定不为空了,
        //   
        ListenerInfo li = mListenerInfo;    
        if (li != null && li.mOnTouchListener != null            
                && (mViewFlags & ENABLED_MASK) == ENABLED            
                && li.mOnTouchListener.onTouch(this, event)) {        
            result = true;    
        }    
        if (!result && onTouchEvent(event)) {        
            result = true;    
        }
    }    // 省略部分代码
    ...
    // 

}

也就是说,如果 onTouch 方法中 return true 的时候,这个 result = true 就会成立;如果 return false,这个 result = true 就不会成立,当 onTouch return false 的时候,我们接着进入 onTouchEvent 方法看下,可以看到,这个方法里面我们进行了事件的处理操作,里面实现了 MotionEvent 的 UP、DOWN、MOVE、CANCEL 等事件;

typescript 复制代码
public boolean onTouchEvent(MotionEvent event) {
    //
    ...
    // 省略部分代码
    switch (action) {    
        case MotionEvent.ACTION_UP:
            //
            ...
            // 省略部分代码
            // 可以看到,onClick 的执行,是在 UP 事件的 performClickInternal 方法中
            if (!post(mPerformClick)) {    
                performClickInternal();
            }
        break;
    }
}

我们进入 performClickInternal 方法看下:

scss 复制代码
private boolean performClickInternal() {    
    // Must notify autofill manager before performing the click actions to avoid scenarios where    
    // the app has a click listener that changes the state of views the autofill service might    
    // be interested on.    
    notifyAutofillManagerOnClick();    
    return performClick();
}

方法调用了 performClick 方法,我们进入这个方法看一下:

java 复制代码
public boolean performClick() {    
    // We still need to call this method to handle the cases where performClick() was called    
    // externally, instead of through performClickInternal()    
    notifyAutofillManagerOnClick();    
    final boolean result;    
    final ListenerInfo li = mListenerInfo;    
    if (li != null && li.mOnClickListener != null) {        
        playSoundEffect(SoundEffectConstants.CLICK); 
        // 可以看到,最终调用了我们实现的 onClick 方法;       
        li.mOnClickListener.onClick(this);        
        result = true;    
    } else {        
        result = false;    
    }    
    sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);    
    notifyEnterOrExitForAutoFillIfNeeded(true);    
    return result;
}

看到这里,我们也就能理解了,为什么 onTouch 方法 return false 的时候,onClick 才会执行的原因,因为 onTouch 方法先执行,如果 return true,则不会执行 onClick 方法了;也就是我们需要根据业务需求,设置不同的处理对象,来防止事件处理冲突;

View 的事件分发流程

ACTION_DOWN 事件分发流程

我们前面有提到,事件的分发是在 ViewGroup 中进行的,我们进入 ViewGroup 的 dispatchTouchEvent 方法看一下:

我们以 ViewPager 嵌套 RecyclerView 为例子:

如果我们重写了 ViewPager 的 onInterceptTouchEvent 方法,并 return true,则表明由 ViewPager 拦截此次事件;

java 复制代码
public boolean dispatchTouchEvent(MotionEvent ev) {
    // 省略部分代码
    ...
    // 
    if (onFilterTouchEventForSecurity(ev)) {
        final boolean intercepted;
        if (actionMasked == MotionEvent.ACTION_DOWN        
                || mFirstTouchTarget != null) { 
            // 判断事件是否要拦截   
            final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;    
            if (!disallowIntercept) {        
                intercepted = onInterceptTouchEvent(ev);        
                ev.setAction(action); // restore action in case it was changed    
            } else {        
                intercepted = false;    
            }
        } else {    
            // There are no touch targets and this action is not an initial down    
            // so this view group continues to intercept touches.    
            intercepted = true;
        }
        // 
    }
    // 
}

首先会通过 onInterceptTouchEvent 判断是不是要拦截,如果拦截这个事件,也就是 intercepted 就会等于 true;则会进入 dispatchTransformedTouchEvent

typescript 复制代码
public boolean dispatchTouchEvent(MotionEvent ev) {
    // 省略部分代 
    ...    
    // 省略部分代码 

    if (mFirstTouchTarget == null) {    
        // No touch targets so treat this as an ordinary view.    
        handled = dispatchTransformedTouchEvent(ev, canceled, null,            
            TouchTarget.ALL_POINTER_IDS);
    }

    // 省略部分代码     
    ...   
    // 省略部分代码 
}

我们进入这个 dispatchTransformedTouchEvent 方法看下:

ini 复制代码
private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,        
        View child, int desiredPointerIdBits) {
    // 省略部分代码
    ...
    // 
    if (child == null) {    
        handled = super.dispatchTouchEvent(transformedEvent);
    } else {    
        final float offsetX = mScrollX - child.mLeft;    
        final float offsetY = mScrollY - child.mTop;    
        transformedEvent.offsetLocation(offsetX, offsetY);    
        if (! child.hasIdentityMatrix()) {        
            transformedEvent.transform(child.getInverseMatrix());    
        }    
        handled = child.dispatchTouchEvent(transformedEvent);}
}

首次的时候,这个 child 为空,进入了super.dispatchTouchEvent(transformedEvent)这个方法,而 ViewGroup 的父类是 View,所以进入了 View 的 dispatchTouchEvent 方法,进行了事件的处理逻辑;

如果 onInterceptTouchEvent return true,则这个 ViewGroup 消费当前事件的流程也就整体串联了起来,当前 ViewGroup 的后续 MOVE、UP、CANCEL 等就会在当前 ViewGroup 消费掉;

我们接着返回去看 不拦截 的 case:

scss 复制代码
public boolean dispatchTouchEvent(MotionEvent ev) {
    // 
    ...
    // 省略部分代码
    if (!canceled && !intercepted) {
        //
        ...
        // 省略部分代码
        if (actionMasked == MotionEvent.ACTION_DOWN        
            || (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)        
            || actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
            //
            ...
            // 省略部分代码
            if (newTouchTarget == null && childrenCount != 0) {
                // 给当前 ViewGroup 的子 View 进行一个排序
                final ArrayList<View> preorderedList = buildTouchDispatchChildList();                
            }
        }
    }
}

不拦截,也就是 onInterceptTouchEvent return false,intercepted 就会等于 false,则先判断这个 ViewGroup 有没有子 View,并获取子 View 的个数,以及调用 buildTouchDispatchChildList 对子 View 进行排序,排序规则如下:

根据 xml 中声明的顺序,倒序插入到这个 ArrayList 中;

然后获取的时候,倒序取出,也就是最终还是先写的,先取出来 ;

css 复制代码
for (int i = childrenCount - 1; i >= 0; i--) {}

倒序取出来之后,进行相关逻辑处理:

ini 复制代码
for (int i = childrenCount - 1; i >= 0; i--) {    
    final int childIndex = getAndVerifyPreorderedIndex(            
            childrenCount, i, customOrder);    
    final View child = getAndVerifyPreorderedView(            
            preorderedList, children, childIndex);
    // 
    if (!child.canReceivePointerEvents()        
            || !isTransformedTouchPointInView(x, y, child, null)) {    
        ev.setTargetAccessibilityFocus(false);    
        continue;
    }
}

取出 View 之后,会进行能不能接受事件处理的判断,第一个 canReceivePointerEvents 方法中首先会判断当前 View 是不是可见的,然后判断当前 View 是不是有动画;

第二个 isTransformedTouchPointInView 方法中判断触摸的地方是不是在当前 View 上;

然后调用 dispatchTransformedTouchEvent 进行事件的分发

scss 复制代码
for (int i = childrenCount - 1; i >= 0; i--) {    
    final int childIndex = getAndVerifyPreorderedIndex(            
            childrenCount, i, customOrder);    
    final View child = getAndVerifyPreorderedView(            
            preorderedList, children, childIndex);
    // 
    if (!child.canReceivePointerEvents()        
            || !isTransformedTouchPointInView(x, y, child, null)) {    
        ev.setTargetAccessibilityFocus(false);    
        continue;
    }
    // 分发处理事件
    if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
        
    }
}

这里会进行事件的分发 dispatchTransformedTouchEvent 由于我们前面已经将 child 获取到了,所以这个这个方法中的第三个参数 child 就不会为空;就会进入 dispatchTransformedTouchEvent 这个方法的 else 逻辑里面

ini 复制代码
private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,        
        View child, int desiredPointerIdBits) {
    if (child == null) {    
        handled = super.dispatchTouchEvent(transformedEvent);
    } else {    
        final float offsetX = mScrollX - child.mLeft;    
        final float offsetY = mScrollY - child.mTop;    
        transformedEvent.offsetLocation(offsetX, offsetY);    
        if (! child.hasIdentityMatrix()) {        
            transformedEvent.transform(child.getInverseMatrix());    
        }    
        handled = child.dispatchTouchEvent(transformedEvent);
    }
}

这里 else 中,如果子 View 还是 ViewGroup,那么就会进入下一轮的递归调用,直到分发到 View 进行事件的消费;

如果 dispatchTransformedTouchEvent 返回 false,则开始下一轮的循环;

如果 dispatchTransformedTouchEvent 返回 true,则会进入事件处理流程,同时 break 循环;

ini 复制代码
if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {    
    // Child wants to receive touch within its bounds.    
    mLastTouchDownTime = ev.getDownTime();    
    if (preorderedList != null) {        
        // childIndex points into presorted list, find original index        
        for (int j = 0; j < childrenCount; j++) {            
            if (children[childIndex] == mChildren[j]) {                
                mLastTouchDownIndex = j;                
                break;            
            }        
        }    
    } else {        
        mLastTouchDownIndex = childIndex;    
    }    
    mLastTouchDownX = ev.getX();    
    mLastTouchDownY = ev.getY();    
    newTouchTarget = addTouchTarget(child, idBitsToAssign);    
    alreadyDispatchedToNewTouchTarget = true;    
    break;
}

最终会调用 addTouchTarget 将当前 View 加入到一个 View 调用链中,并将 mFirstTouchTarget 赋值;

ini 复制代码
private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {    
    final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);    
    target.next = mFirstTouchTarget;    
    mFirstTouchTarget = target;    
    return target;
}

经过 addTouchTarget 之后,target.next = null;mFirstTouchTarget = target;mTouchTarget = target;

也就是 mFirstTouchTarget == mTouchTarget != null;循环终止后,接着进入下面不拦截的流程,也就是 else 流程

ini 复制代码
if (mFirstTouchTarget == null) {    
    // No touch targets so treat this as an ordinary view.    
    handled = dispatchTransformedTouchEvent(ev, canceled, null,            
            TouchTarget.ALL_POINTER_IDS);
} else {    
    // Dispatch to touch targets, excluding the new touch target if we already    
    // dispatched to it.  Cancel touch targets if necessary.    
    TouchTarget predecessor = null;    
    TouchTarget target = mFirstTouchTarget;    
    while (target != null) {        
        final TouchTarget next = target.next;        
        if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {            
            handled = true;        
        } else {            
            final boolean cancelChild = resetCancelNextUpFlag(target.child)                    
                    || intercepted;            
            if (dispatchTransformedTouchEvent(ev, cancelChild,                    
                    target.child, target.pointerIdBits)) {                
                handled = true;            
            }            
            if (cancelChild) {                
                if (predecessor == null) {                    
                    mFirstTouchTarget = next;                
                } else {                    
                    predecessor.next = next;                
                }                
                target.recycle();                
                target = next;                
                continue;            
            }        
        }        
        predecessor = target;        
        target = next;    
    }
}

target != null 由 target.next = null 并且 final TouchTarget next = target.next 可以看到,while 循环只会执行一次;

又因为 alreadyDispatchedToNewTouchTarget && target == newTouchTarget 成立,所以 handled = true;

else 的逻辑,是针对的多点触控的逻辑,不在本次分析范围内,感兴趣的可以自己看下;

如果所有的子 View 都不处理,则相当于是自身进行了拦截,就会进入拦截处理的逻辑;

ini 复制代码
if (mFirstTouchTarget == null) {    
    // No touch targets so treat this as an ordinary view.    
    handled = dispatchTransformedTouchEvent(ev, canceled, null,            
        TouchTarget.ALL_POINTER_IDS);
}

ACTION_MOVE 事件分发流程

Move 事件也分拦截和不拦截的情况;

我们先看不拦截的情况,来看下 MOVE 事件是怎么分发的,同样,我们进入 dispatchTouchEvent 方法看下:

其实可以看到,两个 if 都进行了 DOWN 的判断,所以 MOVE 事件不会进入到这里;

所以,逻辑直接就进入到了 else 的 while 循环里面,最终进入的就是 dispatchTransformedTouchEvent

可以看到,MOVE 事件,不会进行子 View 的问询操作,而是直接分发到目标子 View;

我们来看下拦截的情况,MOVE 事件冲突处理;

事件冲突处理其实可以分为 内部拦截法 和 外部拦截法;

内部拦截法,子 View 处理滑动事件;外部拦截法,父 View 处理滑动事件;

内部拦截法

子 View 处理滑动事件的时候,需要让父 View 不进行事件的拦截,而父 View 在进行拦截之前会有一个判断:

ini 复制代码
if (actionMasked == MotionEvent.ACTION_DOWN        
        || mFirstTouchTarget != null) {    
        final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;    
        if (!disallowIntercept) {        
            intercepted = onInterceptTouchEvent(ev);        
            ev.setAction(action); 
            // restore action in case it was changed    
        } else {        
            intercepted = false;    
        }
}

也就是说,如果我们让 disallowIntercept = true 的话,那么,父 View 就不会拦截子 View 的事件,而设置这个的值,是通过 requestDisallowInterceptTouchEvent 来实现的;

typescript 复制代码
public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {    
    if (disallowIntercept == ((mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0)) {        
        // We're already in this state, assume our ancestors are too        
        return;    
    }    
    if (disallowIntercept) {        
        mGroupFlags |= FLAG_DISALLOW_INTERCEPT;    
    } else {        
        mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;    
    }    
    // Pass it up to our parent    
    if (mParent != null) {        
        mParent.requestDisallowInterceptTouchEvent(disallowIntercept);    
    }
}

也就是说内部拦截法中,子 View 中在 DOWN 事件的时候调用 getParent().requestDisallowInterceptTouchEvent(true),请求父 View 不拦截事件;在 MOVE 事件的时候根据滑动方向,如果需要父 View 处理,则调用 getParent().requestDisallowInterceptTouchEvent(false),请求父 View 拦截事件;

举个例子,我们用 ViewPager 来嵌套一个 RecyclerView 来看下效果,也就是我们最终的代码实现如下:

ViewPager 实现如下:

less 复制代码
public class BadViewPager extends ViewPager {

    private int mLastX, mLastY;

    public BadViewPager(@NonNull Context context) {
        super(context);
    }

    public BadViewPager(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    // 外部拦截法:父容器处理冲突
    // 我想要把事件分发给谁就分发给谁
    // ViewPager 进行事件的拦截
    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
       return true;
    }
}

RecyclerView 实现如下:

java 复制代码
public class MyRecyclerView extends RecyclerView {

    public RecyclerView(Context context) {        super(context);
    }

    public RecyclerView(Context context, AttributeSet attrs) {        super(context, attrs);
    }

    // 内部拦截法:子view处理事件冲突
   private int mLastX, mLastY;

   @Override
   public boolean dispatchTouchEvent(MotionEvent event) {
       int x = (int) event.getX();
       int y = (int) event.getY();

       switch (event.getAction()) {
           case MotionEvent.ACTION_DOWN: {
               // 在 DOWN 的时候,要求 ViewPager 不拦截事件
               getParent().requestDisallowInterceptTouchEvent(true);
               break;
           }
           case MotionEvent.ACTION_MOVE: {
               int deltaX = x - mLastX;
               int deltaY = y - mLastY;
               if (Math.abs(deltaX) > Math.abs(deltaY)) {
                   getParent().requestDisallowInterceptTouchEvent(false);
               }
               break;
           }
           case MotionEvent.ACTION_UP: {
               break;
           }
           default:
               break;
       }

       mLastX = x;
       mLastY = y;
       return super.dispatchTouchEvent(event);
   }
}

我们运行看下效果:

可以看到,并没有达到我们期望的效果,RecyclerView 并不能上下滑动,只能 ViewPager 的左右滑动,事件还是冲突了,那么问题出在哪里呢?我们接着分析 dispatchTouchEvent 源码;

我们发现,在源码中,有这么一段逻辑:

scss 复制代码
// Handle an initial down.i
if (actionMasked == MotionEvent.ACTION_DOWN) {    
    // Throw away all previous state when starting a new touch gesture.    
    // The framework may have dropped the up or cancel event for the previous gesture    
    // due to an app switch, ANR, or some other state change.    
    cancelAndClearTouchTargets(ev);    
    resetTouchState();
}

如果是 DOWN 事件,会把所有的参数状态进行重置,包括了 mGroupFlags 也就是说,我们在 RecyclerView 中设置的 mGroupFlags 并没有生效,在 DWON 的时候被重置了;

也就是说,DOWN 事件中

ini 复制代码
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;

disallowIntercept 一定是 false;

ini 复制代码
if (!disallowIntercept) {    
    intercepted = onInterceptTouchEvent(ev);    
    ev.setAction(action); // restore action in case it was changed
}

这段逻辑,一定走了进来,也就是 intercepted 是 true,事件进行了拦截,因为我们前面把 ViewPager 的 onInterceptTouchEvent 方法返回了 true;根据前面的分析,ViewPager 把 DOWN 事件进行了拦截,也就不会分发给子 View(RecyclerView)了,所以,RecyclerView 就能竖向滑动了,如果 RecyclerView 想拿到竖向滑动的事件,那么父 View(ViewPager)对 DWON 事件不能进行拦截,修改如下:

less 复制代码
public class BadViewPager extends ViewPager {

    private int mLastX, mLastY;

    public BadViewPager(@NonNull Context context) {
        super(context);
    }

    public BadViewPager(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    // 外部拦截法:父容器处理冲突
    // 我想要把事件分发给谁就分发给谁
    // ViewPager 进行事件的拦截
    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
       // 对 DOWN 事件不进行拦截,交给子 View(RecyclerView) 处理;
       if(event.getAction() == MotionEvent.ACTION_DOWN) {
           super.onInterceptTouchEvent(event);
           return false; 
       }
       return true;
    }
}

ViewPager 不拦截 MOVE 事件,当 RecyclerView 拿到 MOVE 事件的时候,进行左右滑动的时候,要求父 View(ViewPager)拦截事件自己处理,通过 getParent().requestDisallowInterceptTouchEvent(false);

ViewPager 拦截了 MOVE 事件,intercepted = true,我们看下 ViewPager 怎么处理 MOVE 事件:

ViewPager 执行到了 else 的逻辑中,因为 intercepted = true,所以 cancelChild 也等于 true;

diapatchTransformedTouchEvent 中 cancelChild 传入的就是 true,我们进入这个方法看下:

可以看到,子 View (RecyclerView)的 MOVE 事件被替换成取消了,然后调用子 View (RecyclerView)的 dispatchTouchEvent 方法进行事件的处理,RecyclerView 最终调用的是 return super.dispatchTouchEvent(),所以 diapatchTransformedTouchEvent 最终返回的也是 true,所以 handled = true;

因为 cancelChild = true;所以 mFirstTouchTarget 被重置成了 null,前面有分析 next 其实空的原因;

那么当第二个 MOVE 事件进来的时候,父 View(ViewPager)直接进行拦截,intercepted = true;

接着进入了事件分发处理流程 dispatchTransformedTouchEvent 方法

接下来的 MOVE 事件,就被父 View(ViewPager)拦截并处理了;

我们运行看下效果:

我们看到,可以上下滑动了,RecyclerView 拿到了 MOVE 事件;

外部拦截法

外部拦截法就比较简单了,判断父 View(ViewPager)的滑动方向和触摸事件,进行处理即可,子 View(RecyclerView)不用做任何处理;

java 复制代码
public class BadViewPager extends ViewPager {    
    private int mLastX, mLastY;    
    public BadViewPager(@NonNull Context context) {        
        super(context);    
    }    
    public BadViewPager(@NonNull Context context, @Nullable AttributeSet attrs) {        
        super(context, attrs);    
    }    
    // 外部拦截法:父容器处理冲突    
    // 我想要把事件分发给谁就分发给谁    
    @Override    
    public boolean onInterceptTouchEvent(MotionEvent event) {                
        int x = (int) event.getX();        
        int y = (int) event.getY();        
        switch (event.getAction()) {            
            case MotionEvent.ACTION_DOWN: {                
                mLastX = (int) event.getX();                
                mLastY = (int) event.getY();                
            break;            
        }            
        case MotionEvent.ACTION_MOVE: {                
            int deltaX = x - mLastX;                
            int deltaY = y - mLastY;
            // 水平滑动的时候,自己处理,垂直滑动的时候,不处理                
            if (Math.abs(deltaX) > Math.abs(deltaY)) {                    
                return true;                
            }                
            break;            
        }            
        case MotionEvent.ACTION_UP: {                
            break;            
        }            
        default:                
            break;        
        }        
        return super.onInterceptTouchEvent(event);    
    }
}

简历润色

简历上可写:深度理解事件分发的原理,可解决复杂滑动冲突;

下一章预告

自定义 ViewPager 炫酷指示器~

欢迎三连

来都来了,点个关注,点个赞吧,你的支持是我最大的动力~~~感谢!

相关推荐
纠结哥_Shrek32 分钟前
Java 有很多常用的库
java·开发语言
爱是小小的癌1 小时前
Java-数据结构-优先级队列(堆)
java·前端·数据结构
天乐敲代码1 小时前
JAVASE入门十五脚-网络TCP,UDP,,Lambda
java
爱写代码的山山2 小时前
虚幻UE5手机安卓Android Studio开发设置2025
android·ue5·虚幻
2501_903238652 小时前
自定义登录页面的Spring Security实践
java·后端·spring·个人开发
飞翔的佩奇3 小时前
Java项目: 基于SpringBoot+mybatis+maven+mysql实现的图书管理系统(含源码+数据库+答辩PPT+毕业论文)
java·数据库·spring boot·mysql·spring·毕业设计·图书管理
dal118网工任子仪3 小时前
94,【2】buuctf web [安洵杯 2019]easy_serialize_php
android·前端·php
jerry6095 小时前
注解(Annotation)
java·数据库·sql
Future_yzx5 小时前
Java Web的发展史与SpringMVC入门学习(SpringMVC框架入门案例)
java·前端·学习
Kevin Coding6 小时前
Flutter使用Flavor实现切换环境和多渠道打包
android·flutter·ios