如何应对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 炫酷指示器~

欢迎三连

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

相关推荐
吾日三省吾码2 小时前
JVM 性能调优
java
Estar.Lee3 小时前
查手机号归属地免费API接口教程
android·网络·后端·网络协议·tcp/ip·oneapi
温辉_xh3 小时前
uiautomator案例
android
弗拉唐3 小时前
springBoot,mp,ssm整合案例
java·spring boot·mybatis
oi774 小时前
使用itextpdf进行pdf模版填充中文文本时部分字不显示问题
java·服务器
工业甲酰苯胺4 小时前
MySQL 主从复制之多线程复制
android·mysql·adb
少说多做3434 小时前
Android 不同情况下使用 runOnUiThread
android·java
知兀4 小时前
Java的方法、基本和引用数据类型
java·笔记·黑马程序员
蓝黑20205 小时前
IntelliJ IDEA常用快捷键
java·ide·intellij-idea