Android 事件分發機制原始碼詳解-最新 API

weixin_33976072發表於2019-01-15

大體內容如下

  • 概念
  • Activity 對事件的分發
  • ViewGroup 的事件分發過程
  • View 的事件分發過程
  • View 的點選事件處理
  • 總結
    分析了原始碼可以得到的結論
    事件的傳遞過程文字描述
    事件傳遞機制流程圖
    虛擬碼展示事件分發攔截和消費三者的關係

本篇文章是基於最新 Android 原始碼 ( API27 ),進行分析總結的,可以直接翻到文章末尾檢視「原始碼總結」和「事件傳遞流程圖」,帶著大體流程和結論去看原始碼,效率更高。

概念

事件:就是使用者手指從觸控螢幕的那一刻起,到手指離開螢幕的那一刻為止,中間產生的一系列動作,(DOWN MOVE UP等)都是事件,都被封裝到了 MotionEvent 中。

所謂事件分發:就是當一個 MotionEvent 產生以後,系統需要把它傳遞給某一個具體的 View,而這個傳遞的過程就是分發過程。

5255410-d16f687d0a7f6627.jpg
image

事件的大體流向:

5255410-5d6a7c30827e5750.jpg
image

事件一級一級的往下傳遞,如果沒有任何一個 View 消耗掉事件,那麼最終還是會傳遞給 Activity 的,於是就有了網上的說法,事件傳遞是由外向內,事件消耗是由內向外傳遞的

分發過程中幾個重要的方法:

  • dispatchTouchEvent(MotionEvent)

  • onInterceptTouchEvent(MotionEvent)

  • onTouchEvent(MotionEvent)

  • requestDisallowInterceptTouchEvent(boolean)

下面開始一步一步詳細分析原始碼:

Activity 對事件的分發

事件分發的第一個回撥方法就是 dispatchTouchEvent,每次都會呼叫

/**
 * Activity#dispatchTouchEvent
 * You can override this to intercept all touch screen events before they are dispatched to the window.           
 * @return boolean Return true if this event was consumed.
 */
public boolean dispatchTouchEvent(MotionEvent ev) {
    if (ev.getAction() == MotionEvent.ACTION_DOWN) {
        onUserInteraction();
    }
    if (getWindow().superDispatchTouchEvent(ev)) {
        return true;
    }
    return onTouchEvent(ev);
}

說明:
onUserInteraction() 是一個空實現的方法,官方示意為:實現這個方法,就會告訴你使用者與裝置已經開始互動了;與之對應的還有一個 onUserLeaveHint(),這兩個方法可以配合起來,來決定狀態列顯示通知和取消的時機。

第二個 if 是重點,如果 Window.superDispatchTouchEvent(ev) 返回 true,那麼事件被消費,到此就結束了。如果返回的 false,即事件一級一級向下傳遞,直至到最後一個 View#OnTouchEvent() 全部返回了 false,那麼最終會回撥到 Activity#onTouchEvent() 方法。

getWindow() 返回的就是一個 Window,是一個抽象類,它的唯一實現類是 PhoneWindow,Window#superDispatchTouchEvent(MotionEvent)也是一個抽象方法,所以事件是傳遞到了PhoneWindow#superDispatchTouchEvent(MotionEvent)

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

我們看到事件進一步通過 mDecor 進行分發了,DecorView 就是我們在 Activity 裡邊通過 setContentView(xxx) 設定的佈局掛載到的一個頂級父 View,換句話說,我們在 Activity 中通過 setContentView(xxx) 設定的 View,其實就是 DecorView的一個子 View。

DecorView 繼承自 FrameLayout,是 ViewGroup 型別。

簡單貼下 DecorView 的程式碼,下一篇會分析 Activity 的 setContentView(xxx) 到底是怎麼載入我們的佈局的

Activity#setContentView(int layoutResID) -> 會回撥到 PhoneWindow#setContentView(int)


// DecorView的例項化過程
public class PhoneWindow extends Window implements MenuBuilder.Callback {
    // This is the top-level view of the window, containing the window decor.
    private DecorView mDecor;
    ......
    
    // PhoneWindow#setContentView(int)
    @Override
    public void setContentView(int layoutResID) {
         // installDecor() new 出來一個物件
        if (mContentParent == null) {
            installDecor();
        } else if (!hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
            mContentParent.removeAllViews();
    }
    ......
}
說明:
installDecor() -> 回撥到 generateDecor(int),在該方法中最終通過 new DecorView(Context, int, PhoneWindow,WindowManager.LayoutParams) 出來的,留到下一篇分析
    ......
}


// 在 API 27,DecorView 單獨是一個類,是 ViewGroup 型別
public class DecorView extends FrameLayout implements RootViewSurfaceTaker, WindowCallbacks {
    ......
    // DecorView#superDispatchTouchEvent(MotionEvent)
    public boolean superDispatchTouchEvent(MotionEvent event) {
        return super.dispatchTouchEvent(event); // 實際呼叫的就是 ViewGrup 的方法
    }
    ......
}

看到這裡,我們已經知道了,其實啥也沒幹,事件就是簡單的從 Activity 傳到了 ViewGroup 的dispatchTouchEvent()。

ViewGroup 的事件分發過程

ViewGroup 對事件的分發,就是通過 ViewGroup#dispatchTouchEvent(MotionEvent) 來進行事件傳遞的過程

    // ViewGroup#dispatchTouchEvent(MotionEvent)
    // 這個方法每次都會呼叫
    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
    ......
        boolean handled = false;
        if (onFilterTouchEventForSecurity(ev)) {
            // 該 if 條件預設返回 true;除非當前的 Window 被另一個可見的 Window 部分或者全部遮擋掉了,就會丟棄掉該事件
            final int action = ev.getAction();
            final int actionMasked = action & MotionEvent.ACTION_MASK;

            // 程式碼片段一 
            if (actionMasked == MotionEvent.ACTION_DOWN) {
                // 這個方法很關鍵,只要是 DOWN 事件傳遞到這裡,會清除一些狀態
                // 注意 執行完 cancelAndClearTouchTargets(ev) 方法後 mFirstTouchTarget == null,
                // 這個具體是什麼等下細說
                // 先記住一個結論:事件如果能夠正常傳遞給子 View,並且被子 View 消費掉,
                // 那麼mFirstTouchTarget 就會被賦值(即 mFirstTouchTarget != null)
                cancelAndClearTouchTargets(ev);
                resetTouchState();
            }


            // 程式碼片段二  這個地方檢測 ViewGroup 是否攔截事件
            // DOWN 事件時,這個 mFirstTouchTarget == null,會判斷ViewGroup 是否攔截事件
            // 情況一:ViewGroup 攔截事件,即onInterceptTouchEvent(ev) 方法返回 true,
            // 會直接呼叫 ViewGroup.onTouchEvent(MotionEvent)方法自己處理事件,這個時候 mFirstTouchTarget == null;
            // 當 MOVE/UP事件到來時,該 if 條件不滿足,ViewGroup.onInterceptTouchEvent(ev) 不會被呼叫,此時的 MOVE/UP事件直接傳遞到了 ViewGroup.onTouchEvent(MotionEvent)中

            // 情況二:如果 ViewGroup 不攔截事件,事件順利傳遞給子 View ,並且事件被子 view 消費掉的話,
            // mFirstTouchTarget 會被賦值並指向子元素,mFirstTouchTarget != null 條件才成立;後續的 MOVE/UP 事件會走「程式碼片段四」進行傳遞
            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;
            }
        ......

            // A. 往下走 DOWN 事件,會有兩種情況
            // 情況A1:onInterceptTouchEvent預設返回 false,不攔截;不取消,即預設的 if 條件滿足,
            // for 迴圈遍歷所有的子 view,事件繼續往下傳遞;注意:子 View 是否消費 DOWN 事件,
            // 會影響到後續的 MOVE UP等事件的傳遞情況(即會影響到情況 B)
            // 情況A2:如果我們重寫 ViewGroup#onInterceptTouchEvent(MotionEvent)並返回 true,
            // 那麼這個 if 不滿足,就會走下面 ViewGroup 自己的 OnTouchEvent()方法 (即程式碼片段三)

            // B. 後續的MOVE/UP等事件 走到這裡時,if條件不滿足,又分兩種情況:
            // 情況B1:如果子 View 消費了 DOWN 事件,那麼會直接走到 「程式碼片段四 」,進行事件的傳遞
            // 情況B2:如果子 View 不消費 DOWN 事件,那麼事件就會交給父View 處理,會走「程式碼片段三 」
            TouchTarget newTouchTarget = null;  // TouchTarget在這裡宣告

            // 先看不攔截事件的情況
            if (!canceled && !intercepted) {
                if (actionMasked == MotionEvent.ACTION_DOWN
                        || (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
                        || actionMasked == MotionEvent.ACTION_HOVER_MOVE) {
                    // 除了 DOWN 事件,後續的 MOVE 和 UP 事件進不來
                    final int childrenCount = mChildrenCount;

                    // DOWN事件走到這裡,如果同時 ViewGroup 有子 View,就繼續往下走了
                    if (newTouchTarget == null && childrenCount != 0) {
                        final View[] children = mChildren;
                        // 遍歷所有的子 View,
                        for (int i = childrenCount - 1; i >= 0; i--) {
                            final int childIndex = getAndVerifyPreorderedIndex(
                                    childrenCount, i, customOrder);
                            final View child = getAndVerifyPreorderedView(
                                    preorderedList, children, childIndex);

                        ......
                            // 剔除中間幾行不重要的程式碼,刪掉的程式碼直白的說下:如果當前的某個 view 處於獲取到焦點狀態,
                            // 那麼優先把這個事件傳遞給它,如果該 View 不消費,不處理的話,事件就繼續正常分發下去


                            // 重點來了:這個地方分為2種情況,取決於dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)返回值
                            // 情況1:返回 true,表示子 View 消費了事件(先別管是怎麼消費的)
                            // 情況2:返回 false,表示子 View 不消費事件,if 條件不滿足,這個時候mFirstTouchTarget == null,就不會被賦值
                            // 這裡先分析 假設消費 true 的情況,那麼 if 滿足,正常進入,這時候 child!=null
                            if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {  //呼叫一  該 if 條件具體原始碼及分析 在下面
                            ......
                                // 下面這句程式碼執行完 mFirstTouchTarget != null
                                // 同時 newTouchTarget != null,跳出迴圈
                                newTouchTarget = addTouchTarget(child, idBitsToAssign);
                                alreadyDispatchedToNewTouchTarget = true;
                                break;
                            }
                        }

                    }

                ......
                }
            }


            // Dispatch to touch targets.
            if (mFirstTouchTarget == null) {
                // 程式碼片段三
                // 這裡有3種情況,都會走到這裡
                // 1. ViewGroup 主動攔截事件
                // 2. ViewGroup 沒有子 View
                // 3. ViewGroup 有子 View,但是都不消費事件 dispatchTransformedTouchEvent() 返回了 false

                // 注意這個時候,引數三 child傳的為 null -> 會呼叫 ViewGroup.onTouchEvent(MotionEvent)
                // 呼叫二
                handled = dispatchTransformedTouchEvent(ev, canceled, null,
                        TouchTarget.ALL_POINTER_IDS);
            } else {
                // intercepted = false 的情況會 即 ViewGroup#onInterceptTouchEvent(ev) 返回了 false,不攔截事件,同時事件正常的傳遞到了目標子 View
                // 程式碼片段四 
                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;
                        // 子 View 消費了 DOWN 事件,那麼後續的 MOVE/UP 等事件,就是在這裡傳遞給對應的子 View 的 
                        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;
                }
            }
        }
    ......
        return handled;
    }

預設第一次,就是DOWN事件傳遞到程式碼片段二的時候,if 滿足條件,同時mFirstTouchTarget == null;是否攔截事件,這時候取決於 FLAG_DISALLOW_INTERCEPT,預設disallowIntercept = false,直接會走 ViewGroup#onInterceptTouchEvent(MotionEvent),這個值是子 View通過呼叫 requestDisallowInterceptTouchEvent(true) 來改變的,子 View 一旦呼叫設定後,ViewGroup將無法攔截除了 DOWN 以外的其它事件。

之所以說除了 DOWN 以外的其它事件,是因為每次當 DOWN 事件傳遞到 ViewGroup 的 dispatchTouchEvent()時候,會呼叫「程式碼片段一」 resetTouchState();標記位 FLAG_DISALLOW_INTERCEPT會被重置,這將導致子 View 中設定的這個標記無效;

所以,當 DOWN 事件傳遞到 ViewGroup 時,ViewGroup 總是會呼叫自己的 onInterceptTouchEvent() 來詢問是否要攔截事件;

    /** 常量的宣告 轉為二進位制位:1000 0000 0000 0000
     * When set, this ViewGroup should not intercept touch events.
     * {@hide}
     */
    protected static final int FLAG_DISALLOW_INTERCEPT = 0x80000;

    // 子 View 呼叫,從而影響到父 View 是否能攔截事件
    @Override
    public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
        ......
        if (disallowIntercept) {
            mGroupFlags |= FLAG_DISALLOW_INTERCEPT;
        } else {
            mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
        }

        // Pass it up to our parent
        if (mParent != null) {
            mParent.requestDisallowInterceptTouchEvent(disallowIntercept);
        }
    }
5255410-ba7f7b62dd06605d.jpg
image
    # ViewGroup#onInterceptTouchEvent(MotionEvent)
    /**
     * 可以實現該方法攔截所有的觸控事件,
     * 返回 true,就會呼叫自己的onTouchEvent()
     * 返回 false,有可能會呼叫子 View 的 onTouchEvent()
     */
public boolean onInterceptTouchEvent(MotionEvent ev) {
    if (ev.isFromSource(InputDevice.SOURCE_MOUSE)
            && ev.getAction() == MotionEvent.ACTION_DOWN
            && ev.isButtonPressed(MotionEvent.BUTTON_PRIMARY)
            && isOnScrollbarThumb(ev.getX(), ev.getY())) {
        return true;
    }
    return false;
}
    /**
     * View 的事件分發:把MotionEvent 傳遞給相關的 childview,如果傳遞過來的 child == null,那麼該 ViewGroup 的onTouchEvent(MotionEvent)方法就會被呼叫
     */
    private boolean dispatchTransformedTouchEvent(MotionEvent event, boolean cancel,
            View child, int desiredPointerIdBits) {
        final boolean handled;

        // DOWN 事件傳遞過來,if 不成立
        final int oldAction = event.getAction();
        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;
        }

       ......
        
        // 重點  這裡有2種情況
        // 情況1:當前 ViewGroup 是有子 View 的情況,此時傳過來的 child != null,由此事件就傳遞到了具體的 childView 了,事件消耗與否取決於子 View
        // 情況2:ViewGroup 攔截了事件,此時傳遞過來的 child == null
        if (child == null) {
            // 呼叫 super 的方法,最終會呼叫到 ViewGroup#onTouchEvent(MotionEvent)裡
            handled = super.dispatchTouchEvent(transformedEvent);
        } else {
            ......
            // 事件繼續向下傳遞,由此事件已經從父 View 傳遞給了下一層 View,接下來的傳遞過程與頂級父 View 的傳遞過程是一致的,如此迴圈,完成整個事件的分發
            handled = child.dispatchTouchEvent(transformedEvent);
        }
        
        return handled;
    }

接下來繼續往下分析 View.dispatchTouchEvent(MotionEvent)

View 的事件分發過程


    /**
     * View#dispatchTouchEvent(MotionEvent)
     * Pass the touch screen motion event down to the target view, or this
     * view if it is the target.
     * @return True 表示當前 view 消費掉此事件
     */
    public boolean dispatchTouchEvent(MotionEvent event) {
        ......
        boolean result = false;
        final int actionMasked = event.getActionMasked();
       
        if (onFilterTouchEventForSecurity(event)) {
            // 預設情況下都是會進來的;除非當前的 Window 被另一個可見的 Window 部分或者全部遮擋掉了,就會丟棄掉該事件
            
            // 重中之重的地方來了;首先 ListenerInfo 是 View.java 裡邊一個靜態類,裡邊封裝了各種監聽事件,比如 焦點變化的監聽,滑動狀態改變的監聽 點選、長按事件、觸控事件的監聽等等
            //這裡我們如果設定了 觸控事件,那麼就會回撥到觸控事件的 onTouch()方法中,根據返回值決定了 result 的值
            ListenerInfo li = mListenerInfo;
            if (li != null && li.mOnTouchListener != null
                    && (mViewFlags & ENABLED_MASK) == ENABLED
                    && li.mOnTouchListener.onTouch(this, event)) {
                result = true;
            }

            
            // 情況一:如果設定了觸控事件,並且li.mOnTouchListener.onTouch(this, event)返回 true,表示事件被消費掉了,不再往下傳遞;那麼 View.onTouchEvent(MotionEvent)方法就不會執行
            // 情況二:如果設定了觸控事件,但是返回了 false,或者使用者就沒有設定觸控事件,那麼最終就會回撥到 onTouchEvent(event)方法
            // 由此我們可以看到設定的觸控事件的優先順序會高於OnTouchEvent(),給使用者提供一個外部處理觸控事件的回撥,可以提前做一些事情
            if (!result && onTouchEvent(event)) {
                result = true;
            }
        }
        ......
        return result;
    }

說明:

如果是從上邊 「呼叫二」super.dispatchTouchEvent(transformedEvent) 呼叫的,那麼就會呼叫 ViewGroup.onTouchEvent(MotionEvent),事件就交給 ViewGroup 自己處理;

如果是具體的 View 呼叫的「呼叫一」,那麼事件就相當於傳遞到末端了,是否消費事件取決於 onTouch(View, MotionEvent) 和 onTouchEvent(MotionEvent)這兩個方法的返回值;

如果最終的 result = true,那麼就表示事件被子 View 消費掉了,事件不再往上傳遞(子 View 都不處理,那麼事件就會一層一層的傳遞給父 View ,父 View 的 OnTouchEvent(MotionEvent) 就會被呼叫)

View 的點選事件處理

我們給 View 設定的點選事件到目前為止還沒有看到,實際上,點選事件的回撥時機是在 View#onTouchEvent(MotionEvent) 的 case MotionEvent.ACTION_UP: 手指抬起中進行回撥的,簡單的貼下程式碼

public boolean onTouchEvent(MotionEvent event) {
    final int action = event.getAction();
    // 值得一提的是,只要設定了點選事件或者長按事件,就會改變 viewFlags 的值,clickable = true
    final boolean clickable = ((viewFlags & CLICKABLE) == CLICKABLE
            || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE)
            || (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE;
    ......
    if (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
        switch (action) { 
            case MotionEvent.ACTION_UP:
                ......
                 // Only perform take click actions if we were in the pressed state
                 if (!focusTaken) {
                     // Use a Runnable and post this rather than calling
                     // performClick directly. This lets other visual state
                     // of the view update before click actions start.
                     if (mPerformClick == null) {
                         mPerformClick = new PerformClick();
                     }
                     // 點選事件
                     if (!post(mPerformClick)) {
                         performClick();
                     }
                  }
                ......

                break;
                ......
        }
        // 只要是能夠進入 if 條件,預設都是返回 true 的,即消費掉這個事件
        return true;
    }
    

總結

分析了原始碼可以得到的結論

  1. ViewGroup 預設不攔截任何事件,ViewGroup#onInterceptTouchEvent(MotionEvent) 方法預設返回 false

  2. ViewGroup#onInterceptTouchEvent(MotionEvent) 是用來攔截某個事件的,如果當前 ViewGroup 攔截了某個事件(這個事件可能是 DOWN 或者其它事件),那麼同一個事件序列中的事件再次傳遞過來時,該方法不會被再次呼叫,而是直接呼叫了 ViewGroup#onTouchEvent(MotionEvent)方法

  3. 事件傳遞到某個 View,如果它不消耗 DOWN 事件( onTouchEvent(MotionEvent) 返回了 false),那麼後續的MOVE UP 等事件都不會再傳遞給它,並且事件將重新交由它的父 View 去處理,即父 View 的 onTouchEvent(MotionEvent)會被呼叫。(mFirstTouchTarget == null,走「程式碼片段三」的情況)

  4. View 沒有onInterceptTouchEvent(MotionEvent) 方法,一旦事件傳遞給它,那麼它的 onTouchEvent(MotionEvent) 就會被呼叫

  5. onTouchEvent(MotionEvent) 返回結果表示是否消耗當前事件,如果不消耗,則在同一個事件序列中,當前 View 無法再次接收到其它事件

  6. 如果給 View 設定了setOnTouchListener() 和 onTouchEvent(),要想兩者都可以執行,觸控事件 onTouch() 返回 false 即可

事件的傳遞過程文字描述

事件的傳遞順序是 Activity -> Window -> ViewGroup -> View,當一個點選事件產生後,就會按如下順序傳遞到 父 ViewGroup 的 dispatchTouchEvent(),如果 ViewGroup 攔截事件,那麼事件會直接傳遞到 ViewGroup 的 onTouchEvent() 方法中;如果父 ViewGroup 不攔截事件,那麼就會 for 迴圈遍歷子 View,進行事件的下發,如果事件往下傳遞的過程中有一個子 View 的 onTouchEvent() 返回 true消費掉事件,那麼事件到此為止;

考慮一種情況:所有子 View 都不處理事件,那麼這個事件就會傳遞到父 View 的 onTouchEvent(),如果父 View 也不消費事件,那麼這個事件就會一級一級往上一個父 View 傳遞,如果所有的 View 都不消費事件,那麼這個事件最終會傳遞到 Activity 的 onTouchEvent(MotionEvent),最終 Activity 預設也是把該事件丟棄掉的,但是原始碼裡邊給了我們一個思路,就是說如果事件是在 Window 的邊界外產生的,那麼我們就可以重寫 Activity.onTouchEvent(MotionEvent) 來處理

事件傳遞機制流程圖

最後貼一張圖 Android 事件分發的流程圖:

5255410-9c4a84bf56d47fdf.jpg
image

虛擬碼展示事件分發攔截和消費三者的關係

一段有意思的虛擬碼[虛擬碼來源於藝術探索一書]:

public boolean dispatchTouchEvent(MotionEvent event) {
    boolean consume = false;
    if (onInterceptTouchEvent(event)) {
        consume = onTouchEvent(event);
    } else {
        consume = childView.dispatchTouchEvent(event);
    }
    return consume;
}

推薦閱讀:
手寫酷狗側滑選單效果
Jenkins 自動化構建 Android 專案圖文教程(一)
Jenkins 自動化構建 Android 專案圖文教程(二)

5255410-4b92bd8cf552029e.jpg
微信掃碼關注,接收更多更全文章

相關文章