主頁 > 移動端開發 > 拇指記者深入Android公司,打探事件分發機制背后的秘密

拇指記者深入Android公司,打探事件分發機制背后的秘密

2021-04-14 06:40:04 移動端開發

前言

聊到事件分發,很多朋友就會想到view的dispatchTouchEvent,其實在此之前,Android還做了很多作業,

比如跨行程獲取輸入事件的方式?在dispatchTouchEvent責任鏈之前還有一條InputStage責任鏈?DecorView,PhoneWindow之間的傳遞順序?

另外還包括事件分發程序中事件序列的處理方式?ViewGroup和View之間的協調?mFirstTouchTarget真偽鏈表?等等,

這一切,都要從你可愛的小拇指說起...

當你的拇指觸碰手機的那一剎那,手機就被你深深的影響了,沒錯,手機會收到你給他布置的任務,

這個任務可以是:

  • 滑動界面任務
  • 點擊按鈕任務
  • 長按任務

等等,總之,你向手機傳遞了這個任務資訊,接下來就是手機的處理任務時間,

我們可以假設手機系統就是一個大的公司(Android公司),而我們觸摸手機的任務就是一個完整的專案需求,今天就和大家一起深入Android公司內部,打探事件分發的那些秘密,

在此之前,我也列出了問題和大綱:

2.png

硬體部門和內核部門

首先,我的拇指找到了Android公司,說出了自己的需求,比如:點擊某個View并滑動到另外的位置,

Android公司會派出硬體部門,和我的小拇指進行會談,接收到我的需求之后,硬體部門生成簡單的終端,并傳遞給內核部門,

內核部門將任務進行加工,生成了內部事件——event,并添加到公司內部的一個管理系統 /dev/input/目錄下,

這樣做的目的是把外來的需求轉化成內部通用,都能看懂的任務,

任務處理部門(SystemServer行程)

當任務記錄在公司管理系統上,就會有專門的任務處理部門對這些任務進行處理,他們做的事情就是一直監聽/dev/input/目錄,當發現有新的事件就會進行處理,

那這個任務處理部門到底是何方神圣呢?

不知道大家還記不記得在SystemServer行程中啟動了一系列系統有關的服務,比如AMS,PMS等等,其中還有一個不是很起眼的角色,叫做InputManagerService

這個服務就是用來負責與硬體通信,接受螢屏輸入事件,

在其內部,會啟動一個讀執行緒,也就是InputReader,它會從這個管理系統也就是/dev/input/目錄拿到任務,并且分發給InputDispatcher執行緒,然后進行統一的事件分發調度,

分配給具體的專案組(InputChannel)

然后任務處理部門需要把任務交給 專業處理任務的專案組了,這就涉及到跨部門溝通了(跨行程通信),

大家都知道跨部門溝通是個比較麻煩的事情,誰來完成這個事情呢?InputChannel

讓我們回到ViewRootImplsetView方法:

public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView) {
    synchronized (this) {
      //創建InputChannel
      mInputChannel = new InputChannel();
      //通過Binder進入systemserver行程
      res = mWindowSession.addToDisplay(mWindow, mSeq, mWindowAttributes,
                  getHostVisibility(), mDisplay.getDisplayId(),
                  mAttachInfo.mContentInsets, mAttachInfo.mStableInsets,
                  mAttachInfo.mOutsets, mInputChannel);
    }
}

在該方法中,創建了一個InputChannel物件,并且通過Binder進入systemserver行程,最終形成socket的客戶端,

這里涉及到socket通信的知識,比較重要的就是c層的socketpair方法,

socketpair()函式用于創建一對無名的、相互連接的套接子,如果函式成功,則回傳0,創建好的套接字分別是sv[0]和sv[1];這對套接字可以用于全雙工通信,每一個套接字既可以讀也可以寫,

通過這個方法,就生成了socket通信的客戶端和服務端:

  • socket服務端保存到system_server中的WindowState的mInputChannel;
  • socket客戶端通過binder傳回到遠程行程的UI主執行緒ViewRootImpl的mInputChannel;

感興趣的可以看看gityuan對于input分析的博客,文末有鏈接,

所以小結一下就是,在App行程創建了一個物件InputChannel,通過Binder機制傳入了SystemServer行程,也就是WindowManagerService中,然后在WindowManagerService中創建了一對套接字用于行程間通信,而傳過來的InputChannel就指向了socket的客戶端,

然后App行程的主執行緒就會監聽這個socket客戶端,當收到訊息(輸出事件)后,回呼NativeInputEventReceiver.handleEvent()方法,最侄訓走到InputEventReceiver.dispachInputEvent方法,

dispachInputEvent,處理輸入事件,感覺離我們熟知的事件分發比較近了,

沒錯,到此,任務已經分配到了具體的專案組,也就是我們所使用的具體APP中了,

小組中任務第一次分發(InputStage)

當任務到達了專案組,首先組內會對這個任務進行分發,這里會涉及到第一次責任鏈分發模式

為什么強調是第一次呢?因為還沒有到達我們熟知的view事件分發階段,在此之前,還會有一次事件分類的責任鏈分發作業,也就是InputStage處理事件分發,

//InputEventReceiver.java
private void dispatchInputEvent(int seq, InputEvent event) {
    mSeqMap.put(event.getSequenceNumber(), seq);
    onInputEvent(event); 
}

//ViewRootImpl.java ::WindowInputEventReceiver
final class WindowInputEventReceiver extends InputEventReceiver {
    public void onInputEvent(InputEvent event) {
       enqueueInputEvent(event, this, 0, true); 
    }
}

//ViewRootImpl.java
void enqueueInputEvent(InputEvent event,
        InputEventReceiver receiver, int flags, boolean processImmediately) {
    adjustInputEventForCompatibility(event);
    QueuedInputEvent q = obtainQueuedInputEvent(event, receiver, flags);

    QueuedInputEvent last = mPendingInputEventTail;
    if (last == null) {
        mPendingInputEventHead = q;
        mPendingInputEventTail = q;
    } else {
        last.mNext = q;
        mPendingInputEventTail = q;
    }
    mPendingInputEventCount += 1;

    if (processImmediately) {
        doProcessInputEvents(); 
    } else {
        scheduleProcessInputEvents();
    }
}

兜兜轉轉,沒想到還是到了ViewRootImpl這里,所以ViewRootImpl不僅負責了界面的繪制,也負責了事件分發的部分處理作業,

這里的enqueueInputEvent方法中,有涉及到一個QueuedInputEvent類,這個類就是一個封裝了InputEvent的事件類,然后經過賦值呼叫到doProcessInputEvents方法:

   void doProcessInputEvents() {
        // Deliver all pending input events in the queue.
        while (mPendingInputEventHead != null) {
            QueuedInputEvent q = mPendingInputEventHead;
            mPendingInputEventHead = q.mNext;
            deliverInputEvent(q);
        }
    }

    private void deliverInputEvent(QueuedInputEvent q) {
        InputStage stage;
        if (stage != null) {
            stage.deliver(q);
        } else {
            finishInputEvent(q);
        }
    }

    abstract class InputStage {
        private final InputStage mNext;

        public InputStage(InputStage next) {
            mNext = next;
        }

        public final void deliver(QueuedInputEvent q) {
            apply(q, onProcess(q));
        }

到這里邏輯好像慢慢清晰了,QueuedInputEvent是一種輸入事件,InputStage是處理輸入事件的責任鏈,next欄位則表示責任鏈的下一個InputStage

InputStage到底干了哪些事情呢?回傳到ViewRootImpl的setView方法再看看:

	public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView) {
        synchronized (this) {
		// Set up the input pipeline.
        mSyntheticInputStage = new SyntheticInputStage();
        InputStage viewPostImeStage = new ViewPostImeInputStage(mSyntheticInputStage);
        InputStage nativePostImeStage = new NativePostImeInputStage(viewPostImeStage,
                 "aq:native-post-ime:" + counterSuffix);
        InputStage earlyPostImeStage = new EarlyPostImeInputStage(nativePostImeStage);
        InputStage imeStage = new ImeInputStage(earlyPostImeStage,
                "aq:ime:" + counterSuffix);
        InputStage viewPreImeStage = new ViewPreImeInputStage(imeStage);
        InputStage nativePreImeStage = new NativePreImeInputStage(viewPreImeStage,
                        "aq:native-pre-ime:" + counterSuffix);

        mFirstInputStage = nativePreImeStage;
        mFirstPostImeInputStage = earlyPostImeStage;
         }
    }

可以看到在setView方法中,就把這條輸入事件處理的責任鏈拼接完成了,不同的InputStage子類,通過構造方法一個個串聯起來了,那這些InputStage到底干了啥呢?

  • SyntheticInputStage,綜合處理事件階段,比如處理導航面板、操作桿等事件,
  • ViewPostImeInputStage,視圖輸入處理階段,比如按鍵、手指觸摸等運動事件,我們熟知的view事件分發就發生在這個階段,
  • NativePostImeInputStage,本地方法處理階段,主要構建了可延遲的佇列,
  • EarlyPostImeInputStage,輸入法早期處理階段,
  • ImeInputStage,輸入法事件處理階段,處理輸入法字符,
  • ViewPreImeInputStage,視圖預處理輸入法事件階段,呼叫視圖view的dispatchKeyEventPreIme方法,
  • NativePreImeInputStage,本地方法預處理輸入法事件階段,

小結一下,事件到達應用端的主執行緒,會通過ViewRootImpl進行一系列InputStage來處理事件,這個階段其實是對事件進行一些簡單的分類處理,比如視圖輸入事件,輸入法事件,導航面板事件等等,

事件分發完成后,會告知SystemServer行程的InputDispatcher執行緒,最終將該事件移除,完成此次事件的分發消費,

我們的view手指觸摸事件就是發生在ViewPostImeInputStage階段了,具體來看看:

    final class ViewPostImeInputStage extends InputStage {
        @Override
        protected int onProcess(QueuedInputEvent q) {
            if (q.mEvent instanceof KeyEvent) {
                return processKeyEvent(q);
            } else {
                final int source = q.mEvent.getSource();
                if ((source & InputDevice.SOURCE_CLASS_POINTER) != 0) {
                    return processPointerEvent(q);
                } 
            }
        }
    
    private int processPointerEvent(QueuedInputEvent q) {
            final MotionEvent event = (MotionEvent)q.mEvent;
            boolean handled = mView.dispatchPointerEvent(event)
            return handled ? FINISH_HANDLED : FORWARD;
        }

//View.java
    public final boolean dispatchPointerEvent(MotionEvent event) {
            if (event.isTouchEvent()) {
                return dispatchTouchEvent(event);
            } else {
                return dispatchGenericMotionEvent(event);
        }
    }

經過一系列分發,最侄訓執行到mView的dispatchTouchEvent方法,而這個mView就是DecorView,同樣是在setView中進行賦值的,就不細說了,

至此,終于到了我們熟悉的環節,dispatchTouchEvent方法,

大佬之間的任務整理(DecorView)

確定了任務的分類,接下來就開始組內任務討論整理了,這個階段發生在幾個大佬之間的談話,這幾個大佬分別是DecorView、PhoneWindow、Activity/Dialog

//DecorView.java
    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        //cb其實就是對應的Activity/Dialog
        final Window.Callback cb = mWindow.getCallback();
        return cb != null && !mWindow.isDestroyed() && mFeatureId < 0
                ? cb.dispatchTouchEvent(ev) : super.dispatchTouchEvent(ev);
    }


//Activity.java
    public boolean dispatchTouchEvent(MotionEvent ev) {
        if (ev.getAction() == MotionEvent.ACTION_DOWN) {
            onUserInteraction();
        }
        if (getWindow().superDispatchTouchEvent(ev)) {
            return true;
        }
        return onTouchEvent(ev);
    }

//PhoneWindow.java
    @Override
    public boolean superDispatchTouchEvent(MotionEvent event) {
        return mDecor.superDispatchTouchEvent(event);
    }

//DecorView.java
    public boolean superDispatchTouchEvent(MotionEvent event) {
        return super.dispatchTouchEvent(event);
    }    

可以看到,從DecorView開始,事件依次經過了Activity、PhoneWindow、DecorView

有點奇怪哈,為啥是這樣一個順序呢?而不是直接ViewRootImpl交給Activity,再交給頂層View——DecorView?而是轉來轉去,緣起和從呢?

  • 首先,為什么ViewRootImpl不直接把事件交給Activity?

因為界面上不止Activity一種形態呀,如果界面上存在Dialog,而Dialog的Window屬于子Window,是可以覆寫應用級Window的,所以總不能把事件直接交給Activity吧?都被覆寫了,所以這時候應該把事件交給Dialog,

為了方便,我們用到了DecorView這個角色來充當分發的第一元素,由他來找到當前界面window的所持著,所以代碼中也是找到mWindow.getCallback(),其實也就是對應的Activity或者Dialog,

  • 其次,交給Acitivity后,為什么不直接交給頂層View——DecorView開始分發事件呢?

因為ActivityDecorView之間并沒有直接關系,DecorView怎么來的?通過setContentView被創建出來的,所以在Activity中是看不到DecorView身影的,DecorView的實體保存在PhoneWindow中,由Window所管理,

所以Activity的事件肯定是交給Window來管理,之前也說過PhoneWindow的指責就是幫助Activity管理View,所以事件分發交給它也是它的職責所在,而PhoneWindow的處理方式,就是交給頂層的DecorView來處理了,

這樣,一個事件分發的鏈條就形成了:

DecorView——>Activity——>PhoneWindow——>DecorView——>ViewGroup

交給做任務具體的人(ViewGroup)

接下來就開始分派任務了,也就是ViewGroup的事件分發時間,這部分內容是老生常談了,最重要的就是這個dispatchTouchEvent方法,

假設我們沒有看過原始碼,那么事件來了,會產生多種傳遞攔截的可能,我畫了個腦圖:

1.png

其中產生的疑問就包括:

  • ViewGroup是否攔截事件,攔截后怎么處理?
  • 不攔截后交給子View或者子ViewGroup怎么處理?
  • 子View怎么決定是否攔截?
  • 子View攔截后怎么處理事件?
  • 子View不攔截事件后父元素ViewGroup怎么處理事件?
  • ViewGroup不攔截,子View也不攔截,最終事件怎么處理?

接下來就具體分析分析,

ViewGroup是否攔截事件,攔截后怎么處理?

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {

        //1
        final boolean intercepted;
        if (actionMasked == MotionEvent.ACTION_DOWN|| mFirstTouchTarget != null) {
            final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
            if (!disallowIntercept) {
                intercepted = onInterceptTouchEvent(ev);
            } 
        } 

        //2    
        if (!canceled && !intercepted) {
            //事件傳遞給子view
        }

        //3
        if (mFirstTouchTarget == null) {
            handled = dispatchTransformedTouchEvent(ev, canceled, null,
                        TouchTarget.ALL_POINTER_IDS);
        }
    }

    private boolean dispatchTransformedTouchEvent(View child) {
        if (child == null) {
            handled = super.dispatchTouchEvent(event);
        } else { 
            handled = child.dispatchTouchEvent(event);
        }
    }

上述代碼分成了三部分,分為ViewGroup是否攔截、攔截后則不再傳遞下去,ViewGroup攔截后的處理,

1、ViewGroup是否攔截

可以看到,初始化了一個變數intercepted,代表viewGroup是否攔截,

如果滿足兩個條件任意一個,才去討論ViewGroup是否攔截:

  • 事件為ACTION_DOWN,也就是按下事件,
  • mFirstTouchTarget不為null

其中mFirstTouchTarget是個鏈表結構,代表某個子元素成功消費了該事件,所以mFirstTouchTarget為null就代表沒有子view消費事件,這個待會再細談,
當第一次進入這個方法,事件肯定就是ACTION_DOWN,所以就進入了if陳述句,這時候獲取了一個叫做disallowIntercept(不允許攔截)的變數,暫且按下不表,接著看,
然后給這個intercepted賦值為onInterceptTouchEvent方法的結果,我們可以理解為 viewGroup是否攔截取決于onInterceptTouchEvent方法,

2、攔截后則不再傳遞

如果viewGroup攔截了,也就是intercepted為true,自然也就不需要再往子view或者子ViewGroup進行傳遞了,

3、ViewGroup攔截后的處理

如果mFirstTouchTarget為null,則表示沒有子View進行攔截,然后就轉向執行dispatchTransformedTouchEvent方法,代表ViewGroup要自己再進行一次分發處理,

這里有個問題就是為什么不直接判斷intercepted呢?非要去判斷這個mFirstTouchTarget

  • 因為mFirstTouchTarget==null不僅代表ViewGroup要自己消費事件,也代表了ViewGroup沒消費并且子View也沒有去消費事件,兩種情況都會執行到這里,

也就是ViewGroup攔截或子View沒有攔截,都會呼叫到dispatchTransformedTouchEvent方法,在該方法中,最后會呼叫super.dispatchTouchEvent

super代表ViewGroup的父類View,也就是ViewGroup會作為一個普通View執行View.dispatchTouchEvent方法,至于這個方法具體做了什么,待會和View的事件處理再一起看,

通過上面的分析,我們可以得出ViewGroup攔截的偽代碼:


public boolean dispatchTouchEvent(MotionEvent event) {
    boolean isConsume = false;
    if (isViewGroup) {
        if (onInterceptTouchEvent(event)) {
            isConsume = super.dispatchTouchEvent(event);
        } 
    } 
    return isConsume;
}

如果是ViewGroup,會先執行到onInterceptTouchEvent方法判斷是否攔截,如果攔截,則執行父類View的dispatchTouchEvent方法,

ViewGroup不攔截后交給子View或者子ViewGroup處理?

接著說ViewGroup不攔截的情況,也就會傳到子View的情況:

    if (!canceled && !intercepted) {
        if (actionMasked == MotionEvent.ACTION_DOWN
                || (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
                || actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
            final int childrenCount = mChildrenCount;

            //1
            if (newTouchTarget == null && childrenCount != 0) {
                for (int i = childrenCount - 1; i >= 0; i--) {
                    final int childIndex = getAndVerifyPreorderedIndex(
                            childrenCount, i, customOrder);
                    final View child = getAndVerifyPreorderedView(
                            preorderedList, children, childIndex);

                    //2
                    if (!child.canReceivePointerEvents()
                            || !isTransformedTouchPointInView(x, y, child, null)) {
                        ev.setTargetAccessibilityFocus(false);
                        continue;
                    }

                    //3
                    if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
                        newTouchTarget = addTouchTarget(child, idBitsToAssign);
                        alreadyDispatchedToNewTouchTarget = true;
                        break;
                    }
                }
            }
        }
    }

ViewGroup不攔截,則intercepted為false,那么就會進入上述的if陳述句中,

同樣分為三部分來說,分別是遍歷子View,判斷事件坐標,傳遞事件

1、遍歷子View

第一部分就是遍歷當前ViewGroup所有的子View,

2、判斷事件坐標

然后會判斷這個事件是否在當前子View的坐標內,如果用戶觸摸的地方都不是當前的View自然不需要對這個view在進行分發處理,還有個條件就是當前View沒有在影片狀態,

3、傳遞事件

如果事件坐標在這個View內,就開始傳遞事件,呼叫dispatchTransformedTouchEvent方法,如果為true,就呼叫addTouchTarget方法記錄事件消費鏈,

dispatchTransformedTouchEvent方法是不是有點熟悉?沒錯,剛才也出現過,再看一遍:

    private boolean dispatchTransformedTouchEvent(View child) {
        if (child == null) {
            handled = super.dispatchTouchEvent(event);
        } else { 
            handled = child.dispatchTouchEvent(event);
        }
    }

這里對傳進來的 child進行了判斷,這個child就是子View,如果子View不為null,就呼叫這個子View的dispatchTouchEvent方法,繼續分發事件,如果為null,就是剛才的情況,呼叫父類的dispatchTouchEvent方法,默認為自己來消費事件,

當然,這個child有可能為viewGroup有可能為View,總之就是繼續分發呼叫子View或者子ViewGroup的方法,

到此,一個關于dispatchTouchEvent的遞回就顯現出來了:
如果某個ViewGroup無法消費事件,那么就會傳遞給子view/子ViewGroup的dispatchTouchEvent方法,如果是ViewGroup,那么又會重復這個操作,直到某個View/ViewGroup消費事件,

最后,如果dispatchTransformedTouchEvent方法回傳true,就代表有子view消費了事件,然后會呼叫到addTouchTarget方法:

在該方法中,會對mFirstTouchTarget這個單鏈表進行了賦值,記錄消費鏈(但是在單點觸控的情況下,其單鏈表的結構并沒有用上,只是作為一個普通的TouchTarget物件,待會會說到),然后就break退出了回圈,

接下來就看看關于View內部具體處理事件的邏輯,

子View怎么處理事件,是否攔截?

public boolean dispatchTouchEvent(MotionEvent event) {
        
        if (onFilterTouchEventForSecurity(event)) {
            
            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;
            }
        }
        return result;
    }

其實就是兩個邏輯:

  • 1、如果View設定了setOnTouchListener并且onTouch方法回傳true,那么onTouchEvent就不會被執行,
  • 2、否則,執行onTouchEvent方法,

所以默認情況下是直接會執行onTouchEvent方法,

關于View的事件分發我們也可以寫一段偽代碼,并且增加了setOnClickListener方法的呼叫:

public void consumeEvent(MotionEvent event) {
    if (!setOnTouchListener || !onTouch) {
        onTouchEvent(event);
    } 

    if (setOnClickListener) {
        onClick();
    }
}

子View攔截后怎么處理事件?

子View攔截后,就會給單鏈表mFirstTouchTarget賦值,

這個剛才已經說過了,邏輯就在addTouchTarget方法中,我們來具體看看:

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

    public static TouchTarget obtain(@NonNull View child, int pointerIdBits) {
        final TouchTarget target;
        target.child = child;
        return target;
    }

這個單鏈表到底怎么連的呢?之前我們說過dispatchTouchEvent是一個遞回的程序,當某個子View消費了事件,那么通過addTouchTarget方法,就會讓mFirstTouchTarget的child值指向那個子View,依此向上,最后就會拼接成一個類似單鏈表結構,尾節點就是消費的那個View,

為什么說類似呢?因為mFirstTouchTarget并沒有真正連起來,而是通過每個ViewGroup的mFirstTouchTarget間接連起來,

打個比方,我們假設一個View樹關系:

    A
   / \
  B   C
    /  \
   D    E

A、B、C為ViewGroup,D、E為View,

當我們觸摸的點在ViewD中,事件分發的順序就是A-C—D

在C遍歷D的時候,ViewD消費了事件,所以走到了addTouchTarget方法中,包裝了一個包含ViewD的TouchTarget,我們叫它TargetD,

然后設定C的mFirstTouchTarget為TargetD,也就是其child值為ViewD,

再回傳上一層,也就是A層,因為D消費了事件,所以C的dispatchTouchEvent方法也回傳了true,同樣呼叫了addTouchTarget方法,包裝了一個TargetC,

然后會設定A的mFirstTouchTarget為TargetC,也就是其child值為ViewC,

最終的分發結構就是:

A.mFirstTouchTarget.child -> C

C.mFirstTouchTarget.child -> D

所以說mFirstTouchTarget通過child找到了消費鏈的下一層View,然后下一層又繼續通過child找到下下層View,依次往下,就記錄了消費的完整路徑,

mFirstTouchTarget的鏈表結構用到哪了呢?多點觸控,

對于多點觸控且點擊目標不同的情況,mFirstTouchTarget才會作為鏈表結構存在,next指向上一個手指按下時創建的TouchTarget物件,

而在單點觸控情況下,mFirstTouchTarget鏈表會蛻變成單個TouchTarget物件:

  • mFirstTouchTarget.next 始終為null,
  • mFirstTouchTarget.child 賦值為這條消費鏈的下一層View,一層層遞回呼叫每一層的mFirstTouchTarget.child,直到消費的那個view,

最后再補充一點,每次ACTION_DOWN事件來到的時候,mFirstTouchTarget就會被重置,迎接新的一輪事件序列,

子View不攔截事件后ViewGroup怎么處理事件?

子View不攔截事件,那么mFirstTouchTarget就為null,退出回圈后,呼叫了dispatchTransformedTouchEvent方法,

        //3
        if (mFirstTouchTarget == null) {
            handled = dispatchTransformedTouchEvent(ev, canceled, null,
                        TouchTarget.ALL_POINTER_IDS);
        }

最終呼叫了super.dispatchTouchEvent,也就是View.dispatchTouchEvent方法,

可以看到子View不攔截事件和ViewGroup攔截事件的處理是一樣的都會走到這個方法中,

那么這個方法到底干了什么呢?上面說到View的處理方法dispatchTouchEvent已經說過了,還是那段偽代碼,只不過在這里View是作為ViewGroup的父類,

所以,小結一下,如果所有子View都不處理事件,那么:

  • 默認執行ViewGrouponTouchEvent方法,
  • 如果設定ViewGroupsetOnTouchListener,就會執行onTouch方法,

ViewGroup不攔截,子View也不攔截,最終事件怎么處理?

最后一點,如果ViewGroup不攔截,子View也不攔截,這個意思就是mFirstTouchTarget == null 的同時,dispatchTransformedTouchEvent方法也回傳false,

總之,就是所有ViewGroup的dispatchTouchEvent方法都回傳false,這時候該怎么處理呢?回傳到一開始大佬會談的時候:

//Activity.java
    public boolean dispatchTouchEvent(MotionEvent ev) {
        if (ev.getAction() == MotionEvent.ACTION_DOWN) {
            onUserInteraction();
        }
        if (getWindow().superDispatchTouchEvent(ev)) {
            return true;
        }
        return onTouchEvent(ev);
    }

沒錯,如果superDispatchTouchEvent方法回傳false,那么就會執行Activity的onTouchEvent方法,

小結

小結一下:

  • 事件分發的本質就是一個遞回方法,通過往下傳遞,呼叫dispatchTouchEvent方法,找到事件的處理者,這也就是專案中常見的責任鏈模式

  • 在消費程序中,ViewGroup的處理方法就是onInterceptTouchEvent

  • 在消費程序中,View的處理方法就是onTouchEvent方法,

  • 如果底層View不消費,則一步步往上執行父元素的onTouchEvent方法,

  • 如果所有View的onTouchEvent方法都回傳false,則最后會執行到Activity的onTouchEvent方法,事件分發也就結束了,

完整事件消費偽代碼:

public boolean dispatchTouchEvent(MotionEvent event) {
    boolean isConsume = false;
    if (isViewGroup) {
        //ViewGroup
        if (onInterceptTouchEvent(event)) {
            isConsume = consumeEvent(event);
        } else {
            isConsume = child.dispatchTouchEvent(event);
        }
    } else {
        //View
        isConsume = consumeEvent(event);
    }

    if (!isConsume) {
        //如果自己沒攔截,子View沒有消費,自己也要呼叫消費方法
        isConsume = consumeEvent(event);
    }
    return isConsume;
}


public void consumeEvent(MotionEvent event) {
    //自己消費事件的邏輯,默認會呼叫到onTouchEvent
    if (!setOnTouchListener || !onTouch) {
        onTouchEvent(event);
    } 
}

dispatchTouchEvent() + onInterceptTouchEvent() + onTouchEvent(),大家也可以把這三個方法作為理解記憶事件分發的重點,

后續任務處理(事件序列)

終于,任務找到了它的主人,看似流程也結束了,但是還存在一個問題就是,這個任務之后的后續任務該怎么處理呢?比如要增加某某模塊功能,

不可能再走一遍公司流程吧?如果按照正常邏輯,是應該找到當初負責我們任務的那個人來繼續處理,看看Android公司是不是這么做的,

一個MotionEvent事件序列一般包括:

ACTION_DOWN、ACTION_MOVE、ACTION_UP、ACTION_CANCEL

剛才我們都說的是ACTION_DOWN,也就是手機按下的事件處理,那么后續的移動手機,離開螢屏事件該怎么處理呢?

假設之前已經有一個ACTION_DOWN并且被某個子View消費了,所以mFirstTouchTarget會有一條完整的指向,這時候來了第二個事件——ACTION_MOVE

    if (!canceled && !intercepted) {
       if (actionMasked == MotionEvent.ACTION_DOWN
                        || (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
                        || actionMasked == MotionEvent.ACTION_HOVER_MOVE) {          
    }

然后就會發現,ACTION_MOVE事件根本進不去對子View的回圈方法,而是直接到了最后面的邏輯:

    if (mFirstTouchTarget == null) {
        handled = dispatchTransformedTouchEvent(ev, canceled, null,
                TouchTarget.ALL_POINTER_IDS);
    } else {
        TouchTarget target = mFirstTouchTarget;
        while (target != null) {
            final TouchTarget next = target.next;
            if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
                handled = true;
            } else {
                if (dispatchTransformedTouchEvent(ev, cancelChild,
                    target.child, target.pointerIdBits)) {
                       handled = true;
                }
            }
            predecessor = target;
            target = next;
        }
    }

如果mFirstTouchTarget為null,就是之前說過的轉到ViewGroup自身的onTouchEvent方法,

這里很明顯不為null,所以走到else中,又開始遍歷mFirstTouchTarget,之前說過單點觸控的時候,target.next為null,target.child為消費鏈的下一層View,所以其實就是將事件交給了下一層View,

這里有個點很多朋友可能之前沒注意到,就是當ACTION_DOWN的時候,走到這里,會通過mFirstTouchTarget找到那個消費的View執行dispatchTransformedTouchEvent
但是這之前,遍歷View的時候已經執行了一次dispatchTransformedTouchEvent方法,難道這里還要執行一次dispatchTransformedTouchEvent方法嗎?
這不就重復了?

  • 這就涉及到另一個變數alreadyDispatchedToNewTouchTarget,這個變數代表之前是否已經執行過一次View消費事件,當事件為ACTION_DOWN,就會遍歷View,如果view消費了事件,那么alreadyDispatchedToNewTouchTarget就被賦值為true,所以到這里也就不會再次執行了,直接handled = true

所以后續任務的處理邏輯也基本明白了:

只要某個View開始處理攔截事件,那么這一整個事件序列都只能交給它來處理,

優化任務派發流程(解決滑動沖突)

到此,任務終于是分發完成了,任務完成后,小組開了一個總結會議

其實任務分發程序還是有可以優化的程序,比如有些任務是不一定就只交給一個人做,比如交給兩個人做,把A擅長的任務給A做,B擅長的任務給B做,最大化利用好每個人,

但是我們之前的邏輯默認是按下任務交給了A,后續都會交給A,所以這時候就需要設計一種機制對某些任務進行攔截,

其實這就涉及到滑動沖突的問題了,舉例一個場景:

外面的ViewGroup是橫向移動,而內部的ViewGroup是需要縱向移動的,所以需要在ACTION_MOVE的時候對事件進行判斷和攔截,(類似ViewGroup+Fragment+Recyclerview)

直接說Android公司的解決方案,兩種方案:

  • 外部攔截法,
  • 內部攔截法,

外部攔截法

外部攔截法比較簡單,因為不管子View是否攔截,每次都會執行onInterceptTouchEvnet方法,所以我們就可以在這個方法中,根據自己的業務條件選擇是否攔截事件,

    //外部攔截法:父view.java      
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercepted = false;
        //父view攔截條件
        boolean parentCanIntercept;

        switch (ev.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
                intercepted = false;
                break;
            case MotionEvent.ACTION_MOVE:
                if (parentCanIntercept) {
                    intercepted = true;
                } else {
                    intercepted = false;
                }
                break;
            case MotionEvent.ACTION_UP:
                intercepted = false;
                break;
        }
        return intercepted;

    }

邏輯很簡單,就是根據業務條件,在onInterceptTouchEvent中決定是否攔截,因為這種方法是在父View中控制是否攔截,所以這種方法叫做外部攔截法,

但是這和我們之前的認知又沖突了,如果ACTION_DOWN交給了子View處理,那么后續事件應該會直接被分發給這個view呀,為什么還能被父View攔截的?

我們再來看看dispatchTouchEvent方法:

    public boolean dispatchTouchEvent(MotionEvent ev) {
        final boolean intercepted;
        if (actionMasked == MotionEvent.ACTION_DOWN|| mFirstTouchTarget != null) {
            intercepted = onInterceptTouchEvent(ev);
        } 

        // Dispatch to touch targets.
        if (mFirstTouchTarget == null) {
            handled = dispatchTransformedTouchEvent(ev, canceled, null,
                        TouchTarget.ALL_POINTER_IDS);
        } else {
            while (target != null) {
                if (alreadyDispatchedToNewTouchTarget && target == newTouchTarget) {
                    handled = true;
                } else {
                    final boolean cancelChild = resetCancelNextUpFlag(target.child)
                            || intercepted;
                    if (dispatchTransformedTouchEvent(ev, cancelChild,
                        target.child, target.pointerIdBits)) {
                        handled = true;
                    }
                }
            }
        }
    }

當事件為ACTION_MOVE的時候,并且在onInterceptTouchEvent方法回傳了true,所以這里的intercepted=true,再到下面的邏輯,cancelChild的值也為true,然后被傳到了dispatchTransformedTouchEvent方法,沒錯,又是這個方法,不同的是cancelChild子段為true,

看這個欄位的名字肯定是和取消子view事件有關的,繼續看看:

    private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
            View child, int desiredPointerIdBits) {
        final boolean handled;
        if (cancel || oldAction == MotionEvent.ACTION_CANCEL) {
            event.setAction(MotionEvent.ACTION_CANCEL);
            if (child == null) {
                handled = super.dispatchTouchEvent(event);
            } else {
                handled = child.dispatchTouchEvent(event);
            }
            event.setAction(oldAction);
            return handled;
        }
    }

看出來了么,當第二個欄位cancel為true的時候,事件會被修改成ACTION_CANCEL!!,然后才會被繼續傳下去,

所以就算某個View消費了ACTION_DOWN,但是當后續事件來的同時,在父元素的onInterceptTouchEvent()中回傳true,那么這個事件就會被修改為ACTION_CACLE事件再傳給子View,

所以子View再次交出了對該事件序列的控制權,這也就是外部攔截法能實作的原因,

內部攔截法

繼續看看內部攔截法:

    //父view.java            
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) {
            return false;
        } else {
            return true;
        }
    }

    //子view.java
    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        //父view攔截條件
        boolean parentCanIntercept;

        switch (event.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
                getParent().requestDisallowInterceptTouchEvent(true);
                break;
            case MotionEvent.ACTION_MOVE:
                if (parentCanIntercept) {
                    getParent().requestDisallowInterceptTouchEvent(false);
                }
                break;
            case MotionEvent.ACTION_UP:
                break;
        }
        return super.dispatchTouchEvent(event);
    }

內部攔截法是將主動權交給子View,如果子View需要事件就直接消耗,否則交給父容器處理,我們列舉下DOWN和MOVE兩種情況:

  • ACTION_DOWN的時候,子View必須能消費,所以父View的onInterceptTouchEvent要回傳false,否則就被父View攔截了,而且后續事件也不會傳到子View這里了,
  • ACTION_MOVE的時候,父View的onInterceptTouchEvent方法要回傳true,表示當子View不想消費的時候,父View能及時消費,那么子View怎么控制呢?可以看到代碼設定了一個requestDisallowInterceptTouchEvent方法,這個是干嘛呢?
    protected static final int FLAG_DISALLOW_INTERCEPT = 0x80000;
    @Override
    public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
        if (disallowIntercept) {
            mGroupFlags |= FLAG_DISALLOW_INTERCEPT;
        } else {
            mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
        }
    }

這種通過|=&= ~ 運算子修改引數是原始碼中常見的設定標識的方法:

  • |= 將標志位設定為1
  • &= ~ 將標識位設定為0

所以在需要父元素攔截的時候就設定了requestDisallowInterceptTouchEvent(false)方法,讓標志位設定為0,這樣父元素就能執行到onInterceptTouchEvent方法,

具體生效代碼就在dispatchTouchEvent方法中:

    if (actionMasked == MotionEvent.ACTION_DOWN) {
        cancelAndClearTouchTargets(ev);
        resetTouchState();
    }

    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;
        }
    }

可以看到,如果disallowIntercept為false,就代表父View要攔截,然后就會執行到onInterceptTouchEvent方法,在onInterceptTouchEvent方法中回傳ture,父View成功攔截,

總結

經過拇指記者的探訪,終于把Android公司對于事件任務處理摸清楚了,希望對于螢屏前的你能有些幫助,下期再見啦,

參考

《Android開發藝術探索》
每日一問 | 事件到底是先到DecorView還是先到Window的?
Input系統—事件處理全程序
反思|Android 事件分發機制的設計與實作
View·InputEvent事件投遞原始碼分析
徹底掌握 Android touch 事件分發時序

拜拜

感謝大家的閱讀,有一起學習的小伙伴可以關注下我的公眾號——碼上積木????
每日一個知識點,積少成多,建立知識體系架構,
這里有一群很好的Android小伙伴,歡迎大家加入~

轉載請註明出處,本文鏈接:https://www.uj5u.com/yidong/275726.html

標籤:Android

上一篇:C++學習

下一篇:拇指記者深入Android公司,打探事件分發機制背后的秘密

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • 【從零開始擼一個App】Dagger2

    Dagger2是一個IOC框架,一般用于Android平臺,第一次接觸的朋友,一定會被搞得暈頭轉向。它延續了Java平臺Spring框架代碼碎片化,注解滿天飛的傳統。嘗試將各處代碼片段串聯起來,理清思緒,真不是件容易的事。更不用說還有各版本細微的差別。 與Spring不同的是,Spring是通過反射 ......

    uj5u.com 2020-09-10 06:57:59 more
  • Flutter Weekly Issue 66

    新聞 Flutter 季度調研結果分享 教程 Flutter+FaaS一體化任務編排的思考與設計 詳解Dart中如何通過注解生成代碼 GitHub 用對了嗎?Flutter 團隊分享如何管理大型開源專案 插件 flutter-bubble-tab-indicator A Flutter librar ......

    uj5u.com 2020-09-10 06:58:52 more
  • Proguard 常用規則

    介紹 Proguard 入口,如何查看輸出,如何使用 keep 設定入口以及使用實體,如何配置壓縮,混淆,校驗等規則。

    ......

    uj5u.com 2020-09-10 06:59:00 more
  • Android 開發技術周報 Issue#292

    新聞 Android即將獲得類AirDrop功能:可向附近設備快速分享檔案 谷歌為安卓檔案管理應用引入可安全隱藏資料的Safe Folder功能 Android TV新主界面將顯示電影、電視節目和應用推薦內容 泄露的Android檔案暗示了傳說中的谷歌Pixel 5a與折疊屏新機 谷歌發布Andro ......

    uj5u.com 2020-09-10 07:00:37 more
  • AutoFitTextureView Error inflating class

    報錯: Binary XML file line #0: Binary XML file line #0: Error inflating class xxx.AutoFitTextureView 解決: <com.example.testy2.AutoFitTextureView android: ......

    uj5u.com 2020-09-10 07:00:41 more
  • 根據Uri,Cursor沒有獲取到對應的屬性

    Android: 背景:呼叫攝像頭,拍攝視頻,指定保存的地址,但是回傳的Cursor檔案,只有名稱和大小的屬性,沒有其他諸如時長,連ID屬性都沒有 使用 cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATIO ......

    uj5u.com 2020-09-10 07:00:44 more
  • Android連載29-持久化技術

    一、持久化技術 我們平時所使用的APP產生的資料,在記憶體中都是瞬時的,會隨著斷電、關機等丟失資料,因此android系統采用了持久化技術,用于存盤這些“瞬時”資料 持久化技術包括:檔案存盤、SharedPreference存盤以及資料庫存盤,還有更復雜的SD卡記憶體儲。 二、檔案存盤 最基本存盤方式, ......

    uj5u.com 2020-09-10 07:00:47 more
  • Android Camera2Video整合到自己專案里

    背景: Android專案里呼叫攝像頭拍攝視頻,原本使用的 MediaStore.ACTION_VIDEO_CAPTURE, 后來因專案需要,改成了camera2 1.Camera2Video 官方demo有點問題,下載后,不能直接整合到專案 問題1.多次拍攝視頻崩潰 問題2.雙擊record按鈕, ......

    uj5u.com 2020-09-10 07:00:50 more
  • Android 開發技術周報 Issue#293

    新聞 谷歌為Android TV開發者提供多種新功能 Android 11將自動填表功能整合到鍵盤輸入建議中 谷歌宣布Android Auto即將支持更多的導航和數字停車應用 谷歌Pixel 5只有XL版本 搭載驍龍765G且將比Pixel 4更便宜 [圖]Wear OS將迎來重磅更新:應用啟動時間 ......

    uj5u.com 2020-09-10 07:01:38 more
  • 海豚星空掃碼投屏 Android 接收端 SDK 集成 六步驟

    掃碼投屏,開放網路,獨占設備,不需要額外下載軟體,微信掃碼,發現設備。支持標準DLNA協議,支持倍速播放。視頻,音頻,圖片投屏。好點意思。還支持自定義基于 DLNA 擴展的操作動作。好像要收費,沒體驗。 這里簡單記錄一下集成程序。 一 跟目錄的build.gradle添加私有mevan倉庫 mave ......

    uj5u.com 2020-09-10 07:01:43 more
最新发布
  • 歡迎頁輪播影片

    如圖,引導開始,球從上落下,同時淡入文字,然后文字開始輪播,最后一頁時停止,點擊進入首頁。 在來看看效果圖。 重力球先不講,主要歡迎輪播簡單實作 首先新建一個類 TextTranslationXGuideView,用于影片展示 文本是類似的,最后會有個圖片箭頭影片,布局很簡單,就是一個 TextVi ......

    uj5u.com 2023-04-20 08:40:31 more
  • 【FAQ】關于華為推送服務因營銷訊息頻次管控導致服務通訊類訊息

    一. 問題描述 使用華為推送服務下發IM訊息時,下發訊息請求成功且code碼為80000000,但是手機總是收不到訊息; 在華為推送自助分析(Beta)平臺查看發現,訊息發送觸發了頻控。 二. 問題原因及背景 2023年1月05日起,華為推送服務對咨詢營銷類訊息做了單個設備每日推送數量上限管理,具體 ......

    uj5u.com 2023-04-20 08:40:11 more
  • 歡迎頁輪播影片

    如圖,引導開始,球從上落下,同時淡入文字,然后文字開始輪播,最后一頁時停止,點擊進入首頁。 在來看看效果圖。 重力球先不講,主要歡迎輪播簡單實作 首先新建一個類 TextTranslationXGuideView,用于影片展示 文本是類似的,最后會有個圖片箭頭影片,布局很簡單,就是一個 TextVi ......

    uj5u.com 2023-04-20 08:39:36 more
  • 【FAQ】關于華為推送服務因營銷訊息頻次管控導致服務通訊類訊息

    一. 問題描述 使用華為推送服務下發IM訊息時,下發訊息請求成功且code碼為80000000,但是手機總是收不到訊息; 在華為推送自助分析(Beta)平臺查看發現,訊息發送觸發了頻控。 二. 問題原因及背景 2023年1月05日起,華為推送服務對咨詢營銷類訊息做了單個設備每日推送數量上限管理,具體 ......

    uj5u.com 2023-04-20 08:39:13 more
  • iOS從UI記憶體地址到讀取成員變數(oc/swift)

    開發除錯時,我們發現bug時常首先是從UI顯示發現例外,下一步才會去定位UI相關連的資料的。XCode有給我們提供一系列debug工具,但是很多人可能還沒有形成一套穩定的除錯流程,因此本文嘗試解決這個問題,順便提出一個暴論:UI顯示例外問題只需要兩個步驟就能完成定位作業的80%: 定位例外 UI 組 ......

    uj5u.com 2023-04-19 09:16:23 more
  • FIDE重磅更新!性能飛躍!體驗有禮!

    FIDE 開發者工具重構升級啦!實作500%性能提升,誠邀體驗! 一直以來不少開發者朋友在社區反饋,在使用 FIDE 工具的程序中,時常會遇到諸如加載不及時、代碼預覽/渲染性能不如意的情況,十分影響開發體驗。 作為技術團隊,我們深知一件趁手的開發工具對開發者的重要性,因此,在2023年開年,FinC ......

    uj5u.com 2023-04-19 09:16:15 more
  • 游戲內嵌社區服務開放,助力開發者提升玩家互動與留存

    華為 HMS Core 游戲內嵌社區服務提供快速訪問華為游戲中心論壇能力,支持玩家直接在游戲內瀏覽帖子和交流互動,助力開發者擴展內容生產和觸達的場景。 一、為什么要游戲內嵌社區? 二、游戲內嵌社區的典型使用場景 1、游戲內打開論壇 您可以在游戲內繪制論壇入口,為玩家提供沉浸式發帖、瀏覽、點贊、回帖、 ......

    uj5u.com 2023-04-19 09:15:46 more
  • iOS從UI記憶體地址到讀取成員變數(oc/swift)

    開發除錯時,我們發現bug時常首先是從UI顯示發現例外,下一步才會去定位UI相關連的資料的。XCode有給我們提供一系列debug工具,但是很多人可能還沒有形成一套穩定的除錯流程,因此本文嘗試解決這個問題,順便提出一個暴論:UI顯示例外問題只需要兩個步驟就能完成定位作業的80%: 定位例外 UI 組 ......

    uj5u.com 2023-04-19 09:14:53 more
  • FIDE重磅更新!性能飛躍!體驗有禮!

    FIDE 開發者工具重構升級啦!實作500%性能提升,誠邀體驗! 一直以來不少開發者朋友在社區反饋,在使用 FIDE 工具的程序中,時常會遇到諸如加載不及時、代碼預覽/渲染性能不如意的情況,十分影響開發體驗。 作為技術團隊,我們深知一件趁手的開發工具對開發者的重要性,因此,在2023年開年,FinC ......

    uj5u.com 2023-04-19 09:14:08 more
  • 游戲內嵌社區服務開放,助力開發者提升玩家互動與留存

    華為 HMS Core 游戲內嵌社區服務提供快速訪問華為游戲中心論壇能力,支持玩家直接在游戲內瀏覽帖子和交流互動,助力開發者擴展內容生產和觸達的場景。 一、為什么要游戲內嵌社區? 二、游戲內嵌社區的典型使用場景 1、游戲內打開論壇 您可以在游戲內繪制論壇入口,為玩家提供沉浸式發帖、瀏覽、點贊、回帖、 ......

    uj5u.com 2023-04-19 09:08:34 more